fix: retry runtime postbuild skill copy races

This commit is contained in:
Peter Steinberger 2026-03-16 08:42:37 +00:00
parent 09e8d1e96f
commit 77b1f240fd
2 changed files with 91 additions and 13 deletions

View File

@ -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);

View File

@ -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(