diff --git a/scripts/lib/mountinfo-path.mjs b/scripts/lib/mountinfo-path.mjs new file mode 100644 index 000000000000..9908ce8410ce --- /dev/null +++ b/scripts/lib/mountinfo-path.mjs @@ -0,0 +1,17 @@ +// Decodes octal-escaped path fields in Linux procfs mount tables. + +// The kernel escapes space, tab, newline, and backslash as \040, \011, \012, and +// \134 in the mount-root and mount-point fields of /proc/self/mountinfo, so a +// verbatim comparison silently fails to match any path containing one of them. +const MOUNT_PATH_OCTAL_ESCAPE_RE = /\\([0-7]{3})/g; + +/** + * Decode a procfs mount-table path field into its real path. + * @param {string} value + * @returns {string} + */ +export function decodeMountInfoPath(value) { + return value.replace(MOUNT_PATH_OCTAL_ESCAPE_RE, (_match, octal) => + String.fromCharCode(Number.parseInt(octal, 8)), + ); +} diff --git a/scripts/tsdown-build.mts b/scripts/tsdown-build.mts index e14d1055acf3..c621dae0faad 100644 --- a/scripts/tsdown-build.mts +++ b/scripts/tsdown-build.mts @@ -12,6 +12,7 @@ import fs from "node:fs"; import path from "node:path"; import { pathToFileURL } from "node:url"; import { BUNDLED_PLUGIN_PATH_PREFIX } from "./lib/bundled-plugin-paths.mjs"; +import { decodeMountInfoPath } from "./lib/mountinfo-path.mjs"; import { parsePositiveInt } from "./lib/numeric-options.mjs"; import { assertRealOutputRoot } from "./lib/output-root-guard.mjs"; import { @@ -502,11 +503,18 @@ function resolveCgroupMountPoints(params: MemoryLimitParams = {}) { for (const line of rawMountinfo.split("\n")) { // mountinfo separates its variable optional fields from the fstype with a lone "-". const [fields, describe] = line.split(" - "); - const [, , , root, mountPoint] = (fields ?? "").split(" "); + // mountinfo fields 4 and 5 are the mount root and mount point. + const mountFields = (fields ?? "").split(" "); + const rawRoot = mountFields[3]; + const rawMountPoint = mountFields[4]; const [fsType, , superOptions] = (describe ?? "").split(" "); - if (!mountPoint || !root) { + if (!rawMountPoint || !rawRoot) { continue; } + // The kernel escapes space, tab, newline, and backslash in these two fields, so + // matching them verbatim would miss any cgroup path containing one of them. + const root = decodeMountInfoPath(rawRoot); + const mountPoint = decodeMountInfoPath(rawMountPoint); if (fsType === "cgroup2") { unified.push({ mountPoint, root }); } else if (fsType === "cgroup" && (superOptions ?? "").split(",").includes("memory")) { diff --git a/test/scripts/tsdown-build.test.ts b/test/scripts/tsdown-build.test.ts index 6d99a789b349..99a6acc291e7 100644 --- a/test/scripts/tsdown-build.test.ts +++ b/test/scripts/tsdown-build.test.ts @@ -453,6 +453,38 @@ describe("resolveTsdownBuildInvocation", () => { expect(result.options.env.NODE_OPTIONS).toBe("--max-old-space-size=4352"); }); + it("caps the tsdown heap when the cgroup mount point is octal-escaped in mountinfo", () => { + const slicePath = "/user.slice/user-999.slice/user@999.service"; + // The kernel escapes a space in the mount point as \040. Matching the field + // verbatim misses this mount, and heap sizing silently falls back to host memory. + const cgroupFiles = new Map([ + ["/proc/self/cgroup", `0::${slicePath}/app.slice/openclaw-main-update.service\n`], + [ + "/proc/self/mountinfo", + "30 25 0:26 / /sys/fs/cgroup\\040dir rw,nosuid - cgroup2 cgroup2 rw\n", + ], + [`/sys/fs/cgroup dir${slicePath}/memory.high`, `${5 * 1024 * 1024 * 1024}\n`], + ]); + + const result = resolveTsdownBuildInvocation({ + nodeExecPath: "/usr/bin/node", + npmExecPath: "/tmp/pnpm.cjs", + env: {}, + fs: { + readFileSync(filePath: string) { + const contents = cgroupFiles.get(filePath); + if (contents === undefined) { + throw new Error(`ENOENT: ${filePath}`); + } + return contents; + }, + }, + }); + + // 5 GiB slice budget minus the 768 MiB build headroom. + expect(result.options.env.NODE_OPTIONS).toBe("--max-old-space-size=4352"); + }); + it("caps the tsdown heap when v1 controllers are co-mounted at the cgroup root", () => { const slicePath = "/user.slice/user-999.slice"; // Co-mounted v1 puts memory.limit_in_bytes under the slice directly, with no