mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-07 02:22:46 +00:00
fix(doctor): warn on volatile SQLite state (#94725)
* fix(doctor): warn on volatile SQLite state * fix(doctor): resolve symlinked state paths
This commit is contained in:
@@ -397,6 +397,7 @@ That stages grounded durable candidates into the short-term dreaming store while
|
||||
- **State dir permissions**: verifies writability; offers to repair permissions (and emits a `chown` hint when owner/group mismatch is detected).
|
||||
- **macOS cloud-synced state dir**: warns when state resolves under iCloud Drive (`~/Library/Mobile Documents/com~apple~CloudDocs/...`) or `~/Library/CloudStorage/...` because sync-backed paths can cause slower I/O and lock/sync races.
|
||||
- **Linux SD or eMMC state dir**: warns when state resolves to an `mmcblk*` mount source, because SD or eMMC-backed random I/O can be slower and wear faster under session and credential writes.
|
||||
- **Linux volatile state dir**: warns when state resolves to `tmpfs` or `ramfs`, because sessions, credentials, config, and SQLite state with its WAL/journal sidecars will disappear on reboot. Docker `overlay` mounts are intentionally not flagged because their writable layers persist across host reboots while the container remains.
|
||||
- **Session dirs missing**: `sessions/` and the session store directory are required to persist history and avoid `ENOENT` crashes.
|
||||
- **Transcript mismatch**: warns when recent session entries have missing transcript files.
|
||||
- **Main session "1-line JSONL"**: flags when the main transcript has only one line (history is not accumulating).
|
||||
|
||||
@@ -24,6 +24,7 @@ describe("detectLinuxSdBackedStateDir", () => {
|
||||
const result = detectLinuxSdBackedStateDir("/home/pi/.openclaw", {
|
||||
platform: "linux",
|
||||
mountInfo,
|
||||
resolveRealPath: (statePath) => statePath,
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
@@ -51,6 +52,7 @@ describe("detectLinuxSdBackedStateDir", () => {
|
||||
const result = detectLinuxSdBackedStateDir("/home/user/.openclaw", {
|
||||
platform: "linux",
|
||||
mountInfo,
|
||||
resolveRealPath: (statePath) => statePath,
|
||||
resolveDeviceRealPath: (devicePath) => {
|
||||
if (devicePath === "/dev/disk/by-uuid/abcd-1234") {
|
||||
return "/dev/mmcblk0p2";
|
||||
|
||||
@@ -287,6 +287,27 @@ function tryResolveRealPath(targetPath: string): string | null {
|
||||
}
|
||||
}
|
||||
|
||||
function resolvePathThroughExistingAncestor(
|
||||
targetPath: string,
|
||||
resolveRealPath: (targetPath: string) => string | null,
|
||||
pathOps: Pick<typeof path, "resolve" | "dirname" | "basename">,
|
||||
): string | null {
|
||||
const missingSegments: string[] = [];
|
||||
let candidate = pathOps.resolve(targetPath);
|
||||
while (true) {
|
||||
const resolved = resolveRealPath(candidate);
|
||||
if (resolved) {
|
||||
return pathOps.resolve(resolved, ...missingSegments);
|
||||
}
|
||||
const parent = pathOps.dirname(candidate);
|
||||
if (parent === candidate) {
|
||||
return null;
|
||||
}
|
||||
missingSegments.unshift(pathOps.basename(candidate));
|
||||
candidate = parent;
|
||||
}
|
||||
}
|
||||
|
||||
function decodeMountInfoPath(value: string): string {
|
||||
return value.replace(/\\([0-7]{3})/g, (_, octal: string) =>
|
||||
String.fromCharCode(Number.parseInt(octal, 8)),
|
||||
@@ -436,7 +457,9 @@ export function detectLinuxSdBackedStateDir(
|
||||
const linuxPath = path.posix;
|
||||
|
||||
const resolveRealPath = deps?.resolveRealPath ?? tryResolveRealPath;
|
||||
const resolvedStatePath = resolveRealPath(stateDir) ?? linuxPath.resolve(stateDir);
|
||||
const resolvedStatePath =
|
||||
resolvePathThroughExistingAncestor(stateDir, resolveRealPath, linuxPath) ??
|
||||
linuxPath.resolve(stateDir);
|
||||
const mountInfo = deps?.mountInfo ?? tryReadLinuxMountInfo();
|
||||
if (!mountInfo) {
|
||||
return null;
|
||||
@@ -491,6 +514,72 @@ export function formatLinuxSdBackedStateDirWarning(
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
type LinuxVolatileStateDir = {
|
||||
path: string;
|
||||
mountPoint: string;
|
||||
fsType: string;
|
||||
};
|
||||
|
||||
/** Filesystems whose state disappears on reboot. Docker overlayfs is intentionally excluded. */
|
||||
const VOLATILE_FS_TYPES = new Set(["tmpfs", "ramfs"]);
|
||||
|
||||
/** Detects Linux state directories mounted on filesystems that do not survive a reboot. */
|
||||
export function detectLinuxVolatileStateDir(
|
||||
stateDir: string,
|
||||
deps?: {
|
||||
platform?: NodeJS.Platform;
|
||||
mountInfo?: string;
|
||||
resolveRealPath?: (targetPath: string) => string | null;
|
||||
},
|
||||
): LinuxVolatileStateDir | null {
|
||||
const platform = deps?.platform ?? process.platform;
|
||||
if (platform !== "linux") {
|
||||
return null;
|
||||
}
|
||||
const linuxPath = path.posix;
|
||||
|
||||
const resolveRealPath = deps?.resolveRealPath ?? tryResolveRealPath;
|
||||
const resolvedStatePath =
|
||||
resolvePathThroughExistingAncestor(stateDir, resolveRealPath, linuxPath) ??
|
||||
linuxPath.resolve(stateDir);
|
||||
const mountInfo = deps?.mountInfo ?? tryReadLinuxMountInfo();
|
||||
if (!mountInfo) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const mountEntry = findLinuxMountInfoEntryForPath(
|
||||
resolvedStatePath,
|
||||
parseLinuxMountInfo(mountInfo),
|
||||
linuxPath,
|
||||
);
|
||||
if (!mountEntry || !VOLATILE_FS_TYPES.has(mountEntry.fsType)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
path: linuxPath.resolve(resolvedStatePath),
|
||||
mountPoint: linuxPath.resolve(mountEntry.mountPoint),
|
||||
fsType: mountEntry.fsType,
|
||||
};
|
||||
}
|
||||
|
||||
/** Formats the warning for state stored on a volatile Linux filesystem. */
|
||||
export function formatLinuxVolatileStateDirWarning(
|
||||
displayStateDir: string,
|
||||
volatileDir: LinuxVolatileStateDir,
|
||||
): string {
|
||||
const safeFsType = escapeControlCharsForTerminal(volatileDir.fsType);
|
||||
const safeMountPoint =
|
||||
volatileDir.mountPoint === "/"
|
||||
? "/"
|
||||
: escapeControlCharsForTerminal(shortenHomePath(volatileDir.mountPoint));
|
||||
return [
|
||||
`- State directory is on a volatile filesystem (${displayStateDir}; fs ${safeFsType}, mount ${safeMountPoint}).`,
|
||||
"- Sessions, credentials, config, and SQLite state (including WAL/journal sidecars) will be lost on reboot.",
|
||||
"- Move OPENCLAW_STATE_DIR to a persistent filesystem to avoid data loss.",
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
/** Detects macOS state directories under iCloud Drive or CloudStorage providers. */
|
||||
export function detectMacCloudSyncedStateDir(
|
||||
stateDir: string,
|
||||
@@ -642,6 +731,7 @@ export async function noteStateIntegrity(
|
||||
const requireOAuthDir = shouldRequireOAuthDir(cfg, env);
|
||||
const cloudSyncedStateDir = detectMacCloudSyncedStateDir(stateDir);
|
||||
const linuxSdBackedStateDir = detectLinuxSdBackedStateDir(stateDir);
|
||||
const linuxVolatileStateDir = detectLinuxVolatileStateDir(stateDir);
|
||||
const suppressOrphanTranscriptWarning = shouldSuppressOrphanTranscriptWarning(cfg, agentId);
|
||||
|
||||
if (cloudSyncedStateDir) {
|
||||
@@ -657,6 +747,9 @@ export async function noteStateIntegrity(
|
||||
if (linuxSdBackedStateDir) {
|
||||
warnings.push(formatLinuxSdBackedStateDirWarning(displayStateDir, linuxSdBackedStateDir));
|
||||
}
|
||||
if (linuxVolatileStateDir) {
|
||||
warnings.push(formatLinuxVolatileStateDirWarning(displayStateDir, linuxVolatileStateDir));
|
||||
}
|
||||
|
||||
let stateDirExists = existsDir(stateDir);
|
||||
if (!stateDirExists) {
|
||||
|
||||
137
src/commands/doctor-volatile-fs.test.ts
Normal file
137
src/commands/doctor-volatile-fs.test.ts
Normal file
@@ -0,0 +1,137 @@
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
detectLinuxVolatileStateDir,
|
||||
formatLinuxVolatileStateDirWarning,
|
||||
} from "./doctor-state-integrity.js";
|
||||
|
||||
describe("detectLinuxVolatileStateDir", () => {
|
||||
const TMPFS_MOUNT_INFO = [
|
||||
"22 1 0:21 / / rw,relatime - ext4 /dev/sda1 rw",
|
||||
"30 22 0:30 / /tmp rw,nosuid,nodev - tmpfs tmpfs rw",
|
||||
"35 22 0:35 / /home/user/.openclaw rw - tmpfs tmpfs rw,size=1048576k",
|
||||
].join("\n");
|
||||
const RAMFS_MOUNT_INFO = [
|
||||
"22 1 0:21 / / rw,relatime - ext4 /dev/sda1 rw",
|
||||
"35 22 0:35 / /home/user/.openclaw rw - ramfs ramfs rw",
|
||||
].join("\n");
|
||||
const OVERLAY_MOUNT_INFO = [
|
||||
"22 1 0:21 / / rw,relatime - overlay overlay rw,lowerdir=/lower,upperdir=/upper",
|
||||
].join("\n");
|
||||
const EXT4_MOUNT_INFO = "22 1 0:21 / / rw,relatime - ext4 /dev/sda1 rw";
|
||||
|
||||
it.each([
|
||||
["tmpfs", TMPFS_MOUNT_INFO],
|
||||
["ramfs", RAMFS_MOUNT_INFO],
|
||||
])("detects %s state directories", (fsType, mountInfo) => {
|
||||
const result = detectLinuxVolatileStateDir("/home/user/.openclaw", {
|
||||
platform: "linux",
|
||||
mountInfo,
|
||||
resolveRealPath: (targetPath) => targetPath,
|
||||
});
|
||||
|
||||
expect(result).toMatchObject({
|
||||
path: "/home/user/.openclaw",
|
||||
mountPoint: "/home/user/.openclaw",
|
||||
fsType,
|
||||
});
|
||||
});
|
||||
|
||||
it("uses the most specific matching mount", () => {
|
||||
const mountInfo = [
|
||||
"22 1 0:21 / / rw - ext4 /dev/sda1 rw",
|
||||
"30 22 0:30 / /home rw - ext4 /dev/sda2 rw",
|
||||
"35 30 0:35 / /home/user/.openclaw rw - tmpfs tmpfs rw",
|
||||
].join("\n");
|
||||
|
||||
expect(
|
||||
detectLinuxVolatileStateDir("/home/user/.openclaw", {
|
||||
platform: "linux",
|
||||
mountInfo,
|
||||
resolveRealPath: (targetPath) => targetPath,
|
||||
}),
|
||||
).toMatchObject({
|
||||
mountPoint: "/home/user/.openclaw",
|
||||
fsType: "tmpfs",
|
||||
});
|
||||
});
|
||||
|
||||
it("detects a missing state directory through an existing symlink", () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-doctor-volatile-"));
|
||||
try {
|
||||
const volatileMount = path.join(root, "volatile");
|
||||
const stateLink = path.join(root, "state");
|
||||
fs.mkdirSync(volatileMount);
|
||||
fs.symlinkSync(volatileMount, stateLink, "dir");
|
||||
const resolvedVolatileMount = fs.realpathSync(volatileMount);
|
||||
|
||||
const result = detectLinuxVolatileStateDir(path.join(stateLink, "openclaw"), {
|
||||
platform: "linux",
|
||||
mountInfo: [
|
||||
"22 1 0:21 / / rw,relatime - ext4 /dev/sda1 rw",
|
||||
`35 22 0:35 / ${resolvedVolatileMount} rw - tmpfs tmpfs rw`,
|
||||
].join("\n"),
|
||||
});
|
||||
|
||||
expect(result).toMatchObject({
|
||||
path: path.join(resolvedVolatileMount, "openclaw"),
|
||||
mountPoint: resolvedVolatileMount,
|
||||
fsType: "tmpfs",
|
||||
});
|
||||
} finally {
|
||||
fs.rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it.each([
|
||||
["overlay", OVERLAY_MOUNT_INFO],
|
||||
["ext4", EXT4_MOUNT_INFO],
|
||||
])("does not flag %s filesystems", (_name, mountInfo) => {
|
||||
expect(
|
||||
detectLinuxVolatileStateDir("/home/user/.openclaw", {
|
||||
platform: "linux",
|
||||
mountInfo,
|
||||
resolveRealPath: (targetPath) => targetPath,
|
||||
}),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("does not inspect mount information on non-Linux platforms", () => {
|
||||
expect(
|
||||
detectLinuxVolatileStateDir("/home/user/.openclaw", {
|
||||
platform: "darwin",
|
||||
mountInfo: TMPFS_MOUNT_INFO,
|
||||
resolveRealPath: (targetPath) => targetPath,
|
||||
}),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("does not warn when mount information is unavailable", () => {
|
||||
expect(
|
||||
detectLinuxVolatileStateDir("/home/user/.openclaw", {
|
||||
platform: "linux",
|
||||
mountInfo: "",
|
||||
resolveRealPath: (targetPath) => targetPath,
|
||||
}),
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatLinuxVolatileStateDirWarning", () => {
|
||||
it("covers all SQLite state and sidecar files under the volatile state directory", () => {
|
||||
const warning = formatLinuxVolatileStateDirWarning("~/.openclaw", {
|
||||
path: "/home/user/.openclaw",
|
||||
mountPoint: "/home/user/.openclaw",
|
||||
fsType: "tmpfs",
|
||||
});
|
||||
|
||||
expect(warning).toContain("volatile filesystem");
|
||||
expect(warning).toContain("tmpfs");
|
||||
expect(warning).toContain("SQLite state");
|
||||
expect(warning).toContain("WAL/journal sidecars");
|
||||
expect(warning).toContain("lost on reboot");
|
||||
expect(warning).toContain("OPENCLAW_STATE_DIR");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user