fix: name the network when every transfer fails

A run whose downloads were all cut ended on "Location that was being
written to" and "once that is resolved", pointing at the disk. A
failure caused by the connection (lookup, reset, timeout, TLS, a cut
transfer, HTTP 5xx or 429) is now marked as such, and the summary says to
check the connection and HTTPS_PROXY.
This commit is contained in:
Abdessamad Derraz committed 2026-10-21 05:25:19 +02:00
1 parent 253a41ebc8
commit 4f20b5daaf
4 files changed
+96 -3

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 = "2409cff28ad4eed1546161aa5abd6808fcd6a010bda7943eb1076f4ab0559c9f"
$defaultInstallSha256 = "caec72e5ebd146509e3b1fabc7d8d4c5c921567592d6d1581df6e5cc50f3e96f"
$maximumInstallerBytes = 2MB
$installerExit = 0
$installer = if ($PSScriptRoot) { Join-Path $PSScriptRoot "install.py" } else { $null }
+41 -1
View File
@@ -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. "
+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="2409cff28ad4eed1546161aa5abd6808fcd6a010bda7943eb1076f4ab0559c9f"
DEFAULT_INSTALL_SHA256="caec72e5ebd146509e3b1fabc7d8d4c5c921567592d6d1581df6e5cc50f3e96f"
MAX_INSTALLER_BYTES=2097152
cleanup() {
+53
View File
@@ -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()