@monoes/monobrowse 1.0.16 → 1.0.19

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.
Files changed (39) hide show
  1. package/dist/src/__tests__/browser-launch.test.js +132 -1
  2. package/dist/src/__tests__/browser-launch.test.js.map +1 -1
  3. package/dist/src/__tests__/report-exit-code.test.d.ts +46 -0
  4. package/dist/src/__tests__/report-exit-code.test.d.ts.map +1 -0
  5. package/dist/src/__tests__/report-exit-code.test.js +188 -0
  6. package/dist/src/__tests__/report-exit-code.test.js.map +1 -0
  7. package/dist/src/browser/actions.d.ts.map +1 -1
  8. package/dist/src/browser/actions.js +19 -9
  9. package/dist/src/browser/actions.js.map +1 -1
  10. package/dist/src/browser/bridge.d.ts.map +1 -1
  11. package/dist/src/browser/bridge.js +4 -1
  12. package/dist/src/browser/bridge.js.map +1 -1
  13. package/dist/src/browser/browser.d.ts.map +1 -1
  14. package/dist/src/browser/browser.js +148 -37
  15. package/dist/src/browser/browser.js.map +1 -1
  16. package/dist/src/browser/cdp.d.ts.map +1 -1
  17. package/dist/src/browser/cdp.js +8 -1
  18. package/dist/src/browser/cdp.js.map +1 -1
  19. package/dist/src/browser/types.d.ts +2 -0
  20. package/dist/src/browser/types.d.ts.map +1 -1
  21. package/dist/src/browser/types.js.map +1 -1
  22. package/dist/src/cli/commands-report.d.ts.map +1 -1
  23. package/dist/src/cli/commands-report.js +46 -20
  24. package/dist/src/cli/commands-report.js.map +1 -1
  25. package/dist/src/report/util.d.ts +4 -0
  26. package/dist/src/report/util.d.ts.map +1 -1
  27. package/dist/src/report/util.js +9 -3
  28. package/dist/src/report/util.js.map +1 -1
  29. package/dist/tsconfig.tsbuildinfo +1 -1
  30. package/package.json +1 -1
  31. package/src/__tests__/browser-launch.test.ts +142 -1
  32. package/src/__tests__/report-exit-code.test.ts +207 -0
  33. package/src/browser/actions.ts +21 -12
  34. package/src/browser/bridge.ts +4 -1
  35. package/src/browser/browser.ts +145 -34
  36. package/src/browser/cdp.ts +8 -1
  37. package/src/browser/types.ts +2 -0
  38. package/src/cli/commands-report.ts +46 -19
  39. package/src/report/util.ts +9 -3
@@ -1,5 +1,5 @@
1
1
  import { execSync, spawn } from 'node:child_process';
2
- import { existsSync } from 'node:fs';
2
+ import { existsSync, readFileSync, rmSync } from 'node:fs';
3
3
  import { connect } from 'node:net';
4
4
  import { tmpdir } from 'node:os';
5
5
  import { join } from 'node:path';
@@ -128,9 +128,17 @@ const LAUNCH_PORT_SCAN_TRIES = 10;
128
128
 
