diff --git a/CHANGELOG.md b/CHANGELOG.md index 4173e7ea6a8..8239bc1b347 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,7 @@ Docs: https://docs.openclaw.ai - Tasks: keep terminal mirrored TaskFlow timestamps pinned to task completion time and let maintenance repair stale mirrors, so ACP terminal delivery updates no longer leave inconsistent flow audits. Refs #73609. Thanks @joerod26. - Gateway/sessions: add conservative stuck-session recovery that releases only stale session lanes while active embedded runs, reply operations, and lane tasks remain serialized, so queued follow-ups can drain without aborting legitimate long-running turns. Refs #73581, #73655, #73652, #73705, #73647, #73602, #73592, and #73601. Thanks @WS-Q0758, @bryangauvin, @spenceryang1996-dot, @bmilne1981, @mattmcintyre, @Vksh07, and @Spolen23. - Plugins: cache unchanged plugin manifest loads by file signature, reducing repeated JSON/JSON5 parsing and manifest normalization in bursty startup and runtime registry paths. Refs #73532 and #73647; carries forward #73678. Thanks @TheDutchRuler. +- Plugins/runtime-deps: cache unchanged bundled runtime mirror dist-file materialization decisions and close file-lock handles on owner-write failures, reducing repeated startup chunk scans and avoiding FileHandle-GC recovery stalls. Refs #73532. Thanks @oadiazp and @bstanbury. - CLI/TUI: keep `chat.history` off model-catalog discovery so initial Gateway-backed TUI history loads cannot block behind slow provider/plugin model scans on low-core hosts. Refs #73524. Thanks @harshcatsystems-collab. - Channels/WhatsApp: flag recently reconnected linked accounts in channel status even when the socket is currently healthy, so flapping WhatsApp Web sessions no longer look clean after a brief reconnect. Refs #73602. Thanks @Vksh07. - Agents/model selection: resolve slash-form aliases before provider/model parsing and keep alias-resolved primary models subject to transient provider cooldowns, so cron and persisted sessions do not retry cooled-down raw aliases. Fixes #73573 and #73657. Thanks @akai-shuuichi and @hashslingers. diff --git a/src/plugin-sdk/file-lock.test.ts b/src/plugin-sdk/file-lock.test.ts index 42c91a4a3a9..ca3611f5e21 100644 --- a/src/plugin-sdk/file-lock.test.ts +++ b/src/plugin-sdk/file-lock.test.ts @@ -1,7 +1,7 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; -import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { acquireFileLock, drainFileLockStateForTest, @@ -55,4 +55,28 @@ describe("acquireFileLock", () => { return true; }); }, 5_000); + + it("closes an opened lock handle when writing the owner payload fails", async () => { + const filePath = path.join(tempDir, "write-fails"); + const writeError = new Error("owner write failed"); + const close = vi.fn().mockResolvedValue(undefined); + vi.spyOn(fs, "open").mockResolvedValue({ + close, + writeFile: vi.fn().mockRejectedValue(writeError), + } as unknown as Awaited>); + + await expect( + acquireFileLock(filePath, { + retries: { + retries: 0, + factor: 1, + minTimeout: 1, + maxTimeout: 1, + }, + stale: 100, + }), + ).rejects.toThrow(writeError); + + expect(close).toHaveBeenCalledTimes(1); + }); }); diff --git a/src/plugin-sdk/file-lock.ts b/src/plugin-sdk/file-lock.ts index 356c4011481..28d1f939d77 100644 --- a/src/plugin-sdk/file-lock.ts +++ b/src/plugin-sdk/file-lock.ts @@ -183,10 +183,16 @@ export async function acquireFileLock( for (let attempt = 0; attempt <= options.retries.retries; attempt += 1) { try { const handle = await fs.open(lockPath, "wx"); - await handle.writeFile( - JSON.stringify({ pid: process.pid, createdAt: new Date().toISOString() }, null, 2), - "utf8", - ); + try { + await handle.writeFile( + JSON.stringify({ pid: process.pid, createdAt: new Date().toISOString() }, null, 2), + "utf8", + ); + } catch (writeError) { + await handle.close().catch(() => undefined); + await fs.rm(lockPath, { force: true }).catch(() => undefined); + throw writeError; + } HELD_LOCKS.set(normalizedFile, { count: 1, handle, lockPath }); return { lockPath, diff --git a/src/plugins/bundled-runtime-deps.test.ts b/src/plugins/bundled-runtime-deps.test.ts index 552a8adcb11..fde5b797335 100644 --- a/src/plugins/bundled-runtime-deps.test.ts +++ b/src/plugins/bundled-runtime-deps.test.ts @@ -25,6 +25,7 @@ import { resolveBundledRuntimeDependencyInstallRootPlan, resolveBundledRuntimeDepsNpmRunner, scanBundledPluginRuntimeDeps, + shouldMaterializeBundledRuntimeMirrorDistFile, type BundledRuntimeDepsInstallParams, } from "./bundled-runtime-deps.js"; @@ -99,11 +100,42 @@ afterEach(() => { spawnMock.mockReset(); spawnSyncMock.mockReset(); bundledRuntimeDepsActivityTesting.resetBundledRuntimeDepsInstallActivity(); + bundledRuntimeDepsTesting.clearBundledRuntimeMirrorMaterializeCache(); for (const dir of tempDirs.splice(0)) { fs.rmSync(dir, { recursive: true, force: true }); } }); +describe("shouldMaterializeBundledRuntimeMirrorDistFile", () => { + it("reuses unchanged root dist file decisions without rereading source", () => { + const root = makeTempDir(); + const sourcePath = path.join(root, "shared-runtime.js"); + fs.writeFileSync( + sourcePath, + [ + `//#region extensions/browser/src/runtime.ts`, + `export const marker = "shared-runtime";`, + `//#endregion`, + "", + ].join("\n"), + "utf8", + ); + const realReadFileSync = fs.readFileSync.bind(fs); + let sourceReads = 0; + vi.spyOn(fs, "readFileSync").mockImplementation(((target, options) => { + if (path.resolve(target.toString()) === path.resolve(sourcePath)) { + sourceReads += 1; + } + return realReadFileSync(target, options as never); + }) as typeof fs.readFileSync); + + expect(shouldMaterializeBundledRuntimeMirrorDistFile(sourcePath)).toBe(true); + expect(shouldMaterializeBundledRuntimeMirrorDistFile(sourcePath)).toBe(true); + + expect(sourceReads).toBe(1); + }); +}); + describe("resolveBundledRuntimeDepsNpmRunner", () => { it("ignores npm_execpath and uses the Node-adjacent npm CLI on Windows", () => { const execPath = "C:\\Program Files\\nodejs\\node.exe"; diff --git a/src/plugins/bundled-runtime-deps.ts b/src/plugins/bundled-runtime-deps.ts index 3ec9ee2652b..fc930e664ec 100644 --- a/src/plugins/bundled-runtime-deps.ts +++ b/src/plugins/bundled-runtime-deps.ts @@ -78,6 +78,10 @@ const BUNDLED_RUNTIME_MIRROR_IMPORT_SPECIFIER_RE = const NPM_EXECPATH_ENV_KEY = "npm_execpath"; const registeredBundledRuntimeDepNodePaths = new Set(); +const bundledRuntimeMirrorMaterializeCache = new Map< + string, + { signature: string; materialize: boolean } +>(); export type BundledRuntimeDepsNpmRunner = { command: string; @@ -85,10 +89,15 @@ export type BundledRuntimeDepsNpmRunner = { env?: NodeJS.ProcessEnv; }; -export function shouldMaterializeBundledRuntimeMirrorDistFile(sourcePath: string): boolean { - if (!BUNDLED_RUNTIME_MIRROR_MATERIALIZED_EXTENSIONS.has(path.extname(sourcePath))) { - return false; - } +function clearBundledRuntimeMirrorMaterializeCache(): void { + bundledRuntimeMirrorMaterializeCache.clear(); +} + +function statSignature(stat: Pick): string { + return `${stat.dev}:${stat.ino}:${stat.size}:${stat.mtimeMs}`; +} + +function computeBundledRuntimeMirrorDistFileMaterialization(sourcePath: string): boolean { let source: string; try { source = fs.readFileSync(sourcePath, "utf8"); @@ -113,6 +122,27 @@ export function shouldMaterializeBundledRuntimeMirrorDistFile(sourcePath: string return true; } +export function shouldMaterializeBundledRuntimeMirrorDistFile(sourcePath: string): boolean { + if (!BUNDLED_RUNTIME_MIRROR_MATERIALIZED_EXTENSIONS.has(path.extname(sourcePath))) { + return false; + } + const cacheKey = path.resolve(sourcePath); + let signature: string; + try { + signature = statSignature(fs.statSync(sourcePath)); + } catch { + bundledRuntimeMirrorMaterializeCache.delete(cacheKey); + return false; + } + const cached = bundledRuntimeMirrorMaterializeCache.get(cacheKey); + if (cached?.signature === signature) { + return cached.materialize; + } + const materialize = computeBundledRuntimeMirrorDistFileMaterialization(sourcePath); + bundledRuntimeMirrorMaterializeCache.set(cacheKey, { signature, materialize }); + return materialize; +} + export function materializeBundledRuntimeMirrorDistFile( sourcePath: string, targetPath: string, @@ -404,6 +434,7 @@ function formatRuntimeDepsLockTimeoutMessage(params: { } export const __testing = { + clearBundledRuntimeMirrorMaterializeCache, formatRuntimeDepsLockTimeoutMessage, shouldRemoveRuntimeDepsLock, };