Compare commits

...
2 Commits
Author SHA1 Message Date
sneak 4cda4c88ae An album whose folder cannot be made no longer stops the backup (closes #197)
check / check (push) Canceled after 0s
Each album's folder, links and JSON are written inside one try. A failure
prints `FAILED album <name>: <reason>`, lists the album's ID in the new
`failedAlbums` of the result and of `--json`, and makes `quak backup` exit 1;
the other albums and `failures.json` are still written.

An album whose folder would have another album's JSON name, ignoring case,
gets its ID. A name over 255 bytes (250 for a folder, which leaves room for
`.json`) gets the ID and is cut short between two characters by
`shortenToBytes` in `src/filename.ts`. A link's ID goes right before its
extension.

Apart from its call to `uniqueNames`, the album loop is only indented into
the try.

Model: opus-5-5
2026-10-16 02:58:25 +00:00
clawbot 20410f8f71 The binary make install builds retries a refused or unresolvable connection (closes #206)
check / check (push) Canceled after 0s
Bun's `fetch`, which `~/bin/quak` runs under, rejects with a plain `Error`
that has no `cause` and a code of bun's own. Running the built binary against
local listeners gave `ConnectionRefused` for a refused connection and a failed
TLS handshake, and `ConnectionRefused` or `FailedToOpenSocket` for a name that
does not resolve. Both join `TRANSPORT_CODES` in `src/retry.ts`, so they are
retried and called transient. They stay out of `CONNECT_CODES`, so a `POST` or
`PUT` is not replayed on them, because bun also gives them when it resends a
request on a new connection. `classify` in `src/backup.ts` records a
`TimeoutError` as transient, so a stalled download is no longer `unknown`.

Not changed: a certificate bun rejects is not retried.

Model: opus-5-5
2026-10-16 04:56:20 +02:00
10 changed files with 702 additions and 97 deletions
+48 -15
View File
@@ -134,12 +134,12 @@ matters.
### Exit codes
| Code | Meaning |
| ---- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `0` | The backup is complete: every original is at its save path, and no file failed. |
| `1` | The run finished with files in `failures.json` or an album it could not decrypt, or it stopped on another error, printed as one line `quak: <message>`. The next run tries again. |
| `2` | Another backup of the same directory is running. This run sent no request and changed nothing. |
| `3` | There is no usable session: none is saved, the saved one is corrupt, or the server no longer accepts it. Run `quak login` as the user the cron job runs as. |
| Code | Meaning |
| ---- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `0` | The backup is complete: every original is at its save path, and no file failed. |
| `1` | The run finished with files in `failures.json` or albums it could not decrypt or write (see "Backup layout"), or it stopped on another error, printed as one line `quak: <message>`. The next run tries again. |
| `2` | Another backup of the same directory is running. This run sent no request and changed nothing. |
| `3` | There is no usable session: none is saved, the saved one is corrupt, or the server no longer accepts it. Run `quak login` as the user the cron job runs as. |
`<message>` is the error's message, followed by the message of each error that
caused it. Under Node, as `yarn quak` runs quak, `fetch` reports every failure
@@ -151,7 +151,9 @@ for each, separated by `, `. A file whose download failed that way is in
`failures.json` with that error, as a transient failure. So is each file with no
ML data cached when the ML data fetch failed that way, its error starting
`ML data: `. The `~/bin/quak` that `make install` builds runs under bun, and
prints bun's own wording for such a failure.
prints bun's own wording for such a failure, with no reason after it, such as
`quak: Unable to connect. Is the computer able to access the url?` for a refused
connection. It records such a failure in `failures.json` as transient too.
After a `1`, the next run fetches each missing original again, fetches the ML
data that is not cached, and rebuilds the album links. An album quak could not
@@ -494,6 +496,17 @@ request is repeated only when repeating it could produce a different answer:
declining to retry a transient one costs a single file that the next run picks
up.
The `~/bin/quak` that `make install` builds runs under bun, whose `fetch`
rejects with a plain `Error` that has no `cause` and carries a code of bun's
own. quak retries a refused connection, a name that does not resolve, a reset
connection and a deadline under bun as it does under Node. Bun reports a refused
connection, and a TLS handshake that failed, as `ConnectionRefused`. For a name
that does not resolve it gives `ConnectionRefused` on some requests and
`FailedToOpenSocket` on others. A connection reset or closed before the response
ended is `ECONNRESET`, as under Node, and a deadline is the same `TimeoutError`.
A certificate bun rejects, reported with a code such as
`DEPTH_ZERO_SELF_SIGNED_CERT`, is not retried.
Backoff is exponential with full jitter: the delay before retry _n_ is
`random() * min(maxDelayMs, baseDelayMs * 2 ** (n - 1))`. The exponential term
is the ceiling and the wait is drawn below it, so a client that lost many
@@ -549,9 +562,13 @@ routing errnos `EHOSTUNREACH`, `ENETUNREACH` and `ENETDOWN` are excluded for the
same reason, despite looking like connect-time failures: on Linux an ICMP
unreachable arriving mid-flight, or a local interface going down after the
request was written, delivers them on an already-established socket. They stay
retryable for the idempotent calls. `putFile` is exempt: a presigned PUT stores
one whole object at one key in one request, so replaying it has no partial state
to damage.
retryable for the idempotent calls. So do bun's `ConnectionRefused` and
`FailedToOpenSocket`, on which a `POST` or `PUT` is not replayed either. When a
connection bun kept open from an earlier request closes before any answer, bun
sends the request again on a new connection, and if that one fails it gives one
of these codes although the server may already have the request. `putFile` is
exempt: a presigned PUT stores one whole object at one key in one request, so
replaying it has no partial state to damage.
A download is retried as a whole — request, stream consumption, and decryption —
because a socket reset after the response headers have arrived surfaces in the
@@ -810,8 +827,23 @@ after the file's title, both with unsafe characters replaced. When two
collections would get the same name, or two symlinks in one collection the same
name (ignoring case in both), each of them gets its ID added: two albums named
`Trip` become `Trip (10)/` and `Trip (11)/`, and two files titled `IMG_0001.JPG`
become `IMG_0001 (12345).JPG` and `IMG_0001 (12346).JPG`. IDs never change, so a
name stays the same from run to run until such a clash appears or goes away.
become `IMG_0001 (12345).JPG` and `IMG_0001 (12346).JPG`. A collection whose
directory would have the name of another collection's JSON, again ignoring case,
gets its ID added as well: beside an album `x`, whose JSON is `x.json`, an album
named `x.json` gets the directory `x.json (12)/`. A name too long for a file
name, which most file systems limit to 255 bytes, gets its ID added too. A name
with its ID added is cut short between two characters in the part before the ID,
just far enough that it fits. When a symlink's extension alone leaves no room
for the ID, the extension is cut short and the part before the ID keeps only the
bytes that cut leaves over. A collection's directory name also leaves room for
the `.json` its JSON adds. IDs never change, so a name stays the same from run
to run until such a clash appears or goes away.
A collection whose directory or JSON cannot be written, such as one whose
directory name is taken by a file you put in `collections/`, does not stop the
run. A run without `--json` prints `FAILED album <name>: <reason>` for it,
`--json` and the `BackupResult` list its ID in `failedAlbums`, and the exit code
is 1. The other collections and `failures.json` are still written.
A live photo, which Ente stores as one ZIP of its image and its video, is stored
as those two files, which a photo viewer can open: each is
@@ -1105,9 +1137,10 @@ photos newest first). `lib.subscribe({ onChange })` delivers a `LibraryChange`
`downloadDirectory` (falls back to the library's), `includeOriginals` (default
`true`), `includeThumbnails` (default `false`), `onlyAlbumNames`, `verify`
(default `false`), `onProgress`, and `lock`. See Backup layout above for the
tree it writes. Whether the backup took the lock or its caller did, once the
lock is lost the backup rejects with `lost the lock on <dir>: <reason>` before
it starts another file.
tree it writes, and for the albums `BackupResult` lists in
`undecryptableAlbums` and `failedAlbums`. Whether the backup took the lock or
its caller did, once the lock is lost the backup rejects with
`lost the lock on <dir>: <reason>` before it starts another file.
### Request pools
+25 -5
View File
@@ -17,11 +17,9 @@ pre-1.0
# Next Step
Five bugs are open: https://git.eeqj.de/sneak/quak/issues/197,
https://git.eeqj.de/sneak/quak/issues/198,
https://git.eeqj.de/sneak/quak/issues/199,
https://git.eeqj.de/sneak/quak/issues/205 and
https://git.eeqj.de/sneak/quak/issues/206. The check of quak against a live Ente
Three bugs are open: https://git.eeqj.de/sneak/quak/issues/198,
https://git.eeqj.de/sneak/quak/issues/199 and
https://git.eeqj.de/sneak/quak/issues/205. The check of quak against a live Ente
account, https://git.eeqj.de/sneak/quak/issues/187, needs an account only sneak
has, so it waits on him.
@@ -30,6 +28,28 @@ declares one.
# Completed Steps
- 2026-10-16: The `~/bin/quak` that `make install` builds retries a refused
connection and a name that does not resolve, and the backup records either as
transient (issue 206). Bun's `fetch` reports them with codes of its own,
`ConnectionRefused` and `FailedToOpenSocket`, which `src/retry.ts` now counts
as transport failures. A `POST` or `PUT` is not replayed on them, because bun
also gives them when it resends a request on a new connection after a
connection it kept open closed, and the server may already have the request.
Bun already gave `ECONNRESET` for a reset and a `TimeoutError` for a deadline.
`classify` in `src/backup.ts` now records any `TimeoutError` as transient, so
a stalled download, whose error says only that it stalled, is no longer
`unknown`. The shapes were found by running the binary against local
listeners.
- 2026-10-15: An album whose folder or JSON `quak backup` cannot write no longer
ends the run before the other albums and `failures.json` are written (issue
197). The run prints `FAILED album <name>: <reason>`, lists the album's ID in
`failedAlbums` in the result and in `--json`, goes on, and exits 1. An album
whose folder would have the name of another album's JSON, as `x.json` beside
`x` would, gets its ID added. A folder or link name over 255 bytes is cut
short between two characters, by `shortenToBytes` in `src/filename.ts`, and
gets the ID.
- 2026-10-15: A network failure is printed and recorded with its reason, not as
just `fetch failed` (issue 196). `describeError` in `src/errors.ts` gives an
error's message followed by the message of each error on its `cause` chain,
+97 -58
View File
@@ -50,7 +50,9 @@
// running attempt count, and the last-tried time, and the run continues.
// A file the library could not decrypt is recorded there too, unless a copy of
// it in another album decrypted; an album it could not decrypt is logged by ID
// and listed in `result.undecryptableAlbums`, as its name is not known.
// and listed in `result.undecryptableAlbums`, as its name is not known. An
// album whose folder or JSON cannot be written is logged by name and
// listed in `result.failedAlbums`, and the run goes on to the next album.
// `result.failed` — and thus the CLI's exit code — stays non-zero while any
// failure remains unresolved and clears once every one succeeds. Each run
// reconciles the ledger against the files it attempted, so an entry for a file
@@ -83,7 +85,12 @@ import {
} from "./crypto/index.js";
import { removeLeftoverTempFiles } from "./download/index.js";
import { describeError } from "./errors.js";
import { sanitizeFileName, withExtension } from "./filename.js";
import {
MAX_NAME_BYTES,
sanitizeFileName,
shortenToBytes,
withExtension,
} from "./filename.js";
import {
copyAtomic,
placeOriginal,
@@ -160,6 +167,9 @@ export interface BackupResult {
// The IDs of the albums the library could not decrypt, whose files it
// could not list; the CLI exits non-zero while there are any.
undecryptableAlbums: number[];
// The IDs of the albums whose folder or JSON this run could not write;
// the CLI exits non-zero while there are any.
failedAlbums: number[];
}
// The slice of the library that backup drives. `Library` implements it; a test
@@ -223,10 +233,13 @@ const isPresent = (path: string): boolean => {
// are transient; refusals and local filesystem/decrypt errors are permanent;
// anything else is unknown. The error code and the error as `describeError`
// gives it are inspected, and so is each errno on the error's cause chain,
// where Node's `fetch` puts it.
// where Node's `fetch` puts it. An error named `TimeoutError` is transient
// whatever its message says, as `isRetryable` retries it: each of quak's
// deadlines aborts with one, and the download's says only that it stalled.
const classify = (err: unknown): FailureClass => {
if (hasTransportCode(err)) return "transient";
const e = err as NodeJS.ErrnoException;
if (e?.name === "TimeoutError") return "transient";
const text = `${e?.code ?? ""} ${describeError(err)}`.toLowerCase();
if (
/timeout|timed out|econnreset|econnrefused|econnaborted|network|socket|eai_again|throttl|temporarily|429|500|502|503|504/.test(
@@ -264,31 +277,47 @@ const rebuildSymlink = (linkPath: string, target: string): void => {
// The on-disk names for the entries of one directory, in entry order. Each
// name is used as is unless another entry would get the same name, ignoring
// case (two names that differ only in case are one entry on a case-insensitive
// file system); then every entry sharing it gets ` (<id>)`, before the
// extension when `beforeExtension` is set. A name with an ID added can match
// another entry's own name (`IMG (6).JPG`), so this repeats until no name is
// shared. IDs are stable, so the names are too.
// file system), or it is too long for a file name; then it gets ` (<id>)`,
// right before the extension when `beforeExtension` is set. To fit, the part
// before the ID is cut short as far as it must be. When the extension alone
// leaves no room for the ID, the extension is cut short and the part before
// the ID keeps only the bytes that cut leaves over. With `withJSON`, each
// entry also has the JSON file `<name>.json` beside it, as an album does: a
// name then leaves room for `.json`, and one that is another entry's JSON name
// gets its ID added too.
// Two names with different IDs added never match, but one can match another
// entry's own name (`IMG (6).JPG`), so this repeats until no name is shared.
// IDs are stable, so the names are too.
const uniqueNames = (
entries: { id: number; name: string }[],
beforeExtension: boolean,
opts: { beforeExtension?: boolean; withJSON?: boolean },
): string[] => {
const maxBytes = MAX_NAME_BYTES - (opts.withJSON ? ".json".length : 0);
const withID = (id: number, name: string): string => {
const ext = beforeExtension ? extname(name) : "";
const stem = name.slice(0, name.length - ext.length);
return `${stem} (${id})${ext}`;
const tag = ` (${id})`;
const fullExt = opts.beforeExtension ? extname(name) : "";
const stem = name.slice(0, name.length - fullExt.length);
const ext = shortenToBytes(fullExt, maxBytes - Buffer.byteLength(tag));
const room = maxBytes - Buffer.byteLength(tag + ext);
return shortenToBytes(stem, room) + tag + ext;
};
const names = entries.map((e) => e.name);
const suffixed = new Set<number>();
for (;;) {
const counts = new Map<string, number>();
for (const name of names) {
const count = (name: string): void => {
const key = name.toLowerCase();
counts.set(key, (counts.get(key) ?? 0) + 1);
};
for (const name of names) {
count(name);
if (opts.withJSON) count(`${name}.json`);
}
let changed = false;
for (const [i, { id, name }] of entries.entries()) {
if (suffixed.has(i)) continue;
if (counts.get(name.toLowerCase()) === 1) continue;
const fits = Buffer.byteLength(name) <= maxBytes;
if (fits && counts.get(name.toLowerCase()) === 1) continue;
names[i] = withID(id, name);
suffixed.add(i);
changed = true;
@@ -838,7 +867,7 @@ const runLockedBackup = async (
id: c.id,
name: sanitizeFileName(c.name, `collection-${c.id}`),
})),
false,
{ withJSON: true },
);
const albumDirNames = new Map(
allCollections.map((c, i) => [c.id, dirNames[i]!]),
@@ -854,60 +883,69 @@ const runLockedBackup = async (
log(`FAILED removing old album directories: ${describeError(err)}`);
}
// An album whose folder or JSON cannot be written, such as one whose
// folder name is taken by a file the user put there, is logged and listed
// in the result, and the run goes on to the next album.
const failedAlbums: number[] = [];
for (const c of collections) {
if (lock.lost) throw lock.lost;
const colDirName = albumDirNames.get(c.id)!;
const colDir = join(collectionsDir, colDirName);
mkdirSync(colDir, { recursive: true });
// Every album links the one original, saved from the file's entry in
// `distinct`.
const files = filesByCollection.get(c.id) ?? [];
const links = files.flatMap((f) =>
linksFor(
f,
storedAtSavePath(downloadDirectory, distinct.get(f.id)!),
),
);
const linkNames = uniqueNames(links, true);
try {
removeStaleLinks(colDir, new Set(linkNames), downloadDirectory);
} catch (err) {
log(
`FAILED removing old links in ${c.name}: ${describeError(err)}`,
);
}
const colDirName = albumDirNames.get(c.id)!;
const colDir = join(collectionsDir, colDirName);
mkdirSync(colDir, { recursive: true });
const metaFiles = files.map((f) => ({
id: f.id,
metadata: f.metadata,
}));
for (const [i, link] of links.entries()) {
if (!includeOriginals || link.target === undefined) continue;
const linkName = linkNames[i]!;
// Every album links the one original, saved from the file's entry
// in `distinct`.
const files = filesByCollection.get(c.id) ?? [];
const links = files.flatMap((f) =>
linksFor(
f,
storedAtSavePath(downloadDirectory, distinct.get(f.id)!),
),
);
const linkNames = uniqueNames(links, { beforeExtension: true });
try {
rebuildSymlink(
join(colDir, linkName),
relative(colDir, link.target),
);
removeStaleLinks(colDir, new Set(linkNames), downloadDirectory);
} catch (err) {
log(
`FAILED symlink ${c.name}/${linkName}: ${describeError(err)}`,
);
recordFailure(
link.file.id,
link.file.metadata.title,
c.name,
err,
`FAILED removing old links in ${c.name}: ${describeError(err)}`,
);
}
}
writeAlbumJSON(
join(collectionsDir, `${colDirName}.json`),
c,
metaFiles,
);
const metaFiles = files.map((f) => ({
id: f.id,
metadata: f.metadata,
}));
for (const [i, link] of links.entries()) {
if (!includeOriginals || link.target === undefined) continue;
const linkName = linkNames[i]!;
try {
rebuildSymlink(
join(colDir, linkName),
relative(colDir, link.target),
);
} catch (err) {
log(
`FAILED symlink ${c.name}/${linkName}: ${describeError(err)}`,
);
recordFailure(
link.file.id,
link.file.metadata.title,
c.name,
err,
);
}
}
writeAlbumJSON(
join(collectionsDir, `${colDirName}.json`),
c,
metaFiles,
);
} catch (err) {
log(`FAILED album ${c.name}: ${describeError(err)}`);
failedAlbums.push(c.id);
}
}
// Reconcile the ledger against what this run actually attempted: an entry
@@ -933,6 +971,7 @@ const runLockedBackup = async (
failed: ledger.size,
errors,
undecryptableAlbums,
failedAlbums,
};
};
+3 -1
View File
@@ -470,7 +470,9 @@ export const backupCommand = async (
}
}
return result.failed > 0 || result.undecryptableAlbums.length > 0
return result.failed > 0 ||
result.undecryptableAlbums.length > 0 ||
result.failedAlbums.length > 0
? 1
: 0;
} finally {
+19 -1
View File
@@ -4,7 +4,8 @@
// hands us, and quak does not trust the server. Any name taken from them and
// used on disk goes through here, so it can only ever name one file inside the
// directory the caller chose: never a path, never `..`, never hidden, never a
// Windows device name.
// Windows device name. Such a name can still be too long for a file name, and
// `shortenToBytes` cuts one short.
//
// A path the user typed (`--out`, `outPath`) is not passed through here: the
// caller is trusted, the server is not.
@@ -39,3 +40,20 @@ export const safeExtension = (title: string): string => {
// `name` with its extension, if it has one, replaced by `ext` (".mov").
export const withExtension = (name: string, ext: string): string =>
name.slice(0, name.length - extname(name).length) + ext;
// The longest file name, in bytes, that ext4 and most other file systems allow.
export const MAX_NAME_BYTES = 255;
// `name` cut short to at most `maxBytes` bytes of UTF-8, the encoding a file
// name is written to disk in, between two characters, never inside one. A name
// that fits comes back unchanged.
export const shortenToBytes = (name: string, maxBytes: number): string => {
let bytes = 0;
let end = 0;
for (const character of name) {
bytes += Buffer.byteLength(character);
if (bytes > maxBytes) break;
end += character.length;
}
return name.slice(0, end);
};
+27 -7
View File
@@ -58,6 +58,14 @@ export const resolveRetryOptions = (
});
// Transport failures: the request did not complete, for reasons below HTTP.
//
// The errnos are Node's. The binary `make install` builds runs under bun,
// whose `fetch` rejects with a plain `Error` that has no `cause` and a `code`
// of bun's own. Bun gives `ConnectionRefused` for a refused connection and for
// a TLS handshake that failed, and `ConnectionRefused` or `FailedToOpenSocket`
// for a name that does not resolve. For a connection reset or closed before
// the response ended, it gives Node's `ECONNRESET`. These were found by
// running that binary, built with bun 1.3.13, against listeners on 127.0.0.1.
const TRANSPORT_CODES = new Set([
"ECONNRESET",
"ECONNABORTED",
@@ -70,6 +78,9 @@ const TRANSPORT_CODES = new Set([
"ENETUNREACH",
"ENETRESET",
"ENETDOWN",
// Bun's.
"ConnectionRefused",
"FailedToOpenSocket",
]);
// The subset of the above that can only be reported before a TCP connection
@@ -87,6 +98,12 @@ const TRANSPORT_CODES = new Set([
// ambiguity this set exists to exclude. They stay in `TRANSPORT_CODES`, so
// they remain retryable for idempotent calls; only replay eligibility is
// narrowed. See `isSafeToReplay`.
//
// Bun's `ConnectionRefused` and `FailedToOpenSocket` are absent too, so a
// `POST` or `PUT` is not replayed on them. When a connection bun kept open from
// an earlier request closes before any answer, bun sends the request again on
// a new connection, and if that one fails it gives one of these codes although
// the server may already have the request.
const CONNECT_CODES = new Set(["ENOTFOUND", "EAI_AGAIN", "ECONNREFUSED"]);
// Collect every `code` in an error's cause chain. undici does not put the
@@ -105,7 +122,7 @@ const causeCodes = (err: unknown): { codes: string[]; complete: boolean } => {
return { codes, complete };
};
// Does the error's cause chain hold the errno of a transport failure, one of
// Does the error's cause chain hold the code of a transport failure, one of
// `TRANSPORT_CODES`? `isRetryable` retries such an error, and `classify` in
// `src/backup.ts` calls it transient.
export const hasTransportCode = (err: unknown): boolean =>
@@ -152,7 +169,8 @@ export const isRetryable = (err: unknown): boolean => {
// deliberately literal. Demanding a recognised `cause` instead would
// classify real network failures as permanent and fail backups that should
// have succeeded; the cost of the imprecision is bounded by the attempt
// count.
// count. Bun's fetch rejects with a plain `Error` instead, which is
// recognised by its code (see `TRANSPORT_CODES`).
if (err instanceof TypeError) return true;
return hasTransportCode(err);
};
@@ -170,11 +188,13 @@ export const isRetryable = (err: unknown): boolean => {
//
// Everything else is ambiguous. A 5xx proves the server did process the
// request. A reset or a broken pipe can arrive after it was fully sent and
// acted on. A routing errno can be delivered on an established socket. A
// deadline says nothing at all about the server's state. So every errno in the
// cause chain must be a connect errno: one other errno anywhere in the chain
// is doubt, and doubt is not replayed. A chain longer than the walk is doubt
// too: the links below the limit were never read.
// acted on. A routing errno can be delivered on an established socket. Bun's
// `ConnectionRefused` and `FailedToOpenSocket` can come from a new connection
// bun opened to resend a request that already went out on a connection it kept
// open. A deadline says nothing at all about the server's state. So every code in the
// cause chain must be one of `CONNECT_CODES`: one other code anywhere in the
// chain is doubt, and doubt is not replayed. A chain longer than the walk is
// doubt too: the links below the limit were never read.
export const isSafeToReplay = (err: unknown): boolean => {
const { codes, complete } = causeCodes(err);
return (
+122 -8
View File
@@ -274,15 +274,17 @@ afterAll(() => {
// Run `quak backup <dir>` as `bin/quak.ts` runs it, sending its requests
// through `fetch`, and return its exit code and everything it printed on
// stderr. Its requests are retried as those of `quak backup` are, except that
// a retry does not wait. Its cache is beside `dir`, so each run of one
// directory finds the cache the run before it left, as cron's runs find the
// user's.
// stderr and on stdout. Its requests are retried as those of `quak backup`
// are, except that a retry does not wait. A download is given up after
// `downloadTimeoutMs` with no bytes received, 60 seconds unless given. Its
// cache is beside `dir`, so each run of one directory finds the cache the run
// before it left, as cron's runs find the user's.
const backup = async (
dir: string,
opts: { verify?: boolean } = {},
opts: { verify?: boolean; json?: boolean } = {},
fetch = server,
): Promise<{ code: number; stderr: string }> => {
downloadTimeoutMs?: number,
): Promise<{ code: number; stderr: string; stdout: string }> => {
let printed = "";
const stderr = new Writable({
write(chunk: Buffer, _encoding, done) {
@@ -290,8 +292,10 @@ const backup = async (
done();
},
});
let output = "";
const stdout = new Writable({
write(_chunk, _encoding, done) {
write(chunk: Buffer, _encoding, done) {
output += chunk.toString();
done();
},
});
@@ -308,6 +312,7 @@ const backup = async (
loadSession(path, {
fetch,
retry: { ...UNATTENDED_RETRY_OPTIONS, sleep: async () => {} },
downloadTimeoutMs,
}),
login: prompted,
prompt: prompted,
@@ -316,7 +321,7 @@ const backup = async (
const code = await new Promise<number>((resolve) => {
void run(backupCommand(ctx, dir, opts), stdout, stderr, resolve);
});
return { code, stderr: printed };
return { code, stderr: printed, stdout: output };
};
// The ID of the file whose original a request downloads, or undefined for any
@@ -627,6 +632,40 @@ describe("quak backup", () => {
).toEqual([]);
});
// The user has put a file in collections/ with the name of the Trips
// folder, so quak cannot make that folder. It says so on one line, writes
// the two albums after it, and exits 1, and --json lists the album's ID
// in `failedAlbums`. No file failed, so there is no failures.json.
it("exits 1 when an album's folder cannot be made, writes the other albums, and lists it in --json", async () => {
const dir = mkdtempSync(join(root, "backup-"));
mkdirSync(join(dir, "collections"));
writeFileSync(join(dir, "collections", "Trips"), "mine");
const first = await backup(dir);
const { code, stdout } = await backup(dir, { json: true });
expect(first.code).toBe(1);
expect(first.stderr).toMatch(/^FAILED album Trips: EEXIST: /m);
expect(code).toBe(1);
expect(JSON.parse(stdout)).toMatchObject({
failed: 0,
undecryptableAlbums: [],
failedAlbums: [10],
});
const collections = join(dir, "collections");
expect(readJSON(join(collections, "Family.json")).id).toBe(20);
expect(readdirSync(join(collections, "Family")).sort()).toEqual([
"IMG_0002.heic",
"IMG_0002.mov",
"beach.jpg",
]);
expect(readJSON(join(collections, "Friend's Wedding.json")).id).toBe(
30,
);
expect(existsSync(join(collections, "Trips.json"))).toBe(false);
expect(existsSync(join(dir, "failures.json"))).toBe(false);
});
// The server no longer accepts the saved session's token, as after
// `quak logout` elsewhere, and answers every request with 401. The run
// stops at the refresh it starts with, says to log in again, and exits 3.
@@ -724,6 +763,81 @@ describe("quak backup", () => {
},
);
// The download of toast.mp4 fails as bun's `fetch` reports a failure, in
// the binary `make install` builds: a plain `Error` with bun's own code
// and message, and no cause. These are what bun gave for a refused
// connection, a name that did not resolve and a reset connection. The
// file is tried as often as under Node, and goes into `failures.json`
// with that message, as a transient failure.
it.each([
[
"ConnectionRefused",
"Unable to connect. Is the computer able to access the url?",
],
["FailedToOpenSocket", "Was there a typo in the url or port?"],
[
"ECONNRESET",
"The socket connection was closed unexpectedly. For more information, pass `verbose: true` in the second argument to fetch()",
],
])(
"records a download that failed with bun's %s in failures.json, as transient",
async (bunCode, message) => {
let attempts = 0;
const fetch: typeof globalThis.fetch = async (input, init) => {
if (downloadOf(input) === 200) {
attempts++;
throw Object.assign(new Error(message), {
code: bunCode,
errno: 0,
});
}
return server(input, init);
};
const dir = mkdtempSync(join(root, "backup-"));
const { code } = await backup(dir, {}, fetch);
expect(code).toBe(1);
expect(attempts).toBe(UNATTENDED_RETRY_OPTIONS.attempts);
const failures = readJSON(join(dir, "failures.json"));
expect(failures.files["200"]).toMatchObject({
classification: "transient",
error: message,
});
},
);
// The download of toast.mp4 stalls: the server sends the headers and then
// never a byte of the body. Each attempt is given up after 50 ms, quak's
// own deadline for this run, with a `TimeoutError` whose message says
// only that the download stalled. The file goes into `failures.json` as a
// transient failure, as a request that ran out of time is retried.
it("records a stalled download in failures.json, as transient", async () => {
let attempts = 0;
const fetch: typeof globalThis.fetch = async (input, init) => {
if (downloadOf(input) === 200) {
attempts++;
return new Response(
new ReadableStream<Uint8Array>({
pull: () => new Promise<void>(() => {}),
}),
);
}
return server(input, init);
};
const dir = mkdtempSync(join(root, "backup-"));
const { code } = await backup(dir, {}, fetch, 50);
expect(code).toBe(1);
expect(attempts).toBe(UNATTENDED_RETRY_OPTIONS.attempts);
const failures = readJSON(join(dir, "failures.json"));
expect(failures.files["200"]).toMatchObject({
classification: "transient",
error: "download stalled: no bytes received for 50 ms",
});
});
// The name of Ente's API server does not resolve when quak fetches the ML
// data, after every original has been backed up. No file has ML data
// cached yet, so each goes into `failures.json` with the reason, as a
+249 -1
View File
@@ -1498,13 +1498,15 @@ describe("backup album folders", () => {
files: EnteFile[];
}
const libraryOf = (albums: Album[]): BackupLibrary => ({
// The original of the file `notFound`, if given, always fails to download.
const libraryOf = (albums: Album[], notFound?: number): BackupLibrary => ({
refresh: async () => {},
listCollections: () => albums.map((a) => a.collection),
listFiles: (id) =>
albums.find((a) => a.collection.id === id)?.files ?? [],
undecryptable: () => [],
original: async (fileID) => {
if (fileID === notFound) throw new Error("HTTP 404");
const path = join(root, `source-${fileID}`);
writeFileSync(path, `original ${fileID}`);
return { path };
@@ -1600,6 +1602,252 @@ describe("backup album folders", () => {
expect(albumID(outDir, "Trip (11) (12).json")).toBe(12);
});
// The next three tests end with the album "ok" (14), whose one file
// always fails to download, so each run has a failure to record in
// failures.json after the album folders are written.
const ok: Album = {
collection: collection(14, "ok"),
files: [file(5, 14, "e.jpg")],
};
// The folder of an album named "x.json" would be the JSON file of the
// album "x", and once ended every run there. Two runs, as on two nights,
// write the same tree, and the failed file's attempt count goes up.
it("adds the ID to an album whose folder would have another album's JSON name", async () => {
const outDir = join(root, "backup");
const lib = libraryOf(
[
{
collection: collection(10, "x"),
files: [file(1, 10, "a.jpg")],
},
{
collection: collection(11, "x.json"),
files: [file(2, 11, "b.jpg")],
},
{
collection: collection(12, "Trip"),
files: [file(3, 12, "c.jpg")],
},
{
collection: collection(13, "TRIP.json"),
files: [file(4, 13, "d.jpg")],
},
ok,
],
5,
);
await runBackup(lib, { downloadDirectory: outDir });
const result = await runBackup(lib, { downloadDirectory: outDir });
expect(result.failedAlbums).toEqual([]);
expect(tree(outDir)).toEqual([
"TRIP.json (13)/",
`TRIP.json (13)/d.jpg -> ${linkTo("4.jpg")}`,
"TRIP.json (13).json",
"Trip/",
`Trip/c.jpg -> ${linkTo("3.jpg")}`,
"Trip.json",
"ok/",
"ok.json",
"x/",
`x/a.jpg -> ${linkTo("1.jpg")}`,
"x.json",
"x.json (11)/",
`x.json (11)/b.jpg -> ${linkTo("2.jpg")}`,
"x.json (11).json",
]);
expect(albumID(outDir, "x.json")).toBe(10);
expect(albumID(outDir, "x.json (11).json")).toBe(11);
expect(albumID(outDir, "TRIP.json (13).json")).toBe(13);
expect(albumID(outDir, "ok.json")).toBe(14);
expect(result.failed).toBe(1);
expect(readLedger(outDir).files["5"]).toMatchObject({ attempts: 2 });
});
// A name over 255 bytes is refused as a file name, and an album folder's
// name leaves room for `.json` in its JSON's. "é" takes two bytes, and a
// name is cut between two characters.
it("cuts an album name and a title too long for a file name short and adds the ID", async () => {
const outDir = join(root, "backup");
const lib = libraryOf(
[
{
collection: collection(10, "L".repeat(300)),
files: [file(1, 10, `${"é".repeat(150)}.jpg`)],
},
ok,
],
5,
);
await runBackup(lib, { downloadDirectory: outDir });
const result = await runBackup(lib, { downloadDirectory: outDir });
const folder = `${"L".repeat(245)} (10)`;
const link = `${"é".repeat(123)} (1).jpg`;
expect(Buffer.byteLength(`${folder}.json`)).toBe(255);
expect(Buffer.byteLength(link)).toBe(254);
expect(result.failedAlbums).toEqual([]);
expect(tree(outDir)).toEqual([
`${folder}/`,
`${folder}/${link} -> ${linkTo("1.jpg")}`,
`${folder}.json`,
"ok/",
"ok.json",
]);
expect(albumID(outDir, `${folder}.json`)).toBe(10);
expect(albumID(outDir, "ok.json")).toBe(14);
expect(result.failed).toBe(1);
expect(readLedger(outDir).files["5"]).toMatchObject({ attempts: 2 });
});
// The user has put a file in collections/ with the name of the album
// Trip's folder, so the folder cannot be made.
it("goes on to the next album when an album's folder cannot be made, and lists it as failed", async () => {
const outDir = join(root, "backup");
const lib = libraryOf(
[
{
collection: collection(10, "Trip"),
files: [file(1, 10, "a.jpg")],
},
ok,
],
5,
);
mkdirSync(join(outDir, "collections"), { recursive: true });
writeFileSync(join(outDir, "collections", "Trip"), "mine");
const logged: string[] = [];
const result = await runBackup(lib, {
downloadDirectory: outDir,
onProgress: (message) => logged.push(message),
});
expect(result.failedAlbums).toEqual([10]);
expect(logged).toContainEqual(
expect.stringMatching(/^FAILED album Trip: EEXIST: /),
);
expect(tree(outDir)).toEqual(["Trip", "ok/", "ok.json"]);
expect(albumID(outDir, "ok.json")).toBe(14);
expect(existsSync(saved(outDir, "1.jpg"))).toBe(true);
expect(result.failed).toBe(1);
expect(readLedger(outDir).files["5"]).toMatchObject({ attempts: 1 });
});
// The extensions of these titles leave no room for the ID, so each is cut
// short and the name before the ID keeps only the bytes that cut leaves
// over. File 1's extension is 303 bytes and is cut to 251, which leaves
// none. File 2's is 271 bytes and is cut to 250, since "€" takes three
// bytes, which leaves one, for the "a". Neither extension is only letters
// and digits, so both originals are saved with `.bin`, a name that fits.
it("cuts the extension of a title short when it leaves no room for the ID", async () => {
const outDir = join(root, "backup");
const lib = libraryOf([
{
collection: collection(10, "Trip"),
files: [
file(1, 10, `a.${"L".repeat(300)}-x`),
file(2, 10, `a.${"€".repeat(90)}`),
],
},
]);
const result = await runBackup(lib, { downloadDirectory: outDir });
const link = ` (1).${"L".repeat(250)}`;
const link2 = `a (2).${"€".repeat(83)}`;
expect(Buffer.byteLength(link)).toBe(255);
expect(Buffer.byteLength(link2)).toBe(255);
expect(result.failed).toBe(0);
expect(tree(outDir)).toEqual([
"Trip/",
`Trip/${link} -> ${linkTo("1.bin")}`,
`Trip/${link2} -> ${linkTo("2.bin")}`,
"Trip.json",
]);
});
// Files 1 and 3 share a title, so each gets its ID before the
// extension. File 2's title is too long, and its extension leaves no
// room for the ID. Were the ID put at the end of file 2's title cut
// short, its link would have file 1's name, and the link made second
// would replace the first.
it("keeps names unique when a title's extension leaves no room for the ID", async () => {
const outDir = join(root, "backup");
const shared = `x.${"Q".repeat(245)} (2)`;
const lib = libraryOf([
{
collection: collection(10, "Trip"),
files: [
file(1, 10, shared),
file(2, 10, `x (1).${"Q".repeat(250)}-`),
file(3, 10, shared),
],
},
]);
const result = await runBackup(lib, { downloadDirectory: outDir });
expect(result.failed).toBe(0);
expect(tree(outDir)).toEqual([
"Trip/",
`Trip/ (2).${"Q".repeat(250)} -> ${linkTo("2.bin")}`,
`Trip/x (1).${"Q".repeat(245)} (2) -> ${linkTo("1.bin")}`,
`Trip/x (3).${"Q".repeat(245)} (2) -> ${linkTo("3.bin")}`,
"Trip.json",
]);
});
// A title of 255 bytes is the longest file name allowed, and an album
// name of 250 bytes the longest whose JSON name fits; both are kept as
// they are. A name one byte longer gets the ID and is cut short. "é"
// takes two bytes, and in both names the room left ends inside one, so
// the cut falls before it, one byte short of the room.
it("keeps a name at the byte limit and adds the ID to one a byte over it", async () => {
const outDir = join(root, "backup");
const album = "A".repeat(250);
const title = `${"t".repeat(251)}.jpg`;
const longAlbum = `${"é".repeat(125)}B`;
const longTitle = `${"é".repeat(126)}.jpg`;
const lib = libraryOf([
{
collection: collection(10, album),
files: [file(1, 10, title), file(2, 10, longTitle)],
},
{
collection: collection(11, longAlbum),
files: [file(3, 11, "c.jpg")],
},
]);
const result = await runBackup(lib, { downloadDirectory: outDir });
const folder = `${"é".repeat(122)} (11)`;
const link = `${"é".repeat(123)} (2).jpg`;
expect(Buffer.byteLength(`${album}.json`)).toBe(255);
expect(Buffer.byteLength(title)).toBe(255);
expect(Buffer.byteLength(longAlbum)).toBe(251);
expect(Buffer.byteLength(longTitle)).toBe(256);
expect(Buffer.byteLength(`${folder}.json`)).toBe(254);
expect(Buffer.byteLength(link)).toBe(254);
expect(result.failed).toBe(0);
expect(result.failedAlbums).toEqual([]);
expect(tree(outDir)).toEqual([
`${album}/`,
`${album}/${title} -> ${linkTo("1.jpg")}`,
`${album}/${link} -> ${linkTo("2.jpg")}`,
`${album}.json`,
`${folder}/`,
`${folder}/c.jpg -> ${linkTo("3.jpg")}`,
`${folder}.json`,
]);
expect(albumID(outDir, `${album}.json`)).toBe(10);
expect(albumID(outDir, `${folder}.json`)).toBe(11);
});
it("changes nothing on a second run over an unchanged account", async () => {
const outDir = join(root, "backup");
const lib = libraryOf([
+28 -1
View File
@@ -14,7 +14,11 @@
import { describe, expect, it } from "vitest";
import { safeExtension, sanitizeFileName } from "../../src/filename.js";
import {
safeExtension,
sanitizeFileName,
shortenToBytes,
} from "../../src/filename.js";
const FALLBACK = "file-42";
@@ -91,3 +95,26 @@ describe("safeExtension", () => {
expect(safeExtension("x.jpg\0")).toBe(".bin");
});
});
describe("shortenToBytes", () => {
it("returns a name that fits unchanged", () => {
expect(shortenToBytes("beach", 5)).toBe("beach");
expect(shortenToBytes("café", 5)).toBe("café");
expect(shortenToBytes("", 0)).toBe("");
});
it("cuts a name to the number of bytes given", () => {
expect(shortenToBytes("L".repeat(300), 255)).toBe("L".repeat(255));
});
// In UTF-8 "é" takes two bytes and an emoji four, which a JavaScript
// string holds as two halves.
it("cuts between two characters, never inside one", () => {
expect(shortenToBytes("ééé", 5)).toBe("éé");
expect(shortenToBytes("a\u{1F600}b", 4)).toBe("a");
expect(shortenToBytes("a\u{1F600}b", 5)).toBe("a\u{1F600}");
expect(Buffer.byteLength(shortenToBytes("é".repeat(200), 255))).toBe(
254,
);
});
});
+84
View File
@@ -77,6 +77,14 @@ const recordingSleep = (): {
const errnoError = (code: string, message = code): Error =>
Object.assign(new Error(message), { code });
/**
* An error shaped like what bun's `fetch` rejects with for a failure below
* HTTP: a plain `Error` with bun's own message, a `code`, an `errno` of 0, and
* no `cause`.
*/
const bunError = (code: string, message: string): Error =>
Object.assign(new Error(message), { code, errno: 0 });
// ---------------------------------------------------------------------------
// Classification
// ---------------------------------------------------------------------------
@@ -244,6 +252,82 @@ describe("isRetryable: transport failures", () => {
});
});
describe("isRetryable and isSafeToReplay: bun's fetch errors", () => {
// The binary `make install` builds runs quak under bun, whose `fetch`
// does not reject as Node's does. Each shape here was found by running
// that binary, built with bun 1.3.13, against listeners on 127.0.0.1 and
// a name under `.invalid`.
it("retries a refused connection, and does not replay it", () => {
// Bun gives the same error when the TLS handshake fails, and when a
// new connection it opened to resend a request is refused. That
// request may already have reached the server on a connection bun
// kept open from an earlier request.
const refused = bunError(
"ConnectionRefused",
"Unable to connect. Is the computer able to access the url?",
);
expect(isRetryable(refused)).toBe(true);
expect(isSafeToReplay(refused)).toBe(false);
});
it("retries a name that does not resolve, with either code bun gives, and does not replay it", () => {
// For the same name bun gave `ConnectionRefused` on some requests and
// `FailedToOpenSocket` on others. Either can also come from a new
// connection bun opened to resend a request, as above.
for (const unresolved of [
bunError(
"ConnectionRefused",
"Unable to connect. Is the computer able to access the url?",
),
bunError(
"FailedToOpenSocket",
"Was there a typo in the url or port?",
),
]) {
expect(isRetryable(unresolved)).toBe(true);
expect(isSafeToReplay(unresolved)).toBe(false);
}
});
it("retries a connection closed before the response ended, and does not replay it", () => {
// Bun gives Node's errno, `ECONNRESET`, for a reset and for a close,
// before the response or part-way through the body. The request may
// already have reached the server.
const reset = bunError(
"ECONNRESET",
"The socket connection was closed unexpectedly. For more information, pass `verbose: true` in the second argument to fetch()",
);
expect(isRetryable(reset)).toBe(true);
expect(isSafeToReplay(reset)).toBe(false);
});
it("retries a request that ran out of time, and does not replay it", () => {
// What bun's fetch rejects with when `AbortSignal.timeout()`, which
// bounds each JSON request, fires.
const timedOut = new DOMException(
"The operation timed out.",
"TimeoutError",
);
expect(isRetryable(timedOut)).toBe(true);
expect(isSafeToReplay(timedOut)).toBe(false);
});
it("does not retry a certificate bun rejects", () => {
// A certificate that fails verification fails the same way however
// often it is asked. Under Node it is retried only because every
// `TypeError` is.
expect(
isRetryable(
bunError(
"DEPTH_ZERO_SELF_SIGNED_CERT",
"self signed certificate",
),
),
).toBe(false);
});
});
describe("isRetryable: stream truncation versus corruption", () => {
it("retries a truncated stream", () => {
// Truncation is a transfer that stopped early. The bytes that did