diff --git a/cli/tests/unit/timers_test.ts b/cli/tests/unit/timers_test.ts index ac0403bf74..5b9e1fa482 100644 --- a/cli/tests/unit/timers_test.ts +++ b/cli/tests/unit/timers_test.ts @@ -679,3 +679,77 @@ Deno.test({ Deno.refTimer(NaN); }, }); + +Deno.test({ + name: "AbortSignal.timeout() with no listeners", + permissions: { run: true }, + fn: async () => { + const [statusCode, output] = await execCode(` + const signal = AbortSignal.timeout(2000); + + // This unref timer expires before the signal, and if it does expire, then + // it means the signal has kept the event loop alive. + const timer = setTimeout(() => console.log("Unexpected!"), 1500); + Deno.unrefTimer(timer); + `); + assertEquals(statusCode, 0); + assertEquals(output, ""); + }, +}); + +Deno.test({ + name: "AbortSignal.timeout() with listeners", + permissions: { run: true }, + fn: async () => { + const [statusCode, output] = await execCode(` + const signal = AbortSignal.timeout(1000); + signal.addEventListener("abort", () => console.log("Event fired!")); + `); + assertEquals(statusCode, 0); + assertEquals(output, "Event fired!\n"); + }, +}); + +Deno.test({ + name: "AbortSignal.timeout() with removed listeners", + permissions: { run: true }, + fn: async () => { + const [statusCode, output] = await execCode(` + const signal = AbortSignal.timeout(2000); + + const callback = () => console.log("Unexpected: Event fired"); + signal.addEventListener("abort", callback); + + setTimeout(() => { + console.log("Removing the listener."); + signal.removeEventListener("abort", callback); + }, 500); + + Deno.unrefTimer( + setTimeout(() => console.log("Unexpected: Unref timer"), 1500) + ); + `); + assertEquals(statusCode, 0); + assertEquals(output, "Removing the listener.\n"); + }, +}); + +Deno.test({ + name: "AbortSignal.timeout() with listener for a non-abort event", + permissions: { run: true }, + fn: async () => { + const [statusCode, output] = await execCode(` + const signal = AbortSignal.timeout(2000); + + signal.addEventListener("someOtherEvent", () => { + console.log("Unexpected: someOtherEvent called"); + }); + + Deno.unrefTimer( + setTimeout(() => console.log("Unexpected: Unref timer"), 1500) + ); + `); + assertEquals(statusCode, 0); + assertEquals(output, ""); + }, +}); diff --git a/ext/web/02_event.js b/ext/web/02_event.js index e85b4f5cbc..122add2114 100644 --- a/ext/web/02_event.js +++ b/ext/web/02_event.js @@ -868,6 +868,10 @@ return target?.[eventTargetData]?.mode ?? null; } + function listenerCount(target, type) { + return getListeners(target)?.[type]?.length ?? 0; + } + function getDefaultTargetData() { return { assignedSlot: false, @@ -1326,6 +1330,7 @@ window.__bootstrap.eventTarget = { EventTarget, setEventTargetData, + listenerCount, }; window.__bootstrap.event = { setIsTrusted, diff --git a/ext/web/03_abort_signal.js b/ext/web/03_abort_signal.js index 8b089d0317..39de8d0fc5 100644 --- a/ext/web/03_abort_signal.js +++ b/ext/web/03_abort_signal.js @@ -7,6 +7,7 @@ ((window) => { const webidl = window.__bootstrap.webidl; const { setIsTrusted, defineEventHandler } = window.__bootstrap.event; + const { listenerCount } = window.__bootstrap.eventTarget; const { Set, SetPrototypeAdd, @@ -14,6 +15,7 @@ Symbol, TypeError, } = window.__bootstrap.primordials; + const { setTimeout, refTimer, unrefTimer } = window.__bootstrap.timers; const add = Symbol("[[add]]"); const signalAbort = Symbol("[[signalAbort]]"); @@ -21,6 +23,7 @@ const abortReason = Symbol("[[abortReason]]"); const abortAlgos = Symbol("[[abortAlgos]]"); const signal = Symbol("[[signal]]"); + const timerId = Symbol("[[timerId]]"); const illegalConstructorKey = Symbol("illegalConstructorKey"); @@ -34,6 +37,27 @@ return signal; } + static timeout(millis) { + const prefix = "Failed to call 'AbortSignal.timeout'"; + webidl.requiredArguments(arguments.length, 1, { prefix }); + millis = webidl.converters["unsigned long long"](millis, { + enforceRange: true, + }); + + const signal = new AbortSignal(illegalConstructorKey); + signal[timerId] = setTimeout( + () => { + signal[timerId] = null; + signal[signalAbort]( + new DOMException("Signal timed out.", "TimeoutError"), + ); + }, + millis, + ); + unrefTimer(signal[timerId]); + return signal; + } + [add](algorithm) { if (this.aborted) { return; @@ -73,6 +97,7 @@ super(); this[abortReason] = undefined; this[abortAlgos] = null; + this[timerId] = null; this[webidl.brand] = webidl.brand; } @@ -92,6 +117,25 @@ throw this[abortReason]; } } + + // `addEventListener` and `removeEventListener` have to be overriden in + // order to have the timer block the event loop while there are listeners. + // `[add]` and `[remove]` don't ref and unref the timer because they can + // only be used by Deno internals, which use it to essentially cancel async + // ops which would block the event loop. + addEventListener(...args) { + super.addEventListener(...args); + if (this[timerId] !== null && listenerCount(this, "abort") > 0) { + refTimer(this[timerId]); + } + } + + removeEventListener(...args) { + super.removeEventListener(...args); + if (this[timerId] !== null && listenerCount(this, "abort") === 0) { + unrefTimer(this[timerId]); + } + } } defineEventHandler(AbortSignal.prototype, "abort"); diff --git a/ext/web/lib.deno_web.d.ts b/ext/web/lib.deno_web.d.ts index e8f7f26cd6..77c502fac4 100644 --- a/ext/web/lib.deno_web.d.ts +++ b/ext/web/lib.deno_web.d.ts @@ -307,6 +307,7 @@ declare var AbortSignal: { prototype: AbortSignal; new (): AbortSignal; abort(reason?: any): AbortSignal; + timeout(milliseconds: number): AbortSignal; }; interface FileReaderEventMap { diff --git a/tools/wpt/expectation.json b/tools/wpt/expectation.json index 60f41ef979..43367efe35 100644 --- a/tools/wpt/expectation.json +++ b/tools/wpt/expectation.json @@ -961,16 +961,8 @@ }, "dom": { "abort": { - "AbortSignal.any.html": [ - "AbortSignal.timeout() returns a non-aborted signal", - "Signal returned by AbortSignal.timeout() times out", - "AbortSignal timeouts fire in order" - ], - "AbortSignal.any.worker.html": [ - "AbortSignal.timeout() returns a non-aborted signal", - "Signal returned by AbortSignal.timeout() times out", - "AbortSignal timeouts fire in order" - ], + "AbortSignal.any.html": true, + "AbortSignal.any.worker.html": true, "event.any.html": true, "event.any.worker.html": true },