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) <noreply@anthropic.com>
This commit is contained in:
Jesse Merhi
2026-08-15 17:30:31 +10:00
parent 7dd02c1999
commit 7e64ad61f7
2 changed files with 66 additions and 20 deletions
+33 -20
View File
@@ -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;
}
+33
View File
@@ -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.