fix(browser): recover stale Chromium profile locks (#62935) (#62935)

Co-authored-by: Peter Steinberger <steipete@gmail.com>
This commit is contained in:
Sean Coley
2026-04-25 17:21:49 +12:00
committed by GitHub
parent f00d65a304
commit a35333abe1
5 changed files with 300 additions and 55 deletions

View File

@@ -89,6 +89,7 @@ Docs: https://docs.openclaw.ai
- Browser/security: require `operator.admin` for the `browser.request` gateway method, matching the host/browser-node control authority exposed by that route. Thanks @RichardCao.
- Browser/profiles: allow local managed profiles to override `browser.executablePath`, so different profiles can launch different Chromium-based browsers. Thanks @nobrainer-tech.
- Agents/replay: repair displaced or missing tool results before strict provider replay, use Codex-compatible `aborted` outputs for OpenAI Responses history, and drop partial aborted/error transport turns before retries.
- Browser/profiles: recover from stale Chromium `Singleton*` profile locks after crashes or host moves by clearing dead/foreign locks and retrying launch once. Thanks @seanc-dev.
- Reply media: allow sandboxed replies to deliver OpenClaw-managed `media/outbound` and `media/tool-*` attachments without treating them as sandbox escapes, while keeping alias-escape checks on the managed media root. Fixes #71138. Thanks @mayor686, @truffle-dev, and @neeravmakwana.
- CLI/agent: keep `openclaw agent --json` stdout reserved for the JSON response by routing gateway, plugin, and embedded-fallback diagnostics to stderr before execution starts. Fixes #71319.
- Agents/Gemini: retry reasoning-only, empty, and planning-only Gemini turns instead of letting sessions silently stall. Fixes #71074. (#71362) Thanks @neeravmakwana.

View File

@@ -25,6 +25,16 @@ chromium-browser is already the newest version (2:1snap1-0ubuntu2).
This is NOT a real browser - it's just a wrapper.
Other common Linux launch failures:
- `The profile appears to be in use by another Chromium process` means Chrome
found stale `Singleton*` lock files in the managed profile directory. OpenClaw
removes those locks and retries once when the lock points at a dead or
different-host process.
- `Missing X server or $DISPLAY` means OpenClaw is trying to launch a visible
browser on a host without a desktop session. Use `browser.headless: true`,
start `Xvfb`, or run OpenClaw in a real desktop session.
### Solution 1: Install Google Chrome (Recommended)
Install the official Google Chrome `.deb` package, which is not sandboxed by snap:

View File

@@ -433,6 +433,66 @@ describe("chrome.ts internal", () => {
}
});
it("clears stale singleton locks and retries once after profile-in-use launch failure", async () => {
let cdpReachable = false;
vi.stubGlobal(
"fetch",
vi.fn(async () => {
if (!cdpReachable) {
throw new Error("ECONNREFUSED");
}
return {
ok: true,
json: async () => ({ webSocketDebuggerUrl: "ws://127.0.0.1/devtools" }),
} as unknown as Response;
}),
);
vi.spyOn(fs, "existsSync").mockImplementation((p) => {
const s = String(p);
if (s === "/tmp/profile-chrome" || s.endsWith("Local State") || s.endsWith("Preferences")) {
return true;
}
return false;
});
let spawnCalls = 0;
const firstProc = makeFakeProc();
const secondProc = makeFakeProc();
spawnMock.mockImplementation(() => {
spawnCalls += 1;
if (spawnCalls === 1) {
setTimeout(() => {
firstProc.stderr.emit(
"data",
Buffer.from("The profile appears to be in use by another Chromium process"),
);
}, 0);
return firstProc;
}
cdpReachable = true;
return secondProc;
});
const profile = { ...makeProfile(18888), executablePath: "/tmp/profile-chrome" };
const userDataDir = resolveOpenClawUserDataDir(profile.name);
await fsp.mkdir(userDataDir, { recursive: true });
await fsp.writeFile(path.join(userDataDir, "SingletonCookie"), "cookie");
await fsp.writeFile(path.join(userDataDir, "SingletonSocket"), "socket");
await fsp.symlink("remote-host-535", path.join(userDataDir, "SingletonLock"));
try {
const running = await launchOpenClawChrome(makeResolved(), profile);
expect(running.proc).toBe(secondProc);
expect(firstProc.kill).toHaveBeenCalledWith("SIGKILL");
expect(spawnCalls).toBe(2);
expect(fs.existsSync(path.join(userDataDir, "SingletonLock"))).toBe(false);
expect(fs.existsSync(path.join(userDataDir, "SingletonSocket"))).toBe(false);
running.proc.kill?.("SIGTERM");
} finally {
await fsp.rm(userDataDir, { recursive: true, force: true });
}
});
it("throws with stderr hint + sandbox hint when CDP never becomes reachable", async () => {
const originalPlatform = process.platform;
Object.defineProperty(process, "platform", { value: "linux" });

View File

@@ -11,6 +11,7 @@ import {
resolveGoogleChromeExecutableForPlatform,
} from "./chrome.executables.js";
import {
clearStaleChromeSingletonLocks,
decorateOpenClawProfile,
diagnoseChromeCdp,
ensureProfileCleanExit,
@@ -212,6 +213,55 @@ describe("browser chrome profile decoration", () => {
const profile = prefs.profile as Record<string, unknown>;
expect(profile.name).toBe(DEFAULT_OPENCLAW_BROWSER_PROFILE_NAME);
});
it("clears stale singleton artifacts when the lock points at another host", async () => {
const userDataDir = await createUserDataDir();
await fsp.writeFile(path.join(userDataDir, "SingletonCookie"), "cookie");
await fsp.writeFile(path.join(userDataDir, "SingletonSocket"), "socket");
await fsp.symlink("remote-host-535", path.join(userDataDir, "SingletonLock"));
expect(clearStaleChromeSingletonLocks(userDataDir, "local-host")).toBe(true);
expect(fs.existsSync(path.join(userDataDir, "SingletonLock"))).toBe(false);
expect(fs.existsSync(path.join(userDataDir, "SingletonSocket"))).toBe(false);
expect(fs.existsSync(path.join(userDataDir, "SingletonCookie"))).toBe(false);
});
it("clears stale singleton artifacts when the lock PID is dead on the current host", async () => {
const userDataDir = await createUserDataDir();
const deadPid = 2147483646;
await fsp.symlink(`${os.hostname()}-${deadPid}`, path.join(userDataDir, "SingletonLock"));
expect(clearStaleChromeSingletonLocks(userDataDir, os.hostname())).toBe(true);
expect(fs.existsSync(path.join(userDataDir, "SingletonLock"))).toBe(false);
});
it("keeps singleton artifacts when the lock points at a current-host live process", async () => {
const userDataDir = await createUserDataDir();
await fsp.symlink(`${os.hostname()}-${process.pid}`, path.join(userDataDir, "SingletonLock"));
expect(clearStaleChromeSingletonLocks(userDataDir, os.hostname())).toBe(false);
expect(fs.lstatSync(path.join(userDataDir, "SingletonLock")).isSymbolicLink()).toBe(true);
});
it("keeps singleton artifacts when the lock PID exists but cannot be signaled", async () => {
const userDataDir = await createUserDataDir();
await fsp.symlink(`${os.hostname()}-12345`, path.join(userDataDir, "SingletonLock"));
const err = new Error("operation not permitted") as NodeJS.ErrnoException;
err.code = "EPERM";
const killSpy = vi.spyOn(process, "kill").mockImplementation(((pid, signal) => {
if (pid === 12345 && signal === 0) {
throw err;
}
return true;
}) as typeof process.kill);
try {
expect(clearStaleChromeSingletonLocks(userDataDir, os.hostname())).toBe(false);
expect(fs.lstatSync(path.join(userDataDir, "SingletonLock")).isSymbolicLink()).toBe(true);
} finally {
killSpy.mockRestore();
}
});
});
describe("browser chrome helpers", () => {

View File

@@ -53,6 +53,13 @@ import {
} from "./constants.js";
const log = createSubsystemLogger("browser").child("chrome");
const CHROME_SINGLETON_LOCK_PATHS = [
"SingletonLock",
"SingletonSocket",
"SingletonCookie",
] as const;
const CHROME_SINGLETON_IN_USE_PATTERN = /profile appears to be in use by another chromium process/i;
const CHROME_MISSING_DISPLAY_PATTERN = /missing x server|\$DISPLAY/i;
export type { BrowserExecutable } from "./chrome.executables.js";
export {
@@ -81,6 +88,109 @@ function exists(filePath: string) {
}
}
function processExists(pid: number): boolean {
if (!Number.isInteger(pid) || pid <= 0) {
return false;
}
try {
process.kill(pid, 0);
return true;
} catch (err) {
if ((err as NodeJS.ErrnoException).code === "EPERM") {
return true;
}
return false;
}
}
function clearChromeSingletonArtifacts(userDataDir: string) {
for (const basename of CHROME_SINGLETON_LOCK_PATHS) {
try {
fs.rmSync(path.join(userDataDir, basename), { force: true });
} catch {
// ignore best-effort cleanup
}
}
}
export function clearStaleChromeSingletonLocks(
userDataDir: string,
hostname = os.hostname(),
): boolean {
const lockPath = path.join(userDataDir, "SingletonLock");
let target: string;
try {
target = fs.readlinkSync(lockPath);
} catch {
return false;
}
const match = /^(?<lockHost>.+)-(?<pid>\d+)$/.exec(target);
if (!match?.groups) {
return false;
}
const lockHost = normalizeOptionalString(match.groups.lockHost) ?? "";
const pid = Number.parseInt(match.groups.pid ?? "", 10);
if (lockHost === hostname && processExists(pid)) {
return false;
}
clearChromeSingletonArtifacts(userDataDir);
return true;
}
async function waitForChromeProcessExit(proc: ChildProcess, timeoutMs: number): Promise<void> {
if (proc.exitCode != null || proc.signalCode != null || proc.killed) {
return;
}
await new Promise<void>((resolve) => {
const timer = setTimeout(() => {
proc.off("exit", onExit);
proc.off("close", onExit);
resolve();
}, timeoutMs);
const onExit = () => {
clearTimeout(timer);
resolve();
};
proc.once("exit", onExit);
proc.once("close", onExit);
});
}
async function terminateChromeForRetry(proc: ChildProcess, userDataDir: string) {
try {
proc.kill("SIGKILL");
} catch {
// ignore
}
await waitForChromeProcessExit(proc, CHROME_BOOTSTRAP_EXIT_TIMEOUT_MS);
clearStaleChromeSingletonLocks(userDataDir);
}
function chromeLaunchHints(params: {
stderrOutput: string;
resolved: ResolvedBrowserConfig;
profile: ResolvedBrowserProfile;
}): string {
const hints: string[] = [];
if (process.platform === "linux" && !params.resolved.noSandbox) {
hints.push("If running in a container or as root, try setting browser.noSandbox: true.");
}
if (CHROME_MISSING_DISPLAY_PATTERN.test(params.stderrOutput) && !params.profile.headless) {
hints.push(
"No DISPLAY/X server was detected. Enable browser.headless: true, start Xvfb, or run the Gateway in a desktop session.",
);
}
if (CHROME_SINGLETON_IN_USE_PATTERN.test(params.stderrOutput)) {
hints.push(
`The Chromium profile "${params.profile.name}" is locked. Stop the existing browser or remove stale Singleton* lock files under ~/.openclaw/browser/${params.profile.name}/user-data.`,
);
}
return hints.length > 0 ? `\nHint: ${hints.join("\nHint: ")}` : "";
}
export type RunningChrome = {
pid: number;
exe: BrowserExecutable;
@@ -363,66 +473,80 @@ export async function launchOpenClawChrome(
log.warn(`openclaw browser clean-exit prefs failed: ${String(err)}`);
}
const proc = spawnOnce();
const launchOnceAndWait = async (allowSingletonRecovery: boolean): Promise<RunningChrome> => {
const proc = spawnOnce();
// Collect stderr for diagnostics in case Chrome fails to start.
// The listener is removed on success to avoid unbounded memory growth
// from a long-lived Chrome process that emits periodic warnings.
const stderrChunks: Buffer[] = [];
const onStderr = (chunk: Buffer) => {
stderrChunks.push(chunk);
};
proc.stderr?.on("data", onStderr);
// Collect stderr for diagnostics in case Chrome fails to start.
// The listener is removed on success to avoid unbounded memory growth
// from a long-lived Chrome process that emits periodic warnings.
const stderrChunks: Buffer[] = [];
const onStderr = (chunk: Buffer) => {
stderrChunks.push(chunk);
};
proc.stderr?.on("data", onStderr);
// Wait for CDP to come up.
const readyDeadline = Date.now() + CHROME_LAUNCH_READY_WINDOW_MS;
while (Date.now() < readyDeadline) {
if (await isChromeReachable(profile.cdpUrl)) {
break;
}
await new Promise((r) => setTimeout(r, CHROME_LAUNCH_READY_POLL_MS));
}
if (!(await isChromeReachable(profile.cdpUrl))) {
const diagnosticText = await diagnoseChromeCdp(profile.cdpUrl)
.then(formatChromeCdpDiagnostic)
.catch((err) => `CDP diagnostic failed: ${safeChromeCdpErrorMessage(err)}.`);
const stderrOutput =
normalizeOptionalString(Buffer.concat(stderrChunks).toString("utf8")) ?? "";
const stderrHint = stderrOutput
? `\nChrome stderr:\n${stderrOutput.slice(0, CHROME_STDERR_HINT_MAX_CHARS)}`
: "";
const sandboxHint =
process.platform === "linux" && !resolved.noSandbox
? "\nHint: If running in a container or as root, try setting browser.noSandbox: true in config."
: "";
try {
proc.kill("SIGKILL");
} catch {
// ignore
const readyDeadline = Date.now() + CHROME_LAUNCH_READY_WINDOW_MS;
while (Date.now() < readyDeadline) {
if (await isChromeReachable(profile.cdpUrl)) {
break;
}
await new Promise((r) => setTimeout(r, CHROME_LAUNCH_READY_POLL_MS));
}
if (!(await isChromeReachable(profile.cdpUrl))) {
const diagnosticText = await diagnoseChromeCdp(profile.cdpUrl)
.then(formatChromeCdpDiagnostic)
.catch((err) => `CDP diagnostic failed: ${safeChromeCdpErrorMessage(err)}.`);
const stderrOutput =
normalizeOptionalString(Buffer.concat(stderrChunks).toString("utf8")) ?? "";
if (
allowSingletonRecovery &&
CHROME_SINGLETON_IN_USE_PATTERN.test(stderrOutput) &&
clearStaleChromeSingletonLocks(userDataDir)
) {
log.warn(
`Removed stale Chromium Singleton* locks for profile "${profile.name}" and retrying launch.`,
);
await terminateChromeForRetry(proc, userDataDir);
return await launchOnceAndWait(false);
}
const stderrHint = stderrOutput
? `\nChrome stderr:\n${stderrOutput.slice(0, CHROME_STDERR_HINT_MAX_CHARS)}`
: "";
const launchHints = chromeLaunchHints({ stderrOutput, resolved, profile });
try {
proc.kill("SIGKILL");
} catch {
// ignore
}
throw new Error(
`Failed to start Chrome CDP on port ${profile.cdpPort} for profile "${profile.name}". ${diagnosticText}${launchHints}${stderrHint}`,
);
}
const pid = proc.pid ?? -1;
log.info(
`🦞 openclaw browser started (${exe.kind}) profile "${profile.name}" on 127.0.0.1:${profile.cdpPort} (pid ${pid})`,
);
return {
pid,
exe,
userDataDir,
cdpPort: profile.cdpPort,
startedAt,
proc,
};
} finally {
// Chrome started successfully or launch failed — detach the stderr listener
// and release the buffer.
proc.stderr?.off("data", onStderr);
stderrChunks.length = 0;
}
throw new Error(
`Failed to start Chrome CDP on port ${profile.cdpPort} for profile "${profile.name}". ${diagnosticText}${sandboxHint}${stderrHint}`,
);
}
// Chrome started successfully — detach the stderr listener and release the buffer.
proc.stderr?.off("data", onStderr);
stderrChunks.length = 0;
const pid = proc.pid ?? -1;
log.info(
`🦞 openclaw browser started (${exe.kind}) profile "${profile.name}" on 127.0.0.1:${profile.cdpPort} (pid ${pid})`,
);
return {
pid,
exe,
userDataDir,
cdpPort: profile.cdpPort,
startedAt,
proc,
};
return await launchOnceAndWait(true);
}
export async function stopOpenClawChrome(