@orkestrel/browser 0.0.12 → 0.0.14

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -353,12 +353,35 @@ function probeAllPathNames(names, platform) {
353
353
  for (const name of names) {
354
354
  const result = (0, node_child_process.spawnSync)(finder, [name], { stdio: "pipe" });
355
355
  if (result.status === 0) {
356
- const output = result.stdout.toString("utf-8").trim().split("\n")[0];
357
- if (output !== void 0 && output.length > 0) found.push(output);
356
+ const executable = readFirstLine(result.stdout.toString("utf-8"));
357
+ if (executable !== void 0) found.push(executable);
358
358
  }
359
359
  }
360
360
  return found;
361
361
  }
362
+ /**
363
+ * Returns the first non-empty line of a command's output, without its
364
+ * surrounding whitespace.
365
+ *
366
+ * @remarks
367
+ * `where` reports every PATH match on its own CRLF-terminated line, so a
368
+ * carriage return left on the first line would travel into `existsSync` and
369
+ * `spawn` as part of the executable path.
370
+ *
371
+ * @param output - Raw decoded standard output of a command
372
+ * @returns The first line carrying text, or undefined when the output has none
373
+ *
374
+ * @example
375
+ * ```ts
376
+ * readFirstLine('C:\\bin\\chrome.exe\r\nC:\\other\\chrome.exe\r\n') // 'C:\\bin\\chrome.exe'
377
+ * ```
378
+ */
379
+ function readFirstLine(output) {
380
+ for (const line of output.split(/\r?\n/)) {
381
+ const text = line.trim();
382
+ if (text.length > 0) return text;
383
+ }
384
+ }
362
385
  /** Build the default Playwright browser store base directories to search for a managed Chromium. */
