fix(scripts): decode octal-escaped mountinfo paths before matching cgroups

ClawSweeper P2 on 7e64ad61f7: the cgroup resolver compared mountinfo's mount
root and mount point verbatim. The kernel escapes space, tab, newline, and
backslash in those two fields, so any cgroup mounted under such a path never
matched, the bounded slice was missed, and heap sizing silently fell back to
host memory.

Decode both fields before matching. The decoder lives in scripts/lib beside the
other shared script helpers rather than inline, so the scripts program has one
copy rather than a new ad hoc one.

Regression test fails pre-fix: a v2 mount at "/sys/fs/cgroup\040dir" with a
5 GiB memory.high yields --max-old-space-size=12288 (host fallback) before the
fix and 4352 after.

Follow-up, deliberately not bundled here: src/infra/sqlite-wal.ts,
src/commands/doctor-state-integrity.ts, and src/plugins/bundled-source-overlays.ts
each carry their own private copy of this same decoder. Consolidating all four
into @openclaw/normalization-core is the right end state, but it touches a
shared package plus three core modules and belongs in its own reviewable change.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jesse Merhi
2026-08-18 00:12:29 +10:00
parent 7e64ad61f7
commit d6fe49dd3f
3 changed files with 59 additions and 2 deletions
+17
View File
@@ -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)),
);
}
+10 -2
View File
@@ -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")) {
+32
View File
@@ -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