aboutsummaryrefslogtreecommitdiff
path: root/src/profile/launch.ts
diff options
context:
space:
mode:
authoramankumarsingh77 <amankumarsingh7702@gmail.com>2026-07-26 20:51:43 +0530
committeramankumarsingh77 <amankumarsingh7702@gmail.com>2026-07-26 20:51:43 +0530
commit7d2256e584709e4b7c27ab83b61950f00def7046 (patch)
tree642775bd745cb59423e762f4df8ff6ea65520ac4 /src/profile/launch.ts
parent083012ef3ede192505a953b7779d0c916dcaa98f (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/launch.ts')
-rw-r--r--src/profile/launch.ts34
1 files changed, 26 insertions, 8 deletions
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<string | undefined>((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;
}
},
});