mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-10 21:54:31 +00:00
Doctor: warn when Linux state dir is on SD/eMMC mounts (#31033)
* Doctor state: warn on Linux SD or eMMC state mounts * Doctor tests: cover Linux SD or eMMC state mount detection * Docs doctor: document Linux SD or eMMC state warning * Changelog: add Linux SD or eMMC doctor warning * Update CHANGELOG.md * Doctor: escape mountinfo control chars in SD warning * Doctor tests: cover escaped mountinfo control chars
This commit is contained in:
@@ -140,6 +140,9 @@ function findOtherStateDirs(stateDir: string): string[] {
|
||||
function isPathUnderRoot(targetPath: string, rootPath: string): boolean {
|
||||
const normalizedTarget = path.resolve(targetPath);
|
||||
const normalizedRoot = path.resolve(rootPath);
|
||||
if (normalizedRoot === path.sep) {
|
||||
return normalizedTarget.startsWith(path.sep);
|
||||
}
|
||||
return (
|
||||
normalizedTarget === normalizedRoot ||
|
||||
normalizedTarget.startsWith(`${normalizedRoot}${path.sep}`)
|
||||
@@ -154,6 +157,188 @@ function tryResolveRealPath(targetPath: string): string | null {
|
||||
}
|
||||
}
|
||||
|
||||
function decodeMountInfoPath(value: string): string {
|
||||
return value.replace(/\\([0-7]{3})/g, (_, octal: string) =>
|
||||
String.fromCharCode(Number.parseInt(octal, 8)),
|
||||
);
|
||||
}
|
||||
|
||||
function escapeControlCharsForTerminal(value: string): string {
|
||||
let escaped = "";
|
||||
for (const char of value) {
|
||||
if (char === "\u001b") {
|
||||
escaped += "\\x1b";
|
||||
continue;
|
||||
}
|
||||
if (char === "\r") {
|
||||
escaped += "\\r";
|
||||
continue;
|
||||
}
|
||||
if (char === "\n") {
|
||||
escaped += "\\n";
|
||||
continue;
|
||||
}
|
||||
if (char === "\t") {
|
||||
escaped += "\\t";
|
||||
continue;
|
||||
}
|
||||
const code = char.charCodeAt(0);
|
||||
if ((code >= 0 && code <= 8) || code === 11 || code === 12 || (code >= 14 && code <= 31)) {
|
||||
escaped += `\\x${code.toString(16).padStart(2, "0")}`;
|
||||
continue;
|
||||
}
|
||||
if (code === 127) {
|
||||
escaped += "\\x7f";
|
||||
continue;
|
||||
}
|
||||
escaped += char;
|
||||
}
|
||||
return escaped;
|
||||
}
|
||||
|
||||
type LinuxMountInfoEntry = {
|
||||
mountPoint: string;
|
||||
fsType: string;
|
||||
source: string;
|
||||
};
|
||||
|
||||
export type LinuxSdBackedStateDir = {
|
||||
path: string;
|
||||
mountPoint: string;
|
||||
fsType: string;
|
||||
source: string;
|
||||
};
|
||||
|
||||
function parseLinuxMountInfo(rawMountInfo: string): LinuxMountInfoEntry[] {
|
||||
const entries: LinuxMountInfoEntry[] = [];
|
||||
for (const line of rawMountInfo.split("\n")) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed) {
|
||||
continue;
|
||||
}
|
||||
const separatorIndex = trimmed.indexOf(" - ");
|
||||
if (separatorIndex === -1) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const left = trimmed.slice(0, separatorIndex);
|
||||
const right = trimmed.slice(separatorIndex + 3);
|
||||
const leftFields = left.split(" ");
|
||||
const rightFields = right.split(" ");
|
||||
if (leftFields.length < 5 || rightFields.length < 2) {
|
||||
continue;
|
||||
}
|
||||
|
||||
entries.push({
|
||||
mountPoint: decodeMountInfoPath(leftFields[4]),
|
||||
fsType: rightFields[0],
|
||||
source: decodeMountInfoPath(rightFields[1]),
|
||||
});
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
|
||||
function findLinuxMountInfoEntryForPath(
|
||||
targetPath: string,
|
||||
entries: LinuxMountInfoEntry[],
|
||||
): LinuxMountInfoEntry | null {
|
||||
const normalizedTarget = path.resolve(targetPath);
|
||||
let bestMatch: LinuxMountInfoEntry | null = null;
|
||||
for (const entry of entries) {
|
||||
if (!isPathUnderRoot(normalizedTarget, entry.mountPoint)) {
|
||||
continue;
|
||||
}
|
||||
if (
|
||||
!bestMatch ||
|
||||
path.resolve(entry.mountPoint).length > path.resolve(bestMatch.mountPoint).length
|
||||
) {
|
||||
bestMatch = entry;
|
||||
}
|
||||
}
|
||||
return bestMatch;
|
||||
}
|
||||
|
||||
function isMmcDevicePath(devicePath: string): boolean {
|
||||
const name = path.basename(devicePath);
|
||||
return /^mmcblk\d+(?:p\d+)?$/.test(name);
|
||||
}
|
||||
|
||||
function tryReadLinuxMountInfo(): string | null {
|
||||
try {
|
||||
return fs.readFileSync("/proc/self/mountinfo", "utf8");
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function detectLinuxSdBackedStateDir(
|
||||
stateDir: string,
|
||||
deps?: {
|
||||
platform?: NodeJS.Platform;
|
||||
mountInfo?: string;
|
||||
resolveRealPath?: (targetPath: string) => string | null;
|
||||
resolveDeviceRealPath?: (targetPath: string) => string | null;
|
||||
},
|
||||
): LinuxSdBackedStateDir | null {
|
||||
const platform = deps?.platform ?? process.platform;
|
||||
if (platform !== "linux") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const resolveRealPath = deps?.resolveRealPath ?? tryResolveRealPath;
|
||||
const resolvedStatePath = resolveRealPath(stateDir) ?? path.resolve(stateDir);
|
||||
const mountInfo = deps?.mountInfo ?? tryReadLinuxMountInfo();
|
||||
if (!mountInfo) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const mountEntry = findLinuxMountInfoEntryForPath(
|
||||
resolvedStatePath,
|
||||
parseLinuxMountInfo(mountInfo),
|
||||
);
|
||||
if (!mountEntry) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const sourceCandidates = [mountEntry.source];
|
||||
if (mountEntry.source.startsWith("/dev/")) {
|
||||
const resolvedDevicePath = (deps?.resolveDeviceRealPath ?? tryResolveRealPath)(
|
||||
mountEntry.source,
|
||||
);
|
||||
if (resolvedDevicePath) {
|
||||
sourceCandidates.push(path.resolve(resolvedDevicePath));
|
||||
}
|
||||
}
|
||||
if (!sourceCandidates.some(isMmcDevicePath)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
path: path.resolve(resolvedStatePath),
|
||||
mountPoint: path.resolve(mountEntry.mountPoint),
|
||||
fsType: mountEntry.fsType,
|
||||
source: mountEntry.source,
|
||||
};
|
||||
}
|
||||
|
||||
export function formatLinuxSdBackedStateDirWarning(
|
||||
displayStateDir: string,
|
||||
linuxSdBackedStateDir: LinuxSdBackedStateDir,
|
||||
): string {
|
||||
const displayMountPoint =
|
||||
linuxSdBackedStateDir.mountPoint === "/"
|
||||
? "/"
|
||||
: shortenHomePath(linuxSdBackedStateDir.mountPoint);
|
||||
const safeSource = escapeControlCharsForTerminal(linuxSdBackedStateDir.source);
|
||||
const safeFsType = escapeControlCharsForTerminal(linuxSdBackedStateDir.fsType);
|
||||
const safeMountPoint = escapeControlCharsForTerminal(displayMountPoint);
|
||||
return [
|
||||
`- State directory appears to be on SD/eMMC storage (${displayStateDir}; device ${safeSource}, fs ${safeFsType}, mount ${safeMountPoint}).`,
|
||||
"- SD/eMMC media can be slower for random I/O and wear faster under session/log churn.",
|
||||
"- For better startup and state durability, prefer SSD/NVMe (or USB SSD on Raspberry Pi) for OPENCLAW_STATE_DIR.",
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
export function detectMacCloudSyncedStateDir(
|
||||
stateDir: string,
|
||||
deps?: {
|
||||
@@ -285,6 +470,7 @@ export async function noteStateIntegrity(
|
||||
const displayConfigPath = configPath ? shortenHomePath(configPath) : undefined;
|
||||
const requireOAuthDir = shouldRequireOAuthDir(cfg, env);
|
||||
const cloudSyncedStateDir = detectMacCloudSyncedStateDir(stateDir);
|
||||
const linuxSdBackedStateDir = detectLinuxSdBackedStateDir(stateDir);
|
||||
|
||||
if (cloudSyncedStateDir) {
|
||||
warnings.push(
|
||||
@@ -296,6 +482,9 @@ export async function noteStateIntegrity(
|
||||
].join("\n"),
|
||||
);
|
||||
}
|
||||
if (linuxSdBackedStateDir) {
|
||||
warnings.push(formatLinuxSdBackedStateDirWarning(displayStateDir, linuxSdBackedStateDir));
|
||||
}
|
||||
|
||||
let stateDirExists = existsDir(stateDir);
|
||||
if (!stateDirExists) {
|
||||
|
||||
Reference in New Issue
Block a user