From 3c680c4fb22cc2f0b0a4e1892eb065656749cc63 Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Sat, 3 Oct 2026 11:47:41 -0500 Subject: [PATCH] Expand Node 24 async event consumption compatibility --- .../node-v24-compatibility-meta.json | 2 +- .../src/generated/node-v24-compatibility.json | 14 +- internal/compatibility/dynamic-support.json | 3 +- .../generated/node-v24-backlog.json | 26 ++- .../generated/node-v24-internal.json | 20 +- internal/compatibility/src/generate.mjs | 3 + .../test/ts7/baselines/order-parity.json | 6 + packages/runtime/src/scr_island.c | 159 ++++++++++++- packages/runtime/src/scr_island_manifest.h | 4 +- packages/runtime/src/scr_web.c | 19 +- .../npm/cases/node24-async-events/main.ts | 4 + .../cases/node24-async-events/package.json | 1 + .../node24-async-events-fixture/index.d.ts | 1 + .../node24-async-events-fixture/index.js | 210 ++++++++++++++++++ .../node24-async-events-fixture/package.json | 1 + 15 files changed, 425 insertions(+), 48 deletions(-) create mode 100644 tests/fixtures/npm/cases/node24-async-events/main.ts create mode 100644 tests/fixtures/npm/cases/node24-async-events/package.json create mode 100644 tests/fixtures/npm/node_modules/node24-async-events-fixture/index.d.ts create mode 100644 tests/fixtures/npm/node_modules/node24-async-events-fixture/index.js create mode 100644 tests/fixtures/npm/node_modules/node24-async-events-fixture/package.json diff --git a/docs/src/generated/node-v24-compatibility-meta.json b/docs/src/generated/node-v24-compatibility-meta.json index 08c73ae23..28a4cdd8b 100644 --- a/docs/src/generated/node-v24-compatibility-meta.json +++ b/docs/src/generated/node-v24-compatibility-meta.json @@ -2,6 +2,6 @@ "schemaVersion": 3, "nodeVersion": "24.15.0", "nodeCommit": "848430679556aed0bd073f2bc263331ad84fa119", - "artifactVersion": "875a9e22efbde88c11b9", + "artifactVersion": "11c3ebd9479ef9f7eca0", "rowCount": 3662 } diff --git a/docs/src/generated/node-v24-compatibility.json b/docs/src/generated/node-v24-compatibility.json index 9a174b205..4471c5305 100644 --- a/docs/src/generated/node-v24-compatibility.json +++ b/docs/src/generated/node-v24-compatibility.json @@ -443,9 +443,9 @@ }, "dynamic": { "supported": 0, - "partial": 25, + "partial": 26, "refused": 0, - "not-implemented": 53, + "not-implemented": 52, "by-design": 0, "unreviewed": 0, "not-applicable": 0 @@ -34795,7 +34795,7 @@ }, "dynamic": { "status": "partial", - "detail": "Implemented by the embedded Node compatibility shim.", + "detail": "Waits for EventEmitter or EventTarget events with AbortSignal cancellation, Node abort errors, and listener cleanup.", "verification": "test-backed" } }, @@ -34851,7 +34851,7 @@ }, "dynamic": { "status": "partial", - "detail": "Implemented by the embedded Node compatibility shim.", + "detail": "Iterates EventEmitter or EventTarget events with buffered delivery, close events, cancellation, watermark backpressure, and listener cleanup.", "verification": "test-backed" } }, @@ -34906,9 +34906,9 @@ "verification": "registry-gap" }, "dynamic": { - "status": "not-implemented", - "detail": "This API is not implemented by the dynamic-island shim.", - "verification": "registry-gap" + "status": "partial", + "detail": "Registers disposable abort listeners that run even when ordinary propagation is stopped; already-aborted signals schedule the callback in a microtask.", + "verification": "test-backed" } }, { diff --git a/internal/compatibility/dynamic-support.json b/internal/compatibility/dynamic-support.json index ad0ab9413..7248b78dc 100644 --- a/internal/compatibility/dynamic-support.json +++ b/internal/compatibility/dynamic-support.json @@ -19,6 +19,7 @@ "wasi": "by-design" }, "features": [ + { "chapter": "events", "symbols": ["events.once", "events.on", "events.addAbortListener"], "status": "partial", "evidence": ["tests/fixtures/npm/cases/node24-async-events/main.ts"] }, { "chapter": "events", "symbols": ["emitter.on", "emitter.addListener", "emitter.once", "emitter.prependListener", "emitter.prependOnceListener", "emitter.removeListener", "emitter.off", "emitter.removeAllListeners", "emitter.emit", "emitter.listenerCount", "emitter.eventNames", "events.errorMonitor"], "status": "partial", "evidence": ["tests/fixtures/npm/cases/stream-shims/main.ts", "tests/fixtures/npm/cases/node24-event-emitter/main.ts"] }, { "chapter": "util", "symbols": ["util.promisify", "util.callbackify"], "status": "partial", "evidence": ["tests/fixtures/npm/cases/util-shims/main.ts", "tests/fixtures/npm/cases/node24-util-adapters/main.ts"] }, { "chapter": "util", "symbols": ["util.parseArgs", "parseArgs"], "status": "partial", "evidence": ["tests/fixtures/npm/cases/util-shims/main.ts", "tests/fixtures/npm/cases/node24-parseargs/main.ts"] }, @@ -45,7 +46,7 @@ { "chapter": "typescript", "symbols": ["type"], "status": "supported", "evidence": ["tests/corpus/2705-type-import-link-dynamic/main.ts", "tests/corpus/2706-type-import-link-dynamic-success/main.ts", "tests/corpus/2708-type-import-link-dynamic/main.ts", "tests/harness/errors.test.ts"] } ], "modules": { - "events": { "exports": ["EventEmitter", "once", "on", "listenerCount", "getEventListeners", "setMaxListeners", "defaultMaxListeners", "errorMonitor", "captureRejectionSymbol"], "members": ["emitter.addListener", "emitter.emit", "emitter.eventNames", "emitter.getMaxListeners", "emitter.listenerCount", "emitter.listeners", "emitter.off", "emitter.on", "emitter.once", "emitter.prependListener", "emitter.prependOnceListener", "emitter.removeAllListeners", "emitter.removeListener", "emitter.setMaxListeners", "emitter.rawListeners", "events.getEventListeners", "events.listenerCount", "events.once", "events.on", "events.setMaxListeners"], "evidence": ["tests/fixtures/npm/cases/stream-shims/main.ts"] }, + "events": { "exports": ["EventEmitter", "once", "on", "addAbortListener", "listenerCount", "getEventListeners", "setMaxListeners", "defaultMaxListeners", "errorMonitor", "captureRejectionSymbol"], "members": ["emitter.addListener", "emitter.emit", "emitter.eventNames", "emitter.getMaxListeners", "emitter.listenerCount", "emitter.listeners", "emitter.off", "emitter.on", "emitter.once", "emitter.prependListener", "emitter.prependOnceListener", "emitter.removeAllListeners", "emitter.removeListener", "emitter.setMaxListeners", "emitter.rawListeners", "events.getEventListeners", "events.listenerCount", "events.once", "events.on", "events.setMaxListeners"], "evidence": ["tests/fixtures/npm/cases/stream-shims/main.ts"] }, "path": { "exports": ["sep", "delimiter", "basename", "dirname", "extname", "join", "resolve", "normalize", "relative", "isAbsolute", "toNamespacedPath", "parse", "format", "posix", "win32"], "evidence": ["tests/fixtures/npm/cases/misc-shims/main.ts"] }, "path/posix": { "exports": ["sep", "delimiter", "basename", "dirname", "extname", "join", "resolve", "normalize", "relative", "isAbsolute", "toNamespacedPath", "parse", "format", "posix", "win32"], "evidence": ["tests/fixtures/npm/cases/misc-shims/main.ts"] }, "path/win32": { "exports": ["sep", "delimiter", "basename", "dirname", "extname", "join", "resolve", "normalize", "relative", "isAbsolute", "toNamespacedPath", "parse", "format", "posix", "win32"], "evidence": ["tests/fixtures/npm/cases/misc-shims/main.ts"] }, diff --git a/internal/compatibility/generated/node-v24-backlog.json b/internal/compatibility/generated/node-v24-backlog.json index a381da250..d661b6da9 100644 --- a/internal/compatibility/generated/node-v24-backlog.json +++ b/internal/compatibility/generated/node-v24-backlog.json @@ -14,8 +14,8 @@ "implement": 81 }, "dynamic": { - "audit-partial": 815, - "verify-gap": 2223, + "audit-partial": 816, + "verify-gap": 2222, "replace-refusal": 112, "implement": 218 } @@ -33726,9 +33726,9 @@ "action": "audit-partial", "verification": "test-backed", "confidence": "high", - "source": "island-member:node:events.events.once", + "source": "island-feature:events.events.once", "tests": [ - "tests/fixtures/npm/cases/stream-shims/main.ts" + "tests/fixtures/npm/cases/node24-async-events/main.ts" ] } } @@ -33802,9 +33802,9 @@ "action": "audit-partial", "verification": "test-backed", "confidence": "high", - "source": "island-member:node:events.events.on", + "source": "island-feature:events.events.on", "tests": [ - "tests/fixtures/npm/cases/stream-shims/main.ts" + "tests/fixtures/npm/cases/node24-async-events/main.ts" ] } } @@ -33871,12 +33871,14 @@ "tests": [] }, "dynamic": { - "status": "not-implemented", - "action": "verify-gap", - "verification": "registry-gap", - "confidence": "low", - "source": "island-unmatched:events", - "tests": [] + "status": "partial", + "action": "audit-partial", + "verification": "test-backed", + "confidence": "high", + "source": "island-feature:events.events.addAbortListener", + "tests": [ + "tests/fixtures/npm/cases/node24-async-events/main.ts" + ] } } }, diff --git a/internal/compatibility/generated/node-v24-internal.json b/internal/compatibility/generated/node-v24-internal.json index 044772545..737bbef7c 100644 --- a/internal/compatibility/generated/node-v24-internal.json +++ b/internal/compatibility/generated/node-v24-internal.json @@ -492,9 +492,9 @@ }, "dynamic": { "supported": 0, - "partial": 25, + "partial": 26, "refused": 0, - "not-implemented": 53, + "not-implemented": 52, "by-design": 0, "unreviewed": 0, "not-applicable": 0 @@ -48354,6 +48354,7 @@ "status": "partial", "evidence": "derived:descendants", "tests": [ + "tests/fixtures/npm/cases/node24-async-events/main.ts", "tests/fixtures/npm/cases/node24-event-emitter/main.ts", "tests/fixtures/npm/cases/stream-shims/main.ts" ] @@ -50488,9 +50489,9 @@ }, "dynamic": { "status": "partial", - "evidence": "island-member:node:events.events.once", + "evidence": "island-feature:events.events.once", "tests": [ - "tests/fixtures/npm/cases/stream-shims/main.ts" + "tests/fixtures/npm/cases/node24-async-events/main.ts" ] }, "anchorSource": "exact" @@ -50583,9 +50584,9 @@ }, "dynamic": { "status": "partial", - "evidence": "island-member:node:events.events.on", + "evidence": "island-feature:events.events.on", "tests": [ - "tests/fixtures/npm/cases/stream-shims/main.ts" + "tests/fixtures/npm/cases/node24-async-events/main.ts" ] }, "anchorSource": "exact" @@ -50646,8 +50647,11 @@ "evidence": "compiler-unmatched:events" }, "dynamic": { - "status": "not-implemented", - "evidence": "island-unmatched:events" + "status": "partial", + "evidence": "island-feature:events.events.addAbortListener", + "tests": [ + "tests/fixtures/npm/cases/node24-async-events/main.ts" + ] }, "anchorSource": "exact" }, diff --git a/internal/compatibility/src/generate.mjs b/internal/compatibility/src/generate.mjs index 554987e8c..3da1af5e8 100644 --- a/internal/compatibility/src/generate.mjs +++ b/internal/compatibility/src/generate.mjs @@ -678,6 +678,9 @@ function publicDetail(tier) { return "No dynamic-island implementation exists for this Node API family yet."; } if (source.startsWith("island-feature:")) { + if (source === "island-feature:events.events.once") return "Waits for EventEmitter or EventTarget events with AbortSignal cancellation, Node abort errors, and listener cleanup."; + if (source === "island-feature:events.events.on") return "Iterates EventEmitter or EventTarget events with buffered delivery, close events, cancellation, watermark backpressure, and listener cleanup."; + if (source === "island-feature:events.events.addAbortListener") return "Registers disposable abort listeners that run even when ordinary propagation is stopped; already-aborted signals schedule the callback in a microtask."; if (source.startsWith("island-feature:events.")) { return "Implements EventEmitter listener lifecycle, symbol event names, filtered listener counts, and error monitoring; rejection capture and listener warnings remain outside this subset."; } diff --git a/packages/compiler/test/ts7/baselines/order-parity.json b/packages/compiler/test/ts7/baselines/order-parity.json index a2a790fb9..6cb4f3d91 100644 --- a/packages/compiler/test/ts7/baselines/order-parity.json +++ b/packages/compiler/test/ts7/baselines/order-parity.json @@ -13420,6 +13420,12 @@ "/tests/fixtures/npm/cases/node24-event-emitter/main.ts" ], "diags": [] + }, + "/tests/fixtures/npm/cases/node24-async-events/main.ts": { + "order": [ + "/tests/fixtures/npm/cases/node24-async-events/main.ts" + ], + "diags": [] } } } diff --git a/packages/runtime/src/scr_island.c b/packages/runtime/src/scr_island.c index 5552ba52c..e6622a99b 100644 --- a/packages/runtime/src/scr_island.c +++ b/packages/runtime/src/scr_island.c @@ -3860,7 +3860,7 @@ static const char isl_modules_bootstrap[] = /* Node's events module: the emitter surface streams and CLIs drive — * prepend/once/remove with listener-unwrap, maxListeners bookkeeping * (warnings are not emitted), eventNames/rawListeners, Node's - * unhandled-'error' throw, and the once/getEventListeners statics. + * unhandled-'error' throw, cancellable once, and async event iteration. * EventEmitter is callable so legacy constructors can use .call(this). */ " builtins.events = memo(() => {\n" " function EventEmitter() {\n" @@ -3968,18 +3968,155 @@ static const char isl_modules_bootstrap[] = " EventEmitter.listenerCount = (emitter, n) => emitter.listenerCount(n);\n" " EventEmitter.getEventListeners = (emitter, n) => emitter.listeners(n);\n" " EventEmitter.setMaxListeners = (m, ...emitters) => { for (const e of emitters) e.setMaxListeners(m); };\n" - " EventEmitter.once = (emitter, name) => new Promise((resolve, reject) => {\n" - " const onEvent = (...args) => {\n" - " emitter.removeListener('error', onError);\n" - " resolve(args);\n" + " const resistStopPropagation = Symbol.for('scriptc.events.resistStopPropagation');\n" + " const checkSignal = (signal, name) => {\n" + " if (signal !== undefined && (signal === null || typeof signal !== 'object' || !('aborted' in signal)))\n" + " envArgumentError(name, 'an instance of AbortSignal', signal);\n" + " };\n" + " const checkOptions = (options) => {\n" + " if (options === null || typeof options !== 'object' || Array.isArray(options))\n" + " envArgumentError('options', 'of type object', options);\n" + " };\n" + " const abortError = (signal) => {\n" + " const error = new Error('The operation was aborted', { cause: signal.reason });\n" + " error.name = 'AbortError';\n" + " error.code = 'ABORT_ERR';\n" + " return error;\n" + " };\n" + " const add = (emitter, name, listener, flags) => {\n" + " if (typeof emitter.on === 'function') {\n" + " if (flags && flags.once) emitter.once(name, listener);\n" + " else emitter.on(name, listener);\n" + " } else if (typeof emitter.addEventListener === 'function') emitter.addEventListener(name, listener, flags);\n" + " else envArgumentError('emitter', 'an instance of EventEmitter', emitter);\n" + " };\n" + " const remove = (emitter, name, listener) => {\n" + " if (typeof emitter.removeListener === 'function') emitter.removeListener(name, listener);\n" + " else if (typeof emitter.removeEventListener === 'function') emitter.removeEventListener(name, listener);\n" + " else envArgumentError('emitter', 'an instance of EventEmitter', emitter);\n" + " };\n" + " EventEmitter.addAbortListener = (signal, listener) => {\n" + " if (signal === undefined) envArgumentError('signal', 'an instance of AbortSignal', signal);\n" + " checkSignal(signal, 'signal');\n" + " checkListener(listener);\n" + " let dispose;\n" + " if (signal.aborted) queueMicrotask(() => listener());\n" + " else {\n" + " signal.addEventListener('abort', listener, { once: true, [resistStopPropagation]: true });\n" + " dispose = () => signal.removeEventListener('abort', listener);\n" + " }\n" + " return { __proto__: null, [Symbol.dispose]() { if (dispose) dispose(); } };\n" + " };\n" + " EventEmitter.once = async (emitter, name, options = {}) => {\n" + " checkOptions(options);\n" + " const signal = options.signal;\n" + " checkSignal(signal, 'options.signal');\n" + " if (signal && signal.aborted) throw abortError(signal);\n" + " return new Promise((resolve, reject) => {\n" + " const onError = (err) => {\n" + " remove(emitter, name, onEvent);\n" + " if (signal) remove(signal, 'abort', onAbort);\n" + " reject(err);\n" + " };\n" + " const onEvent = (...args) => {\n" + " if (typeof emitter.removeListener === 'function') emitter.removeListener('error', onError);\n" + " if (signal) remove(signal, 'abort', onAbort);\n" + " resolve(args);\n" + " };\n" + " const onAbort = () => {\n" + " remove(emitter, name, onEvent);\n" + " remove(emitter, 'error', onError);\n" + " reject(abortError(signal));\n" + " };\n" + " const flags = { __proto__: null, once: true, [resistStopPropagation]: true };\n" + " add(emitter, name, onEvent, flags);\n" + " if (name !== 'error' && typeof emitter.once === 'function') emitter.once('error', onError);\n" + " if (signal) add(signal, 'abort', onAbort, flags);\n" + " });\n" + " };\n" + " const queue = () => {\n" + " let values = [], head = 0;\n" + " return {\n" + " get size() { return values.length - head; },\n" + " push(value) { values.push(value); },\n" + " shift() {\n" + " const value = values[head];\n" + " values[head++] = undefined;\n" + " if (head === values.length) { values = []; head = 0; }\n" + " else if (head >= 1024 && head * 2 > values.length) { values = values.slice(head); head = 0; }\n" + " return value;\n" + " },\n" + " };\n" + " };\n" + " const checkWatermark = (value, name) => {\n" + " if (typeof value !== 'number') envArgumentError(name, 'of type number', value);\n" + " if (!Number.isInteger(value) || value < 1 || value > Number.MAX_SAFE_INTEGER) {\n" + " const range = Number.isInteger(value) ? '>= 1 && <= 9007199254740991' : 'an integer';\n" + " const received = Number.isInteger(value) && Math.abs(value) > 2 ** 32 ? String(value).replace(/\\B(?=(\\d{3})+(?!\\d))/g, '_') : String(value);\n" + " const error = new RangeError('The value of \"' + name + '\" is out of range. It must be ' + range + '. Received ' + received);\n" + " error.code = 'ERR_OUT_OF_RANGE';\n" + " throw error;\n" + " }\n" + " };\n" + " const iteratorPrototype = Object.getPrototypeOf(Object.getPrototypeOf(Object.getPrototypeOf((async function* () {})())));\n" + " EventEmitter.on = (emitter, name, options = {}) => {\n" + " checkOptions(options);\n" + " const signal = options.signal;\n" + " checkSignal(signal, 'options.signal');\n" + " if (signal && signal.aborted) throw abortError(signal);\n" + " const high = options.highWaterMark ?? options.highWatermark ?? Number.MAX_SAFE_INTEGER;\n" + " checkWatermark(high, 'options.highWaterMark');\n" + " const low = options.lowWaterMark ?? options.lowWatermark ?? 1;\n" + " checkWatermark(low, 'options.lowWaterMark');\n" + " const events = queue(), pending = queue(), listeners = [];\n" + " let paused = false, error = null, finished = false, abortListener;\n" + " const close = () => {\n" + " if (abortListener) abortListener[Symbol.dispose]();\n" + " while (listeners.length) { const [event, handler] = listeners.pop(); remove(emitter, event, handler); }\n" + " finished = true;\n" + " paused = false;\n" + " const result = { value: undefined, done: true };\n" + " while (pending.size) pending.shift().resolve(result);\n" + " return Promise.resolve(result);\n" " };\n" " const onError = (err) => {\n" - " emitter.removeListener(name, onEvent);\n" - " reject(err);\n" + " if (pending.size) pending.shift().reject(err);\n" + " else error = err;\n" + " close();\n" " };\n" - " emitter.once(name, onEvent);\n" - " if (name !== 'error') emitter.once('error', onError);\n" - " });\n" + " const onEvent = (...args) => {\n" + " if (pending.size) pending.shift().resolve({ value: args, done: false });\n" + " else {\n" + " if (!paused && events.size + 1 > high) { paused = true; emitter.pause(); }\n" + " events.push(args);\n" + " }\n" + " };\n" + " const iterator = Object.setPrototypeOf({\n" + " next() {\n" + " if (events.size) {\n" + " const value = events.shift();\n" + " if (paused && events.size < low) { emitter.resume(); paused = false; }\n" + " return Promise.resolve({ value, done: false });\n" + " }\n" + " if (error) { const err = error; error = null; return Promise.reject(err); }\n" + " if (finished) return close();\n" + " return new Promise((resolve, reject) => pending.push({ resolve, reject }));\n" + " },\n" + " return() { return close(); },\n" + " throw(err) {\n" + " if (!(err instanceof Error)) envArgumentError('EventEmitter.AsyncIterator', 'an instance of Error', err);\n" + " onError(err);\n" + " },\n" + " [Symbol.asyncIterator]() { return this; },\n" + " }, iteratorPrototype);\n" + " const listen = (event, handler) => { add(emitter, event, handler); listeners.push([event, handler]); };\n" + " listen(name, onEvent);\n" + " if (name !== 'error' && typeof emitter.on === 'function') listen('error', onError);\n" + " const closeEvents = options.close;\n" + " if (closeEvents && closeEvents.length) { for (let i = 0; i < closeEvents.length; i++) listen(closeEvents[i], close); }\n" + " if (signal) abortListener = EventEmitter.addAbortListener(signal, () => onError(abortError(signal)));\n" + " return iterator;\n" + " };\n" " EventEmitter.EventEmitter = EventEmitter;\n" " EventEmitter.default = EventEmitter;\n" " return EventEmitter;\n" @@ -7469,7 +7606,7 @@ static const char isl_modules_bootstrap[] = " else if (type === 'function') received = 'function ' + value.name;\n" " else if (type === 'object' && value.constructor && value.constructor.name) received = 'an instance of ' + value.constructor.name;\n" " else received = type === 'object' ? builtins.util().inspect(value, { depth: -1 }) : 'type ' + type + ' (' + builtins.util().inspect(value) + ')';\n" - " const error = new TypeError('The \"' + name + '\" argument must be ' + expected + '. Received ' + received);\n" + " const error = new TypeError('The \"' + name + '\" ' + (name.includes('.') ? 'property' : 'argument') + ' must be ' + expected + '. Received ' + received);\n" " error.code = 'ERR_INVALID_ARG_TYPE';\n" " throw error;\n" " };\n" diff --git a/packages/runtime/src/scr_island_manifest.h b/packages/runtime/src/scr_island_manifest.h index 3e38f9994..3c35ca7ec 100644 --- a/packages/runtime/src/scr_island_manifest.h +++ b/packages/runtime/src/scr_island_manifest.h @@ -5,8 +5,8 @@ static const struct { const char *exports; } isl_builtins[] = { {"node:events", - "EventEmitter,once,on,listenerCount,getEventListeners,setMaxListeners,defaultMaxListeners" - ",errorMonitor,captureRejectionSymbol"}, + "EventEmitter,once,on,addAbortListener,listenerCount,getEventListeners,setMaxListeners,de" + "faultMaxListeners,errorMonitor,captureRejectionSymbol"}, {"node:path", "sep,delimiter,basename,dirname,extname,join,resolve,normalize,relative,isAbsolute,toName" "spacedPath,parse,format,posix,win32"}, diff --git a/packages/runtime/src/scr_web.c b/packages/runtime/src/scr_web.c index 33f7e5cd0..9751f1b66 100644 --- a/packages/runtime/src/scr_web.c +++ b/packages/runtime/src/scr_web.c @@ -924,13 +924,14 @@ static const char web_prelude[] = " const once = !!(options !== null && typeof options === 'object' && options.once);\n" " const list = this._et[t] || (this._et[t] = []);\n" " for (const e of list) { if (e.listener === listener) return; }\n" - " list.push({ listener, once });\n" + " const resistant = !!(options && options[Symbol.for('scriptc.events.resistStopPropagation')]);\n" + " list.push({ listener, once, resistant });\n" " }\n" " removeEventListener(type, listener) {\n" " const list = this._et[String(type)];\n" " if (!list) return;\n" " const i = list.findIndex((e) => e.listener === listener);\n" - " if (i >= 0) list.splice(i, 1);\n" + " if (i >= 0) list.splice(i, 1)[0].removed = true;\n" " }\n" " dispatchEvent(event) {\n" " if (!(event instanceof Event)) throw new TypeError('The \"event\" argument must be an instance of Event.');\n" @@ -940,7 +941,8 @@ static const char web_prelude[] = " const list = this._et[event.type];\n" " if (list) {\n" " for (const e of [...list]) {\n" - " if (event._stopImmediate) break;\n" + " if (e.removed) continue;\n" + " if (event._stopImmediate && !e.resistant) continue;\n" " if (e.once) this.removeEventListener(event.type, e.listener);\n" " if (typeof e.listener === 'function') e.listener.call(this, event);\n" " else if (e.listener !== null && typeof e.listener.handleEvent === 'function') e.listener.handleEvent(event);\n" @@ -1087,13 +1089,16 @@ static const char web_prelude[] = " if (s._aborted) return;\n" " s._aborted = true;\n" " s._reason = reason === undefined ? mkAbortError() : reason;\n" - " const ev = { type: 'abort', target: s, currentTarget: s };\n" + " const ev = new Event('abort');\n" + " ev.target = s; ev.currentTarget = s; ev.eventPhase = Event.AT_TARGET; ev.isTrusted = true;\n" " let firstErr;\n" " let threw = false;\n" " if (s._onabort !== null) {\n" " try { s._onabort.call(s, ev); } catch (e) { firstErr = e; threw = true; }\n" " }\n" " for (const l of s._listeners.slice()) {\n" + " if (l.removed) continue;\n" + " if (ev._stopImmediate && !l.resistant) continue;\n" " if (l.once) s.removeEventListener('abort', l.fn);\n" " try { l.fn.call(s, ev); } catch (e) { if (!threw) { firstErr = e; threw = true; } }\n" " }\n" @@ -1109,13 +1114,15 @@ static const char web_prelude[] = " throwIfAborted() { if (this._aborted) throw this._reason; }\n" " addEventListener(type, fn, options) {\n" " if (String(type) !== 'abort' || typeof fn !== 'function') return;\n" + " if (this._listeners.some(l => l.fn === fn)) return;\n" " const once = options !== undefined && options !== null && !!options.once;\n" - " this._listeners.push({ fn, once });\n" + " const resistant = !!(options && options[Symbol.for('scriptc.events.resistStopPropagation')]);\n" + " this._listeners.push({ fn, once, resistant });\n" " }\n" " removeEventListener(type, fn) {\n" " if (String(type) !== 'abort') return;\n" " const i = this._listeners.findIndex((l) => l.fn === fn);\n" - " if (i >= 0) this._listeners.splice(i, 1);\n" + " if (i >= 0) this._listeners.splice(i, 1)[0].removed = true;\n" " }\n" " static abort(reason) {\n" " const s = mkSignal();\n" diff --git a/tests/fixtures/npm/cases/node24-async-events/main.ts b/tests/fixtures/npm/cases/node24-async-events/main.ts new file mode 100644 index 000000000..0a802c7e8 --- /dev/null +++ b/tests/fixtures/npm/cases/node24-async-events/main.ts @@ -0,0 +1,4 @@ +// @dynamic +// @stderr +import { report } from "node24-async-events-fixture"; +await report(); diff --git a/tests/fixtures/npm/cases/node24-async-events/package.json b/tests/fixtures/npm/cases/node24-async-events/package.json new file mode 100644 index 000000000..089153bcb --- /dev/null +++ b/tests/fixtures/npm/cases/node24-async-events/package.json @@ -0,0 +1 @@ +{"type":"module"} diff --git a/tests/fixtures/npm/node_modules/node24-async-events-fixture/index.d.ts b/tests/fixtures/npm/node_modules/node24-async-events-fixture/index.d.ts new file mode 100644 index 000000000..601984b08 --- /dev/null +++ b/tests/fixtures/npm/node_modules/node24-async-events-fixture/index.d.ts @@ -0,0 +1 @@ +export function report(): Promise; diff --git a/tests/fixtures/npm/node_modules/node24-async-events-fixture/index.js b/tests/fixtures/npm/node_modules/node24-async-events-fixture/index.js new file mode 100644 index 000000000..b2ddadb28 --- /dev/null +++ b/tests/fixtures/npm/node_modules/node24-async-events-fixture/index.js @@ -0,0 +1,210 @@ +import { EventEmitter, once, on, addAbortListener } from 'node:events'; + +export async function report() { + const line = (name, value) => console.log(name, JSON.stringify(value)); + const shape = e => [e.name, e.code, e.message]; + const reject = async (name, fn) => { + try { await fn(); line(name, 'no error'); } catch (e) { line(name, shape(e)); } + }; + const counts = em => em.eventNames().map(n => [String(n), em.listenerCount(n)]); + for (const value of [null, false, 3, 'bad', [], () => {}]) { + await reject('once options', () => once(new EventEmitter(), 'x', value)); + await reject('on options', () => on(new EventEmitter(), 'x', value)); + } + for (const value of [null, false, {}, 3]) { + await reject('once signal', () => once(new EventEmitter(), 'x', { signal: value })); + await reject('on signal', () => on(new EventEmitter(), 'x', { signal: value })); + await reject('abort signal', () => addAbortListener(value, () => {})); + } + await reject('abort undefined', () => addAbortListener(undefined, () => {})); + await reject('abort listener', () => addAbortListener(new AbortController().signal, null)); + await reject('once emitter', () => once({}, 'x')); + await reject('on emitter', () => on({}, 'x')); + for (const name of ['highWaterMark', 'lowWaterMark', 'highWatermark', 'lowWatermark']) { + for (const value of ['2', 0, -1, 1.5, NaN, Infinity, Number.MAX_SAFE_INTEGER + 1]) { + await reject(name, () => on(new EventEmitter(), 'x', { [name]: value })); + } + } + { + const em = new EventEmitter(), ac = new AbortController(), event = Symbol('ready'); + const promise = once(em, event, { signal: ac.signal }); + line('once registered', counts(em)); + em.emit(event, 'ready', 42); + line('once result', await promise); + line('once cleaned', counts(em)); + ac.abort('after completion'); + } + { + const em = new EventEmitter(), ac = new AbortController(), failure = new Error('failure'); + const promise = once(em, 'ready', { signal: ac.signal }); + em.emit('error', failure); + try { await promise; } catch (e) { line('once error identity', e === failure); } + line('once error cleaned', counts(em)); + const errorEvent = once(em, 'error'); em.emit('error', failure); + line('once error event', (await errorEvent)[0] === failure); + } + for (const preaborted of [false, true]) { + const em = new EventEmitter(), ac = new AbortController(), cause = { why: 'cancelled' }; + ac.signal.addEventListener('abort', e => e.stopImmediatePropagation()); + if (preaborted) ac.abort(cause); + const promise = once(em, 'ready', { signal: ac.signal }); + if (!preaborted) ac.abort(cause); + try { await promise; } catch (e) { line('once aborted', [preaborted, ...shape(e), e.cause === cause]); } + line('once abort cleaned', counts(em)); + } + { + const target = new EventTarget(), ac = new AbortController(); + target.addEventListener('ready', e => e.stopImmediatePropagation()); + const promise = once(target, 'ready', { signal: ac.signal }); + target.dispatchEvent(new Event('error')); + const event = new Event('ready'); target.dispatchEvent(event); + line('once target', (await promise)[0] === event); + ac.abort(); + const aborted = once(target, 'never', { signal: ac.signal }); + await reject('once target aborted', () => aborted); + const active = new AbortController(), waiting = once(target, 'never', { signal: active.signal }); + active.abort('target cancellation'); await reject('once target active abort', () => waiting); + const controller = new AbortController(), signalEvent = once(controller.signal, 'abort'); + controller.abort(); line('once signal event', (await signalEvent)[0] instanceof Event); + } + { + const em = new EventEmitter(), symbol = Symbol('item'); + const iterator = on(em, symbol, { close: ['end', 'close'] }); + line('iterator self', iterator[Symbol.asyncIterator]() === iterator); + em.emit(symbol, 1, 'a'); em.emit(symbol, 2); em.emit('end'); + line('iterator closed listeners', counts(em)); + const values = []; for await (const value of iterator) values.push(value); + line('buffered close', values); + line('closed next', await iterator.next()); + } + { + const em = new EventEmitter(), iterator = on(em, 'item'); + const first = iterator.next(), second = iterator.next(), third = iterator.next(); + em.emit('item', 'first'); em.emit('item', 'second'); + line('pending delivery', [await first, await second]); + line('return result', await iterator.return('ignored')); + line('pending return', await third); + line('return cleaned', counts(em)); + } + { + const em = new EventEmitter(), iterator = on(em, 'item'), failure = new Error('failure'); + em.emit('item', 'buffered'); em.emit('error', failure); + line('error drains buffer', await iterator.next()); + try { await iterator.next(); } catch (e) { line('iterator error identity', e === failure); } + line('error next done', await iterator.next()); + line('error cleaned', counts(em)); + const errorIterator = on(em, 'error'); em.emit('error', failure); + line('iterator error event', (await errorIterator.next()).value[0] === failure); + await errorIterator.return(); + } + { + const em = new EventEmitter(), iterator = on(em, 'x'), failure = new Error('pending failure'); + const first = iterator.next().catch(e => e === failure), second = iterator.next(); + em.emit('error', failure); + line('pending error', [await first, await second]); + } + for (const preaborted of [false, true]) { + const em = new EventEmitter(), ac = new AbortController(), cause = { why: 'cancelled' }; + ac.signal.addEventListener('abort', e => e.stopImmediatePropagation()); + if (preaborted) ac.abort(cause); + try { + const iterator = on(em, 'x', { signal: ac.signal }); + const first = iterator.next().catch(e => [shape(e), e.cause === cause]), second = iterator.next(); + ac.abort(cause); + line('iterator aborted', [await first, await second, await iterator.next()]); + } catch (e) { line('iterator preaborted', [shape(e), e.cause === cause]); } + line('iterator abort cleaned', counts(em)); + } + { + const em = new EventEmitter(), ac = new AbortController(), iterator = on(em, 'x', { signal: ac.signal }); + em.emit('x', 'buffered'); ac.abort('buffered abort'); + line('abort drains buffer', await iterator.next()); + await reject('buffered abort', () => iterator.next()); + line('buffered abort done', await iterator.next()); + } + { + const em = new EventEmitter(), iterator = on(em, 'x', { close: ['end'] }); + const a = iterator.next(), b = iterator.next(); em.emit('end'); + line('pending close', [await a, await b, await iterator.next()]); + } + { + const em = new EventEmitter(), iterator = on(em, 'x'); + const failure = new Error('thrown'); + line('iterator throw return', iterator.throw(failure) === undefined); + try { await iterator.next(); } catch (e) { line('iterator throw identity', e === failure); } + line('iterator throw cleaned', counts(em)); + for (const value of [undefined, null, 'failure', {}]) await reject('iterator throw type', () => iterator.throw(value)); + } + for (const legacy of [false, true]) { + const em = new EventEmitter(), log = []; + em.pause = () => { log.push('pause'); }; em.resume = () => { log.push('resume'); }; + const options = legacy ? { highWatermark: 2, lowWatermark: 2 } : { highWaterMark: 2, lowWaterMark: 2, highWatermark: 9 }; + const iterator = on(em, 'item', options); + em.emit('item', 1); em.emit('item', 2); line('watermark threshold', log.slice()); + em.emit('item', 3); line('watermark pause', log.slice()); + await iterator.next(); line('watermark still paused', log.slice()); + await iterator.next(); line('watermark resume', log.slice()); + await iterator.return(); + } + { + const em = new EventEmitter(), iterator = on(em, 'x'); + for (let i = 0; i < 3000; i++) em.emit('x', i); + let sum = 0; for (let i = 0; i < 3000; i++) sum += (await iterator.next()).value[0]; + line('queue compaction', sum); await iterator.return(); + } + { + const em = new EventEmitter(), iterator = on(em, 'x'); + queueMicrotask(() => { em.emit('x', 'loop'); }); + for await (const value of iterator) { line('loop break', value); break; } + line('loop break cleaned', counts(em)); + const ac = new AbortController(), returned = on(em, 'x', { signal: ac.signal }); + await returned.return(); ac.abort(); line('return removes abort', await returned.next()); + } + { + const target = new EventTarget(), iterator = on(target, 'item', { close: ['end'] }); + target.dispatchEvent(new Event('error')); + const event = new Event('item'); target.dispatchEvent(event); target.dispatchEvent(new Event('end')); + line('iterator target', [(await iterator.next()).value[0] === event, await iterator.next()]); + } + { + const ac = new AbortController(), log = []; + ac.signal.addEventListener('abort', e => { log.push('stop'); e.stopImmediatePropagation(); }); + ac.signal.addEventListener('abort', () => { log.push('ordinary'); }); + const disposable = addAbortListener(ac.signal, function(e) { log.push([this === ac.signal, e instanceof Event, e.type]); }); + line('disposable shape', [Object.getPrototypeOf(disposable) === null, typeof disposable[Symbol.dispose]]); + ac.abort(); ac.abort(); disposable[Symbol.dispose](); disposable[Symbol.dispose](); line('resistant abort', log); + } + { + const ac = new AbortController(), log = []; + const first = addAbortListener(ac.signal, () => { log.push('first'); second[Symbol.dispose](); }); + const second = addAbortListener(ac.signal, () => { log.push('second'); }); + const disposed = addAbortListener(ac.signal, () => { log.push('disposed'); }); + disposed[Symbol.dispose](); ac.abort(); first[Symbol.dispose](); line('disposed abort', log); + } + { + const signal = AbortSignal.abort('already aborted'), log = []; + const disposable = addAbortListener(signal, function(e) { log.push([e === undefined, this === undefined]); }); + disposable[Symbol.dispose](); line('preaborted scheduling', log.slice()); + await Promise.resolve(); line('preaborted callback', log); + } + { + const ac = new AbortController(); let calls = 0; + const listener = () => { calls++; }; + const a = addAbortListener(ac.signal, listener), b = addAbortListener(ac.signal, listener); + ac.abort(); line('duplicate abort listener', calls); a[Symbol.dispose](); b[Symbol.dispose](); + const other = new AbortController(), c = addAbortListener(other.signal, listener), d = addAbortListener(other.signal, listener); + c[Symbol.dispose](); other.abort(); line('duplicate disposal', calls); d[Symbol.dispose](); + } + { + const em = new EventEmitter(), ac = new AbortController(); let signals = 0, closes = 0, removed = 0; + const original = ac.signal.removeEventListener.bind(ac.signal); + ac.signal.removeEventListener = (...args) => { removed++; return original(...args); }; + const options = { get signal() { signals++; return ac.signal; }, get close() { closes++; return ['end']; } }; + const promise = once(em, 'x', options); em.emit('x', 1); await promise; + const iterator = on(em, 'x', options); await iterator.return(); + line('option getters and abort cleanup', [signals, closes, removed]); + let lows = 0; + await reject('watermark validation order', () => on(em, 'x', { highWaterMark: 0, get lowWaterMark() { lows++; return 1; } })); + line('invalid high skips low', lows); + } +} diff --git a/tests/fixtures/npm/node_modules/node24-async-events-fixture/package.json b/tests/fixtures/npm/node_modules/node24-async-events-fixture/package.json new file mode 100644 index 000000000..c9b2e6348 --- /dev/null +++ b/tests/fixtures/npm/node_modules/node24-async-events-fixture/package.json @@ -0,0 +1 @@ +{"name":"node24-async-events-fixture","version":"1.0.0","type":"module","main":"index.js","types":"index.d.ts"}