fix: warn on low disk before runtime dependency staging

This commit is contained in:
Peter Steinberger
2026-04-26 07:03:15 +01:00
parent e633f43c53
commit b979f2964c
10 changed files with 288 additions and 0 deletions

View File

@@ -116,6 +116,7 @@ Docs: https://docs.openclaw.ai
`openclaw node start` command, and show an actionable browser-control error
when the local control service is missing. Fixes #66637.
- Gateway/update: fail package updates when the restarted managed gateway reports the wrong version, including fallback restarts and JSON mode, avoiding false-success mixed-version restarts after macOS LaunchAgent updates. Fixes #71835. Thanks @abhinas90 and @jsompis.
- Gateway/update: warn before package updates and bundled plugin runtime-dependency repairs when the target volume appears low on disk space, without blocking installs on best-effort filesystem checks. Fixes #71835. Thanks @abhinas90 and @jsompis.
- Plugins/runtime deps: surface activated plugin load failures in health and fail package-update restart verification or doctor repair when bundled runtime deps still cannot load, avoiding false-success repairs. (#71883) Thanks @Solvely-Colin.
- Gateway/Linux: include fnm `aliases/default/bin` in generated service PATHs and let doctor accept either modern fnm aliases or the legacy `current/bin` symlink, avoiding false PATH repair prompts. Fixes #68169. Thanks @richard-scott.
- Installer/Linux: run apt installs with noninteractive dpkg and needrestart settings so fresh Ubuntu 24.04 `curl | bash` installs do not hang while installing Node.js, Git, or build tools. Fixes #41146. Thanks @iht76, @alexcarv318, @cs3gallery, @firofame, and @cgdusek.

View File

@@ -79,6 +79,12 @@ ignores user npm prefix/global settings, so global-install npm config does not
redirect bundled plugin dependencies into `~/node_modules` or the global package
tree.
Before package updates and bundled runtime-dependency repairs, OpenClaw tries a
best-effort disk-space check for the target volume. Low space produces a warning
with the checked path, but does not block the update because filesystem quotas,
snapshots, and network volumes can change after the check. The actual npm
install, copy, and post-install verification remain authoritative.
### Bundled plugin runtime dependencies
Packaged installs keep bundled plugin runtime dependencies out of the read-only

View File

@@ -1,4 +1,5 @@
import { EventEmitter } from "node:events";
import fsSync from "node:fs";
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
@@ -288,6 +289,20 @@ describe("update-cli", () => {
);
};
const statfsFixture = (params: {
bavail: number;
bsize?: number;
blocks?: number;
}): ReturnType<typeof fsSync.statfsSync> => ({
type: 0,
bsize: params.bsize ?? 1024,
blocks: params.blocks ?? 2_000_000,
bfree: params.bavail,
bavail: params.bavail,
files: 0,
ffree: 0,
});
const makeOkUpdateResult = (overrides: Partial<UpdateRunResult> = {}): UpdateRunResult =>
({
status: "ok",
@@ -911,6 +926,28 @@ describe("update-cli", () => {
expect(logs.join("\n")).not.toContain("already-current");
});
it("warns but still runs package updates when disk space looks low", async () => {
const tempDir = createCaseDir("openclaw-update");
mockPackageInstallStatus(tempDir);
vi.spyOn(fsSync, "statfsSync").mockReturnValue(
statfsFixture({
bavail: 256,
bsize: 1024 * 1024,
}),
);
await updateCommand({ yes: true });
expectPackageInstallSpec("openclaw@latest");
expect(defaultRuntime.exit).not.toHaveBeenCalledWith(1);
expect(
vi
.mocked(defaultRuntime.log)
.mock.calls.map((call) => String(call[0]))
.join("\n"),
).toContain("Low disk space near");
});
it("blocks package updates when the target requires a newer Node runtime", async () => {
mockPackageInstallStatus(createCaseDir("openclaw-update"));
vi.mocked(fetchNpmPackageTargetStatus).mockResolvedValue({

View File

@@ -18,6 +18,7 @@ import { asResolvedSourceConfig, asRuntimeConfig } from "../../config/materializ
import { resolveGatewayInstallEntrypoint } from "../../daemon/gateway-entrypoint.js";
import { resolveGatewayRestartLogPath } from "../../daemon/restart-logs.js";
import { resolveGatewayService } from "../../daemon/service.js";
import { createLowDiskSpaceWarning } from "../../infra/disk-space.js";
import { nodeVersionSatisfiesEngine } from "../../infra/runtime-guard.js";
import {
channelToNpmTag,
@@ -352,6 +353,7 @@ async function runPackageInstallUpdate(params: {
timeoutMs: number;
startedAt: number;
progress: ReturnType<typeof createUpdateProgress>["progress"];
jsonMode: boolean;
}): Promise<UpdateRunResult> {
const manager = await resolveGlobalManager({
root: params.root,
@@ -384,6 +386,18 @@ async function runPackageInstallUpdate(params: {
});
}
const diskWarning = createLowDiskSpaceWarning({
targetPath: pkgRoot ? path.dirname(pkgRoot) : params.root,
purpose: "global package update",
});
if (diskWarning) {
if (params.jsonMode) {
defaultRuntime.error(`Warning: ${diskWarning}`);
} else {
defaultRuntime.log(theme.warn(diskWarning));
}
}
const updateStep = await runUpdateStep({
name: "global update",
argv: globalInstallArgs(installTarget, installSpec),
@@ -1277,6 +1291,7 @@ export async function updateCommand(opts: UpdateCommandOptions): Promise<void> {
timeoutMs: timeoutMs ?? 20 * 60_000,
startedAt,
progress,
jsonMode: Boolean(opts.json),
})
: await runGitUpdate({
root,

View File

@@ -92,6 +92,7 @@ export async function maybeRepairBundledPluginRuntimeDeps(params: {
installSpecs,
env: params.env ?? process.env,
installDeps: params.installDeps,
warn: (message) => params.runtime.log(message),
});
note(`Installed bundled plugin deps: ${result.installSpecs.join(", ")}`, "Bundled plugins");
} catch (error) {

View File

@@ -0,0 +1,79 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it, vi } from "vitest";
import { createLowDiskSpaceWarning, formatDiskSpaceBytes, tryReadDiskSpace } from "./disk-space.js";
function statfsFixture(params: {
bavail: number;
bsize?: number;
blocks?: number;
}): ReturnType<typeof fs.statfsSync> {
return {
type: 0,
bsize: params.bsize ?? 1024,
blocks: params.blocks ?? 2_000_000,
bfree: params.bavail,
bavail: params.bavail,
files: 0,
ffree: 0,
};
}
describe("disk-space helpers", () => {
afterEach(() => {
vi.restoreAllMocks();
});
it("reads disk space from the nearest existing ancestor", () => {
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-disk-space-"));
try {
const statfs = vi.spyOn(fs, "statfsSync").mockReturnValue(
statfsFixture({
bavail: 512,
bsize: 1024,
blocks: 4096,
}),
);
const snapshot = tryReadDiskSpace(path.join(tempDir, "missing", "child"));
expect(snapshot).toEqual({
targetPath: path.join(tempDir, "missing", "child"),
checkedPath: tempDir,
availableBytes: 512 * 1024,
totalBytes: 4096 * 1024,
});
expect(statfs).toHaveBeenCalledWith(tempDir);
} finally {
fs.rmSync(tempDir, { recursive: true, force: true });
}
});
it("formats low disk warnings without making them hard errors", () => {
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-disk-space-"));
try {
vi.spyOn(fs, "statfsSync").mockReturnValue(
statfsFixture({
bavail: 256,
bsize: 1024 * 1024,
}),
);
expect(
createLowDiskSpaceWarning({
targetPath: tempDir,
purpose: "test staging",
thresholdBytes: 512 * 1024 * 1024,
}),
).toBe(`Low disk space near ${tempDir}: 256 MiB available; test staging may fail.`);
} finally {
fs.rmSync(tempDir, { recursive: true, force: true });
}
});
it("keeps byte formatting compact", () => {
expect(formatDiskSpaceBytes(420 * 1024 * 1024)).toBe("420 MiB");
expect(formatDiskSpaceBytes(1536 * 1024 * 1024)).toBe("1.5 GiB");
});
});

85
src/infra/disk-space.ts Normal file
View File

@@ -0,0 +1,85 @@
import fs from "node:fs";
import path from "node:path";
export const LOW_DISK_SPACE_WARNING_THRESHOLD_BYTES = 1024 * 1024 * 1024;
export type DiskSpaceSnapshot = {
targetPath: string;
checkedPath: string;
availableBytes: number;
totalBytes: number | null;
};
function finiteNonNegativeNumber(value: unknown): number | null {
const numberValue = Number(value);
return Number.isFinite(numberValue) && numberValue >= 0 ? numberValue : null;
}
function findExistingDiskSpacePath(targetPath: string): string | null {
let current = path.resolve(targetPath);
while (true) {
try {
const stats = fs.statSync(current);
return stats.isDirectory() ? current : path.dirname(current);
} catch {
const parent = path.dirname(current);
if (parent === current) {
return null;
}
current = parent;
}
}
}
export function tryReadDiskSpace(targetPath: string): DiskSpaceSnapshot | null {
if (typeof fs.statfsSync !== "function") {
return null;
}
const checkedPath = findExistingDiskSpacePath(targetPath);
if (!checkedPath) {
return null;
}
try {
const stats = fs.statfsSync(checkedPath);
const blockSize = finiteNonNegativeNumber(stats.bsize);
const availableBlocks = finiteNonNegativeNumber(stats.bavail);
if (blockSize === null || availableBlocks === null) {
return null;
}
const totalBlocks = finiteNonNegativeNumber(stats.blocks);
return {
targetPath,
checkedPath,
availableBytes: blockSize * availableBlocks,
totalBytes: totalBlocks === null ? null : blockSize * totalBlocks,
};
} catch {
return null;
}
}
export function formatDiskSpaceBytes(bytes: number): string {
const mib = bytes / (1024 * 1024);
if (mib < 1024) {
return `${Math.max(0, Math.round(mib))} MiB`;
}
const gib = mib / 1024;
return `${gib.toFixed(gib < 10 ? 1 : 0)} GiB`;
}
export function createLowDiskSpaceWarning(params: {
targetPath: string;
purpose: string;
thresholdBytes?: number;
}): string | null {
const thresholdBytes = params.thresholdBytes ?? LOW_DISK_SPACE_WARNING_THRESHOLD_BYTES;
const snapshot = tryReadDiskSpace(params.targetPath);
if (!snapshot || snapshot.availableBytes >= thresholdBytes) {
return null;
}
const location =
path.resolve(snapshot.targetPath) === path.resolve(snapshot.checkedPath)
? snapshot.checkedPath
: `${snapshot.targetPath} (volume checked at ${snapshot.checkedPath})`;
return `Low disk space near ${location}: ${formatDiskSpaceBytes(snapshot.availableBytes)} available; ${params.purpose} may fail.`;
}

View File

@@ -41,6 +41,22 @@ function writeInstalledPackage(rootDir: string, packageName: string, version: st
);
}
function statfsFixture(params: {
bavail: number;
bsize?: number;
blocks?: number;
}): ReturnType<typeof fs.statfsSync> {
return {
type: 0,
bsize: params.bsize ?? 1024,
blocks: params.blocks ?? 2_000_000,
bfree: params.bavail,
bavail: params.bavail,
files: 0,
ffree: 0,
};
}
afterEach(() => {
vi.restoreAllMocks();
spawnSyncMock.mockReset();
@@ -276,6 +292,41 @@ describe("installBundledRuntimeDeps", () => {
);
});
it("warns but still installs bundled runtime deps when disk space looks low", () => {
const installRoot = makeTempDir();
const warn = vi.fn();
vi.spyOn(fs, "statfsSync").mockReturnValue(
statfsFixture({
bavail: 256,
bsize: 1024 * 1024,
}),
);
spawnSyncMock.mockImplementation((_command, _args, options) => {
writeInstalledPackage(String(options?.cwd ?? ""), "acpx", "0.5.3");
return {
pid: 123,
output: [],
stdout: "",
stderr: "",
signal: null,
status: 0,
};
});
installBundledRuntimeDeps({
installRoot,
missingSpecs: ["acpx@0.5.3"],
env: {},
warn,
});
expect(warn).toHaveBeenCalledWith(expect.stringContaining("Low disk space near"));
expect(spawnSyncMock).toHaveBeenCalled();
expect(fs.existsSync(path.join(installRoot, "node_modules", "acpx", "package.json"))).toBe(
true,
);
});
it("uses an isolated execution root and copies node_modules back when requested", () => {
const installRoot = makeTempDir();
const installExecutionRoot = makeTempDir();

View File

@@ -6,6 +6,7 @@ import os from "node:os";
import path from "node:path";
import { resolveStateDir } from "../config/paths.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import { createLowDiskSpaceWarning } from "../infra/disk-space.js";
import { resolveHomeRelativePath } from "../infra/home-dir.js";
import { createNpmProjectInstallEnv } from "../infra/npm-install-env.js";
import { normalizeOptionalLowercaseString } from "../shared/string-coerce.js";
@@ -30,6 +31,7 @@ export type BundledRuntimeDepsInstallParams = {
linkNodeModulesFromExecutionRoot?: boolean;
missingSpecs: string[];
installSpecs?: string[];
warn?: (message: string) => void;
};
export type BundledRuntimeDepsEnsureResult = {
@@ -1198,6 +1200,7 @@ export function installBundledRuntimeDeps(params: {
linkNodeModulesFromExecutionRoot?: boolean;
missingSpecs: string[];
env: NodeJS.ProcessEnv;
warn?: (message: string) => void;
}): void {
const installExecutionRoot = params.installExecutionRoot ?? params.installRoot;
const isolatedExecutionRoot =
@@ -1211,6 +1214,13 @@ export function installBundledRuntimeDeps(params: {
try {
fs.mkdirSync(params.installRoot, { recursive: true });
fs.mkdirSync(installExecutionRoot, { recursive: true });
const diskWarning = createLowDiskSpaceWarning({
targetPath: installExecutionRoot,
purpose: "bundled plugin runtime dependency staging",
});
if (diskWarning) {
params.warn?.(diskWarning);
}
// Always make npm see an OpenClaw-owned package root. The package-level
// doctor repair path installs directly in the external stage dir; without a
// manifest, npm can honor a user's global prefix config and write under
@@ -1263,6 +1273,7 @@ export function repairBundledRuntimeDepsInstallRoot(params: {
installSpecs: string[];
env: NodeJS.ProcessEnv;
installDeps?: (params: BundledRuntimeDepsInstallParams) => void;
warn?: (message: string) => void;
}): { installSpecs: string[] } {
return withBundledRuntimeDepsInstallRootLock(params.installRoot, () => {
const retainedManifestSpecs = readRetainedRuntimeDepsManifest(params.installRoot);
@@ -1276,6 +1287,7 @@ export function repairBundledRuntimeDepsInstallRoot(params: {
installRoot: installParams.installRoot,
missingSpecs: installParams.installSpecs ?? installParams.missingSpecs,
env: params.env,
warn: params.warn,
}));
install({
installRoot: params.installRoot,

View File

@@ -2540,6 +2540,7 @@ export function loadOpenClawPlugins(options: PluginLoadOptions = {}): PluginRegi
installExecutionRoot: params.installExecutionRoot,
missingSpecs: params.installSpecs ?? params.missingSpecs,
env,
warn: (message) => logger.warn(`[plugins] ${record.id}: ${message}`),
}));
installer(installParams);
},