From 7d2256e584709e4b7c27ab83b61950f00def7046 Mon Sep 17 00:00:00 2001 From: amankumarsingh77 Date: Sun, 26 Jul 2026 20:51:43 +0530 Subject: fix(profile): resolve replaced binaries and surface real launch failures MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two failures made a profile launch report the wrong cause. Linux appends " (deleted)" to /proc//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) --- src/profile/launch.ts | 34 ++++++++++++++++++++++++++-------- 1 file changed, 26 insertions(+), 8 deletions(-) (limited to 'src/profile/launch.ts') diff --git a/src/profile/launch.ts b/src/profile/launch.ts index fd93d3d..0088b6d 100644 --- a/src/profile/launch.ts +++ b/src/profile/launch.ts @@ -69,29 +69,47 @@ export const openProfileWindow = async ( if (req.explicitUserDataDir) args.push(`--user-data-dir=${req.explicitUserDataDir}`); args.push(`--profile-directory=${req.profileDir}`, "--new-window", sentinelUrl); - let child: ChildProcess | null = null; + let child: ChildProcess; try { // No shell: arguments are passed as an array, so profile names and paths // containing spaces survive intact on every platform. child = spawn(req.exePath, args, { detached: true, stdio: "ignore" }); - child.unref(); - // The launcher process exits as soon as the running browser takes over the - // command line; an error here means the binary itself could not be started. - child.on("error", () => {}); } catch (e) { await rm(file, { force: true }).catch(() => {}); return err(`could not launch ${req.exePath}: ${e instanceof Error ? e.message : String(e)}`); } + // spawn() reports a missing or non-executable binary ASYNCHRONOUSLY, as an + // 'error' event — it does not throw. Ignoring that event made an ENOENT + // indistinguishable from a browser that simply never opened the window, so + // the caller's 15s sentinel poll reported a timeout for what was really a bad + // executable path. Node guarantees exactly one of 'spawn' or 'error', so + // waiting for it costs nothing and turns the failure into a named cause. + const spawnFailure = await new Promise((resolve) => { + child.once("spawn", () => resolve(undefined)); + child.once("error", (e: Error) => resolve(e.message)); + }); + // A later 'error' event with no listener would throw; keep one attached for + // the lifetime of the detached launcher. + child.on("error", () => {}); + child.unref(); + + if (spawnFailure) { + await rm(file, { force: true }).catch(() => {}); + return err(`could not launch ${req.exePath}: ${spawnFailure}`); + } + + let live: ChildProcess | null = child; + return ok({ sentinelUrl, cleanup: async () => { await rm(file, { force: true }).catch(() => {}); }, kill: () => { - if (child) { - try { child.kill("SIGTERM"); } catch {} - child = null; + if (live) { + try { live.kill("SIGTERM"); } catch {} + live = null; } }, }); -- cgit v1.2.3