fix(ollama): warn on WSL2 CUDA crash loop risk

This commit is contained in:
Peter Steinberger
2026-04-27 13:23:38 +01:00
parent 0a076bc0fc
commit 750c180a6c
6 changed files with 285 additions and 0 deletions

View File

@@ -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;

View 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();
});
});

View 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.
}
}