From 5a265ff619c53e68ff0ef2d7dea561d4a5ac664f Mon Sep 17 00:00:00 2001 From: pfg Date: Mon, 5 May 2025 20:57:46 -0700 Subject: [PATCH] abortsignal util custom inspect --- .../bindings/webcore/JSAbortController.cpp | 75 +++++ src/bun.js/bindings/webcore/JSAbortSignal.cpp | 75 +++++ .../test/parallel/test-abortcontroller.js | 280 ++++++++++++++++++ 3 files changed, 430 insertions(+) create mode 100644 test/js/node/test/parallel/test-abortcontroller.js diff --git a/src/bun.js/bindings/webcore/JSAbortController.cpp b/src/bun.js/bindings/webcore/JSAbortController.cpp index 81a279ff6e..e68a1d4a12 100644 --- a/src/bun.js/bindings/webcore/JSAbortController.cpp +++ b/src/bun.js/bindings/webcore/JSAbortController.cpp @@ -48,6 +48,7 @@ #include #include #include +#include "ErrorCode.h" #include namespace WebCore { @@ -56,6 +57,7 @@ using namespace JSC; // Functions static JSC_DECLARE_HOST_FUNCTION(jsAbortControllerPrototypeFunction_abort); +static JSC_DECLARE_HOST_FUNCTION(jsAbortControllerPrototypeFunction_customInspect); // Attributes @@ -149,6 +151,7 @@ void JSAbortControllerPrototype::finishCreation(VM& vm) { Base::finishCreation(vm); reifyStaticProperties(vm, JSAbortController::info(), JSAbortControllerPrototypeTableValues, *this); + this->putDirectNativeFunction(vm, this->globalObject(), builtinNames(vm).inspectCustomPublicName(), 2, jsAbortControllerPrototypeFunction_customInspect, ImplementationVisibility::Public, NoIntrinsic, JSC::PropertyAttribute::Function | 0); JSC_TO_STRING_TAG_WITHOUT_TRANSITION(); } @@ -225,6 +228,78 @@ JSC_DEFINE_HOST_FUNCTION(jsAbortControllerPrototypeFunction_abort, (JSGlobalObje return IDLOperation::call(*lexicalGlobalObject, *callFrame, "abort"); } +static inline JSC::EncodedJSValue jsAbortControllerPrototypeFunction_customInspectBody(JSGlobalObject* lexicalGlobalObject, CallFrame* callFrame, typename IDLOperation::ClassParameter castedThis) +{ + + auto& vm = lexicalGlobalObject->vm(); + auto throwScope = DECLARE_THROW_SCOPE(vm); + auto* globalObject = defaultGlobalObject(lexicalGlobalObject); + + JSValue depthValue = callFrame->argument(0); + JSValue optionsValue = callFrame->argument(1); + + auto depth = depthValue.toNumber(lexicalGlobalObject); + RETURN_IF_EXCEPTION(throwScope, {}); + if (depth < 0) { + return JSValue::encode(jsNontrivialString(vm, "[AbortController]"_s)); + } + + if (!depthValue.isUndefinedOrNull()) { + depthValue = jsNumber(depth - 1); + } + + JSObject* options = optionsValue.toObject(lexicalGlobalObject); + RETURN_IF_EXCEPTION(throwScope, {}); + PropertyNameArray optionsArray(vm, PropertyNameMode::StringsAndSymbols, PrivateSymbolMode::Exclude); + options->getPropertyNames(lexicalGlobalObject, optionsArray, DontEnumPropertiesMode::Exclude); + RETURN_IF_EXCEPTION(throwScope, {}); + + JSObject* newOptions = constructEmptyObject(lexicalGlobalObject); + for (size_t i = 0; i < optionsArray.size(); i++) { + auto name = optionsArray[i]; + + JSValue value = options->get(lexicalGlobalObject, name); + RETURN_IF_EXCEPTION(throwScope, {}); + + newOptions->putDirect(vm, name, value, 0); + RETURN_IF_EXCEPTION(throwScope, {}); + } + + PutPropertySlot slot(newOptions); + newOptions->put(newOptions, lexicalGlobalObject, Identifier::fromString(vm, "depth"_s), depthValue, slot); + RETURN_IF_EXCEPTION(throwScope, {}); + + auto& impl = castedThis->wrapped(); + + JSObject* inputObj = constructEmptyObject(lexicalGlobalObject); + + inputObj->putDirect(vm, Identifier::fromString(vm, "signal"_s), toJS>(*lexicalGlobalObject, *castedThis->globalObject(), throwScope, impl.signal()), 0); + + JSFunction* utilInspect = globalObject->utilInspectFunction(); + auto callData = JSC::getCallData(utilInspect); + MarkedArgumentBuffer arguments; + arguments.append(inputObj); + arguments.append(newOptions); + + auto inspectResult = JSC::profiledCall(globalObject, ProfilingReason::API, utilInspect, callData, inputObj, arguments); + RETURN_IF_EXCEPTION(throwScope, {}); + + auto* inspectString = inspectResult.toString(lexicalGlobalObject); + RETURN_IF_EXCEPTION(throwScope, {}); + + auto inspectStringView = inspectString->view(lexicalGlobalObject); + RETURN_IF_EXCEPTION(throwScope, {}); + + JSValue result = jsString(vm, makeString("AbortController "_s, inspectStringView.data)); + + return JSValue::encode(result); +} + +JSC_DEFINE_HOST_FUNCTION(jsAbortControllerPrototypeFunction_customInspect, (JSGlobalObject * lexicalGlobalObject, CallFrame* callFrame)) +{ + return IDLOperation::call(*lexicalGlobalObject, *callFrame, "inspect"); +} + JSC::GCClient::IsoSubspace* JSAbortController::subspaceForImpl(JSC::VM& vm) { return WebCore::subspaceForImpl( diff --git a/src/bun.js/bindings/webcore/JSAbortSignal.cpp b/src/bun.js/bindings/webcore/JSAbortSignal.cpp index a8f0031096..23738ef982 100644 --- a/src/bun.js/bindings/webcore/JSAbortSignal.cpp +++ b/src/bun.js/bindings/webcore/JSAbortSignal.cpp @@ -52,6 +52,7 @@ #include #include #include +#include "ErrorCode.h" #include namespace WebCore { @@ -63,6 +64,7 @@ static JSC_DECLARE_HOST_FUNCTION(jsAbortSignalConstructorFunction_abort); static JSC_DECLARE_HOST_FUNCTION(jsAbortSignalConstructorFunction_timeout); static JSC_DECLARE_HOST_FUNCTION(jsAbortSignalConstructorFunction_any); static JSC_DECLARE_HOST_FUNCTION(jsAbortSignalPrototypeFunction_throwIfAborted); +static JSC_DECLARE_HOST_FUNCTION(jsAbortSignalPrototypeFunction_customInspect); // Attributes @@ -159,6 +161,7 @@ void JSAbortSignalPrototype::finishCreation(VM& vm) { Base::finishCreation(vm); reifyStaticProperties(vm, JSAbortSignal::info(), JSAbortSignalPrototypeTableValues, *this); + this->putDirectNativeFunction(vm, this->globalObject(), builtinNames(vm).inspectCustomPublicName(), 2, jsAbortSignalPrototypeFunction_customInspect, ImplementationVisibility::Public, NoIntrinsic, JSC::PropertyAttribute::Function | 0); JSC_TO_STRING_TAG_WITHOUT_TRANSITION(); } @@ -339,6 +342,78 @@ JSC_DEFINE_HOST_FUNCTION(jsAbortSignalPrototypeFunction_throwIfAborted, (JSGloba return IDLOperation::call(*lexicalGlobalObject, *callFrame, "throwIfAborted"); } +static inline JSC::EncodedJSValue jsAbortSignalPrototypeFunction_customInspectBody(JSGlobalObject* lexicalGlobalObject, CallFrame* callFrame, typename IDLOperation::ClassParameter castedThis) +{ + + auto& vm = lexicalGlobalObject->vm(); + auto throwScope = DECLARE_THROW_SCOPE(vm); + auto* globalObject = defaultGlobalObject(lexicalGlobalObject); + + JSValue depthValue = callFrame->argument(0); + JSValue optionsValue = callFrame->argument(1); + + auto depth = depthValue.toNumber(lexicalGlobalObject); + RETURN_IF_EXCEPTION(throwScope, {}); + if (depth < 0) { + return JSValue::encode(jsNontrivialString(vm, "[AbortSignal]"_s)); + } + + if (!depthValue.isUndefinedOrNull()) { + depthValue = jsNumber(depth - 1); + } + + JSObject* options = optionsValue.toObject(lexicalGlobalObject); + RETURN_IF_EXCEPTION(throwScope, {}); + PropertyNameArray optionsArray(vm, PropertyNameMode::StringsAndSymbols, PrivateSymbolMode::Exclude); + options->getPropertyNames(lexicalGlobalObject, optionsArray, DontEnumPropertiesMode::Exclude); + RETURN_IF_EXCEPTION(throwScope, {}); + + JSObject* newOptions = constructEmptyObject(lexicalGlobalObject); + for (size_t i = 0; i < optionsArray.size(); i++) { + auto name = optionsArray[i]; + + JSValue value = options->get(lexicalGlobalObject, name); + RETURN_IF_EXCEPTION(throwScope, {}); + + newOptions->putDirect(vm, name, value, 0); + RETURN_IF_EXCEPTION(throwScope, {}); + } + + PutPropertySlot slot(newOptions); + newOptions->put(newOptions, lexicalGlobalObject, Identifier::fromString(vm, "depth"_s), depthValue, slot); + RETURN_IF_EXCEPTION(throwScope, {}); + + auto& impl = castedThis->wrapped(); + + JSObject* inputObj = constructEmptyObject(lexicalGlobalObject); + + inputObj->putDirect(vm, Identifier::fromString(vm, "aborted"_s), jsBoolean(impl.aborted()), 0); + + JSFunction* utilInspect = globalObject->utilInspectFunction(); + auto callData = JSC::getCallData(utilInspect); + MarkedArgumentBuffer arguments; + arguments.append(inputObj); + arguments.append(newOptions); + + auto inspectResult = JSC::profiledCall(globalObject, ProfilingReason::API, utilInspect, callData, inputObj, arguments); + RETURN_IF_EXCEPTION(throwScope, {}); + + auto* inspectString = inspectResult.toString(lexicalGlobalObject); + RETURN_IF_EXCEPTION(throwScope, {}); + + auto inspectStringView = inspectString->view(lexicalGlobalObject); + RETURN_IF_EXCEPTION(throwScope, {}); + + JSValue result = jsString(vm, makeString("AbortSignal "_s, inspectStringView.data)); + + return JSValue::encode(result); +} + +JSC_DEFINE_HOST_FUNCTION(jsAbortSignalPrototypeFunction_customInspect, (JSGlobalObject * lexicalGlobalObject, CallFrame* callFrame)) +{ + return IDLOperation::call(*lexicalGlobalObject, *callFrame, "inspect"); +} + size_t JSAbortSignal::estimatedSize(JSC::JSCell* cell, JSC::VM& vm) { auto* thisObject = jsCast(cell); diff --git a/test/js/node/test/parallel/test-abortcontroller.js b/test/js/node/test/parallel/test-abortcontroller.js new file mode 100644 index 0000000000..7baa3ab6d4 --- /dev/null +++ b/test/js/node/test/parallel/test-abortcontroller.js @@ -0,0 +1,280 @@ +// Flags: --expose-gc +'use strict'; + +require('../common'); +const { inspect } = require('util'); + +const { + ok, + notStrictEqual, + strictEqual, + throws, +} = require('assert'); + +const { + test, + mock, +} = require('node:test'); + +const { setTimeout: sleep } = require('timers/promises'); + +// All of the the tests in this file depend on public-facing Node.js APIs. +// For tests that depend on Node.js internal APIs, please add them to +// test-abortcontroller-internal.js instead. + +test('Abort is fired with the correct event type on AbortControllers', () => { + // Tests that abort is fired with the correct event type on AbortControllers + const ac = new AbortController(); + ok(ac.signal); + + let calls = 0; + const fn = (event) => { + ok(event); + strictEqual(event.type, 'abort'); + calls++; + }; + + ac.signal.onabort = fn; + ac.signal.addEventListener('abort', fn); + + ac.abort(); + ac.abort(); + ok(ac.signal.aborted); + + strictEqual(calls, 2); +}); + +test('Abort events are trusted', () => { + // Tests that abort events are trusted + const ac = new AbortController(); + + let calls = 0; + const fn = (event) => { + ok(event.isTrusted); + calls++; + }; + + ac.signal.onabort = fn; + ac.abort(); + strictEqual(calls, 1); +}); + +test('Abort events have the same isTrusted reference', () => { + // Tests that abort events have the same `isTrusted` reference + const first = new AbortController(); + const second = new AbortController(); + let ev1, ev2; + const ev3 = new Event('abort'); + + first.signal.addEventListener('abort', (event) => { + ev1 = event; + }); + second.signal.addEventListener('abort', (event) => { + ev2 = event; + }); + first.abort(); + second.abort(); + const firstTrusted = Reflect.getOwnPropertyDescriptor(Object.getPrototypeOf(ev1), 'isTrusted').get; + const secondTrusted = Reflect.getOwnPropertyDescriptor(Object.getPrototypeOf(ev2), 'isTrusted').get; + const untrusted = Reflect.getOwnPropertyDescriptor(Object.getPrototypeOf(ev3), 'isTrusted').get; + strictEqual(firstTrusted, secondTrusted); + strictEqual(untrusted, firstTrusted); +}); + +test('AbortSignal is impossible to construct manually', () => { + // Tests that AbortSignal is impossible to construct manually + const ac = new AbortController(); + throws(() => new ac.signal.constructor(), { + code: 'ERR_ILLEGAL_CONSTRUCTOR', + }); +}); + +test('Symbol.toStringTag is correct', () => { + // Symbol.toStringTag + const toString = (o) => Object.prototype.toString.call(o); + const ac = new AbortController(); + strictEqual(toString(ac), '[object AbortController]'); + strictEqual(toString(ac.signal), '[object AbortSignal]'); +}); + +test('AbortSignal.abort() creates an already aborted signal', () => { + const signal = AbortSignal.abort(); + ok(signal.aborted); +}); + +test('AbortController properties and methods valiate the receiver', () => { + const acSignalGet = Object.getOwnPropertyDescriptor( + AbortController.prototype, + 'signal' + ).get; + const acAbort = AbortController.prototype.abort; + + const goodController = new AbortController(); + ok(acSignalGet.call(goodController)); + acAbort.call(goodController); + + const badAbortControllers = [ + null, + undefined, + 0, + NaN, + true, + 'AbortController', + { __proto__: AbortController.prototype }, + ]; + for (const badController of badAbortControllers) { + throws( + () => acSignalGet.call(badController), + { name: 'TypeError' } + ); + throws( + () => acAbort.call(badController), + { name: 'TypeError' } + ); + } +}); + +test('AbortSignal properties validate the receiver', () => { + const signalAbortedGet = Object.getOwnPropertyDescriptor( + AbortSignal.prototype, + 'aborted' + ).get; + + const goodSignal = new AbortController().signal; + strictEqual(signalAbortedGet.call(goodSignal), false); + + const badAbortSignals = [ + null, + undefined, + 0, + NaN, + true, + 'AbortSignal', + { __proto__: AbortSignal.prototype }, + ]; + for (const badSignal of badAbortSignals) { + throws( + () => signalAbortedGet.call(badSignal), + { name: 'TypeError' } + ); + } +}); + +test('AbortController inspection depth 1 or null works', () => { + const ac = new AbortController(); + strictEqual(inspect(ac, { depth: 1 }), + 'AbortController { signal: [AbortSignal] }'); + strictEqual(inspect(ac, { depth: null }), + 'AbortController { signal: AbortSignal { aborted: false } }'); +}); + +test('AbortSignal reason is set correctly', () => { + // Test AbortSignal.reason + const ac = new AbortController(); + ac.abort('reason'); + strictEqual(ac.signal.reason, 'reason'); +}); + +test('AbortSignal reasonable is set correctly with AbortSignal.abort()', () => { + // Test AbortSignal.reason + const signal = AbortSignal.abort('reason'); + strictEqual(signal.reason, 'reason'); +}); + +test('AbortSignal.timeout() works as expected', async () => { + // Test AbortSignal timeout + const signal = AbortSignal.timeout(10); + ok(!signal.aborted); + + const { promise, resolve } = Promise.withResolvers(); + + const fn = () => { + ok(signal.aborted); + strictEqual(signal.reason.name, 'TimeoutError'); + strictEqual(signal.reason.code, 23); + resolve(); + }; + + setTimeout(fn, 20); + await promise; +}); + +test('AbortSignal.timeout() does not prevent the signal from being collected', async () => { + // Test AbortSignal timeout doesn't prevent the signal + // from being garbage collected. + let ref; + { + ref = new globalThis.WeakRef(AbortSignal.timeout(1_200_000)); + } + + await sleep(10); + globalThis.gc(); + strictEqual(ref.deref(), undefined); +}); + +test('AbortSignal with a timeout is not collected while there is an active listener', async () => { + // Test that an AbortSignal with a timeout is not gc'd while + // there is an active listener on it. + let ref; + function handler() {} + { + ref = new globalThis.WeakRef(AbortSignal.timeout(1_200_000)); + ref.deref().addEventListener('abort', handler); + } + + await sleep(10); + globalThis.gc(); + notStrictEqual(ref.deref(), undefined); + ok(ref.deref() instanceof AbortSignal); + + ref.deref().removeEventListener('abort', handler); + + await sleep(10); + globalThis.gc(); + strictEqual(ref.deref(), undefined); +}); + +test('Setting a long timeout should not keep the process open', () => { + AbortSignal.timeout(1_200_000); +}); + +test('AbortSignal.reason should default', () => { + // Test AbortSignal.reason default + const signal = AbortSignal.abort(); + ok(signal.reason instanceof DOMException); + strictEqual(signal.reason.code, 20); + + const ac = new AbortController(); + ac.abort(); + ok(ac.signal.reason instanceof DOMException); + strictEqual(ac.signal.reason.code, 20); +}); + +test('abortSignal.throwIfAborted() works as expected', () => { + // Test abortSignal.throwIfAborted() + throws(() => AbortSignal.abort().throwIfAborted(), { + code: 20, + name: 'AbortError', + }); + + // Does not throw because it's not aborted. + const ac = new AbortController(); + ac.signal.throwIfAborted(); +}); + +test('abortSignal.throwIfAobrted() works as expected (2)', () => { + const originalDesc = Reflect.getOwnPropertyDescriptor(AbortSignal.prototype, 'aborted'); + const actualReason = new Error(); + Reflect.defineProperty(AbortSignal.prototype, 'aborted', { value: false }); + throws(() => AbortSignal.abort(actualReason).throwIfAborted(), actualReason); + Reflect.defineProperty(AbortSignal.prototype, 'aborted', originalDesc); +}); + +test('abortSignal.throwIfAobrted() works as expected (3)', () => { + const originalDesc = Reflect.getOwnPropertyDescriptor(AbortSignal.prototype, 'reason'); + const actualReason = new Error(); + const fakeExcuse = new Error(); + Reflect.defineProperty(AbortSignal.prototype, 'reason', { value: fakeExcuse }); + throws(() => AbortSignal.abort(actualReason).throwIfAborted(), actualReason); + Reflect.defineProperty(AbortSignal.prototype, 'reason', originalDesc); +});