Skip to content

Commit ba7bf2e

Browse files
artokunclaude
andauthored
fix(1370): stat the partial instead of asserting it (#1392)
* wip(1370): claim the unverified resumable-partial claim Refs #1370 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(#1370): stat the partial instead of asserting it `download_model action:"status"` told users: cancelled — the partial was left on disk and can be resumed by re-issuing the download (it picks up where it left off) Nothing ever checked. The string was selected from `status === "cancelled"` plus two flags; no code stat'd the file. The reporter paused a 33 GB download BECAUSE of that sentence, scanned their whole install tree, found no partial of any kind, and restarted from zero. The message was not stale. It was never an observation. What makes it worse is that the neighbouring branches were already careful. A ComfyUI-Manager dispatch correctly says there is NO local partial. A reclaimed-dead writer correctly says one MAY exist and was left untouched. `afterCancelAdvice("local")` correctly hedges with "resumes any .partial the dead writer left, or restarts cleanly". Only the ordinary cancel stated a fact about the filesystem without consulting it — surrounded by code that knew better. Both cancelled branches now report what is on disk: the partial's SIZE when there is one, so "resumable" is something the user can weigh against restarting, or its absence, so they can decide before spending the bandwidth rather than after. A cancel that leaves nothing is not an error; telling someone it left something is. SEVERAL CANDIDATE NAMES, not one. The staged file is `.<basename>.partial` in the download cache, and a cancelled job knows its destination by routes of differing reliability — `filename`, the landed path, the destination key, the URL's last segment. A few stats are cheap, and guessing one wrong would reintroduce the same false claim inverted: reporting "nothing to resume" over a 30 GB partial that is sitting right there. A zero-byte partial reports as absent, because resuming from it saves nothing and calling it resumable is the original bug in miniature. Only rows that could HAVE a local partial are checked — a Manager dispatch never writes one and already says so. DELIBERATELY NOT IN SCOPE: why the reporter's bytes were missing. Candidates include a Manager-side dispatch whose `via_manager` flag was stripped (#1197) and local downloads being routed through Manager at all (#1374). Those are separate issues, and this message has to be honest under every one of them rather than depending on which is true. Mutation-tested: counting a zero-byte file as resumable, and dropping the basename extraction, each kill a test. Fixes #1370 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(#1370): derive the partial path the way the WRITER does, not the way it sounds Caught while writing my own review prompt, before anyone else saw it. The fix I had just committed was wrong in the same shape as the bug it fixes, pointing the other way. I looked for `.<destination filename>.partial` in the cache dir, because that is what "the partial for this download" sounds like. The writer stages under `.<sha256(cacheIdentity)[0:32]><ext>.partial` — keyed by the CACHE identity (cachePathForUrl), not the destination. So the lookup would have missed every real partial and told users "NO partial was found on disk" for downloads that had one. That is worse than the original defect. The old message at least erred toward "your bytes are safe"; mine would have told someone with a 30 GB resumable partial to start over. MY TESTS COULD NOT HAVE CAUGHT IT, because the fixtures created files under the name I was searching for. They encoded my belief about the naming rather than the naming — the third time that exact shape has cost me today, after an index-based removeWidget double and an empty-graph object_info fixture. The fixtures now build their files from `stagedPartialPathForUrl`, the same function production uses, so the two cannot disagree; one test pins that the staged name is hashed and does NOT contain the destination filename. SCOPE, stated in the message rather than papered over: cacheIdentity folds in representation-affecting request headers and cloud credentials, which a job record deliberately does not keep. So this reproduces the staged path exactly for an unauthenticated public download — the reporter's case, and the common one — and cannot for an authenticated variant. A miss is therefore reported as "none found under this URL's staged name", explicitly not as "none exists". Having just shipped one over-claim I am not going to hide a smaller one. Also drops the multi-candidate search: with the path derived correctly there is exactly one place to look, and trying several names could only ever match a DIFFERENT download's partial and report its bytes as resumable. Mutation-tested: reverting to the filename-based guess kills a test. Refs #1370 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(#1370): the staged partial is HIDDEN, and there is now one definition of its path Codex round 2, and this is the second time I got the same path wrong. Round 1: searched `.<destination filename>.partial` — wrong KEY, since the writer stages by cache identity. Round 2 (my "fix"): derived the cache path correctly and DROPPED THE LEADING DOT, so it looked for `hash.ext.partial` while the writer writes `.hash.ext.partial`. Still missed every real partial. Still would have told a user holding 30 GB of resumable bytes to start over — the inverted false claim this issue is about, shipped in the commit that claimed to prevent it. BOTH TIMES MY TESTS AGREED WITH ME, because the fixtures were built by calling the same helper under test. A fixture derived from the code under test cannot falsify it. The previous version even pinned the name with `/[0-9a-f]{32}\.safetensors\.partial$/`, which matches equally well with and without the dot. So the fix is structural rather than another careful correction: ONE exported `stagedPartialPathForTarget`, and the WRITER now calls it. Not two derivations that agree today — the same function, so they cannot disagree. A test asserts the writer routes through it and that the hand-rolled expression exists in exactly one place; another asserts the leading dot on its own, separately from the hash, because the combined regex is what hid it. Mutation-tested: dropping the dot fails 2 tests, and re-inlining the path at the writer fails the wiring test. Refs #1370 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent faa4876 commit ba7bf2e

3 files changed

Lines changed: 267 additions & 3 deletions

File tree

Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
1+
// #1370 — `download_model action:"status"` told users "the partial was left on disk and
2+
// can be resumed by re-issuing the download". Nothing ever stat'd the file. The reporter
3+
// paused a 33 GB download BECAUSE of that sentence, found no partial anywhere, and
4+
// restarted from zero.
5+
//
6+
// THE FIXTURES HERE ARE BUILT FROM THE PRODUCTION PATH FUNCTION, NOT FROM A NAME I TYPED.
7+
// My first version of this file created `.<destination filename>.partial` and searched for
8+
// the same string, so it passed while production stages under
9+
// `.<sha256(cacheIdentity)[0:32]><ext>.partial` — keyed by the CACHE identity, not the
10+
// destination. The lookup would have missed every real partial and reported "no partial
11+
// found" for downloads that had one: this issue's own bug inverted, and worse, because the
12+
// original at least erred toward "your bytes are safe".
13+
//
14+
// A test whose fixture encodes the belief under test cannot fail for the reason it exists.
15+
// Deriving the path from `stagedPartialPathForUrl` is what makes the two impossible to
16+
// disagree.
17+
18+
import { describe, expect, it, afterEach } from "vitest";
19+
import { mkdtemp, mkdir, writeFile, rm } from "node:fs/promises";
20+
import { basename, dirname } from "node:path";
21+
import { tmpdir } from "node:os";
22+
import { join } from "node:path";
23+
24+
import { findResumablePartial, stagedPartialPathForUrl } from "../../services/download-cache.js";
25+
26+
const URL_A = "https://huggingface.co/Comfy-Org/flux2-dev/resolve/main/flux2_dev_fp8mixed.safetensors";
27+
const URL_B = "https://huggingface.co/Comfy-Org/other/resolve/main/mistral_3_small.safetensors";
28+
29+
const dirs: string[] = [];
30+
afterEach(async () => {
31+
for (const d of dirs.splice(0)) await rm(d, { recursive: true, force: true });
32+
delete process.env.COMFYUI_DOWNLOAD_CACHE_DIR;
33+
});
34+
35+
async function useTempCache(): Promise<void> {
36+
const dir = await mkdtemp(join(tmpdir(), "dl-partial-"));
37+
dirs.push(dir);
38+
process.env.COMFYUI_DOWNLOAD_CACHE_DIR = dir;
39+
}
40+
41+
/** Stage a partial exactly where the writer would put it for this URL. */
42+
async function stagePartialFor(url: string, bytes: number): Promise<string> {
43+
const p = stagedPartialPathForUrl(url);
44+
await mkdir(dirname(p), { recursive: true });
45+
await writeFile(p, Buffer.alloc(bytes, 1));
46+
return p;
47+
}
48+
49+
describe("findResumablePartial reports what is ACTUALLY staged (#1370)", () => {
50+
it("finds the partial the writer would have staged for this URL", async () => {
51+
await useTempCache();
52+
const staged = await stagePartialFor(URL_A, 4096);
53+
const found = await findResumablePartial(URL_A);
54+
expect(found?.bytes).toBe(4096);
55+
expect(found?.path).toBe(staged);
56+
});
57+
58+
it("the staged name is HASHED and HIDDEN — both details cost a round", async () => {
59+
// Two separate mistakes, one per review round:
60+
// 1. searched `.<destination filename>.partial` — wrong KEY (the writer stages by
61+
// cache identity, not destination);
62+
// 2. derived the cache path correctly and dropped the LEADING DOT, so it looked for
63+
// `hash.ext.partial` while the writer wrote `.hash.ext.partial`.
64+
// Each time the lookup found nothing and would have told a user holding 30 GB of
65+
// resumable bytes to start over. The dot is asserted separately because the previous
66+
// version of this test used `/[0-9a-f]{32}\.safetensors\.partial$/`, which matches
67+
// happily with OR without it.
68+
await useTempCache();
69+
const p = stagedPartialPathForUrl(URL_A);
70+
const name = basename(p);
71+
expect(p, "must not be keyed on the destination filename").not.toMatch(/flux2_dev_fp8mixed/);
72+
expect(name.startsWith("."), `staged file must be hidden, got ${name}`).toBe(true);
73+
expect(name).toMatch(/^\.[0-9a-f]{32}\.safetensors\.partial$/);
74+
});
75+
76+
it("WIRING: the WRITER stages through the same function the lookup reads", async () => {
77+
// The root cause of both rounds was two parallel derivations of one path, each
78+
// confident and one wrong. A test that only compares the helper against itself cannot
79+
// see that — my fixtures were built by calling the helper under test, so they agreed
80+
// with every wrong version of it.
81+
//
82+
// This asserts the writer does not hand-roll the path any more. It is the only check
83+
// here that could have failed while all the others passed.
84+
const { readFileSync } = await import("node:fs");
85+
const src = readFileSync(new URL("../../services/download-cache.ts", import.meta.url), "utf8")
86+
.replace(/\/\*[\s\S]*?\*\//g, "")
87+
.replace(/\/\/.*/g, "");
88+
expect(src).toMatch(/const partial = stagedPartialPathForTarget\(target\);/);
89+
// …and the hand-rolled expression exists in exactly ONE place: the shared helper.
90+
const handRolled = src.match(/join\(cacheDir\(\), `\.\$\{basename\([a-z]+\)\}\.partial`\)/g) ?? [];
91+
expect(handRolled.length, "the staged path must be built in exactly one place").toBe(1);
92+
});
93+
94+
it("returns NULL when nothing is staged — the reporter's case", async () => {
95+
await useTempCache();
96+
expect(await findResumablePartial(URL_A)).toBeNull();
97+
});
98+
99+
it("does not report ANOTHER download's partial as this one's", async () => {
100+
// The staged name is keyed on the URL, so two downloads never share one. Reporting a
101+
// neighbour's bytes as resumable would be a new false claim, not a fix for the old one.
102+
await useTempCache();
103+
await stagePartialFor(URL_B, 8192);
104+
expect(await findResumablePartial(URL_A)).toBeNull();
105+
expect((await findResumablePartial(URL_B))?.bytes).toBe(8192);
106+
});
107+
108+
it("a ZERO-BYTE partial is reported as absent", async () => {
109+
// Resuming from it saves nothing, and calling it resumable restates this bug in
110+
// miniature: a claim of retained bytes where there are none.
111+
await useTempCache();
112+
await stagePartialFor(URL_A, 0);
113+
expect(await findResumablePartial(URL_A)).toBeNull();
114+
});
115+
116+
it("ignores the COMPLETED cache entry — only the staged .partial is resumable", async () => {
117+
await useTempCache();
118+
const partial = stagedPartialPathForUrl(URL_A);
119+
const completed = partial.replace(/\.partial$/, "");
120+
await mkdir(dirname(completed), { recursive: true });
121+
await writeFile(completed, Buffer.alloc(8192, 1));
122+
expect(await findResumablePartial(URL_A)).toBeNull();
123+
});
124+
125+
it("a missing or unparseable URL yields null rather than throwing", async () => {
126+
await useTempCache();
127+
expect(await findResumablePartial(undefined)).toBeNull();
128+
expect(await findResumablePartial("")).toBeNull();
129+
expect(await findResumablePartial("not a url")).toBeNull();
130+
});
131+
});

src/services/download-cache.ts

Lines changed: 75 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2347,7 +2347,7 @@ async function downloadIntoCache(
23472347
// resumes from the byte it left off on the next call, rather than
23482348
// restarting from zero. (See streamUrlToFile for the Range + flags
23492349
// handshake.) Cleanup on terminal failure stays unchanged.
2350-
const partial = join(cacheDir(), `.${basename(target)}.partial`);
2350+
const partial = stagedPartialPathForTarget(target);
23512351
const rejectedMarker = `${partial}.rejected`;
23522352

23532353
/**
@@ -3302,3 +3302,77 @@ export async function downloadWithCache(
33023302
return { targetPath: options.targetPath, usedCache: false };
33033303
}
33043304
}
3305+
3306+
/**
3307+
* The staged `.partial` path for a cache target. THE ONE DEFINITION (#1370).
3308+
*
3309+
* It is a HIDDEN file — `.<basename>.partial`, leading dot — in the cache dir. That dot
3310+
* cost two rounds. My first lookup searched for `.<destination filename>.partial` (wrong
3311+
* key: the writer stages by CACHE identity, not destination). The correction derived the
3312+
* cache path properly and then dropped the leading dot, so it looked for `hash.ext.partial`
3313+
* while the writer wrote `.hash.ext.partial` — still missing every real partial, still
3314+
* reporting "no partial found" to someone holding 30 GB of resumable bytes.
3315+
*
3316+
* Both times my tests agreed with me, because the fixtures were built by calling the same
3317+
* helper being tested. A fixture derived from the code under test cannot falsify it.
3318+
*
3319+
* So there is now exactly one expression, and the WRITER uses it too. Not a parallel
3320+
* derivation that happens to agree today — the same function, so they cannot disagree.
3321+
*/
3322+
export function stagedPartialPathForTarget(target: string): string {
3323+
return join(cacheDir(), `.${basename(target)}.partial`);
3324+
}
3325+
3326+
/**
3327+
* Where a download's resumable `.partial` is staged, derived the way the writer derives it
3328+
* (#1370).
3329+
*
3330+
* MY FIRST VERSION OF THIS GUESSED THE NAME AND GUESSED WRONG. It looked for
3331+
* `.<destination filename>.partial`, because that is what "the partial for this download"
3332+
* sounds like. The writer stages under `.<sha256(cacheIdentity)[0:32]><ext>.partial` —
3333+
* keyed by the CACHE identity, not the destination — so the lookup would have missed every
3334+
* time and reported "no partial was found" for downloads that had one. That is the same
3335+
* false claim this issue is about, pointing the other way, and it would have been worse:
3336+
* the original at least erred toward "your bytes are safe".
3337+
*
3338+
* The tests missed it because the fixtures created files under the name I was searching
3339+
* for. They encoded my belief about the naming rather than the naming, which is the third
3340+
* time that shape has cost me today. Building the path from `cachePathForUrl` — the same
3341+
* function the writer uses — is what makes the two definitions impossible to disagree.
3342+
*
3343+
* SCOPE, stated because the caller has to phrase its answer around it: `cacheIdentity`
3344+
* folds in representation-affecting request headers and cloud credentials, which a job
3345+
* record deliberately does not keep. So this reproduces the staged path exactly for an
3346+
* unauthenticated public download (the reporter's case, and the common one) and cannot for
3347+
* an authenticated variant. A miss therefore means "none found under this URL's staged
3348+
* name", never "none exists" — and the caller must not upgrade it to the latter.
3349+
*/
3350+
export function stagedPartialPathForUrl(url: string): string {
3351+
return stagedPartialPathForTarget(cachePathForUrl(url));
3352+
}
3353+
3354+
/**
3355+
* Stat the staged `.partial` for a URL. Returns null when there is nothing usable there.
3356+
*
3357+
* A zero-byte partial reports as absent: resuming from it saves nothing, and calling it
3358+
* resumable would restate this issue's bug in miniature — a claim of retained bytes where
3359+
* there are none.
3360+
*/
3361+
export async function findResumablePartial(
3362+
url: string | undefined,
3363+
): Promise<{ path: string; bytes: number } | null> {
3364+
if (typeof url !== "string" || !url.trim()) return null;
3365+
let candidate: string;
3366+
try {
3367+
candidate = stagedPartialPathForUrl(url.trim());
3368+
} catch {
3369+
return null;
3370+
}
3371+
try {
3372+
const st = await stat(candidate);
3373+
if (st.isFile() && st.size > 0) return { path: candidate, bytes: st.size };
3374+
} catch {
3375+
// ENOENT is the common, expected answer.
3376+
}
3377+
return null;
3378+
}

src/tools/model-management.ts

Lines changed: 61 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ import {
2626
type DownloadJob,
2727
} from "../services/download-jobs.js";
2828
import { readDownloadProgress } from "../services/download-progress.js";
29+
import { findResumablePartial } from "../services/download-cache.js";
2930
import { errorToToolResult, ModelError } from "../utils/errors.js";
3031
import {
3132
downloadCivitaiModelAction,
@@ -222,6 +223,39 @@ function stillWritingClause(route: DownloadRoute): string {
222223
* re-issue, while the stale note said doing that corrupts the file (codex
223224
* review). One function, so they cannot drift again.
224225
*/
226+
/**
227+
* What is ACTUALLY on disk for a cancelled download (#1370).
228+
*
229+
* The row this replaces said "the partial was left on disk and can be resumed by
230+
* re-issuing the download (it picks up where it left off)" on the strength of
231+
* `status === "cancelled"` alone. A reporter paused a 33 GB download because of that
232+
* sentence, found nothing on disk, and restarted from zero. The sentence was not stale —
233+
* it was never checked.
234+
*
235+
* Both answers are useful and neither is a failure: a partial means "re-issue and you keep
236+
* these bytes", and no partial means "re-issue and you start over" — which is exactly the
237+
* fact someone needs BEFORE spending the bandwidth, not after. The size is included
238+
* because "resumable" without a number is not something you can weigh against restarting.
239+
*/
240+
function describePartial(partial: { path: string; bytes: number } | null): string {
241+
if (!partial) {
242+
return (
243+
`no resumable partial was found for this URL, so re-issuing very likely starts from ` +
244+
`the beginning. That is not an error — but it is worth knowing before you re-spend ` +
245+
`the bandwidth on a large file. (The staged file is keyed by the download's cache ` +
246+
`identity, which folds in auth headers this record deliberately does not keep, so ` +
247+
`for an AUTHENTICATED download this means "none found under the unauthenticated ` +
248+
`key", not "none exists".)`
249+
);
250+
}
251+
const gb = partial.bytes / 1024 ** 3;
252+
const size = gb >= 1 ? `${gb.toFixed(2)} GB` : `${(partial.bytes / 1024 ** 2).toFixed(1)} MB`;
253+
return (
254+
`a partial of ${size} is on disk (${partial.path}) and re-issuing the same download ` +
255+
`resumes from it.`
256+
);
257+
}
258+
225259
function afterCancelAdvice(route: DownloadRoute): string {
226260
switch (route) {
227261
case "manager":
@@ -968,6 +1002,31 @@ async function statusAction(args: {
9681002
// listing actually contains a collision (two writers, one file).
9691003
const idCounts = new Map<string, number>();
9701004
for (const j of list) idCounts.set(j.id, (idCounts.get(j.id) ?? 0) + 1);
1005+
// #1370 — LOOK, then say. The cancelled row claimed "the partial was left on disk
1006+
// and can be resumed by re-issuing the download" purely from `status === "cancelled"`;
1007+
// nothing stat'd the file. A reporter paused a 33 GB download BECAUSE of that
1008+
// sentence, found no partial anywhere, and restarted from zero.
1009+
//
1010+
// Stat'd up front because the row builder below is synchronous, and only for rows
1011+
// that could have one: a Manager dispatch never writes a local partial and already
1012+
// says so.
1013+
//
1014+
// Keyed by the job's URL, because that is what the WRITER keys the staged file on
1015+
// (cachePathForUrl). My first version searched for `.<destination filename>.partial`
1016+
// — what "the partial for this download" sounds like, and not what is on disk. It
1017+
// would have reported "no partial" for every download that had one: the same false
1018+
// claim inverted, and pointing the more damaging way, since the original at least
1019+
// erred toward "your bytes are safe".
1020+
const partials = new Map<string, { path: string; bytes: number } | null>();
1021+
await Promise.all(
1022+
list
1023+
.filter((j) => j.status === "cancelled" && !j.viaManager)
1024+
.map(async (j) => {
1025+
partials.set(`${j.id}\n${j.trayId}`, await findResumablePartial(j.url));
1026+
}),
1027+
);
1028+
const partialFor = (j: DownloadJob): { path: string; bytes: number } | null =>
1029+
partials.get(`${j.id}\n${j.trayId}`) ?? null;
9711030
const collidingIds = [...idCounts.entries()].filter(([, n]) => n > 1).map(([k]) => k);
9721031
const lines = list.map((j) => {
9731032
const p = readDownloadProgress(j.progressId ?? j.trayId);
@@ -1027,10 +1086,10 @@ async function statusAction(args: {
10271086
// left by a cancel (none may exist).
10281087
(j.viaManager
10291088
? `\n cancelled — the previous session's writer was confirmed GONE (its process no longer exists), so a later session closed its stale record; no live transfer was aborted. This was a remote ComfyUI-Manager dispatch: the host MAY still be fetching server-side (no Manager recall API) and there is NO local partial to resume — check list_local_models to see whether the file landed; re-issuing starts a NEW dispatch.`
1030-
: `\n cancelled — the previous session's writer was confirmed GONE (its process no longer exists), so a later session closed its stale record; no live transfer was aborted. Any .partial the dead writer left was untouched — re-issue the download to resume from it, or to restart cleanly if there is none.`)
1089+
: `\n cancelled — the previous session's writer was confirmed GONE (its process no longer exists), so a later session closed its stale record; no live transfer was aborted. ${describePartial(partialFor(j))}`)
10311090
: j.viaManager
10321091
? `\n cancelled — this was a remote ComfyUI-Manager dispatch, so there is NO local partial to resume, and the host MAY still be fetching server-side (there's no Manager recall API). Re-issuing starts a NEW server-side dispatch (a duplicate, not a resume). Check list_local_models to see whether the file landed before deciding.`
1033-
: `\n cancelled — the partial was left on disk and can be resumed by re-issuing the download (it picks up where it left off)`) +
1092+
: `\n cancelled — ${describePartial(partialFor(j))}`) +
10341093
// A recovery-critical note from cancellation cleanup (e.g. a previous
10351094
// destination file preserved under a .bak path because it couldn't be
10361095
// restored) — surface it so the user can recover, not mask it.

0 commit comments

Comments
 (0)