fix(hooks): reload mutable handlers after restored-mtime edits (#128477)

This commit is contained in:
Alix-007
2026-08-26 02:34:33 +08:00
committed by GitHub
parent 9a8755f6e3
commit 5e76b5a742
2 changed files with 42 additions and 11 deletions
+39 -8
View File
@@ -2,7 +2,7 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import { buildImportUrl } from "./import-url.js";
describe("buildImportUrl", () => {
@@ -25,23 +25,24 @@ describe("buildImportUrl", () => {
expect(url).toMatch(/^file:\/\//);
});
it("appends mtime-based cache buster for workspace hooks", () => {
it("appends file-metadata cache buster for workspace hooks", () => {
const url = buildImportUrl(tmpFile, "openclaw-workspace");
expect(url).toMatch(/\?t=[\d.]+&s=\d+/);
expect(url).toMatch(/\?t=[\d.]+&c=[\d.]+&s=\d+/);
const { mtimeMs, size } = fs.statSync(tmpFile);
const { ctimeMs, mtimeMs, size } = fs.statSync(tmpFile);
expect(url).toContain(`?t=${mtimeMs}`);
expect(url).toContain(`&c=${ctimeMs}`);
expect(url).toContain(`&s=${size}`);
});
it("appends mtime-based cache buster for managed hooks", () => {
it("appends file-metadata cache buster for managed hooks", () => {
const url = buildImportUrl(tmpFile, "openclaw-managed");
expect(url).toMatch(/\?t=[\d.]+&s=\d+/);
expect(url).toMatch(/\?t=[\d.]+&c=[\d.]+&s=\d+/);
});
it("appends mtime-based cache buster for plugin hooks", () => {
it("appends file-metadata cache buster for plugin hooks", () => {
const url = buildImportUrl(tmpFile, "openclaw-plugin");
expect(url).toMatch(/\?t=[\d.]+&s=\d+/);
expect(url).toMatch(/\?t=[\d.]+&c=[\d.]+&s=\d+/);
});
it("returns same URL for bundled hooks across calls (cacheable)", () => {
@@ -56,6 +57,36 @@ describe("buildImportUrl", () => {
expect(url1).toBe(url2);
});
it("reloads a workspace hook after a same-size edit with restored mtime", async () => {
const initialSource = 'export default () => "before";\n';
const editedSource = 'export default () => "after!";\n';
expect(Buffer.byteLength(editedSource)).toBe(Buffer.byteLength(initialSource));
fs.writeFileSync(tmpFile, initialSource);
const cleanTime = new Date(Math.floor(Date.now() / 1000) * 1000);
fs.utimesSync(tmpFile, cleanTime, cleanTime);
const initialStat = fs.statSync(tmpFile);
const initialUrl = buildImportUrl(tmpFile, "openclaw-workspace");
const initialHandler = (await import(/* @vite-ignore */ initialUrl)).default as () => string;
expect(initialHandler()).toBe("before");
await vi.waitFor(
() => {
fs.writeFileSync(tmpFile, editedSource);
fs.utimesSync(tmpFile, initialStat.atime, initialStat.mtime);
expect(fs.statSync(tmpFile).ctimeMs).not.toBe(initialStat.ctimeMs);
},
{ interval: 1, timeout: 1_000 },
);
const editedStat = fs.statSync(tmpFile);
expect(editedStat.size).toBe(initialStat.size);
expect(editedStat.mtimeMs).toBe(initialStat.mtimeMs);
const editedUrl = buildImportUrl(tmpFile, "openclaw-workspace");
const editedHandler = (await import(/* @vite-ignore */ editedUrl)).default as () => string;
expect(editedHandler()).toBe("after!");
});
it("falls back to Date.now() when file does not exist", () => {
const url = buildImportUrl("/nonexistent/handler.js", "openclaw-workspace");
expect(url).toMatch(/\?t=\d+/);
+3 -3
View File
@@ -6,7 +6,7 @@
* module cache across gateway restarts.
*
* Workspace, managed, and plugin hooks may be edited by the user between
* restarts. For those we append `?t=<mtime>&s=<size>` so the module key
* restarts. For those we append `?t=<mtime>&c=<ctime>&s=<size>` so the module key
* reflects on-disk changes while staying stable for unchanged files.
*/
@@ -29,8 +29,8 @@ export function buildImportUrl(handlerPath: string, source: HookSource): string
// Use file metadata so the cache key only changes when the file changes
try {
const { mtimeMs, size } = fs.statSync(handlerPath);
return `${base}?t=${mtimeMs}&s=${size}`;
const { ctimeMs, mtimeMs, size } = fs.statSync(handlerPath);
return `${base}?t=${mtimeMs}&c=${ctimeMs}&s=${size}`;
} catch {
// If stat fails (unlikely), fall back to Date.now() to guarantee freshness
return `${base}?t=${Date.now()}`;