Skip to content

Commit f24bfdd

Browse files
committed
Fix incomplete GGUF downloads, group sharded models, surface download errors
Three related bugs in the llama.cpp/GGUF local model flow: - Incomplete downloads: downloadGgufFile() wrote straight to the final .gguf name, so a network drop, crash, or force-quit mid-download left a truncated file indistinguishable from a real model — listModels() would offer it, and loading it failed with a confusing 'corrupt GGUF' error instead of the app just knowing it never finished. Now downloads go to a .gguf.part file and only get renamed to the final name after every byte is confirmed received; any leftover .part files from a session that never got to clean up after itself (crash/force-quit) are swept on next launch. - Sharded models (e.g. *-00001-of-00002.gguf) listed as two separate, confusing, and partially-broken entries — selecting the second shard on its own doesn't load. groupShardedModels() merges same-model shards into one entry keyed off the lowest part present, with a synthetic "(N parts)" label, and deleteModel() now removes every sibling shard instead of orphaning the rest. - A failed Hugging Face download was silently swallowed in the Settings UI — the progress bar just vanished with no toast, no error, nothing. Also fixes the Settings label bug from the previous commit's follow-up: LocalGgufModel now carries both name (real filename, used for load/delete) and label (display-only) so the grouped "(N parts)" text never gets sent back as if it were a real file name.
1 parent a10f67d commit f24bfdd

8 files changed

Lines changed: 209 additions & 9 deletions

File tree

app/src/huggingface.ts

Lines changed: 37 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,14 @@ export interface DownloadProgress {
4747
totalBytes: number | null;
4848
}
4949

50+
// Suffix for in-progress downloads. Writing directly to the final .gguf name
51+
// would let a truncated file — from a network drop, a crash, or the app
52+
// being force-quit mid-download, none of which run our error-path cleanup —
53+
// sit there indistinguishable from a real model, so listModels() would offer
54+
// it and loading it would fail with a confusing "corrupt GGUF" error instead
55+
// of the app just not knowing about it.
56+
export const PARTIAL_DOWNLOAD_SUFFIX = ".part";
57+
5058
export async function downloadGgufFile(
5159
modelId: string,
5260
filename: string,
@@ -66,7 +74,8 @@ export async function downloadGgufFile(
6674

6775
const totalBytes = Number(res.headers.get("content-length")) || null;
6876
let receivedBytes = 0;
69-
const writeStream = fs.createWriteStream(destPath);
77+
const partPath = destPath + PARTIAL_DOWNLOAD_SUFFIX;
78+
const writeStream = fs.createWriteStream(partPath);
7079
const reader = res.body.getReader();
7180

7281
try {
@@ -79,7 +88,33 @@ export async function downloadGgufFile(
7988
writeStream.write(value, (err) => (err ? reject(err) : resolve()));
8089
});
8190
}
82-
} finally {
91+
} catch (err) {
8392
writeStream.end();
93+
fs.rmSync(partPath, { force: true });
94+
throw err;
95+
}
96+
await new Promise<void>((resolve, reject) => {
97+
writeStream.once("error", reject);
98+
writeStream.end(() => resolve());
99+
});
100+
if (totalBytes !== null && receivedBytes !== totalBytes) {
101+
fs.rmSync(partPath, { force: true });
102+
throw new Error(`Download of "${filename}" was incomplete (got ${receivedBytes} of ${totalBytes} bytes).`);
103+
}
104+
fs.renameSync(partPath, destPath);
105+
}
106+
107+
// Leftover *.gguf.part files can only come from a download that never
108+
// finished (crash, force-quit, killed process) — there's no resume support,
109+
// so they're permanently unusable. Called once at startup rather than left
110+
// for the user to notice a phantom download stuck at some old percentage.
111+
export async function cleanupIncompleteDownloads(modelsDir: string): Promise<void> {
112+
const fs = await import("node:fs");
113+
const path = await import("node:path");
114+
if (!fs.existsSync(modelsDir)) return;
115+
for (const f of fs.readdirSync(modelsDir)) {
116+
if (f.toLowerCase().endsWith(PARTIAL_DOWNLOAD_SUFFIX)) {
117+
fs.rmSync(path.join(modelsDir, f), { force: true });
118+
}
84119
}
85120
}

