From 5e76b5a742d6479cc2961ef09425baadfbdea4d5 Mon Sep 17 00:00:00 2001 From: Alix-007 Date: Wed, 26 Aug 2026 02:34:33 +0800 Subject: [PATCH] fix(hooks): reload mutable handlers after restored-mtime edits (#128477) --- src/hooks/import-url.test.ts | 47 ++++++++++++++++++++++++++++++------ src/hooks/import-url.ts | 6 ++--- 2 files changed, 42 insertions(+), 11 deletions(-) diff --git a/src/hooks/import-url.test.ts b/src/hooks/import-url.test.ts index ceb2ed64948b..01bf42a7d2b1 100644 --- a/src/hooks/import-url.test.ts +++ b/src/hooks/import-url.test.ts @@ -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+/); diff --git a/src/hooks/import-url.ts b/src/hooks/import-url.ts index 16c5a40e6303..883879a79db9 100644 --- a/src/hooks/import-url.ts +++ b/src/hooks/import-url.ts @@ -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=&s=` so the module key + * restarts. For those we append `?t=&c=&s=` 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()}`;