Skip to content

Commit e8e75a7

Browse files
authored
fix(update): honor outer archive timeout (#4790)
1 parent 6df6a8b commit e8e75a7

4 files changed

Lines changed: 244 additions & 4 deletions

File tree

‎loopx/self_update_download.py‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -67,13 +67,17 @@ def run_archive_installer(
6767
code = 28
6868
break
6969
observation["stage"] = "installer_execution"
70+
installer_env = dict(env)
71+
installer_env["LOOPX_INSTALLER_TIMEOUT_SECONDS"] = str(
72+
max(1, int(remaining))
73+
)
7074
try:
7175
return subprocess.run(
7276
["bash", str(script)],
7377
check=False,
7478
text=True, encoding="utf-8", errors="replace",
7579
capture_output=True,
76-
env=env,
80+
env=installer_env,
7781
timeout=remaining,
7882
), observation
7983
except subprocess.TimeoutExpired:

‎scripts/install-from-github.sh‎

Lines changed: 65 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ ref="${LOOPX_REF:-stable}"
66
archive_url_override="${LOOPX_ARCHIVE_URL:-}"
77
archive_url="$archive_url_override"
88
python_bin="${LOOPX_PYTHON:-python3}"
9+
installer_timeout_seconds="${LOOPX_INSTALLER_TIMEOUT_SECONDS:-}"
910
export LOOPX_REPO="$repo"
1011
export LOOPX_REF="$ref"
1112

@@ -25,6 +26,11 @@ if [[ -n "${LOOPX_RESOLVED_SOURCE_GIT_COMMIT:-}" \
2526
echo "loopx installer error: LOOPX_RESOLVED_SOURCE_GIT_COMMIT must be a full Git commit SHA" >&2
2627
exit 2
2728
fi
29+
if [[ -n "$installer_timeout_seconds" \
30+
&& ! "$installer_timeout_seconds" =~ ^[1-9][0-9]*$ ]]; then
31+
echo "loopx installer error: LOOPX_INSTALLER_TIMEOUT_SECONDS must be a positive integer" >&2
32+
exit 2
33+
fi
2834

2935
tmp_dir="$(mktemp -d "${TMPDIR:-/tmp}/loopx-install.XXXXXX")"
3036
cleanup() {
@@ -112,8 +118,65 @@ extract_dir="$tmp_dir/extract"
112118
mkdir -p "$extract_dir"
113119

114120
echo "loopx installer: downloading $archive_url" >&2
115-
curl -fsSL --connect-timeout 10 --max-time 120 --retry 2 --retry-max-time 150 \
116-
"$archive_url" -o "$archive_path"
121+
archive_deadline=$((SECONDS + ${installer_timeout_seconds:-150}))
122+
archive_attempt=1
123+
archive_max_attempts=3
124+
archive_attempts_completed=0
125+
archive_downloaded=0
126+
last_curl_code=28
127+
last_http_status=0
128+
while [[ "$archive_attempt" -le "$archive_max_attempts" ]]; do
129+
remaining=$((archive_deadline - SECONDS))
130+
if [[ "$remaining" -le 0 ]]; then
131+
break
132+
fi
133+
attempt_timeout="$remaining"
134+
if [[ "$archive_attempt" -lt "$archive_max_attempts" ]]; then
135+
reserved_attempts=$((archive_max_attempts - archive_attempt))
136+
attempt_timeout=$((remaining - reserved_attempts))
137+
if [[ "$attempt_timeout" -gt 120 ]]; then
138+
attempt_timeout=120
139+
elif [[ "$attempt_timeout" -lt 1 ]]; then
140+
attempt_timeout=1
141+
fi
142+
fi
143+
archive_attempts_completed="$archive_attempt"
144+
if http_status="$(curl --silent --show-error --fail --location \
145+
--connect-timeout 10 --max-time "$attempt_timeout" \
146+
--continue-at - --write-out '%{http_code}' \
147+
"$archive_url" -o "$archive_path")"; then
148+
archive_downloaded=1
149+
break
150+
else
151+
last_curl_code=$?
152+
fi
153+
if [[ "$http_status" =~ ^[0-9]{3}$ ]]; then
154+
last_http_status="$http_status"
155+
else
156+
last_http_status=0
157+
fi
158+
retryable=0
159+
case "$last_curl_code" in
160+
5|6|7|18|28|35|52|55|56)
161+
retryable=1
162+
;;
163+
22)
164+
case "$last_http_status" in
165+
403|408|429|500|502|503|504)
166+
retryable=1
167+
;;
168+
esac
169+
;;
170+
esac
171+
if [[ "$retryable" -ne 1 ]]; then
172+
break
173+
fi
174+
archive_attempt=$((archive_attempt + 1))
175+
done
176+
if [[ "$archive_downloaded" -ne 1 ]]; then
177+
echo "loopx installer error: archive download failed after $archive_attempts_completed attempt(s) (curl $last_curl_code, HTTP $last_http_status)" >&2
178+
exit "$last_curl_code"
179+
fi
117180
archive_sha256="$("$python_bin" - "$archive_path" <<'PY'
118181
from pathlib import Path
119182
import hashlib

‎tests/test_archive_installer_commit_response.py‎

Lines changed: 153 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,66 @@
11
"""Exercise the shipped shell installer with a large GitHub commit response."""
22

3+
import hashlib
4+
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
35
import json
46
import os
57
from pathlib import Path
68
import shlex
79
import subprocess
810
import sys
911
import tarfile
12+
import threading
13+
import time
1014

1115
import pytest
1216

1317

18+
def _start_partial_archive_server(payload, *, complete_resume):
19+
requests = []
20+
partial_size = max(1, len(payload) // 3)
21+
22+
class Handler(BaseHTTPRequestHandler):
23+
def do_GET(self):
24+
range_header = self.headers.get("Range")
25+
requests.append(range_header)
26+
if len(requests) == 1:
27+
self.send_response(200)
28+
self.send_header("Content-Length", str(len(payload)))
29+
self.end_headers()
30+
self.wfile.write(payload[:partial_size])
31+
self.wfile.flush()
32+
time.sleep(3)
33+
return
34+
if not complete_resume:
35+
self.send_response(503)
36+
self.send_header("Content-Length", "0")
37+
self.end_headers()
38+
return
39+
expected_range = f"bytes={partial_size}-"
40+
if range_header != expected_range:
41+
self.send_response(400)
42+
self.send_header("Content-Length", "0")
43+
self.end_headers()
44+
return
45+
self.send_response(206)
46+
self.send_header("Content-Length", str(len(payload) - partial_size))
47+
self.send_header(
48+
"Content-Range",
49+
f"bytes {partial_size}-{len(payload) - 1}/{len(payload)}",
50+
)
51+
self.end_headers()
52+
self.wfile.write(payload[partial_size:])
53+
54+
def log_message(self, _format, *_args):
55+
pass
56+
57+
server = ThreadingHTTPServer(("127.0.0.1", 0), Handler)
58+
server.daemon_threads = True
59+
thread = threading.Thread(target=server.serve_forever, daemon=True)
60+
thread.start()
61+
return server, thread, requests, partial_size
62+
63+
1464
@pytest.mark.skipif(os.name == "nt", reason="POSIX archive installer")
1565
def test_invalid_commit_override_precedes_temp_directory_failure(tmp_path):
1666
source = Path(__file__).resolve().parents[1]
@@ -67,10 +117,11 @@ def test_commit_response_uses_file_transport_and_cleans_up(tmp_path, valid, ref_
67117
+ shlex.quote(sys.executable)
68118
+ " -c "
69119
+ shlex.quote(
70-
"import os,sys,shutil; "
120+
"import json,os,sys,shutil; "
71121
"args=sys.argv[1:]; "
72122
"is_api=any('api.github.com' in a for a in args); "
73123
"open(os.environ['TEST_CALLS'],'a').write('api\\n' if is_api else 'archive\\n'); "
124+
"open(os.environ['TEST_ARCHIVE_ARGS'],'w').write(json.dumps(args)) if not is_api else None; "
74125
"sys.exit(22) if is_api and (os.environ['TEST_REF_KIND']=='sha' or os.environ['TEST_API_MODE']!='public') else None; "
75126
"source=os.environ['TEST_RESPONSE'] if any('api.github.com' in a for a in args) "
76127
"else os.environ['TEST_ARCHIVE']; "
@@ -98,8 +149,10 @@ def test_commit_response_uses_file_transport_and_cleans_up(tmp_path, valid, ref_
98149
TMPDIR=str(scratch),
99150
LOOPX_PYTHON=sys.executable,
100151
LOOPX_REF=sha if ref_kind == "sha" else "stable",
152+
LOOPX_INSTALLER_TIMEOUT_SECONDS="480",
101153
TEST_REF_KIND=ref_kind,
102154
TEST_API_MODE=api_mode,
155+
TEST_ARCHIVE_ARGS=str(tmp_path / "archive-args.json"),
103156
TEST_CALLS=str(tmp_path / "calls"),
104157
TEST_RESPONSE=str(fixture),
105158
TEST_ARCHIVE=str(archive),
@@ -130,3 +183,102 @@ def test_commit_response_uses_file_transport_and_cleans_up(tmp_path, valid, ref_
130183
else:
131184
assert calls[0] == "api"
132185
assert ("authenticated" in calls) == (api_mode != "public")
186+
if "archive" in calls:
187+
archive_args = json.loads((tmp_path / "archive-args.json").read_text())
188+
assert archive_args[archive_args.index("--max-time") + 1] == "120"
189+
assert "--retry" not in archive_args
190+
assert archive_args[archive_args.index("--continue-at") + 1] == "-"
191+
192+
193+
@pytest.mark.skipif(os.name == "nt", reason="POSIX archive installer")
194+
def test_archive_download_resumes_after_attempt_timeout(tmp_path):
195+
source = Path(__file__).resolve().parents[1]
196+
package = tmp_path / "package"
197+
scripts = package / "scripts"
198+
scripts.mkdir(parents=True)
199+
installer = scripts / "install-local.sh"
200+
installer.write_text(
201+
'#!/bin/sh\nprintf "%s\\n" "$LOOPX_ARCHIVE_SHA256" > "$TEST_RECEIPT"\n'
202+
)
203+
installer.chmod(0o755)
204+
(package / "payload.bin").write_bytes(bytes(range(256)) * 4096)
205+
archive = tmp_path / "package.tar.gz"
206+
with tarfile.open(archive, "w:gz") as handle:
207+
handle.add(package, arcname="package")
208+
payload = archive.read_bytes()
209+
server, thread, requests, partial_size = _start_partial_archive_server(
210+
payload, complete_resume=True
211+
)
212+
scratch = tmp_path / "scratch"
213+
scratch.mkdir()
214+
receipt = tmp_path / "receipt"
215+
env = {k: v for k, v in os.environ.items() if not k.startswith("LOOPX_")}
216+
env.update(
217+
TMPDIR=str(scratch),
218+
LOOPX_PYTHON=sys.executable,
219+
LOOPX_ARCHIVE_URL=f"http://127.0.0.1:{server.server_port}/archive.tar.gz",
220+
LOOPX_INSTALLER_TIMEOUT_SECONDS="4",
221+
TEST_RECEIPT=str(receipt),
222+
)
223+
try:
224+
result = subprocess.run(
225+
["bash", str(source / "scripts/install-from-github.sh")],
226+
env=env,
227+
capture_output=True,
228+
text=True,
229+
timeout=15,
230+
)
231+
finally:
232+
server.shutdown()
233+
server.server_close()
234+
thread.join(timeout=5)
235+
236+
assert result.returncode == 0, result.stderr
237+
assert requests[:2] == [None, f"bytes={partial_size}-"]
238+
assert receipt.read_text().strip() == hashlib.sha256(payload).hexdigest()
239+
assert list(scratch.iterdir()) == []
240+
241+
242+
@pytest.mark.skipif(os.name == "nt", reason="POSIX archive installer")
243+
def test_archive_timeout_failure_never_extracts_partial_file(tmp_path):
244+
source = Path(__file__).resolve().parents[1]
245+
payload = bytes(range(256)) * 4096
246+
server, thread, requests, partial_size = _start_partial_archive_server(
247+
payload, complete_resume=False
248+
)
249+
binary = tmp_path / "bin"
250+
binary.mkdir()
251+
tar_marker = tmp_path / "tar-called"
252+
tar = binary / "tar"
253+
tar.write_text(
254+
'#!/bin/sh\nprintf called > "$TEST_TAR_MARKER"\nexit 99\n'
255+
)
256+
tar.chmod(0o755)
257+
scratch = tmp_path / "scratch"
258+
scratch.mkdir()
259+
env = {k: v for k, v in os.environ.items() if not k.startswith("LOOPX_")}
260+
env.update(
261+
PATH=f"{binary}{os.pathsep}{env['PATH']}",
262+
TMPDIR=str(scratch),
263+
LOOPX_PYTHON=sys.executable,
264+
LOOPX_ARCHIVE_URL=f"http://127.0.0.1:{server.server_port}/archive.tar.gz",
265+
LOOPX_INSTALLER_TIMEOUT_SECONDS="4",
266+
TEST_TAR_MARKER=str(tar_marker),
267+
)
268+
try:
269+
result = subprocess.run(
270+
["bash", str(source / "scripts/install-from-github.sh")],
271+
env=env,
272+
capture_output=True,
273+
text=True,
274+
timeout=15,
275+
)
276+
finally:
277+
server.shutdown()
278+
server.server_close()
279+
thread.join(timeout=5)
280+
281+
assert result.returncode != 0
282+
assert requests[:2] == [None, f"bytes={partial_size}-"]
283+
assert not tar_marker.exists()
284+
assert list(scratch.iterdir()) == []

‎tests/test_self_update_download.py‎

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,27 @@ def run(args, **kwargs):
9191
assert diagnostic["attempts"][0]["http_status"] == 0
9292

9393

94+
def test_installer_receives_remaining_outer_timeout_budget(monkeypatch):
95+
calls = []
96+
97+
def run(args, **kwargs):
98+
calls.append((args, kwargs))
99+
if args[0] == "curl":
100+
Path(args[args.index("--output") + 1]).write_text("exit 0")
101+
return subprocess.CompletedProcess(args, 0, "200", "")
102+
return subprocess.CompletedProcess(args, 0, "", "")
103+
104+
monkeypatch.setattr("loopx.self_update_download.time.monotonic", lambda: 0.0)
105+
monkeypatch.setattr("loopx.self_update_download.subprocess.run", run)
106+
result, diagnostic = run_archive_installer(
107+
"https://example.invalid/", env={}, timeout_seconds=600
108+
)
109+
110+
assert result.returncode == 0
111+
assert diagnostic["stage"] == "installer_execution"
112+
assert calls[-1][1]["env"]["LOOPX_INSTALLER_TIMEOUT_SECONDS"] == "600"
113+
114+
94115
@pytest.mark.parametrize("timeout", [False, True])
95116
def test_installer_failure_is_not_retried(monkeypatch, timeout):
96117
calls = []

0 commit comments

Comments
 (0)