Skip to content

Commit 8fe34c5

Browse files
authored
fix(update): retry bootstrap downloads with safe diagnostics (#3991)
Signed-off-by: huangruiteng <huangrt01@163.com>
1 parent 8c08efd commit 8fe34c5

6 files changed

Lines changed: 274 additions & 67 deletions

File tree

‎docs/guides/installing-loopx.md‎

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -140,6 +140,23 @@ loopx update plan # read-only command, validation, and rollback plan
140140
loopx update apply # explicit local-environment mutation
141141
```
142142

143+
For archive installs, `update apply` downloads the bootstrap installer to a
144+
private temporary file before executing it. Downloading is limited to three
145+
attempts, a 60-second total download budget (or the smaller command timeout),
146+
and 20 seconds per transfer, with 1- and 2-second retry delays. Transient
147+
403/408/429/500/502/503/504 responses and selected connection/transfer failures
148+
are retried; 401/404 and certificate-verification failures stop immediately.
149+
Installer execution is never retried, and partial downloads are never executed.
150+
The command timeout covers downloading and installer execution together.
151+
152+
JSON `execution.installer_download` and the text execution report show the
153+
stage and each attempt's HTTP status and curl exit code. HTTP `0` means no
154+
usable HTTP status was received. Download diagnostics exclude URLs, response
155+
bodies, headers, and raw curl errors so proxy credentials and signed parameters
156+
cannot leak. These retries cover the bootstrap download, not subsequent
157+
archive downloads or a failed installation. Pip/pipx and read-only plans keep
158+
their existing behavior.
159+
143160
Bare `loopx update` remains a read-only plan. The older `--check`, `--dry-run`,
144161
and `--execute` spellings remain compatibility aliases, but new instructions
145162
should use the named actions.

‎examples/loopx-update-smoke.py‎

Lines changed: 18 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -183,7 +183,7 @@ def test_module_plan() -> None:
183183
assert payload["plan"]["backup"]["rollback_release_id"] == "20260621T170342Z", payload
184184
assert "loopx update --rollback 20260621T170342Z" in payload["plan"]["backup"]["rollback_command"], payload
185185
assert "ln -sfn" not in payload["plan"]["backup"]["rollback_command"], payload
186-
assert "LOOPX_ARCHIVE_URL=https://example.invalid/loopx.tar.gz" in payload["plan"]["install_command"], payload
186+
assert "--archive-url https://example.invalid/loopx.tar.gz" in payload["plan"]["install_command"], payload
187187
rendered = render_update_plan_markdown(payload)
188188
assert "**No update was applied.**" in rendered, rendered
189189
assert "## Next Action" in rendered, rendered
@@ -199,8 +199,8 @@ def test_default_source_uses_stable_ref() -> None:
199199
assert payload["source"]["channel"] == "github_archive_stable", payload
200200
assert payload["source"]["ref_source"] == "default_stable", payload
201201
assert "/tar.gz/stable" in payload["source"]["archive_url"], payload
202-
assert "LOOPX_REF=stable" in payload["plan"]["install_command"], payload
203-
assert "LOOPX_ARCHIVE_URL=" not in payload["plan"]["install_command"], payload
202+
assert payload["plan"]["install_command"] == "loopx update apply", payload
203+
assert "--archive-url" not in payload["plan"]["install_command"], payload
204204
default_env = _installer_env_for_source(
205205
payload["source"],
206206
base_env={"LOOPX_ARCHIVE_URL": "https://stale.invalid/archive.tar.gz"},
@@ -216,47 +216,19 @@ def test_explicit_archive_url_reaches_installer() -> None:
216216
doctor_payload=fake_doctor_payload(),
217217
)
218218
assert payload["source"]["channel"] == "github_archive_url_override", payload
219-
assert "LOOPX_ARCHIVE_URL=https://example.invalid/loopx.tar.gz" in payload["plan"]["install_command"], payload
220-
assert "set -o errexit -o pipefail" in payload["plan"]["install_command"], payload
221-
assert "export LOOPX_REPO=example/loopx" in payload["plan"]["install_command"], payload
222-
assert "export LOOPX_REF=fixture" in payload["plan"]["install_command"], payload
219+
assert "--archive-url https://example.invalid/loopx.tar.gz" in payload["plan"]["install_command"], payload
220+
assert "loopx update apply" in payload["plan"]["install_command"], payload
221+
assert "--repo example/loopx" in payload["plan"]["install_command"], payload
222+
assert "--ref fixture" in payload["plan"]["install_command"], payload
223223
installer_env = _installer_env_for_source(payload["source"], base_env={})
224224
assert installer_env["LOOPX_ARCHIVE_URL"] == "https://example.invalid/loopx.tar.gz", installer_env
225225

226226

227-
def test_update_preview_stops_before_doctor_when_download_fails() -> None:
228-
payload = build_update_plan(
229-
repo="example/loopx",
230-
ref="fixture",
231-
archive_url="https://example.invalid/loopx.tar.gz",
232-
execute=False,
233-
doctor_payload=fake_doctor_payload(),
234-
)
235-
with TemporaryDirectory() as tmpdir:
236-
bin_dir = Path(tmpdir)
237-
marker_path = bin_dir / "doctor-ran"
238-
curl_bin = bin_dir / "curl"
239-
curl_bin.write_text("#!/usr/bin/env bash\nexit 22\n", encoding="utf-8")
240-
curl_bin.chmod(0o755)
241-
loopx_bin = bin_dir / "loopx"
242-
loopx_bin.write_text(
243-
"#!/usr/bin/env bash\n"
244-
f"touch {json.dumps(str(marker_path))}\n"
245-
"exit 0\n",
246-
encoding="utf-8",
247-
)
248-
loopx_bin.chmod(0o755)
249-
env = dict(os.environ)
250-
env["PATH"] = f"{bin_dir}{os.pathsep}{env.get('PATH', '')}"
251-
result = subprocess.run(
252-
["bash", "-c", payload["plan"]["install_command"]],
253-
text=True,
254-
capture_output=True,
255-
env=env,
256-
)
257-
258-
assert result.returncode == 22, result
259-
assert not marker_path.exists(), result
227+
def test_update_preview_uses_managed_download_path() -> None:
228+
payload = build_update_plan(doctor_payload=fake_doctor_payload())
229+
command = payload["plan"]["install_command"]
230+
assert command.startswith("loopx update apply"), command
231+
assert "curl" not in command, command
260232

261233

262234
def test_execute_update_propagates_installer_download_failure() -> None:
@@ -290,10 +262,13 @@ def test_execute_update_propagates_installer_download_failure() -> None:
290262

291263
assert result["ok"] is False, result
292264
install_command = run.call_args_list[0].args[0]
293-
assert install_command[:2] == ["bash", "-lc"], install_command
294-
assert "set -o pipefail" in install_command[2], install_command
265+
assert install_command[0] == "curl", install_command
266+
assert "--output" in install_command, install_command
295267
assert result["execution"]["install_returncode"] == 22, result
296268
assert result["execution"]["doctor_returncode"] == 0, result
269+
assert result["execution"]["installer_download"]["stage"] == "installer_download", result
270+
assert result["changes_applied"] is False, result
271+
assert "Download attempt 1: HTTP `0`, curl `22`" in render_update_plan_markdown(result)
297272

298273

299274
def test_active_release_python_reaches_installer() -> None:
@@ -562,7 +537,7 @@ def main() -> int:
562537
test_module_plan()
563538
test_default_source_uses_stable_ref()
564539
test_explicit_archive_url_reaches_installer()
565-
test_update_preview_stops_before_doctor_when_download_fails()
540+
test_update_preview_uses_managed_download_path()
566541
test_execute_update_propagates_installer_download_failure()
567542
test_active_release_python_reaches_installer()
568543
test_active_release_python_marker_fails_closed()

‎loopx/self_update.py‎

Lines changed: 14 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313

1414
from .doctor import collect_doctor
1515
from .install_contract import NO_CLONE_INSTALL_URL
16+
from .self_update_download import run_archive_installer
1617

1718

1819
UPDATE_PLAN_SCHEMA_VERSION = "loopx_update_plan_v0"
@@ -105,20 +106,7 @@ def _command_for_source(source: dict[str, Any]) -> str:
105106
"Update a trusted LoopX checkout, then run its "
106107
"`scripts/install-windows.ps1` with PowerShell 7."
107108
)
108-
exports = [
109-
f"LOOPX_REPO={shlex.quote(str(source['repo']))}",
110-
f"LOOPX_REF={shlex.quote(str(source['ref']))}",
111-
]
112-
archive_url = source.get("archive_url")
113-
if source.get("channel") == "github_archive_url_override" and archive_url:
114-
exports.append(f"LOOPX_ARCHIVE_URL={shlex.quote(str(archive_url))}")
115-
return (
116-
"set -o errexit -o pipefail\n"
117-
+ "\n".join(f"export {value}" for value in exports)
118-
+ f"\ncurl -fsSL {shlex.quote(str(source['installer_url']))} | bash\n"
119-
'export PATH="$HOME/.local/bin:$PATH"\n'
120-
"loopx doctor"
121-
)
109+
return _update_action_command(UpdateAction.APPLY, source)
122110

123111

124112
def _installer_env_for_source(
@@ -1104,16 +1092,10 @@ def execute_update_plan(
11041092
current_release_root if isinstance(current_release_root, str) else None
11051093
),
11061094
)
1107-
install_result = subprocess.run(
1108-
[
1109-
"bash",
1110-
"-lc",
1111-
f"set -o pipefail; curl -fsSL {shlex.quote(installer_url)} | bash",
1112-
],
1113-
text=True,
1114-
capture_output=True,
1095+
install_result, download_observation = run_archive_installer(
1096+
installer_url,
11151097
env=env,
1116-
timeout=timeout_seconds,
1098+
timeout_seconds=timeout_seconds,
11171099
)
11181100
loopx_bin = Path.home() / ".local" / "bin" / "loopx"
11191101
doctor_result = subprocess.run(
@@ -1124,6 +1106,7 @@ def execute_update_plan(
11241106
timeout=timeout_seconds,
11251107
)
11261108
execution = {
1109+
"installer_download": download_observation,
11271110
"install_returncode": install_result.returncode,
11281111
"doctor_returncode": doctor_result.returncode,
11291112
"install_stdout_tail": install_result.stdout[-2000:],
@@ -1472,4 +1455,12 @@ def render_update_plan_markdown(payload: dict[str, Any]) -> str:
14721455
f"- Doctor return code: `{execution.get('doctor_returncode')}`",
14731456
]
14741457
)
1458+
download = execution.get("installer_download")
1459+
if isinstance(download, dict):
1460+
lines.append(f"- Installer stage: `{download.get('stage')}`")
1461+
for attempt in download.get("attempts", []):
1462+
lines.append(
1463+
f"- Download attempt {attempt['attempt']}: "
1464+
f"HTTP `{attempt['http_status']}`, curl `{attempt['curl_returncode']}`"
1465+
)
14751466
return "\n".join(lines) + "\n"

‎loopx/self_update_download.py‎

Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
"""Bounded archive-installer download; never execute a partial response."""
2+
3+
import subprocess
4+
import time
5+
from pathlib import Path
6+
from tempfile import TemporaryDirectory
7+
from typing import Any
8+
9+
10+
def run_archive_installer(
11+
url: str, *, env: dict[str, str], timeout_seconds: int
12+
) -> tuple[subprocess.CompletedProcess[str], dict[str, Any]]:
13+
deadline = time.monotonic() + timeout_seconds
14+
download_deadline = min(deadline, time.monotonic() + 60)
15+
observation: dict[str, Any] = {"stage": "installer_download", "attempts": []}
16+
# Deliberately omit URLs, headers, bodies and raw curl errors: any of these
17+
# can carry proxy credentials or signed URL parameters.
18+
with TemporaryDirectory(prefix="loopx-update-") as raw:
19+
script = Path(raw) / "install.sh"
20+
script.touch(mode=0o600)
21+
code = 28
22+
for attempt in range(1, 4):
23+
remaining = download_deadline - time.monotonic()
24+
if remaining <= 0:
25+
break
26+
# Remove bytes from a failed transfer before the next attempt.
27+
script.write_bytes(b"")
28+
args = [
29+
"curl",
30+
"--silent",
31+
"--show-error",
32+
"--fail",
33+
"--location",
34+
"--connect-timeout",
35+
"10",
36+
"--max-time",
37+
str(min(20, remaining)),
38+
"--output",
39+
str(script),
40+
"--write-out",
41+
"%{http_code}",
42+
url,
43+
]
44+
try:
45+
result = subprocess.run(
46+
args,
47+
text=True,
48+
capture_output=True,
49+
env=env,
50+
timeout=remaining,
51+
check=False,
52+
)
53+
code = result.returncode
54+
status = (
55+
int(result.stdout.strip()) if result.stdout.strip().isdigit() else 0
56+
)
57+
except subprocess.TimeoutExpired:
58+
code, status = 28, 0
59+
except OSError:
60+
code, status = 127, 0
61+
observation["attempts"].append(
62+
{"attempt": attempt, "curl_returncode": code, "http_status": status}
63+
)
64+
if code == 0 and 200 <= status < 300 and script.stat().st_size:
65+
remaining = deadline - time.monotonic()
66+
if remaining <= 0:
67+
code = 28
68+
break
69+
observation["stage"] = "installer_execution"
70+
try:
71+
return subprocess.run(
72+
["bash", str(script)],
73+
check=False,
74+
text=True,
75+
capture_output=True,
76+
env=env,
77+
timeout=remaining,
78+
), observation
79+
except subprocess.TimeoutExpired:
80+
return subprocess.CompletedProcess(
81+
[],
82+
124,
83+
"",
84+
"Installer execution timed out; it was not retried.",
85+
), observation
86+
# 403 can be transient at an edge/proxy; retry only within this
87+
# budget. Never change credentials, endpoint, or TLS verification.
88+
retryable = status in {403, 408, 429, 500, 502, 503, 504} or code in {
89+
5,
90+
6,
91+
7,
92+
18,
93+
28,
94+
35,
95+
52,
96+
55,
97+
56,
98+
}
99+
code = code or 22
100+
if not retryable or attempt == 3:
101+
break
102+
remaining = download_deadline - time.monotonic()
103+
if remaining <= 0:
104+
break
105+
time.sleep(min(attempt, remaining))
106+
return subprocess.CompletedProcess(
107+
[], code, "", "Installer download failed; see installer_download diagnostics."
108+
), observation

0 commit comments

Comments
 (0)