diff options
| author | amankumarsingh77 <amankumarsingh7702@gmail.com> | 2026-07-26 20:51:43 +0530 |
|---|---|---|
| committer | amankumarsingh77 <amankumarsingh7702@gmail.com> | 2026-07-26 20:51:43 +0530 |
| commit | 7d2256e584709e4b7c27ab83b61950f00def7046 (patch) | |
| tree | 642775bd745cb59423e762f4df8ff6ea65520ac4 /src/profile/browser-process.ts | |
| parent | 083012ef3ede192505a953b7779d0c916dcaa98f (diff) | |
fix(profile): resolve replaced binaries and surface real launch failures
Two failures made a profile launch report the wrong cause.
Linux appends " (deleted)" to /proc/<pid>/exe once the running binary is
unlinked, which is what a google-chrome package upgrade does while the
browser stays open. detectRunningBrowser passed that literal string on as
the executable path, so every launch tried to spawn
"/opt/google/chrome/chrome (deleted)" and hit ENOENT. Executable paths are
now validated against disk, with the marker stripped and argv[0] used as a
fallback; a binary genuinely named "… (deleted)" still wins.
spawn() reports a missing or non-executable binary asynchronously via an
'error' event rather than throwing, and that event was discarded — so
openProfileWindow returned success, no window opened, and the caller
blamed the browser 15s later with a sentinel timeout. The launch now
awaits 'spawn'/'error', returns the real cause, and removes its handshake
page on the failure path.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Diffstat (limited to 'src/profile/browser-process.ts')
| -rw-r--r-- | src/profile/browser-process.ts | 62 |
1 files changed, 56 insertions, 6 deletions
diff --git a/src/profile/browser-process.ts b/src/profile/browser-process.ts index c0f6178..77bc05c 100644 --- a/src/profile/browser-process.ts +++ b/src/profile/browser-process.ts @@ -92,6 +92,47 @@ export type BrowserCandidate = { readonly explicitUserDataDir?: string; }; +/** + * Linux marks a replaced binary in `/proc/<pid>/exe`: once the file behind a + * running process is unlinked — which is exactly what a `google-chrome` package + * upgrade does while the browser keeps running — the symlink reads + * "/opt/google/chrome/chrome (deleted)". Spawning that string fails with + * ENOENT, so the marker has to come off. + * + * It is stripped only as a fallback, and only when the stripped path exists: a + * binary genuinely named "… (deleted)" must still win. + */ +const DELETED_SUFFIX = " (deleted)"; + +/** The path without Linux's deleted-binary marker, or undefined if unmarked. */ +export const stripDeletedSuffix = (path: string): string | undefined => + path.endsWith(DELETED_SUFFIX) && path.length > DELETED_SUFFIX.length + ? path.slice(0, -DELETED_SUFFIX.length) + : undefined; + +const exists = async (path: string): Promise<boolean> => { + try { + await access(path); + return true; + } catch { + return false; + } +}; + +/** + * A spawnable executable path for a raw readlink/argv value: the value itself + * when it exists, else the same value with the deleted-binary marker removed. + * Undefined when neither form is on disk — a path we cannot spawn must never be + * handed on as if it were usable. + */ +export const spawnableExePath = async (raw: string): Promise<string | undefined> => { + if (raw.length === 0) return undefined; + if (await exists(raw)) return raw; + const stripped = stripDeletedSuffix(raw); + if (stripped && (await exists(stripped))) return stripped; + return undefined; +}; + /** Path comparison that tolerates separator and case differences per platform. */ const samePath = (a: string, b: string): boolean => { const norm = (p: string): string => { @@ -163,14 +204,19 @@ const collectLinux = async (): Promise<ReadonlyArray<BrowserCandidate>> => { continue; } if (!cmdline || hasChildProcessType(cmdline)) continue; - let exePath: string | undefined; + let link: string | undefined; try { - exePath = await readlink(`/proc/${pid}/exe`); + link = await readlink(`/proc/${pid}/exe`); } catch { - // /proc/<pid>/exe is unreadable for processes we don't own; argv[0] is - // the next best thing. - exePath = cmdline.split(" ")[0]; + // /proc/<pid>/exe is unreadable for processes we don't own. } + // argv[0] backs up the symlink in both directions: unreadable for a process + // we don't own, and pointing at an unlinked binary after an in-place + // upgrade. Chrome's browser process rewrites its argv to the bare + // executable path, so on Linux argv[0] is the live install location. + const exePath = + (link ? await spawnableExePath(link) : undefined) ?? + (await spawnableExePath(cmdline.split(" ")[0] ?? "")); const explicit = parseUserDataDirFlag(cmdline); candidates.push({ ...(exePath ? { exePath } : {}), @@ -354,7 +400,11 @@ export const isBrowserRunning = async (): Promise<boolean> => (await detectRunni */ export const resolveBrowserExecutable = async (running?: RunningBrowser): Promise<string | undefined> => { const detected = running ?? (await detectRunningBrowser()); - if (detected.exePath) return detected.exePath; + // Re-check spawnability here too: this function is the single gate every + // launch passes through, and a caller may hand us a RunningBrowser collected + // before the binary was replaced on disk. + const usable = detected.exePath ? await spawnableExePath(detected.exePath) : undefined; + if (usable) return usable; if (process.platform !== "win32") return undefined; const fromRegistry = await winRegistryExecutable(); |
