comfyui-mcp 0.52.129 → 0.52.130
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/services/download-cache.js +193 -24
- package/dist/services/download-cache.js.map +1 -1
- package/dist/services/download-jobs.js +96 -37
- package/dist/services/download-jobs.js.map +1 -1
- package/dist/services/download-progress.js +20 -6
- package/dist/services/download-progress.js.map +1 -1
- package/dist/services/model-resolver.js +62 -38
- package/dist/services/model-resolver.js.map +1 -1
- package/dist/tools/model-management.js +166 -90
- package/dist/tools/model-management.js.map +1 -1
- package/package.json +1 -1
|
@@ -985,10 +985,7 @@ function cacheIdentity(url, headers, storageAuth) {
|
|
|
985
985
|
return id;
|
|
986
986
|
}
|
|
987
987
|
function cachePathForUrl(url, headers = {}, storageAuth) {
|
|
988
|
-
const hash =
|
|
989
|
-
.update(cacheIdentity(url, headers, storageAuth))
|
|
990
|
-
.digest("hex")
|
|
991
|
-
.slice(0, HASH_CHARS);
|
|
988
|
+
const hash = cacheIdentityKey(url, headers, storageAuth);
|
|
992
989
|
let extension = "";
|
|
993
990
|
try {
|
|
994
991
|
extension = extname(basename(new URL(url).pathname));
|
|
@@ -999,6 +996,139 @@ function cachePathForUrl(url, headers = {}, storageAuth) {
|
|
|
999
996
|
}
|
|
1000
997
|
return join(cacheDir(), `${hash}${extension}`);
|
|
1001
998
|
}
|
|
999
|
+
/**
|
|
1000
|
+
* Claim the deterministic cache identity before reading, truncating, appending,
|
|
1001
|
+
* or renaming its staged files. The persisted-job scan is intentionally only a
|
|
1002
|
+
* liveness/adoption hint; it is not atomic. This O_EXCL claim closes the remaining
|
|
1003
|
+
* cross-process overlap window for the shared `.partial` and refuses on any
|
|
1004
|
+
* unclassified/live owner rather than guessing that an observed snapshot is safe.
|
|
1005
|
+
*/
|
|
1006
|
+
async function acquireStagedWriterLock(partialPath, logUrl) {
|
|
1007
|
+
const lockPath = `${partialPath}.lock`;
|
|
1008
|
+
const token = randomBytes(16).toString("hex");
|
|
1009
|
+
const body = JSON.stringify({ pid: process.pid, token, updated: Date.now() });
|
|
1010
|
+
for (let attempt = 0; attempt < 2; attempt++) {
|
|
1011
|
+
try {
|
|
1012
|
+
await writeFile(lockPath, body, { flag: "wx", mode: 0o600 });
|
|
1013
|
+
return {
|
|
1014
|
+
release: async () => {
|
|
1015
|
+
try {
|
|
1016
|
+
const current = JSON.parse(await readFile(lockPath, "utf8"));
|
|
1017
|
+
// Never remove a successor's claim if a cleanup race replaced this one.
|
|
1018
|
+
if (current.token === token)
|
|
1019
|
+
await rm(lockPath, { force: true });
|
|
1020
|
+
}
|
|
1021
|
+
catch {
|
|
1022
|
+
// Best effort. A failed cleanup leaves a visible claim; the next caller
|
|
1023
|
+
// will probe its pid and either reclaim a dead owner or refuse safely.
|
|
1024
|
+
}
|
|
1025
|
+
},
|
|
1026
|
+
};
|
|
1027
|
+
}
|
|
1028
|
+
catch (err) {
|
|
1029
|
+
if (err?.code !== "EEXIST")
|
|
1030
|
+
throw err;
|
|
1031
|
+
let observedRaw;
|
|
1032
|
+
let owner;
|
|
1033
|
+
try {
|
|
1034
|
+
observedRaw = await readFile(lockPath);
|
|
1035
|
+
owner = JSON.parse(observedRaw.toString("utf8"));
|
|
1036
|
+
}
|
|
1037
|
+
catch {
|
|
1038
|
+
throw interferenceError("the staged writer lock exists but its owner identity could not be read", "before starting", logUrl);
|
|
1039
|
+
}
|
|
1040
|
+
const pid = owner.pid;
|
|
1041
|
+
// Keep this in the range Node accepts for process.kill. A positive integer
|
|
1042
|
+
// outside signed 32-bit range is still an invalid syscall argument (for
|
|
1043
|
+
// example 999999999999 produces ERR_INVALID_ARG_TYPE), not proof of a dead
|
|
1044
|
+
// owner. Treat every such shape as an ownership refusal.
|
|
1045
|
+
if (typeof pid !== "number" ||
|
|
1046
|
+
!Number.isSafeInteger(pid) ||
|
|
1047
|
+
pid <= 0 ||
|
|
1048
|
+
pid > 0x7fff_ffff) {
|
|
1049
|
+
throw interferenceError("the staged writer lock has no valid owner identity", "before starting", logUrl);
|
|
1050
|
+
}
|
|
1051
|
+
let ownerGone = false;
|
|
1052
|
+
try {
|
|
1053
|
+
process.kill(pid, 0);
|
|
1054
|
+
}
|
|
1055
|
+
catch (probeErr) {
|
|
1056
|
+
// An existing process, including this process, means a live claim. Only
|
|
1057
|
+
// ESRCH is proof that the lock owner is gone; EPERM/other errors are
|
|
1058
|
+
// inconclusive and must remain ModelError refusals so callers cannot
|
|
1059
|
+
// fall through to an unlocked direct download.
|
|
1060
|
+
const code = probeErr?.code;
|
|
1061
|
+
if (code !== "ESRCH") {
|
|
1062
|
+
throw interferenceError(`the staged writer owner's process could not be verified (PID probe failed with ${code ?? "an unknown error"})`, "before starting", logUrl);
|
|
1063
|
+
}
|
|
1064
|
+
ownerGone = true;
|
|
1065
|
+
}
|
|
1066
|
+
if (!ownerGone) {
|
|
1067
|
+
throw interferenceError(`the staged writer lock is held by process ${pid}`, "before starting", logUrl);
|
|
1068
|
+
}
|
|
1069
|
+
// The recorded owner is proven gone. Move the exact path aside atomically,
|
|
1070
|
+
// then compare the moved bytes with the observation. A contender that got
|
|
1071
|
+
// here after another contender installed a replacement will move THAT live
|
|
1072
|
+
// claim instead; the comparison catches it before anything is deleted.
|
|
1073
|
+
//
|
|
1074
|
+
// Restore a changed claim with an O_EXCL hard link, never rename: rename can
|
|
1075
|
+
// overwrite a successor that landed while the aside copy was being checked.
|
|
1076
|
+
// `link` is supported on the local filesystems supported by this cache and
|
|
1077
|
+
// fails with EEXIST if another contender already restored/replaced the path.
|
|
1078
|
+
const claimPath = `${lockPath}.stale-${randomBytes(16).toString("hex")}`;
|
|
1079
|
+
try {
|
|
1080
|
+
await downloadCacheFs.rename(lockPath, claimPath);
|
|
1081
|
+
}
|
|
1082
|
+
catch (err) {
|
|
1083
|
+
if (err?.code === "ENOENT")
|
|
1084
|
+
continue;
|
|
1085
|
+
throw interferenceError("the staged writer lock changed before its stale claim could be moved", "before starting", logUrl);
|
|
1086
|
+
}
|
|
1087
|
+
let movedRaw;
|
|
1088
|
+
try {
|
|
1089
|
+
movedRaw = await readFile(claimPath);
|
|
1090
|
+
}
|
|
1091
|
+
catch {
|
|
1092
|
+
try {
|
|
1093
|
+
await downloadCacheFs.link(claimPath, lockPath);
|
|
1094
|
+
await downloadCacheFs.rm(claimPath, { force: true });
|
|
1095
|
+
}
|
|
1096
|
+
catch {
|
|
1097
|
+
// Preserve the aside copy when restoration loses to a successor or
|
|
1098
|
+
// the filesystem refuses the non-overwriting restore operation.
|
|
1099
|
+
}
|
|
1100
|
+
throw interferenceError("the stale writer claim could not be re-read after its atomic move", "before starting", logUrl);
|
|
1101
|
+
}
|
|
1102
|
+
if (!movedRaw.equals(observedRaw)) {
|
|
1103
|
+
try {
|
|
1104
|
+
await downloadCacheFs.link(claimPath, lockPath);
|
|
1105
|
+
await downloadCacheFs.rm(claimPath, { force: true });
|
|
1106
|
+
}
|
|
1107
|
+
catch {
|
|
1108
|
+
// EEXIST means a successor already occupies the canonical path; any
|
|
1109
|
+
// other failure leaves the aside copy intact for diagnosis. In neither
|
|
1110
|
+
// case may this contender remove bytes it did not create.
|
|
1111
|
+
}
|
|
1112
|
+
throw interferenceError("the staged writer lock changed while its stale claim was being reclaimed", "before starting", logUrl);
|
|
1113
|
+
}
|
|
1114
|
+
// The exact dead claim was moved, so removing only its private aside copy
|
|
1115
|
+
// cannot affect a successor that may already have claimed lockPath.
|
|
1116
|
+
await downloadCacheFs.rm(claimPath, { force: true }).catch(() => undefined);
|
|
1117
|
+
}
|
|
1118
|
+
}
|
|
1119
|
+
throw interferenceError("another writer won the staged identity claim", "before starting", logUrl);
|
|
1120
|
+
}
|
|
1121
|
+
function cacheIdentityKey(url, headers, storageAuth) {
|
|
1122
|
+
return createHash("sha256")
|
|
1123
|
+
.update(cacheIdentity(url, headers, storageAuth))
|
|
1124
|
+
.digest("hex")
|
|
1125
|
+
.slice(0, HASH_CHARS);
|
|
1126
|
+
}
|
|
1127
|
+
export function downloadCacheIdentity(url, headers = {}, storageAuth) {
|
|
1128
|
+
const cacheKey = cacheIdentityKey(url, headers, storageAuth);
|
|
1129
|
+
const cachePath = cachePathForUrl(url, headers, storageAuth);
|
|
1130
|
+
return { cacheKey, cachePath, partialPath: stagedPartialPathForTarget(cachePath) };
|
|
1131
|
+
}
|
|
1002
1132
|
async function touch(path) {
|
|
1003
1133
|
const now = new Date();
|
|
1004
1134
|
await downloadCacheFs.utimes(path, now, now);
|
|
@@ -2106,7 +2236,9 @@ fetchImpl = downloadFetch) {
|
|
|
2106
2236
|
// Representation-aware identity (#467): a same-URL download with different HTTP
|
|
2107
2237
|
// auth headers OR different cloud (S3/Azure) credentials gets its OWN cache file,
|
|
2108
2238
|
// partial and in-flight slot — never coalesced onto another caller's stream.
|
|
2109
|
-
const
|
|
2239
|
+
const identity = downloadCacheIdentity(url, headers, storageAuth);
|
|
2240
|
+
const target = identity.cachePath;
|
|
2241
|
+
const partial = stagedPartialPathForTarget(target);
|
|
2110
2242
|
const key = target;
|
|
2111
2243
|
const existing = inflight.get(key);
|
|
2112
2244
|
// A job COALESCING onto an in-flight physical download gets no resume decision
|
|
@@ -2130,8 +2262,15 @@ fetchImpl = downloadFetch) {
|
|
|
2130
2262
|
// can ever false-complete or be corrupted by another's cancel.
|
|
2131
2263
|
if (existing)
|
|
2132
2264
|
return existing;
|
|
2265
|
+
// `inflight` deduplicates callers in THIS process. The atomic staged claim is
|
|
2266
|
+
// the cross-process gate: a second session must refuse before it can observe,
|
|
2267
|
+
// truncate, append, or rename the shared partial. The claim is acquired inside
|
|
2268
|
+
// the promise, after that promise is published, so same-process callers coalesce
|
|
2269
|
+
// before either one reaches the filesystem.
|
|
2270
|
+
let stagedLock;
|
|
2133
2271
|
const promise = (async () => {
|
|
2134
2272
|
await downloadCacheFs.mkdir(cacheDir(), { recursive: true });
|
|
2273
|
+
stagedLock = await acquireStagedWriterLock(partial, logUrl ?? redactUrlForLogs(url));
|
|
2135
2274
|
try {
|
|
2136
2275
|
const info = await downloadCacheFs.stat(target);
|
|
2137
2276
|
if (info.isFile()) {
|
|
@@ -2159,7 +2298,6 @@ fetchImpl = downloadFetch) {
|
|
|
2159
2298
|
// resumes from the byte it left off on the next call, rather than
|
|
2160
2299
|
// restarting from zero. (See streamUrlToFile for the Range + flags
|
|
2161
2300
|
// handshake.) Cleanup on terminal failure stays unchanged.
|
|
2162
|
-
const partial = stagedPartialPathForTarget(target);
|
|
2163
2301
|
const rejectedMarker = `${partial}.rejected`;
|
|
2164
2302
|
/**
|
|
2165
2303
|
* The byte offset THIS attempt may resume from, re-derived from disk every
|
|
@@ -2600,6 +2738,7 @@ fetchImpl = downloadFetch) {
|
|
|
2600
2738
|
}
|
|
2601
2739
|
finally {
|
|
2602
2740
|
inflight.delete(key);
|
|
2741
|
+
await stagedLock?.release();
|
|
2603
2742
|
}
|
|
2604
2743
|
}
|
|
2605
2744
|
/** A cryptographically-random, unguessable temp path next to `base`. NOT a
|
|
@@ -3036,7 +3175,50 @@ export function stagedPartialPathForTarget(target) {
|
|
|
3036
3175
|
* name", never "none exists" — and the caller must not upgrade it to the latter.
|
|
3037
3176
|
*/
|
|
3038
3177
|
export function stagedPartialPathForUrl(url) {
|
|
3039
|
-
return
|
|
3178
|
+
return downloadCacheIdentity(url).partialPath;
|
|
3179
|
+
}
|
|
3180
|
+
/** Read a staged partial at the exact path selected by the local writer. */
|
|
3181
|
+
export async function observeStagedPartialAtPath(path) {
|
|
3182
|
+
if (typeof path !== "string" || !path.trim()) {
|
|
3183
|
+
return { state: "unavailable", path: "" };
|
|
3184
|
+
}
|
|
3185
|
+
// Preserve the writer's exact path. The cache root may legitimately contain
|
|
3186
|
+
// whitespace, and trimming here would silently inspect a different identity.
|
|
3187
|
+
const candidate = path;
|
|
3188
|
+
try {
|
|
3189
|
+
const st = await stat(candidate);
|
|
3190
|
+
if (!st.isFile())
|
|
3191
|
+
return { state: "unavailable", path: candidate };
|
|
3192
|
+
// A real zero-byte staged file carries no bytes that a re-issued download
|
|
3193
|
+
// can resume. Preserve the prior bytes > 0 contract by treating it as
|
|
3194
|
+
// non-resumable/absent rather than a present durable partial.
|
|
3195
|
+
if (st.size <= 0)
|
|
3196
|
+
return { state: "absent", path: candidate };
|
|
3197
|
+
return { state: "present", path: candidate, bytes: st.size, modifiedMs: st.mtimeMs };
|
|
3198
|
+
}
|
|
3199
|
+
catch (err) {
|
|
3200
|
+
// ENOENT is a useful observation: no resumable file exists yet. Permission,
|
|
3201
|
+
// sharing, and other stat failures are deliberately kept distinct so status
|
|
3202
|
+
// cannot turn an unreadable file into "0 bytes" or "absent".
|
|
3203
|
+
if (err?.code === "ENOENT") {
|
|
3204
|
+
return { state: "absent", path: candidate };
|
|
3205
|
+
}
|
|
3206
|
+
return { state: "unavailable", path: candidate };
|
|
3207
|
+
}
|
|
3208
|
+
}
|
|
3209
|
+
/** Read the staged partial that the local downloader would use for `url`. */
|
|
3210
|
+
export async function observeStagedPartial(url, headers = {}, storageAuth) {
|
|
3211
|
+
if (typeof url !== "string" || !url.trim()) {
|
|
3212
|
+
return { state: "unavailable", path: "" };
|
|
3213
|
+
}
|
|
3214
|
+
let partialPath;
|
|
3215
|
+
try {
|
|
3216
|
+
partialPath = downloadCacheIdentity(url.trim(), headers, storageAuth).partialPath;
|
|
3217
|
+
}
|
|
3218
|
+
catch {
|
|
3219
|
+
return { state: "unavailable", path: "" };
|
|
3220
|
+
}
|
|
3221
|
+
return observeStagedPartialAtPath(partialPath);
|
|
3040
3222
|
}
|
|
3041
3223
|
/**
|
|
3042
3224
|
* Stat the staged `.partial` for a URL. Returns null when there is nothing usable there.
|
|
@@ -3054,23 +3236,10 @@ export function stagedPartialPathForUrl(url) {
|
|
|
3054
3236
|
* the historical bare-URL behaviour, whose scope the note on `stagedPartialPathForUrl`
|
|
3055
3237
|
* states. A miss still means "none found under this identity", never "none exists".
|
|
3056
3238
|
*/
|
|
3057
|
-
export async function findResumablePartial(url, headers = {}) {
|
|
3058
|
-
|
|
3059
|
-
|
|
3060
|
-
|
|
3061
|
-
try {
|
|
3062
|
-
candidate = stagedPartialPathForTarget(cachePathForUrl(url.trim(), headers));
|
|
3063
|
-
}
|
|
3064
|
-
catch {
|
|
3065
|
-
return null;
|
|
3066
|
-
}
|
|
3067
|
-
try {
|
|
3068
|
-
const st = await stat(candidate);
|
|
3069
|
-
if (st.isFile() && st.size > 0)
|
|
3070
|
-
return { path: candidate, bytes: st.size };
|
|
3071
|
-
}
|
|
3072
|
-
catch {
|
|
3073
|
-
// ENOENT is the common, expected answer.
|
|
3239
|
+
export async function findResumablePartial(url, headers = {}, storageAuth) {
|
|
3240
|
+
const observed = await observeStagedPartial(url, headers, storageAuth);
|
|
3241
|
+
if (observed.state === "present" && observed.bytes > 0) {
|
|
3242
|
+
return { path: observed.path, bytes: observed.bytes };
|
|
3074
3243
|
}
|
|
3075
3244
|
return null;
|
|
3076
3245
|
}
|