From 02fcb54d9d57f07c974a376667dd545c56fcc9b4 Mon Sep 17 00:00:00 2001 From: Abdessamad Derraz <3028866+Abdess@users.noreply.github.com> Date: Sun, 18 Oct 2026 15:40:59 +0200 Subject: [PATCH] fix: retry a file list the network cut short --- install.ps1 | 2 +- install.py | 27 ++++++++++++++++++++------- install.sh | 2 +- tests/test_install.py | 29 +++++++++++++++++++++++++++++ 4 files changed, 51 insertions(+), 9 deletions(-) diff --git a/install.ps1 b/install.ps1 index a796a821..0a837d7f 100644 --- a/install.ps1 +++ b/install.ps1 @@ -8,7 +8,7 @@ param( $ErrorActionPreference = "Stop" $defaultInstallUrl = "https://raw.githubusercontent.com/Abdess/retrobios/main/install.py" -$defaultInstallSha256 = "546d190d17449d7b25bcf981bd396645c194364f363a2d315380a043ca101e38" +$defaultInstallSha256 = "e57aa03a8f581b6bf02cb1d3161af6da19719425383790da0ed5f761afaf436c" $maximumInstallerBytes = 2MB $installerExit = 0 $installer = if ($PSScriptRoot) { Join-Path $PSScriptRoot "install.py" } else { $null } diff --git a/install.py b/install.py index 9ce4a113..bc2969a8 100644 --- a/install.py +++ b/install.py @@ -1257,18 +1257,31 @@ def normalize_platform(name: str) -> str: def _read_limited_json( response: http.client.HTTPResponse, limit: int, label: str ) -> object: - """Read a bounded UTF-8 JSON response.""" + """Read a bounded UTF-8 JSON response. + + A body shorter than its Content-Length is a dropped connection, raised + as one so the caller retries: parsed, the cut read as an invalid file + list and the run ended without the network advice. + """ raw_length = response.headers.get("Content-Length") if response.headers else None + expected: int | None = None if raw_length: try: - if int(raw_length) > limit: - raise ValueError(f"{label} exceeds {limit} bytes") - except ValueError as exc: - if "exceeds" in str(exc): - raise - payload = response.read(limit + 1) + expected = int(raw_length) + except ValueError: + expected = None + if expected is not None and expected > limit: + raise ValueError(f"{label} exceeds {limit} bytes") + try: + payload = response.read(limit + 1) + except http.client.IncompleteRead as exc: + raise ConnectionError( + f"{label} cut after {len(exc.partial)} bytes" + ) from exc if len(payload) > limit: raise ValueError(f"{label} exceeds {limit} bytes") + if expected is not None and len(payload) < expected: + raise ConnectionError(f"{label} cut after {len(payload)} of {expected} bytes") try: return json.loads(payload.decode("utf-8")) except (UnicodeDecodeError, json.JSONDecodeError) as exc: diff --git a/install.sh b/install.sh index a51c9988..68cfd842 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="546d190d17449d7b25bcf981bd396645c194364f363a2d315380a043ca101e38" +DEFAULT_INSTALL_SHA256="e57aa03a8f581b6bf02cb1d3161af6da19719425383790da0ed5f761afaf436c" MAX_INSTALLER_BYTES=2097152 cleanup() { diff --git a/tests/test_install.py b/tests/test_install.py index 574696e7..dffcba2b 100644 --- a/tests/test_install.py +++ b/tests/test_install.py @@ -2446,5 +2446,34 @@ class QuitMeansNoAtTheLastQuestion(unittest.TestCase): self.assertIs(install._confirm("? "), expected) + +class ACutFileListIsRetried(unittest.TestCase): + """Wi-Fi dropped mid install/batocera.json: the short body parsed as an + invalid file list, no retry, no network advice.""" + + class _Response(io.BytesIO): + def __init__(self, body: bytes, length: int): + super().__init__(body) + self.headers = {"Content-Length": str(length)} + + def __enter__(self): + return self + + def __exit__(self, *exc): + return False + + def test_the_second_attempt_succeeds(self): + full = json.dumps({"a": 1}).encode() + answers = iter([self._Response(full[:4], len(full)), self._Response(full, len(full))]) + with unittest.mock.patch.object(install.urllib.request, "urlopen", + lambda *a, **k: next(answers)), \ + unittest.mock.patch.object(install.time, "sleep"): + self.assertEqual(install._fetch_json("https://x/y.json", 1000, "manifest"), {"a": 1}) + + def test_a_short_body_is_a_connection_error(self): + with self.assertRaises(ConnectionError): + install._read_limited_json(self._Response(b'{"a"', 10), 1000, "manifest") + + if __name__ == "__main__": unittest.main()