all repos — sz-chrome-remote-cdp @ master

pi-browser-harness, patched to drive a Chrome on another host over CDP, with castle_cdp_search web search

src/daemon/spawn.ts (view raw)

 1
 2
 3
 4
 5
 6
 7
 8
 9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
 100
 101
 102
 103
 104
 105
 106
 107
 108
 109
 110
 111
 112
 113
 114
 115
 116
 117
 118
 119
 120
 121
 122
 123
 124
 125
 126
 127
 128
 129
 130
 131
 132
 133
 134
 135
 136
 137
 138
 139
 140
 141
 142
 143
 144
 145
 146
 147
 148
 149
 150
 151
 152
 153
 154
 155
 156
 157
 158
 159
 160
 161
 162
 163
 164
 165
 166
 167
 168
 169
 170
 171
 172
 173
 174
 175
 176
 177
 178
 179
 180
 181
 182
 183
 184
 185
 186
 187
 188
 189
 190
 191
 192
 193
 194
 195
 196
 197
 198
 199
 200
 201
 202
 203
 204
 205
 206
 207
 208
 209
 210
 211
 212
 213
 214
 215
 216
 217
 218
 219
 220
 221
 222
 223
 224
 225
 226
 227
 228
 229
 230
 231
 232
 233
 234
 235
 236
 237
 238
 239
 240
 241
 242
 243
 244
 245
 246
 247
 248
import { spawn, type ChildProcess } from "node:child_process";
import { accessSync } from "node:fs";
import { access, unlink } from "node:fs/promises";
import { createRequire } from "node:module";
import { fileURLToPath } from "node:url";
import { dirname, join } from "node:path";
import { createConnection } from "node:net";
import { DAEMON_SOCKET_PATH, serialize, type WireControl } from "./protocol";

const moduleDir = dirname(fileURLToPath(import.meta.url));

const requireFromHere = createRequire(import.meta.url);

const isWindows = process.platform === "win32";

// No-op on Windows: a named pipe is not a filesystem entry and unlinking a pipe path throws.
const cleanupStaleSocket = (): void => {
  if (isWindows) return;
  unlink(DAEMON_SOCKET_PATH).catch(() => {});
};

export const daemonSocketMayExist = async (
  platform: NodeJS.Platform = process.platform,
  socketPath: string = DAEMON_SOCKET_PATH,
): Promise<boolean> => {
  if (platform === "win32") return true;
  try {
    await access(socketPath);
    return true;
  } catch {
    return false;
  }
};

/**
 * FORK: ask a running daemon to exit, and wait until it has.
 *
 * Needed by /browser-target. The CDP target is resolved per daemon process and
 * the daemon holds an open WebSocket to the old browser, so switching browsers
 * means replacing the process — re-reading the stored target would not drop
 * that connection.
 *
 * Resolves true once the daemon is gone (or was never running). Resolves false
 * if it is still answering after the timeout, so callers can report the failure
 * instead of respawning into a port clash.
 */
export const stopDaemon = async (timeoutMs = 5_000): Promise<boolean> => {
  if (!(await isDaemonRunning(1_500))) {
    cleanupStaleSocket();
    return true;
  }

  await new Promise<void>((resolve) => {
    let settled = false;
    const done = (): void => {
      if (settled) return;
      settled = true;
      resolve();
    };

    const sock = createConnection(DAEMON_SOCKET_PATH);
    const timer = setTimeout(() => {
      sock.destroy();
      done();
    }, timeoutMs);

    sock.on("connect", () => {
      // Registration is required before the daemon forwards anything to its
      // message handler, so the shutdown has to come from a registered client.
      const reg: WireControl = { type: "control", action: "register", clientId: "_target_switch" };
      sock.write(serialize(reg) + "\n");
    });

    sock.on("data", (chunk: Buffer) => {
      if (!chunk.toString().includes('"registered"')) return;
      const bye: WireControl = { type: "control", action: "shutdown", reason: "browser target changed" };
      try {
        sock.write(serialize(bye) + "\n");
      } catch {
        // Daemon may already be going away; the poll below is the real check.
      }
      clearTimeout(timer);
      sock.destroy();
      done();
    });

    sock.on("error", () => {
      clearTimeout(timer);
      done();
    });
  });

  // The daemon exits asynchronously, so confirm rather than assume.
  const deadline = Date.now() + timeoutMs;
  while (Date.now() < deadline) {
    if (!(await isDaemonRunning(1_000))) {
      cleanupStaleSocket();
      return true;
    }
    await new Promise((r) => setTimeout(r, 200));
  }
  return false;
};

