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";
|
} 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);
|
||||||
|
|||||||
@ -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(
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user