129
129
  export async function launchBrowser(config: BrowserConfig = {}): Promise<number> {
130
130
  const rawPort = config.port ?? DEFAULT_PORT;
131
- // Validate port is in a safe range for localhost CDP debugging
132
- if (!Number.isInteger(rawPort) || rawPort < 1024 || rawPort > 65535) {
133
- throw new Error(`Invalid port: ${rawPort}. Must be an integer between 1024 and 65535.`);
131
+ // Port 0 means "let Chrome bind a free port and tell us which" — see
132
+ // launchOnFreePort. Otherwise validate port is in a safe range for
133
+ // localhost CDP debugging.
134
+ if (rawPort === 0) {
135
+ if (!config.userDataDir) {
136
+ throw new Error(
137
+ 'port 0 requires a dedicated userDataDir (Chrome reports the port it bound inside it).',
138
+ );
139
+ }
140
+ } else if (!Number.isInteger(rawPort) || rawPort < 1024 || rawPort > 65535) {
141
+ throw new Error(`Invalid port: ${rawPort}. Must be 0 or an integer between 1024 and 65535.`);
134
142
  }
135
143
 
136
144
  // Every launch/attach is this tool's only chance to notice that a previous
@@ -139,6 +147,8 @@ export async function launchBrowser(config: BrowserConfig = {}): Promise<number>
139
147
  // instance on the port we are about to use is freed before we probe it.
140
148
  await reapIdleLaunchedBrowser();
141
149
 
150
+ if (rawPort === 0) return launchOnFreePort(config, 0);
151
+
142
152
  // strictPort: fail fast on the exact requested port, matching the old
143
153
  // behavior (Vite has the same escape hatch for the same reason) — for
144
154
  // callers that treat the error as a signal ("this port is taken by
@@ -158,19 +168,7 @@ export async function launchBrowser(config: BrowserConfig = {}): Promise<number>
158
168
  for (let i = 0; i < LAUNCH_PORT_SCAN_TRIES && rawPort + i <= 65535; i++)
159
169
  candidates.push(rawPort + i);
160
170
 
161
- // Attach-if-already-Chrome only applies to the EXACT requested port — the
162
- // original, deliberate, single-port risk ("don't silently take over an
163
- // unrelated real browser that happens to be on this port"). Scanning past
164
- // an occupied default must not let that same shortcut attach to a
165
- // DIFFERENT Chrome instance the caller never named; forward candidates are
166
- // launch-only (skip if anything is there, Chrome or not).
167
- if (await isTcpPortOpen(rawPort)) {
168
- if (await isChromeIdentity(rawPort)) return rawPort;
169
- } else {
170
- return launchOnFreePort(config, rawPort);
171
- }
172
-
173
- for (const candidate of candidates.slice(1)) {
171
+ for (const candidate of candidates) {
174
172
  // TCP-level check for "is anything at all listening" — isPortOpen()
175
173
  // does a full CDP /json fetch, which returns false BOTH for a genuinely
176
174
  // free port and for one occupied by a non-CDP process (that ambiguity is
@@ -179,10 +177,39 @@ export async function launchBrowser(config: BrowserConfig = {}): Promise<number>
179
177
  // socket first tells free and occupied apart up front, so the scan can
180
178
  // skip an occupied candidate instead of trying to spawn Chrome on top of
181
179
  // it and only discovering the conflict after a timeout.
182
- if (!(await isTcpPortOpen(candidate))) return launchOnFreePort(config, candidate);
183
- // Occupied (by anything not just non-Chrome, per the note above)
184
- // try the next candidate instead of failing outright, same as a normal
185
- // EADDRINUSE retry would.
180
+ if (await isTcpPortOpen(candidate)) {
181
+ // Attach-if-already-Chrome only applies to the EXACT requested port
182
+ // the original, deliberate, single-port risk ("don't silently take
183
+ // over an unrelated real browser that happens to be on this port").
184
+ // Scanning past an occupied default must not let that same shortcut
185
+ // attach to a DIFFERENT Chrome instance the caller never named;
186
+ // forward candidates are launch-only (skip if anything is there,
187
+ // Chrome or not).
188
+ if (candidate === rawPort && (await isChromeIdentity(candidate))) return candidate;
189
+ // Occupied (by anything — not just non-Chrome, per the note above) —
190
+ // try the next candidate instead of failing outright, same as a
191
+ // normal EADDRINUSE retry would.
192
+ continue;
193
+ }
194
+ try {
195
+ return await launchOnFreePort(config, candidate);
196
+ } catch (err) {
197
+ const isLastCandidate = candidate === candidates[candidates.length - 1];
198
+ // isTcpPortOpen() above is a connect() probe, not an atomic claim: two
199
+ // concurrent launchBrowser() calls with no explicit --port can both
200
+ // observe the same candidate as free and both spawn Chrome on it
201
+ // before either binds. One wins; the other's Chrome exits before its
202
+ // CDP endpoint opens ("Chrome exited before the CDP endpoint opened
203
+ // ... code=21"). Something is listening on the candidate NOW that
204
+ // was not a moment ago — a losing race, not a broken Chrome install —
205
+ // so try the next candidate exactly like an already-occupied one,
206
+ // instead of failing the whole launch outright. A candidate that
207
+ // fails with nothing now listening (a real launch failure — bad
208
+ // executable, sandbox refusal, etc.) is not a race: retrying the
209
+ // next candidate would only fail the same way, so surface it as-is.
210
+ if (!isLastCandidate && (await isTcpPortOpen(candidate))) continue;
211
+ throw err;
212
+ }
186
213
  }
187
214
  throw new Error(
188
215
  `Ports ${candidates[0]}-${candidates[candidates.length - 1]} are all occupied and port ${candidates[0]} ` +
@@ -190,9 +217,50 @@ export async function launchBrowser(config: BrowserConfig = {}): Promise<number>
190
217
  );
191
218
  }
192
219
 
220
+ /** The port Chrome wrote to `<userDataDir>/DevToolsActivePort` once its
221
+ * DevTools server was listening, or null if it has not (yet). */
222
+ function readDevToolsActivePort(file: string): number | null {
223
+ try {
224
+ const port = Number.parseInt(readFileSync(file, 'utf8').split('\n')[0], 10);
225
+ return Number.isInteger(port) && port > 0 && port <= 65535 ? port : null;
226
+ } catch {
227
+ return null;
228
+ }
229
+ }
230
+
231
+ /**
232
+ * Spawn Chrome and wait for its CDP endpoint.
233
+ *
234
+ * With a fixed `port`, "our Chrome is up" is inferred from *some* Chrome
235
+ * answering on 127.0.0.1:<port> — which is not necessarily ours. When the port
236
+ * is already bound on 127.0.0.1 (a concurrent launch picked the same "free"
237
+ * port between its probe and Chrome's bind), Chrome does not fail: it logs
238
+ * `bind() failed: Address already in use` and listens on [::1]:<port>
239
+ * instead. The poll then accepts the OTHER launcher's browser, both callers
240
+ * drive one Chrome, and whichever closes first kills the other's connections
241
+ * ("CDP connection closed") while this launch's own Chrome is orphaned.
242
+ *
243
+ * `port: 0` removes the race instead of narrowing it: Chrome asks the kernel
244
+ * for a free port at bind time — atomic, nothing to collide on — and writes
245
+ * the port it got to DevToolsActivePort in its own (caller-dedicated) profile
246
+ * directory, which is proof the endpoint is the process we spawned.
247
+ */
193
248
  async function launchOnFreePort(config: BrowserConfig, port: number): Promise<number> {
194
249
  const chromePath = findChrome(config.executablePath);
195
- const userDataDir = config.userDataDir ?? join(tmpdir(), `monomind-browser-${port}`);
250
+ // Suffixed with our own pid: two concurrent launches that both probe the
251
+ // same candidate port as free (the race this function exists to survive —
252
+ // see the retry-on-collision caller above) briefly run Chrome with
253
+ // *identical* --user-data-dir values if it were derived from the port
254
+ // alone, sharing a profile directory (lock files, preferences, the
255
+ // DevToolsActivePort file itself) between two unrelated Chrome processes
256
+ // for as long as the loser stays alive. Different processes always have
257
+ // different pids, so this can never collide even when the port does.
258
+ const userDataDir =
259
+ config.userDataDir ?? join(tmpdir(), `monomind-browser-${port}-${process.pid}`);
260
+ const activePortFile = join(userDataDir, 'DevToolsActivePort');
261
+ // A reused profile dir may hold a previous run's file naming a port some
262
+ // other process now owns.
263
+ if (port === 0) rmSync(activePortFile, { force: true });
196
264
 
197
265
  const defaultArgs = [
198
266
  `--remote-debugging-port=${port}`,
@@ -264,10 +332,21 @@ async function launchOnFreePort(config: BrowserConfig, port: number): Promise<nu
264
332
  });
265
333
 
266
334
  child.unref();
267
- if (child.pid) {
268
- launchedPids.set(port, child.pid);
269
- launchedUserDataDirs.set(port, userDataDir);
270
- }
335
+ const track = (boundPort: number) => {
336
+ if (!child.pid) return;
337
+ launchedPids.set(boundPort, child.pid);
338
+ launchedUserDataDirs.set(boundPort, userDataDir);
339
+ };
340
+ // Tracked only once Chrome is CONFIRMED up on `boundPort`, not right after
341
+ // spawn(). For port !== 0 this used to track(port) unconditionally the
342
+ // moment the child was spawned, before knowing whether it would actually
343
+ // win the port — when a racing launchBrowser() call retries a candidate
344
+ // that a concurrent process also spawned Chrome on (see the retry loop in
345
+ // launchBrowser), BOTH child processes called track() for the SAME
346
+ // candidate, and whichever call's spawn happened to run last clobbered the
347
+ // map entry with its own pid — including the LOSING, about-to-exit
348
+ // process's pid overwriting the real winner's, corrupting the very map
349
+ // closeBrowser()'s kill fallback depends on.
271
350
 
272
351
  const launchTimeout = config.launchTimeoutMs ?? LAUNCH_TIMEOUT;
273
352
  const deadline = Date.now() + launchTimeout;
@@ -275,17 +354,34 @@ async function launchOnFreePort(config: BrowserConfig, port: number): Promise<nu
275
354
  if (earlyFailure) throw earlyFailure;
276
355
  await sleep(POLL_INTERVAL);
277
356
  if (earlyFailure) throw earlyFailure;
278
- if (await isPortOpen(port)) {
279
- if (await isChromeIdentity(port)) return port;
357
+ const boundPort = port === 0 ? readDevToolsActivePort(activePortFile) : port;
358
+ if (boundPort !== null && (await isPortOpen(boundPort))) {
359
+ if (await isChromeIdentity(boundPort)) {
360
+ track(boundPort);
361
+ return boundPort;
362
+ }
280
363
  throw new Error(
281
- `Port ${port} is occupied by a CDP-speaking process that does not identify as Chrome/Chromium. ` +
282
- `Refusing to attach — pass a different port or free port ${port}.`,
364
+ `Port ${boundPort} is occupied by a CDP-speaking process that does not identify as Chrome/Chromium. ` +
365
+ `Refusing to attach — pass a different port or free port ${boundPort}.`,
283
366
  );
284
367
  }
285
368
  }
286
369
 
287
370
  if (earlyFailure) throw earlyFailure;
288
371
 
372
+ if (port === 0) {
373
+ // Not tracked under any port yet, so no closeBrowser() could ever reach
374
+ // it — stop it here rather than leave it running.
375
+ try {
376
+ if (child.pid) process.kill(child.pid, 'SIGKILL');
377
+ } catch {
378
+ /* already gone */
379
+ }
380
+ throw new Error(
381
+ `Chrome did not report a CDP port in ${activePortFile} within ${launchTimeout}ms`,
382
+ );
383
+ }
384
+
289
385
  // Timed out waiting for our Chrome to come up on the port. Distinguish
290
386
  // "nothing is listening" (real launch failure) from "something non-CDP is
291
387
  // squatting the port" (confusing generic timeout otherwise) via a raw TCP probe.
@@ -363,20 +459,30 @@ export async function connectToTarget(
363
459
  */
364
460
  export async function closeBrowser(client: CdpClient, port: number): Promise<void> {
365
461
  let gracefullyClosed = false;
462
+ let closeTimer: ReturnType<typeof setTimeout> | undefined;
366
463
  try {
367
464
  await Promise.race([
368
465
  client.send('Browser.close', {}),
466
+ // Deliberately NOT unref'd, for the same reason waitForProcessExit()'s
467
+ // poll below is not. This timer is the only thing that settles the race
468
+ // when Chrome's socket dies without ever acknowledging the close, and
469
+ // closeBrowser() is actively awaited by callers. An unref'd timer does
470
+ // not hold the event loop open, so Node would consider the loop drained
471
+ // and exit — status 0, before the caller's verdict was ever printed.
472
+ // Cleared in the finally so a close that IS acknowledged does not pin
473
+ // the loop for the rest of BROWSER_CLOSE_TIMEOUT_MS.
369
474
  new Promise<never>((_, reject) => {
370
- const t = setTimeout(
475
+ closeTimer = setTimeout(
371
476
  () => reject(new Error('Browser.close timed out')),
372
477
  BROWSER_CLOSE_TIMEOUT_MS,
373
478
  );
374
- t.unref?.();
375
479
  }),
376
480
  ]);
377
481
  gracefullyClosed = true;
378
482
  } catch {
379
483
  // fall through to PID-kill fallback below
484
+ } finally {
485
+ clearTimeout(closeTimer);
380
486
  }
381
487
 
382
488
  let pid = launchedPids.get(port);
@@ -501,22 +607,27 @@ export async function reapIdleLaunchedBrowser(): Promise<number | null> {
501
607
  if ((await fetchTargets(session.port)).length > 0) return null;
502
608
 
503
609
  const client = new CdpClient();
610
+ let reapTimer: ReturnType<typeof setTimeout> | undefined;
504
611
  try {
505
612
  // CdpClient.connect() has no timeout of its own — a socket that never
506
613
  // opens (and never errors) would hang the launch this reap is running
507
614
  // inside of, which is strictly worse than leaving the port squatted.
615
+ // Not unref'd (see closeBrowser): a socket that never opens and never
616
+ // errors leaves this timer as the only thing that can settle the race,
617
+ // and the reap is awaited inside launch/attach. Cleared in the finally
618
+ // below so a connect that succeeds does not pin the event loop.
508
619
  await Promise.race([
509
620
  client.connect(wsUrl),
510
621
  new Promise<never>((_, reject) => {
511
- const t = setTimeout(
622
+ reapTimer = setTimeout(
512
623
  () => reject(new Error('Reap: CDP connect timed out')),
513
624
  REAP_CONNECT_TIMEOUT_MS,
514
625
  );
515
- t.unref?.();
516
626
  }),
517
627
  ]);
518
628
  await closeBrowser(client, session.port);
519
629
  } finally {
630
+ clearTimeout(reapTimer);
520
631
  try {
521
632
  client.close();
522
633
  } catch {
@@ -125,13 +125,20 @@ export class CdpClient {
125
125
  const cmd: CdpCommand = { id, method, params };
126
126
  if (sessionId) cmd.sessionId = sessionId;
127
127
 
128
+ // This timer is deliberately NOT unref'd. It is the backstop for the
129
+ // exact case described at the top of this file — a socket that dies
130
+ // without ever firing 'close'/'error', so flushPending() never runs.
131
+ // send()'s promise is awaited by essentially every caller; an unref'd
132
+ // timer cannot hold the event loop open, so Node would drain and exit
133
+ // (status 0, no output) instead of rejecting with a timeout the caller
134
+ // can report. Every settle path below clears it, so a command that gets
135
+ // its response never keeps the loop alive.
128
136
  let timer: ReturnType<typeof setTimeout> | undefined;
129
137
  if (timeoutMs > 0) {
130
138
  timer = setTimeout(() => {
131
139
  this.pendingCommands.delete(id);
132
140
  reject(new Error(`CDP command "${method}" timed out after ${timeoutMs}ms`));
133
141
  }, timeoutMs);
134
- timer.unref?.();
135
142
  }
136
143
 
137
144
  this.pendingCommands.set(id, {
@@ -1,4 +1,6 @@
1
1
  export interface BrowserConfig {
2
+ /** CDP port. 0 lets Chrome bind a free one (requires `userDataDir`); the
3
+ * port it bound is launchBrowser's return value. */
2
4
  port?: number;
3
5
  /** Fail fast if `port` is occupied by anything other than an attachable
4
6
  * Chrome, instead of scanning forward to the next free port. Default false
@@ -186,6 +186,34 @@ export const reportCommand: Command = {
186
186
  trendWindow: ctx.flags['trend-window'] as number | undefined,
187
187
  };
188
188
 
189
+ // Closing the browser is cleanup, not part of the verdict. It used to run
190
+ // in a `finally` sitting between "we have the result" and "we print it",
191
+ // which meant anything that wedged the teardown — a Chrome that never
192
+ // acknowledged `Browser.close`, a CDP socket that died silently — took the
193
+ // verdict down with it: no output at all, and exit 0 on a page that FAILED
194
+ // its budgets. That is exactly the signal CI, agents and `&&` chains gate
195
+ // on, so a hung teardown reported success on a broken page. The verdict is
196
+ // now printed and the exit code fixed BEFORE any of this runs.
197
+ const teardown = async (): Promise<void> => {
198
+ // A report is a one-shot command — CI should not be left with an
199
+ // orphan Chrome. Only close a browser THIS process launched: an
200
+ // attached one belongs to the user's own `open`/`connect` session.
201
+ if (ctx.flags['keep-open'] || browser.getLaunchedPid(session.port) === undefined) return;
202
+ browser.stopRequestCapture(sessionId);
203
+ browser.teardownConsoleCapture(sessionId);
204
+ try {
205
+ await browser.closeBrowser(client, session.port);
206
+ } catch {
207
+ /* best-effort */
208
+ }
209
+ session.client = null;
210
+ session.sessionId = '';
211
+ session.targetId = '';
212
+ session.refs = new Map();
213
+ await browser.clearActivePort();
214
+ await browser.clearRefCache();
215
+ };
216
+
189
217
  let result:
190
218
  | Awaited<ReturnType<typeof runReport>>
191
219
  | Awaited<ReturnType<typeof runReportRepeated>>;
@@ -193,25 +221,11 @@ export const reportCommand: Command = {
193
221
  result = repeat
194
222
  ? await runReportRepeated(client, sessionId, { ...runOptions, repeat })
195
223
  : await runReport(client, sessionId, runOptions);
196
- } finally {
197
- // A report is a one-shot command CI should not be left with an
198
- // orphan Chrome. Only close a browser THIS process launched: an
199
- // attached one belongs to the user's own `open`/`connect` session.
200
- if (!ctx.flags['keep-open'] && browser.getLaunchedPid(session.port) !== undefined) {
201
- browser.stopRequestCapture(sessionId);
202
- browser.teardownConsoleCapture(sessionId);
203
- try {
204
- await browser.closeBrowser(client, session.port);
205
- } catch {
206
- /* best-effort */
207
- }
208
- session.client = null;
209
- session.sessionId = '';
210
- session.targetId = '';
211
- session.refs = new Map();
212
- await browser.clearActivePort();
213
- await browser.clearRefCache();
214
- }
224
+ } catch (err) {
225
+ // No verdict to print on this path, so the old ordering still applies:
226
+ // clean up, then let the error propagate to the CLI's error handler.
227
+ await teardown();
228
+ throw err;
215
229
  }
216
230
 
217
231
  const { report, htmlPath, jsonPath, summary, historyDir } = result;
@@ -220,6 +234,15 @@ export const reportCommand: Command = {
220
234
  // runs must not exit 0 just because the last run happened to be green.
221
235
  const passed = flake ? flake.verdict === 'pass' : report.verdict === 'pass';
222
236
 
237
+ // Set the process-wide failure code up front, before printing and before
238
+ // teardown. Returning `exitCode` below is the normal channel, but it only
239
+ // reaches the CLI if this function returns; if teardown never settles,
240
+ // Node drains the event loop and exits on its own — and a natural exit
241
+ // uses process.exitCode. Setting it here means a failing page cannot exit
242
+ // 0 no matter what happens after this line. Only ever set on failure, so
243
+ // this never clears a non-zero code set elsewhere.
244
+ if (!passed) process.exitCode = 1;
245
+
223
246
  if (ctx.flags.json) {
224
247
  const { toJsonReport } = await import('../report/index.js');
225
248
  print(
@@ -265,6 +288,10 @@ export const reportCommand: Command = {
265
288
  if (historyDir) print(`History: ${historyDir}`);
266
289
  }
267
290
 
291
+ // Verdict is printed and the exit code is fixed — only now is it safe to
292
+ // close the browser.
293
+ await teardown();
294
+
268
295
  // Non-zero exit is the point of RIG-06 — CI and agents gate on it.
269
296
  return {
270
297
  success: passed,
@@ -3,10 +3,13 @@
3
3
  /** Budget for one non-navigation collector (AX tree, screenshot, eval). */
4
4
  export const STEP_TIMEOUT_MS = 15_000;
5
5
 
6
+ /** Not unref'd: callers `await sleep(...)` (e.g. the web-vitals settle
7
+ * window), so this timer is on an actively-awaited path. An unref'd timer
8
+ * does not keep the event loop alive, so once the CDP socket closes Node
9
+ * would drain and exit mid-report instead of resuming after the sleep. */
6
10
  export function sleep(ms: number): Promise<void> {
7
11
  return new Promise((r) => {
8
- const t = setTimeout(r, ms);
9
- t.unref?.();
12
+ setTimeout(r, ms);
10
13
  });
11
14
  }
12
15
 
@@ -23,9 +26,12 @@ export async function withTimeout<T>(promise: Promise<T>, ms: number, label: str
23
26
  try {
24
27
  return await Promise.race([
25
28
  promise,
29
+ // Not unref'd: this timer is the only thing that settles the race when
30
+ // `promise` is waiting on a socket that quietly went away, and every
31
+ // caller awaits the result. The finally below clears it, so work that
32
+ // finishes in time never holds the event loop open.
26
33
  new Promise<never>((_, reject) => {
27
34
  timer = setTimeout(() => reject(new Error(`${label} timed out after ${ms}ms`)), ms);
28
- timer.unref?.();
29
35
  }),
30
36
  ]);
31
37
  } finally {