From 40377b142304fe890ace0dc8978dc43f22efea9e Mon Sep 17 00:00:00 2001 From: bing Date: Wed, 6 May 2026 21:47:25 +0800 Subject: [PATCH 1/2] feat: support create_external_arraybuffer ported from https://github.com/ChainSafe/lodestar-z/pull/356 External array buffers have their lifetimes managed by V8's garbage collector, but their backing memory is still managed by the native implementation. We need to call `adjustExternalMemory` to let V8 know about the native allocations; and we need a finalizer to cleanup such allocations (which we add in this PR, and use in lodestar-z) More details from that PR: > This is one of possible likely causes for increased GC pressure on experiments to swap out blst-ts for lodestar-z/bls, as observed on feat2 and feat3 deployments in [this PR](https://github.com/ChainSafe/lodestar/pull/9342/). > > With external array buffers, V8 is only aware of the pointer to the backing memory, instead of having to track both the pointer and the backing memory. This means that during marking phase the GC does not have to walk the backing memory to mark it as 'live' - the frequency of the GC firing off is still the same, but each cycle does less work. > > This of course comes with a tradeoff, we need a **finalizer** to let V8 know how much external memory is in native heap so that the GC tells the native impl to free the useless memory. > > Though, regardless of the effect, we should still probably do this anyway, since [napi-rs does the same](https://github.com/napi-rs/napi-rs/blob/159395b365c583a6642ad481edc5708d9f36a24b/crates/napi/src/bindgen_runtime/js_values/arraybuffer.rs#L175), and only defaults to V8 managed array buffers if it is disallowed (like in Electron). --- src/Env.zig | 13 ++++++++++--- src/finalize_callback.zig | 31 ++++++++++++++++++++++++++++++- src/js/typed_arrays.zig | 37 +++++++++++++++++++++++++++++++++++++ src/napi.zig | 2 ++ 4 files changed, 79 insertions(+), 4 deletions(-) diff --git a/src/Env.zig b/src/Env.zig index 3b6bf1a..4735d80 100644 --- a/src/Env.zig +++ b/src/Env.zig @@ -293,7 +293,7 @@ pub fn createDate(self: Env, time: f64) NapiError!Value { pub fn createExternal(self: Env, data: [*]const u8, finalize_cb: c.napi_finalize, finalize_hint: ?*anyopaque) NapiError!Value { var value: c.napi_value = undefined; try status.check( - c.napi_create_external(self.env, @constCast(@ptrCast(data)), finalize_cb, finalize_hint, &value), + c.napi_create_external(self.env, @ptrCast(@constCast(data)), finalize_cb, finalize_hint, &value), ); return Value{ .env = self.env, @@ -305,7 +305,14 @@ pub fn createExternal(self: Env, data: [*]const u8, finalize_cb: c.napi_finalize pub fn createExternalArrayBuffer(self: Env, data: []const u8, finalize_cb: c.napi_finalize, finalize_hint: ?*anyopaque) NapiError!Value { var value: c.napi_value = undefined; try status.check( - c.napi_create_external_arraybuffer(self.env, @constCast(@ptrCast(data.ptr)), data.len, finalize_cb, finalize_hint, &value), + c.napi_create_external_arraybuffer( + self.env, + @ptrCast(@constCast(data.ptr)), + data.len, + finalize_cb, + finalize_hint, + &value, + ), ); return Value{ .env = self.env, @@ -317,7 +324,7 @@ pub fn createExternalArrayBuffer(self: Env, data: []const u8, finalize_cb: c.nap pub fn createExternalBuffer(self: Env, data: []const u8, finalize_cb: c.napi_finalize, finalize_hint: ?*anyopaque) NapiError!Value { var value: c.napi_value = undefined; try status.check( - c.napi_create_external_buffer(self.env, data.len, @constCast(@ptrCast(data.ptr)), finalize_cb, finalize_hint, &value), + c.napi_create_external_buffer(self.env, data.len, @ptrCast(@constCast(data.ptr)), finalize_cb, finalize_hint, &value), ); return Value{ .env = self.env, diff --git a/src/finalize_callback.zig b/src/finalize_callback.zig index 2bd6fee..144688c 100644 --- a/src/finalize_callback.zig +++ b/src/finalize_callback.zig @@ -19,10 +19,39 @@ pub fn wrapFinalizeCallback( if (data == null) return; return finalize_cb( Env{ .env = env }, - @alignCast(@ptrCast(data)), + @ptrCast(@alignCast(data)), hint, ); } }; return wrapper.f; } + +/// Typed finalizer for externally backed buffers. +/// +/// The C-ABI `napi_finalize` parameters (`?*anyopaque` data, `?*anyopaque` hint) +/// are unpacked into a single `[]Element` slice. By convention this helper +/// expects the hint pointer slot to carry the element count (set via +/// `@ptrFromInt(slice.len)` at creation time). +pub fn SliceFinalizeCallback(comptime Element: type) type { + return *const fn (Env, []Element) void; +} + +pub fn wrapSliceFinalizeCallback( + comptime Element: type, + comptime finalize_cb: SliceFinalizeCallback(Element), +) c.napi_finalize { + const wrapper = struct { + pub fn f( + env: c.napi_env, + data: ?*anyopaque, + hint: ?*anyopaque, + ) callconv(.c) void { + if (data == null) return; + const len: usize = @intFromPtr(hint); + const ptr: [*]Element = @ptrCast(@alignCast(data)); + return finalize_cb(Env{ .env = env }, ptr[0..len]); + } + }; + return wrapper.f; +} diff --git a/src/js/typed_arrays.zig b/src/js/typed_arrays.zig index d2edae0..2f1f365 100644 --- a/src/js/typed_arrays.zig +++ b/src/js/typed_arrays.zig @@ -1,3 +1,4 @@ +const std = @import("std"); const napi = @import("../napi.zig"); const context = @import("context.zig"); const TypedarrayType = napi.value_types.TypedarrayType; @@ -48,6 +49,42 @@ pub fn TypedArray(comptime Element: type, comptime array_type: TypedarrayType) t return typed_ptr[0..info.length]; } + /// Creates a new JavaScript TypedArray backed by an *external* (native-heap) + /// ArrayBuffer. + /// + /// The contents of `slice` are copied into a freshly allocated native buffer + /// (via `context.allocator()`). + /// + /// V8 holds the pointer to manage the JS-side lifetime; the + /// native buffer is freed by a finalizer when V8 collects the ArrayBuffer. + pub fn fromExternal(slice: []const Element) !Self { + const e = context.env(); + const buf = try context.allocator().dupe(Element, slice); + + const byte_len = slice.len * @sizeOf(Element); + const len_hint: ?*anyopaque = @ptrFromInt(slice.len); + const finalize_cb = comptime napi.wrapSliceFinalizeCallback(Element, externalFinalizer); + const arraybuffer = e.createExternalArrayBuffer(std.mem.sliceAsBytes(buf), finalize_cb, len_hint) catch |err| { + context.allocator().free(buf); + return err; + }; + + _ = try e.adjustExternalMemory(@intCast(byte_len)); + const val = try e.createTypedarray(array_type, slice.len, arraybuffer, 0); + return .{ .val = val }; + } + + /// Finalizer for buffers allocated by `fromExternal`. Frees the native + /// allocation and reverses the matching `adjustExternalMemory` accounting. + /// + /// Caller is responsible for calling a matching `adjustExternalMemory` at + /// the appropriate callsite to let V8 know about native heap memory usage. + fn externalFinalizer(env: napi.Env, data: []Element) void { + const byte_len = data.len * @sizeOf(Element); + context.allocator().free(data); + _ = env.adjustExternalMemory(-@as(i64, @intCast(byte_len))) catch {}; + } + /// Creates a new JavaScript TypedArray from a Zig slice by copying the data. /// /// This function allocates a new `ArrayBuffer` in V8, copies the contents diff --git a/src/napi.zig b/src/napi.zig index aad1c2b..bbba986 100644 --- a/src/napi.zig +++ b/src/napi.zig @@ -14,11 +14,13 @@ pub const Ref = @import("Ref.zig"); pub const CallbackInfo = @import("callback_info.zig").CallbackInfo; pub const Callback = @import("callback.zig").Callback; pub const FinalizeCallback = @import("finalize_callback.zig").FinalizeCallback; +pub const SliceFinalizeCallback = @import("finalize_callback.zig").SliceFinalizeCallback; pub const value_types = @import("value_types.zig"); pub const createCallback = @import("create_callback.zig").createCallback; pub const registerDecls = @import("register_decls.zig").registerDecls; pub const wrapFinalizeCallback = @import("finalize_callback.zig").wrapFinalizeCallback; +pub const wrapSliceFinalizeCallback = @import("finalize_callback.zig").wrapSliceFinalizeCallback; pub const wrapCallback = @import("callback.zig").wrapCallback; pub const AsyncWork = @import("async_work.zig").AsyncWork; From 536389998665d1461d3454de2a226ab21b64f59b Mon Sep 17 00:00:00 2001 From: bing Date: Thu, 14 May 2026 11:48:53 +0800 Subject: [PATCH 2/2] add example and test --- examples/js_dsl/mod.test.ts | 12 ++++++++++++ examples/js_dsl/mod.zig | 18 ++++++++++++++++++ 2 files changed, 30 insertions(+) diff --git a/examples/js_dsl/mod.test.ts b/examples/js_dsl/mod.test.ts index 9c195e0..505bd95 100644 --- a/examples/js_dsl/mod.test.ts +++ b/examples/js_dsl/mod.test.ts @@ -160,6 +160,18 @@ describe("typed arrays", () => { expect(result).toBeInstanceOf(Uint8Array); expect(result.length).toEqual(0); }); + + it("externalUint8Array copies a JS array into a native-backed Uint8Array", () => { + const test_cases = [ + { input: [] }, + { input: [10, 20, 30, 40] }, + ]; + + for (const tc of test_cases) { + const result = mod.externalUint8Array(tc.input); + expect(result).toBeInstanceOf(Uint8Array); + } + }); }); // Section 7: Promises diff --git a/examples/js_dsl/mod.zig b/examples/js_dsl/mod.zig index b46afa8..71804ee 100644 --- a/examples/js_dsl/mod.zig +++ b/examples/js_dsl/mod.zig @@ -190,6 +190,24 @@ pub fn allocUint8(len: Number) !Uint8Array { return arr; } +/// Build a Uint8Array backed by an *external* (native-heap) ArrayBuffer. +/// +/// Takes a JS array of numbers, copies the bytes into a buffer owned by +/// `js.allocator()`, and hands the pointer to V8. The DSL wires up a +/// `SliceFinalizeCallback` so the native buffer is freed automatically when +/// V8 collects the ArrayBuffer — no manual cleanup needed on the JS side. +pub fn externalUint8Array(arr: Array) !Uint8Array { + const len = try arr.length(); + const alloc = js.allocator(); + const tmp = try alloc.alloc(u8, len); + defer alloc.free(tmp); + var i: u32 = 0; + while (i < len) : (i += 1) { + tmp[i] = @intCast((try arr.getNumber(i)).assertI32()); + } + return Uint8Array.fromExternal(tmp); +} + // ============================================================================ // Section 7: Promises // ============================================================================