mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 15:30:47 +00:00
fix(ollama): warn on WSL2 CUDA crash loop risk
This commit is contained in:
@@ -39,6 +39,7 @@ import {
|
||||
resolveConfiguredOllamaProviderConfig,
|
||||
} from "./src/stream.js";
|
||||
import { createOllamaWebSearchProvider } from "./src/web-search-provider.js";
|
||||
import { checkWsl2CrashLoopRisk } from "./src/wsl2-crash-loop-check.js";
|
||||
|
||||
function usesOllamaOpenAICompatTransport(model: {
|
||||
api?: unknown;
|
||||
@@ -60,6 +61,9 @@ export default definePluginEntry({
|
||||
name: "Ollama Provider",
|
||||
description: "Bundled Ollama provider plugin",
|
||||
register(api: OpenClawPluginApi) {
|
||||
if (api.registrationMode === "full") {
|
||||
void checkWsl2CrashLoopRisk(api.logger);
|
||||
}
|
||||
api.registerMemoryEmbeddingProvider(ollamaMemoryEmbeddingProviderAdapter);
|
||||
api.registerMediaUnderstandingProvider(ollamaMediaUnderstandingProvider);
|
||||
const startupPluginConfig = (api.pluginConfig ?? {}) as OllamaPluginConfig;
|
||||
|
||||
157
extensions/ollama/src/wsl2-crash-loop-check.test.ts
Normal file
157
extensions/ollama/src/wsl2-crash-loop-check.test.ts
Normal file
@@ -0,0 +1,157 @@
|
||||
import { promisify } from "node:util";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const { isWSL2SyncMock } = vi.hoisted(() => ({
|
||||
isWSL2SyncMock: vi.fn(() => false),
|
||||
}));
|
||||
|
||||
vi.mock("openclaw/plugin-sdk/runtime-env", () => ({
|
||||
isWSL2Sync: isWSL2SyncMock,
|
||||
}));
|
||||
|
||||
vi.mock("node:fs/promises", () => ({
|
||||
access: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("node:child_process", async () => {
|
||||
const { promisify: realPromisify } = await import("node:util");
|
||||
const mockExecFile = vi.fn();
|
||||
const execFilePromise = vi.fn();
|
||||
(mockExecFile as unknown as Record<symbol, unknown>)[realPromisify.custom] = execFilePromise;
|
||||
return { execFile: mockExecFile };
|
||||
});
|
||||
|
||||
import { execFile } from "node:child_process";
|
||||
import { access } from "node:fs/promises";
|
||||
import {
|
||||
checkWsl2CrashLoopRisk,
|
||||
hasWslCuda,
|
||||
isOllamaEnabledWithRestartAlways,
|
||||
parseSystemctlShowProperties,
|
||||
} from "./wsl2-crash-loop-check.js";
|
||||
|
||||
const accessMock = vi.mocked(access);
|
||||
const execFileMock = execFile as unknown as ReturnType<typeof vi.fn> & {
|
||||
[key: symbol]: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
const execFilePromiseMock = vi.mocked(execFileMock[promisify.custom]);
|
||||
|
||||
function createLogger() {
|
||||
return {
|
||||
debug: vi.fn(),
|
||||
error: vi.fn(),
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
};
|
||||
}
|
||||
|
||||
function mockSystemctl(stdout: string): void {
|
||||
execFilePromiseMock.mockResolvedValue({ stdout, stderr: "" });
|
||||
}
|
||||
|
||||
describe("wsl2 crash-loop check", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
isWSL2SyncMock.mockReturnValue(false);
|
||||
});
|
||||
|
||||
it("parses systemctl show properties", () => {
|
||||
expect(
|
||||
parseSystemctlShowProperties("UnitFileState=enabled\nRestart=always\nIgnoredLine\n"),
|
||||
).toEqual(
|
||||
new Map([
|
||||
["UnitFileState", "enabled"],
|
||||
["Restart", "always"],
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it("detects enabled Restart=always ollama service", async () => {
|
||||
mockSystemctl("UnitFileState=enabled\nRestart=always\n");
|
||||
|
||||
await expect(isOllamaEnabledWithRestartAlways()).resolves.toBe(true);
|
||||
|
||||
expect(execFilePromiseMock).toHaveBeenCalledWith(
|
||||
"systemctl",
|
||||
["show", "ollama.service", "--property=UnitFileState,Restart", "--no-pager"],
|
||||
{ timeout: 5000 },
|
||||
);
|
||||
});
|
||||
|
||||
it("does not treat enabled-runtime as persistent autostart", async () => {
|
||||
mockSystemctl("UnitFileState=enabled-runtime\nRestart=always\n");
|
||||
|
||||
await expect(isOllamaEnabledWithRestartAlways()).resolves.toBe(false);
|
||||
});
|
||||
|
||||
it("requires Restart=always", async () => {
|
||||
mockSystemctl("UnitFileState=enabled\nRestart=on-failure\n");
|
||||
|
||||
await expect(isOllamaEnabledWithRestartAlways()).resolves.toBe(false);
|
||||
});
|
||||
|
||||
it("returns false when systemctl is unavailable", async () => {
|
||||
execFilePromiseMock.mockRejectedValue(new Error("systemd unavailable"));
|
||||
|
||||
await expect(isOllamaEnabledWithRestartAlways()).resolves.toBe(false);
|
||||
});
|
||||
|
||||
it("detects CUDA from the first available WSL marker", async () => {
|
||||
accessMock.mockResolvedValueOnce(undefined);
|
||||
|
||||
await expect(hasWslCuda()).resolves.toBe(true);
|
||||
expect(accessMock).toHaveBeenCalledWith("/dev/dxg");
|
||||
});
|
||||
|
||||
it("checks the remaining CUDA markers before returning false", async () => {
|
||||
accessMock.mockRejectedValue(new Error("missing"));
|
||||
|
||||
await expect(hasWslCuda()).resolves.toBe(false);
|
||||
expect(accessMock).toHaveBeenCalledTimes(4);
|
||||
});
|
||||
|
||||
it("warns for WSL2 plus Ollama autostart plus CUDA", async () => {
|
||||
isWSL2SyncMock.mockReturnValue(true);
|
||||
mockSystemctl("UnitFileState=enabled\nRestart=always\n");
|
||||
accessMock.mockResolvedValueOnce(undefined);
|
||||
const logger = createLogger();
|
||||
|
||||
await checkWsl2CrashLoopRisk(logger);
|
||||
|
||||
expect(logger.warn).toHaveBeenCalledTimes(1);
|
||||
const message = String(logger.warn.mock.calls[0]?.[0]);
|
||||
expect(message).toContain("WSL2 crash-loop risk");
|
||||
expect(message).toContain("sudo systemctl disable ollama");
|
||||
expect(message).toContain("autoMemoryReclaim=disabled");
|
||||
expect(message).toContain("OLLAMA_KEEP_ALIVE=5m");
|
||||
});
|
||||
|
||||
it("does not probe systemd outside WSL2", async () => {
|
||||
const logger = createLogger();
|
||||
|
||||
await checkWsl2CrashLoopRisk(logger);
|
||||
|
||||
expect(execFilePromiseMock).not.toHaveBeenCalled();
|
||||
expect(logger.warn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not warn when CUDA is not visible", async () => {
|
||||
isWSL2SyncMock.mockReturnValue(true);
|
||||
mockSystemctl("UnitFileState=enabled\nRestart=always\n");
|
||||
accessMock.mockRejectedValue(new Error("missing"));
|
||||
const logger = createLogger();
|
||||
|
||||
await checkWsl2CrashLoopRisk(logger);
|
||||
|
||||
expect(logger.warn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("never throws from advisory checks", async () => {
|
||||
isWSL2SyncMock.mockReturnValue(true);
|
||||
execFilePromiseMock.mockRejectedValue(new Error("boom"));
|
||||
const logger = createLogger();
|
||||
|
||||
await expect(checkWsl2CrashLoopRisk(logger)).resolves.toBeUndefined();
|
||||
expect(logger.warn).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
84
extensions/ollama/src/wsl2-crash-loop-check.ts
Normal file
84
extensions/ollama/src/wsl2-crash-loop-check.ts
Normal file
@@ -0,0 +1,84 @@
|
||||
import { execFile } from "node:child_process";
|
||||
import { access } from "node:fs/promises";
|
||||
import { promisify } from "node:util";
|
||||
import type { PluginLogger } from "openclaw/plugin-sdk/plugin-entry";
|
||||
import { isWSL2Sync } from "openclaw/plugin-sdk/runtime-env";
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
const SYSTEMCTL_TIMEOUT_MS = 5_000;
|
||||
const WSL_CUDA_MARKERS = [
|
||||
"/dev/dxg",
|
||||
"/usr/lib/wsl/lib/nvidia-smi",
|
||||
"/usr/lib/wsl/lib/libcuda.so.1",
|
||||
"/usr/local/cuda",
|
||||
];
|
||||
|
||||
export function parseSystemctlShowProperties(stdout: string): Map<string, string> {
|
||||
const properties = new Map<string, string>();
|
||||
for (const line of stdout.split(/\r?\n/u)) {
|
||||
const separator = line.indexOf("=");
|
||||
if (separator <= 0) {
|
||||
continue;
|
||||
}
|
||||
properties.set(line.slice(0, separator), line.slice(separator + 1));
|
||||
}
|
||||
return properties;
|
||||
}
|
||||
|
||||
export async function isOllamaEnabledWithRestartAlways(): Promise<boolean> {
|
||||
try {
|
||||
const { stdout } = await execFileAsync(
|
||||
"systemctl",
|
||||
["show", "ollama.service", "--property=UnitFileState,Restart", "--no-pager"],
|
||||
{ timeout: SYSTEMCTL_TIMEOUT_MS },
|
||||
);
|
||||
const properties = parseSystemctlShowProperties(stdout);
|
||||
return properties.get("UnitFileState") === "enabled" && properties.get("Restart") === "always";
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function hasWslCuda(): Promise<boolean> {
|
||||
for (const marker of WSL_CUDA_MARKERS) {
|
||||
try {
|
||||
await access(marker);
|
||||
return true;
|
||||
} catch {
|
||||
// Try the next cheap marker.
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export async function checkWsl2CrashLoopRisk(logger: PluginLogger): Promise<void> {
|
||||
try {
|
||||
if (!isWSL2Sync()) {
|
||||
return;
|
||||
}
|
||||
if (!(await isOllamaEnabledWithRestartAlways())) {
|
||||
return;
|
||||
}
|
||||
if (!(await hasWslCuda())) {
|
||||
return;
|
||||
}
|
||||
|
||||
logger.warn(
|
||||
[
|
||||
"[ollama] WSL2 crash-loop risk: ollama.service is enabled with Restart=always and CUDA is visible.",
|
||||
"On WSL2, GPU-backed Ollama can pin host memory while loading a model.",
|
||||
"Hyper-V memory reclaim cannot always reclaim those pinned pages, so Windows can terminate and restart the WSL2 VM.",
|
||||
"",
|
||||
"Common evidence: repeated WSL2 reboots, high CPU in app.slice at startup, and SIGTERM from systemd rather than the Linux OOM killer.",
|
||||
"See: https://github.com/ollama/ollama/issues/11317",
|
||||
"",
|
||||
"Mitigation:",
|
||||
" 1. Disable autostart: sudo systemctl disable ollama",
|
||||
" 2. Add [experimental] autoMemoryReclaim=disabled to %USERPROFILE%\\.wslconfig on Windows, then run wsl --shutdown",
|
||||
" 3. Set OLLAMA_KEEP_ALIVE=5m in the Ollama service environment or start ollama serve manually when needed",
|
||||
].join("\n"),
|
||||
);
|
||||
} catch {
|
||||
// Advisory only: never break provider registration or model discovery.
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user