mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-25 11:55:47 -06:00
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:
@@ -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)),
|
||||
);
|
||||
}
|
||||
@@ -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")) {
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user