fix(cloud): preserve existing output when downloads fail (#3840)

* fix(cloud): preserve existing output when downloads fail

* test(cloud): pin same-directory download staging
This commit is contained in:
James Russo
2026-09-10 05:44:40 -04:00
committed by GitHub
parent 962c95406d
commit d393541c2b
3 changed files with 153 additions and 95 deletions
+95 -1
View File
@@ -1,4 +1,14 @@
import { mkdtempSync, readFileSync, rmSync, statSync } from "node:fs";
import {
chmodSync,
lstatSync,
mkdtempSync,
readFileSync,
readdirSync,
rmSync,
statSync,
symlinkSync,
writeFileSync,
} from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
@@ -109,4 +119,88 @@ describe("cloud/download", () => {
).rejects.toThrow();
expect(() => statSync(dest)).toThrow();
});
async function expectFailedReplacement(dest: string, fetchImpl: typeof fetch, error: RegExp) {
await expect(downloadToFile("https://example/x", dest, { fetchImpl })).rejects.toThrow(error);
expect(readFileSync(dest, "utf8")).toBe("previous render");
expect(readdirSync(dir)).toEqual(["out.mp4"]);
}
it("preserves an existing output when the response is truncated", async () => {
const dest = join(dir, "out.mp4");
writeFileSync(dest, "previous render");
await expectFailedReplacement(
dest,
makeBytesFetch(new Uint8Array([1]), { "content-length": "2" }),
/Truncated download/,
);
});
it("keeps the old output visible until successful replacement and preserves its mode", async () => {
const dest = join(dir, "out.mp4");
writeFileSync(dest, "previous render");
chmodSync(dest, 0o640);
await downloadToFile("https://example/x", dest, {
fetchImpl: makeBytesFetch(new Uint8Array([42])),
onProgress: () => {
expect(readFileSync(dest, "utf8")).toBe("previous render");
expect(readdirSync(dir).filter((name) => name.startsWith(".hf-download-"))).toHaveLength(1);
},
});
expect(readFileSync(dest)).toEqual(Buffer.from([42]));
if (process.platform !== "win32") expect(statSync(dest).mode & 0o777).toBe(0o640);
expect(readdirSync(dir)).toEqual(["out.mp4"]);
});
it("preserves the previous output when cancelled during progress", async () => {
const dest = join(dir, "out.mp4");
writeFileSync(dest, "previous render");
const controller = new AbortController();
await expect(
downloadToFile("https://example/x", dest, {
fetchImpl: makeBytesFetch(new Uint8Array(1024 * 1024)),
signal: controller.signal,
onProgress: () => controller.abort(new Error("user cancelled")),
}),
).rejects.toThrow(/user cancelled/);
expect(readFileSync(dest, "utf8")).toBe("previous render");
expect(readdirSync(dir)).toEqual(["out.mp4"]);
});
it("preserves the previous output when the response stream errors", async () => {
const dest = join(dir, "out.mp4");
writeFileSync(dest, "previous render");
let sent = false;
const body = new ReadableStream<Uint8Array>({
pull(controller) {
if (sent) controller.error(new Error("network interrupted"));
else {
sent = true;
controller.enqueue(new Uint8Array([42]));
}
},
});
await expectFailedReplacement(
dest,
(async () => new Response(body)) as typeof fetch,
/network interrupted/,
);
});
it.skipIf(process.platform === "win32")(
"preserves existing and dangling output symlinks",
async () => {
const target = join(dir, "target.mp4");
const alias = join(dir, "alias.mp4");
symlinkSync("target.mp4", alias);
for (const value of [1, 2]) {
await downloadToFile("https://example/x", alias, {
fetchImpl: makeBytesFetch(new Uint8Array([value])),
});
expect(lstatSync(alias).isSymbolicLink()).toBe(true);
expect(readFileSync(target)).toEqual(Buffer.from([value]));
}
expect(readdirSync(dir).sort()).toEqual(["alias.mp4", "target.mp4"]);
},
);
});
+57 -92
View File
@@ -9,12 +9,23 @@
* Failure behavior is "all or nothing": on any error we (1) listen for
* stream errors / aborts so awaits resolve promptly instead of hanging,
* (2) verify the final byte count matches `content-length` when the
* server supplied one, and (3) `unlinkSync` the partial output so a
* subsequent retry doesn't pick up a corrupted file.
* server supplied one, and (3) discard the private staged output. An
* existing destination is replaced only after the download succeeds.
*/
import { createWriteStream, mkdirSync, unlinkSync } from "node:fs";
import { dirname } from "node:path";
import {
chmodSync,
createWriteStream,
lstatSync,
mkdirSync,
mkdtempSync,
readlinkSync,
renameSync,
rmSync,
statSync,
} from "node:fs";
import { dirname, join, resolve } from "node:path";
import { pipeline } from "node:stream/promises";
export interface DownloadOptions {
signal?: AbortSignal;
@@ -31,9 +42,11 @@ export interface DownloadResult {
/**
* Stream `url` into `destPath`. Creates the parent directory if needed,
* truncates any existing file at the destination, and deletes the
* partial output on any error so the caller never observes a corrupt
* file at the returned path.
* replaces an existing file only after the complete response is written,
* and removes staged bytes on failure while preserving the old output.
* Atomic replacement requires a writable parent directory and creates a new
* inode: mode is retained, owner/group are not, and hard links keep old bytes.
* A read-only file can be replaced when its parent permits the rename.
*/
// fallow-ignore-next-line complexity
export async function downloadToFile(
@@ -56,96 +69,48 @@ export async function downloadToFile(
const total = totalHeader ? Number.parseInt(totalHeader, 10) : undefined;
const totalOpt = total !== undefined && Number.isFinite(total) ? total : undefined;
const file = createWriteStream(destPath);
const destination = resolveDownloadDestination(destPath);
const previous = statSync(destination, { throwIfNoEntry: false });
const stage = mkdtempSync(join(dirname(destination), ".hf-download-"));
const stagedFile = join(stage, "download");
let bytes = 0;
let errored = false;
try {
for await (const chunk of res.body as unknown as AsyncIterable<Uint8Array>) {
if (options.signal?.aborted) {
throw options.signal.reason instanceof Error
? options.signal.reason
: new Error("Download aborted");
}
bytes += chunk.byteLength;
options.onProgress?.(bytes, totalOpt);
if (!file.write(chunk)) {
await waitForDrain(file, options.signal);
}
}
if (totalOpt !== undefined && bytes !== totalOpt) {
throw new Error(
`Truncated download: got ${bytes} bytes, expected ${totalOpt} (content-length). ` +
`The presigned URL may have expired mid-transfer — refetch via \`hyperframes cloud get\`.`,
);
}
} catch (err) {
errored = true;
throw err;
await pipeline(
async function* () {
for await (const chunk of res.body as unknown as AsyncIterable<Uint8Array>) {
bytes += chunk.byteLength;
options.onProgress?.(bytes, totalOpt);
yield chunk;
}
if (totalOpt !== undefined && bytes !== totalOpt) {
throw new Error(
`Truncated download: got ${bytes} bytes, expected ${totalOpt} (content-length). ` +
`The presigned URL may have expired mid-transfer — refetch via \`hyperframes cloud get\`.`,
);
}
},
createWriteStream(stagedFile, { flags: "wx" }),
{ signal: options.signal },
);
options.signal?.throwIfAborted();
if (previous?.isFile()) chmodSync(stagedFile, previous.mode & 0o777);
renameSync(stagedFile, destination);
} catch (error) {
const reason = options.signal?.reason;
if (options.signal?.aborted && reason instanceof Error) throw reason;
throw error;
} finally {
await closeFile(file);
if (errored) {
// Don't let a partial file pose as the final artifact. Best-
// effort unlink — if it fails (already gone, permission), we
// re-throw the original error.
try {
unlinkSync(destPath);
} catch {
/* swallow */
}
}
rmSync(stage, { recursive: true, force: true });
}
return { path: destPath, bytes };
}
/**
* Resolve when the write stream emits `drain`, or reject on `error` /
* `close` / signal abort — avoids the hang from awaiting a one-shot
* `drain` event that never fires because the stream tore down first.
*/
function waitForDrain(file: NodeJS.WritableStream, signal?: AbortSignal): Promise<void> {
return new Promise<void>((resolve, reject) => {
const cleanup = () => {
file.off("drain", onDrain);
file.off("error", onError);
file.off("close", onClose);
signal?.removeEventListener("abort", onAbort);
};
const onDrain = (): void => {
cleanup();
resolve();
};
const onError = (err: Error): void => {
cleanup();
reject(err);
};
const onClose = (): void => {
cleanup();
reject(new Error("write stream closed before drain"));
};
const onAbort = (): void => {
cleanup();
const reason = signal?.reason;
reject(reason instanceof Error ? reason : new Error("Download aborted"));
};
file.once("drain", onDrain);
file.once("error", onError);
file.once("close", onClose);
signal?.addEventListener("abort", onAbort, { once: true });
});
}
function closeFile(file: NodeJS.WritableStream): Promise<void> {
return new Promise<void>((resolve) => {
// Best-effort cleanup: any underlying failure has already been
// surfaced as the original throw from the for-await loop. We
// listen for `error` so a failing close (bad fd, late ENOSPC on
// flush) doesn't leak an unhandled 'error' onto the stream, and
// resolve either way so the finally block proceeds to unlinkSync.
const done = (): void => {
file.off("error", done);
resolve();
};
file.once("error", done);
file.end(() => done());
});
/** Preserve the existing behavior of writing through a caller-selected symlink. */
function resolveDownloadDestination(destPath: string): string {
let destination = resolve(destPath);
for (let hops = 0; hops < 40; hops++) {
if (!lstatSync(destination, { throwIfNoEntry: false })?.isSymbolicLink()) return destination;
destination = resolve(dirname(destination), readlinkSync(destination));
}
throw new Error(`Too many symbolic links in download destination: ${destPath}`);
}
+1 -2
View File
@@ -852,8 +852,7 @@ async function streamVideo(
destPath: string,
asJson: boolean,
): Promise<{ bytes: number }> {
// `downloadToFile` already creates the parent directory and cleans
// up the partial file on error — no pre-mkdir needed here.
// `downloadToFile` creates the parent directory and preserves existing output on failure.
if (!asJson) {
console.log("");
console.log(`${c.accent("◆")} Downloading to ${c.accent(destPath)}`);