fix(updater): download via curl with resume instead of Chromium fetch
Chromium's network stack restarts requests mid-stream on flaky Wi-Fi, yielding a right-sized but corrupt gzip (progress hits 100% three times, then 'archive corrupt'). curl resumes with HTTP Range so a dropped connection picks up where it stopped, retries internally, and the whole file is still byte-count + gunzip verified before handoff. Falls back to the fetch pipeline when curl is absent.
This commit is contained in:
parent
0589dd0396
commit
9afe49c378
2 changed files with 83 additions and 30 deletions
|
|
@ -1220,46 +1220,100 @@ handleIpc("folio:performUpdate", async () => {
|
|||
app.getPath("temp"),
|
||||
`folio-update-${Date.now()}.tar.gz`
|
||||
);
|
||||
// Downloads of the ~100MB asset have twice truncated near the end while
|
||||
// the server copy stayed intact. The old 2-byte magic check could not
|
||||
// see that, so the detached installer failed after the app had quit and
|
||||
// the update silently no-oped. Now: retry the download up to 3 times,
|
||||
// require the advertised byte count, and gunzip the whole file before
|
||||
// handing off. A corrupt file is deleted and the user gets a real error.
|
||||
const { Readable } = await import("stream");
|
||||
const { createWriteStream } = await import("fs");
|
||||
const { pipeline } = await import("stream/promises");
|
||||
// Downloads go through curl, not Chromium's network stack: on flaky Wi-Fi
|
||||
// the Electron net stack restarts requests mid-stream, producing a
|
||||
// right-sized but corrupt gzip ("100% three times, then failed"). curl
|
||||
// resumes with HTTP Range (-C -) so a dropped connection continues where
|
||||
// it left off instead of restarting, and retries internally. We still
|
||||
// verify the exact byte count and gunzip the whole file before handoff.
|
||||
const total = Number(asset.size) || 0;
|
||||
let lastError = "";
|
||||
let downloaded = false;
|
||||
for (let attempt = 1; attempt <= 3 && !downloaded; attempt++) {
|
||||
try {
|
||||
const res = await fetch(asset.browser_download_url, {
|
||||
redirect: "follow",
|
||||
signal: AbortSignal.timeout(10 * 60 * 1000),
|
||||
const { spawn } = await import("child_process");
|
||||
const haveCurl = await new Promise<boolean>((resolve) => {
|
||||
const p = spawn("curl", ["--version"], { stdio: "ignore" });
|
||||
p.on("error", () => resolve(false));
|
||||
p.on("close", (code) => resolve(code === 0));
|
||||
});
|
||||
if (haveCurl) {
|
||||
for (let attempt = 1; attempt <= 3 && !downloaded; attempt++) {
|
||||
const code = await new Promise<number>((resolve) => {
|
||||
const c = spawn(
|
||||
"curl",
|
||||
[
|
||||
"-fsSL",
|
||||
"-C", "-", // resume a partial file
|
||||
"--max-time", "900",
|
||||
"--retry", "5", "--retry-delay", "2", "--retry-all-errors",
|
||||
"--connect-timeout", "15",
|
||||
"-o", tarball,
|
||||
asset.browser_download_url,
|
||||
],
|
||||
{ stdio: "ignore" }
|
||||
);
|
||||
const timer = setInterval(() => {
|
||||
try {
|
||||
getWindow()?.webContents.send("folio:update-progress", {
|
||||
received: fs.statSync(tarball).size,
|
||||
total,
|
||||
});
|
||||
} catch {
|
||||
/* file not created yet */
|
||||
}
|
||||
}, 1000);
|
||||
c.on("error", () => { clearInterval(timer); resolve(-1); });
|
||||
c.on("close", (rc) => { clearInterval(timer); resolve(rc ?? -1); });
|
||||
});
|
||||
if (!res.ok || !res.body) {
|
||||
lastError = `Download failed (HTTP ${res.status}).`;
|
||||
if (code !== 0) {
|
||||
lastError = `Download failed (curl exit ${code}).`;
|
||||
continue;
|
||||
}
|
||||
let received = 0;
|
||||
const source = Readable.fromWeb(res.body as never);
|
||||
source.on("data", (chunk: Buffer) => {
|
||||
received += chunk.length;
|
||||
getWindow()?.webContents.send("folio:update-progress", { received, total });
|
||||
});
|
||||
await pipeline(source, createWriteStream(tarball));
|
||||
if (looksTruncated(received, total)) {
|
||||
lastError = `Download truncated (${received}/${total} bytes).`;
|
||||
const size = fs.existsSync(tarball) ? fs.statSync(tarball).size : 0;
|
||||
if (looksTruncated(size, total)) {
|
||||
lastError = `Download truncated (${size}/${total} bytes).`;
|
||||
continue;
|
||||
}
|
||||
if (!(await verifyArchiveGzip(tarball))) {
|
||||
lastError = "Downloaded archive is corrupt (gzip stream incomplete).";
|
||||
fs.rmSync(tarball, { force: true }); // corrupted resume file must not poison the next attempt
|
||||
continue;
|
||||
}
|
||||
downloaded = true;
|
||||
} catch (e) {
|
||||
lastError = `Download failed: ${(e as Error).message}`;
|
||||
}
|
||||
} else {
|
||||
// No curl on PATH: fall back to the in-process fetch pipeline.
|
||||
const { Readable } = await import("stream");
|
||||
const { createWriteStream } = await import("fs");
|
||||
const { pipeline } = await import("stream/promises");
|
||||
for (let attempt = 1; attempt <= 3 && !downloaded; attempt++) {
|
||||
try {
|
||||
const res = await fetch(asset.browser_download_url, {
|
||||
redirect: "follow",
|
||||
signal: AbortSignal.timeout(10 * 60 * 1000),
|
||||
});
|
||||
if (!res.ok || !res.body) {
|
||||
lastError = `Download failed (HTTP ${res.status}).`;
|
||||
continue;
|
||||
}
|
||||
let received = 0;
|
||||
const source = Readable.fromWeb(res.body as never);
|
||||
source.on("data", (chunk: Buffer) => {
|
||||
received += chunk.length;
|
||||
getWindow()?.webContents.send("folio:update-progress", { received, total });
|
||||
});
|
||||
await pipeline(source, createWriteStream(tarball));
|
||||
if (looksTruncated(received, total)) {
|
||||
lastError = `Download truncated (${received}/${total} bytes).`;
|
||||
continue;
|
||||
}
|
||||
if (!(await verifyArchiveGzip(tarball))) {
|
||||
lastError = "Downloaded archive is corrupt (gzip stream incomplete).";
|
||||
continue;
|
||||
}
|
||||
downloaded = true;
|
||||
} catch (e) {
|
||||
lastError = `Download failed: ${(e as Error).message}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!downloaded) {
|
||||
|
|
@ -1294,7 +1348,6 @@ handleIpc("folio:performUpdate", async () => {
|
|||
// systemd-run puts the script in its own transient unit that outlives us;
|
||||
// verified to survive on this machine. Fall back to a detached spawn for
|
||||
// non-systemd sessions.
|
||||
const { spawn } = await import("child_process");
|
||||
let handedOff = false;
|
||||
try {
|
||||
const { execFileSync } = await import("child_process");
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue