Skip to content

Commit dd2b4b8

Browse files
committed
Add tests for tab timeout, abort signal, and disconnect detection
1 parent b786250 commit dd2b4b8

1 file changed

Lines changed: 188 additions & 0 deletions

File tree

tests/unit/tab-timeout.test.ts

Lines changed: 188 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,188 @@
1+
import { describe, it, expect, vi } from "vitest";
2+
3+
/**
4+
* Tests for Chrome tab slot leak prevention.
5+
*
6+
* These test the timeout and abort mechanisms that prevent
7+
* pLimit slots from being held forever when scrapes hang.
8+
*/
9+
10+
describe("withPage hard timeout", () => {
11+
it("rejects after timeoutMs if fn never resolves", async () => {
12+
// Simulate the Promise.race pattern used in withPage
13+
const neverResolves = new Promise<string>(() => {});
14+
const timeout = new Promise<never>((_, reject) => {
15+
setTimeout(() => reject(new Error("withPage hard timeout after 100ms")), 100);
16+
});
17+
18+
await expect(Promise.race([neverResolves, timeout])).rejects.toThrow(
19+
"withPage hard timeout after 100ms",
20+
);
21+
});
22+
23+
it("returns result if fn resolves before timeout", async () => {
24+
const fast = new Promise<string>((resolve) => {
25+
setTimeout(() => resolve("done"), 10);
26+
});
27+
const timeout = new Promise<never>((_, reject) => {
28+
setTimeout(() => reject(new Error("timeout")), 200);
29+
});
30+
31+
const result = await Promise.race([fast, timeout]);
32+
expect(result).toBe("done");
33+
});
34+
});
35+
36+
describe("page.close timeout", () => {
37+
it("does not block if page.close hangs", async () => {
38+
// Simulate a hung page.close
39+
const hungClose = new Promise<void>(() => {});
40+
const closeTimeout = new Promise<void>((resolve) => setTimeout(resolve, 100));
41+
42+
const start = Date.now();
43+
await Promise.race([hungClose, closeTimeout]);
44+
const elapsed = Date.now() - start;
45+
46+
// Should resolve in ~100ms, not hang
47+
expect(elapsed).toBeLessThan(500);
48+
});
49+
});
50+
51+
describe("drainLimit timeout", () => {
52+
it("breaks out of drain loop after deadline", async () => {
53+
// Simulate drainLimit with a timeout
54+
const drainWithTimeout = async (timeoutMs: number) => {
55+
const deadline = Date.now() + timeoutMs;
56+
let activeCount = 1; // simulate stuck slot
57+
let iterations = 0;
58+
59+
while (activeCount > 0) {
60+
if (Date.now() > deadline) {
61+
break;
62+
}
63+
iterations++;
64+
await new Promise((r) => setTimeout(r, 10));
65+
}
66+
return iterations;
67+
};
68+
69+
const start = Date.now();
70+
const iterations = await drainWithTimeout(100);
71+
const elapsed = Date.now() - start;
72+
73+
expect(elapsed).toBeLessThan(500);
74+
expect(iterations).toBeGreaterThan(0);
75+
});
76+
});
77+
78+
describe("abort signal propagation", () => {
79+
it("AbortController signal starts as non-aborted", () => {
80+
const controller = new AbortController();
81+
expect(controller.signal.aborted).toBe(false);
82+
});
83+
84+
it("abort() sets signal.aborted to true", () => {
85+
const controller = new AbortController();
86+
controller.abort();
87+
expect(controller.signal.aborted).toBe(true);
88+
});
89+
90+
it("abort event listener fires on abort", async () => {
91+
const controller = new AbortController();
92+
const listener = vi.fn();
93+
controller.signal.addEventListener("abort", listener, { once: true });
94+
95+
controller.abort();
96+
expect(listener).toHaveBeenCalledTimes(1);
97+
});
98+
99+
it("Promise.race with abort signal rejects on abort", async () => {
100+
const controller = new AbortController();
101+
102+
const slowWork = new Promise<string>((resolve) => {
103+
setTimeout(() => resolve("done"), 5000);
104+
});
105+
106+
const abortPromise = new Promise<never>((_, reject) => {
107+
controller.signal.addEventListener(
108+
"abort",
109+
() => reject(new Error("aborted")),
110+
{ once: true },
111+
);
112+
});
113+
114+
// Abort after 50ms
115+
setTimeout(() => controller.abort(), 50);
116+
117+
await expect(Promise.race([slowWork, abortPromise])).rejects.toThrow("aborted");
118+
});
119+
120+
it("already-aborted signal rejects immediately", async () => {
121+
const controller = new AbortController();
122+
controller.abort();
123+
124+
const abortPromise = new Promise<never>((_, reject) => {
125+
if (controller.signal.aborted) {
126+
reject(new Error("already aborted"));
127+
}
128+
});
129+
130+
await expect(abortPromise).rejects.toThrow("already aborted");
131+
});
132+
});
133+
134+
describe("client disconnect detection", () => {
135+
it("setInterval checks socket.destroyed periodically", async () => {
136+
const socket = { destroyed: false };
137+
const abortController = new AbortController();
138+
let checkCount = 0;
139+
140+
const interval = setInterval(() => {
141+
checkCount++;
142+
if (socket.destroyed) {
143+
abortController.abort();
144+
clearInterval(interval);
145+
}
146+
}, 50);
147+
148+
// Simulate client disconnect after 120ms
149+
setTimeout(() => {
150+
socket.destroyed = true;
151+
}, 120);
152+
153+
await new Promise((r) => setTimeout(r, 250));
154+
clearInterval(interval);
155+
156+
expect(abortController.signal.aborted).toBe(true);
157+
expect(checkCount).toBeGreaterThanOrEqual(2);
158+
});
159+
160+
it("does not abort if response finishes before disconnect", async () => {
161+
const socket = { destroyed: false };
162+
const abortController = new AbortController();
163+
let writableEnded = false;
164+
165+
const interval = setInterval(() => {
166+
if (socket.destroyed && !writableEnded) {
167+
abortController.abort();
168+
clearInterval(interval);
169+
}
170+
}, 50);
171+
172+
// Response finishes first
173+
setTimeout(() => {
174+
writableEnded = true;
175+
}, 50);
176+
177+
// Client disconnects later
178+
setTimeout(() => {
179+
socket.destroyed = true;
180+
}, 150);
181+
182+
await new Promise((r) => setTimeout(r, 250));
183+
clearInterval(interval);
184+
185+
// Should NOT have aborted because response was already sent
186+
expect(abortController.signal.aborted).toBe(false);
187+
});
188+
});

0 commit comments

Comments
 (0)