fix: retry runtime postbuild skill copy races
This commit is contained in:
parent
09e8d1e96f
commit
77b1f240fd
@ -8,6 +8,8 @@ import {
|
||||
} from "./runtime-postbuild-shared.mjs";
|
||||
|
||||
const GENERATED_BUNDLED_SKILLS_DIR = "bundled-skills";
|
||||
const TRANSIENT_COPY_ERROR_CODES = new Set(["EEXIST", "ENOENT", "ENOTEMPTY", "EBUSY"]);
|
||||
const COPY_RETRY_DELAYS_MS = [10, 25, 50];
|
||||
|
||||
export function rewritePackageExtensions(entries) {
|
||||
if (!Array.isArray(entries)) {
|
||||
@ -82,6 +84,39 @@ function resolveBundledSkillTarget(rawPath) {
|
||||
};
|
||||
}
|
||||
|
||||
function isTransientCopyError(error) {
|
||||
return (
|
||||
!!error &&
|
||||
typeof error === "object" &&
|
||||
typeof error.code === "string" &&
|
||||
TRANSIENT_COPY_ERROR_CODES.has(error.code)
|
||||
);
|
||||
}
|
||||
|
||||
function sleepSync(ms) {
|
||||
if (!Number.isFinite(ms) || ms <= 0) {
|
||||
return;
|
||||
}
|
||||
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
|
||||
}
|
||||
|
||||
function copySkillPathWithRetry(params) {
|
||||
const maxAttempts = COPY_RETRY_DELAYS_MS.length + 1;
|
||||
for (let attempt = 0; attempt < maxAttempts; attempt += 1) {
|
||||
try {
|
||||
removePathIfExists(params.targetPath);
|
||||
fs.mkdirSync(path.dirname(params.targetPath), { recursive: true });
|
||||
fs.cpSync(params.sourcePath, params.targetPath, params.copyOptions);
|
||||
return;
|
||||
} catch (error) {
|
||||
if (!isTransientCopyError(error) || attempt === maxAttempts - 1) {
|
||||
throw error;
|
||||
}
|
||||
sleepSync(COPY_RETRY_DELAYS_MS[attempt] ?? 0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function copyDeclaredPluginSkillPaths(params) {
|
||||
const skills = Array.isArray(params.manifest.skills) ? params.manifest.skills : [];
|
||||
const copiedSkills = [];
|
||||
@ -104,21 +139,23 @@ function copyDeclaredPluginSkillPaths(params) {
|
||||
continue;
|
||||
}
|
||||
const targetPath = ensurePathInsideRoot(params.distPluginDir, target.outputPath);
|
||||
removePathIfExists(targetPath);
|
||||
fs.mkdirSync(path.dirname(targetPath), { recursive: true });
|
||||
const shouldExcludeNestedNodeModules = /^node_modules(?:\/|$)/u.test(
|
||||
normalizeManifestRelativePath(raw),
|
||||
);
|
||||
fs.cpSync(sourcePath, targetPath, {
|
||||
dereference: true,
|
||||
force: true,
|
||||
recursive: true,
|
||||
filter: (candidatePath) => {
|
||||
if (!shouldExcludeNestedNodeModules || candidatePath === sourcePath) {
|
||||
return true;
|
||||
}
|
||||
const relativeCandidate = path.relative(sourcePath, candidatePath).replaceAll("\\", "/");
|
||||
return !relativeCandidate.split("/").includes("node_modules");
|
||||
copySkillPathWithRetry({
|
||||
sourcePath,
|
||||
targetPath,
|
||||
copyOptions: {
|
||||
dereference: true,
|
||||
force: true,
|
||||
recursive: true,
|
||||
filter: (candidatePath) => {
|
||||
if (!shouldExcludeNestedNodeModules || candidatePath === sourcePath) {
|
||||
return true;
|
||||
}
|
||||
const relativeCandidate = path.relative(sourcePath, candidatePath).replaceAll("\\", "/");
|
||||
return !relativeCandidate.split("/").includes("node_modules");
|
||||
},
|
||||
},
|
||||
});
|
||||
copiedSkills.push(target.manifestPath);
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
copyBundledPluginMetadata,
|
||||
rewritePackageExtensions,
|
||||
@ -237,6 +237,47 @@ describe("copyBundledPluginMetadata", () => {
|
||||
expect(fs.existsSync(staleNodeModulesDir)).toBe(false);
|
||||
});
|
||||
|
||||
it("retries transient skill copy races from concurrent runtime postbuilds", () => {
|
||||
const repoRoot = makeRepoRoot("openclaw-bundled-plugin-retry-");
|
||||
const pluginDir = path.join(repoRoot, "extensions", "diffs");
|
||||
fs.mkdirSync(path.join(pluginDir, "skills", "diffs"), { recursive: true });
|
||||
fs.writeFileSync(path.join(pluginDir, "skills", "diffs", "SKILL.md"), "# Diffs\n", "utf8");
|
||||
writeJson(path.join(pluginDir, "openclaw.plugin.json"), {
|
||||
id: "diffs",
|
||||
configSchema: { type: "object" },
|
||||
skills: ["./skills"],
|
||||
});
|
||||
writeJson(path.join(pluginDir, "package.json"), {
|
||||
name: "@openclaw/diffs",
|
||||
openclaw: { extensions: ["./index.ts"] },
|
||||
});
|
||||
|
||||
const realCpSync = fs.cpSync.bind(fs);
|
||||
let attempts = 0;
|
||||
const cpSyncSpy = vi.spyOn(fs, "cpSync").mockImplementation((...args) => {
|
||||
attempts += 1;
|
||||
if (attempts === 1) {
|
||||
const error = Object.assign(new Error("race"), { code: "EEXIST" });
|
||||
throw error;
|
||||
}
|
||||
return realCpSync(...args);
|
||||
});
|
||||
|
||||
try {
|
||||
copyBundledPluginMetadata({ repoRoot });
|
||||
} finally {
|
||||
cpSyncSpy.mockRestore();
|
||||
}
|
||||
|
||||
expect(attempts).toBe(2);
|
||||
expect(
|
||||
fs.readFileSync(
|
||||
path.join(repoRoot, "dist", "extensions", "diffs", "skills", "diffs", "SKILL.md"),
|
||||
"utf8",
|
||||
),
|
||||
).toContain("Diffs");
|
||||
});
|
||||
|
||||
it("removes generated outputs for plugins no longer present in source", () => {
|
||||
const repoRoot = makeRepoRoot("openclaw-bundled-plugin-removed-");
|
||||
const staleBundledSkillDir = path.join(
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user