From de83b78378d8863bcb1ba9c6f34bfb07db31c4f9 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 5 Oct 2026 12:12:06 -0700 Subject: [PATCH 1/2] fix(runtime): account for ArrayBuffer ownership and external memory --- CHANGELOG.md | 1 + docs/runtime/nodejs-compat.mdx | 4 + src/js/node/v8.ts | 14 +- src/jsc/JSValue.rs | 4 + src/jsc/array_buffer.rs | 29 +- src/jsc/bindings/BunClientData.cpp | 1 - src/jsc/bindings/BunClientData.h | 47 --- src/jsc/bindings/BunProcess.cpp | 18 +- src/jsc/bindings/JSBuffer.cpp | 8 +- src/jsc/bindings/JSBuffer.h | 3 +- src/jsc/bindings/NodeV8.cpp | 41 +- src/jsc/bindings/ScriptExecutionContext.cpp | 24 +- src/jsc/bindings/ScriptExecutionContext.h | 1 + src/jsc/bindings/Uint8Array.cpp | 6 +- src/jsc/bindings/ZigGlobalObject.cpp | 13 +- src/jsc/bindings/sqlite/JSSQLStatement.cpp | 2 +- src/jsc/bindings/webcore/JSWorker.cpp | 9 +- src/jsc/bindings/webcore/MessageEvent.cpp | 6 +- .../webcore/SerializedScriptValue.cpp | 7 +- .../bindings/webcore/SerializedScriptValue.h | 2 +- .../bindings/webcore/WorkerMessagingProxy.cpp | 11 + .../bindings/webcore/WorkerMessagingProxy.h | 2 + src/runtime/api/BunObject.rs | 4 + src/runtime/ffi/FFIObject.rs | 3 + src/runtime/image/Image.rs | 7 +- src/runtime/webcore/Blob.rs | 2 + .../arraybuffer-accounting.fixture.mjs | 391 ++++++++++++++++++ test/js/node/process/process.test.js | 62 +++ test/js/node/v8/v8-serdes-buffer.test.ts | 8 + .../worker_threads/worker_threads.test.ts | 23 +- test/napi/napi.test.ts | 18 + 31 files changed, 664 insertions(+), 107 deletions(-) create mode 100644 test/js/node/process/arraybuffer-accounting.fixture.mjs diff --git a/CHANGELOG.md b/CHANGELOG.md index 0c1f28468834..36cb2d273247 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -207,3 +207,4 @@ - Build Windows ARM64 alongside x64, require Foundation Authenticode signatures before Windows release packaging, and qualify both architectures with the same native compatibility selection. Unsigned CI artifacts are explicitly test-only. - Pin immutable [OpenClaw WebKit `f1e1ca1156`](https://github.com/openclaw/WebKit/releases/tag/autobuild-f1e1ca1156c8cb3b468bec0e1989fbfa08899661) with all ten archive checksums, including Windows ARM64, cached repeated stack coordinates, and the ARM64 allocation-accounting arithmetic fix. +- Account for typed-array and ArrayBuffer payloads before GC, preserve allocation-origin charges across worker transfers, separate external storage from JS heap statistics, and answer worker heap-statistics requests during JavaScript loops and atomic waits. Builds on oven-sh/bun#34406 and oven-sh/WebKit#303; thanks @robobun. diff --git a/docs/runtime/nodejs-compat.mdx b/docs/runtime/nodejs-compat.mdx index 3c53bc1cfa80..7ed1c348389c 100644 --- a/docs/runtime/nodejs-compat.mdx +++ b/docs/runtime/nodejs-compat.mdx @@ -228,6 +228,8 @@ Socket close notifications use internal scheduling and continue to run when user ### [`node:process`](https://nodejs.org/api/process.html) +`process.memoryUsage().arrayBuffers` includes fixed backing storage and typed arrays before GC, including optimized allocations. Transferring a buffer moves its `external` payload charge to the receiving worker while its `arrayBuffers` charge stays with the allocating worker until storage is freed. Resizable buffers contribute their current payload to `external`; shared fixed backing storage contributes only to the allocator's `arrayBuffers`. Shared growable backing storage does not contribute to either field. Unshared WebAssembly memory contributes to `external` at allocation and growth, including before `.buffer` is accessed. External memory also includes native object overhead. Backing storage is excluded from `heapUsed` and `heapTotal`. + `process.platform`, `arch`, `version`, `versions`, `pid`, and `release` expose Node-compatible data descriptors. `argv` and `execArgv` initialize lazily and support descriptor-based replacement. `ppid` and `title` retain their native behavior behind data descriptors. `exitCode` remains a non-configurable accessor. On POSIX, initializing `process.stdout` or `process.stderr` does not retain a duplicate pipe writer. Explicitly closing the underlying descriptor publishes EOF while the process remains alive. On Windows, the initialized pipe handle retains the writer until process exit, matching Node. On POSIX, socket and pipe stdio use nonblocking writes. `end()` and pipeline completion flush pending output and shut down the write side of socket stdio, so peers receive EOF before process exit. Actual pipes stay open until process exit or explicit descriptor closure. `destroy()` preserves fd 1/2; after POSIX socket or pipe finalization, later stream writes fail with `EPIPE`. On Windows, piped stdout/stderr reset their writable state after `end()` or pipeline completion and accept subsequent writes. @@ -296,6 +298,8 @@ Valid `vm.Script` cached data is accepted when JSC runs without a JIT. Validatio `resourceLimits` reports effective heap, nursery, code-range, and stack settings. With the pinned OpenClaw WebKit, managed-heap exhaustion terminates only the affected worker with `ERR_WORKER_OUT_OF_MEMORY` and exit code 1. ArrayBuffer backing stores are excluded. `codeRangeSizeMb` is reporting-only because JSC shares its executable allocator. See [worker resource limits](/runtime/workers#node-worker-resource-limits) for defaults and limitations. +`worker.getHeapStatistics()` takes an owning-thread snapshot while a worker is idle, executing JavaScript, or blocked in `Atomics.wait()`. The snapshot includes freshly allocated external storage. Inspection preserves the atomic wait and pending requests settle if the worker exits. + File workers select `--require` and `--import` preloads from their environment independently of `execArgv`. Explicit `env` or `execArgv` options re-read the worker's `NODE_OPTIONS` and `BUN_OPTIONS` before applying the selected CLI arguments. `execArgv: []` removes CLI preloads while environment preloads still apply. Require preloads run before import preloads. `MessagePort.emit()` and `parentPort.emit()` preserve the emitted value for Node listeners. Web listeners receive `MessageEvent` objects for `message` and `messageerror`, and `CustomEvent` objects for other events. diff --git a/src/js/node/v8.ts b/src/js/node/v8.ts index 27da92d8d016..7907365abbfc 100644 --- a/src/js/node/v8.ts +++ b/src/js/node/v8.ts @@ -10,6 +10,8 @@ const { stopGCProfiler, discardGCProfiler, getHeapUsage, + serialize: serializeNative, + bufferEnvelopeMagic: kBufferEnvelopeMagic, queryObjects: queryHeapObjects, } = $cpp("NodeV8.cpp", "Bun::createNodeV8Binding"); @@ -41,7 +43,6 @@ const FunctionPrototypeCall = Function.prototype.call; const uncurryThis = func => FunctionPrototypeCall.bind(func); const ObjectGetOwnPropertyDescriptor = Object.getOwnPropertyDescriptor; const Uint8ArrayCtor = Uint8Array; -const BufferAllocUnsafe = Buffer.allocUnsafe; const TypedArrayProto = Object.getPrototypeOf(Uint8ArrayCtor.prototype); const TypedArrayPrototypeGetBuffer = uncurryThis(ObjectGetOwnPropertyDescriptor(TypedArrayProto, "buffer")!.get); const TypedArrayPrototypeGetByteOffset = uncurryThis( @@ -50,7 +51,6 @@ const TypedArrayPrototypeGetByteOffset = uncurryThis( const TypedArrayPrototypeGetByteLength = uncurryThis( ObjectGetOwnPropertyDescriptor(TypedArrayProto, "byteLength")!.get, ); -const TypedArrayPrototypeSet = uncurryThis(TypedArrayProto.set); const DataViewPrototypeGetBuffer = uncurryThis(ObjectGetOwnPropertyDescriptor(DataView.prototype, "buffer")!.get); const DataViewPrototypeGetByteOffset = uncurryThis( ObjectGetOwnPropertyDescriptor(DataView.prototype, "byteOffset")!.get, @@ -299,7 +299,6 @@ function getCppHeapStatistics(type = "detailed") { // Buffer-bearing payloads are framed as MAGIC + version + SSV([value, buffers]) so deserialize // can restore Buffer prototypes (see internal/serialization_buffers). Leading 0xFF cannot collide // with bare SSV output; Buffer-free payloads stay bare SSV so older readers keep working. -const kBufferEnvelopeMagic = [0xff, 0x42, 0x55, 0x4e, 0x01]; // 0xFF "BUN" v1 function hasBufferEnvelopeMagic(view) { // In-bounds integer-indexed reads on a typed array never consult the @@ -347,14 +346,7 @@ function stopCoverage() { } function serialize(arg1) { const tagged = require("internal/serialization_buffers").tagBuffers(arg1); - if (tagged === null) { - return jsc.serialize(arg1, { binaryType: "nodebuffer" }); - } - const payload = jsc.serialize(tagged, { binaryType: "nodebuffer" }); - const framed = BufferAllocUnsafe(kBufferEnvelopeMagic.length + TypedArrayPrototypeGetByteLength(payload)); - for (let i = 0; i < kBufferEnvelopeMagic.length; i++) framed[i] = kBufferEnvelopeMagic[i]; - TypedArrayPrototypeSet(framed, payload, kBufferEnvelopeMagic.length); - return framed; + return serializeNative(tagged === null ? arg1 : tagged, tagged !== null); } function getDefaultHeapSnapshotPath() { diff --git a/src/jsc/JSValue.rs b/src/jsc/JSValue.rs index 70c5c377612d..a45f34bbe4a8 100644 --- a/src/jsc/JSValue.rs +++ b/src/jsc/JSValue.rs @@ -592,6 +592,7 @@ impl JSValue { } else { Some(MarkedArrayBuffer_deallocator) }, + crate::array_buffer::ArrayBufferAllocationMode::RuntimeOwned, ) }) } @@ -619,6 +620,7 @@ impl JSValue { } else { Some(MarkedArrayBuffer_deallocator) }, + crate::array_buffer::ArrayBufferAllocationMode::RuntimeOwned, ) }) } @@ -646,6 +648,7 @@ impl JSValue { len, ctx, Some(free), + crate::array_buffer::ArrayBufferAllocationMode::RuntimeOwned, ) }) } @@ -2005,6 +2008,7 @@ unsafe extern "C" { len: usize, ctx: *mut c_void, deallocator: Option, + allocation: crate::array_buffer::ArrayBufferAllocationMode, ) -> JSValue; safe fn JSBuffer__bufferFromLength(global: &JSGlobalObject, len: i64) -> JSValue; safe fn JSC__JSValue__dateInstanceFromNumber(global: &JSGlobalObject, n: f64) -> JSValue; diff --git a/src/jsc/array_buffer.rs b/src/jsc/array_buffer.rs index 149d4671e90c..233adbb0feb0 100644 --- a/src/jsc/array_buffer.rs +++ b/src/jsc/array_buffer.rs @@ -13,6 +13,14 @@ bun_core::declare_scope!(ArrayBuffer, visible); /// when a zero-copy ArrayBuffer/typed array backing store is collected. pub type JSTypedArrayBytesDeallocator = Option; +/// Matches JSC::ArrayBufferAllocationMode. Foreign storage does not acquire an allocator-origin charge. +#[repr(u8)] +#[derive(Clone, Copy)] +pub enum ArrayBufferAllocationMode { + External = 0, + RuntimeOwned = 1, +} + // ────────────────────────────────────────────────────────────────────────── // ArrayBuffer // ────────────────────────────────────────────────────────────────────────── @@ -115,6 +123,7 @@ unsafe extern "C" { len: usize, dealloc: JSTypedArrayBytesDeallocator, ctx: *mut c_void, + allocation: ArrayBufferAllocationMode, ) -> JSValue; fn Bun__makeTypedArrayWithBytesNoCopy( global: &JSGlobalObject, @@ -123,6 +132,7 @@ unsafe extern "C" { len: usize, dealloc: JSTypedArrayBytesDeallocator, ctx: *mut c_void, + allocation: ArrayBufferAllocationMode, ) -> JSValue; fn Bun__createTypedArrayForCopy( global: *const JSGlobalObject, @@ -459,6 +469,7 @@ impl ArrayBuffer { // The deallocator ignores its ctx (mi_free needs no ctx). Any non-null // sentinel would do; pass the data ptr itself. self.ptr.cast(), + ArrayBufferAllocationMode::RuntimeOwned, ) }; } @@ -472,6 +483,7 @@ impl ArrayBuffer { self.byte_len, Some(MarkedArrayBuffer_deallocator), self.ptr.cast(), + ArrayBufferAllocationMode::RuntimeOwned, ) } } @@ -504,6 +516,7 @@ impl ArrayBuffer { self.byte_len, None, ptr::null_mut(), + ArrayBufferAllocationMode::External, ) }; } @@ -517,6 +530,7 @@ impl ArrayBuffer { self.byte_len, None, ptr::null_mut(), + ArrayBufferAllocationMode::External, ) }; } @@ -542,6 +556,7 @@ impl ArrayBuffer { ctx: &JSGlobalObject, deallocator: *mut c_void, callback: JSTypedArrayBytesDeallocator, + allocation: ArrayBufferAllocationMode, ) -> JsResult { if !self.value.is_empty() { return Ok(self.value); @@ -557,6 +572,7 @@ impl ArrayBuffer { self.byte_len, callback, deallocator, + allocation, ) }; } @@ -570,6 +586,7 @@ impl ArrayBuffer { self.byte_len, callback, deallocator, + allocation, ) } } @@ -1017,12 +1034,20 @@ pub(crate) unsafe fn make_array_buffer_with_bytes_no_copy( len: usize, deallocator: JSTypedArrayBytesDeallocator, deallocator_context: *mut c_void, + allocation: ArrayBufferAllocationMode, ) -> JsResult { crate::host_fn::from_js_host_call(global, || { // SAFETY: forwarded verbatim; the caller upholds this function's // contract (`ptr` valid for `len` bytes until `deallocator` runs). unsafe { - Bun__makeArrayBufferWithBytesNoCopy(global, ptr, len, deallocator, deallocator_context) + Bun__makeArrayBufferWithBytesNoCopy( + global, + ptr, + len, + deallocator, + deallocator_context, + allocation, + ) } }) } @@ -1042,6 +1067,7 @@ pub unsafe fn make_typed_array_with_bytes_no_copy( len: usize, deallocator: JSTypedArrayBytesDeallocator, deallocator_context: *mut c_void, + allocation: ArrayBufferAllocationMode, ) -> JsResult { crate::host_fn::from_js_host_call(global, || { // SAFETY: forwarded verbatim; the caller upholds this function's @@ -1054,6 +1080,7 @@ pub unsafe fn make_typed_array_with_bytes_no_copy( len, deallocator, deallocator_context, + allocation, ) } }) diff --git a/src/jsc/bindings/BunClientData.cpp b/src/jsc/bindings/BunClientData.cpp index d56add189a02..915a1de9c49d 100644 --- a/src/jsc/bindings/BunClientData.cpp +++ b/src/jsc/bindings/BunClientData.cpp @@ -63,7 +63,6 @@ JSVMClientData::JSVMClientData(VM& vm, RefPtr sourceProvide , CLIENT_ISO_SUBSPACE_INIT(m_domConstructorSpace) , CLIENT_ISO_SUBSPACE_INIT(m_domNamespaceObjectSpace) , m_clientSubspaces(makeUnique()) - , m_heapUsage(vm.heap) { } diff --git a/src/jsc/bindings/BunClientData.h b/src/jsc/bindings/BunClientData.h index fd9e452ecd4f..63e1a03f965b 100644 --- a/src/jsc/bindings/BunClientData.h +++ b/src/jsc/bindings/BunClientData.h @@ -76,7 +76,6 @@ class DOMWrapperWorld; #include "NodeVMOptionNames.h" #include "NodeVMSourceOriginCache.h" #include "NodeVMCompilationCache.h" -#include namespace Zig { class GlobalObject; } @@ -84,47 +83,6 @@ class GlobalObject; namespace Bun { class StrongRootBlock; -// Add allocations since the last collection to its survivor size. Heap::size() -// counts mark bits and misses newly allocated cells and array backing storage. -class HeapUsage final : public JSC::HeapObserver { - WTF_MAKE_NONCOPYABLE(HeapUsage); - -public: - explicit HeapUsage(JSC::Heap& heap) - : m_heap(heap) - { - m_heap.addObserver(this); - } - - ~HeapUsage() final - { - m_heap.removeObserver(this); - } - - size_t get() const - { - return m_sizeAfterLastCollection + (m_heap.totalBytesAllocated() - m_allocatedAtLastCollection); - } - -private: - void willGarbageCollect() final {} - - // Heap::didFinishCollection() notifies observers after updateAllocationLimits() - // stored this collection's size, in the end phase of the collection, while - // the mutator is stopped. The mutator reads m_sizeAfterLastCollection once - // it resumes, the same way it reads JSC's own counters. - void didGarbageCollect(JSC::CollectionScope scope) final - { - m_sizeAfterLastCollection = scope == JSC::CollectionScope::Full - ? m_heap.sizeAfterLastFullCollection() - : m_heap.sizeAfterLastEdenCollection(); - m_allocatedAtLastCollection = m_heap.totalBytesAllocated(); - } - - JSC::Heap& m_heap; - size_t m_sizeAfterLastCollection { 0 }; - uint64_t m_allocatedAtLastCollection { 0 }; -}; } namespace JSC { @@ -249,9 +207,6 @@ class JSVMClientData : public JSC::VM::ClientData { Bun::NodeVMOptionNames& nodeVMOptionNames() { return m_nodeVMOptionNames; } Bun::NodeVMSourceOriginCache& nodeVMSourceOriginCache() { return m_nodeVMSourceOriginCache; } - // Survivor size plus allocations since the last collection; mutator thread only. - size_t heapUsage() const { return m_heapUsage.get(); } - // The VM's default (first) Zig::GlobalObject: what defaultGlobalObject(JSC::VM&) returns on threads whose thread-local // default is not this VM's, e.g. the collector thread running a collection's end phase. gcProtect'ed for the VM's life. JSC::JSGlobalObject* defaultGlobalObject { nullptr }; @@ -351,8 +306,6 @@ class JSVMClientData : public JSC::VM::ClientData { Bun::NodeVMCompilationCache nodeVMCompilationCache; private: - Bun::HeapUsage m_heapUsage; - SentinelLinkedList> m_clients; bool m_isWorkerVM { false }; bool m_isNodeWorkerVM { false }; diff --git a/src/jsc/bindings/BunProcess.cpp b/src/jsc/bindings/BunProcess.cpp index 3dec12b1195f..0faab26585ef 100644 --- a/src/jsc/bindings/BunProcess.cpp +++ b/src/jsc/bindings/BunProcess.cpp @@ -4198,26 +4198,14 @@ JSC_DEFINE_HOST_FUNCTION(Process_functionMemoryUsage, (JSC::JSGlobalObject * glo // arrayBuffers: 9386 // } - size_t heapUsed = WebCore::clientData(vm)->heapUsage(); - size_t heapTotal = std::max(vm.heap.capacity(), heapUsed); + size_t heapUsed = vm.heap.jsHeapSizeForReporting(); + size_t heapTotal = vm.heap.jsHeapCapacityForReporting(); result->putDirectOffset(vm, 0, JSC::jsNumber(current_rss)); result->putDirectOffset(vm, 1, JSC::jsNumber(heapTotal)); result->putDirectOffset(vm, 2, JSC::jsNumber(heapUsed)); - result->putDirectOffset(vm, 3, JSC::jsNumber(vm.heap.extraMemorySize() + vm.heap.externalMemorySize())); - - // JSC won't count this number until vm.heap.addReference() is called. - // That will only happen in cases like: - // - new ArrayBuffer() - // - new Uint8Array(42).buffer - // - fs.readFile(path, "utf-8") (sometimes) - // - ... - // - // But it won't happen in cases like: - // - new Uint8Array(42) - // - Buffer.alloc(42) - // - new Uint8Array(42).slice() + result->putDirectOffset(vm, 3, JSC::jsNumber(vm.heap.externalMemorySizeForReporting())); result->putDirectOffset(vm, 4, JSC::jsNumber(vm.heap.arrayBufferSize())); RELEASE_AND_RETURN(throwScope, JSC::JSValue::encode(result)); diff --git a/src/jsc/bindings/JSBuffer.cpp b/src/jsc/bindings/JSBuffer.cpp index 29fb49bb4002..46cff2cda62b 100644 --- a/src/jsc/bindings/JSBuffer.cpp +++ b/src/jsc/bindings/JSBuffer.cpp @@ -414,7 +414,7 @@ bool Bun::rejectBytesNoCopyAboveArrayBufferLimit(JSC::JSGlobalObject* globalObje return true; } -JSC::EncodedJSValue JSBuffer__bufferFromPointerAndLengthAndDeinit(JSC::JSGlobalObject* lexicalGlobalObject, char* ptr, size_t length, void* ctx, JSTypedArrayBytesDeallocator bytesDeallocator) +JSC::EncodedJSValue JSBuffer__bufferFromPointerAndLengthAndDeinit(JSC::JSGlobalObject* lexicalGlobalObject, char* ptr, size_t length, void* ctx, JSTypedArrayBytesDeallocator bytesDeallocator, JSC::ArrayBufferAllocationMode allocationMode) { JSC::JSUint8Array* uint8Array = nullptr; @@ -429,7 +429,8 @@ JSC::EncodedJSValue JSBuffer__bufferFromPointerAndLengthAndDeinit(JSC::JSGlobalO auto buffer = ArrayBuffer::createFromBytes({ reinterpret_cast(ptr), length }, createSharedTask([=](void* p) { bytesDeallocator(p, ctx); - })); + }), + allocationMode); uint8Array = JSC::JSUint8Array::create(lexicalGlobalObject, subclassStructure, WTF::move(buffer), 0, length); } else { @@ -2641,7 +2642,8 @@ extern "C" JSC::EncodedJSValue JSBuffer__fromMmap(Zig::GlobalObject* globalObjec auto buffer = ArrayBuffer::createFromBytes({ static_cast(ptr), length }, createSharedTask([lengthAsContext](void* p) { unmapBufferBytes(p, lengthAsContext); - })); + }), + JSC::ArrayBufferAllocationMode::RuntimeOwned); auto* view = JSC::JSUint8Array::create(globalObject, structure, WTF::move(buffer), 0, length); RETURN_IF_EXCEPTION(scope, {}); diff --git a/src/jsc/bindings/JSBuffer.h b/src/jsc/bindings/JSBuffer.h index 2104d8a50a35..8b783747b624 100644 --- a/src/jsc/bindings/JSBuffer.h +++ b/src/jsc/bindings/JSBuffer.h @@ -23,6 +23,7 @@ #include "root.h" #include +#include #include #include #include @@ -31,7 +32,7 @@ #include "headers-handwritten.h" extern "C" JSC::EncodedJSValue JSBuffer__bufferFromLength(JSC::JSGlobalObject* lexicalGlobalObject, int64_t length); -extern "C" JSC::EncodedJSValue JSBuffer__bufferFromPointerAndLengthAndDeinit(JSC::JSGlobalObject* lexicalGlobalObject, char* ptr, size_t length, void* ctx, JSTypedArrayBytesDeallocator bytesDeallocator); +extern "C" JSC::EncodedJSValue JSBuffer__bufferFromPointerAndLengthAndDeinit(JSC::JSGlobalObject* lexicalGlobalObject, char* ptr, size_t length, void* ctx, JSTypedArrayBytesDeallocator bytesDeallocator, JSC::ArrayBufferAllocationMode); extern "C" JSC::EncodedJSValue Bun__encoding__toString(const uint8_t* input, size_t len, JSC::JSGlobalObject* globalObject, Encoding encoding); extern "C" JSC::EncodedJSValue Bun__encoding__toStringUTF8(const uint8_t* input, size_t len, JSC::JSGlobalObject* globalObject); extern "C" bool Bun__Buffer_fill(EncodedSlice*, void*, size_t, WebCore::BufferEncodingType); diff --git a/src/jsc/bindings/NodeV8.cpp b/src/jsc/bindings/NodeV8.cpp index f714bf4a05c1..d5a75f2faec9 100644 --- a/src/jsc/bindings/NodeV8.cpp +++ b/src/jsc/bindings/NodeV8.cpp @@ -5,11 +5,14 @@ #include "ErrorCode.h" #include "BunClientData.h" #include "NodeV8.h" +#include "MessagePort.h" +#include "SerializedScriptValue.h" #include "ZigGlobalObject.h" #include #include #include +#include #include #include #include @@ -17,21 +20,44 @@ #include #include #include +#include namespace Bun { using namespace JSC; +static constexpr std::array bufferEnvelopeMagic { 0xff, 0x42, 0x55, 0x4e, 0x01 }; + +JSC_DEFINE_HOST_FUNCTION(functionSerializeForNode, (JSGlobalObject * globalObject, CallFrame* callFrame)) +{ + auto& vm = globalObject->vm(); + auto scope = DECLARE_THROW_SCOPE(vm); + auto* domGlobal = uncheckedDowncast(globalObject); + Vector> transfers; + Vector> ports; + auto result = WebCore::SerializedScriptValue::create(*domGlobal, callFrame->argument(0), WTF::move(transfers), ports, WebCore::SerializationForStorage::Yes); + EXCEPTION_ASSERT(result.hasException() == !!scope.exception()); + if (result.hasException()) { + WebCore::propagateException(*domGlobal, scope, result.releaseException()); + RELEASE_AND_RETURN(scope, {}); + } + auto serialized = result.releaseReturnValue(); + auto prefix = callFrame->argument(1).asBoolean() ? std::span(bufferEnvelopeMagic) : std::span(); + auto buffer = serialized->toArrayBuffer(ArrayBufferSharingMode::Default, prefix); + size_t length = buffer->byteLength(); + RELEASE_AND_RETURN(scope, JSValue::encode(JSUint8Array::create(globalObject, domGlobal->JSBufferSubclassStructure(), WTF::move(buffer), 0, length))); +} + JSC_DEFINE_HOST_FUNCTION(functionGetHeapUsage, (JSGlobalObject * globalObject, CallFrame*)) { auto& vm = globalObject->vm(); auto scope = DECLARE_THROW_SCOPE(vm); - size_t used = WebCore::clientData(vm)->heapUsage(); - size_t capacity = std::max(vm.heap.capacity(), used); + size_t used = vm.heap.jsHeapSizeForReporting(); + size_t capacity = vm.heap.jsHeapCapacityForReporting(); JSObject* result = constructEmptyObject(globalObject); result->putDirect(vm, Identifier::fromString(vm, "heapSize"_s), jsNumber(used)); result->putDirect(vm, Identifier::fromString(vm, "heapCapacity"_s), jsNumber(capacity)); - result->putDirect(vm, Identifier::fromString(vm, "extraMemorySize"_s), jsNumber(vm.heap.extraMemorySize() + vm.heap.externalMemorySize())); + result->putDirect(vm, Identifier::fromString(vm, "extraMemorySize"_s), jsNumber(vm.heap.externalMemorySizeForReporting())); result->putDirect(vm, Identifier::fromString(vm, "globalObjectCount"_s), jsNumber(vm.heap.globalObjectCount())); RELEASE_AND_RETURN(scope, JSValue::encode(result)); } @@ -156,7 +182,16 @@ JSC_DEFINE_HOST_FUNCTION(functionStopGCProfiler, (JSGlobalObject * globalObject, JSC::JSObject* createNodeV8Binding(JSC::JSGlobalObject* globalObject) { auto& vm = JSC::getVM(globalObject); + auto scope = DECLARE_THROW_SCOPE(vm); JSC::JSObject* object = JSC::constructEmptyObject(vm, globalObject->nullPrototypeObjectStructure()); + auto* magic = constructEmptyArray(globalObject, nullptr, bufferEnvelopeMagic.size()); + RETURN_IF_EXCEPTION(scope, nullptr); + for (unsigned i = 0; i < bufferEnvelopeMagic.size(); ++i) { + magic->putDirectIndex(globalObject, i, jsNumber(bufferEnvelopeMagic[i])); + RETURN_IF_EXCEPTION(scope, nullptr); + } + object->putDirect(vm, Identifier::fromString(vm, "bufferEnvelopeMagic"_s), magic); + object->putDirectNativeFunction(vm, globalObject, Identifier::fromString(vm, "serialize"_s), 2, functionSerializeForNode, ImplementationVisibility::Public, NoIntrinsic, 0); object->putDirectNativeFunction(vm, globalObject, JSC::Identifier::fromString(vm, "queryObjects"_s), 1, functionQueryObjects, ImplementationVisibility::Public, JSC::NoIntrinsic, 0); object->putDirectNativeFunction(vm, globalObject, JSC::Identifier::fromString(vm, "getHeapUsage"_s), 0, functionGetHeapUsage, ImplementationVisibility::Public, JSC::NoIntrinsic, 0); object->putDirectNativeFunction(vm, globalObject, JSC::Identifier::fromString(vm, "isStringOneByteRepresentation"_s), 1, functionIsStringOneByteRepresentation, ImplementationVisibility::Public, JSC::NoIntrinsic, 0); diff --git a/src/jsc/bindings/ScriptExecutionContext.cpp b/src/jsc/bindings/ScriptExecutionContext.cpp index d5ee798b5f45..43a1527d1e63 100644 --- a/src/jsc/bindings/ScriptExecutionContext.cpp +++ b/src/jsc/bindings/ScriptExecutionContext.cpp @@ -303,6 +303,24 @@ bool ScriptExecutionContext::postTaskTo(ScriptExecutionContextIdentifier identif return true; } +bool ScriptExecutionContext::postVMInspection(ScriptExecutionContextIdentifier identifier, Function&& inspection) +{ + const BunVmHandleRef* retained = nullptr; + { + Locker locker { allScriptExecutionContextsMapLock }; + auto* context = allScriptExecutionContextsMap().get(identifier); + if (!context || context->isTerminating()) + return false; + context->m_vm->traps().requestVMInspection(WTF::move(inspection)); + retained = Bun__VmHandle__retainRef(context->m_vmHandle); + } + Bun__VmHandle__postAndRelease(retained, new EventLoopTask([](ScriptExecutionContext& context) { + context.vm().traps().runVMInspections(); + }), + BunLoopKind::Regular); + return true; +} + void ScriptExecutionContext::didCreateDestructionObserver(ContextDestructionObserver& observer) { #if ASSERT_ENABLED @@ -444,10 +462,8 @@ void ScriptExecutionContext::removeFromContextsMap() void ScriptExecutionContext::markTerminating() { - // An early-out for postTaskTo(): from here posts to this context are pointless. Not - // a fence — a poster that looked us up just before this still posts, and the VM - // handle deals with it (queued and released unrun by the teardown, or refused and - // deleted once the handle is closed). + // Fence VM-inspection registration before the owning thread starts tearing down JSC. + Locker locker { allScriptExecutionContextsMapLock }; m_isTerminating.store(true, std::memory_order_release); } diff --git a/src/jsc/bindings/ScriptExecutionContext.h b/src/jsc/bindings/ScriptExecutionContext.h index 7c7b4de123d8..7519df4376fc 100644 --- a/src/jsc/bindings/ScriptExecutionContext.h +++ b/src/jsc/bindings/ScriptExecutionContext.h @@ -131,6 +131,7 @@ class ScriptExecutionContext : public CanMakeWeakPtr, pu // target's thread when the work whose completion this is was initiated, or Regular for work no // script there initiated. WEBCORE_EXPORT static bool postTaskTo(ScriptExecutionContextIdentifier identifier, BunLoopKind loopKind, Function&& task); + static bool postVMInspection(ScriptExecutionContextIdentifier, Function&&); WEBCORE_EXPORT static bool ensureOnContextThread(ScriptExecutionContextIdentifier, Function&& task); WEBCORE_EXPORT JSC::JSGlobalObject* globalObject(); diff --git a/src/jsc/bindings/Uint8Array.cpp b/src/jsc/bindings/Uint8Array.cpp index 5b4171ab606f..19b47bb51b0d 100644 --- a/src/jsc/bindings/Uint8Array.cpp +++ b/src/jsc/bindings/Uint8Array.cpp @@ -24,7 +24,8 @@ extern "C" JSC::EncodedJSValue JSUint8Array__fromDefaultAllocator(JSC::JSGlobalO auto buffer = ArrayBuffer::createFromBytes({ ptr, length }, createSharedTask([](void* p) { freeDefaultAllocatorBytes(p, nullptr); - })); + }), + JSC::ArrayBufferAllocationMode::RuntimeOwned); uint8Array = JSC::JSUint8Array::create(lexicalGlobalObject, lexicalGlobalObject->typedArrayStructureWithTypedArrayType(), WTF::move(buffer), 0, length); } else { @@ -47,7 +48,8 @@ extern "C" JSC::EncodedJSValue JSArrayBuffer__fromDefaultAllocator(JSC::JSGlobal buffer = ArrayBuffer::createFromBytes({ ptr, length }, createSharedTask([](void* p) { freeDefaultAllocatorBytes(p, nullptr); - })); + }), + JSC::ArrayBufferAllocationMode::RuntimeOwned); } else { buffer = ArrayBuffer::create(0, 1); } diff --git a/src/jsc/bindings/ZigGlobalObject.cpp b/src/jsc/bindings/ZigGlobalObject.cpp index 0036edda3316..ae18bf0faa05 100644 --- a/src/jsc/bindings/ZigGlobalObject.cpp +++ b/src/jsc/bindings/ZigGlobalObject.cpp @@ -1500,7 +1500,8 @@ extern "C" JSC::EncodedJSValue ArrayBuffer__fromSharedMemfd(int64_t fd, JSC::JSG auto buffer = ArrayBuffer::createFromBytes({ reinterpret_cast(reinterpret_cast(ptr) + byteOffset), byteLength }, createSharedTask([ptr, totalLength](void* p) { munmap(ptr, totalLength); - })); + }), + JSC::ArrayBufferAllocationMode::RuntimeOwned); if (type == JSC::Uint8ArrayType) { auto uint8array = JSC::JSUint8Array::create(globalObject, globalObject->m_typedArrayUint8.get(globalObject), WTF::move(buffer), 0, byteLength); @@ -1582,7 +1583,7 @@ extern "C" JSC::EncodedJSValue Bun__createUint8ArrayForCopy(JSC::JSGlobalObject* RELEASE_AND_RETURN(scope, JSValue::encode(array)); } -extern "C" JSC::EncodedJSValue Bun__makeArrayBufferWithBytesNoCopy(JSC::JSGlobalObject* globalObject, const void* ptr, size_t len, JSTypedArrayBytesDeallocator deallocator, void* deallocatorContext) +extern "C" JSC::EncodedJSValue Bun__makeArrayBufferWithBytesNoCopy(JSC::JSGlobalObject* globalObject, const void* ptr, size_t len, JSTypedArrayBytesDeallocator deallocator, void* deallocatorContext, JSC::ArrayBufferAllocationMode allocationMode) { auto& vm = JSC::getVM(globalObject); auto scope = DECLARE_THROW_SCOPE(vm); @@ -1592,14 +1593,15 @@ extern "C" JSC::EncodedJSValue Bun__makeArrayBufferWithBytesNoCopy(JSC::JSGlobal auto buffer = ArrayBuffer::createFromBytes({ static_cast(ptr), len }, createSharedTask([=](void* p) { if (deallocator) deallocator(p, deallocatorContext); - })); + }), + allocationMode); JSArrayBuffer* jsBuffer = JSArrayBuffer::create(vm, globalObject->arrayBufferStructure(ArrayBufferSharingMode::Default), WTF::move(buffer)); RETURN_IF_EXCEPTION(scope, {}); return JSValue::encode(jsBuffer); } -extern "C" JSC::EncodedJSValue Bun__makeTypedArrayWithBytesNoCopy(JSC::JSGlobalObject* globalObject, TypedArrayType ty, const void* ptr, size_t len, JSTypedArrayBytesDeallocator deallocator, void* deallocatorContext) +extern "C" JSC::EncodedJSValue Bun__makeTypedArrayWithBytesNoCopy(JSC::JSGlobalObject* globalObject, TypedArrayType ty, const void* ptr, size_t len, JSTypedArrayBytesDeallocator deallocator, void* deallocatorContext, JSC::ArrayBufferAllocationMode allocationMode) { auto& vm = JSC::getVM(globalObject); auto scope = DECLARE_THROW_SCOPE(vm); @@ -1609,7 +1611,8 @@ extern "C" JSC::EncodedJSValue Bun__makeTypedArrayWithBytesNoCopy(JSC::JSGlobalO auto buffer_ = ArrayBuffer::createFromBytes({ static_cast(ptr), len }, createSharedTask([=](void* p) { if (deallocator) deallocator(p, deallocatorContext); - })); + }), + allocationMode); RefPtr&& buffer = WTF::move(buffer_); if (!buffer) { throwOutOfMemoryError(globalObject, scope); diff --git a/src/jsc/bindings/sqlite/JSSQLStatement.cpp b/src/jsc/bindings/sqlite/JSSQLStatement.cpp index 949228d753d6..e75aa96de658 100644 --- a/src/jsc/bindings/sqlite/JSSQLStatement.cpp +++ b/src/jsc/bindings/sqlite/JSSQLStatement.cpp @@ -1404,7 +1404,7 @@ JSC_DEFINE_HOST_FUNCTION(jsSQLStatementSerialize, (JSC::JSGlobalObject * lexical return {}; } - RELEASE_AND_RETURN(scope, JSBuffer__bufferFromPointerAndLengthAndDeinit(lexicalGlobalObject, reinterpret_cast(data), static_cast(length), NULL, sqlite_free_typed_array)); + RELEASE_AND_RETURN(scope, JSBuffer__bufferFromPointerAndLengthAndDeinit(lexicalGlobalObject, reinterpret_cast(data), static_cast(length), NULL, sqlite_free_typed_array, JSC::ArrayBufferAllocationMode::External)); } JSC_DEFINE_HOST_FUNCTION(jsSQLStatementLoadExtensionFunction, (JSC::JSGlobalObject * lexicalGlobalObject, JSC::CallFrame* callFrame)) diff --git a/src/jsc/bindings/webcore/JSWorker.cpp b/src/jsc/bindings/webcore/JSWorker.cpp index 46fc2a25f2ac..e4dcae7cdbe4 100644 --- a/src/jsc/bindings/webcore/JSWorker.cpp +++ b/src/jsc/bindings/webcore/JSWorker.cpp @@ -1095,11 +1095,10 @@ static inline JSC::EncodedJSValue jsWorkerPrototypeFunction_getHeapStatisticsBod uint64_t reqId = worker.contextProxy().registerCrossVMRequest(vm, promise); auto parentId = globalObject->scriptExecutionContext()->identifier(); auto parentLoopKind = globalObject->scriptExecutionContext()->currentLoopKind(); - bool accepted = worker.contextProxy().postTaskToWorkerGlobalScope([reqId, parentId, parentLoopKind, protectedProxy = Ref { worker.contextProxy() }](ScriptExecutionContext& workerCtx) mutable { - auto& wvm = workerCtx.vm(); - double heapSize = static_cast(WebCore::clientData(wvm)->heapUsage()); - double capacity = std::max(static_cast(wvm.heap.capacity()), heapSize); - double extra = static_cast(wvm.heap.extraMemorySize() + wvm.heap.externalMemorySize()); + bool accepted = worker.contextProxy().postVMInspection([reqId, parentId, parentLoopKind, protectedProxy = Ref { worker.contextProxy() }](JSC::VM& wvm) mutable { + double heapSize = static_cast(wvm.heap.jsHeapSizeForReporting()); + double capacity = static_cast(wvm.heap.jsHeapCapacityForReporting()); + double extra = static_cast(wvm.heap.externalMemorySizeForReporting()); ScriptExecutionContext::postTaskTo(parentId, parentLoopKind, [reqId, protectedProxy = WTF::move(protectedProxy), heapSize, capacity, extra](ScriptExecutionContext& parentCtx) { resolveCrossVMRequest(protectedProxy.get(), reqId, parentCtx, [&](VM& pvm, JSGlobalObject* go) -> JSValue { JSObject* o = constructEmptyObject(go); diff --git a/src/jsc/bindings/webcore/MessageEvent.cpp b/src/jsc/bindings/webcore/MessageEvent.cpp index 7847f3bc2bf5..f2aa806277a3 100644 --- a/src/jsc/bindings/webcore/MessageEvent.cpp +++ b/src/jsc/bindings/webcore/MessageEvent.cpp @@ -94,11 +94,11 @@ auto MessageEvent::create(JSC::JSGlobalObject& globalObject, Ref strongData(vm, deserialized); - auto event = adoptRef(*new MessageEvent(eventNames().messageEvent, WTF::move(data), origin, lastEventId, WTF::move(source), WTF::move(ports))); + auto event = adoptRef(*new MessageEvent(eventNames().messageEvent, JSValueTag {}, origin, lastEventId, WTF::move(source), WTF::move(ports))); JSC::Strong strongWrapper(vm, uncheckedDowncast(toJS(&globalObject, uncheckedDowncast(&globalObject), event.get()))); RETURN_IF_EXCEPTION(scope, std::nullopt); - // Since we've already deserialized the SerializedScriptValue, cache the result so we don't have to deserialize - // again the next time JSMessageEvent::data() gets called by the main world. + // Keep the decoded value, not the transport storage and its pre-transfer memory estimate. + event->jsData().set(vm, strongWrapper.get(), deserialized); event->cachedData().set(vm, strongWrapper.get(), deserialized); return MessageEventWithStrongData { event, WTF::move(strongWrapper) }; diff --git a/src/jsc/bindings/webcore/SerializedScriptValue.cpp b/src/jsc/bindings/webcore/SerializedScriptValue.cpp index 4a9e48b8f89b..56138416ee02 100644 --- a/src/jsc/bindings/webcore/SerializedScriptValue.cpp +++ b/src/jsc/bindings/webcore/SerializedScriptValue.cpp @@ -4848,8 +4848,10 @@ Ref SerializedScriptValue::createDoubleArrayFastPath(Vect return adoptRef(*new SerializedScriptValue(WTF::move(data), length, FastPath::DoubleArray)); } -Ref SerializedScriptValue::toArrayBuffer() +Ref SerializedScriptValue::toArrayBuffer(JSC::ArrayBufferSharingMode sharingMode, std::span prefix) { + // Framing is applied before this serialization's backing storage is exposed. + m_data.insertSpan(0, prefix); if (this->m_data.size() == 0) { return ArrayBuffer::create(static_cast(0), static_cast(1)); } @@ -4861,7 +4863,8 @@ Ref SerializedScriptValue::toArrayBuffer() })); // Note: using the SharedArrayBufferContents::create function directly didn't work. - arrayBuffer->makeShared(); + if (sharingMode == JSC::ArrayBufferSharingMode::Shared) + arrayBuffer->makeShared(); return arrayBuffer; } diff --git a/src/jsc/bindings/webcore/SerializedScriptValue.h b/src/jsc/bindings/webcore/SerializedScriptValue.h index 7f3665aa3d96..38a38b9cc6c2 100644 --- a/src/jsc/bindings/webcore/SerializedScriptValue.h +++ b/src/jsc/bindings/webcore/SerializedScriptValue.h @@ -139,7 +139,7 @@ class SerializedScriptValue : public ThreadSafeRefCounted JSC::JSValue deserialize(JSC::JSGlobalObject&, JSC::JSGlobalObject*, const Vector>&, const Vector& blobURLs, const Vector& blobFilePaths, SerializationErrorMode = SerializationErrorMode::Throwing, bool* didFail = nullptr); - WEBCORE_EXPORT Ref toArrayBuffer(); + WEBCORE_EXPORT Ref toArrayBuffer(JSC::ArrayBufferSharingMode = JSC::ArrayBufferSharingMode::Shared, std::span prefix = {}); static JSC::JSValue fromArrayBuffer(JSC::JSGlobalObject&, JSC::JSGlobalObject*, JSC::ArrayBuffer* arrayBuffer, size_t byteOffset = 0, size_t maxByteLength = 0, SerializationErrorMode = SerializationErrorMode::Throwing, bool* didFail = nullptr); static Ref createFromWireBytes(Vector&& data) diff --git a/src/jsc/bindings/webcore/WorkerMessagingProxy.cpp b/src/jsc/bindings/webcore/WorkerMessagingProxy.cpp index 9f3f6b2e5ba6..a231c61d6036 100644 --- a/src/jsc/bindings/webcore/WorkerMessagingProxy.cpp +++ b/src/jsc/bindings/webcore/WorkerMessagingProxy.cpp @@ -378,6 +378,16 @@ bool WorkerMessagingProxy::postTaskToWorkerGlobalScope(Function&& inspection) +{ + { + Locker lock { m_pendingTasksLock }; + if (m_state.load() >= State::Closing || !m_workerVMReady) + return false; + } + return ScriptExecutionContext::postVMInspection(m_workerContextIdentifier, WTF::move(inspection)); +} + uint64_t WorkerMessagingProxy::registerCrossVMRequest(JSC::VM& vm, JSC::JSPromise* promise) { uint64_t id = m_nextRequestId.fetch_add(1); @@ -526,6 +536,7 @@ void WorkerMessagingProxy::workerThreadStarted() Locker lock { m_pendingTasksLock }; if (m_state.load() != State::Pending) return; + m_workerVMReady = true; } ScriptExecutionContext::postTaskTo(m_loaderContextIdentifier, m_loaderLoopKind, [protectedThis = Ref { *this }](ScriptExecutionContext&) { RefPtr workerObject = protectedThis->m_workerObject; diff --git a/src/jsc/bindings/webcore/WorkerMessagingProxy.h b/src/jsc/bindings/webcore/WorkerMessagingProxy.h index 85a2a6026cf9..49b9aa09ac90 100644 --- a/src/jsc/bindings/webcore/WorkerMessagingProxy.h +++ b/src/jsc/bindings/webcore/WorkerMessagingProxy.h @@ -85,6 +85,7 @@ class WorkerMessagingProxy final : public ThreadSafeRefCounted&&); + bool postVMInspection(Function&&); void setKeepAlive(bool); // Whether the thread keeps the parent's loop alive; nullopt once the thread is released // (node: the handle is gone and hasRef() reads back undefined). @@ -171,6 +172,7 @@ class WorkerMessagingProxy final : public ThreadSafeRefCounted> m_pendingTasks WTF_GUARDED_BY_LOCK(m_pendingTasksLock); + bool m_workerVMReady WTF_GUARDED_BY_LOCK(m_pendingTasksLock) { false }; HashMap> m_pendingCrossVMRequests WTF_GUARDED_BY_LOCK(m_pendingTasksLock); std::atomic m_nextRequestId { 1 }; diff --git a/src/runtime/api/BunObject.rs b/src/runtime/api/BunObject.rs index cd5203e6dca8..49b0f77a6cf8 100644 --- a/src/runtime/api/BunObject.rs +++ b/src/runtime/api/BunObject.rs @@ -1786,6 +1786,7 @@ fn mmap_file(global_this: &JSGlobalObject, callframe: &CallFrame) -> JsResult(), Some(global_deallocator), + jsc::array_buffer::ArrayBufferAllocationMode::RuntimeOwned, ) } } @@ -2668,6 +2671,7 @@ pub(crate) mod JSZlib { global_this, ptr.cast::(), Some(global_deallocator), + jsc::array_buffer::ArrayBufferAllocationMode::RuntimeOwned, ) } } diff --git a/src/runtime/ffi/FFIObject.rs b/src/runtime/ffi/FFIObject.rs index 510d8ae872e2..b7705db35268 100644 --- a/src/runtime/ffi/FFIObject.rs +++ b/src/runtime/ffi/FFIObject.rs @@ -44,6 +44,7 @@ fn create_buffer_with_ctx( len: usize, ctx: *mut c_void, deallocator: jsc::JSTypedArrayBytesDeallocator, + allocation: jsc::array_buffer::ArrayBufferAllocationMode, ) -> JSValue; } // SAFETY: `global` is live; `slice` stays valid for the Buffer's lifetime. @@ -55,6 +56,7 @@ fn create_buffer_with_ctx( slice.len(), ctx, callback, + jsc::array_buffer::ArrayBufferAllocationMode::External, ) }) } @@ -652,6 +654,7 @@ fn to_array_buffer( global_this, ctx.unwrap_or(core::ptr::null_mut()), callback, + jsc::array_buffer::ArrayBufferAllocationMode::External, ) } } diff --git a/src/runtime/image/Image.rs b/src/runtime/image/Image.rs index b31492c3c963..1943ca15cbbb 100644 --- a/src/runtime/image/Image.rs +++ b/src/runtime/image/Image.rs @@ -1799,7 +1799,12 @@ impl PipelineTask { // it exactly once at GC and ignores the null ctx. let v = unsafe { ArrayBuffer::from_bytes(mut_slice, jsc::JSType::Uint8Array) - .to_js_with_context(global, core::ptr::null_mut(), Some(out.free)) + .to_js_with_context( + global, + core::ptr::null_mut(), + Some(out.free), + jsc::array_buffer::ArrayBufferAllocationMode::RuntimeOwned, + ) }; match v { Ok(v) => promise.resolve(global, v)?, diff --git a/src/runtime/webcore/Blob.rs b/src/runtime/webcore/Blob.rs index fc47dea081f4..d97d1e5e4e6e 100644 --- a/src/runtime/webcore/Blob.rs +++ b/src/runtime/webcore/Blob.rs @@ -2844,6 +2844,7 @@ impl BlobExt for Blob { global, store.into_raw().cast::(), Some(blob_store_array_buffer_deallocator), + jsc::array_buffer::ArrayBufferAllocationMode::External, ) } } @@ -2873,6 +2874,7 @@ impl BlobExt for Blob { global, store.into_raw().cast::(), Some(blob_store_array_buffer_deallocator), + jsc::array_buffer::ArrayBufferAllocationMode::RuntimeOwned, ) } } diff --git a/test/js/node/process/arraybuffer-accounting.fixture.mjs b/test/js/node/process/arraybuffer-accounting.fixture.mjs new file mode 100644 index 000000000000..df1b3e9f419e --- /dev/null +++ b/test/js/node/process/arraybuffer-accounting.fixture.mjs @@ -0,0 +1,391 @@ +import assert from "node:assert/strict"; +import { once } from "node:events"; +import { readFileSync } from "node:fs"; +import { createRequire } from "node:module"; +import { getHeapStatistics, serialize } from "node:v8"; +import { Worker, isMainThread, parentPort, workerData } from "node:worker_threads"; + +const MiB = 1024 * 1024; +const bytes = 16 * MiB; +const collect = () => (globalThis.Bun ? Bun.gc(true) : globalThis.gc()); +const tick = () => new Promise(resolve => setImmediate(resolve)); +function sample() { + const m = process.memoryUsage(); + const v = getHeapStatistics(); + return { + arrayBuffers: m.arrayBuffers, + external: m.external, + external_memory: v.external_memory, + heapUsed: m.heapUsed, + heapTotal: m.heapTotal, + used_heap_size: v.used_heap_size, + total_heap_size: v.total_heap_size, + }; +} +function externalDelta(after, before, expected, label) { + for (const key of ["external", "external_memory"]) + assert.ok( + Math.abs(after[key] - before[key] - expected) < 65536, + `${label} ${key}: ${after[key] - before[key]} != ${expected}`, + ); +} +async function settle() { + await tick(); + collect(); + await tick(); + collect(); +} +async function lifecycle(kind) { + if (["file", "blob", "response", "serialize", "serialize-buffer"].includes(kind)) return nativeOutput(kind); + const isWasm = kind === "wasm" || kind === "wasm-shared"; + if (isWasm) { + globalThis.warmWasm = new WebAssembly.Memory({ initial: 1, maximum: 256, shared: kind === "wasm-shared" }); + warmWasm.buffer; + } + const native = + kind === "native" ? createRequire(import.meta.url)("../../../napi/napi-app/build/Debug/napitests.node") : undefined; + await settle(); + const rows = []; + if (kind === "fast") { + function alloc(n) { + return new Uint8Array(n); + } + globalThis.warm = Array.from({ length: 100000 }, () => alloc(128)); + await settle(); + rows.push(sample()); + globalThis.held = Array.from({ length: 8192 }, () => alloc(512)); + } else { + rows.push(sample()); + if (kind === "typed") globalThis.held = new Uint8Array(bytes); + if (kind === "buffer") globalThis.held = Buffer.alloc(bytes); + if (kind === "native") globalThis.held = native.create_external_arraybuffer_for_transfer(bytes); + if (["arraybuffer", "detach", "views", "resize-transfer"].includes(kind)) globalThis.held = new ArrayBuffer(bytes); + if (kind === "resizable") globalThis.held = new ArrayBuffer(4 * MiB, { maxByteLength: bytes }); + if (kind === "shared") globalThis.held = new SharedArrayBuffer(bytes); + if (kind === "growable") globalThis.held = new SharedArrayBuffer(4 * MiB, { maxByteLength: bytes }); + if (isWasm) globalThis.held = new WebAssembly.Memory({ initial: 64, maximum: 256, shared: kind === "wasm-shared" }); + } + const expectedBuffers = + kind === "resizable" || kind === "growable" || kind === "native" || isWasm ? 0 : kind === "fast" ? 4 * MiB : bytes; + const expectedExternal = + kind === "shared" || kind === "growable" || kind === "wasm-shared" + ? 0 + : kind === "resizable" || kind === "wasm" + ? 4 * MiB + : kind === "native" + ? bytes + : expectedBuffers; + rows.push(sample()); + assert.equal(rows[1].arrayBuffers - rows[0].arrayBuffers, expectedBuffers, `${kind} arrayBuffers before GC`); + externalDelta(rows[1], rows[0], expectedExternal, `${kind} allocation`); + assert.ok(rows[1].heapUsed - rows[0].heapUsed < 3 * MiB, `${kind} payload entered JS heap`); + assert.ok(rows[1].used_heap_size - rows[0].used_heap_size < 3 * MiB, `${kind} payload entered v8 JS heap`); + if (kind === "typed") { + globalThis.materialized = held.buffer; + assert.equal(sample().arrayBuffers, rows[1].arrayBuffers, "materialization counted twice"); + externalDelta(sample(), rows[1], 0, "materialization"); + } + if (kind === "resizable") { + held.resize(12 * MiB); + assert.equal(sample().arrayBuffers, rows[0].arrayBuffers); + externalDelta(sample(), rows[0], 12 * MiB, "grown"); + held.resize(2 * MiB); + externalDelta(sample(), rows[0], 2 * MiB, "shrunk"); + globalThis.received = held.transferToFixedLength(); + assert.equal(held.byteLength, 0); + assert.equal(sample().arrayBuffers - rows[0].arrayBuffers, 2 * MiB, "fixed transfer allocates"); + externalDelta(sample(), rows[0], 2 * MiB, "fixed transfer"); + } + if (kind === "growable") { + held.grow(12 * MiB); + assert.equal(sample().arrayBuffers, rows[0].arrayBuffers); + externalDelta(sample(), rows[0], 0, "shared growth"); + } + if (kind === "detach") { + globalThis.received = structuredClone(held, { transfer: [held] }); + assert.equal(held.byteLength, 0); + assert.equal(sample().arrayBuffers, rows[1].arrayBuffers); + externalDelta(sample(), rows[1], 0, "local transfer"); + } + if (kind === "views") { + globalThis.aliases = [ + new Uint8Array(held), + new Uint32Array(held, 4, 64), + new DataView(held, 1, 128), + Buffer.from(held), + ]; + aliases.push(aliases[0].subarray(3, 64)); + assert.equal(sample().arrayBuffers, rows[1].arrayBuffers, "views share one allocation"); + externalDelta(sample(), rows[1], 0, "views share one holder"); + } + if (kind === "resize-transfer") { + globalThis.received = held.transfer(bytes / 2); + assert.equal(held.byteLength, 0); + assert.equal(sample().arrayBuffers - rows[0].arrayBuffers, bytes / 2, "resized transfer replaces allocation"); + externalDelta(sample(), rows[0], bytes / 2, "resized transfer"); + globalThis.received = received.transfer(0); + assert.equal(sample().arrayBuffers, rows[0].arrayBuffers, "zero transfer frees allocation"); + externalDelta(sample(), rows[0], 0, "zero transfer"); + } + if (isWasm) { + globalThis.materialized = held.buffer; + externalDelta(sample(), rows[0], expectedExternal, "wasm buffer exposure"); + held.grow(128); + const grownExternal = kind === "wasm" ? 12 * MiB : 0; + externalDelta(sample(), rows[0], grownExternal, "wasm growth before buffer exposure"); + globalThis.materialized = held.buffer; + globalThis.held = null; + await settle(); + externalDelta(sample(), rows[0], grownExternal, "wasm backing storage outlives memory wrapper"); + assert.equal(sample().arrayBuffers, rows[0].arrayBuffers, "wasm bypasses ArrayBuffer allocator"); + } + collect(); + rows.push(sample()); + assert.ok(rows[2].heapUsed - rows[0].heapUsed < 3 * MiB, `${kind} collected payload entered JS heap`); + globalThis.held = globalThis.materialized = globalThis.received = globalThis.aliases = null; + await settle(); + rows.push(sample()); + assert.equal(rows[3].arrayBuffers, rows[0].arrayBuffers, `${kind} release`); + externalDelta(rows[3], rows[0], 0, `${kind} release`); + globalThis.warm = null; + globalThis.warmWasm = null; + return rows; +} + +async function allocateNativeOutput(kind, isSerializer) { + if (kind === "file") globalThis.held = readFileSync(isMainThread ? process.argv[3] : workerData.path); + if (kind === "blob" || kind === "response") globalThis.held = await nativeInput.arrayBuffer(); + if (isSerializer) globalThis.held = serialize(nativeInput); + // Return a scalar so the measuring async frame cannot retain the output during release checks. + return held.byteLength; +} + +async function nativeOutput(kind) { + const isSerializer = kind === "serialize" || kind === "serialize-buffer"; + if (kind === "blob") globalThis.nativeInput = new Blob([new Uint8Array(bytes)]); + if (kind === "response") globalThis.nativeInput = new Response("x".repeat(bytes)); + if (kind === "serialize") globalThis.nativeInput = new Uint8Array(bytes); + if (kind === "serialize-buffer") globalThis.nativeInput = Buffer.alloc(bytes); + await settle(); + const before = sample(); + const length = await allocateNativeOutput(kind, isSerializer); + await settle(); + const retained = sample(); + if (isSerializer) { + assert.ok(held.buffer instanceof ArrayBuffer, "serializer Buffer has unshared backing storage"); + assert.equal(retained.arrayBuffers, before.arrayBuffers, "serializer storage is external to the allocator"); + externalDelta(retained, before, length, "serializer backing store"); + } else { + assert.ok(retained.arrayBuffers >= length, `${kind} native output is counted`); + if (kind !== "response") + assert.equal(retained.arrayBuffers - before.arrayBuffers, length, `${kind} native allocation origin`); + } + globalThis.nativeInput = null; + await settle(); + const isolated = sample(); + globalThis.held = null; + await settle(); + const released = sample(); + assert.equal( + isolated.arrayBuffers - released.arrayBuffers, + isSerializer ? 0 : length, + `${kind} origin release`, + ); + externalDelta(released, isolated, -length, `${kind} external release`); + return [before, retained, isolated, released]; +} + +// Keep JIT compilation of the busy loop separate from this large fixture. +function spinUntilReleased(flag) { + while (!Atomics.load(flag, 0)) {} +} + +function warmReporting() { + // Compile the reporting path before measuring backing-store changes. + for (let i = 0; i < 64; i++) sample(); +} + +async function warmWorkerLifecycle() { + // Worker and stdio teardown also compile code charged as native overhead. + const worker = new Worker( + 'const { parentPort } = require("node:worker_threads"); parentPort.on("message", () => {}); parentPort.postMessage("ready");', + { eval: true }, + ); + try { + await once(worker, "message"); + } finally { + await worker.terminate(); + } +} + +const kind = isMainThread ? process.argv[2] : typeof workerData === "string" ? workerData : workerData.kind; +warmReporting(); +if (!isMainThread && ["busy", "wait", "shutdown", "startup"].includes(kind)) { + // Finish startup work before comparing snapshots of a fresh allocation. + if (kind === "busy" || kind === "wait") await settle(); + globalThis.held = new Uint8Array(bytes); + const flag = new Int32Array(workerData.shared); + parentPort.postMessage(sample()); + if (kind === "wait") { + assert.equal(Atomics.wait(flag, 0, 0), "ok"); + } else { + spinUntilReleased(flag); + } + parentPort.postMessage("released"); +} else if (!isMainThread && kind === "transfer") { + await settle(); + parentPort.postMessage(sample()); + parentPort.on("message", async message => { + if (message instanceof ArrayBuffer) { + globalThis.held = message; + parentPort.postMessage(sample()); + } else if (message === "release") { + globalThis.held = null; + await settle(); + parentPort.postMessage(sample()); + } + }); +} else if (!isMainThread && kind === "owner-exit") { + parentPort.once("message", () => { + globalThis.held = new ArrayBuffer(bytes); + parentPort.postMessage(held, [held]); + }); + parentPort.postMessage("ready"); +} else if (!isMainThread) { + parentPort.postMessage(await lifecycle(kind)); +} else if (kind === "shutdown" || kind === "startup") { + const shared = new SharedArrayBuffer(4); + const worker = new Worker(new URL(import.meta.url), { workerData: { kind, shared } }); + let timer; + try { + if (kind === "shutdown") await once(worker, "message"); + const requests = Array.from({ length: 32 }, () => + worker.getHeapStatistics().then( + stats => ({ used: stats.used_heap_size }), + error => ({ code: error.code }), + ), + ); + const ending = kind === "shutdown" ? worker.terminate() : undefined; + const results = await Promise.race([ + Promise.all(requests), + new Promise((_, reject) => { + timer = setTimeout(() => reject(new Error(`${kind} requests did not settle`)), 1500); + }), + ]); + for (const result of results) { + if ("code" in result) assert.equal(result.code, "ERR_WORKER_NOT_RUNNING"); + else assert.ok(result.used >= 0); + } + await ending; + console.log(JSON.stringify({ kind, results })); + } finally { + clearTimeout(timer); + Atomics.store(new Int32Array(shared), 0, 1); + await worker.terminate(); + } +} else if (kind === "busy" || kind === "wait") { + const shared = new SharedArrayBuffer(4); + const flag = new Int32Array(shared); + const worker = new Worker(new URL(import.meta.url), { workerData: { kind, shared } }); + let timer; + try { + const self = (await once(worker, "message"))[0]; + const start = performance.now(); + const stats = await Promise.race([ + Promise.all(Array.from({ length: 32 }, () => worker.getHeapStatistics())), + new Promise((_, reject) => { + timer = setTimeout(() => reject(new Error(`${kind} worker snapshot blocked`)), 1500); + }), + ]); + clearTimeout(timer); + for (const stat of stats) { + assert.ok(stat.external_memory >= bytes, "fresh external payload"); + assert.ok( + Math.abs(stat.external_memory - self.external_memory) < 65536, + "worker-local and parent snapshot agree", + ); + assert.ok(stat.total_heap_size >= stat.used_heap_size); + } + const released = once(worker, "message"); + if (kind === "wait") { + // Keep the expected value unchanged until a notification reaches a parked waiter. + while (Atomics.notify(flag, 0, 1) !== 1) { + assert.ok(performance.now() - start < 1500, "worker did not enter its atomic wait"); + await tick(); + } + } else Atomics.store(flag, 0, 1); + assert.equal((await released)[0], "released", "inspection preserves wait result"); + console.log(JSON.stringify({ kind, elapsedMs: performance.now() - start, self, stats })); + } finally { + clearTimeout(timer); + Atomics.store(flag, 0, 1); + Atomics.notify(flag, 0); + await worker.terminate(); + } +} else if (kind === "transfer") { + const worker = new Worker(new URL(import.meta.url), { workerData: kind }); + try { + const workerBefore = (await once(worker, "message"))[0]; + await settle(); + const before = sample(); + globalThis.held = new ArrayBuffer(bytes); + const response = once(worker, "message"); + worker.postMessage(held, [held]); + assert.equal(held.byteLength, 0); + const sent = sample(); + assert.equal(sent.arrayBuffers - before.arrayBuffers, bytes, "origin charge follows allocator"); + externalDelta(sent, before, 0, "sender after transfer"); + const received = (await response)[0]; + assert.equal(received.arrayBuffers, workerBefore.arrayBuffers, "receiver did not allocate"); + externalDelta(received, workerBefore, bytes, "receiver"); + assert.ok(received.heapUsed - workerBefore.heapUsed < 3 * MiB, "transfer pressure is not JS heap"); + assert.ok(received.used_heap_size - workerBefore.used_heap_size < 3 * MiB, "transfer pressure is not V8 heap"); + const parentRead = await worker.getHeapStatistics(); + assert.ok(Math.abs(parentRead.external_memory - received.external_memory) < 65536); + globalThis.held = null; + await settle(); + assert.equal(sample().arrayBuffers - before.arrayBuffers, bytes, "sender GC preserves origin"); + const release = once(worker, "message"); + worker.postMessage("release"); + await release; + await settle(); + assert.equal(sample().arrayBuffers, before.arrayBuffers, "receiver frees origin charge"); + console.log(JSON.stringify({ kind, before, sent, received, parentRead, after: sample() })); + } finally { + await worker.terminate(); + } +} else if (kind === "owner-exit") { + await warmWorkerLifecycle(); + const worker = new Worker(new URL(import.meta.url), { workerData: kind }); + try { + assert.equal((await once(worker, "message"))[0], "ready"); + await settle(); + const before = sample(); + let received = once(worker, "message"); + worker.postMessage("allocate"); + globalThis.held = (await received)[0]; + // The fulfilled events.once promise otherwise retains the transferred buffer. + received = null; + await worker.terminate(); + assert.equal(held.byteLength, bytes); + assert.equal(sample().arrayBuffers, before.arrayBuffers, "allocator exit does not move charge"); + externalDelta(sample(), before, bytes, "allocator exited"); + await settle(); + const retained = sample(); + globalThis.held = null; + await settle(); + externalDelta(sample(), retained, -bytes, "allocator exited and storage freed"); + console.log(JSON.stringify({ kind, before, retained, after: sample() })); + } finally { + await worker.terminate(); + } +} else { + const main = await lifecycle(kind); + const worker = new Worker(new URL(import.meta.url), { workerData: { kind, path: process.argv[3] } }); + try { + const workerRows = (await once(worker, "message"))[0]; + console.log(JSON.stringify({ kind, main, worker: workerRows })); + } finally { + await worker.terminate(); + } +} diff --git a/test/js/node/process/process.test.js b/test/js/node/process/process.test.js index 376cb227da8e..08eef1e9eeb5 100644 --- a/test/js/node/process/process.test.js +++ b/test/js/node/process/process.test.js @@ -4,6 +4,7 @@ import { memoryUsage as jscMemoryUsage } from "bun:jsc"; import { describe, expect, it } from "bun:test"; import { familySync } from "detect-libc"; import { bunEnv, bunExe, isASAN, isDebug, isLinux, isMacOS, isWindows, tempDir, tmpdirSync } from "harness"; +import { fileURLToPath } from "node:url"; import { basename, join, resolve } from "path"; import { getHeapStatistics } from "v8"; @@ -3016,6 +3017,67 @@ it("process.memoryUsage.arrayBuffers", () => { expect(process.memoryUsage().arrayBuffers).toBeGreaterThanOrEqual(initial + 16 * 1024 * 1024); }); +for (const kind of [ + "typed", + "buffer", + "arraybuffer", + "fast", + "resizable", + "shared", + "growable", + "detach", + "transfer", + "owner-exit", + "wasm", + "wasm-shared", + "views", + "resize-transfer", + "blob", + "response", + "serialize", + "serialize-buffer", +]) { + it(`ArrayBuffer memory ownership: ${kind}`, async () => { + await using child = Bun.spawn({ + cmd: [ + bunExe(), + "--expose-gc", + fileURLToPath(new URL("./arraybuffer-accounting.fixture.mjs", import.meta.url)), + kind, + ], + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([child.stdout.text(), child.stderr.text(), child.exited]); + expect(stderr).toBe(""); + expect(stdout).toContain(`"kind":"${kind}"`); + expect(exitCode).toBe(0); + }); +} + +it("ArrayBuffer memory ownership: native file buffers", async () => { + using dir = tempDir("arraybuffer-file", { "data.bin": "" }); + const path = join(String(dir), "data.bin"); + await Bun.write(path, new Uint8Array(16 * 1024 * 1024)); + await using child = Bun.spawn({ + cmd: [ + bunExe(), + "--expose-gc", + fileURLToPath(new URL("./arraybuffer-accounting.fixture.mjs", import.meta.url)), + "file", + path, + ], + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([child.stdout.text(), child.stderr.text(), child.exited]); + expect(stderr).toBe(""); + expect(stdout).toContain('"kind":"file"'); + expect(exitCode).toBe(0); +}); + it("should handle user assigned `default` properties", async () => { process.default = 1; process.hello = 2; diff --git a/test/js/node/v8/v8-serdes-buffer.test.ts b/test/js/node/v8/v8-serdes-buffer.test.ts index eaf4cbc64753..6ab422212b20 100644 --- a/test/js/node/v8/v8-serdes-buffer.test.ts +++ b/test/js/node/v8/v8-serdes-buffer.test.ts @@ -6,6 +6,14 @@ import v8 from "node:v8"; // serializer delegate behavior (DefaultSerializer host objects). Non-Buffer // payloads keep the bare JSC-serialized format for backward compatibility. describe("v8 serialize/deserialize Buffer identity", () => { + test.each([new Uint8Array([1, 2]), Buffer.from([1, 2])])("serialized output is transferable: %p", input => { + const serialized = v8.serialize(input); + expect(serialized.buffer).toBeInstanceOf(ArrayBuffer); + const transferred = structuredClone(serialized.buffer, { transfer: [serialized.buffer] }); + expect(serialized.byteLength).toBe(0); + expect(Array.from(v8.deserialize(Buffer.from(transferred)))).toEqual([1, 2]); + }); + test("Buffer round-trips as Buffer", () => { const out = v8.deserialize(v8.serialize(Buffer.from("hi"))); expect(Buffer.isBuffer(out)).toBe(true); diff --git a/test/js/node/worker_threads/worker_threads.test.ts b/test/js/node/worker_threads/worker_threads.test.ts index 318e2334aa34..8671857c977a 100644 --- a/test/js/node/worker_threads/worker_threads.test.ts +++ b/test/js/node/worker_threads/worker_threads.test.ts @@ -4,7 +4,7 @@ import { once } from "node:events"; import fs from "node:fs"; import { join, relative, resolve } from "node:path"; import { Readable } from "node:stream"; -import { pathToFileURL } from "node:url"; +import { fileURLToPath, pathToFileURL } from "node:url"; import wt, { BroadcastChannel, getEnvironmentData, @@ -2336,6 +2336,7 @@ test("getHeapStatistics reports allocated capacity separately from used heap", a const stats = await worker.getHeapStatistics(); expect(stats.used_heap_size).toBeGreaterThan(0); expect(stats.total_heap_size).toBeGreaterThan(stats.used_heap_size); + expect(stats.external_memory).toBeGreaterThan(0); } finally { await worker.terminate(); } @@ -2355,6 +2356,26 @@ test("getHeapStatistics settles when terminated mid-request", async () => { ).resolves.toMatch(/^(ok|ERR_WORKER_NOT_RUNNING)$/); }); +for (const kind of ["busy", "wait", "shutdown", "startup"]) { + test(`getHeapStatistics settles requests during worker ${kind}`, async () => { + await using child = Bun.spawn({ + cmd: [ + bunExe(), + "--expose-gc", + fileURLToPath(new URL("../process/arraybuffer-accounting.fixture.mjs", import.meta.url)), + kind, + ], + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([child.stdout.text(), child.stderr.text(), child.exited]); + expect(stderr).toBe(""); + expect(stdout).toContain(`"kind":"${kind}"`); + expect(exitCode).toBe(0); + }); +} + test("*Internal introspection methods are DontEnum on Worker.prototype", () => { const enumerable: string[] = []; for (const k in globalThis.Worker.prototype) enumerable.push(k); diff --git a/test/napi/napi.test.ts b/test/napi/napi.test.ts index 851a60d00360..53eceffdaa85 100644 --- a/test/napi/napi.test.ts +++ b/test/napi/napi.test.ts @@ -438,6 +438,24 @@ describe.concurrent.skipIf(!canBuildNodeAddons())("napi", () => { }); describe("napi_create_external_arraybuffer", () => { + it("charges external payload without charging the ArrayBuffer allocator", async () => { + await using child = Bun.spawn({ + cmd: [ + bunExe(), + "--expose-gc", + join(import.meta.dir, "../js/node/process/arraybuffer-accounting.fixture.mjs"), + "native", + ], + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([child.stdout.text(), child.stderr.text(), child.exited]); + expect(stderr).toBe(""); + expect(stdout).toContain('"kind":"native"'); + expect(exitCode).toBe(0); + }); + it("wraps caller data and does not fire finalize_cb while the ArrayBuffer is alive", async () => { const result = await checkSameOutput("test_external_arraybuffer_finalizer", []); expect(result).toContain("PASS: napi_create_external_arraybuffer wraps caller data without copying"); From 6facf8734f92f9a2ee0434d286481aca51c595ef Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 5 Oct 2026 12:55:35 -0700 Subject: [PATCH 2/2] test: stabilize accounting lifetimes and native output controls --- test/js/node/process/arraybuffer-accounting.fixture.mjs | 2 ++ test/napi/napi-app/conversion_tests.cpp | 1 + test/napi/napi-app/module.js | 6 ++++-- 3 files changed, 7 insertions(+), 2 deletions(-) diff --git a/test/js/node/process/arraybuffer-accounting.fixture.mjs b/test/js/node/process/arraybuffer-accounting.fixture.mjs index df1b3e9f419e..72833af668b8 100644 --- a/test/js/node/process/arraybuffer-accounting.fixture.mjs +++ b/test/js/node/process/arraybuffer-accounting.fixture.mjs @@ -238,6 +238,8 @@ if (!isMainThread && ["busy", "wait", "shutdown", "startup"].includes(kind)) { parentPort.on("message", async message => { if (message instanceof ArrayBuffer) { globalThis.held = message; + // Only held should retain the payload when the later release command runs. + message = null; parentPort.postMessage(sample()); } else if (message === "release") { globalThis.held = null; diff --git a/test/napi/napi-app/conversion_tests.cpp b/test/napi/napi-app/conversion_tests.cpp index 755271a3b18a..c37e0ac377e0 100644 --- a/test/napi/napi-app/conversion_tests.cpp +++ b/test/napi/napi-app/conversion_tests.cpp @@ -3,6 +3,7 @@ #include "utils.h" #include +#include #include namespace napitests { diff --git a/test/napi/napi-app/module.js b/test/napi/napi-app/module.js index 423e38132470..7144ee7ca6b6 100644 --- a/test/napi/napi-app/module.js +++ b/test/napi/napi-app/module.js @@ -1411,6 +1411,7 @@ nativeTests.test_napi_get_prototype_proxy = () => { }; nativeTests.test_get_value_string = () => { + const { writeSync } = require("node:fs"); function to16Bit(string) { if (typeof Bun != "object") return string; const jsc = require("bun:jsc"); @@ -1443,9 +1444,10 @@ nativeTests.test_get_value_string = () => { // ["\ud801", "unpaired high surrogate"], // ["\udc02", "unpaired low surrogate"], ]) { - console.log(`test napi_get_value_string on ${string} (${description})`); + // Keep labels ordered with native printf even when stdout is backpressured. + writeSync(1, `test napi_get_value_string on ${string} (${description})\n`); for (const encoding of ["latin1", "utf8", "utf16"]) { - console.log(encoding); + writeSync(1, `${encoding}\n`); const fn = nativeTests[`test_get_value_string_${encoding}`]; fn(string); }