From 534968d8a6d85c14c0cf835be5ead65bf51a656e Mon Sep 17 00:00:00 2001 From: duang777 Date: Sun, 20 Sep 2026 14:02:05 +0800 Subject: [PATCH 1/2] fix(update): honor outer archive timeout Propagate the remaining update budget into the archive installer and resume partial archive transfers across curl retries. This lets explicit longer update timeouts cover slow but progressing downloads while retaining fail-closed activation. Fixes #4735 Signed-off-by: duang777 --- loopx/self_update_download.py | 6 +++++- scripts/install-from-github.sh | 11 +++++++++- .../test_archive_installer_commit_response.py | 10 ++++++++- tests/test_self_update_download.py | 21 +++++++++++++++++++ 4 files changed, 45 insertions(+), 3 deletions(-) diff --git a/loopx/self_update_download.py b/loopx/self_update_download.py index f67207349e..6f5962197b 100644 --- a/loopx/self_update_download.py +++ b/loopx/self_update_download.py @@ -67,13 +67,17 @@ def run_archive_installer( code = 28 break observation["stage"] = "installer_execution" + installer_env = dict(env) + installer_env["LOOPX_INSTALLER_TIMEOUT_SECONDS"] = str( + max(1, int(remaining)) + ) try: return subprocess.run( ["bash", str(script)], check=False, text=True, encoding="utf-8", errors="replace", capture_output=True, - env=env, + env=installer_env, timeout=remaining, ), observation except subprocess.TimeoutExpired: diff --git a/scripts/install-from-github.sh b/scripts/install-from-github.sh index 2a596fd95a..61ddbfd0a4 100755 --- a/scripts/install-from-github.sh +++ b/scripts/install-from-github.sh @@ -6,6 +6,7 @@ ref="${LOOPX_REF:-stable}" archive_url_override="${LOOPX_ARCHIVE_URL:-}" archive_url="$archive_url_override" python_bin="${LOOPX_PYTHON:-python3}" +installer_timeout_seconds="${LOOPX_INSTALLER_TIMEOUT_SECONDS:-}" export LOOPX_REPO="$repo" export LOOPX_REF="$ref" @@ -25,6 +26,11 @@ if [[ -n "${LOOPX_RESOLVED_SOURCE_GIT_COMMIT:-}" \ echo "loopx installer error: LOOPX_RESOLVED_SOURCE_GIT_COMMIT must be a full Git commit SHA" >&2 exit 2 fi +if [[ -n "$installer_timeout_seconds" \ + && ! "$installer_timeout_seconds" =~ ^[1-9][0-9]*$ ]]; then + echo "loopx installer error: LOOPX_INSTALLER_TIMEOUT_SECONDS must be a positive integer" >&2 + exit 2 +fi tmp_dir="$(mktemp -d "${TMPDIR:-/tmp}/loopx-install.XXXXXX")" cleanup() { @@ -112,7 +118,10 @@ extract_dir="$tmp_dir/extract" mkdir -p "$extract_dir" echo "loopx installer: downloading $archive_url" >&2 -curl -fsSL --connect-timeout 10 --max-time 120 --retry 2 --retry-max-time 150 \ +curl -fsSL --connect-timeout 10 \ + --max-time "${installer_timeout_seconds:-120}" \ + --retry 2 --retry-max-time "${installer_timeout_seconds:-150}" \ + --continue-at - \ "$archive_url" -o "$archive_path" archive_sha256="$("$python_bin" - "$archive_path" <<'PY' from pathlib import Path diff --git a/tests/test_archive_installer_commit_response.py b/tests/test_archive_installer_commit_response.py index 952b3ef2e5..1d6f33d6bb 100644 --- a/tests/test_archive_installer_commit_response.py +++ b/tests/test_archive_installer_commit_response.py @@ -67,10 +67,11 @@ def test_commit_response_uses_file_transport_and_cleans_up(tmp_path, valid, ref_ + shlex.quote(sys.executable) + " -c " + shlex.quote( - "import os,sys,shutil; " + "import json,os,sys,shutil; " "args=sys.argv[1:]; " "is_api=any('api.github.com' in a for a in args); " "open(os.environ['TEST_CALLS'],'a').write('api\\n' if is_api else 'archive\\n'); " + "open(os.environ['TEST_ARCHIVE_ARGS'],'w').write(json.dumps(args)) if not is_api else None; " "sys.exit(22) if is_api and (os.environ['TEST_REF_KIND']=='sha' or os.environ['TEST_API_MODE']!='public') else None; " "source=os.environ['TEST_RESPONSE'] if any('api.github.com' in a for a in args) " "else os.environ['TEST_ARCHIVE']; " @@ -98,8 +99,10 @@ def test_commit_response_uses_file_transport_and_cleans_up(tmp_path, valid, ref_ TMPDIR=str(scratch), LOOPX_PYTHON=sys.executable, LOOPX_REF=sha if ref_kind == "sha" else "stable", + LOOPX_INSTALLER_TIMEOUT_SECONDS="480", TEST_REF_KIND=ref_kind, TEST_API_MODE=api_mode, + TEST_ARCHIVE_ARGS=str(tmp_path / "archive-args.json"), TEST_CALLS=str(tmp_path / "calls"), TEST_RESPONSE=str(fixture), TEST_ARCHIVE=str(archive), @@ -130,3 +133,8 @@ def test_commit_response_uses_file_transport_and_cleans_up(tmp_path, valid, ref_ else: assert calls[0] == "api" assert ("authenticated" in calls) == (api_mode != "public") + if "archive" in calls: + archive_args = json.loads((tmp_path / "archive-args.json").read_text()) + assert archive_args[archive_args.index("--max-time") + 1] == "480" + assert archive_args[archive_args.index("--retry-max-time") + 1] == "480" + assert archive_args[archive_args.index("--continue-at") + 1] == "-" diff --git a/tests/test_self_update_download.py b/tests/test_self_update_download.py index 4b105f46bf..0da4f3f3c9 100644 --- a/tests/test_self_update_download.py +++ b/tests/test_self_update_download.py @@ -91,6 +91,27 @@ def run(args, **kwargs): assert diagnostic["attempts"][0]["http_status"] == 0 +def test_installer_receives_remaining_outer_timeout_budget(monkeypatch): + calls = [] + + def run(args, **kwargs): + calls.append((args, kwargs)) + if args[0] == "curl": + Path(args[args.index("--output") + 1]).write_text("exit 0") + return subprocess.CompletedProcess(args, 0, "200", "") + return subprocess.CompletedProcess(args, 0, "", "") + + monkeypatch.setattr("loopx.self_update_download.time.monotonic", lambda: 0.0) + monkeypatch.setattr("loopx.self_update_download.subprocess.run", run) + result, diagnostic = run_archive_installer( + "https://example.invalid/", env={}, timeout_seconds=600 + ) + + assert result.returncode == 0 + assert diagnostic["stage"] == "installer_execution" + assert calls[-1][1]["env"]["LOOPX_INSTALLER_TIMEOUT_SECONDS"] == "600" + + @pytest.mark.parametrize("timeout", [False, True]) def test_installer_failure_is_not_retried(monkeypatch, timeout): calls = [] From 4078cd90bd72ff34b09f3ced036c69fb0a0d6d24 Mon Sep 17 00:00:00 2001 From: "duanjialing.777" Date: Sun, 20 Sep 2026 21:55:20 +0800 Subject: [PATCH 2/2] fix(update): resume archive downloads across retries Signed-off-by: duanjialing.777 --- scripts/install-from-github.sh | 64 +++++++- .../test_archive_installer_commit_response.py | 148 +++++++++++++++++- 2 files changed, 205 insertions(+), 7 deletions(-) diff --git a/scripts/install-from-github.sh b/scripts/install-from-github.sh index 61ddbfd0a4..a3663e6fe2 100755 --- a/scripts/install-from-github.sh +++ b/scripts/install-from-github.sh @@ -118,11 +118,65 @@ extract_dir="$tmp_dir/extract" mkdir -p "$extract_dir" echo "loopx installer: downloading $archive_url" >&2 -curl -fsSL --connect-timeout 10 \ - --max-time "${installer_timeout_seconds:-120}" \ - --retry 2 --retry-max-time "${installer_timeout_seconds:-150}" \ - --continue-at - \ - "$archive_url" -o "$archive_path" +archive_deadline=$((SECONDS + ${installer_timeout_seconds:-150})) +archive_attempt=1 +archive_max_attempts=3 +archive_attempts_completed=0 +archive_downloaded=0 +last_curl_code=28 +last_http_status=0 +while [[ "$archive_attempt" -le "$archive_max_attempts" ]]; do + remaining=$((archive_deadline - SECONDS)) + if [[ "$remaining" -le 0 ]]; then + break + fi + attempt_timeout="$remaining" + if [[ "$archive_attempt" -lt "$archive_max_attempts" ]]; then + reserved_attempts=$((archive_max_attempts - archive_attempt)) + attempt_timeout=$((remaining - reserved_attempts)) + if [[ "$attempt_timeout" -gt 120 ]]; then + attempt_timeout=120 + elif [[ "$attempt_timeout" -lt 1 ]]; then + attempt_timeout=1 + fi + fi + archive_attempts_completed="$archive_attempt" + if http_status="$(curl --silent --show-error --fail --location \ + --connect-timeout 10 --max-time "$attempt_timeout" \ + --continue-at - --write-out '%{http_code}' \ + "$archive_url" -o "$archive_path")"; then + archive_downloaded=1 + break + else + last_curl_code=$? + fi + if [[ "$http_status" =~ ^[0-9]{3}$ ]]; then + last_http_status="$http_status" + else + last_http_status=0 + fi + retryable=0 + case "$last_curl_code" in + 5|6|7|18|28|35|52|55|56) + retryable=1 + ;; + 22) + case "$last_http_status" in + 403|408|429|500|502|503|504) + retryable=1 + ;; + esac + ;; + esac + if [[ "$retryable" -ne 1 ]]; then + break + fi + archive_attempt=$((archive_attempt + 1)) +done +if [[ "$archive_downloaded" -ne 1 ]]; then + echo "loopx installer error: archive download failed after $archive_attempts_completed attempt(s) (curl $last_curl_code, HTTP $last_http_status)" >&2 + exit "$last_curl_code" +fi archive_sha256="$("$python_bin" - "$archive_path" <<'PY' from pathlib import Path import hashlib diff --git a/tests/test_archive_installer_commit_response.py b/tests/test_archive_installer_commit_response.py index 1d6f33d6bb..9a3cbb2dcd 100644 --- a/tests/test_archive_installer_commit_response.py +++ b/tests/test_archive_installer_commit_response.py @@ -1,5 +1,7 @@ """Exercise the shipped shell installer with a large GitHub commit response.""" +import hashlib +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer import json import os from pathlib import Path @@ -7,10 +9,58 @@ import subprocess import sys import tarfile +import threading +import time import pytest +def _start_partial_archive_server(payload, *, complete_resume): + requests = [] + partial_size = max(1, len(payload) // 3) + + class Handler(BaseHTTPRequestHandler): + def do_GET(self): + range_header = self.headers.get("Range") + requests.append(range_header) + if len(requests) == 1: + self.send_response(200) + self.send_header("Content-Length", str(len(payload))) + self.end_headers() + self.wfile.write(payload[:partial_size]) + self.wfile.flush() + time.sleep(3) + return + if not complete_resume: + self.send_response(503) + self.send_header("Content-Length", "0") + self.end_headers() + return + expected_range = f"bytes={partial_size}-" + if range_header != expected_range: + self.send_response(400) + self.send_header("Content-Length", "0") + self.end_headers() + return + self.send_response(206) + self.send_header("Content-Length", str(len(payload) - partial_size)) + self.send_header( + "Content-Range", + f"bytes {partial_size}-{len(payload) - 1}/{len(payload)}", + ) + self.end_headers() + self.wfile.write(payload[partial_size:]) + + def log_message(self, _format, *_args): + pass + + server = ThreadingHTTPServer(("127.0.0.1", 0), Handler) + server.daemon_threads = True + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + return server, thread, requests, partial_size + + @pytest.mark.skipif(os.name == "nt", reason="POSIX archive installer") def test_invalid_commit_override_precedes_temp_directory_failure(tmp_path): source = Path(__file__).resolve().parents[1] @@ -135,6 +185,100 @@ def test_commit_response_uses_file_transport_and_cleans_up(tmp_path, valid, ref_ assert ("authenticated" in calls) == (api_mode != "public") if "archive" in calls: archive_args = json.loads((tmp_path / "archive-args.json").read_text()) - assert archive_args[archive_args.index("--max-time") + 1] == "480" - assert archive_args[archive_args.index("--retry-max-time") + 1] == "480" + assert archive_args[archive_args.index("--max-time") + 1] == "120" + assert "--retry" not in archive_args assert archive_args[archive_args.index("--continue-at") + 1] == "-" + + +@pytest.mark.skipif(os.name == "nt", reason="POSIX archive installer") +def test_archive_download_resumes_after_attempt_timeout(tmp_path): + source = Path(__file__).resolve().parents[1] + package = tmp_path / "package" + scripts = package / "scripts" + scripts.mkdir(parents=True) + installer = scripts / "install-local.sh" + installer.write_text( + '#!/bin/sh\nprintf "%s\\n" "$LOOPX_ARCHIVE_SHA256" > "$TEST_RECEIPT"\n' + ) + installer.chmod(0o755) + (package / "payload.bin").write_bytes(bytes(range(256)) * 4096) + archive = tmp_path / "package.tar.gz" + with tarfile.open(archive, "w:gz") as handle: + handle.add(package, arcname="package") + payload = archive.read_bytes() + server, thread, requests, partial_size = _start_partial_archive_server( + payload, complete_resume=True + ) + scratch = tmp_path / "scratch" + scratch.mkdir() + receipt = tmp_path / "receipt" + env = {k: v for k, v in os.environ.items() if not k.startswith("LOOPX_")} + env.update( + TMPDIR=str(scratch), + LOOPX_PYTHON=sys.executable, + LOOPX_ARCHIVE_URL=f"http://127.0.0.1:{server.server_port}/archive.tar.gz", + LOOPX_INSTALLER_TIMEOUT_SECONDS="4", + TEST_RECEIPT=str(receipt), + ) + try: + result = subprocess.run( + ["bash", str(source / "scripts/install-from-github.sh")], + env=env, + capture_output=True, + text=True, + timeout=15, + ) + finally: + server.shutdown() + server.server_close() + thread.join(timeout=5) + + assert result.returncode == 0, result.stderr + assert requests[:2] == [None, f"bytes={partial_size}-"] + assert receipt.read_text().strip() == hashlib.sha256(payload).hexdigest() + assert list(scratch.iterdir()) == [] + + +@pytest.mark.skipif(os.name == "nt", reason="POSIX archive installer") +def test_archive_timeout_failure_never_extracts_partial_file(tmp_path): + source = Path(__file__).resolve().parents[1] + payload = bytes(range(256)) * 4096 + server, thread, requests, partial_size = _start_partial_archive_server( + payload, complete_resume=False + ) + binary = tmp_path / "bin" + binary.mkdir() + tar_marker = tmp_path / "tar-called" + tar = binary / "tar" + tar.write_text( + '#!/bin/sh\nprintf called > "$TEST_TAR_MARKER"\nexit 99\n' + ) + tar.chmod(0o755) + scratch = tmp_path / "scratch" + scratch.mkdir() + env = {k: v for k, v in os.environ.items() if not k.startswith("LOOPX_")} + env.update( + PATH=f"{binary}{os.pathsep}{env['PATH']}", + TMPDIR=str(scratch), + LOOPX_PYTHON=sys.executable, + LOOPX_ARCHIVE_URL=f"http://127.0.0.1:{server.server_port}/archive.tar.gz", + LOOPX_INSTALLER_TIMEOUT_SECONDS="4", + TEST_TAR_MARKER=str(tar_marker), + ) + try: + result = subprocess.run( + ["bash", str(source / "scripts/install-from-github.sh")], + env=env, + capture_output=True, + text=True, + timeout=15, + ) + finally: + server.shutdown() + server.server_close() + thread.join(timeout=5) + + assert result.returncode != 0 + assert requests[:2] == [None, f"bytes={partial_size}-"] + assert not tar_marker.exists() + assert list(scratch.iterdir()) == []