mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 09:00:42 +00:00
fix: warn on low disk before runtime dependency staging
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
79
src/infra/disk-space.test.ts
Normal file
79
src/infra/disk-space.test.ts
Normal 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
85
src/infra/disk-space.ts
Normal 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.`;
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user