363
386
  function defaultStoreBases(env, platform) {
364
387
  const bases = [];
@@ -399,6 +422,11 @@ function findAllInStore(base, platform) {
399
422
  * @remarks
400
423
  * POSIX launches own an isolated process group so lifecycle teardown can
401
424
  * signal and await every Chromium subprocess without affecting the caller.
425
+ * Windows launches are not detached and own no group, so teardown there
426
+ * signals one process by identifier: the spawned process, or the process the
427
+ * launcher handed the endpoint to when that spawned process re-executed the
428
+ * browser and exited. Terminating a Chromium browser process takes its own
429
+ * subprocesses with it, so that single signal drains the tree.
402
430
  *
403
431
  * @param executable - Absolute path to the browser executable
404
432
  * @param port - Port the browser exposes its CDP endpoint on
@@ -743,6 +771,7 @@ var Browser = class {
743
771
  #endpoint;
744
772
  #client;
745
773
  #process;
774
+ #servingPid;
746
775
  #profile;
747
776
  #contexts = [];
748
777
  #destroyed = false;
@@ -784,7 +813,7 @@ var Browser = class {
784
813
  return this.#status === "connected";
785
814
  }
786
815
  get pid() {
787
- return this.#process?.pid;
816
+ return this.#servingPid ?? this.#process?.pid;
788
817
  }
789
818
  async discover() {
790
819
  const result = await this.#discoverCDP();
@@ -980,6 +1009,7 @@ var Browser = class {
980
1009
  this.#unbindTransport();
981
1010
  this.#unbindProcess();
982
1011
  this.#process = void 0;
1012
+ this.#servingPid = void 0;
983
1013
  const cleanup = this.#cleanupExitedProcess(process, contexts, client);
984
1014
  this.#exitCleanup = cleanup;
985
1015
  cleanup.catch((error) => this.#emitter.emit("error", error));
@@ -997,27 +1027,19 @@ var Browser = class {
997
1027
  }
998
1028
  #handleTransportLoss() {
999
1029
  if (this.#destroyed || this.#status !== "connected") return;
1000
- const process = this.#process;
1001
- if (process !== void 0 && (process.exitCode !== null || process.signalCode !== null)) {
1002
- this.#handleProcessExit();
1030
+ if (this.#process === void 0) {
1031
+ this.#confirmTransportLoss();
1003
1032
  return;
1004
1033
  }
1005
- if (process !== void 0) {
1006
- setTimeout(() => this.#confirmTransportLoss(), 50);
1034
+ if (!this.#alive()) {
1035
+ this.#handleProcessExit();
1007
1036
  return;
1008
1037
  }
1009
- this.#confirmTransportLoss();
1038
+ setTimeout(() => this.#confirmTransportLoss(), 50);
1010
1039
  }
1011
1040
  #confirmTransportLoss() {
1012
1041
  if (this.#destroyed || this.#status !== "connected") return;
1013
- const process = this.#process;
1014
- if (process !== void 0 && (process.exitCode !== null || process.signalCode !== null)) {
1015
- this.#handleProcessExit();
1016
- return;
1017
- }
1018
- if (process?.pid !== void 0) try {
1019
- process.kill(0);
1020
- } catch {
1042
+ if (this.#process !== void 0 && !this.#alive()) {
1021
1043
  this.#handleProcessExit();
1022
1044
  return;
1023
1045
  }
@@ -1054,6 +1076,7 @@ var Browser = class {
1054
1076
  }
1055
1077
  #bindProcess(process) {
1056
1078
  this.#unbindProcess();
1079
+ if (this.#servingPid !== void 0) return;
1057
1080
  process.once("exit", this.#onProcessExit);
1058
1081
  if (process.exitCode !== null || process.signalCode !== null) queueMicrotask(this.#onProcessExit);
1059
1082
  }
@@ -1178,6 +1201,7 @@ var Browser = class {
1178
1201
  timeout: this.#timeout()
1179
1202
  });
1180
1203
  await this.#raceAbort(client.connect());
1204
+ await this.#takeEndpointOwner(process, client);
1181
1205
  const connection = this.#options.profile === void 0 ? "launch" : "persistent";
1182
1206
  this.#client = client;
1183
1207
  this.#endpoint = endpoint;
@@ -1196,12 +1220,49 @@ var Browser = class {
1196
1220
  await this.#terminate(process);
1197
1221
  await this.#releaseProfile();
1198
1222
  this.#process = void 0;
1223
+ this.#servingPid = void 0;
1199
1224
  this.#client = void 0;
1200
1225
  this.#endpoint = void 0;
1201
1226
  this.#owned = void 0;
1202
1227
  throw error;
1203
1228
  }
1204
1229
  }
1230
+ /**
1231
+ * Takes over the process serving the CDP endpoint when the spawned child is
1232
+ * no longer it.
1233
+ *
1234
+ * @remarks
1235
+ * A Windows launcher — Microsoft Edge — re-executes the browser with the
1236
+ * same `--remote-debugging-port` and exits 0 before the endpoint answers, so
1237
+ * the child this instance spawned parents the real tree without being part
1238
+ * of it. Chromium names the process behind the endpoint as the `browser`
1239
+ * entry of `SystemInfo.getProcessInfo`, which identifies it without a
1240
+ * platform branch. An endpoint that names no such process cannot be owned,
1241
+ * so the launch closes it and fails rather than leaking it.
1242
+ */
1243
+ async #takeEndpointOwner(process, client) {
1244
+ if (process.exitCode === null && process.signalCode === null) return;
1245
+ let result;
1246
+ try {
1247
+ result = await client.send("SystemInfo.getProcessInfo");
1248
+ } catch {
1249
+ result = void 0;
1250
+ }
1251
+ let pid;
1252
+ if ((0, _orkestrel_contract.isRecord)(result) && (0, _orkestrel_contract.isArray)(result["processInfo"])) for (const entry of result["processInfo"]) {
1253
+ if (!(0, _orkestrel_contract.isRecord)(entry) || entry["type"] !== "browser" || !(0, _orkestrel_contract.isInteger)(entry["id"])) continue;
1254
+ pid = entry["id"];
1255
+ break;
1256
+ }
1257
+ if (pid === void 0) {
1258
+ await this.#closeRemote(client);
1259
+ throw new BrowserConnectionError("The browser launcher exited without naming the process serving its CDP endpoint", {
1260
+ executable: this.#options.executable,
1261
+ pid: process.pid
1262
+ });
1263
+ }
1264
+ this.#servingPid = pid;
1265
+ }
1205
1266
  async #waitForLaunch(process, executable, args) {
1206
1267
  const context = {
1207
1268
  executable,
@@ -1213,6 +1274,7 @@ var Browser = class {
1213
1274
  const exited = (0, node_events.once)(process, "exit", { signal }).then((values) => {
1214
1275
  const code = (0, _orkestrel_contract.isInteger)(values[0]) ? values[0] : null;
1215
1276
  const exitSignal = (0, _orkestrel_contract.isString)(values[1]) ? values[1] : null;
1277
+ if (code === 0 && exitSignal === null) return ready;
1216
1278
  throw new BrowserConnectionError(this.#formatLaunchExit(code, exitSignal), context);
1217
1279
  });
1218
1280
  try {
@@ -1275,6 +1337,7 @@ var Browser = class {
1275
1337
  this.#contexts = [];
1276
1338
  await this.#terminate(this.#process);
1277
1339
  this.#process = void 0;
1340
+ this.#servingPid = void 0;
1278
1341
  terminated = true;
1279
1342
  } finally {
1280
1343
  const contexts = this.#contexts;
@@ -1304,6 +1367,7 @@ var Browser = class {
1304
1367
  if (!await this.#waitForTerminationWithin(process, 3e3)) await this.#terminate(process);
1305
1368
  }
1306
1369
  this.#process = void 0;
1370
+ this.#servingPid = void 0;
1307
1371
  terminated = true;
1308
1372
  } finally {
1309
1373
  const contexts = this.#contexts;
@@ -1415,31 +1479,39 @@ var Browser = class {
1415
1479
  const pid = process.pid;
1416
1480
  return globalThis.process.platform !== "win32" && pid !== void 0 && pid > 0;
1417
1481
  }
1418
- #inspectProcessGroup(process) {
1419
- if (!this.#hasProcessGroup(process)) return false;
1482
+ #alive() {
1483
+ const process = this.#process;
1484
+ if (process === void 0) return false;
1485
+ if (this.#servingPid !== void 0) return this.#signalProcess(process, 0) !== false;
1486
+ return process.exitCode === null && process.signalCode === null;
1487
+ }
1488
+ #inspectRemainder(process) {
1489
+ if (!this.#hasProcessGroup(process) && this.#servingPid === void 0) return false;
1420
1490
  return this.#signalProcess(process, 0);
1421
1491
  }
1422
1492
  #signalProcess(process, signal) {
1423
1493
  const pid = process.pid;
1494
+ const serving = this.#servingPid;
1424
1495
  try {
1425
1496
  if (this.#hasProcessGroup(process) && pid !== void 0) return globalThis.process.kill(-pid, signal);
1497
+ if (serving !== void 0) return globalThis.process.kill(serving, signal);
1426
1498
  return process.kill(signal);
1427
1499
  } catch (error) {
1428
1500
  const code = (0, _orkestrel_contract.isError)(error) && "code" in error && (0, _orkestrel_contract.isString)(error.code) ? error.code : void 0;
1429
1501
  if (code === "ESRCH") return false;
1430
1502
  if (signal === 0) return code === "EPERM" ? true : void 0;
1431
1503
  throw new BrowserConnectionError("Failed to signal the browser process", {
1432
- pid,
1504
+ pid: serving ?? pid,
1433
1505
  signal,
1434
1506
  cause: error
1435
1507
  });
1436
1508
  }
1437
1509
  }
1438
- async #waitForProcessGroupWithin(process, timeout) {
1510
+ async #waitForRemainderWithin(process, timeout) {
1439
1511
  const deadline = performance.now() + timeout;
1440
1512
  let confirmed = false;
1441
1513
  while (true) {
1442
- const alive = this.#inspectProcessGroup(process);
1514
+ const alive = this.#inspectRemainder(process);
1443
1515
  if (alive === false) return true;
1444
1516
  if (alive === true) confirmed = true;
1445
1517
  const remaining = deadline - performance.now();
@@ -1449,17 +1521,17 @@ var Browser = class {
1449
1521
  }
1450
1522
  async #waitForTerminationWithin(process, timeout, final = false) {
1451
1523
  const exited = this.#waitForProcessWithin(process, timeout);
1452
- const drained = this.#waitForProcessGroupWithin(process, timeout);
1524
+ const drained = this.#waitForRemainderWithin(process, timeout);
1453
1525
  const results = await Promise.all([exited, drained]);
1454
1526
  return results[0] && (results[1] === true || final && results[1] === void 0);
1455
1527
  }
1456
1528
  async #terminate(process) {
1457
- if (process === void 0 || (process.exitCode !== null || process.signalCode !== null) && this.#inspectProcessGroup(process) === false) return;
1529
+ if (process === void 0 || (process.exitCode !== null || process.signalCode !== null) && this.#inspectRemainder(process) === false) return;
1458
1530
  if (this.#signalProcess(process, "SIGTERM") === false) return;
1459
1531
  if (await this.#waitForTerminationWithin(process, 3e3)) return;
1460
1532
  if (this.#signalProcess(process, "SIGKILL") === false) return;
1461
1533
  if (await this.#waitForTerminationWithin(process, 3e3, true)) return;
1462
- throw new BrowserConnectionError("Browser process did not exit after SIGKILL", { pid: process.pid });
1534
+ throw new BrowserConnectionError("Browser process did not exit after SIGKILL", { pid: this.#servingPid ?? process.pid });
1463
1535
  }
1464
1536
  #finish() {
1465
1537
  this.#unbindTransport();
@@ -1470,6 +1542,7 @@ var Browser = class {
1470
1542
  this.#endpoint = void 0;
1471
1543
  this.#client = void 0;
1472
1544
  this.#process = void 0;
1545
+ this.#servingPid = void 0;
1473
1546
  this.#exitCleanup = void 0;
1474
1547
  this.#contexts = [];
1475
1548
  this.#emitter.emit("destroy");
@@ -1530,6 +1603,7 @@ exports.normalizeExecutablePath = normalizeExecutablePath;
1530
1603
  exports.parseBrowserEngine = parseBrowserEngine;
1531
1604
  exports.probeAllPathNames = probeAllPathNames;
1532
1605
  exports.probePathNames = probePathNames;
1606
+ exports.readFirstLine = readFirstLine;
1533
1607
  exports.removeBrowserProfile = removeBrowserProfile;
1534
1608
  exports.waitForCDPReady = waitForCDPReady;
1535
1609
  exports.windowsRoots = windowsRoots;