app/src/llamacpp-manager.test.ts

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
import { describe, it, expect, beforeEach } from "vitest";
2+
import * as fs from "node:fs";
3+
import * as os from "node:os";
4+
import * as path from "node:path";
5+
import { groupShardedModels, listModels, deleteModel } from "./llamacpp-manager";
6+
7+
describe("groupShardedModels", () => {
8+
it("leaves a normal single-file model untouched", () => {
9+
const result = groupShardedModels([{ name: "llama-3.2-3b.gguf", path: "/m/llama-3.2-3b.gguf", sizeBytes: 100 }]);
10+
expect(result).toEqual([
11+
{ name: "llama-3.2-3b.gguf", label: "llama-3.2-3b.gguf", path: "/m/llama-3.2-3b.gguf", sizeBytes: 100 },
12+
]);
13+
});
14+
15+
it("merges a multi-part model into a single labeled entry using the first shard as the loadable path", () => {
16+
const result = groupShardedModels([
17+
{ name: "Qwen3-Coder-Next-Q6_K-00002-of-00002.gguf", path: "/m/part2.gguf", sizeBytes: 25_109_299 },
18+
{ name: "Qwen3-Coder-Next-Q6_K-00001-of-00002.gguf", path: "/m/part1.gguf", sizeBytes: 38_883_345 },
19+
]);
20+
expect(result).toEqual([
21+
{
22+
name: "Qwen3-Coder-Next-Q6_K-00001-of-00002.gguf",
23+
label: "Qwen3-Coder-Next-Q6_K.gguf (2 parts)",
24+
path: "/m/part1.gguf",
25+
sizeBytes: 25_109_299 + 38_883_345,
26+
},
27+
]);
28+
});
29+
30+
it("uses the lowest present part as the representative when part 1 is missing", () => {
31+
const result = groupShardedModels([
32+
{ name: "model-00002-of-00003.gguf", path: "/m/p2.gguf", sizeBytes: 10 },
33+
{ name: "model-00003-of-00003.gguf", path: "/m/p3.gguf", sizeBytes: 10 },
34+
]);
35+
expect(result[0].name).toBe("model-00002-of-00003.gguf");
36+
expect(result[0].label).toBe("model.gguf (2 parts)");
37+
});
38+
39+
it("keeps unrelated models and shard groups separate", () => {
40+
const result = groupShardedModels([
41+
{ name: "other.gguf", path: "/m/other.gguf", sizeBytes: 5 },
42+
{ name: "a-00001-of-00002.gguf", path: "/m/a1.gguf", sizeBytes: 1 },
43+
{ name: "a-00002-of-00002.gguf", path: "/m/a2.gguf", sizeBytes: 1 },
44+
]);
45+
expect(result).toHaveLength(2);
46+
expect(result.map((m) => m.name).sort()).toEqual(["a-00001-of-00002.gguf", "other.gguf"]);
47+
});
48+
});
49+
50+
describe("listModels", () => {
51+
let dir: string;
52+
53+
beforeEach(() => {
54+
dir = fs.mkdtempSync(path.join(os.tmpdir(), "llamacpp-models-test-"));
55+
});
56+
57+
it("returns an empty list when the directory doesn't exist", () => {
58+
expect(listModels(path.join(dir, "missing"))).toEqual([]);
59+
});
60+
61+
it("ignores incomplete .gguf.part downloads", () => {
62+
fs.writeFileSync(path.join(dir, "finished.gguf"), "x".repeat(10));
63+
fs.writeFileSync(path.join(dir, "still-downloading.gguf.part"), "x".repeat(3));
64+
const names = listModels(dir).map((m) => m.name);
65+
expect(names).toEqual(["finished.gguf"]);
66+
});
67+
});
68+
69+
describe("deleteModel", () => {
70+
let dir: string;
71+
72+
beforeEach(() => {
73+
dir = fs.mkdtempSync(path.join(os.tmpdir(), "llamacpp-delete-test-"));
74+
});
75+
76+
it("deletes every shard of a multi-part model, not just the representative one", async () => {
77+
fs.writeFileSync(path.join(dir, "big-00001-of-00002.gguf"), "x");
78+
fs.writeFileSync(path.join(dir, "big-00002-of-00002.gguf"), "x");
79+
await deleteModel(dir, "big-00001-of-00002.gguf");
80+
expect(fs.readdirSync(dir)).toEqual([]);
81+
});
82+
83+
it("rejects a path-traversal attempt", async () => {
84+
await expect(deleteModel(dir, "../evil.gguf")).rejects.toThrow(/Invalid model file name/);
85+
});
86+
});

