Durable atomic writer with fsync and per-chunk download progress (closes #39)
check / check (push) Successful in 41s

The atomic writer fsyncs the staged temp file before rename and the directory after, and is exported for reuse. downloadFile/downloadThumbnail gain an optional per-chunk onProgress hook (non-decreasing, final equals bytesWritten; no-op when absent). Retry and TAG_FINAL checks unchanged.

Model: opus-4-8
This commit was merged in pull request #56.
This commit is contained in:
2026-09-22 12:01:06 +02:00
parent ead083c1d6
commit 8f575550af
2 changed files with 187 additions and 11 deletions
+51 -10
View File
@@ -1,5 +1,5 @@
import { randomUUID } from "node:crypto";
import { rename, rm, writeFile } from "node:fs/promises";
import { open, rename, rm } from "node:fs/promises";
import { dirname, join } from "node:path";
import {
fromBase64,
@@ -19,12 +19,20 @@ export interface DownloadResult {
bytesWritten: number;
}
// Fired as decrypted plaintext accumulates, with the running total of
// plaintext bytes recovered so far. Within one download it is non-decreasing
// and its last value equals the final `bytesWritten`. A retry restarts the
// file from byte zero (see `fetchAndDecrypt`), so a fresh attempt begins its
// own count from zero.
export type ProgressCallback = (bytesDone: number) => void;
const ENC_CHUNK_SIZE = STREAM_CHUNK_SIZE + STREAM_CHUNK_OVERHEAD;
const streamDecrypt = async (
stream: ReadableStream<Uint8Array>,
header: Uint8Array,
key: Uint8Array,
onProgress?: ProgressCallback,
): Promise<Uint8Array> => {
const state = initStreamPull(header, key);
const reader = stream.getReader();
@@ -51,6 +59,7 @@ const streamDecrypt = async (
totalPlain += plaintext.length;
chunksPulled++;
lastTag = tag;
onProgress?.(totalPlain);
}
if (done) {
@@ -76,6 +85,7 @@ const streamDecrypt = async (
totalPlain += pulled.plaintext.length;
chunksPulled++;
lastTag = pulled.tag;
onProgress?.(totalPlain);
}
break;
}
@@ -107,21 +117,47 @@ const streamDecrypt = async (
return result;
};
// Write `plaintext` to `destination` atomically: stage it in a temporary
// sibling file (same directory, so the rename cannot cross a filesystem
// boundary) and rename it into place. Callers therefore never observe a
// partially written destination, and a pre-existing file at that path is
// replaced only once the new contents are complete on disk.
const writeAtomic = async (
// Write `plaintext` to `destination` atomically and durably: stage it in a
// temporary sibling file (same directory, so the rename cannot cross a
// filesystem boundary) and rename it into place. Callers therefore never
// observe a partially written destination, and a pre-existing file at that
// path is replaced only once the new contents are complete on disk.
//
// Durability against a power cut needs two fsyncs. Without them the write can
// return while the data or the rename is still only in the kernel's page
// cache, and a crash then resurrects an empty renamed file — exactly the
// corruption a later backup run treats as a complete download. So the temp
// file's contents are fsynced before the rename, and the containing directory
// is fsynced after it, so both the bytes and the new directory entry are on
// stable storage before this returns.
//
// Exported so the metadata store can reuse the same durable write.
export const writeAtomic = async (
destination: string,
plaintext: Uint8Array,
): Promise<void> => {
const dir = dirname(destination);
// The random suffix keeps concurrent downloads of the same destination
// from stepping on each other's temporary file.
const tmpPath = join(dirname(destination), `.quak-${randomUUID()}.tmp`);
const tmpPath = join(dir, `.quak-${randomUUID()}.tmp`);
try {
await writeFile(tmpPath, plaintext);
const handle = await open(tmpPath, "w");
try {
await handle.writeFile(plaintext);
await handle.sync();
} finally {
await handle.close();
}
await rename(tmpPath, destination);
// Fsync the directory so the rename itself survives a crash: renaming
// over a synced temp file still leaves the new directory entry in the
// page cache until the directory is synced.
const dirHandle = await open(dir, "r");
try {
await dirHandle.sync();
} finally {
await dirHandle.close();
}
} catch (err) {
// Best-effort cleanup. A failure to remove the temporary file must
// never replace the error that actually explains what went wrong.
@@ -150,16 +186,18 @@ const fetchAndDecrypt = async (
openStream: () => Promise<ReadableStream<Uint8Array>>,
header: Uint8Array,
key: Uint8Array,
onProgress?: ProgressCallback,
): Promise<Uint8Array> =>
withRetry(async () => {
const stream = await openStream();
return streamDecrypt(stream, header, key);
return streamDecrypt(stream, header, key, onProgress);
}, api.getRetryOptions());
export const downloadFile = async (
api: ApiClient,
file: EnteFile,
outPath?: string,
onProgress?: ProgressCallback,
): Promise<DownloadResult> => {
const resolvedPath = outPath ?? file.metadata.title;
const header = fromBase64(file.file.decryptionHeader);
@@ -168,6 +206,7 @@ export const downloadFile = async (
() => api.getFileStream(file.id, { retry: false }),
header,
file.key,
onProgress,
);
// Outside the retry, deliberately: only the attempt that produced a
// complete, authenticated plaintext gets to stage a temporary file, so a
@@ -181,6 +220,7 @@ export const downloadThumbnail = async (
api: ApiClient,
file: EnteFile,
outPath?: string,
onProgress?: ProgressCallback,
): Promise<DownloadResult> => {
const resolvedPath = outPath ?? `thumb_${file.metadata.title}`;
const header = fromBase64(file.thumbnail.decryptionHeader);
@@ -189,6 +229,7 @@ export const downloadThumbnail = async (
() => api.getThumbnailStream(file.id, { retry: false }),
header,
file.key,
onProgress,
);
await writeAtomic(resolvedPath, plaintext);
return { path: resolvedPath, bytesWritten: plaintext.length };