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"; } from "./runtime-postbuild-shared.mjs";
const GENERATED_BUNDLED_SKILLS_DIR = "bundled-skills"; 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) { export function rewritePackageExtensions(entries) {
if (!Array.isArray(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) { function copyDeclaredPluginSkillPaths(params) {
const skills = Array.isArray(params.manifest.skills) ? params.manifest.skills : []; const skills = Array.isArray(params.manifest.skills) ? params.manifest.skills : [];
const copiedSkills = []; const copiedSkills = [];
@ -104,21 +139,23 @@ function copyDeclaredPluginSkillPaths(params) {
continue; continue;
} }
const targetPath = ensurePathInsideRoot(params.distPluginDir, target.outputPath); const targetPath = ensurePathInsideRoot(params.distPluginDir, target.outputPath);
removePathIfExists(targetPath);
fs.mkdirSync(path.dirname(targetPath), { recursive: true });
const shouldExcludeNestedNodeModules = /^node_modules(?:\/|$)/u.test( const shouldExcludeNestedNodeModules = /^node_modules(?:\/|$)/u.test(
normalizeManifestRelativePath(raw), normalizeManifestRelativePath(raw),
); );
fs.cpSync(sourcePath, targetPath, { copySkillPathWithRetry({
dereference: true, sourcePath,
force: true, targetPath,
recursive: true, copyOptions: {
filter: (candidatePath) => { dereference: true,
if (!shouldExcludeNestedNodeModules || candidatePath === sourcePath) { force: true,
return true; recursive: true,
} filter: (candidatePath) => {
const relativeCandidate = path.relative(sourcePath, candidatePath).replaceAll("\\", "/"); if (!shouldExcludeNestedNodeModules || candidatePath === sourcePath) {
return !relativeCandidate.split("/").includes("node_modules"); return true;
}
const relativeCandidate = path.relative(sourcePath, candidatePath).replaceAll("\\", "/");
return !relativeCandidate.split("/").includes("node_modules");
},
}, },
}); });
copiedSkills.push(target.manifestPath); copiedSkills.push(target.manifestPath);

View File

@ -1,7 +1,7 @@
import fs from "node:fs"; import fs from "node:fs";
import os from "node:os"; import os from "node:os";
import path from "node:path"; import path from "node:path";
import { afterEach, describe, expect, it } from "vitest"; import { afterEach, describe, expect, it, vi } from "vitest";
import { import {
copyBundledPluginMetadata, copyBundledPluginMetadata,
rewritePackageExtensions, rewritePackageExtensions,
@ -237,6 +237,47 @@ describe("copyBundledPluginMetadata", () => {
expect(fs.existsSync(staleNodeModulesDir)).toBe(false); 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", () => { it("removes generated outputs for plugins no longer present in source", () => {
const repoRoot = makeRepoRoot("openclaw-bundled-plugin-removed-"); const repoRoot = makeRepoRoot("openclaw-bundled-plugin-removed-");
const staleBundledSkillDir = path.join( const staleBundledSkillDir = path.join(