@orkestrel/browser 0.0.6 → 0.0.7

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.
@@ -29,7 +29,7 @@ var BROWSER_LAUNCH_ARGS = Object.freeze(["--no-first-run", "--no-default-browser
29
29
  var BROWSER_HEADLESS_ARG = "--headless=new";
30
30
  /** Prefix for isolated browser profiles created beneath the operating-system temp directory. */
31
31
  var BROWSER_PROFILE_PREFIX = "orkestrel-browser-";
32
- /** Grace period after SIGTERM before a launched process is escalated to SIGKILL during teardown. */
32
+ /** Bound for each launched-process exit window during TERM-to-KILL teardown. */
33
33
  var BROWSER_KILL_GRACE_MS = 3e3;
34
34
  /** Bound for the `discover: false` port-occupancy probe before launching — short, since it only needs to detect an already-listening CDP endpoint, not perform full discovery. */
35
35
  var BROWSER_PORT_PROBE_TIMEOUT_MS = 200;
@@ -394,6 +394,10 @@ function findAllInStore(base, platform) {
394
394
  /**
395
395
  * Launch a browser process with raw-CDP debugging flags.
396
396
  *
397
+ * @remarks
398
+ * POSIX launches own an isolated process group so lifecycle teardown can
399
+ * signal and await every Chromium subprocess without affecting the caller.
400
+ *
397
401
  * @param executable - Absolute path to the browser executable
398
402
  * @param port - Port the browser exposes its CDP endpoint on
399
403
  * @param headless - Whether to launch in headless mode
@@ -407,7 +411,10 @@ function launchBrowserProcess(executable, port, headless, profile, extra) {
407
411
  args.push(`--remote-debugging-port=${port}`, ...BROWSER_LAUNCH_ARGS);
408
412
  if (headless) args.push(BROWSER_HEADLESS_ARG);
409
413
  if (profile !== void 0) args.push(`--user-data-dir=${profile}`);
410
- return spawn(executable, args, { stdio: "ignore" });
414
+ return spawn(executable, args, {
415
+ stdio: "ignore",
416
+ detached: process.platform !== "win32"
417
+ });
411
418
  }
412
419
  /**
413
420
  * Poll a browser's CDP version endpoint until it responds or the timeout elapses.
@@ -498,11 +505,11 @@ var WebSocketCDPTransport = class {
498
505
  #reject;
499
506
  #timer;
500
507
  #closeResolve;
501
- #onSocketClose = () => this.#closeResolve?.();
508
+ #onSocketClose = this.#handleSocketClose.bind(this);
502
509
  constructor(options) {
503
510
  this.#emitter = new Emitter({
504
- on: options.on,
505
- error: options.error
511
+ ...options.on !== void 0 ? { on: options.on } : {},
512
+ ...options.error !== void 0 ? { error: options.error } : {}
506
513
  });
507
514
  this.#url = options.url;
508
515
  this.#timeout = options.timeout ?? BROWSER_DEFAULT_TIMEOUT_MS;
@@ -673,6 +680,9 @@ var WebSocketCDPTransport = class {
673
680
  });
674
681
  socket.emitter.on("error", (error) => this.#emitter.emit("error", error));
675
682
  }
683
+ #handleSocketClose() {
684
+ this.#closeResolve?.();
685
+ }
676
686
  };
677
687
  //#endregion
678
688
  //#region src/server/factories.ts
@@ -736,15 +746,16 @@ var Browser = class {
736
746
  #destroyed = false;
737
747
  #connecting;
738
748
  #disconnecting;
749
+ #exitCleanup;
739
750
  #shutdown;
740
751
  #transport;
741
- #onTransportClose = () => this.#handleTransportLoss();
742
- #onTransportError = () => this.#handleTransportLoss();
743
- #onProcessExit = () => this.#handleProcessExit();
752
+ #onTransportClose = this.#handleTransportLoss.bind(this);
753
+ #onTransportError = this.#handleTransportLoss.bind(this);
754
+ #onProcessExit = this.#handleProcessExit.bind(this);
744
755
  constructor(options) {
745
756
  this.#emitter = new Emitter({
746
- on: options?.on,
747
- error: options?.error
757
+ ...options?.on !== void 0 ? { on: options.on } : {},
758
+ ...options?.error !== void 0 ? { error: options.error } : {}
748
759
  });
749
760
  this.#options = options ?? {};
750
761
  this.#engine = this.#options.engine ?? (this.#options.executable !== void 0 ? parseBrowserEngine(this.#options.executable) ?? "chromium" : "chromium");
@@ -780,14 +791,12 @@ var Browser = class {
780
791
  }
781
792
  async connect() {
782
793
  if (this.#destroyed) throw new BrowserDestroyedError();
783
- if (this.#disconnecting !== void 0) await this.#disconnecting;
784
- if (this.#destroyed) throw new BrowserDestroyedError();
785
- if (this.#status === "connected") return;
786
794
  const active = this.#connecting;
787
795
  if (active !== void 0) {
788
796
  await active;
789
797
  return;
790
798
  }
799
+ if (this.#status === "connected") return;
791
800
  const attempt = this.#establish();
792
801
  this.#connecting = attempt;
793
802
  try {
@@ -803,15 +812,11 @@ var Browser = class {
803
812
  }
804
813
  async disconnect() {
805
814
  if (this.#destroyed) return;
806
- const active = this.#disconnecting;
807
- if (active !== void 0) {
808
- await active;
809
- return;
810
- }
811
- if (this.#connecting !== void 0) {
812
- await this.#connecting.catch(() => void 0);
813
- if (this.#destroyed) return;
814
- }
815
+ const disconnecting = this.#disconnecting;
816
+ const connecting = this.#connecting;
817
+ if (disconnecting !== void 0) await disconnecting;
818
+ if (connecting !== void 0) await connecting.catch(() => void 0);
819
+ if (this.#destroyed) return;
815
820
  if (this.#status !== "connected" || this.#client === void 0) return;
816
821
  const attempt = this.#detach();
817
822
  this.#disconnecting = attempt;
@@ -892,6 +897,10 @@ var Browser = class {
892
897
  return shutdown;
893
898
  }
894
899
  async #establish() {
900
+ if (this.#disconnecting !== void 0) await this.#disconnecting;
901
+ await this.#settleExit();
902
+ if (this.#destroyed) throw new BrowserDestroyedError();
903
+ if (this.#status === "connected") return;
895
904
  this.#assertNotAborted();
896
905
  this.#status = "connecting";
897
906
  try {
@@ -965,18 +974,19 @@ var Browser = class {
965
974
  const connected = this.#status === "connected";
966
975
  const client = this.#client;
967
976
  const contexts = this.#contexts;
977
+ const process = this.#process;
968
978
  this.#unbindTransport();
969
979
  this.#unbindProcess();
970
980
  this.#process = void 0;
971
- this.#releaseProfile().catch((error) => this.#emitter.emit("error", error));
981
+ const cleanup = this.#cleanupExitedProcess(process, contexts, client);
982
+ this.#exitCleanup = cleanup;
983
+ cleanup.catch((error) => this.#emitter.emit("error", error));
972
984
  this.#client = void 0;
973
985
  this.#contexts = [];
974
986
  this.#connection = void 0;
975
987
  this.#owned = void 0;
976
988
  this.#endpoint = void 0;
977
989
  if (connected) this.#status = "disconnected";
978
- this.#destroyContextList(contexts);
979
- this.#closeClient(client);
980
990
  this.#emitter.emit("error", new BrowserConnectionError("The browser process exited unexpectedly", { cause: BROWSER_PROCESS_EXIT_CAUSE }));
981
991
  if (connected) {
982
992
  this.#emitter.emit("disconnect");
@@ -1136,7 +1146,7 @@ var Browser = class {
1136
1146
  else {
1137
1147
  const found = findSystemBrowser({
1138
1148
  ...this.#options.browsers,
1139
- engine: requestedEngine
1149
+ ...requestedEngine !== void 0 ? { engine: requestedEngine } : {}
1140
1150
  });
1141
1151
  executable = found?.executable;
1142
1152
  resolvedEngine = found?.engine;
@@ -1250,6 +1260,7 @@ var Browser = class {
1250
1260
  this.#registerContext(context);
1251
1261
  }
1252
1262
  async #destroyResources() {
1263
+ let terminated = false;
1253
1264
  try {
1254
1265
  await this.#settle();
1255
1266
  this.#unbindTransport();
@@ -1262,17 +1273,22 @@ var Browser = class {
1262
1273
  this.#contexts = [];
1263
1274
  await this.#terminate(this.#process);
1264
1275
  this.#process = void 0;
1265
- await this.#closeClient(client);
1266
- this.#client = void 0;
1276
+ terminated = true;
1267
1277
  } finally {
1278
+ const contexts = this.#contexts;
1279
+ this.#contexts = [];
1280
+ const client = this.#client;
1281
+ this.#client = void 0;
1268
1282
  try {
1269
- await this.#releaseProfile();
1283
+ await this.#cleanupLocal(contexts, client);
1284
+ if (terminated) await this.#releaseProfile();
1270
1285
  } finally {
1271
1286
  this.#finish();
1272
1287
  }
1273
1288
  }
1274
1289
  }
1275
1290
  async #closeResources() {
1291
+ let terminated = false;
1276
1292
  try {
1277
1293
  await this.#settle();
1278
1294
  this.#unbindTransport();
@@ -1283,14 +1299,18 @@ var Browser = class {
1283
1299
  await this.#closeRemote(client);
1284
1300
  const process = this.#process;
1285
1301
  if (process !== void 0) {
1286
- if (!await this.#waitForProcessWithin(process, 3e3)) await this.#terminate(process);
1302
+ if (!await this.#waitForTerminationWithin(process, 3e3)) await this.#terminate(process);
1287
1303
  }
1288
1304
  this.#process = void 0;
1289
- await this.#closeClient(client);
1290
- this.#client = void 0;
1305
+ terminated = true;
1291
1306
  } finally {
1307
+ const contexts = this.#contexts;
1308
+ this.#contexts = [];
1309
+ const client = this.#client;
1310
+ this.#client = void 0;
1292
1311
  try {
1293
- await this.#releaseProfile();
1312
+ await this.#cleanupLocal(contexts, client);
1313
+ if (terminated) await this.#releaseProfile();
1294
1314
  } finally {
1295
1315
  this.#finish();
1296
1316
  }
@@ -1299,6 +1319,13 @@ var Browser = class {
1299
1319
  async #settle() {
1300
1320
  await this.#connecting?.catch(() => void 0);
1301
1321
  await this.#disconnecting?.catch(() => void 0);
1322
+ await this.#settleExit();
1323
+ }
1324
+ async #settleExit() {
1325
+ const cleanup = this.#exitCleanup;
1326
+ if (cleanup === void 0) return;
1327
+ await cleanup;
1328
+ if (this.#exitCleanup === cleanup) this.#exitCleanup = void 0;
1302
1329
  }
1303
1330
  async #closeRemote(client) {
1304
1331
  let remote = client;
@@ -1356,9 +1383,19 @@ var Browser = class {
1356
1383
  this.#profile = void 0;
1357
1384
  if (profile !== void 0) await removeBrowserProfile(profile);
1358
1385
  }
1359
- async #waitForProcess(process) {
1360
- if (process.exitCode !== null || process.signalCode !== null) return;
1361
- await once(process, "exit");
1386
+ async #cleanupExitedProcess(process, contexts, client) {
1387
+ let terminated = false;
1388
+ try {
1389
+ await this.#terminate(process);
1390
+ terminated = true;
1391
+ } finally {
1392
+ await this.#cleanupLocal(contexts, client);
1393
+ }
1394
+ if (terminated) await this.#releaseProfile();
1395
+ }
1396
+ async #cleanupLocal(contexts, client) {
1397
+ await this.#destroyContextList(contexts);
1398
+ await this.#closeClient(client);
1362
1399
  }
1363
1400
  async #waitForProcessWithin(process, timeout) {
1364
1401
  if (process.exitCode !== null || process.signalCode !== null) return true;
@@ -1371,20 +1408,55 @@ var Browser = class {
1371
1408
  throw error;
1372
1409
  }
1373
1410
  }
1374
- async #terminate(process) {
1375
- if (process === void 0 || process.exitCode !== null || process.signalCode !== null) return;
1411
+ #hasProcessGroup(process) {
1412
+ const pid = process.pid;
1413
+ return globalThis.process.platform !== "win32" && pid !== void 0 && pid > 0;
1414
+ }
1415
+ #inspectProcessGroup(process) {
1416
+ if (!this.#hasProcessGroup(process)) return false;
1417
+ return this.#signalProcess(process, 0);
1418
+ }
1419
+ #signalProcess(process, signal) {
1420
+ const pid = process.pid;
1376
1421
  try {
1377
- process.kill("SIGTERM");
1378
- } catch {
1379
- return;
1422
+ if (this.#hasProcessGroup(process) && pid !== void 0) return globalThis.process.kill(-pid, signal);
1423
+ return process.kill(signal);
1424
+ } catch (error) {
1425
+ const code = isError(error) && "code" in error && isString(error.code) ? error.code : void 0;
1426
+ if (code === "ESRCH") return false;
1427
+ if (signal === 0) return code === "EPERM" ? true : void 0;
1428
+ throw new BrowserConnectionError("Failed to signal the browser process", {
1429
+ pid,
1430
+ signal,
1431
+ cause: error
1432
+ });
1380
1433
  }
1381
- if (await this.#waitForProcessWithin(process, 3e3)) return;
1382
- try {
1383
- process.kill("SIGKILL");
1384
- } catch {
1385
- return;
1434
+ }
1435
+ async #waitForProcessGroupWithin(process, timeout) {
1436
+ const deadline = performance.now() + timeout;
1437
+ let confirmed = false;
1438
+ while (true) {
1439
+ const alive = this.#inspectProcessGroup(process);
1440
+ if (alive === false) return true;
1441
+ if (alive === true) confirmed = true;
1442
+ const remaining = deadline - performance.now();
1443
+ if (remaining <= 0) return confirmed ? false : void 0;
1444
+ await new Promise((resolve) => setTimeout(resolve, Math.min(BROWSER_WAIT_POLL_INTERVAL_MS, remaining)));
1386
1445
  }
1387
- await this.#waitForProcess(process);
1446
+ }
1447
+ async #waitForTerminationWithin(process, timeout, final = false) {
1448
+ const exited = this.#waitForProcessWithin(process, timeout);
1449
+ const drained = this.#waitForProcessGroupWithin(process, timeout);
1450
+ const results = await Promise.all([exited, drained]);
1451
+ return results[0] && (results[1] === true || final && results[1] === void 0);
1452
+ }
1453
+ async #terminate(process) {
1454
+ if (process === void 0 || (process.exitCode !== null || process.signalCode !== null) && this.#inspectProcessGroup(process) === false) return;
1455
+ if (this.#signalProcess(process, "SIGTERM") === false) return;
1456
+ if (await this.#waitForTerminationWithin(process, 3e3)) return;
1457
+ if (this.#signalProcess(process, "SIGKILL") === false) return;
1458
+ if (await this.#waitForTerminationWithin(process, 3e3, true)) return;
1459
+ throw new BrowserConnectionError("Browser process did not exit after SIGKILL", { pid: process.pid });
1388
1460
  }
1389
1461
  #finish() {
1390
1462
  this.#unbindTransport();
@@ -1395,6 +1467,7 @@ var Browser = class {
1395
1467
  this.#endpoint = void 0;
1396
1468
  this.#client = void 0;
1397
1469
  this.#process = void 0;
1470
+ this.#exitCleanup = void 0;
1398
1471
  this.#contexts = [];
1399
1472
  this.#emitter.emit("destroy");
1400
1473
  this.#emitter.destroy();