Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
99 changes: 90 additions & 9 deletions electron/stt/modelManager.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import { mkdir, mkdtemp, rm, stat, writeFile } from "node:fs/promises";
import { createHash } from "node:crypto";
import { existsSync } from "node:fs";
import { mkdir, mkdtemp, readFile, rm, stat, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
Expand All @@ -22,6 +24,9 @@ describe("modelManager", () => {
for (const f of STT_MODELS.whisper.files) {
expect(f.approximateBytes).toBeGreaterThan(0);
expect(f.url).toContain("huggingface.co");
// Pinned to an immutable commit: resolving through `main` would let a
// re-upload invalidate every cached model in the field at once.
expect(f.url).toMatch(/\/resolve\/[0-9a-f]{40}\//);
}
});

Expand All @@ -45,19 +50,95 @@ describe("modelManager", () => {
it("ensureModels succeeds when the file is already present (cache hit)", async () => {
const paths = modelPaths(dir);
await mkdir(path.dirname(paths.whisper), { recursive: true });
await writeFile(paths.whisper, "dummy-ggml");
const cached = Buffer.from("dummy-ggml");
await writeFile(paths.whisper, cached);
const originalSha = STT_MODELS.whisper.files[0].expectedSha256;
STT_MODELS.whisper.files[0].expectedSha256 = createHash("sha256").update(cached).digest("hex");
let fetches = 0;
const fetcher: typeof fetch = async () => {
fetches++;
return new Response("should not be reached", { status: 200 });
};
await ensureModels({
baseDir: dir,
only: ["whisper"],
fetcher,
onProgress: () => undefined,
});
expect(fetches).toBe(0);
try {
await ensureModels({
baseDir: dir,
only: ["whisper"],
fetcher,
onProgress: () => undefined,
});
expect(fetches).toBe(0);
} finally {
STT_MODELS.whisper.files[0].expectedSha256 = originalSha;
}
});

it("re-downloads a non-empty cached model when its checksum is wrong", async () => {
const paths = modelPaths(dir);
await mkdir(path.dirname(paths.whisper), { recursive: true });
await writeFile(paths.whisper, "corrupt-cache");
const replacement = Buffer.from("verified-ggml-weights");
const originalSha = STT_MODELS.whisper.files[0].expectedSha256;
STT_MODELS.whisper.files[0].expectedSha256 = createHash("sha256")
.update(replacement)
.digest("hex");
let fetches = 0;
const fetcher: typeof fetch = async () => {
fetches++;
return new Response(replacement, { status: 200 });
};

try {
await ensureModels({ baseDir: dir, only: ["whisper"], fetcher });
expect(fetches).toBe(1);
expect(await readFile(paths.whisper)).toEqual(replacement);
// The stale copy is displaced by the atomic rename, not quarantined
// beside it: a `.bad` sibling would strand 264 MB nothing ever reaps.
expect(existsSync(`${paths.whisper}.bad`)).toBe(false);
expect(existsSync(`${paths.whisper}.partial`)).toBe(false);
} finally {
STT_MODELS.whisper.files[0].expectedSha256 = originalSha;
}
});

it("never lets a mismatching download occupy the live model path", async () => {
const paths = modelPaths(dir);
const originalSha = STT_MODELS.whisper.files[0].expectedSha256;
STT_MODELS.whisper.files[0].expectedSha256 = createHash("sha256")
.update("the-weights-we-asked-for")
.digest("hex");
const served = Buffer.from("truncated-or-tampered-weights");
const fetcher: typeof fetch = async () => new Response(served, { status: 200 });

try {
await expect(ensureModels({ baseDir: dir, only: ["whisper"], fetcher })).rejects.toThrow(
/SHA-256 mismatch/,
);
expect(existsSync(paths.whisper)).toBe(false);
expect(existsSync(`${paths.whisper}.partial`)).toBe(false);
} finally {
STT_MODELS.whisper.files[0].expectedSha256 = originalSha;
}
});

it("keeps the cached model when the replacement download also mismatches", async () => {
const paths = modelPaths(dir);
await mkdir(path.dirname(paths.whisper), { recursive: true });
await writeFile(paths.whisper, "the-only-copy-the-user-has");
const originalSha = STT_MODELS.whisper.files[0].expectedSha256;
STT_MODELS.whisper.files[0].expectedSha256 = createHash("sha256")
.update("the-weights-we-asked-for")
.digest("hex");
const fetcher: typeof fetch = async () =>
new Response(Buffer.from("also-wrong"), { status: 200 });

try {
await expect(ensureModels({ baseDir: dir, only: ["whisper"], fetcher })).rejects.toThrow(
/SHA-256 mismatch/,
);
expect(await readFile(paths.whisper, "utf8")).toBe("the-only-copy-the-user-has");
} finally {
STT_MODELS.whisper.files[0].expectedSha256 = originalSha;
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
});

it("ensureModels downloads the missing GGML file with progress", async () => {
Expand Down
36 changes: 27 additions & 9 deletions electron/stt/modelManager.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { createHash } from "node:crypto";
import { createReadStream, existsSync } from "node:fs";
import { mkdir, rename, stat } from "node:fs/promises";
import { mkdir, rename, rm, stat } from "node:fs/promises";
import path from "node:path";
import { Readable } from "node:stream";
import { pipeline } from "node:stream/promises";
Expand Down Expand Up @@ -55,6 +55,12 @@ const MODEL_BASE = "https://huggingface.co";
// GitHub org was renamed.
const MODEL_REPO = "ggerganov/whisper.cpp";
const MODEL_FILE = "ggml-small-q8_0.bin";
// Pinned to a commit rather than `main` so `expectedSha256` is an invariant and
// not a bet: `main` is a mutable branch pointer, and a re-upload under it would
// now invalidate every cache in the field at once instead of merely breaking new
// installs. This revision was checked against HuggingFace's paths-info API — its
// LFS oid for MODEL_FILE is exactly the digest below.
const MODEL_REVISION = "5359861c739e955e79d9a303bcbc70fb988958b1";

export const STT_MODELS: Record<SttModelId, SttModelDescriptor> = {
whisper: {
Expand All @@ -63,7 +69,7 @@ export const STT_MODELS: Record<SttModelId, SttModelDescriptor> = {
files: [
{
name: MODEL_FILE,
url: `${MODEL_BASE}/${MODEL_REPO}/resolve/main/${MODEL_FILE}`,
url: `${MODEL_BASE}/${MODEL_REPO}/resolve/${MODEL_REVISION}/${MODEL_FILE}`,
expectedSha256: "49C8FB02B65E6049D5FA6C04F81F53B867B5EC9540406812C643F177317F779F",
approximateBytes: 264_000_000,
},
Expand Down Expand Up @@ -153,7 +159,9 @@ export interface DownloadOptions {
* Stream a model file to disk atomically (<filename>.partial → rename on
* success), optionally verify the SHA-256.
*
* If the file already exists and is non-empty, skips the download.
* If the file already exists, is non-empty, and matches the expected hash,
* skips the download; otherwise a replacement is fetched and the stale copy is
* only displaced once that replacement has itself been verified.
*/
async function ensureFile(
filePath: string,
Expand All @@ -164,7 +172,14 @@ async function ensureFile(
if (existsSync(filePath)) {
const s = await stat(filePath);
if (s.isFile() && s.size > 0) {
return;
if (!expectedSha256) return;
const actual = await sha256OfFile(filePath);
Comment thread
EtienneLescot marked this conversation as resolved.
if (actual.toLowerCase() === expectedSha256.toLowerCase()) return;
// Deliberately leave the stale file where it is. Moving it aside now
// would buy nothing — the rename at the end of this function is already
// atomic, so there is no window to close — while costing the user their
// only model if the replacement never lands (offline, HF 5xx, ENOSPC)
// and stranding 264 MB that nothing ever cleans up.
}
}

Expand All @@ -182,17 +197,22 @@ async function ensureFile(
});
const { createWriteStream } = await import("node:fs");
await pipeline(source, createWriteStream(tmp));
await rename(tmp, filePath);

if (expectedSha256) {
const actual = await sha256OfFile(filePath);
const actual = await sha256OfFile(tmp);
if (actual.toLowerCase() !== expectedSha256.toLowerCase()) {
await rename(filePath, `${filePath}.bad`).catch(() => undefined);
// Drop the bad download and keep whatever was already on disk: when both
// copies mismatch, the bytes the user has been running are exactly the
// ones worth diagnosing. The cleanup is guarded because a Windows AV
// scanner still holding the handle raises EPERM/EBUSY, and that bare
// errno would escape in place of the mismatch message below.
await rm(tmp, { force: true }).catch(() => undefined);
throw new Error(
`SHA-256 mismatch for ${path.basename(filePath)}: expected ${expectedSha256}, got ${actual}`,
);
}
}
await rename(tmp, filePath);
}

export interface EnsureModelsOptions {
Expand All @@ -217,8 +237,6 @@ export async function ensureModels(opts: EnsureModelsOptions): Promise<void> {
}));

for (const { id, descriptor, filePath } of targets) {
if (await areModelsPresent(opts.baseDir)) continue;

await mkdir(path.dirname(filePath), { recursive: true });

const file = descriptor.files[0];
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -234,9 +234,20 @@ The single shipped artifact is `ggml-small-q8_0.bin` from
`ggerganov/whisper.cpp` on HuggingFace: Whisper `small`, multilingual (~99
languages), q8_0 quantised, ~264 MB. Precision is baked into the GGML file —
there is no runtime `--int8` flag. `electron/stt/modelManager.ts` downloads
the file once into the user-data cache, verifies its SHA-256, and writes it
through an atomic `.partial` rename, so a half-downloaded file can never be
picked up as a usable model.
the file once into the user-data cache and writes it through an atomic
`.partial` rename, so a half-downloaded file can never be picked up as a
usable model. The SHA-256 is checked on the cached copy too, not only on a
fresh download, so a model corrupted after the fact is re-fetched rather than
handed to whisper.cpp. A cached copy that fails the check is left alone until
a verified replacement has landed — the atomic rename displaces it — so a
failed re-download never leaves a user with no model at all.

The download URL resolves through an immutable commit revision rather than
`resolve/main`. Because the cache is now verified on every start, a re-upload
under the mutable branch pointer would invalidate every installed cache at
once instead of merely breaking new installs; pinning makes the recorded
digest an invariant. Bumping the model therefore means bumping the revision
and the digest together.

> The HuggingFace identifier is intentionally `ggerganov/whisper.cpp`,
> **not** `ggml-org/whisper.cpp`. The latter matches the GitHub org the
Expand Down
Loading