diff --git a/install.ps1 b/install.ps1 index 63815cec0..f0549a723 100644 --- a/install.ps1 +++ b/install.ps1 @@ -16,7 +16,7 @@ # and Invoke-WebRequest throws on a cut transfer before the hash is read: # the site's build rewrites this line there (scripts/sitebootstrap.py). $mismatchAdvice = "The download was most likely cut short: try again. Within minutes of an update of this project the two files can come from caches of different ages. Waiting five minutes settles that." - $defaultInstallSha256 = "65f7d49de83de9d99734293009762adaff6f844c2c3627f507d4dc3b029f57be" + $defaultInstallSha256 = "fa8d130b046b1c1d1bc2e8da49905a7572d81ee95d023b483cdcf009a1d4a64c" $maximumInstallerBytes = 2MB $installerExit = 0 $installer = if ($PSScriptRoot) { Join-Path $PSScriptRoot "install.py" } else { $null } diff --git a/install.py b/install.py index 12cea69cf..f887c308b 100644 --- a/install.py +++ b/install.py @@ -5759,8 +5759,9 @@ 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) + reason = (NetworkReason if exc.code >= 500 or exc.code == 429 + else HttpReason)(reason) + reason.url = url 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(): @@ -6264,6 +6265,32 @@ 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.""" + url = "" + + +class HttpReason(str): + """A file the server answered for with an error of its own (HTTP 404): + the summary names the address, where "HTTP 404" alone told the user + neither what failed nor that running again changes nothing.""" + + url = "" + + +def _http_failure_lines(failures: list[tuple[str, str]]) -> list[str]: + """The address the first refused file was asked at, and what chose it: + a mirror named with RETROBIOS_BASE_URL that holds the lists and not yet + the files, or a list out of step with the files it names.""" + refused = [reason for _dest, reason in failures if isinstance(reason, HttpReason)] + if not refused: + return [] + more = f" (and {_plural(len(refused) - 1, 'other file')})" if len(refused) > 1 else "" + lines = [f" The server answered {refused[0]} for {refused[0].url}{more}."] + if os.environ.get("RETROBIOS_BASE_URL"): + return lines + _address_lines(refused[0].url)[1:] + return lines + [" The file list names files the server does not hold, and running " + "again changes nothing until the two match.", + f" More help: {TROUBLESHOOTING_PAGE}"] + def _transfer_failure(exc: BaseException) -> bool: """Whether a download error came from the connection, not the disk.""" @@ -6409,8 +6436,12 @@ 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.") + advice += _http_failure_lines(failures or []) if any(isinstance(reason, NetworkReason) for _dest, reason in failures or []): advice.append(NETWORK_FAILURE_ADVICE) + if os.environ.get("RETROBIOS_BASE_URL"): + # A mirror that is down read as this machine's connection. + advice += _address_lines(BASE_URL)[1:] if any(str(reason).startswith(NOT_KEPT) for _dest, reason in failures or []): # The same run fails the same way: "retry only those" sent a phone # user round the loop with nothing changed. @@ -6472,7 +6503,7 @@ def _report_outcome( if errors and not downloaded: say(f"\nNothing was installed. {_plural(errors, 'file')} failed.") - if failures and all(isinstance(r, (NetworkReason, OfflineReason)) + if failures and all(isinstance(r, (NetworkReason, HttpReason, OfflineReason)) 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. diff --git a/install.sh b/install.sh index 6f2d73596..17fab0cf5 100755 --- a/install.sh +++ b/install.sh @@ -23,7 +23,7 @@ DEFAULT_INSTALL_URL="https://raw.githubusercontent.com/Abdess/retrobios/main/ins # curl or wget stop on a cut transfer before the hash is read: the site's # build rewrites this line there (scripts/sitebootstrap.py). MISMATCH_ADVICE="The download was most likely cut short: try again. Within minutes of an update of this project the two files can come from caches of different ages. Waiting five minutes settles that." -DEFAULT_INSTALL_SHA256="65f7d49de83de9d99734293009762adaff6f844c2c3627f507d4dc3b029f57be" +DEFAULT_INSTALL_SHA256="fa8d130b046b1c1d1bc2e8da49905a7572d81ee95d023b483cdcf009a1d4a64c" MAX_INSTALLER_BYTES=2097152 cleanup() { diff --git a/install/launchers/index.json b/install/launchers/index.json index 578c43e15..40d7f1510 100644 --- a/install/launchers/index.json +++ b/install/launchers/index.json @@ -27,8 +27,8 @@ { "dest": "roms/ports/retrobios/install.py", "repo_path": "install.py", - "size": 1094917, - "sha256": "65f7d49de83de9d99734293009762adaff6f844c2c3627f507d4dc3b029f57be", + "size": 1096372, + "sha256": "fa8d130b046b1c1d1bc2e8da49905a7572d81ee95d023b483cdcf009a1d4a64c", "executable": false }, { @@ -71,8 +71,8 @@ "muos": { "archive": { "dest": "ARCHIVE/RetroBIOS.muxapp", - "size": 1148544, - "sha256": "ba22f19af63c4003f7195636f6c7a5ebe3900ce6a300fe089058ff78eff52eb2" + "size": 1149999, + "sha256": "143e0067766ccb85f299ca125c1dbdaa5ebec15e85ea49a9e354a677fb7f931d" }, "members": [ { @@ -92,8 +92,8 @@ { "name": "RetroBIOS/install.py", "repo_path": "install.py", - "size": 1094917, - "sha256": "65f7d49de83de9d99734293009762adaff6f844c2c3627f507d4dc3b029f57be", + "size": 1096372, + "sha256": "fa8d130b046b1c1d1bc2e8da49905a7572d81ee95d023b483cdcf009a1d4a64c", "executable": false }, { @@ -166,8 +166,8 @@ { "dest": ".config/modules/retrobios/install.py", "repo_path": "install.py", - "size": 1094917, - "sha256": "65f7d49de83de9d99734293009762adaff6f844c2c3627f507d4dc3b029f57be", + "size": 1096372, + "sha256": "fa8d130b046b1c1d1bc2e8da49905a7572d81ee95d023b483cdcf009a1d4a64c", "executable": false }, { diff --git a/tests/test_install.py b/tests/test_install.py index 9b4d80a6a..4f75d11cf 100644 --- a/tests/test_install.py +++ b/tests/test_install.py @@ -5160,6 +5160,54 @@ class ANetworkFailureNamesTheNetwork(unittest.TestCase): self.assertNotIn("being written to", text) +class AnHttpErrorNamesItsAddress(unittest.TestCase): + """A file the server answered 404 for ended on "HTTP 404" alone: no + address, no cause, and a mirror chosen with RETROBIOS_BASE_URL went + unnamed, as it did behind a NETWORK failure of that mirror.""" + + def _said(self, failures: list, base_url: str = "") -> str: + out = io.StringIO() + env = {"RETROBIOS_BASE_URL": base_url} if base_url else {} + with unittest.mock.patch.dict(os.environ, env), contextlib.redirect_stdout(out), \ + self.assertRaises(SystemExit) as raised: + install._report_outcome([Path("/bios")], 0, 0, len(failures), 0, False, + failures=failures) + self.assertEqual(raised.exception.code, 2) + return " ".join(out.getvalue().split()) + + def _reason(self, kind: type, words: str, url: str) -> str: + reason = kind(words) + reason.url = url + return reason + + def test_the_address_and_the_cause_are_said(self): + url = "https://raw.githubusercontent.com/Abdess/retrobios/main/bios/a.bin" + failures = [("a.bin", self._reason(install.HttpReason, "HTTP 404", url)), + ("b.bin", self._reason(install.HttpReason, "HTTP 404", url + "x"))] + with unittest.mock.patch.dict(os.environ): + os.environ.pop("RETROBIOS_BASE_URL", None) + text = self._said(failures) + self.assertIn(f"The server answered HTTP 404 for {url} (and 1 other file).", text) + self.assertIn("running again changes nothing", text) + self.assertIn("BIOS folder: /bios", text) + self.assertNotIn("RETROBIOS_BASE_URL", text) + + def test_a_mirror_named_in_the_environment_is_said(self): + mirror = "http://127.0.0.1:18766" + for kind, words in ((install.HttpReason, "HTTP 404"), + (install.NetworkReason, "the connection timed out")): + with self.subTest(kind=kind.__name__): + text = self._said([("a.bin", self._reason(kind, words, f"{mirror}/a.bin"))], + base_url=mirror) + self.assertIn("RETROBIOS_BASE_URL is set in this environment", text) + + def test_the_download_records_the_address(self): + error = urllib.error.HTTPError("https://x/bios/a.bin", 404, "Not Found", None, None) + reason = ANetworkFailureNamesTheNetwork._fail(self, error) + self.assertIsInstance(reason, install.HttpReason) + self.assertTrue(reason.url.endswith("/bios/a.bin"), reason.url) + + class RetroBatIsFoundWhereItSaysItIs(unittest.TestCase): """RetroBat on D:\\RetroBat was not detected, and --platform retrobat wrote 7.6 GB into C:\\RetroBat\\bios, a folder it does not read.