Skip to content
File

Blob: src/workerd/api/tests/abortsignal-test.js

javascript598 lines
1// Copyright (c) 2023 Cloudflare, Inc.
2// Licensed under the Apache 2.0 license found in the LICENSE file or at:
3// https://opensource.org/licenses/Apache-2.0
4import { strictEqual, ok, throws, rejects } from 'node:assert';
5import { WorkerEntrypoint, RpcTarget } from 'cloudflare:workers';
6 
7// Test for the AbortSignal and AbortController standard Web API implementations.
8// The implementation for these are in api/basics.{h|c++}
9 
10class WrappedAbortSignal extends RpcTarget {
11 constructor() {
12 super();
13 this.ac = new AbortController();
14 }
15 
16 forget() {
17 this.ac.signal.skipReleaseForTest();
18 }
19 
20 getSignal() {
21 return this.ac.signal;
22 }
23}
24 
25let globalAbortController;
26export class RpcRemoteEnd extends WorkerEntrypoint {
27 async echo(signal) {
28 return signal;
29 }
30 
31 async countToInfinity(signal) {
32 let onAbortWasFired = false;
33 
34 signal.onabort = () => {
35 onAbortWasFired = true;
36 };
37 
38 for (let i = 0; ; i++) {
39 await scheduler.wait(50);
40 if (signal.aborted) {
41 return { counter: i, reason: signal.reason, onAbortWasFired };
42 }
43 }
44 }
45 
46 async countToInfinityWithRequest(req) {
47 return this.countToInfinity(req.signal);
48 }
49 
50 async countToInfinityWithTimeout(remoteSignal) {
51 let timeout = AbortSignal.timeout(1000);
52 let signal = AbortSignal.any([timeout, remoteSignal]);
53 return this.countToInfinity(signal);
54 }
55 
56 async ignoreSignal(signal) {
57 let i = 0;
58 
59 for (i = 0; i < 10; i++) {
60 await scheduler.wait(50);
61 }
62 
63 return { counter: i, reason: signal.reason };
64 }
65 
66 async chainReaction(signal) {
67 let onAbortWasFired = false;
68 
69 signal.onabort = () => {
70 onAbortWasFired = true;
71 };
72 
73 const inner = await this.env.RpcRemoteEnd.countToInfinity(signal);
74 return { inner, reason: signal.reason, onAbortWasFired };
75 }
76 
77 async tryUsingGlobalAbortController() {
78 if (globalAbortController === undefined) {
79 globalAbortController = new AbortController();
80 await this.env.RpcRemoteEnd.echo(globalAbortController.signal); // send the signal over
81 } else {
82 globalAbortController.abort(new Error('boom?'));
83 }
84 }
85 
86 async getWrappedSignal() {
87 return new WrappedAbortSignal();
88 }
89}
90 
91export const abortcontroller = {
92 test() {
93 // AbortSignal is not directly creatable
94 throws(() => new AbortSignal());
95 
96 const ac = new AbortController();
97 ok(ac.signal instanceof AbortSignal);
98 strictEqual(ac.signal.aborted, false);
99 
100 // every call to ac.signal should always be the same value.
101 strictEqual(ac.signal, ac.signal);
102 
103 // signal is read only
104 throws(() => (ac.signal = 1));
105 
106 let invoked = 0;
107 ac.signal.onabort = (event) => {
108 invoked++;
109 strictEqual(event.type, 'abort');
110 };
111 
112 // Will not throw because the signal is not aborted
113 ac.signal.throwIfAborted();
114 
115 // reason and aborted are read only
116 throws(() => (ac.signal.reason = 1));
117 throws(() => (ac.signal.aborted = 'foo'));
118 
119 // trigger our abort with a default reason...
120 ac.abort();
121 
122 // This one shouldn't get called since it is added after the abort
123 ac.signal.addEventListener('abort', () => {
124 throw new Error('should not have been called');
125 });
126 
127 // Will throw because the signal is now aborted.
128 throws(() => ac.signal.throwIfAborted());
129 
130 strictEqual(ac.signal.aborted, true);
131 strictEqual(ac.signal.reason.message, 'The operation was aborted');
132 strictEqual(ac.signal.reason.name, 'AbortError');
133 
134 // Abort can be called multiple times with no effect.
135 ac.abort();
136 
137 strictEqual(invoked, 1);
138 },
139};
140 
141export const abortcontrollerWithReason = {
142 test() {
143 const ac = new AbortController();
144 ok(ac.signal instanceof AbortSignal);
145 strictEqual(ac.signal.aborted, false);
146 
147 let invoked = 0;
148 
149 ac.signal.addEventListener('abort', (event) => {
150 invoked++;
151 strictEqual(ac.signal.reason, 'foo');
152 });
153 
154 ac.abort('foo');
155 strictEqual(ac.signal.aborted, true);
156 strictEqual(ac.signal.reason, 'foo');
157 
158 strictEqual(invoked, 1);
159 },
160};
161 
162export const alreadyAborted = {
163 test() {
164 const aborted = AbortSignal.abort();
165 strictEqual(aborted.aborted, true);
166 throws(() => aborted.throwIfAborted());
167 
168 const abortedWithReason = AbortSignal.abort('foo');
169 strictEqual(abortedWithReason.aborted, true);
170 try {
171 abortedWithReason.throwIfAborted();
172 throw new Error('should have thrown');
173 } catch (err) {
174 strictEqual(err, 'foo');
175 }
176 },
177};
178 
179export const timedAbort = {
180 async test() {
181 const timed = AbortSignal.timeout(100);
182 let resolve;
183 const promise = new Promise((r) => (resolve = r));
184 let invoked = 0;
185 timed.onabort = () => {
186 invoked++;
187 resolve();
188 };
189 await promise;
190 strictEqual(invoked, 1);
191 },
192};
193 
194export const anyAbort = {
195 async test() {
196 // Set a timeout way in the future so this one doesn't happen first.
197 const timed = AbortSignal.timeout(1000000);
198 const ac = new AbortController();
199 
200 // Creates an AbortSignal that will be triggered when either of the two
201 // given signals is triggered.
202 const any = AbortSignal.any([timed, ac.signal]);
203 
204 let invoked = 0;
205 any.onabort = () => {
206 invoked++;
207 };
208 
209 ac.abort();
210 
211 strictEqual(invoked, 1);
212 },
213};
214 
215export const anyAbort2 = {
216 async test() {
217 const timed = AbortSignal.timeout(100);
218 const ac = new AbortController();
219 const any = AbortSignal.any([timed, ac.signal]);
220 
221 let invoked = 0;
222 let resolve;
223 const promise = new Promise((r) => (resolve = r));
224 
225 any.onabort = () => {
226 invoked++;
227 resolve();
228 };
229 
230 await promise;
231 
232 strictEqual(invoked, 1);
233 },
234};
235 
236export const anyAbort3 = {
237 async test() {
238 const timed = AbortSignal.timeout(1000000);
239 const aborted = AbortSignal.abort(123);
240 // If one of the signals is already abort, the any signal will be
241 // immediately aborted also.
242 const any = AbortSignal.any([timed, aborted]);
243 strictEqual(any.aborted, true);
244 strictEqual(any.reason, 123);
245 },
246};
247 
248function initAny(signal, resolve) {
249 const any = AbortSignal.any([signal]);
250 any.onabort = () => {
251 resolve();
252 };
253}
254 
255export const anyAbort4 = {
256 async test() {
257 // Reproduces a failure seen under asan.
258 const ac = new AbortController();
259 ac.signal.addEventListener('abort', (event) => {});
260 const { promise, resolve } = Promise.withResolvers();
261 
262 // Set up AbortSignal.any() to call "resolve" when ac.signal aborts. We use a separate
263 // function to avoid accidentally capturing references in this scope.
264 initAny(ac.signal, resolve);
265 
266 gc();
267 ac.abort();
268 await promise;
269 },
270};
271 
272export const onabortPrototypeProperty = {
273 test() {
274 const ac = new AbortController();
275 ok('onabort' in AbortSignal.prototype);
276 strictEqual(ac.signal.onabort, null);
277 delete ac.signal.onabort;
278 ok('onabort' in AbortSignal.prototype);
279 strictEqual(ac.signal.onabort, null);
280 let called = false;
281 ac.signal.onabort = () => {
282 called = true;
283 };
284 ac.abort();
285 ok(called);
286 
287 // Setting the value to something other than a function or object
288 // should cause the value to become null.
289 [123, null, 'foo'].forEach((v) => {
290 ac.signal.onabort = () => {};
291 ac.signal.onabort = v;
292 strictEqual(ac.signal.onabort, null);
293 });
294 
295 const handler = {};
296 ac.signal.onabort = handler;
297 strictEqual(ac.signal.onabort, handler);
298 },
299};
300 
301export const rpcUnusedSignal = {
302 async test(ctrl, env, ctx) {
303 const ac = new AbortController();
304 const responseSignal = await env.RpcRemoteEnd.echo(ac.signal);
305 
306 ok(responseSignal instanceof AbortSignal);
307 strictEqual(responseSignal.aborted, false);
308 strictEqual(responseSignal.reason, undefined);
309 },
310};
311 
312export const rpcNeverAbortsSignal = {
313 async test(ctrl, env, ctx) {
314 const otherRequest = new Request('http://example.com');
315 
316 const responseSignal = await env.RpcRemoteEnd.echo(otherRequest.signal);
317 ok(responseSignal instanceof AbortSignal);
318 strictEqual(responseSignal.aborted, false);
319 strictEqual(responseSignal.reason, undefined);
320 },
321};
322 
323export const rpcAbortSignalTimeout = {
324 async test(ctrl, env, ctx) {
325 const signal = AbortSignal.timeout(200);
326 const res = await env.RpcRemoteEnd.countToInfinity(signal);
327 
328 // We don't care the exact value it got to, but at least 1 iteration should have happened
329 ok(res.counter >= 1);
330 
331 // Make sure the reason was passed without being garbled
332 ok(res.reason instanceof DOMException);
333 strictEqual(res.reason.message, 'The operation was aborted due to timeout');
334 
335 // Make sure an event was dispatched on the remote side
336 ok(res.onAbortWasFired);
337 },
338};
339 
340export const rpcAbortSignalAbort = {
341 async test(ctrl, env, ctx) {
342 // NB: AbortSignal.abort returns an abort signal that is already aborted
343 const expectedReason = "just didn't feel like it";
344 const signal = AbortSignal.abort(expectedReason);
345 const res = await env.RpcRemoteEnd.countToInfinity(signal);
346 
347 // No iterations should have happened
348 strictEqual(res.counter, 0);
349 
350 // Make sure the reason was passed without being garbled
351 strictEqual(res.reason, "just didn't feel like it");
352 
353 // No event is dispatched on an already aborted signal
354 ok(!res.onAbortWasFired);
355 },
356};
357 
358export const rpcAbortControllerSignal = {
359 async test(ctrl, env, ctx) {
360 const ac = new AbortController();
361 const resPromise = env.RpcRemoteEnd.countToInfinity(ac.signal);
362 
363 // Wait an arbitrary amount of time, then use the AbortController to abort the remote end.
364 await scheduler.wait(200);
365 const expectedReason = 'changed my mind';
366 ac.abort(expectedReason);
367 
368 const res = await resPromise;
369 
370 // We don't care the exact value it got to, but at least 1 iteration should have happened
371 ok(res.counter >= 1);
372 
373 // Make sure the reason was passed without being garbled
374 strictEqual(res.reason, expectedReason);
375 
376 // Make sure an event was dispatched on the remote side
377 ok(res.onAbortWasFired);
378 },
379};
380 
381export const rpcAbortControllerSignalNoReasonProvided = {
382 async test(ctrl, env, ctx) {
383 const ac = new AbortController();
384 const resPromise = env.RpcRemoteEnd.countToInfinity(ac.signal);
385 
386 // Wait an arbitrary amount of time, then use the AbortController to abort the remote end.
387 await scheduler.wait(200);
388 ac.abort();
389 
390 const res = await resPromise;
391 
392 // We don't care the exact value it got to, but at least 1 iteration should have happened
393 ok(res.counter >= 1);
394 
395 // Make sure the reason was passed without being garbled
396 ok(res.reason instanceof DOMException);
397 strictEqual(res.reason.message, 'The operation was aborted');
398 
399 // Make sure an event was dispatched on the remote side
400 ok(res.onAbortWasFired);
401 },
402};
403 
404export const rpcAbortSignalFurtherCloned = {
405 async test(ctrl, env, ctx) {
406 const ac = new AbortController();
407 const resPromise = env.RpcRemoteEnd.chainReaction(ac.signal);
408 
409 // Wait an arbitrary amount of time, then use the AbortController to abort the remote end.
410 await scheduler.wait(200);
411 const expectedReason = 'changed my mind';
412 ac.abort(expectedReason);
413 
414 const res = await resPromise;
415 
416 // We don't care the exact value it got to, but at least 1 iteration should have happened
417 ok(res.inner.counter >= 1);
418 
419 // Make sure the reason was passed without being garbled
420 strictEqual(res.reason, expectedReason);
421 strictEqual(res.inner.reason, expectedReason);
422 
423 // Make sure an event was dispatched on the remote side
424 ok(res.onAbortWasFired);
425 ok(res.inner.onAbortWasFired);
426 },
427};
428 
429export const rpcAbortSignalManyClients = {
430 async test(ctrl, env, ctx) {
431 const signal = AbortSignal.timeout(200);
432 
433 const responses = await Promise.all(
434 Array.from({ length: 5 }, () => env.RpcRemoteEnd.countToInfinity(signal))
435 );
436 strictEqual(responses.length, 5);
437 
438 for (const res of responses) {
439 // We don't care the exact value it got to, but at least 1 iteration should have happened
440 ok(res.counter >= 1);
441 
442 // Make sure the reason was passed without being garbled
443 ok(res.reason instanceof DOMException);
444 strictEqual(
445 res.reason.message,
446 'The operation was aborted due to timeout'
447 );
448 
449 // Make sure an event was dispatched on the remote side
450 ok(res.onAbortWasFired);
451 }
452 },
453};
454 
455export const rpcAbortSignalAny = {
456 async test(ctrl, env, ctx) {
457 const unusedAc = new AbortController();
458 const signal = AbortSignal.any([AbortSignal.timeout(200), unusedAc.signal]);
459 const res = await env.RpcRemoteEnd.countToInfinity(signal);
460 
461 // We don't care the exact value it got to, but at least 1 iteration should have happened
462 ok(res.counter >= 1);
463 
464 // Make sure the reason was passed without being garbled
465 ok(res.reason instanceof DOMException);
466 strictEqual(res.reason.message, 'The operation was aborted due to timeout');
467 
468 // Make sure an event was dispatched on the remote side
469 ok(res.onAbortWasFired);
470 },
471};
472 
473export const rpcAbortSignalAnyOnRemoteEnd = {
474 async test(ctrl, env, ctx) {
475 const ac = new AbortController();
476 const resPromise = env.RpcRemoteEnd.countToInfinityWithTimeout(ac.signal);
477 
478 // Wait an arbitrary amount of time, then use the AbortController to abort the remote end.
479 await scheduler.wait(200);
480 const expectedReason =
481 'our timeout triggered before the 1000ms timeout on the other side';
482 ac.abort(expectedReason);
483 
484 const res = await resPromise;
485 
486 // We don't care the exact value it got to, but at least 1 iteration should have happened
487 ok(res.counter >= 1);
488 
489 // Make sure the reason was passed without being garbled
490 strictEqual(res.reason, expectedReason);
491 
492 // Make sure an event was dispatched on the remote side
493 ok(res.onAbortWasFired);
494 },
495};
496 
497export const rpcRequestSignal = {
498 async test(ctrl, env, ctx) {
499 // Construct a request holding an AbortSignal, and then send this request to the other side
500 // Note that this signal isn't affected by the request_signal_passthrough compat flag, which
501 // only modifies the behaviour of the signal on the incoming request.
502 const req = new Request('http://example.com', {
503 signal: AbortSignal.timeout(200),
504 });
505 
506 const res = await env.RpcRemoteEnd.countToInfinityWithRequest(req);
507 
508 // We don't care the exact value it got to, but at least 1 iteration should have happened
509 ok(res.counter >= 1);
510 
511 // Make sure the reason was passed without being garbled
512 ok(res.reason instanceof DOMException);
513 strictEqual(res.reason.message, 'The operation was aborted due to timeout');
514 
515 // Make sure an event was dispatched on the remote side
516 ok(res.onAbortWasFired);
517 },
518};
519 
520export const rpcCrossRequestSignal = {
521 async test(ctrl, env, ctx) {
522 // Save an AbortController in the global scope
523 await env.RpcRemoteEnd.tryUsingGlobalAbortController();
524 
525 // Try to use it again
526 await rejects(
527 async () => env.RpcRemoteEnd.tryUsingGlobalAbortController(),
528 {
529 name: 'Error',
530 message:
531 "Cannot perform I/O on behalf of a different request. I/O objects (such as streams, request/response bodies, and others) created in the context of one request handler cannot be accessed from a different request's handler. This is a limitation of Cloudflare Workers which allows us to improve overall performance. (I/O type: RefcountedCanceler)",
532 }
533 );
534 },
535};
536 
537export const rpcRemoteCanIgnoreSignal = {
538 async test(ctrl, env, ctx) {
539 const ac = new AbortController();
540 const resPromise = env.RpcRemoteEnd.ignoreSignal(ac.signal);
541 
542 // Wait an arbitrary amount of time, then use the AbortController to abort the remote end.
543 await scheduler.wait(200);
544 const expectedReason = 'changed my mind';
545 ac.abort(expectedReason);
546 
547 const res = await resPromise;
548 
549 // Every iteration completes, the remote is not reacting to the abort
550 strictEqual(res.counter, 10);
551 
552 // Make sure the reason was passed without being garbled
553 strictEqual(res.reason, expectedReason);
554 },
555};
556 
557export const rpcDestroySignalUnclean = {
558 async test(ctrl, env, ctx) {
559 // wrapper is a RPCTarget that just holds an AbortSignal
560 const wrapper = await env.RpcRemoteEnd.getWrappedSignal();
561 
562 // Get our clone of the signal
563 const signal = await wrapper.getSignal();
564 
565 // Tell the AbortSignal not to send a release message on disposal
566 await wrapper.forget();
567 
568 // Destroy the wrapper
569 wrapper[Symbol.dispose]();
570 
571 // No release message was sent, our clone will provide a message explaining the other side is
572 // gone.
573 ok(signal.aborted);
574 strictEqual(
575 signal.reason.message,
576 'An AbortSignal received over RPC was implicitly aborted because the connection back to its ' +
577 'trigger was lost.'
578 );
579 },
580};
581 
582export const rpcDestroySignalClean = {
583 async test(ctrl, env, ctx) {
584 // wrapper is a RPCTarget that just holds an AbortSignal
585 const wrapper = await env.RpcRemoteEnd.getWrappedSignal();
586 
587 // Get our clone of the signal
588 const signal = await wrapper.getSignal();
589 
590 // Destroy the wrapper
591 wrapper[Symbol.dispose]();
592 
593 // A release message was sent, the signal will remain in an unaborted state
594 ok(!signal.aborted);
595 strictEqual(signal.reason, undefined);
596 },
597};