export const isDaemonRunning = async (timeoutMs = 2_000): Promise<boolean> => {
  if (!(await daemonSocketMayExist())) return false;

  return new Promise((resolve) => {
    let settled = false;
    const done = (alive: boolean): void => {
      if (settled) return;
      settled = true;
      resolve(alive);
    };

    const sock = createConnection(DAEMON_SOCKET_PATH);
    const timer = setTimeout(() => {
      sock.destroy();
      cleanupStaleSocket();
      done(false);
    }, timeoutMs);

    sock.on("connect", () => {
      const regMsg: WireControl = { type: "control", action: "register", clientId: "_liveness_probe" };
      sock.write(serialize(regMsg) + "\n");
    });

    sock.on("data", (chunk: Buffer) => {
      const text = chunk.toString();
      if (text.includes('"registered"')) {
        clearTimeout(timer);
        const deregMsg: WireControl = { type: "control", action: "deregister", clientId: "_liveness_probe" };
        try { sock.write(serialize(deregMsg) + "\n"); } catch {}
        sock.destroy();
        done(true);
      }
    });

    sock.on("error", () => {
      clearTimeout(timer);
      sock.destroy();
      cleanupStaleSocket();
      done(false);
    });

    // Closed without a 'registered' ack: the daemon is alive but rejected us, so do NOT unlink its socket.
    sock.on("close", () => {
      clearTimeout(timer);
      done(false);
    });
  });
};

// Walks upward like Node's own node_modules lookup. Exported with an injectable start so the
// hoisted layout pi uses can be exercised against a fixture instead of this checkout.
export const resolveTsxCliFromFilesystem = (startDir: string = moduleDir): string | null => {
  let dir = startDir;

  while (true) {
    const candidate = join(dir, "node_modules", "tsx", "dist", "cli.mjs");
    try {
      accessSync(candidate);
      return candidate;
    } catch {
      // Keep walking toward the filesystem root. This mirrors Node's node_modules lookup
      // without relying on createRequire().resolve(), which is broken for external modules
      // loaded through jiti/static inside a Bun-compiled executable such as pi.exe.
    }

    const parent = dirname(dir);
    if (parent === dir) return null;
    dir = parent;
  }
};

const resolveTsxCli = (): string | null => {
  try {
    const resolved = requireFromHere.resolve("tsx/cli");
    // A compiled runtime may theoretically return a virtual path that a separately spawned
    // Node process cannot read. Only accept a resolver result that exists on the host FS.
    accessSync(resolved);
    return resolved;
  } catch {
    return resolveTsxCliFromFilesystem();
  }
};

export const daemonNodeExecutable = (
  env: NodeJS.ProcessEnv = process.env,
  versions: Readonly<Record<string, string | undefined>> = process.versions,
  platform: NodeJS.Platform = process.platform,
  execPath: string = process.execPath,
): string => {
  const override = env["PI_BROWSER_NODE"];
  if (override) return override;

  // Under normal Node keep the exact executable that launched pi/the extension. Under Bun,
  // process.execPath is bun/pi.exe, not Node, so run the tsx CLI with the required Node runtime.
  if ("bun" in versions) return platform === "win32" ? "node.exe" : "node";

  return execPath;
};

export const daemonSpawnCommand = (): { readonly command: string; readonly args: ReadonlyArray<string> } | null => {
  const daemonScript = join(moduleDir, "index.ts");
  const tsxCli = resolveTsxCli();
  if (!tsxCli) return null;

  return {
    command: daemonNodeExecutable(),
    args: [tsxCli, daemonScript],
  };
};

export const spawnDaemon = (): ChildProcess | null => {
  const spec = daemonSpawnCommand();
  if (!spec) return null;

  const child = spawn(spec.command, [...spec.args], {
    detached: true,
    stdio: "ignore",
    env: { ...process.env },
    windowsHide: true,
  });

  // `spawn` reports a missing executable (a bare `node` that is not on PATH under a Bun host)
  // asynchronously via `error`; without a listener that is an uncaught exception in pi.
  // ensureDaemon's poll loop reports the failure as a timeout, which is what the user sees.
  child.on("error", () => {});

  child.unref();
  return child;
};

export const ensureDaemon = async (timeoutMs = 10_000): Promise<boolean> => {
  if (await isDaemonRunning()) return true;

  const child = spawnDaemon();
  if (!child) return false;

  const deadline = Date.now() + timeoutMs;
  while (Date.now() < deadline) {
    if (await isDaemonRunning()) return true;
    await new Promise((r) => setTimeout(r, 200));
  }

  return false;
};