Files
SnapOtter/packages/ai/src/runtime-resources.ts
T
SnapOtterandGitHub 991c981529 fix: make OCR portable and reliable across AMD64 and ARM64 (#519)
* fix: make OCR portable and reliable

* fix: harden OCR installation portability

* fix: pin OCR partials across downloads

* fix: make OCR execution reliably asynchronous

* fix: harden OCR portability and docs routes

* fix: preserve decoder and docs safeguards
2026-07-15 03:34:24 +08:00

487 lines
17 KiB
TypeScript

import { readFileSync } from "node:fs";
import { totalmem } from "node:os";
import { posix } from "node:path";
export const OCR_RUNTIME_MINIMUM_MEMORY_BYTES = 4 * 1024 * 1024 * 1024;
const CGROUP_MEMORY_LIMIT_PATHS = [
"/sys/fs/cgroup/memory.max",
"/sys/fs/cgroup/memory/memory.limit_in_bytes",
"/sys/fs/cgroup/memory.limit_in_bytes",
] as const;
const CGROUP_MEMBERSHIP_RESOLUTION_ATTEMPTS = 3;
export interface OcrRuntimeMemoryOptions {
/** Exact test/caller override after physical and cgroup limits are resolved. */
effectiveMemoryBytes?: number;
/** Test seam for the host's configured physical capacity. */
physicalMemoryBytes?: number;
/** Test seam for cgroup v1/v2 capacity files. */
readTextFile?: (path: string) => string;
/** Test seam for Linux fail-closed cgroup discovery. */
hostPlatform?: NodeJS.Platform;
}
function positiveSafeBytes(value: number, label: string): number {
if (!Number.isSafeInteger(value) || value <= 0) {
throw new Error(`${label} must be a positive safe integer`);
}
return value;
}
function parseCgroupLimit(raw: string, zeroIsLimit = false): bigint | null {
const value = raw.trim();
if (value === "max" || !/^[0-9]+$/.test(value)) return null;
const parsed = BigInt(value);
return parsed > 0n || zeroIsLimit ? parsed : null;
}
function hasErrorCode(error: unknown, code: string): boolean {
return typeof error === "object" && error !== null && "code" in error && error.code === code;
}
function hasParentPathSegment(value: string): boolean {
return value.split("/").includes("..");
}
function decodeMountInfoPath(value: string): string {
return value.replace(/\\([0-7]{3})/g, (_match, octal: string) =>
String.fromCharCode(Number.parseInt(octal, 8)),
);
}
interface MountInfoRecord {
id: number;
parentId: number;
device: string;
filesystem: string;
root: string;
mountPoint: string;
controllers: Set<string>;
}
interface CgroupMount extends MountInfoRecord {
filesystem: "cgroup" | "cgroup2";
}
function parseMountInfo(raw: string): MountInfoRecord[] {
const mounts: MountInfoRecord[] = [];
for (const line of raw.split("\n")) {
if (!line) continue;
const fields = line.split(" ");
const separator = fields.indexOf("-");
if (
separator < 6 ||
!/^[1-9][0-9]*$/.test(fields[0]) ||
!/^[1-9][0-9]*$/.test(fields[1]) ||
!/^[0-9]+:[0-9]+$/.test(fields[2])
) {
throw new Error("unable to resolve the process cgroup memory capacity");
}
const id = Number(fields[0]);
const parentId = Number(fields[1]);
if (!Number.isSafeInteger(id) || !Number.isSafeInteger(parentId)) {
throw new Error("unable to resolve the process cgroup memory capacity");
}
const filesystem = fields[separator + 1];
const root = decodeMountInfoPath(fields[3]);
const decodedMountPoint = decodeMountInfoPath(fields[4]);
if (!decodedMountPoint.startsWith("/") || hasParentPathSegment(decodedMountPoint)) {
throw new Error("unable to resolve the process cgroup memory capacity");
}
mounts.push({
id,
parentId,
device: fields[2],
filesystem,
root,
mountPoint: posix.normalize(decodedMountPoint),
controllers: new Set(
fields
.slice(separator + 3)
.join(",")
.split(","),
),
});
}
return mounts;
}
function isStrictPathPrefix(parent: string, child: string): boolean {
return parent === "/" ? child !== "/" : child.startsWith(`${parent}/`);
}
function pathDepth(value: string): number {
return value.split("/").filter(Boolean).length;
}
function parseVisibleMounts(raw: string): MountInfoRecord[] {
const mounts = parseMountInfo(raw);
const mountsById = new Map<number, MountInfoRecord>();
for (const mount of mounts) {
if (mountsById.has(mount.id)) {
throw new Error("unable to resolve the process cgroup memory capacity");
}
mountsById.set(mount.id, mount);
}
const parentStates = new Map<number, "visiting" | "visited">();
const validateParentChain = (mount: MountInfoRecord): void => {
if (mount.parentId === mount.id) {
if (mount.mountPoint !== "/") {
throw new Error("unable to resolve the process cgroup memory capacity");
}
parentStates.set(mount.id, "visited");
return;
}
const state = parentStates.get(mount.id);
if (state === "visiting") {
throw new Error("unable to resolve the process cgroup memory capacity");
}
if (state === "visited") return;
parentStates.set(mount.id, "visiting");
const parent = mountsById.get(mount.parentId);
if (parent) validateParentChain(parent);
parentStates.set(mount.id, "visited");
};
for (const mount of mounts) validateParentChain(mount);
const coveredIds = new Set<number>();
for (const mount of mounts) {
const parent = mountsById.get(mount.parentId);
if (parent && parent.id !== mount.id && parent.mountPoint === mount.mountPoint) {
coveredIds.add(parent.id);
}
}
const visibleMounts: MountInfoRecord[] = [];
const visibleIds = new Set<number>();
const topMounts = mounts
.filter((mount) => !coveredIds.has(mount.id))
.sort((left, right) => pathDepth(left.mountPoint) - pathDepth(right.mountPoint));
for (const mount of topMounts) {
let containingParent = mountsById.get(mount.parentId);
while (containingParent?.mountPoint === mount.mountPoint) {
if (containingParent.parentId === containingParent.id) {
containingParent = undefined;
break;
}
containingParent = mountsById.get(containingParent.parentId);
}
let longestVisiblePrefix: MountInfoRecord | undefined;
for (const visibleMount of visibleMounts) {
if (
isStrictPathPrefix(visibleMount.mountPoint, mount.mountPoint) &&
(!longestVisiblePrefix ||
visibleMount.mountPoint.length > longestVisiblePrefix.mountPoint.length)
) {
longestVisiblePrefix = visibleMount;
}
}
const visible = containingParent
? visibleIds.has(containingParent.id) && longestVisiblePrefix?.id === containingParent.id
: longestVisiblePrefix === undefined;
if (visible) {
if (visibleMounts.some((candidate) => candidate.mountPoint === mount.mountPoint)) {
throw new Error("unable to resolve the process cgroup memory capacity");
}
visibleMounts.push(mount);
visibleIds.add(mount.id);
}
}
return visibleMounts;
}
function isCgroupMount(mount: MountInfoRecord): mount is CgroupMount {
return mount.filesystem === "cgroup" || mount.filesystem === "cgroup2";
}
function isPathPrefix(parent: string, child: string): boolean {
return parent === child || isStrictPathPrefix(parent, child);
}
function normalizedAbsolutePath(value: string): string | null {
if (!value.startsWith("/") || hasParentPathSegment(value)) return null;
return posix.normalize(value);
}
function hasConsistentCgroupPath(
selected: CgroupMount,
path: string,
visibleMounts: MountInfoRecord[],
): boolean {
let owner: MountInfoRecord | undefined;
for (const mount of visibleMounts) {
if (
isPathPrefix(mount.mountPoint, path) &&
(!owner || mount.mountPoint.length > owner.mountPoint.length)
) {
owner = mount;
}
}
if (!owner) return false;
if (owner.id === selected.id) return true;
if (
owner.filesystem !== selected.filesystem ||
owner.device !== selected.device ||
!isStrictPathPrefix(selected.mountPoint, owner.mountPoint)
) {
return false;
}
const selectedRoot = normalizedAbsolutePath(selected.root);
const ownerRoot = normalizedAbsolutePath(owner.root);
if (!selectedRoot || !ownerRoot) return false;
const relativeMountPoint = posix.relative(selected.mountPoint, owner.mountPoint);
return ownerRoot === posix.normalize(posix.join(selectedRoot, relativeMountPoint));
}
function validLinuxMembership(fields: RegExpExecArray | null): fields is RegExpExecArray {
if (fields === null || !fields[3]?.startsWith("/") || hasParentPathSegment(fields[3])) {
return false;
}
const hierarchy = fields[1];
const controllers = fields[2];
return hierarchy === "0"
? controllers === ""
: controllers.length > 0 && /^[1-9][0-9]*$/.test(hierarchy);
}
function cgroupProcessPath(mount: CgroupMount, membership: string): string | null {
if (
hasParentPathSegment(mount.root) ||
hasParentPathSegment(mount.mountPoint) ||
hasParentPathSegment(membership)
) {
return null;
}
const root = posix.normalize(mount.root);
const member = posix.normalize(membership);
if (!root.startsWith("/") || !member.startsWith("/") || !mount.mountPoint.startsWith("/")) {
return null;
}
let suffix: string;
if (root === "/") suffix = member.slice(1);
else if (member === root) suffix = "";
else if (member.startsWith(`${root}/`)) suffix = member.slice(root.length + 1);
else return null;
const candidate = posix.normalize(posix.join(mount.mountPoint, suffix));
return candidate === mount.mountPoint || candidate.startsWith(`${mount.mountPoint}/`)
? candidate
: null;
}
function resolveMembershipMemoryLimits(
readTextFile: (path: string) => string,
failClosed: boolean,
membershipRaw: string,
): bigint[] | null {
const membershipLines = membershipRaw.split("\n").filter(Boolean);
if (failClosed && membershipLines.length === 0) {
throw new Error("unable to read the process cgroup memory capacity");
}
const parsedMemberships = membershipLines.map((line) => /^([^:]*):([^:]*):(.*)$/.exec(line));
if (parsedMemberships.some((fields) => fields !== null && hasParentPathSegment(fields[3]))) {
throw new Error("unable to read the process cgroup memory capacity");
}
if (failClosed && parsedMemberships.some((fields) => !validLinuxMembership(fields))) {
throw new Error("unable to read the process cgroup memory capacity");
}
const allMemberships = parsedMemberships
.filter(
(fields): fields is RegExpExecArray => fields !== null && fields[3]?.startsWith("/") === true,
)
.map(([, hierarchy, controllers, membershipPath]) => ({
kind: hierarchy === "0" && controllers === "" ? "cgroup2" : "cgroup",
controllers: new Set(controllers.split(",").filter(Boolean)),
path: membershipPath,
}));
const v1MemoryMemberships = allMemberships.filter(
(membership) => membership.kind === "cgroup" && membership.controllers.has("memory"),
);
const memberships =
v1MemoryMemberships.length > 0
? v1MemoryMemberships
: allMemberships.filter((membership) => membership.kind === "cgroup2");
if (memberships.length === 0) return null;
let mountInfoRaw: string;
try {
mountInfoRaw = readTextFile("/proc/self/mountinfo");
} catch {
throw new Error("unable to resolve the process cgroup memory capacity");
}
const visibleMounts = parseVisibleMounts(mountInfoRaw);
const mounts = visibleMounts.filter(isCgroupMount);
const selectedMemberships = memberships.flatMap((membership) => {
const selected: Array<{ mount: CgroupMount; processPath: string; limitFile: string }> = [];
for (const mount of mounts) {
if (
mount.filesystem !== membership.kind ||
(mount.filesystem === "cgroup" && !mount.controllers.has("memory"))
) {
continue;
}
const processPath = cgroupProcessPath(mount, membership.path);
if (!processPath) continue;
selected.push({
mount,
processPath,
limitFile: mount.filesystem === "cgroup2" ? "memory.max" : "memory.limit_in_bytes",
});
}
if (selected.length === 0) {
throw new Error("unable to resolve the process cgroup memory capacity");
}
return selected;
});
const limits: bigint[] = [];
for (const selected of selectedMemberships) {
if (!hasConsistentCgroupPath(selected.mount, selected.processPath, visibleMounts)) {
throw new Error("unable to resolve the process cgroup memory capacity");
}
let current = selected.processPath;
while (true) {
let raw: string | undefined;
const limitPath = posix.join(current, selected.limitFile);
if (!hasConsistentCgroupPath(selected.mount, limitPath, visibleMounts)) {
throw new Error("unable to resolve the process cgroup memory capacity");
}
try {
raw = readTextFile(limitPath);
} catch (error) {
const absentV2Limit =
selected.mount.filesystem === "cgroup2" && hasErrorCode(error, "ENOENT");
if (absentV2Limit) {
const controllersPath = posix.join(current, "cgroup.controllers");
if (!hasConsistentCgroupPath(selected.mount, controllersPath, visibleMounts)) {
throw new Error("unable to resolve the process cgroup memory capacity");
}
try {
readTextFile(controllersPath);
} catch {
throw new Error("unable to read the process cgroup memory capacity");
}
} else {
throw new Error("unable to read the process cgroup memory capacity");
}
}
if (raw !== undefined) {
const normalized = raw.trim();
if (normalized !== "max" && !/^[0-9]+$/.test(normalized)) {
throw new Error("malformed cgroup memory capacity");
}
const limit = parseCgroupLimit(raw, true);
if (limit !== null) limits.push(limit);
}
if (current === selected.mount.mountPoint) break;
const parent = posix.dirname(current);
if (parent === current || !parent.startsWith(selected.mount.mountPoint)) break;
current = parent;
}
}
return limits;
}
function membershipMemoryLimits(
readTextFile: (path: string) => string,
failClosed: boolean,
): bigint[] | null {
for (let attempt = 0; attempt < CGROUP_MEMBERSHIP_RESOLUTION_ATTEMPTS; attempt += 1) {
let membershipRaw: string;
try {
membershipRaw = readTextFile("/proc/self/cgroup");
} catch {
if (failClosed || attempt > 0) {
throw new Error("unable to read the process cgroup memory capacity");
}
return null;
}
let limits: bigint[] | null;
try {
limits = resolveMembershipMemoryLimits(readTextFile, failClosed, membershipRaw);
} catch (error) {
let failedMembershipRaw: string;
try {
failedMembershipRaw = readTextFile("/proc/self/cgroup");
} catch {
throw new Error("unable to read the process cgroup memory capacity");
}
if (failedMembershipRaw === membershipRaw) throw error;
continue;
}
let confirmedMembershipRaw: string;
try {
confirmedMembershipRaw = readTextFile("/proc/self/cgroup");
} catch {
throw new Error("unable to read the process cgroup memory capacity");
}
if (confirmedMembershipRaw === membershipRaw) return limits;
}
throw new Error("unable to read a stable process cgroup memory capacity");
}
/** Configured capacity available to this process, including container limits. */
export function getOcrRuntimeEffectiveMemoryBytes(options: OcrRuntimeMemoryOptions = {}): number {
if (options.effectiveMemoryBytes !== undefined) {
return positiveSafeBytes(options.effectiveMemoryBytes, "effective OCR runtime memory");
}
const physical = positiveSafeBytes(
options.physicalMemoryBytes ?? totalmem(),
"physical OCR runtime memory",
);
let effective = BigInt(physical);
const readTextFile = options.readTextFile ?? ((path: string) => readFileSync(path, "utf8"));
const hostPlatform = options.hostPlatform ?? process.platform;
const isLinux = hostPlatform === "linux";
const membershipLimits = membershipMemoryLimits(readTextFile, isLinux);
if (membershipLimits === null) {
for (const path of CGROUP_MEMORY_LIMIT_PATHS) {
try {
const limit = parseCgroupLimit(readTextFile(path), isLinux);
if (limit !== null && limit < effective) effective = limit;
} catch {
// A host normally exposes either cgroup v2, one v1 layout, or neither.
}
}
} else {
for (const limit of membershipLimits) if (limit < effective) effective = limit;
}
const constrained =
options.physicalMemoryBytes === undefined && typeof process.constrainedMemory === "function"
? process.constrainedMemory()
: undefined;
if (constrained && Number.isSafeInteger(constrained) && constrained > 0) {
effective = effective < BigInt(constrained) ? effective : BigInt(constrained);
}
return Number(effective);
}
export function hasOcrRuntimeMemory(
minimumMemoryBytes: number,
options: OcrRuntimeMemoryOptions = {},
): boolean {
positiveSafeBytes(minimumMemoryBytes, "OCR runtime minimum memory");
return getOcrRuntimeEffectiveMemoryBytes(options) >= minimumMemoryBytes;
}
export function assertOcrRuntimeMemory(
minimumMemoryBytes: number,
options: OcrRuntimeMemoryOptions = {},
): void {
positiveSafeBytes(minimumMemoryBytes, "OCR runtime minimum memory");
const effectiveMemoryBytes = getOcrRuntimeEffectiveMemoryBytes(options);
if (effectiveMemoryBytes < minimumMemoryBytes) {
throw new Error(
`insufficient memory for accurate OCR runtime: ${minimumMemoryBytes} bytes required, ${effectiveMemoryBytes} available; Fast OCR remains available`,
);
}
}