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.
@@ -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 = createHash("sha256")
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 target = cachePathForUrl(url, headers, storageAuth);
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 stagedPartialPathForTarget(cachePathForUrl(url));
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
- if (typeof url !== "string" || !url.trim())
3059
- return null;
3060
- let candidate;
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
  }