app/src/llamacpp-manager.ts

Lines changed: 74 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -226,20 +226,76 @@ export async function dispose(): Promise<void> {
226226
}
227227

228228
export interface LocalGgufModel {
229+
// Real file name of the representative shard (part 1, or the lowest part
230+
// present) — this is what gets passed back to loadModel/deleteModel.
229231
name: string;
232+
// What to show in the UI. Same as `name` for a normal single-file model;
233+
// for a multi-part one it's a synthetic "(N parts)" label instead, since
234+
// showing the raw "-00001-of-00002.gguf" filename as if it were the
235+
// whole model's name is misleading.
236+
label: string;
230237
path: string;
231238
sizeBytes: number;
232239
}
233240

241+
// Matches Hugging Face's multi-part GGUF naming convention, e.g.
242+
// "Qwen3-Coder-Next-Q6_K-00001-of-00002.gguf". node-llama-cpp loads every
243+
// part automatically once given the path to part 1, so listing each shard
244+
// as its own separate model is both confusing (one weight file looks like
245+
// two different models) and wrong (selecting a non-first shard on its own
246+
// doesn't work) — group them into a single entry instead.
247+
const SHARD_PATTERN = /^(.*)-(\d+)-of-(\d+)(\.gguf)$/i;
248+
249+
interface RawGgufFile {
250+
name: string;
251+
path: string;
252+
sizeBytes: number;
253+
}
254+
255+
export function groupShardedModels(files: RawGgufFile[]): LocalGgufModel[] {
256+
const groups = new Map<string, { totalSize: number; parts: Map<number, RawGgufFile> }>();
257+
const standalone: LocalGgufModel[] = [];
258+
259+
for (const file of files) {
260+
const match = file.name.match(SHARD_PATTERN);
261+
if (!match) {
262+
standalone.push({ ...file, label: file.name });
263+
continue;
264+
}
265+
const [, base, partStr, , ext] = match;
266+
const key = `${base}${ext}`;
267+
const part = Number(partStr);
268+
const group = groups.get(key) ?? { totalSize: 0, parts: new Map() };
269+
group.totalSize += file.sizeBytes;
270+
group.parts.set(part, file);
271+
groups.set(key, group);
272+
}
273+
274+
const grouped: LocalGgufModel[] = [...groups.entries()].map(([key, group]) => {
275+
const lowestPart = Math.min(...group.parts.keys());
276+
const representative = group.parts.get(lowestPart)!;
277+
const partCount = group.parts.size;
278+
return {
279+
name: representative.name,
280+
label: partCount > 1 ? `${key} (${partCount} parts)` : representative.name,
281+
path: representative.path,
282+
sizeBytes: group.totalSize,
283+
};
284+
});
285+
286+
return [...standalone, ...grouped];
287+
}
288+
234289
export function listModels(modelsDir: string): LocalGgufModel[] {
235290
if (!fs.existsSync(modelsDir)) return [];
236-
return fs
291+
const files = fs
237292
.readdirSync(modelsDir)
238293
.filter((f) => f.toLowerCase().endsWith(".gguf"))
239294
.map((f) => {
240295
const full = path.join(modelsDir, f);
241296
return { name: f, path: full, sizeBytes: fs.statSync(full).size };
242297
});
298+
return groupShardedModels(files);
243299
}
244300

245301
// Model paths currently kept warm in modelCache — used for the
@@ -276,9 +332,26 @@ export async function deleteModel(modelsDir: string, name: string): Promise<void
276332
}
277333
await Promise.allSettled(modelsToDispose.map((model) => model.dispose()));
278334
fs.rmSync(target, { force: true });
335+
336+
// A multi-part model's sibling shards live under the same name pattern
337+
// in the same directory — leaving them behind would orphan otherwise-
338+
// unusable files that just sit there confusing the next listModels() call.
339+
const shardMatch = name.match(SHARD_PATTERN);
340+
if (shardMatch) {
341+
const [, base, , , ext] = shardMatch;
342+
const siblingPattern = new RegExp(`^${escapeRegExp(base)}-\\d+-of-\\d+${escapeRegExp(ext)}$`, "i");
343+
for (const f of fs.readdirSync(root)) {
344+
if (f !== name && siblingPattern.test(f)) fs.rmSync(path.join(root, f), { force: true });
345+
}
346+
}
347+
279348
scheduleIdleEviction();
280349
}
281350

