mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-27 04:47:03 -06:00
fix(release): retry plugin dependency install timeouts (#117618)
This commit is contained in:
@@ -9,6 +9,12 @@ export function resolvePluginNpmCommand(
|
||||
shell: boolean;
|
||||
windowsVerbatimArguments?: boolean;
|
||||
};
|
||||
/** Run package-local npm ci with bounded retries for registry timeouts. */
|
||||
export function runPluginNpmCiWithRetry(
|
||||
args: unknown,
|
||||
options: Record<string, unknown>,
|
||||
params?: Record<string, unknown>,
|
||||
): unknown;
|
||||
/** Build the package.json that should be used while packaging a plugin for npm. */
|
||||
export function resolveAugmentedPluginNpmPackageJson(params: unknown):
|
||||
| {
|
||||
|
||||
@@ -229,6 +229,36 @@ function spawnCommandSync(command, args, options) {
|
||||
return spawnSync(command, args, options);
|
||||
}
|
||||
|
||||
/** @internal Directly tested release-script implementation detail. */
|
||||
export function runPluginNpmCiWithRetry(args, options, params = {}) {
|
||||
const attempts = params.attempts ?? 3;
|
||||
const timeoutMs = params.timeoutMs ?? 180_000;
|
||||
const spawn = params.spawn ?? spawnNpmSync;
|
||||
const cleanupAttempt = params.cleanupAttempt ?? (() => {});
|
||||
const pluginDir = params.pluginDir ?? "plugin";
|
||||
|
||||
let result;
|
||||
for (let attempt = 1; attempt <= attempts; attempt += 1) {
|
||||
result = spawn(args, { ...options, timeout: timeoutMs });
|
||||
if (result.error?.code !== "ETIMEDOUT") {
|
||||
return result;
|
||||
}
|
||||
|
||||
// A timed-out npm process can leave a partial tree that makes the next
|
||||
// package attempt nondeterministic. Restore the staging invariant even
|
||||
// when the retry budget is exhausted.
|
||||
cleanupAttempt();
|
||||
if (attempt === attempts) {
|
||||
return result;
|
||||
}
|
||||
console.error(
|
||||
`[plugin-npm-publish] bundled dependency install timed out for ${pluginDir} ` +
|
||||
`(attempt ${attempt}/${attempts}); retrying`,
|
||||
);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function resolveInstalledPackageDir(packageDir, packageName) {
|
||||
return path.join(packageDir, "node_modules", ...packageName.split("/"));
|
||||
}
|
||||
@@ -433,7 +463,7 @@ function installPackageLocalBundledDependencies(params) {
|
||||
generateNpmPackageLock(params.packageDir, { installStrategy: "shallow" }),
|
||||
"utf8",
|
||||
);
|
||||
const result = spawnNpmSync(
|
||||
const result = runPluginNpmCiWithRetry(
|
||||
[
|
||||
"ci",
|
||||
"--install-strategy=shallow",
|
||||
@@ -451,6 +481,10 @@ function installPackageLocalBundledDependencies(params) {
|
||||
env: process.env,
|
||||
stdio: ["ignore", "ignore", "inherit"],
|
||||
},
|
||||
{
|
||||
cleanupAttempt: () => fs.rmSync(nodeModulesPath, { recursive: true, force: true }),
|
||||
pluginDir: params.pluginDir,
|
||||
},
|
||||
);
|
||||
if (result.error) {
|
||||
throw result.error;
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
// Plugin npm manifest tests validate generated plugin package manifests.
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
||||
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { dirname, join, win32 } from "node:path";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import {
|
||||
resolveAugmentedPluginNpmPackageJson,
|
||||
resolveAugmentedPluginNpmManifest,
|
||||
resolvePluginNpmCommand,
|
||||
runPluginNpmCiWithRetry,
|
||||
withAugmentedPluginNpmManifestForPackage,
|
||||
} from "../scripts/lib/plugin-npm-package-manifest.mjs";
|
||||
import { cleanupTempDirs, makeTempRepoRoot, writeJsonFile } from "./helpers/temp-repo.js";
|
||||
@@ -188,6 +189,99 @@ describe("plugin npm package manifest staging", () => {
|
||||
).toThrow("OpenClaw refuses to shell out to bare npm on Windows");
|
||||
});
|
||||
|
||||
it("retries timed-out bundled dependency installs after cleaning partial output", () => {
|
||||
const timeoutError = Object.assign(new Error("timed out"), { code: "ETIMEDOUT" });
|
||||
const spawnResults = [
|
||||
{ error: timeoutError, status: null },
|
||||
{ error: undefined, status: 0 },
|
||||
];
|
||||
const spawnOptions: Array<Record<string, unknown>> = [];
|
||||
let cleanupCalls = 0;
|
||||
|
||||
const result = runPluginNpmCiWithRetry(
|
||||
["ci"],
|
||||
{ cwd: "/tmp/plugin" },
|
||||
{
|
||||
cleanupAttempt: () => {
|
||||
cleanupCalls += 1;
|
||||
},
|
||||
pluginDir: "whatsapp",
|
||||
spawn: (_args: string[], options: Record<string, unknown>) => {
|
||||
spawnOptions.push(options);
|
||||
return spawnResults.shift();
|
||||
},
|
||||
timeoutMs: 1234,
|
||||
},
|
||||
) as { status: number | null };
|
||||
|
||||
expect(result.status).toBe(0);
|
||||
expect(cleanupCalls).toBe(1);
|
||||
expect(spawnOptions).toEqual([
|
||||
{ cwd: "/tmp/plugin", timeout: 1234 },
|
||||
{ cwd: "/tmp/plugin", timeout: 1234 },
|
||||
]);
|
||||
});
|
||||
|
||||
it("does not retry ordinary bundled dependency install failures", () => {
|
||||
let spawnCalls = 0;
|
||||
const result = runPluginNpmCiWithRetry(
|
||||
["ci"],
|
||||
{},
|
||||
{
|
||||
cleanupAttempt: () => {
|
||||
throw new Error("cleanup should not run");
|
||||
},
|
||||
spawn: () => {
|
||||
spawnCalls += 1;
|
||||
return { error: undefined, status: 1 };
|
||||
},
|
||||
},
|
||||
) as { status: number | null };
|
||||
|
||||
expect(result.status).toBe(1);
|
||||
expect(spawnCalls).toBe(1);
|
||||
});
|
||||
|
||||
it("cleans an exhausted timeout before reusing the same package directory", () => {
|
||||
const repoDir = makeTempRepoRoot(tempDirs, "openclaw-plugin-npm-timeout-");
|
||||
const packageDir = join(repoDir, "extensions", "whatsapp");
|
||||
const nodeModulesPath = join(packageDir, "node_modules");
|
||||
const timeoutError = Object.assign(new Error("timed out"), { code: "ETIMEDOUT" });
|
||||
mkdirSync(packageDir, { recursive: true });
|
||||
|
||||
const firstResult = runPluginNpmCiWithRetry(
|
||||
["ci"],
|
||||
{ cwd: packageDir },
|
||||
{
|
||||
attempts: 3,
|
||||
cleanupAttempt: () => rmSync(nodeModulesPath, { recursive: true, force: true }),
|
||||
pluginDir: "whatsapp",
|
||||
spawn: () => {
|
||||
mkdirSync(nodeModulesPath, { recursive: true });
|
||||
return { error: timeoutError, status: null };
|
||||
},
|
||||
},
|
||||
) as { error?: NodeJS.ErrnoException };
|
||||
|
||||
expect(firstResult.error?.code).toBe("ETIMEDOUT");
|
||||
expect(existsSync(nodeModulesPath)).toBe(false);
|
||||
|
||||
const secondResult = runPluginNpmCiWithRetry(
|
||||
["ci"],
|
||||
{ cwd: packageDir },
|
||||
{
|
||||
cleanupAttempt: () => rmSync(nodeModulesPath, { recursive: true, force: true }),
|
||||
pluginDir: "whatsapp",
|
||||
spawn: () => {
|
||||
expect(existsSync(nodeModulesPath)).toBe(false);
|
||||
return { error: undefined, status: 0 };
|
||||
},
|
||||
},
|
||||
) as { status: number | null };
|
||||
|
||||
expect(secondResult.status).toBe(0);
|
||||
});
|
||||
|
||||
it("overlays generated channel configs while packing and restores source manifest", () => {
|
||||
const repoDir = makeTempRepoRoot(tempDirs, "openclaw-plugin-npm-package-manifest-");
|
||||
const packageDir = join(repoDir, "extensions", "twitch");
|
||||
|
||||
Reference in New Issue
Block a user