summaryrefslogtreecommitdiff
path: root/test/concurrency.test.ts
blob: 500fee0473b2fd21a2fc6d8ff96132864253b426 (plain)
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
/**
 * The semaphore, exercised through the only door it has: search().
 *
 * These run against a deliberately unreachable CDP endpoint. Every search fails
 * fast at the connect step, which is exactly what is wanted — the questions here
 * are about slot bookkeeping, not about Google. Hitting the real browser to test
 * a counter would be slow, flaky, and rude to whoever is using it.
 */

import assert from "node:assert/strict";
import { test } from "node:test";

import { MAX_CONCURRENT_SEARCHES, disconnect, search } from "../src/search.ts";

const UNREACHABLE = "127.0.0.1:1"; // Nothing listens on port 1.
const quiet = () => {};

const withUnreachableBrowser = async (body: () => Promise<void>): Promise<void> => {
  const saved = process.env["BU_CDP_HTTP"];
  process.env["BU_CDP_HTTP"] = UNREACHABLE;
  disconnect();
  try {
    await body();
  } finally {
    if (saved === undefined) delete process.env["BU_CDP_HTTP"];
    else process.env["BU_CDP_HTTP"] = saved;
    disconnect();
  }
};

const attempt = (query: string, signal: AbortSignal) =>
  search({ query, numResults: 1 }, signal, quiet).then(
    () => "fulfilled" as const,
    (e: Error) => e.name,
  );

test("a failed search returns its slot, so the next one still runs", async () => {
  await withUnreachableBrowser(async () => {
    // Three times the cap, run sequentially. If a slot leaked on the failure
    // path, the run past the cap would hang until the test timed out.
    for (let i = 0; i < MAX_CONCURRENT_SEARCHES * 3; i++) {
      const name = await attempt(`q${i}`, AbortSignal.timeout(10_000));
      assert.equal(name, "BrowserUnavailableError", `attempt ${i}`);
    }
  });
});

test("aborting while queued does not wedge the semaphore", async () => {
  await withUnreachableBrowser(async () => {
    // Saturate the cap and queue two more, then cancel the queued ones. The
    // bug this guards against: a cancelled waiter that is later handed a slot
    // resolves into nothing, and the slot is never counted back.
    const cancel = new AbortController();
    const running = Array.from({ length: MAX_CONCURRENT_SEARCHES }, (_, i) =>
      attempt(`running${i}`, AbortSignal.timeout(10_000)),
    );
    const queued = Array.from({ length: 2 }, (_, i) => attempt(`queued${i}`, cancel.signal));
    cancel.abort();

    await Promise.all(running);
    for (const outcome of await Promise.all(queued)) {
      assert.ok(
        outcome === "SearchAbortedError" || outcome === "BrowserUnavailableError",
        `unexpected outcome ${outcome}`,
      );
    }

    // The real assertion: the pool still works afterwards. A wedged semaphore
    // would leave this hanging rather than failing.
    for (let i = 0; i < MAX_CONCURRENT_SEARCHES + 1; i++) {
      assert.equal(await attempt(`after${i}`, AbortSignal.timeout(10_000)), "BrowserUnavailableError");
    }
  });
});

test("an unreachable endpoint is reported, never silently swapped for a local browser", async () => {
  await withUnreachableBrowser(async () => {
    const error = await search({ query: "q", numResults: 1 }, AbortSignal.timeout(10_000), quiet).then(
      () => null,
      (e: Error) => e,
    );
    assert.ok(error, "expected a throw");
    assert.equal(error.name, "BrowserUnavailableError");
    assert.match(error.message, new RegExp(UNREACHABLE.replace(".", "\\.")));
  });
});