351+
function escapeRegExp(s: string): string {
352+
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
353+
}
354+
282355
// Maps this app's provider-agnostic ChatMessage[] (system/user/assistant,
283356
// full history resent on every call — same shape every provider gets) onto
284357
// node-llama-cpp's ChatHistoryItem[] shape. Tool/function-calling isn't

app/src/main.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -767,6 +767,10 @@ app.whenReady().then(async () => {
767767
await ollama.start();
768768
llamacpp.setModelCacheLimit(settingsStore.getSettings().llamaCppMaxCachedModels ?? 2);
769769
await llamacpp.setGpuBackend(settingsStore.getSettings().llamaCppGpuBackend ?? "auto");
770+
// Leftover *.gguf.part files from a download that never finished last
771+
// session (crash, force-quit) — there's no resume support, so they can
772+
// only ever be dead weight; clear them before the model list is ever read.
773+
await huggingface.cleanupIncompleteDownloads(getLlamaCppModelsDir());
770774
setupAutoUpdater(() => mainWindow);
771775
void connectEnabledMcpServers();
772776
scheduler.init((provider, model, prompt) => completePrompt(provider as ProviderId, model, prompt));

frontend/src/pages/Chat.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1456,7 +1456,7 @@ export default function Chat() {
14561456
<SelectLabel>llama.cpp (local)</SelectLabel>
14571457
{llamaCppModels.map((m) => (
14581458
<SelectItem key={m.name} value={formatModelRef("llamacpp", m.name)}>
1459-
{m.name}
1459+
{m.label}
14601460
</SelectItem>
14611461
))}
14621462
</SelectGroup>
@@ -1476,7 +1476,7 @@ export default function Chat() {
14761476
<SelectLabel>ROCm (AMD)</SelectLabel>
14771477
{llamaCppModels.map((m) => (
14781478
<SelectItem key={m.name} value={formatModelRef("rocm", m.name)}>
1479-
{m.name}
1479+
{m.label}
14801480
</SelectItem>
14811481
))}
14821482
</SelectGroup>

frontend/src/pages/Compare.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,7 @@ export default function Compare() {
4242

4343
const candidates: CandidateModel[] = [
4444
...ollamaModels.map((m) => ({ ref: formatModelRef("ollama", m.name), label: `${m.name} (Ollama)` })),
45-
...llamaCppModels.map((m) => ({ ref: formatModelRef("llamacpp", m.name), label: `${m.name} (llama.cpp)` })),
45+
...llamaCppModels.map((m) => ({ ref: formatModelRef("llamacpp", m.name), label: `${m.label} (llama.cpp)` })),
4646
...OPENAI_MODELS.map((m) => ({ ref: formatModelRef("openai", m.id), label: m.label })),
4747
...ANTHROPIC_MODELS.map((m) => ({ ref: formatModelRef("anthropic", m.id), label: m.label })),
4848
...GEMINI_MODELS.map((m) => ({ ref: formatModelRef("gemini", m.id), label: m.label })),

frontend/src/pages/Settings.tsx

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -337,7 +337,8 @@ export default function Settings() {
337337
delete next[key];
338338
return next;
339339
});
340-
if (!res.error) window.api.llamacpp.listModels().then(setLlamaCppModels);
340+
if (res.error) toast.error(res.error);
341+
else window.api.llamacpp.listModels().then(setLlamaCppModels);
341342
}
342343

343344
async function deleteLlamaCppModel(name: string) {
@@ -1094,12 +1095,12 @@ export default function Settings() {
10941095
</div>
10951096
</SettingsRow>
10961097
{llamaCppModels.map((m) => (
1097-
<SettingsRow key={m.name} label={m.name} description={formatBytes(m.sizeBytes)}>
1098+
<SettingsRow key={m.name} label={m.label} description={formatBytes(m.sizeBytes)}>
10981099
<Button
10991100
size="icon"
11001101
variant="ghost"
11011102
onClick={() => deleteLlamaCppModel(m.name)}
1102-
aria-label={`Delete ${m.name}`}
1103+
aria-label={`Delete ${m.label}`}
11031104
>
11041105
<Trash2 className="text-destructive" />
11051106
</Button>

frontend/src/types/electron.d.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -350,6 +350,7 @@ export interface CustomProviderConfig {
350350

351351
export interface LocalGgufModel {
352352
name: string;
353+
label: string;
353354
path: string;
354355
sizeBytes: number;
355356
}

0 commit comments

Comments
 (0)