diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 3f5295369..42ce804dd 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -313,6 +313,11 @@ jobs: - name: Build the DiRT 3 / DiRT Showdown ws2_32 stand-in run: python3 tools/components/prepare.py dirt-ws2 + - name: Cache graphics driver inputs + uses: actions/cache@v4 + with: + path: app/build/quest-inputs + key: quest-inputs-${{ hashFiles('tools/quest/build-driver.py') }} - name: Prepare graphics driver variants run: python3 tools/quest/build-driver.py "$ANDROID_HOME/ndk/$NDK_VERSION" - name: Record and pack prepared components @@ -558,9 +563,11 @@ jobs: - name: Cache checksum-pinned pack inputs uses: actions/cache@v4 with: - path: ~/.cache/droiddeck-pack-inputs - key: pack-inputs-v1-${{ hashFiles('tools/packs/build.sh', 'tools/packs/fetch-verified.sh', 'tools/packs/tracking/release.env') }} - restore-keys: pack-inputs-v1- + path: | + ~/.cache/droiddeck-pack-inputs + ~/.cache/droiddeck-build + key: pack-inputs-v2-${{ hashFiles('tools/packs/build.sh', 'tools/packs/fetch-verified.sh', 'tools/packs/tracking/release.env', 'tools/powervr/release.json') }} + restore-keys: pack-inputs-v2- - name: Build the add-on packs run: | python3 tools/powervr/fetch.py diff --git a/tools/quest/build-driver.py b/tools/quest/build-driver.py index 43c7c5a3b..b11ec27d6 100644 --- a/tools/quest/build-driver.py +++ b/tools/quest/build-driver.py @@ -7,6 +7,9 @@ import sys import tarfile import tempfile import hashlib +import http.client +import time +import urllib.error import urllib.request import zipfile @@ -32,11 +35,23 @@ def pack(entries, output): print(output) +def download(url, attempts=4): + for attempt in range(1, attempts + 1): + try: + with urllib.request.urlopen(url, timeout=120) as response: + return response.read() + except (OSError, http.client.HTTPException) as error: + if attempt == attempts or isinstance(error, urllib.error.HTTPError) and error.code < 500 and error.code != 429: + raise + print(f'Download failed ({error}); retrying in {attempt * 15} s') + time.sleep(attempt * 15) + + def build_a6xx(ndk, output_dir=OUTPUT.parent, archive_path=None): cached = archive_path or ROOT / 'app/build/quest-inputs' / f'Turnip-{A6XX_TAG}.zip' if not cached.exists(): cached.parent.mkdir(parents=True, exist_ok=True) - data = urllib.request.urlopen(A6XX_URL, timeout=120).read() + data = download(A6XX_URL) if hashlib.sha256(data).hexdigest() != A6XX_SHA256: raise ValueError('A6xx archive checksum mismatch') cached.write_bytes(data) diff --git a/tools/tests/test_quest_driver_download.py b/tools/tests/test_quest_driver_download.py new file mode 100644 index 000000000..aed4f47c1 --- /dev/null +++ b/tools/tests/test_quest_driver_download.py @@ -0,0 +1,56 @@ +import importlib.util +import io +import unittest +import urllib.error +from pathlib import Path +from unittest import mock + +SCRIPT = Path(__file__).resolve().parents[1] / "quest/build-driver.py" +spec = importlib.util.spec_from_file_location("build_driver", SCRIPT) +driver = importlib.util.module_from_spec(spec) +spec.loader.exec_module(driver) + + +def http_error(code): + return urllib.error.HTTPError("https://example.invalid/a.zip", code, "error", {}, io.BytesIO()) + + +class Response(io.BytesIO): + def __enter__(self): + return self + + def __exit__(self, *args): + self.close() + + +class DriverDownloadTest(unittest.TestCase): + def fetch(self, *outcomes): + calls = [] + + def urlopen(url, timeout): + calls.append(url) + outcome = outcomes[len(calls) - 1] + if isinstance(outcome, Exception): + raise outcome + return Response(outcome) + + with mock.patch.object(driver.urllib.request, "urlopen", urlopen), mock.patch.object(driver.time, "sleep") as sleep: + try: + return driver.download("https://example.invalid/a.zip"), len(calls), sleep.call_count + except Exception as error: + return error, len(calls), sleep.call_count + + def test_a_timeout_or_server_error_is_retried(self): + self.assertEqual((b"zip", 3, 2), self.fetch(TimeoutError("read timed out"), http_error(502), b"zip")) + + def test_a_missing_file_fails_at_once(self): + error, calls, sleeps = self.fetch(http_error(404)) + self.assertEqual((404, 1, 0), (error.code, calls, sleeps)) + + def test_it_gives_up_after_the_last_attempt(self): + error, calls, sleeps = self.fetch(*[http_error(504)] * 4) + self.assertEqual((504, 4, 3), (error.code, calls, sleeps)) + + +if __name__ == "__main__": + unittest.main()