diff --git a/docs/web/control-ui/development.md b/docs/web/control-ui/development.md index e89a7b06c67e..23a814f70442 100644 --- a/docs/web/control-ui/development.md +++ b/docs/web/control-ui/development.md @@ -25,6 +25,8 @@ For bundled builds, the Gateway retains manifest-verified assets so already-open Bundled public assets (themes, fonts, icons, and artwork) use `?v=` URLs with a one-year immutable HTTP cache. The ID includes a digest of the public files, so rebuilding changed files at the same commit also changes their URLs. The Gateway snapshots this identity at startup; restart it after rebuilding an in-place installation. Unversioned requests, stale IDs, documents, `sw.js`, and custom `gateway.controlUi.root` installs keep `Cache-Control: no-cache`. The service worker keeps its network-first policy for public assets, allowing the browser's HTTP cache to satisfy matching versioned requests. +The Gateway shares prepared bundled asset bytes across browsers, including Brotli and gzip variants. Cold file admission and reads run in a worker so simultaneous page loads do not block chat delivery. Custom roots continue to read current files on each request. + Non-index static assets use `Last-Modified` for conditional `GET` and `HEAD` requests. `If-None-Match` takes precedence over `If-Modified-Since`: `*` matches an existing asset, while other values receive the normal `200` response because static assets do not emit ETags. Date-only revalidation still returns `304` for unchanged assets. If no available content encoding is acceptable, the Gateway returns `406` before evaluating either condition. All three HTTP-date formats are interpreted as UTC. Invalid or repeated `If-Modified-Since` fields are ignored, so they cannot suppress the current asset bytes. A leap-second validator remains earlier than the following second. diff --git a/src/gateway/control-ui-conditional.http.test.ts b/src/gateway/control-ui-conditional.http.test.ts index fa9ce74b6c4f..f1da65e592ad 100644 --- a/src/gateway/control-ui-conditional.http.test.ts +++ b/src/gateway/control-ui-conditional.http.test.ts @@ -1,10 +1,12 @@ +import fsSync from "node:fs"; import fs from "node:fs/promises"; import { createServer, request, type IncomingMessage, type Server } from "node:http"; import path from "node:path"; -import { gzipSync } from "node:zlib"; -import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { brotliCompressSync, brotliDecompressSync, gunzipSync, gzipSync } from "node:zlib"; +import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"; import { createTempDirTracker } from "../../test/helpers/temp-dir.js"; import { handleControlUiHttpRequest } from "./control-ui.js"; +import type { ControlUiRootState } from "./server-control-ui-root.js"; const assetBody = Buffer.from('console.log("conditional fixture");\n'); const modifiedAt = new Date("2024-01-01T00:00:00.000Z"); @@ -150,6 +152,8 @@ describe.each([ await fs.mkdir(path.dirname(assetPath)); await fs.writeFile(assetPath, assetBody); await fs.writeFile(`${assetPath}.gz`, gzipSync(assetBody)); + await fs.writeFile(`${assetPath}.br`, brotliCompressSync(assetBody)); + await fs.writeFile(path.join(root, "assets", "cold.js"), assetBody); await fs.utimes(assetPath, modifiedAt, modifiedAt); for (const asset of [beforeLeapSecondAsset, afterLeapSecondAsset]) { const target = path.join(root, "assets", asset.filename); @@ -178,6 +182,12 @@ describe.each([ path.join(root, "index.html"), ``, ); + const rootState: ControlUiRootState = { + kind, + path: root, + realPath: root, + ...(kind === "bundled" ? { publicAssetBuildId: "fixture-build" } : {}), + }; server = createServer((req, res) => { res.setHeader( "X-Test-If-Modified-Since-Count", @@ -186,12 +196,7 @@ describe.each([ void handleControlUiHttpRequest(req, res, { basePath, config: {}, - root: { - kind, - path: root, - realPath: root, - ...(kind === "bundled" ? { publicAssetBuildId: "fixture-build" } : {}), - }, + root: rootState, }).catch((error: unknown) => { res.statusCode = 500; res.end(error instanceof Error ? error.message : String(error)); @@ -210,6 +215,49 @@ describe.each([ assetUrl = `${baseUrl}/assets/app-fixture.js`; }); + if (kind === "bundled" && basePath === "") { + it("serves warm representations and admits cold files without main-thread filesystem work", async () => { + await requestAsset(assetUrl, "GET", {}); + const operations = [ + "openSync", + "readSync", + "readFileSync", + "lstatSync", + "statSync", + "fstatSync", + "realpathSync", + ] as const; + const spies = operations.map((operation) => vi.spyOn(fsSync, operation)); + try { + for (const encoding of ["br", "gzip"] as const) { + const response = await requestAsset(assetUrl, "GET", { "Accept-Encoding": encoding }); + expect(response.response.headers["content-encoding"]).toBe(encoding); + expect((encoding === "br" ? brotliDecompressSync : gunzipSync)(response.body)).toEqual( + assetBody, + ); + const head = await requestAsset(assetUrl, "HEAD", { "Accept-Encoding": encoding }); + expect(head.body.byteLength).toBe(0); + expect(head.response.headers["content-length"]).toBe(String(response.body.byteLength)); + const unchanged = await requestAsset(assetUrl, "GET", { + "Accept-Encoding": encoding, + "If-Modified-Since": lastModified, + }); + expect(unchanged.response.statusCode).toBe(304); + } + expect((await requestAsset(`${baseUrl}/assets/cold.js`, "GET", {})).body).toEqual( + assetBody, + ); + for (const spy of spies) { + expect(spy).not.toHaveBeenCalled(); + } + } finally { + for (const spy of spies) { + spy.mockRestore(); + } + } + }); + } + afterAll(async () => { try { const listeningServer = server; diff --git a/src/gateway/control-ui-file.test.ts b/src/gateway/control-ui-file.test.ts new file mode 100644 index 000000000000..d1d1f3342fc3 --- /dev/null +++ b/src/gateway/control-ui-file.test.ts @@ -0,0 +1,90 @@ +import fs from "node:fs"; +import path from "node:path"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { useAutoCleanupTempDirTracker } from "../../test/helpers/temp-dir.js"; +import { readControlUiFile } from "./control-ui-file.js"; + +const tempDirs = useAutoCleanupTempDirTracker(afterEach); +afterEach(() => vi.restoreAllMocks()); + +function createFile(body: string) { + const rootPath = tempDirs.make("openclaw-ui-read-"); + const filePath = path.join(rootPath, "asset.txt"); + fs.writeFileSync(filePath, body); + vi.spyOn(fs, "openSync"); + vi.spyOn(fs, "closeSync"); + return { rootPath, filePath, rejectHardlinks: true, readBody: true }; +} + +function expectClosed() { + expect(fs.openSync).toHaveBeenCalled(); + expect(vi.mocked(fs.closeSync).mock.calls.map(([fd]) => fd)).toEqual( + vi + .mocked(fs.openSync) + .mock.results.filter((result) => result.type === "return") + .map((result) => result.value), + ); +} + +describe("pinned Control UI file reads", () => { + it.each([0, 512 * 1024 + 19])("reads and closes a file of %i bytes", (size) => { + const body = "x".repeat(size); + const file = createFile(body); + const result = readControlUiFile(file); + expect(result?.body).toBeInstanceOf(Uint8Array); + expect(result && new TextDecoder().decode(result.body)).toBe(body); + expectClosed(); + }); + + it("fills short reads without adding bytes beyond the pinned size", () => { + const body = "a small static response"; + const file = createFile(body); + const read = fs.readSync; + vi.spyOn(fs, "readSync").mockImplementation((fd, buffer, options) => { + fs.appendFileSync(file.filePath, "extra"); + return read(fd, buffer, { ...options, length: Math.min(options?.length ?? 0, 3) }); + }); + const result = readControlUiFile(file); + expect(result && new TextDecoder().decode(result.body)).toBe(body); + expectClosed(); + }); + + it("returns only bytes read when the pinned file is truncated", () => { + const file = createFile("original longer content"); + const read = fs.readSync; + vi.spyOn(fs, "readSync").mockImplementationOnce((fd, buffer, options) => { + fs.writeFileSync(file.filePath, "short"); + return read(fd, buffer, options); + }); + const result = readControlUiFile(file); + expect(result && new TextDecoder().decode(result.body)).toBe("short"); + expectClosed(); + }); + + it("closes the descriptor when a read fails", () => { + const file = createFile("response"); + const error = Object.assign(new Error("synthetic read failure"), { code: "EIO" }); + vi.spyOn(fs, "readSync").mockImplementationOnce(() => { + throw error; + }); + expect(() => readControlUiFile(file)).toThrow(error); + expectClosed(); + }); + + it("serves metadata above the body limit but closes before rejecting a body read", () => { + const file = createFile(""); + fs.truncateSync(file.filePath, 2 ** 31); + vi.spyOn(fs, "readSync"); + expect(readControlUiFile({ ...file, readBody: false })).toEqual({ + path: fs.realpathSync(file.filePath), + size: 2 ** 31, + mtimeMs: fs.statSync(file.filePath).mtimeMs, + }); + expect(fs.readSync).not.toHaveBeenCalled(); + expectClosed(); + expect(() => readControlUiFile(file)).toThrow( + expect.objectContaining({ code: "ERR_FS_FILE_TOO_LARGE" }), + ); + expectClosed(); + }); +}); diff --git a/src/gateway/control-ui-file.ts b/src/gateway/control-ui-file.ts new file mode 100644 index 000000000000..bc04cf1ca6cb --- /dev/null +++ b/src/gateway/control-ui-file.ts @@ -0,0 +1,78 @@ +import fs from "node:fs"; +import { matchRootFileOpenFailure, openRootFileSync } from "@openclaw/fs-safe/advanced"; + +export type ControlUiFileRead = { + rootPath: string; + rootRealPath?: string; + filePath: string; + rejectHardlinks: boolean; + readBody: boolean; +}; + +export type ControlUiFileSnapshot = { + path: string; + size: number; + mtimeMs: number; + body?: Uint8Array; +}; + +export type ControlUiPreparedFile = Omit & { body?: Buffer }; +export type ControlUiRootAsset = { + file: ControlUiPreparedFile; + br?: ControlUiPreparedFile | Error | null; + gzip?: ControlUiPreparedFile | Error | null; +}; + +export function readControlUiFile(input: ControlUiFileRead): ControlUiFileSnapshot | null { + const opened = openRootFileSync({ + absolutePath: input.filePath, + rootPath: input.rootPath, + rootRealPath: input.rootRealPath, + boundaryLabel: "control ui root", + skipLexicalRootCheck: true, + // Preserve in-root aliases while fs-safe rejects canonical targets outside the root. + rejectSymlinks: false, + rejectHardlinks: input.rejectHardlinks, + }); + if (!opened.ok) { + return matchRootFileOpenFailure(opened, { + io: (failure) => { + throw failure.error; + }, + fallback: () => null, + }); + } + try { + const snapshot: ControlUiFileSnapshot = { + path: opened.path, + size: opened.stat.size, + mtimeMs: opened.stat.mtimeMs, + }; + if (!input.readBody) { + return snapshot; + } + if (opened.stat.size > 2 ** 31 - 1) { + throw Object.assign(new RangeError("Control UI file exceeds the 2 GiB read limit"), { + code: "ERR_FS_FILE_TOO_LARGE", + }); + } + // An independent backing buffer transfers without copying pooled Buffer memory. + const body = new Uint8Array(opened.stat.size); + let offset = 0; + while (offset < body.length) { + const count = fs.readSync(opened.fd, body, { + offset, + length: Math.min(512 * 1024, body.length - offset), + position: offset, + }); + if (count === 0) { + break; + } + offset += count; + } + snapshot.body = offset === body.length ? body : body.slice(0, offset); + return snapshot; + } finally { + fs.closeSync(opened.fd); + } +} diff --git a/src/gateway/control-ui-file.worker.ts b/src/gateway/control-ui-file.worker.ts new file mode 100644 index 000000000000..3b97060ac27d --- /dev/null +++ b/src/gateway/control-ui-file.worker.ts @@ -0,0 +1,29 @@ +import { isRecord } from "@openclaw/normalization-core/record-coerce"; +import { serveWorkerTasks } from "../infra/worker-task-server.js"; +import { readControlUiFile, type ControlUiFileSnapshot } from "./control-ui-file.js"; + +serveWorkerTasks( + (input) => { + if ( + !isRecord(input) || + typeof input.rootPath !== "string" || + (input.rootRealPath !== undefined && typeof input.rootRealPath !== "string") || + typeof input.filePath !== "string" || + typeof input.rejectHardlinks !== "boolean" || + typeof input.readBody !== "boolean" + ) { + throw new Error("Invalid Control UI file read request"); + } + return readControlUiFile({ + rootPath: input.rootPath, + rootRealPath: input.rootRealPath, + filePath: input.filePath, + rejectHardlinks: input.rejectHardlinks, + readBody: input.readBody, + }); + }, + { + transferList: (snapshot) => + snapshot?.body?.buffer instanceof ArrayBuffer ? [snapshot.body.buffer] : [], + }, +); diff --git a/src/gateway/control-ui-response-metadata.http.test.ts b/src/gateway/control-ui-response-metadata.http.test.ts index c252fa498f03..c190bbc966b7 100644 --- a/src/gateway/control-ui-response-metadata.http.test.ts +++ b/src/gateway/control-ui-response-metadata.http.test.ts @@ -3,7 +3,8 @@ import type { AddressInfo } from "node:net"; import { afterAll, beforeAll, describe, expect, it } from "vitest"; import { respondNotFound, respondPlainText } from "./control-ui-http-utils.js"; import { respondControlUiNotAcceptable } from "./control-ui-static.js"; -import { handleControlUiHttpRequest, type ControlUiRootState } from "./control-ui.js"; +import { handleControlUiHttpRequest } from "./control-ui.js"; +import type { ControlUiRootState } from "./server-control-ui-root.js"; type HttpResult = { body: Buffer; diff --git a/src/gateway/control-ui-static.test.ts b/src/gateway/control-ui-static.test.ts deleted file mode 100644 index 53d44b20b0ad..000000000000 --- a/src/gateway/control-ui-static.test.ts +++ /dev/null @@ -1,75 +0,0 @@ -import fs from "node:fs"; -import path from "node:path"; -import { afterEach, describe, expect, it, vi } from "vitest"; -import { useAutoCleanupTempDirTracker } from "../../test/helpers/temp-dir.js"; -import { readAndCloseControlUiFile } from "./control-ui-static.js"; - -const tempDirs = useAutoCleanupTempDirTracker(afterEach); -afterEach(() => vi.restoreAllMocks()); - -type ReadChunk = ( - fd: number, - buffer: Buffer, - offset: number, - length: number, - position: number | null, - callback: (error: NodeJS.ErrnoException | null, bytesRead: number, buffer: Buffer) => void, -) => void; - -function openFile(body: string) { - const filePath = path.join(tempDirs.make("openclaw-ui-read-"), "asset.txt"); - fs.writeFileSync(filePath, body); - const fd = fs.openSync(filePath, "r"); - vi.spyOn(fs, "closeSync"); - return { filePath, fd, size: fs.fstatSync(fd).size }; -} - -function expectClosed(fd: number) { - // Observe release itself: after awaiting the read, another worker may reuse the fd number. - expect(fs.closeSync).toHaveBeenCalledWith(fd); -} - -describe("pinned Control UI file reads", () => { - it.each([0, 512 * 1024 + 19])("reads and closes a file of %i bytes", async (size) => { - const body = "x".repeat(size); - const file = openFile(body); - const result = await readAndCloseControlUiFile(file); - expect(result.toString()).toBe(body); - expectClosed(file.fd); - }); - - it("fills short reads without adding bytes beyond the pinned size", async () => { - const file = openFile("a small static response"); - const read = fs.read; - const shortRead: ReadChunk = (fd, buffer, offset, length, position, callback) => - read(fd, buffer, offset, Math.min(length, 3), position, callback); - vi.spyOn(fs, "read").mockImplementation(shortRead as typeof fs.read); - expect((await readAndCloseControlUiFile(file)).toString()).toBe("a small static response"); - expectClosed(file.fd); - }); - - it("returns only bytes read when the pinned file is truncated", async () => { - const file = openFile("original longer content"); - fs.writeFileSync(file.filePath, "short"); - expect((await readAndCloseControlUiFile(file)).toString()).toBe("short"); - expectClosed(file.fd); - }); - - it("closes the descriptor when a read fails", async () => { - const file = openFile("response"); - const error = Object.assign(new Error("synthetic read failure"), { code: "EIO" }); - const failRead: ReadChunk = (_fd, buffer, _offset, _length, _position, callback) => - queueMicrotask(() => callback(error, 0, buffer)); - vi.spyOn(fs, "read").mockImplementation(failRead as typeof fs.read); - await expect(readAndCloseControlUiFile(file)).rejects.toBe(error); - expectClosed(file.fd); - }); - - it("retains the readFile allocation limit and closes before rejecting", async () => { - const file = openFile(""); - await expect(readAndCloseControlUiFile({ ...file, size: 2 ** 31 })).rejects.toMatchObject({ - code: "ERR_FS_FILE_TOO_LARGE", - }); - expectClosed(file.fd); - }); -}); diff --git a/src/gateway/control-ui-static.ts b/src/gateway/control-ui-static.ts index e682a79db53e..b351c6bf64db 100644 --- a/src/gateway/control-ui-static.ts +++ b/src/gateway/control-ui-static.ts @@ -1,5 +1,4 @@ -// Control UI static-response policy: MIME types, caching, encoding, and pinned-file reads. -import fs from "node:fs"; +// Control UI static-response policy: MIME types, caching, and encoding. import type { IncomingMessage, ServerResponse } from "node:http"; import path from "node:path"; import { promisify } from "node:util"; @@ -11,6 +10,7 @@ import { } from "../infra/http-content-encoding.js"; import { pruneMapToMaxSize } from "../infra/map-size.js"; import { getOrCreatePromise } from "../shared/lazy-promise.js"; +import type { ControlUiRootAsset } from "./control-ui-file.js"; import { respondPlainText } from "./control-ui-http-utils.js"; import { matchesHttpIfModifiedSince } from "./http-conditional.js"; @@ -72,46 +72,41 @@ export function resolveControlUiHtmlEncoding(req: IncomingMessage): ControlUiEnc ); } -type OpenedControlUiRepresentation = { - bodyFile: { path: string; fd: number; size: number }; +export function isControlUiCompressibleAsset(filePath: string): boolean { + return CONTROL_UI_COMPRESSIBLE_EXTENSIONS.has(path.extname(filePath).toLowerCase()); +} + +type ControlUiRepresentation = { + file: ControlUiRootAsset["file"]; encoding?: ControlUiContentEncoding; }; -export function resolveOpenedControlUiRepresentation(params: { +export function resolveControlUiRepresentation(params: { req: IncomingMessage; - sourceFile: { path: string; fd: number; size: number }; + asset: ControlUiRootAsset; contentPath: string; precompressed: boolean; - openPrecompressedFile: (filePath: string) => { path: string; fd: number; size: number } | null; -}): OpenedControlUiRepresentation | null { - const { req, sourceFile, precompressed, openPrecompressedFile } = params; - const extension = path.extname(params.contentPath).toLowerCase(); +}): ControlUiRepresentation | null { + const { req, asset, precompressed } = params; const encodings = resolveHttpContentEncodings( req.headers?.["accept-encoding"], - precompressed && CONTROL_UI_COMPRESSIBLE_EXTENSIONS.has(extension) + precompressed && isControlUiCompressibleAsset(params.contentPath) ? CONTROL_UI_DYNAMIC_ENCODINGS : new Set(), ); // A missing sidecar changes availability, not this request's encoding preferences. for (const selected of encodings) { if (selected === "identity") { - return { bodyFile: sourceFile }; + return { file: asset.file }; } - - const suffix = selected === "br" ? ".br" : ".gz"; - let compressedFile: { path: string; fd: number; size: number } | null; - try { - compressedFile = openPrecompressedFile(`${sourceFile.path}${suffix}`); - } catch (error) { - fs.closeSync(sourceFile.fd); - throw error; + const file = asset[selected]; + if (file instanceof Error) { + throw file; } - if (compressedFile) { - fs.closeSync(sourceFile.fd); - return { bodyFile: compressedFile, encoding: selected }; + if (file) { + return { file, encoding: selected }; } } - fs.closeSync(sourceFile.fd); return null; } @@ -203,7 +198,7 @@ function compressControlUiBody(body: Buffer, encoding: ControlUiContentEncoding) : compressGzip(body, { level: 6 }); } -export async function serveControlUiAsset( +export function serveControlUiAsset( res: ServerResponse, filePath: string, body: Buffer, @@ -257,41 +252,3 @@ export async function sendControlUiHtmlBody( setControlUiEncodingHeaders(res, ".html", encoding); res.end(encoding === "identity" ? body : await cachedCompressedControlUiHtml(body, encoding)); } - -// Reuse the stat captured by safe open: another queued fstat adds a full -// event-loop wait under load. Keep Node readFile's allocation and chunk limits; -// this read ends at the pinned size, even if the file subsequently grows. -export async function readAndCloseControlUiFile(file: { - fd: number; - size: number; -}): Promise { - try { - if (file.size > 2 ** 31 - 1) { - throw Object.assign(new RangeError("Control UI file exceeds the 2 GiB read limit"), { - code: "ERR_FS_FILE_TOO_LARGE", - }); - } - const buffer = Buffer.allocUnsafe(file.size); - let offset = 0; - while (offset < buffer.length) { - const length = Math.min(512 * 1024, buffer.length - offset); - const bytesRead = await new Promise((resolve, reject) => { - fs.read(file.fd, buffer, offset, length, null, (error, count) => { - if (error) { - reject(error); - } else { - resolve(count); - } - }); - }); - if (bytesRead === 0) { - break; - } - offset += bytesRead; - } - return buffer.subarray(0, offset); - } finally { - // Release before compression waits in zlib's worker queue. - fs.closeSync(file.fd); - } -} diff --git a/src/gateway/control-ui.http.test.ts b/src/gateway/control-ui.http.test.ts index bb848bd78b3d..94f5672636ec 100644 --- a/src/gateway/control-ui.http.test.ts +++ b/src/gateway/control-ui.http.test.ts @@ -37,7 +37,6 @@ import { CONTROL_UI_BOOTSTRAP_CONFIG_PATH, type ControlUiPluginFrameGrantAck, } from "./control-ui-contract.js"; -import { resolveOpenedControlUiRepresentation } from "./control-ui-static.js"; import { handleControlUiAssistantMediaRequest, handleControlUiAvatarRequest, @@ -3055,63 +3054,6 @@ describe("handleControlUiHttpRequest", () => { }); }); - it.each(["/", "/settings", "/assets/actual.txt"])( - "serves a pinned small file in one asynchronous filesystem operation at %s", - async (url) => { - await withControlUiRoot({ - fn: async (tmp) => { - await writeAssetFile(tmp, "actual.txt", "inside-ok\n"); - const read = vi.spyOn(fsSync, "read"); - const stat = vi.spyOn(fsSync, "stat"); - const fstat = vi.spyOn(fsSync, "fstat"); - const lstat = vi.spyOn(fsSync, "lstat"); - try { - const { res, end, handled } = await runControlUiRequest({ - url, - method: "GET", - rootPath: tmp, - }); - expect(handled).toBe(true); - expect(res.statusCode).toBe(200); - expect(responseBody(end)).toContain(url.startsWith("/assets/") ? "inside-ok" : " { - await withControlUiRoot({ - fn: async (tmp) => { - const { filePath } = await writeAssetFile(tmp, "actual.txt", "original"); - const fstat = fsSync.fstatSync; - vi.spyOn(fsSync, "fstatSync").mockImplementationOnce((fd) => { - const stat = fstat(fd); - fsSync.appendFileSync(filePath, "-appended-after-open"); - return stat; - }); - const { res, end } = await runControlUiRequest({ - url: "/assets/actual.txt", - method: "GET", - rootPath: tmp, - }); - expect(res.statusCode).toBe(200); - expect(responseBody(end)).toBe("original"); - }, - }); - }); - it("serves static assets without synchronous file reads", async () => { await withControlUiRoot({ fn: async (tmp) => { @@ -3172,34 +3114,25 @@ describe("handleControlUiHttpRequest", () => { const { filePath } = await writeAssetFile(tmp, "app-AbCd1234.js", source); await fs.writeFile(`${filePath}.br`, brotliCompressSync(source)); await fs.writeFile(`${filePath}.gz`, gzipSync(source)); - const closeSync = vi.spyOn(fsSync, "closeSync"); + const { res, end, setHeader, handled } = await runControlUiRequest({ + url: "/assets/app-AbCd1234.js", + method: "GET", + rootPath: tmp, + rootKind: "bundled", + headers: { "accept-encoding": "gzip;q=0.5, br, identity;q=0.1" }, + }); - try { - const { res, end, setHeader, handled } = await runControlUiRequest({ - url: "/assets/app-AbCd1234.js", - method: "GET", - rootPath: tmp, - rootKind: "bundled", - headers: { "accept-encoding": "gzip;q=0.5, br, identity;q=0.1" }, - }); - - expect(handled).toBe(true); - expect(res.statusCode).toBe(200); - expect(setHeader).toHaveBeenCalledWith( - "Cache-Control", - "public, max-age=31536000, immutable", - ); - expect(setHeader).toHaveBeenCalledWith("Vary", "Accept-Encoding"); - expect(setHeader).toHaveBeenCalledWith("Content-Encoding", "br"); - const compressed = end.mock.calls[0]?.[0]; - expect(Buffer.isBuffer(compressed)).toBe(true); - expect(brotliDecompressSync(compressed as Buffer).toString()).toBe(source); - expect(closeSync.mock.invocationCallOrder.at(-1)).toBeLessThan( - end.mock.invocationCallOrder[0] ?? Number.POSITIVE_INFINITY, - ); - } finally { - closeSync.mockRestore(); - } + expect(handled).toBe(true); + expect(res.statusCode).toBe(200); + expect(setHeader).toHaveBeenCalledWith( + "Cache-Control", + "public, max-age=31536000, immutable", + ); + expect(setHeader).toHaveBeenCalledWith("Vary", "Accept-Encoding"); + expect(setHeader).toHaveBeenCalledWith("Content-Encoding", "br"); + const compressed = end.mock.calls[0]?.[0]; + expect(Buffer.isBuffer(compressed)).toBe(true); + expect(brotliDecompressSync(compressed as Buffer).toString()).toBe(source); }, }); }); @@ -3348,40 +3281,6 @@ describe("handleControlUiHttpRequest", () => { }); }); - it("closes the source descriptor when opening a sidecar fails", async () => { - await withControlUiRoot({ - fn: async (tmp) => { - const { filePath } = await writeAssetFile(tmp, "app-MnOp3456.js", "source\n"); - const fd = fsSync.openSync(filePath, "r"); - const openError = Object.assign(new Error("descriptor limit"), { code: "EMFILE" }); - const closeSync = vi.spyOn(fsSync, "closeSync"); - - try { - expect(() => - resolveOpenedControlUiRepresentation({ - req: { - headers: { "accept-encoding": "br, identity;q=0" }, - } as IncomingMessage, - sourceFile: { path: filePath, fd, size: fsSync.fstatSync(fd).size }, - contentPath: filePath, - precompressed: true, - openPrecompressedFile: () => { - throw openError; - }, - }), - ).toThrow(openError); - expect(closeSync).toHaveBeenCalledWith(fd); - } finally { - const sourceWasClosed = closeSync.mock.calls.some(([closedFd]) => closedFd === fd); - closeSync.mockRestore(); - if (!sourceWasClosed) { - fsSync.closeSync(fd); - } - } - }, - }); - }); - it("keeps configured-root assets identity encoded and revalidated", async () => { await withControlUiRoot({ fn: async (tmp) => { @@ -3584,35 +3483,27 @@ describe("handleControlUiHttpRequest", () => { it.each([ ["index", "/"], ["SPA fallback", "/chat"], - ])("compresses %s HTML after closing its descriptor", async (_name, url) => { + ])("compresses prepared %s HTML", async (_name, url) => { const html = `${"hello ".repeat(200)}\n`; await withControlUiRoot({ indexHtml: html, fn: async (tmp) => { const { res, end, setHeader } = makeMockHttpResponse(); - const closeSync = vi.spyOn(fsSync, "closeSync"); - try { - await handleControlUiHttpRequest( - { - url, - method: "GET", - headers: { "accept-encoding": "gzip" }, - } as IncomingMessage, - res, - { root: { kind: "resolved", path: tmp } }, - ); + await handleControlUiHttpRequest( + { + url, + method: "GET", + headers: { "accept-encoding": "gzip" }, + } as IncomingMessage, + res, + { root: { kind: "resolved", path: tmp } }, + ); - expect(setHeader).toHaveBeenCalledWith("Cache-Control", "no-cache"); - expect(setHeader).toHaveBeenCalledWith("Content-Encoding", "gzip"); - expect(gunzipSync(end.mock.calls[0]?.[0] as Buffer).toString()).toContain( - '', - ); - expect(closeSync.mock.invocationCallOrder.at(-1)).toBeLessThan( - end.mock.invocationCallOrder[0] ?? Number.POSITIVE_INFINITY, - ); - } finally { - closeSync.mockRestore(); - } + expect(setHeader).toHaveBeenCalledWith("Cache-Control", "no-cache"); + expect(setHeader).toHaveBeenCalledWith("Content-Encoding", "gzip"); + expect(gunzipSync(end.mock.calls[0]?.[0] as Buffer).toString()).toContain( + '', + ); }, }); }); diff --git a/src/gateway/control-ui.ts b/src/gateway/control-ui.ts index 28a97686f887..ce423899bb53 100644 --- a/src/gateway/control-ui.ts +++ b/src/gateway/control-ui.ts @@ -17,11 +17,7 @@ import { } from "../agents/identity-avatar.js"; import { resolveGatewayPublicOrigin } from "../config/gateway-public-origin.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; -import { - matchRootFileOpenFailure, - openRootFileSync, - readFileDescriptorBounded, -} from "../infra/boundary-file-read.js"; +import { readFileDescriptorBounded } from "../infra/boundary-file-read.js"; import { resolveDevInstallGitBranch } from "../infra/dev-install-branch.js"; import { openLocalFileSafely, FsSafeError } from "../infra/fs-safe.js"; import { assertLocalMediaAllowed, LocalMediaAccessError } from "../media/local-media-access.js"; @@ -60,7 +56,6 @@ import { type AssistantMediaSession, type AssistantMediaReader, } from "./assistant-media-policy.js"; -import type { ControlUiAssetRetention } from "./control-ui-asset-retention.js"; import { resolveControlUiBootstrapPresentation } from "./control-ui-bootstrap-presentation.js"; import { buildControlUiRootAssetPath, @@ -82,6 +77,7 @@ import { buildControlUiCspHeader, computeInlineScriptHashes, } from "./control-ui-csp.js"; +import type { ControlUiRootAsset } from "./control-ui-file.js"; import { isReadHttpMethod, respondNotFound as respondControlUiNotFound, @@ -95,9 +91,8 @@ import { isControlUiFileUnmodified, isControlUiPrecompressedAssetExtension, isControlUiStaticAssetExtension, - readAndCloseControlUiFile, resolveControlUiHtmlEncoding, - resolveOpenedControlUiRepresentation, + resolveControlUiRepresentation, respondControlUiNotAcceptable, respondControlUiNotModified, respondHeadForControlUiFile, @@ -115,6 +110,7 @@ import { } from "./http-image-response.js"; import type { GatewayHttpRequestAuthOptions } from "./http-request-authority.js"; import { authorizeControlUiReadRequestOrReply } from "./http-utils.js"; +import { readControlUiRootAsset, type ControlUiRootState } from "./server-control-ui-root.js"; import { isTerminalConfigEnabled } from "./terminal/enabled.js"; const ROOT_PREFIX = "/"; @@ -135,21 +131,6 @@ type ControlUiRequestOptions = Partial & { root?: ControlUiRootState; }; -export type ControlUiRootState = - | { - kind: "bundled"; - path: string; - realPath?: string; - retainedAssets?: ControlUiAssetRetention; - publicAssetBuildId?: string; - } - | { kind: "resolved"; path: string; realPath?: string } - | { kind: "invalid"; path: string } - | { kind: "preparing" } - // The document route is unauthenticated; build diagnostics stay in Gateway logs. - | { kind: "failed" } - | { kind: "missing" }; - const CONTROL_UI_NAMESPACE_PREFIX = "/__openclaw__/"; /** Anchors bundled assets before deep-linked documents begin preloading. */ function rewriteControlUiIndexHtmlAssetHrefs( @@ -874,33 +855,6 @@ function isExpectedSafePathError(error: unknown): boolean { return code === "ENOENT" || code === "ENOTDIR" || code === "ELOOP"; } -function resolveSafeControlUiFile( - rootReal: string, - filePath: string, - rejectHardlinks: boolean, -): { path: string; fd: number; size: number; mtimeMs: number } | null { - const opened = openRootFileSync({ - absolutePath: filePath, - rootPath: rootReal, - rootRealPath: rootReal, - boundaryLabel: "control ui root", - skipLexicalRootCheck: true, - // Symlinked assets that resolve inside the root are served; fs-safe still - // rejects hops whose canonical target escapes the control-ui root. - rejectSymlinks: false, - rejectHardlinks, - }); - if (!opened.ok) { - return matchRootFileOpenFailure(opened, { - io: (failure) => { - throw failure.error; - }, - fallback: () => null, - }); - } - return { path: opened.path, fd: opened.fd, size: opened.stat.size, mtimeMs: opened.stat.mtimeMs }; -} - function isSafeRelativePath(relPath: string) { if (!relPath) { return false; @@ -1057,12 +1011,12 @@ export async function handleControlUiHttpRequest( } const root = rootState.path; - const rootReal = (() => { + const rootReal = await (async () => { if (rootState.realPath) { return rootState.realPath; } try { - return fs.realpathSync(root); + return await fs.promises.realpath(root); } catch (error) { if (isExpectedSafePathError(error)) { return null; @@ -1132,7 +1086,6 @@ export async function handleControlUiHttpRequest( respondControlUiNotFound(res); return true; } - const rejectHardlinks = !isBundledRoot; // Vite fingerprints every file emitted under the bundled assets directory. // Configured roots remain revalidated because their naming is not our contract. const fingerprintedAsset = isBundledRoot && fileRel.startsWith("assets/"); @@ -1144,69 +1097,12 @@ export async function handleControlUiHttpRequest( url.searchParams.get("v") === publicAssetBuildId && isControlUiVersionedPublicAsset(fileRel), ); - let servingRootReal = rootReal; - let rejectRepresentationHardlinks = rejectHardlinks; - let safeFile = resolveSafeControlUiFile(rootReal, filePath, rejectHardlinks); - if (!safeFile && fingerprintedAsset && rootState.kind === "bundled") { - const retained = rootState.retainedAssets?.resolveAsset(fileRel); - if (retained) { - servingRootReal = retained.rootRealPath; - rejectRepresentationHardlinks = true; - safeFile = resolveSafeControlUiFile(retained.rootRealPath, retained.filePath, true); - } - } - // An index alias still owns document preparation when its physical target has - // another name. Preserve existing aliases that resolve to a canonical index too. - if ( - safeFile && - path.basename(fileRel) !== "index.html" && - path.basename(safeFile.path) !== "index.html" - ) { - // Future filesystem clocks must not make later replacements look unmodified; - // clamp to response origination as in resolveByteResponse. - const originatedAtMs = Date.now(); - const lastModifiedMs = Math.floor(Math.min(safeFile.mtimeMs, originatedAtMs) / 1_000) * 1_000; - const representation = resolveOpenedControlUiRepresentation({ - req, - sourceFile: safeFile, - contentPath: fileRel, - precompressed: fingerprintedAsset, - openPrecompressedFile: (compressedPath) => - resolveSafeControlUiFile(servingRootReal, compressedPath, rejectRepresentationHardlinks), - }); - if (!representation) { - respondControlUiNotAcceptable(res); - return true; - } - // Negotiation failures precede preconditions; release the selected representation on 304. - if (isControlUiFileUnmodified(req, lastModifiedMs, originatedAtMs)) { - fs.closeSync(representation.bodyFile.fd); - respondControlUiNotModified(res, { immutable: immutableAsset, lastModifiedMs }); - return true; - } - if (req.method === "HEAD") { - try { - respondHeadForControlUiFile(res, fileRel, { - immutable: immutableAsset, - encoding: representation.encoding, - contentLength: representation.bodyFile.size, - lastModifiedMs, - }); - return true; - } finally { - fs.closeSync(representation.bodyFile.fd); - } - } - const body = await readAndCloseControlUiFile(representation.bodyFile); - await serveControlUiAsset(res, fileRel, body, { - immutable: immutableAsset, - encoding: representation.encoding, - lastModifiedMs, - }); - return true; - } - - if (!safeFile) { + const readBody = + req.method !== "HEAD" && + req.headers?.["if-none-match"] === undefined && + req.headers?.["if-modified-since"] === undefined; + let asset = await readControlUiRootAsset(rootState, fileRel, readBody); + if (!asset) { // Missing assets stay 404; dotted routes can still use the SPA document. if (isControlUiStaticAssetExtension(path.extname(fileRel).toLowerCase())) { respondControlUiNotFound(res); @@ -1217,41 +1113,80 @@ export async function handleControlUiHttpRequest( } const indexPath = path.resolve(root, "index.html"); if (filePath !== indexPath) { - safeFile = resolveSafeControlUiFile(rootReal, indexPath, rejectHardlinks); + fileRel = "index.html"; + asset = await readControlUiRootAsset(rootState, fileRel, readBody); } } - // Direct documents and SPA fallbacks share rewriting, CSP, encoding and fd ownership. - if (safeFile) { - if (req.method === "HEAD") { - try { + const serve = async (prepared: ControlUiRootAsset | null): Promise => { + if (!prepared) { + respondControlUiNotFound(res); + return; + } + // Both requested and physical index aliases retain document preparation. + if ( + path.basename(fileRel) === "index.html" || + path.basename(prepared.file.path) === "index.html" + ) { + if (req.method === "HEAD") { const encoding = resolveControlUiHtmlEncoding(req); if (encoding === "not-acceptable") { respondControlUiNotAcceptable(res); - return true; + return; } respondHeadForControlUiFile(res, "index.html", { encoding: encoding === "identity" ? undefined : encoding, }); - return true; - } finally { - fs.closeSync(safeFile.fd); + return; } + if (!prepared.file.body) { + return await serve(await readControlUiRootAsset(rootState, fileRel, true)); + } + await serveResolvedIndexHtml( + req, + res, + prepared.file.body.toString("utf8"), + basePath, + terminalEnabled, + opts?.config?.gateway?.controlUi?.environment, + publicAssetBuildId, + ); + return; } - const body = (await readAndCloseControlUiFile(safeFile)).toString("utf8"); - await serveResolvedIndexHtml( + const originatedAtMs = Date.now(); + const lastModifiedMs = + Math.floor(Math.min(prepared.file.mtimeMs, originatedAtMs) / 1_000) * 1_000; + const representation = resolveControlUiRepresentation({ req, - res, - body, - basePath, - terminalEnabled, - opts?.config?.gateway?.controlUi?.environment, - publicAssetBuildId, - ); - return true; - } - - respondControlUiNotFound(res); + asset: prepared, + contentPath: fileRel, + precompressed: fingerprintedAsset, + }); + if (!representation) { + respondControlUiNotAcceptable(res); + return; + } + if (isControlUiFileUnmodified(req, lastModifiedMs, originatedAtMs)) { + respondControlUiNotModified(res, { immutable: immutableAsset, lastModifiedMs }); + return; + } + const headers = { + immutable: immutableAsset, + encoding: representation.encoding, + lastModifiedMs, + }; + if (req.method === "HEAD") { + respondHeadForControlUiFile(res, fileRel, { + ...headers, + contentLength: representation.file.size, + }); + } else if (representation.file.body) { + serveControlUiAsset(res, fileRel, representation.file.body, headers); + } else { + await serve(await readControlUiRootAsset(rootState, fileRel, true)); + } + }; + await serve(asset); return true; } /* oxlint-disable max-lines -- TODO: split this grandfathered oversized file. */ diff --git a/src/gateway/server-control-ui-root.identity.test.ts b/src/gateway/server-control-ui-root.identity.test.ts index cf4152314ea1..a304f65ccf56 100644 --- a/src/gateway/server-control-ui-root.identity.test.ts +++ b/src/gateway/server-control-ui-root.identity.test.ts @@ -26,8 +26,11 @@ vi.mock("../version.js", async (importOriginal) => ({ resolveRuntimeServiceBuildId: () => fixture.buildId, })); -import { handleControlUiHttpRequest, type ControlUiRootState } from "./control-ui.js"; -import { createGatewayControlUiRootLifecycle } from "./server-control-ui-root.js"; +import { handleControlUiHttpRequest } from "./control-ui.js"; +import { + createGatewayControlUiRootLifecycle, + type ControlUiRootState, +} from "./server-control-ui-root.js"; import { makeMockHttpResponse } from "./test-http-response.js"; async function requestIndex(root: ControlUiRootState) { diff --git a/src/gateway/server-control-ui-root.resolve.test.ts b/src/gateway/server-control-ui-root.resolve.test.ts index 81d78b39f8c7..6111cb467701 100644 --- a/src/gateway/server-control-ui-root.resolve.test.ts +++ b/src/gateway/server-control-ui-root.resolve.test.ts @@ -3,6 +3,7 @@ import fs from "node:fs"; import path from "node:path"; import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; import { createDeferred } from "../../test/helpers/promise.js"; +import { WorkerTaskPool } from "../infra/worker-task-pool.js"; import { getActiveGatewayRootWorkCount, markGatewayRestartDraining, @@ -28,7 +29,10 @@ vi.mock("./control-ui-asset-retention.js", () => ({ createControlUiAssetRetention: vi.fn(() => retentionMocks), })); -import { createGatewayControlUiRootLifecycle } from "./server-control-ui-root.js"; +import { + createGatewayControlUiRootLifecycle, + readControlUiRootAsset, +} from "./server-control-ui-root.js"; function readyAssets(root = "/repo/dist/control-ui", publicAssetBuildId?: string) { return { kind: "ready", indexPath: `${root}/index.html`, publicAssetBuildId }; @@ -84,6 +88,19 @@ describe("createGatewayControlUiRootLifecycle", () => { expect(controlUiAssetsMocks.ensureControlUiAssetsBuilt).not.toHaveBeenCalled(); }); + test("does not admit a first file read after its root has stopped", async () => { + controlUiAssetsMocks.resolveControlUiRootSync.mockReturnValue("/repo/dist/control-ui"); + const { lifecycle } = createLifecycle(); + const root = lifecycle.state; + if (root.kind !== "resolved") { + throw new Error("Expected a prepared root"); + } + const read = vi.spyOn(WorkerTaskPool.prototype, "run").mockResolvedValue(null); + await lifecycle.stop(); + expect(() => readControlUiRootAsset(root, "index.html", true)).toThrow(); + expect(read).not.toHaveBeenCalled(); + }); + test("prepares retained generations for bundled roots without delaying construction", async () => { controlUiAssetsMocks.resolveControlUiRootSync.mockReturnValue("/repo/dist/control-ui"); controlUiAssetsMocks.isPackageProvenControlUiRootSync.mockReturnValue(true); diff --git a/src/gateway/server-control-ui-root.ts b/src/gateway/server-control-ui-root.ts index 2d6172391e43..978b37f94814 100644 --- a/src/gateway/server-control-ui-root.ts +++ b/src/gateway/server-control-ui-root.ts @@ -8,14 +8,181 @@ import { resolveControlUiRootOverrideSync, resolveControlUiRootSync, } from "../infra/control-ui-assets.js"; +import { resolveRuntimeProcessEntrypointUrl } from "../infra/runtime-process-url.js"; +import { WorkerTaskPool } from "../infra/worker-task-pool.js"; import { getGatewayRestartDrainSignal, runOutsideGatewayRootWorkAdmission, } from "../process/gateway-work-admission.js"; import type { RuntimeEnv } from "../runtime.js"; +import { resolveGlobalSingleton } from "../shared/global-singleton.js"; import { resolveRuntimeServiceBuildId } from "../version.js"; -import { createControlUiAssetRetention } from "./control-ui-asset-retention.js"; -import type { ControlUiRootState } from "./control-ui.js"; +import { + createControlUiAssetRetention, + type ControlUiAssetRetention, +} from "./control-ui-asset-retention.js"; +import type { + ControlUiFileRead, + ControlUiFileSnapshot, + ControlUiPreparedFile, + ControlUiRootAsset, +} from "./control-ui-file.js"; +import { isControlUiCompressibleAsset } from "./control-ui-static.js"; + +export type ControlUiRootState = + | { + kind: "bundled"; + path: string; + realPath?: string; + retainedAssets?: ControlUiAssetRetention; + publicAssetBuildId?: string; + } + | { kind: "resolved"; path: string; realPath?: string } + | { kind: "invalid"; path: string } + | { kind: "preparing" } + // The document route is unauthenticated; build diagnostics stay in Gateway logs. + | { kind: "failed" } + | { kind: "missing" }; + +type ReadyRoot = Extract; +type RootFiles = { + controller: AbortController; + pending: Map>; + cached: Map; + bytes: number; +}; +type FileRuntime = { + pool?: WorkerTaskPool; + roots: WeakMap; +}; +const MAX_PREPARED_BYTES = 96 * 1024 * 1024; +const MAX_PREPARED_ENTRIES = 2_048; + +function fileRuntime() { + return resolveGlobalSingleton( + Symbol.for("openclaw.controlUiRootFiles"), + () => ({ roots: new WeakMap() }), + async (runtime) => { + const pool = runtime.pool; + runtime.pool = undefined; + runtime.roots = new WeakMap(); + await pool?.close(); + }, + ); +} + +function rootFiles(runtime: FileRuntime, root: ControlUiRootState): RootFiles { + let files = runtime.roots.get(root); + if (!files) { + files = { controller: new AbortController(), pending: new Map(), cached: new Map(), bytes: 0 }; + runtime.roots.set(root, files); + } + return files; +} + +/** Bundled bytes belong to this root generation; custom roots only share concurrent reads. */ +export function readControlUiRootAsset( + root: ReadyRoot, + fileRel: string, + readBody: boolean, +): Promise { + const runtime = fileRuntime(); + const owner = rootFiles(runtime, root); + owner.controller.signal.throwIfAborted(); + const key = `${readBody ? "body" : "metadata"}:${fileRel}`; + const cachedKey = owner.cached.has(`body:${fileRel}`) ? `body:${fileRel}` : key; + const cached = owner.cached.get(cachedKey); + if (cached) { + owner.cached.delete(cachedKey); + owner.cached.set(cachedKey, cached); + return Promise.resolve(cached.asset); + } + const pending = + owner.pending.get(key) ?? (!readBody ? owner.pending.get(`body:${fileRel}`) : undefined); + if (pending) { + return pending; + } + const pool = (runtime.pool ??= new WorkerTaskPool({ + workerUrl: resolveRuntimeProcessEntrypointUrl("controlUiFile"), + maxWorkers: 2, + sharedCompute: true, + maxPendingTasks: 2_048, + maxPendingBytes: 8 * 1024 * 1024, + })); + const read = async ( + input: Omit, + ): Promise => { + const file = await pool.run( + { ...input, readBody }, + { + signal: owner.controller.signal, + inputBytes: + 2 * (input.rootPath.length + (input.rootRealPath?.length ?? 0) + input.filePath.length), + }, + ); + return ( + file && { + ...file, + body: + file.body && Buffer.from(file.body.buffer, file.body.byteOffset, file.body.byteLength), + } + ); + }; + const preparation = (async (): Promise => { + let location = { + rootPath: root.path, + rootRealPath: root.realPath, + filePath: path.resolve(root.path, fileRel), + rejectHardlinks: root.kind !== "bundled", + }; + let file = await read(location); + if (!file && root.kind === "bundled" && fileRel.startsWith("assets/")) { + const retained = root.retainedAssets?.resolveAsset(fileRel); + if (retained) { + location = { ...retained, rootPath: retained.rootRealPath, rejectHardlinks: true }; + file = await read(location); + } + } + if (!file) { + return null; + } + const asset: ControlUiRootAsset = { file }; + if ( + root.kind === "bundled" && + fileRel.startsWith("assets/") && + isControlUiCompressibleAsset(fileRel) + ) { + const sourcePath = file.path; + const sidecar = (suffix: string) => + read({ ...location, filePath: `${sourcePath}${suffix}` }).catch((error: unknown) => + error instanceof Error ? error : new Error(String(error)), + ); + [asset.br, asset.gzip] = await Promise.all([sidecar(".br"), sidecar(".gz")]); + } + owner.controller.signal.throwIfAborted(); + if (root.kind === "bundled" && !(asset.br instanceof Error) && !(asset.gzip instanceof Error)) { + const bytes = + (file.body?.byteLength ?? 0) + + (asset.br?.body?.byteLength ?? 0) + + (asset.gzip?.body?.byteLength ?? 0); + if (bytes <= MAX_PREPARED_BYTES) { + owner.cached.set(key, { asset, bytes }); + owner.bytes += bytes; + while (owner.bytes > MAX_PREPARED_BYTES || owner.cached.size > MAX_PREPARED_ENTRIES) { + const oldest = owner.cached.entries().next().value; + if (!oldest) { + break; + } + owner.cached.delete(oldest[0]); + owner.bytes -= oldest[1].bytes; + } + } + } + return asset; + })().finally(() => owner.pending.delete(key)); + owner.pending.set(key, preparation); + return preparation; +} type GatewayControlUiRootParams = { controlUiRootOverride?: string; @@ -212,6 +379,12 @@ export function createGatewayControlUiRootLifecycle( stopped = true; preparation?.controller.abort(); await preparation?.promise; + // Retire even an unused root: a late HTTP handler must not start its first read after stop. + const files = rootFiles(fileRuntime(), state); + files.controller.abort(); + await Promise.allSettled(files.pending.values()); + files.cached.clear(); + files.bytes = 0; }, }; } diff --git a/src/gateway/server-http.ts b/src/gateway/server-http.ts index 24394461ebf7..a082b13d2944 100644 --- a/src/gateway/server-http.ts +++ b/src/gateway/server-http.ts @@ -38,7 +38,6 @@ import { } from "./control-ui-routing.js"; import { isControlUiSharePath } from "./control-ui-share.js"; import { normalizeControlUiBasePath } from "./control-ui-shared.js"; -import type { ControlUiRootState } from "./control-ui.js"; import { classifyGatewayProbePath, classifyMcpAppStandalonePath, @@ -66,6 +65,7 @@ import { handleProviderOAuthCallback, PROVIDER_OAUTH_CALLBACK_PATH, } from "./provider-browser-auth.js"; +import type { ControlUiRootState } from "./server-control-ui-root.js"; import { getControlUiModule, getControlUiPluginAssetsModule, diff --git a/src/gateway/server-runtime-state.ts b/src/gateway/server-runtime-state.ts index 37bb958eeaa9..2c13be5466b2 100644 --- a/src/gateway/server-runtime-state.ts +++ b/src/gateway/server-runtime-state.ts @@ -15,7 +15,6 @@ import type { PluginRuntimeCore } from "../plugins/runtime/types-core.js"; import { getSpawnBroker, runWithSpawnBroker } from "../process/spawn-broker/context.js"; import type { AuthRateLimiter } from "./auth-rate-limit.js"; import type { ResolvedGatewayAuth } from "./auth.js"; -import type { ControlUiRootState } from "./control-ui.js"; import type { NodeDesktopStreamBroker } from "./desktop/node-stream-broker.js"; import type { DesktopSessionRegistry } from "./desktop/session-registry.js"; import type { HooksConfigResolved } from "./hooks.js"; @@ -29,6 +28,7 @@ import { createSandboxHostHttpServer } from "./mcp-app-sandbox-http.js"; import { isLoopbackHost, resolveGatewayListenHosts } from "./net.js"; import { createGatewayPortalService, type GatewayPortalService } from "./portals/portal-service.js"; import { MAX_PREAUTH_PAYLOAD_BYTES } from "./server-constants.js"; +import type { ControlUiRootState } from "./server-control-ui-root.js"; import { attachGatewayUpgradeHandler, createGatewayHttpServer } from "./server-http.js"; import type { GatewayRequestContext } from "./server-methods/types.js"; import type { HookClientIpConfig, HooksRequestHandler } from "./server/hooks-request-handler.js"; diff --git a/src/infra/runtime-process-entrypoints.ts b/src/infra/runtime-process-entrypoints.ts index ae5a00092d8e..c3f8d1202db0 100644 --- a/src/infra/runtime-process-entrypoints.ts +++ b/src/infra/runtime-process-entrypoints.ts @@ -18,6 +18,7 @@ export const runtimeProcessEntrypoints = { stateRead: runtimeProcessEntrypoint("state/openclaw-state-read.worker"), spawnBroker: runtimeProcessEntrypoint("process/spawn-broker/worker"), cronStreamMatcher: runtimeProcessEntrypoint("gateway/cron-stream-matcher.worker"), + controlUiFile: runtimeProcessEntrypoint("gateway/control-ui-file.worker"), nativeHookRelayClient: runtimeProcessEntrypoint("agents/harness/native-hook-relay-client.worker"), computerHost: runtimeProcessEntrypoint("gateway/desktop/computer.worker"), imageProcessor: runtimeProcessEntrypoint("media/image-processor.worker"),