aboutsummaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/profile/browser-process.ts62
-rw-r--r--src/profile/launch.ts34
2 files changed, 82 insertions, 14 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();
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;
}
},
});