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(".", "\\.")));
});
});
|