mirror of
https://github.com/openclaw/openclaw.git
synced 2026-04-27 11:56:43 +02:00
fix: preserve live runtime deps temp dirs
This commit is contained in:
@@ -23,6 +23,7 @@ Docs: https://docs.openclaw.ai
|
||||
- Agents/bootstrap: refresh cached workspace bootstrap snapshots on long-lived main-session turns when `AGENTS.md`, `SOUL.md`, `MEMORY.md`, or `TOOLS.md` change on disk, while preserving unchanged snapshot identity through the workspace file cache. (#64871; related #43901, #26497, #28594, #30896) Thanks @aimqwest and @mikejuyoon.
|
||||
- macOS Gateway: detect installed-but-unloaded LaunchAgent split-brain states during status, doctor, and restart, and re-bootstrap launchd supervision before falling back to unmanaged listener restarts. Fixes #67335, #53475, and #71060; refs #58890, #60885, and #70801. Thanks @ze1tgeist88, @dafacto, and @vishutdhar.
|
||||
- Plugins/install: treat mirrored core logger dependencies as staged bundled runtime deps so packaged Gateway starts do not crash when the external plugin-runtime-deps root is missing `tslog`. Fixes #72228; supersedes #72493. Thanks @deepujain.
|
||||
- Build/plugins: preserve active bundled runtime-dependency staging temp directories owned by live build processes so overlapping postbuild runs no longer delete each other's staged deps mid-prune. Supersedes #72220. Thanks @VACInc.
|
||||
- Plugins/install: hide bundled runtime-dependency npm child windows on Windows across Gateway startup, postinstall, and packaged staging paths so Telegram/Anthropic dependency repair no longer flashes shell windows. Fixes #72315. Thanks @athuljayaram and @joshfeng.
|
||||
- Plugins/Windows: normalize lazy plugin service override imports before Node ESM loading so drive-letter browser-control module paths no longer fail with `ERR_UNSUPPORTED_ESM_URL_SCHEME`. Fixes #72573; supersedes #72599 and #72582. Thanks @llzzww316, @feineryonah-byte, and @WuKongAI-CMU.
|
||||
- Plugins/install: stage bundled plugin runtime dependencies before Gateway startup, drain update restarts, and materialize plugin-owned root chunks in external mirrors so staged deps resolve under native ESM. Fixes #72058; supersedes #72084. Thanks @amnesia106 and @drvoss.
|
||||
|
||||
@@ -8,6 +8,7 @@ import { resolveNpmRunner } from "./npm-runner.mjs";
|
||||
|
||||
const TRANSIENT_TEMP_REMOVE_ERROR_CODES = new Set(["EBUSY", "ENOTEMPTY", "EPERM"]);
|
||||
const TEMP_REMOVE_RETRY_DELAYS_MS = [10, 25, 50];
|
||||
const TEMP_OWNER_FILE = "owner.json";
|
||||
|
||||
function readJson(filePath) {
|
||||
return JSON.parse(fs.readFileSync(filePath, "utf8"));
|
||||
@@ -48,13 +49,26 @@ function makeTempDir(parentDir, prefix) {
|
||||
return fs.mkdtempSync(path.join(parentDir, prefix));
|
||||
}
|
||||
|
||||
function writeRuntimeDepsTempOwner(tempDir) {
|
||||
writeJson(path.join(tempDir, TEMP_OWNER_FILE), {
|
||||
pid: process.pid,
|
||||
createdAtMs: Date.now(),
|
||||
});
|
||||
}
|
||||
|
||||
function makeOwnedTempDir(parentDir, prefix) {
|
||||
const tempDir = makeTempDir(parentDir, prefix);
|
||||
writeRuntimeDepsTempOwner(tempDir);
|
||||
return tempDir;
|
||||
}
|
||||
|
||||
function sanitizeTempPrefixSegment(value) {
|
||||
const normalized = value.replace(/[^A-Za-z0-9._-]+/g, "-").replace(/-+/g, "-");
|
||||
return normalized.length > 0 ? normalized : "plugin";
|
||||
}
|
||||
|
||||
function makePluginOwnedTempDir(pluginDir, label) {
|
||||
return makeTempDir(pluginDir, `.openclaw-runtime-deps-${label}-`);
|
||||
return makeOwnedTempDir(pluginDir, `.openclaw-runtime-deps-${label}-`);
|
||||
}
|
||||
|
||||
function assertPathIsNotSymlink(targetPath, label) {
|
||||
@@ -84,6 +98,7 @@ function replaceDirAtomically(targetPath, sourcePath) {
|
||||
try {
|
||||
if (fs.existsSync(targetPath)) {
|
||||
fs.renameSync(targetPath, backupPath);
|
||||
writeRuntimeDepsTempOwner(backupPath);
|
||||
movedExistingTarget = true;
|
||||
}
|
||||
fs.renameSync(sourcePath, targetPath);
|
||||
@@ -100,7 +115,7 @@ function writeJsonAtomically(targetPath, value) {
|
||||
assertPathIsNotSymlink(targetPath, "write runtime deps stamp");
|
||||
const targetParentDir = path.dirname(targetPath);
|
||||
fs.mkdirSync(targetParentDir, { recursive: true });
|
||||
const tempDir = makeTempDir(
|
||||
const tempDir = makeOwnedTempDir(
|
||||
targetParentDir,
|
||||
`.openclaw-runtime-deps-stamp-${sanitizeTempPrefixSegment(path.basename(targetPath))}-`,
|
||||
);
|
||||
@@ -954,6 +969,35 @@ function readRuntimeDepsStamp(stampPath) {
|
||||
}
|
||||
}
|
||||
|
||||
function readRuntimeDepsTempOwner(tempDir) {
|
||||
try {
|
||||
const owner = readJson(path.join(tempDir, TEMP_OWNER_FILE));
|
||||
return owner && typeof owner === "object" ? owner : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function isLiveProcess(pid) {
|
||||
if (!Number.isInteger(pid) || pid <= 0) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
process.kill(pid, 0);
|
||||
return true;
|
||||
} catch (error) {
|
||||
return error?.code === "EPERM";
|
||||
}
|
||||
}
|
||||
|
||||
function shouldRemoveRuntimeDepsTempDir(tempDir) {
|
||||
const owner = readRuntimeDepsTempOwner(tempDir);
|
||||
if (!owner || typeof owner.pid !== "number") {
|
||||
return true;
|
||||
}
|
||||
return !isLiveProcess(owner.pid);
|
||||
}
|
||||
|
||||
function removeStaleRuntimeDepsTempDirs(pluginDir) {
|
||||
if (!fs.existsSync(pluginDir)) {
|
||||
return;
|
||||
@@ -961,6 +1005,9 @@ function removeStaleRuntimeDepsTempDirs(pluginDir) {
|
||||
for (const entry of fs.readdirSync(pluginDir, { withFileTypes: true })) {
|
||||
if (entry.name.startsWith(".openclaw-runtime-deps-")) {
|
||||
const targetPath = path.join(pluginDir, entry.name);
|
||||
if (!shouldRemoveRuntimeDepsTempDir(targetPath)) {
|
||||
continue;
|
||||
}
|
||||
for (let attempt = 0; attempt <= TEMP_REMOVE_RETRY_DELAYS_MS.length; attempt += 1) {
|
||||
try {
|
||||
removePathIfExists(targetPath);
|
||||
@@ -1243,7 +1290,10 @@ export function stageBundledPluginRuntimeDeps(params = {}) {
|
||||
}
|
||||
|
||||
export const __testing = {
|
||||
removeStaleRuntimeDepsTempDirs,
|
||||
replaceDirAtomically,
|
||||
runNpmInstall,
|
||||
writeRuntimeDepsTempOwner,
|
||||
};
|
||||
|
||||
if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) {
|
||||
|
||||
@@ -327,6 +327,73 @@ describe("stageBundledPluginRuntimeDeps", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps runtime deps temp dirs owned by a live build process", () => {
|
||||
const { pluginDir, repoRoot } = createBundledPluginFixture({
|
||||
packageJson: {
|
||||
name: "@openclaw/fixture-plugin",
|
||||
version: "1.0.0",
|
||||
dependencies: { "left-pad": "1.3.0" },
|
||||
openclaw: { bundle: { stageRuntimeDependencies: true } },
|
||||
},
|
||||
});
|
||||
const activeTempDir = path.join(pluginDir, ".openclaw-runtime-deps-stage-active");
|
||||
fs.mkdirSync(activeTempDir, { recursive: true });
|
||||
stageBundledPluginRuntimeDepsTesting.writeRuntimeDepsTempOwner(activeTempDir);
|
||||
fs.writeFileSync(path.join(activeTempDir, "marker.txt"), "active\n", "utf8");
|
||||
|
||||
stageBundledPluginRuntimeDeps({
|
||||
cwd: repoRoot,
|
||||
installPluginRuntimeDepsImpl: ({ fingerprint, stampPath }: RuntimeDepsStampParams) => {
|
||||
const nodeModulesDir = path.join(pluginDir, "node_modules");
|
||||
fs.mkdirSync(nodeModulesDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(nodeModulesDir, "marker.txt"), "installed\n", "utf8");
|
||||
writeRuntimeDepsStamp(stampPath, fingerprint);
|
||||
},
|
||||
});
|
||||
|
||||
expect(fs.readFileSync(path.join(activeTempDir, "marker.txt"), "utf8")).toBe("active\n");
|
||||
expect(fs.readFileSync(path.join(pluginDir, "node_modules", "marker.txt"), "utf8")).toBe(
|
||||
"installed\n",
|
||||
);
|
||||
});
|
||||
|
||||
it("restores atomically replaced dirs when concurrent cleanup runs during rename failure", () => {
|
||||
const parentDir = createTempDir("openclaw-runtime-deps-replace-");
|
||||
const targetPath = path.join(parentDir, "node_modules");
|
||||
const sourcePath = path.join(parentDir, "source-node_modules");
|
||||
fs.mkdirSync(targetPath, { recursive: true });
|
||||
fs.writeFileSync(path.join(targetPath, "marker.txt"), "original\n", "utf8");
|
||||
fs.mkdirSync(sourcePath, { recursive: true });
|
||||
fs.writeFileSync(path.join(sourcePath, "marker.txt"), "replacement\n", "utf8");
|
||||
|
||||
const realRenameSync = fs.renameSync.bind(fs);
|
||||
let backupPath: string | null = null;
|
||||
vi.spyOn(fs, "renameSync").mockImplementation((oldPath, newPath) => {
|
||||
const oldPathString = String(oldPath);
|
||||
const newPathString = String(newPath);
|
||||
if (
|
||||
oldPathString === targetPath &&
|
||||
path.basename(newPathString).startsWith(".openclaw-runtime-deps-backup-")
|
||||
) {
|
||||
backupPath = newPathString;
|
||||
return realRenameSync(oldPath, newPath);
|
||||
}
|
||||
if (oldPathString === sourcePath && newPathString === targetPath) {
|
||||
expect(backupPath).not.toBeNull();
|
||||
stageBundledPluginRuntimeDepsTesting.removeStaleRuntimeDepsTempDirs(parentDir);
|
||||
expect(fs.existsSync(path.join(backupPath ?? "", "marker.txt"))).toBe(true);
|
||||
throw new Error("rename failed after backup");
|
||||
}
|
||||
return realRenameSync(oldPath, newPath);
|
||||
});
|
||||
|
||||
expect(() =>
|
||||
stageBundledPluginRuntimeDepsTesting.replaceDirAtomically(targetPath, sourcePath),
|
||||
).toThrow("rename failed after backup");
|
||||
|
||||
expect(fs.readFileSync(path.join(targetPath, "marker.txt"), "utf8")).toBe("original\n");
|
||||
});
|
||||
|
||||
it("restages when installed root runtime dependency contents change", () => {
|
||||
const { pluginDir, repoRoot } = createBundledPluginFixture({
|
||||
packageJson: {
|
||||
|
||||
Reference in New Issue
Block a user