mirror of
https://github.com/openclaw/openclaw.git
synced 2026-10-03 09:39:25 +00:00
perf(gateway): keep Control UI file reads off the event loop (#158464)
* perf(gateway): keep Control UI file reads off the event loop * fix(gateway): break Control UI type import cycle Keep root state with the root lifecycle and prepared asset types with file reads. Preserve static HTTP behavior while removing the type cycle caught by the architecture gate.
This commit is contained in:
parent
c4257984ae
commit
9ee2b0b3b6
16 changed files with 584 additions and 434 deletions
|
|
@ -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=<build-id>` 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.
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
`<html data-openclaw-control-ui-build-id="source-build"><head><script>const fragment = 'href="unfinished';</script><script src="./assets/app-fixture.js"></script><link href="./assets/app.css"><link rel="icon" href="./favicon.svg"><link rel="icon" href="${basePath}/favicon-32.png"></head></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;
|
||||
|
|
|
|||
90
src/gateway/control-ui-file.test.ts
Normal file
90
src/gateway/control-ui-file.test.ts
Normal file
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
78
src/gateway/control-ui-file.ts
Normal file
78
src/gateway/control-ui-file.ts
Normal file
|
|
@ -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<ControlUiFileSnapshot, "body"> & { 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);
|
||||
}
|
||||
}
|
||||
29
src/gateway/control-ui-file.worker.ts
Normal file
29
src/gateway/control-ui-file.worker.ts
Normal file
|
|
@ -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<ControlUiFileSnapshot | null>(
|
||||
(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] : [],
|
||||
},
|
||||
);
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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<ControlUiContentEncoding>(),
|
||||
);
|
||||
// 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<Buffer> {
|
||||
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<number>((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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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" : "<html");
|
||||
// Safe open already captured stat; a second queued metadata read adds
|
||||
// another event-loop wait before these bytes can reach the browser.
|
||||
expect(read).toHaveBeenCalledOnce();
|
||||
expect(stat).not.toHaveBeenCalled();
|
||||
expect(fstat).not.toHaveBeenCalled();
|
||||
expect(lstat).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
read.mockRestore();
|
||||
stat.mockRestore();
|
||||
fstat.mockRestore();
|
||||
lstat.mockRestore();
|
||||
}
|
||||
},
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
it("bounds a static response by the size captured with its pinned descriptor", async () => {
|
||||
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 = `<html><body>${"hello ".repeat(200)}</body></html>\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(
|
||||
'<html data-openclaw-control-ui-base-path="" data-openclaw-terminal-enabled="true">',
|
||||
);
|
||||
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(
|
||||
'<html data-openclaw-control-ui-base-path="" data-openclaw-terminal-enabled="true">',
|
||||
);
|
||||
},
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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<GatewayHttpRequestAuthOptions> & {
|
|||
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<void> => {
|
||||
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. */
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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<ControlUiRootState, { path: string; kind: "bundled" | "resolved" }>;
|
||||
type RootFiles = {
|
||||
controller: AbortController;
|
||||
pending: Map<string, Promise<ControlUiRootAsset | null>>;
|
||||
cached: Map<string, { asset: ControlUiRootAsset; bytes: number }>;
|
||||
bytes: number;
|
||||
};
|
||||
type FileRuntime = {
|
||||
pool?: WorkerTaskPool<ControlUiFileRead, ControlUiFileSnapshot | null>;
|
||||
roots: WeakMap<ControlUiRootState, RootFiles>;
|
||||
};
|
||||
const MAX_PREPARED_BYTES = 96 * 1024 * 1024;
|
||||
const MAX_PREPARED_ENTRIES = 2_048;
|
||||
|
||||
function fileRuntime() {
|
||||
return resolveGlobalSingleton<FileRuntime>(
|
||||
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<ControlUiRootAsset | null> {
|
||||
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<ControlUiFileRead, "readBody">,
|
||||
): Promise<ControlUiPreparedFile | null> => {
|
||||
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<ControlUiRootAsset | null> => {
|
||||
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;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue