Compare commits

..
1 Commits
Author SHA1 Message Date
sneak 307f1713c8 Fix two intermittently failing library tests (closes #90)
check / check (push) Successful in 28s
Library.close() now returns a promise that resolves once an in-flight
refresh, including its cache write, has finished. A refresh changes RAM
before it writes the cache file, so the interval test could see the new
state, close, and remove the directory while the write was still running.
The library tests now await close().

The precache test waited for its stub source to be called, but the cache
records a file only after checking it on disk, so status() could lag. It
now waits for both fills to report "done".

Model: opus-5-5
2026-09-23 01:26:12 +00:00
14 changed files with 49 additions and 195 deletions
+3 -3
View File
@@ -564,9 +564,9 @@ and pass it. The three pools default to 10 / 5 / 25 (see Request pools below).
refresh/ML times and errors, originals usage and effective limit, precache refresh/ML times and errors, originals usage and effective limit, precache
progress, and `closed`). `lib.close()` stops the background timer; it is progress, and `closed`). `lib.close()` stops the background timer; it is
idempotent, and an in-flight refresh is left to finish. The promise it returns idempotent, and an in-flight refresh is left to finish. The promise it returns
resolves once that refresh (including its cache write), the ML data fetch and resolves once that refresh, including its cache write, has finished, so the
the precache fetches already running have all finished, so the cache directory cache directory can then be removed; precache fetches already running are not
can then be removed. waited for.
### Default reads vs. fresh reads ### Default reads vs. fresh reads
+5 -11
View File
@@ -20,17 +20,11 @@ Tag v1.0.0.
- 2026-09-23: Fixed two intermittently failing library tests (issue 90). - 2026-09-23: Fixed two intermittently failing library tests (issue 90).
`Library.close()` now returns a promise that resolves once an in-flight `Library.close()` now returns a promise that resolves once an in-flight
refresh (including its cache write), the ML data fetch and running precache refresh, including its cache write, has finished; the library tests await it,
sweeps have finished; the library tests await it, so `afterEach` no longer so `afterEach` no longer removes the cache directory while a refresh is still
removes the cache directory while something is still writing into it. The writing into it. The precache test waits for both fills to report "done"
precache test waits for both fills to report "done" instead of for its stub instead of for its stub source to be called, which happened before the cache
source to be called, which happened before the cache recorded the file. recorded the file.
- 2026-09-23: Pinned three guards reviewers found untested (issue 89). The
download idle deadline's timer is unref'd, so it can never keep the process
alive, and a test checks no timer is left after a download completes or fails.
A test covers the rejection of `#` in a request path. The EXIF scan compares
the `Exif` header only in an APP1 segment of length 8 or more, so it never
reads the next segment's bytes, with a test for a short one.
- 2026-09-23: Made the download deadline an idle deadline (issue 24). - 2026-09-23: Made the download deadline an idle deadline (issue 24).
`downloadTimeoutMs` now aborts a file or thumbnail download only after no `downloadTimeoutMs` now aborts a file or thumbnail download only after no
bytes have arrived for that long, default 60 seconds, instead of bounding the bytes have arrived for that long, default 60 seconds, instead of bounding the
+2 -3
View File
@@ -49,8 +49,8 @@ export interface StreamOptions {
// An abort signal that fires once `ms` pass without a call to `restart`. It // An abort signal that fires once `ms` pass without a call to `restart`. It
// aborts with a `TimeoutError`, the same reason `AbortSignal.timeout()` gives, // aborts with a `TimeoutError`, the same reason `AbortSignal.timeout()` gives,
// so the retry classifier treats an idle download exactly as it treats any // so the retry classifier treats an idle download exactly as it treats any
// other deadline. `stop` must be called when the download ends. The timer is // other deadline. `stop` must be called when the download ends, or the timer
// unref'd, so even one left running never keeps the process alive. // keeps the process alive until it fires.
const idleDeadline = (ms: number) => { const idleDeadline = (ms: number) => {
const controller = new AbortController(); const controller = new AbortController();
let timer: ReturnType<typeof setTimeout> | undefined; let timer: ReturnType<typeof setTimeout> | undefined;
@@ -65,7 +65,6 @@ const idleDeadline = (ms: number) => {
), ),
); );
}, ms); }, ms);
timer.unref();
}; };
restart(); restart();
return { signal: controller.signal, restart, stop }; return { signal: controller.signal, restart, stop };
+12 -14
View File
@@ -266,9 +266,8 @@ export class Library {
// that, a fresh read propagates it. // that, a fresh read propagates it.
private cycle?: Promise<void>; private cycle?: Promise<void>;
// Guards the ML fetch pass so a slow backfill never runs twice at once; a // Guards the ML fetch pass so a slow backfill never runs twice at once; a
// refresh whose pass is still running kicks nothing new. Holds the running // refresh whose pass is still running kicks nothing new.
// pass, so `close()` can wait for it. private mlFetching = false;
private mlFetch?: Promise<void>;
private closed = false; private closed = false;
private lastRefreshAt?: number; private lastRefreshAt?: number;
private lastError?: string; private lastError?: string;
@@ -562,20 +561,18 @@ export class Library {
// Stop the background timer. Idempotent. An in-flight refresh is left to // Stop the background timer. Idempotent. An in-flight refresh is left to
// finish; it will not schedule another cycle once closed. The returned // finish; it will not schedule another cycle once closed. The returned
// promise resolves once that refresh (including its cache write), the ML // promise resolves once that refresh, including its cache write, has
// fetch pass and the precache fetches already running have all finished, // finished, so a caller can then remove the cache directory. A refresh
// so a caller can then remove the cache directory. A refresh failure is // failure is reported through `status()`, not thrown here. Precache
// reported through `status()`, not thrown here. // fetches already running are not waited for.
async close(): Promise<void> { async close(): Promise<void> {
this.closed = true; this.closed = true;
const precacheClosed = this.precache?.close(); this.precache?.close();
if (this.timer !== undefined) { if (this.timer !== undefined) {
clearTimeout(this.timer); clearTimeout(this.timer);
this.timer = undefined; this.timer = undefined;
} }
await this.cycle?.catch(() => {}); await this.cycle?.catch(() => {});
await this.mlFetch;
await precacheClosed;
} }
private scheduleNext(): void { private scheduleNext(): void {
@@ -639,9 +636,7 @@ export class Library {
// outside the refresh's success/failure so a fetch or disk problem // outside the refresh's success/failure so a fetch or disk problem
// there never marks the metadata refresh failed, and it is not // there never marks the metadata refresh failed, and it is not
// awaited so it never stalls the refresh interval. // awaited so it never stalls the refresh interval.
this.mlFetch ??= this.runMLFetch().finally(() => { void this.runMLFetch();
this.mlFetch = undefined;
});
} catch (err) { } catch (err) {
const error = err instanceof Error ? err.message : String(err); const error = err instanceof Error ? err.message : String(err);
this.lastError = error; this.lastError = error;
@@ -755,12 +750,13 @@ export class Library {
// Bind so the call keeps the client as its receiver when invoked // Bind so the call keeps the client as its receiver when invoked
// through the pool below. // through the pool below.
const fetchMLData = this.client.fetchMLData?.bind(this.client); const fetchMLData = this.client.fetchMLData?.bind(this.client);
if (!mldata || !fetchMLData || this.closed) return; if (!mldata || !fetchMLData || this.closed || this.mlFetching) return;
const files = this.uniqueFiles(); const files = this.uniqueFiles();
const needed = mldata.neededFor(files); const needed = mldata.neededFor(files);
if (needed.length === 0) return; if (needed.length === 0) return;
this.mlFetching = true;
this.emit({ operation: "fetchMLData", status: "started" }); this.emit({ operation: "fetchMLData", status: "started" });
try { try {
const fileKeys = new Map<number, Uint8Array>(); const fileKeys = new Map<number, Uint8Array>();
@@ -793,6 +789,8 @@ export class Library {
const error = err instanceof Error ? err.message : String(err); const error = err instanceof Error ? err.message : String(err);
this.lastMLError = error; this.lastMLError = error;
this.emit({ operation: "fetchMLData", status: "failed", error }); this.emit({ operation: "fetchMLData", status: "failed", error });
} finally {
this.mlFetching = false;
} }
} }
+12 -16
View File
@@ -92,10 +92,9 @@ export class Precache {
private pinned = new Set<number>(); private pinned = new Set<number>();
// A sweep runs at most once per fill at a time; a re-kick while one runs is // A sweep runs at most once per fill at a time; a re-kick while one runs is
// a no-op, and the next refresh re-kicks after it finishes. Each holds the // a no-op, and the next refresh re-kicks after it finishes.
// running sweep, so `close()` can wait for it. private thumbRunning = false;
private thumbSweep?: Promise<void>; private originalsRunning = false;
private originalsSweep?: Promise<void>;
private readonly aborter = new AbortController(); private readonly aborter = new AbortController();
private closed = false; private closed = false;
@@ -192,19 +191,15 @@ export class Precache {
} }
// Stop the fills. In-flight fetches are left to settle; queued ones drop. // Stop the fills. In-flight fetches are left to settle; queued ones drop.
// Resolves once both sweeps have finished, so nothing is still writing. close(): void {
async close(): Promise<void> {
this.closed = true; this.closed = true;
this.aborter.abort(); this.aborter.abort();
await Promise.all([
this.thumbSweep?.catch(() => {}),
this.originalsSweep?.catch(() => {}),
]);
} }
private kickThumbnails(): void { private kickThumbnails(): void {
if (this.thumbSweep) return; if (this.thumbRunning) return;
this.thumbSweep = this.sweep( this.thumbRunning = true;
void this.sweep(
"precacheThumbnails", "precacheThumbnails",
() => this.thumbOrder, () => this.thumbOrder,
(id) => this.cache!.pathsFor(id).thumbnailPath !== undefined, (id) => this.cache!.pathsFor(id).thumbnailPath !== undefined,
@@ -216,13 +211,14 @@ export class Precache {
signal: this.aborter.signal, signal: this.aborter.signal,
}), }),
).finally(() => { ).finally(() => {
this.thumbSweep = undefined; this.thumbRunning = false;
}); });
} }
private kickOriginals(): void { private kickOriginals(): void {
if (this.originalsSweep) return; if (this.originalsRunning) return;
this.originalsSweep = this.sweep( this.originalsRunning = true;
void this.sweep(
"precacheOriginals", "precacheOriginals",
() => this.originalsOrder, () => this.originalsOrder,
(id) => this.cache!.pathsFor(id).originalPath !== undefined, (id) => this.cache!.pathsFor(id).originalPath !== undefined,
@@ -233,7 +229,7 @@ export class Precache {
signal: this.aborter.signal, signal: this.aborter.signal,
}), }),
).finally(() => { ).finally(() => {
this.originalsSweep = undefined; this.originalsRunning = false;
}); });
} }
+1 -4
View File
@@ -45,11 +45,8 @@ export const extractExifFromJpeg = (
error: `segment length ${len} at byte ${offset} runs past the end of the file`, error: `segment length ${len} at byte ${offset} runs past the end of the file`,
}; };
if (marker === 0xe1) { if (marker === 0xe1) {
// APP1 — check for "Exif\0\0" header. A length under 8 cannot hold // APP1 — check for "Exif\0\0" header
// the six-byte header, so the segment is not EXIF; below 6 the
// bytes compared would also lie past the segment.
if ( if (
len >= 8 &&
buf[offset + 4] === 0x45 && buf[offset + 4] === 0x45 &&
buf[offset + 5] === 0x78 && buf[offset + 5] === 0x78 &&
buf[offset + 6] === 0x69 && buf[offset + 6] === 0x69 &&
-56
View File
@@ -474,16 +474,6 @@ describe("ApiClient request URLs", () => {
); );
expect(calls).toHaveLength(0); expect(calls).toHaveLength(0);
}); });
it("rejects a path that carries a fragment", async () => {
const { fetch, calls } = recordingFetch();
const client = new ApiClient({ fetch });
await expect(client.getJSON("/diff#top")).rejects.toThrow(
/must not contain "\?" or "#"/,
);
expect(calls).toHaveLength(0);
});
}); });
describe("ApiError", () => { describe("ApiError", () => {
@@ -990,52 +980,6 @@ describe("ApiClient timeouts", () => {
} }
expect(joined).toEqual(payload); expect(joined).toEqual(payload);
}); });
it("leaves no timer pending after a download completes or fails", async () => {
vi.useFakeTimers();
try {
const { fetch } = scriptedFetch(
streamResponse(new Uint8Array([1, 2, 3])),
textResponse("gone", 404),
);
const client = new ApiClient({
fetch,
retry: { ...noWait, attempts: 1 },
});
expect(await readAll(await client.getFileStream(1))).toBe(3);
expect(vi.getTimerCount()).toBe(0);
await expect(client.getFileStream(2)).rejects.toBeInstanceOf(
ApiError,
);
expect(vi.getTimerCount()).toBe(0);
} finally {
vi.useRealTimers();
}
});
it("never lets the download timer keep the process alive", async () => {
const spy = vi.spyOn(globalThis, "setTimeout");
try {
const { fetch } = scriptedFetch(
streamResponse(new Uint8Array([1])),
);
const client = new ApiClient({
fetch,
downloadTimeoutMs: 12_345,
retry: noWait,
});
const stream = await client.getFileStream(1);
const i = spy.mock.calls.findIndex((call) => call[1] === 12_345);
const timer = spy.mock.results[i]!.value as NodeJS.Timeout;
expect(timer.hasRef()).toBe(false);
await stream.cancel();
} finally {
spy.mockRestore();
}
});
}); });
describe("ApiClient error typing", () => { describe("ApiClient error typing", () => {
-14
View File
@@ -51,20 +51,6 @@ describe("extractExifFromJpeg", () => {
expect(extractExifFromJpeg(bytes(SOI, app0, SOS))).toEqual({}); expect(extractExifFromJpeg(bytes(SOI, app0, SOS))).toEqual({});
}); });
it("ignores an APP1 segment too short to hold the Exif header", () => {
// A length under 8 cannot hold the six-byte "Exif\0\0" header, so the
// segment is not EXIF. This one has length 7 and holds only "Exif\0",
// which the old code, lacking the length check, returned as EXIF.
const short = app1(EXIF_HEADER.slice(0, 5));
expect(extractExifFromJpeg(bytes(SOI, short, SOS))).toEqual({});
});
it("accepts an APP1 segment of length 8 holding just the Exif header", () => {
const scan = extractExifFromJpeg(bytes(SOI, app1(EXIF_HEADER), SOS));
expect(scan.error).toBeUndefined();
expect([...scan.exif!]).toEqual(EXIF_HEADER);
});
it("reports a JPEG truncated inside a segment header", () => { it("reports a JPEG truncated inside a segment header", () => {
const scan = extractExifFromJpeg(bytes(SOI, [0xff, 0xe1, 0x00])); const scan = extractExifFromJpeg(bytes(SOI, [0xff, 0xe1, 0x00]));
expect(scan.exif).toBeUndefined(); expect(scan.exif).toBeUndefined();
+3 -3
View File
@@ -116,7 +116,7 @@ describe("Library content wiring", () => {
expect(lib.photos.byID({ fileID: 1 })!.record().thumbnailPath).toBe( expect(lib.photos.byID({ fileID: 1 })!.record().thumbnailPath).toBe(
result.path, result.path,
); );
await lib.close(); lib.close();
}); });
it("drives thumbnails.ensure through the cache", async () => { it("drives thumbnails.ensure through the cache", async () => {
@@ -139,7 +139,7 @@ describe("Library content wiring", () => {
expect(results).toEqual([ expect(results).toEqual([
{ fileID: 1, path: join(root, "cache", "thumbnails", "1.jpg") }, { fileID: 1, path: join(root, "cache", "thumbnails", "1.jpg") },
]); ]);
await lib.close(); lib.close();
}); });
it("throws from content methods when opened without a content source", async () => { it("throws from content methods when opened without a content source", async () => {
@@ -155,6 +155,6 @@ describe("Library content wiring", () => {
await expect( await expect(
lib.thumbnails.ensure({ fileIDs: [1], priority: "visible" }), lib.thumbnails.ensure({ fileIDs: [1], priority: "visible" }),
).rejects.toThrow(/content cache/i); ).rejects.toThrow(/content cache/i);
await lib.close(); lib.close();
}); });
}); });
+3 -3
View File
@@ -179,7 +179,7 @@ describe("Library.fresh", () => {
// And the change is now live for the default namespaces too. // And the change is now live for the default namespaces too.
expect(lib.photos.byID({ fileID: 1002 })?.fileID).toBe(1002); expect(lib.photos.byID({ fileID: 1002 })?.fileID).toBe(1002);
} finally { } finally {
await lib.close(); lib.close();
} }
}); });
@@ -228,7 +228,7 @@ describe("Library.fresh", () => {
expect(reads.photos.byID({ fileID: 1002 })?.fileID).toBe(1002); expect(reads.photos.byID({ fileID: 1002 })?.fileID).toBe(1002);
} }
} finally { } finally {
await lib.close(); lib.close();
} }
}); });
@@ -266,7 +266,7 @@ describe("Library.fresh", () => {
); );
expect(lib.status().lastError).toBeUndefined(); expect(lib.status().lastError).toBeUndefined();
} finally { } finally {
await lib.close(); lib.close();
} }
}); });
}); });
+1 -61
View File
@@ -17,8 +17,7 @@
* failure surfaces via `onProgress` ("failed") and `status()`, and a later * failure surfaces via `onProgress` ("failed") and `status()`, and a later
* success clears the error. `open()` itself resolves even when the first * success clears the error. `open()` itself resolves even when the first
* refresh fails (offline start from cache). * refresh fails (offline start from cache).
* 5. `close()` stops the timer and is idempotent, and its promise resolves * 5. `close()` stops the timer and is idempotent.
* only once an in-flight refresh has written the cache file.
* 6. `cacheDirectory` defaults to the env-paths cache dir plus the user id. * 6. `cacheDirectory` defaults to the env-paths cache dir plus the user id.
* 7. `open()` branches on the cache: an empty cache awaits the first refresh * 7. `open()` branches on the cache: an empty cache awaits the first refresh
* (it has nothing to serve yet); an existing cache serves its copy at once * (it has nothing to serve yet); an existing cache serves its copy at once
@@ -656,65 +655,6 @@ describe("Library.open and background refresh", () => {
expect(client.collectionsSinceTimes.length).toBe(callsAfterOpen); expect(client.collectionsSinceTimes.length).toBe(callsAfterOpen);
}); });
it("close() resolves only after an in-flight refresh has written the cache", async () => {
const path = join(cacheDirectory, "metadata.json");
const seed = await MetadataStore.load(path);
seed.userID = USER_ID;
seed.collectionsSinceTime = 500;
seed.putCollection(collection(1, 400));
seed.putFile(file(1001, 1, 400));
await seed.save();
const client = new MockClient();
client.collectionsQueue.push({
collections: [collection(1, 600)],
deleted: [],
cursor: 600,
});
client.filesFor(1, {
files: [file(1002, 1, 600)],
deleted: [],
cursor: 600,
});
// Hold the refresh's cache write until the test releases it.
const realSave = MetadataStore.prototype.save;
let releaseSave: () => void = () => {};
const saveHeld = new Promise<void>((resolve) => {
releaseSave = resolve;
});
const saveSpy = vi
.spyOn(MetadataStore.prototype, "save")
.mockImplementation(async function (this: MetadataStore) {
await saveHeld;
return realSave.call(this);
});
const lib = await Library.open({ client, cacheDirectory });
try {
await vi.waitFor(() => expect(saveSpy).toHaveBeenCalled(), {
timeout: 2000,
interval: 5,
});
let closed = false;
const closing = lib.close().then(() => {
closed = true;
});
await new Promise((r) => setTimeout(r, 50));
expect(closed).toBe(false);
releaseSave();
await closing;
const reloaded = await MetadataStore.load(path);
expect(reloaded.getFile(1, 1002)?.id).toBe(1002);
} finally {
releaseSave();
await lib.close();
saveSpy.mockRestore();
}
});
it("defaults cacheDirectory to the env-paths cache dir plus user id", async () => { it("defaults cacheDirectory to the env-paths cache dir plus user id", async () => {
const xdg = join(dir, "xdg-cache"); const xdg = join(dir, "xdg-cache");
const prev = process.env.XDG_CACHE_HOME; const prev = process.env.XDG_CACHE_HOME;
+2 -2
View File
@@ -374,7 +374,7 @@ describe("Library ML-data fetch on refresh", () => {
await new Promise((r) => setTimeout(r, FAST_INTERVAL * 1000 * 5)); await new Promise((r) => setTimeout(r, FAST_INTERVAL * 1000 * 5));
expect(client.mlFetchCalls.length).toBe(callsAfterFirst); expect(client.mlFetchCalls.length).toBe(callsAfterFirst);
} finally { } finally {
await lib.close(); lib.close();
} }
}); });
@@ -443,7 +443,7 @@ describe("Library ML-data fetch on refresh", () => {
expect(client.mlFetchCalls.length).toBeGreaterThan(callsBefore); expect(client.mlFetchCalls.length).toBeGreaterThan(callsBefore);
expect(client.mlFetchCalls.flat()).toContain(1001); expect(client.mlFetchCalls.flat()).toContain(1001);
} finally { } finally {
await lib.close(); lib.close();
} }
}); });
}); });
+1 -1
View File
@@ -531,7 +531,7 @@ describe("Library exposes the read surface over its live store", () => {
expect(client.collectionsCalls).toBe(collectionsBefore); expect(client.collectionsCalls).toBe(collectionsBefore);
expect(client.filesCalls).toBe(filesBefore); expect(client.filesCalls).toBe(filesBefore);
} finally { } finally {
await lib.close(); lib.close();
} }
}); });
}); });
+4 -4
View File
@@ -159,7 +159,7 @@ describe("Library.snapshot and Library.subscribe", () => {
for (const p of snap.photos) expect("key" in p).toBe(false); for (const p of snap.photos) expect("key" in p).toBe(false);
for (const a of snap.albums) expect("key" in a).toBe(false); for (const a of snap.albums) expect("key" in a).toBe(false);
} finally { } finally {
await lib.close(); lib.close();
} }
}); });
@@ -219,7 +219,7 @@ describe("Library.snapshot and Library.subscribe", () => {
expect(change.refreshedAt).toBeGreaterThan(0); expect(change.refreshedAt).toBeGreaterThan(0);
} finally { } finally {
unsubscribe(); unsubscribe();
await lib.close(); lib.close();
} }
}); });
@@ -251,7 +251,7 @@ describe("Library.snapshot and Library.subscribe", () => {
expect(changes).toEqual([]); expect(changes).toEqual([]);
} finally { } finally {
unsubscribe(); unsubscribe();
await lib.close(); lib.close();
} }
}); });
@@ -297,7 +297,7 @@ describe("Library.snapshot and Library.subscribe", () => {
); );
expect(changes).toEqual([]); expect(changes).toEqual([]);
} finally { } finally {
await lib.close(); lib.close();
} }
}); });
}); });