From 7e64ad61f702ab2fe8bde8451a0206d4ffb4b063 Mon Sep 17 00:00:00 2001 From: Jesse Merhi <79823012+jesse-merhi@users.noreply.github.com> Date: Sat, 15 Aug 2026 17:30:31 +1000 Subject: [PATCH] fix(scripts): keep every cgroup mount view, not just the last one seen One hierarchy can be visible through several mounts and only some expose a subtree containing this process. Retaining only the last view dropped the budget whenever a non-representable bind view came later, sending the build back to host MemTotal. Co-Authored-By: Claude Opus 5 (1M context) --- scripts/tsdown-build.mts | 53 +++++++++++++++++++------------ test/scripts/tsdown-build.test.ts | 33 +++++++++++++++++++ 2 files changed, 66 insertions(+), 20 deletions(-) diff --git a/scripts/tsdown-build.mts b/scripts/tsdown-build.mts index 20d1354ebfb1..e14d1055acf3 100644 --- a/scripts/tsdown-build.mts +++ b/scripts/tsdown-build.mts @@ -83,6 +83,8 @@ type MemoryLimitParams = { procSelfMountinfoPath?: string; }; +type CgroupMount = { mountPoint: string; root: string }; + type TsdownBuildParams = MemoryLimitParams & { args?: string[]; comSpec?: string; @@ -493,8 +495,10 @@ function resolveCgroupMountPoints(params: MemoryLimitParams = {}) { // Unreadable off Linux; the documented defaults still apply. } - let unified = { mountPoint: DEFAULT_CGROUP_V2_MOUNT_PATH, root: "/" }; - let v1Memory = { mountPoint: DEFAULT_CGROUP_V1_MEMORY_MOUNT_PATH, root: "/" }; + // One hierarchy can be visible through several mounts, and only some of them expose a subtree + // containing this process, so every view is kept as a candidate rather than the last one seen. + const unified: CgroupMount[] = []; + const v1Memory: CgroupMount[] = []; for (const line of rawMountinfo.split("\n")) { // mountinfo separates its variable optional fields from the fstype with a lone "-". const [fields, describe] = line.split(" - "); @@ -504,12 +508,19 @@ function resolveCgroupMountPoints(params: MemoryLimitParams = {}) { continue; } if (fsType === "cgroup2") { - unified = { mountPoint, root }; + unified.push({ mountPoint, root }); } else if (fsType === "cgroup" && (superOptions ?? "").split(",").includes("memory")) { - v1Memory = { mountPoint, root }; + v1Memory.push({ mountPoint, root }); } } - return { unified, v1Memory }; + return { + unified: + unified.length > 0 ? unified : [{ mountPoint: DEFAULT_CGROUP_V2_MOUNT_PATH, root: "/" }], + v1Memory: + v1Memory.length > 0 + ? v1Memory + : [{ mountPoint: DEFAULT_CGROUP_V1_MEMORY_MOUNT_PATH, root: "/" }], + }; } // mountinfo field 4 is the subtree a cgroupfs mount exposes, so /proc/self/cgroup records are @@ -540,19 +551,17 @@ function resolveCgroupMemoryLimitPaths(params: MemoryLimitParams = {}) { } const paths: string[] = []; - const addHierarchy = ( - mount: { mountPoint: string; root: string }, - limitFiles: string[], - cgroupPath: string, - ) => { - const relative = relativeCgroupPath(mount.root, cgroupPath); - if (relative === null) { - return; - } - const segments = relative.split("/").filter(Boolean); - for (let depth = segments.length; depth >= 0; depth -= 1) { - for (const limitFile of limitFiles) { - paths.push(path.join(mount.mountPoint, ...segments.slice(0, depth), limitFile)); + const addHierarchy = (mounts: CgroupMount[], limitFiles: string[], cgroupPath: string) => { + for (const mount of mounts) { + const relative = relativeCgroupPath(mount.root, cgroupPath ?? mount.root); + if (relative === null) { + continue; + } + const segments = relative.split("/").filter(Boolean); + for (let depth = segments.length; depth >= 0; depth -= 1) { + for (const limitFile of limitFiles) { + paths.push(path.join(mount.mountPoint, ...segments.slice(0, depth), limitFile)); + } } } }; @@ -576,8 +585,12 @@ function resolveCgroupMemoryLimitPaths(params: MemoryLimitParams = {}) { // Only probe the mounts blind when this process has no memory cgroup record at all; a record // that no mount can represent means the limit is unreadable here, not that the root applies. if (!sawMemoryRecord) { - addHierarchy(mounts.unified, CGROUP_V2_MEMORY_LIMIT_FILES, mounts.unified.root); - addHierarchy(mounts.v1Memory, CGROUP_V1_MEMORY_LIMIT_FILES, mounts.v1Memory.root); + for (const mount of mounts.unified) { + addHierarchy([mount], CGROUP_V2_MEMORY_LIMIT_FILES, mount.root); + } + for (const mount of mounts.v1Memory) { + addHierarchy([mount], CGROUP_V1_MEMORY_LIMIT_FILES, mount.root); + } } return paths; } diff --git a/test/scripts/tsdown-build.test.ts b/test/scripts/tsdown-build.test.ts index b2f610eedc4d..6d99a789b349 100644 --- a/test/scripts/tsdown-build.test.ts +++ b/test/scripts/tsdown-build.test.ts @@ -514,6 +514,39 @@ describe("resolveTsdownBuildInvocation", () => { expect(result.options.env.NODE_OPTIONS).toBe("--max-old-space-size=4352"); }); + it("keeps a representable cgroup mount when a later view cannot represent it", () => { + // Several mounts can expose one hierarchy; only the first covers this process here, so + // retaining just the last-seen view would lose the budget entirely. + const cgroupFiles = new Map([ + ["/proc/self/cgroup", "0::/docker/abc123/openclaw-main-update.service\n"], + [ + "/proc/self/mountinfo", + "30 25 0:26 /docker/abc123 /sys/fs/cgroup rw - cgroup2 cgroup2 rw\n" + + "31 25 0:26 /other/branch /mnt/peer-cgroup rw - cgroup2 cgroup2 rw\n", + ], + ["/sys/fs/cgroup/openclaw-main-update.service/memory.max", `${5 * 1024 * 1024 * 1024}\n`], + ["/test/meminfo", "MemTotal: 7340032 kB\n"], + ]); + + const result = resolveTsdownBuildInvocation({ + nodeExecPath: "/usr/bin/node", + npmExecPath: "/tmp/pnpm.cjs", + env: {}, + procMeminfoPath: "/test/meminfo", + fs: { + readFileSync(filePath: string) { + const contents = cgroupFiles.get(filePath); + if (contents === undefined) { + throw new Error(`ENOENT: ${filePath}`); + } + return contents; + }, + }, + }); + + expect(result.options.env.NODE_OPTIONS).toBe("--max-old-space-size=4352"); + }); + it("ignores a cgroup mount that cannot represent this process's cgroup", () => { // An inherited namespace can leave a mount whose subtree holds someone else's cgroup. // Sizing the build from it would apply an unrelated limit, so it must be skipped.