@nxtedition/shared
Advanced tools
Sorry, the diff of this file is not supported yet
| export default (await import('node-gyp-build')).default(import.meta.dirname) |
+516
| #include <nan.h> | ||
| #include <atomic> | ||
| #include <cerrno> | ||
| #include <cstdio> | ||
| #include <cstdlib> | ||
| #include <cstring> | ||
| // ─── Platform-specific ring buffer implementation ──────────────────────────── | ||
| #ifdef _WIN32 | ||
| #ifndef WIN32_LEAN_AND_MEAN | ||
| #define WIN32_LEAN_AND_MEAN | ||
| #endif | ||
| #ifndef NOMINMAX | ||
| #define NOMINMAX | ||
| #endif | ||
| #include <windows.h> | ||
| #include <memoryapi.h> // VirtualAlloc2, MapViewOfFile3, UnmapViewOfFileEx (Windows 10 1803+) | ||
| struct ring_buf_t | ||
| { | ||
| std::atomic<int> refcount; | ||
| size_t size; | ||
| void *addr; | ||
| HANDLE mapping; | ||
| }; | ||
| static void ring_buf_free(ring_buf_t *rb) | ||
| { | ||
| // Restore both views to placeholders, then release each separately. | ||
| // VirtualFree(addr, 0, MEM_RELEASE) only releases the placeholder it was | ||
| // called on — the split created two distinct regions, so both must be freed. | ||
| UnmapViewOfFileEx(rb->addr, MEM_PRESERVE_PLACEHOLDER); | ||
| UnmapViewOfFileEx((char *)rb->addr + rb->size, MEM_PRESERVE_PLACEHOLDER); | ||
| VirtualFree(rb->addr, 0, MEM_RELEASE); | ||
| VirtualFree((char *)rb->addr + rb->size, 0, MEM_RELEASE); | ||
| CloseHandle(rb->mapping); | ||
| free(rb); | ||
| } | ||
| static ring_buf_t *ring_buf_alloc(size_t requested_size) | ||
| { | ||
| SYSTEM_INFO si; | ||
| GetSystemInfo(&si); | ||
| size_t align = si.dwAllocationGranularity; // typically 65536 on Windows | ||
| size_t size = (requested_size + align - 1) & ~(align - 1); | ||
| // Reserve a contiguous 2*size placeholder, then split it into two halves. | ||
| void *addr = VirtualAlloc2(nullptr, nullptr, size * 2, MEM_RESERVE | MEM_RESERVE_PLACEHOLDER, | ||
| PAGE_NOACCESS, nullptr, 0); | ||
| if (!addr) | ||
| return nullptr; | ||
| if (!VirtualFree(addr, size, MEM_RELEASE | MEM_PRESERVE_PLACEHOLDER)) | ||
| { | ||
| VirtualFree(addr, 0, MEM_RELEASE); | ||
| return nullptr; | ||
| } | ||
| HANDLE mapping = CreateFileMapping(INVALID_HANDLE_VALUE, nullptr, PAGE_READWRITE, | ||
| (DWORD)(size >> 32), (DWORD)(size & 0xFFFFFFFF), nullptr); | ||
| if (!mapping) | ||
| { | ||
| // After the split both halves are separate placeholders; free each individually. | ||
| VirtualFree(addr, 0, MEM_RELEASE); | ||
| VirtualFree((char *)addr + size, 0, MEM_RELEASE); | ||
| return nullptr; | ||
| } | ||
| // Map the same section into both placeholders. | ||
| void *v1 = MapViewOfFile3(mapping, nullptr, addr, 0, size, MEM_REPLACE_PLACEHOLDER, | ||
| PAGE_READWRITE, nullptr, 0); | ||
| if (!v1) | ||
| { | ||
| CloseHandle(mapping); | ||
| VirtualFree(addr, 0, MEM_RELEASE); | ||
| VirtualFree((char *)addr + size, 0, MEM_RELEASE); | ||
| return nullptr; | ||
| } | ||
| void *v2 = MapViewOfFile3(mapping, nullptr, (char *)addr + size, 0, size, MEM_REPLACE_PLACEHOLDER, | ||
| PAGE_READWRITE, nullptr, 0); | ||
| if (!v2) | ||
| { | ||
| UnmapViewOfFileEx(v1, MEM_PRESERVE_PLACEHOLDER); | ||
| VirtualFree(addr, 0, MEM_RELEASE); | ||
| VirtualFree((char *)addr + size, 0, MEM_RELEASE); | ||
| CloseHandle(mapping); | ||
| return nullptr; | ||
| } | ||
| ring_buf_t *rb = (ring_buf_t *)malloc(sizeof(ring_buf_t)); | ||
| if (!rb) | ||
| { | ||
| UnmapViewOfFileEx(v1, MEM_PRESERVE_PLACEHOLDER); | ||
| UnmapViewOfFileEx(v2, MEM_PRESERVE_PLACEHOLDER); | ||
| VirtualFree(addr, 0, MEM_RELEASE); | ||
| VirtualFree((char *)addr + size, 0, MEM_RELEASE); | ||
| CloseHandle(mapping); | ||
| return nullptr; | ||
| } | ||
| rb->refcount.store(1, std::memory_order_relaxed); | ||
| rb->size = size; | ||
| rb->addr = addr; | ||
| rb->mapping = mapping; | ||
| return rb; | ||
| } | ||
| static const char *ring_buf_errmsg(char *buf, size_t n) | ||
| { | ||
| snprintf(buf, n, "ring_alloc failed: Windows error %lu", (unsigned long)GetLastError()); | ||
| return buf; | ||
| } | ||
| #else // POSIX (Linux / macOS) | ||
| #define _GNU_SOURCE 1 | ||
| #include <fcntl.h> | ||
| #include <sys/mman.h> | ||
| #include <unistd.h> | ||
| #ifndef MAP_ANONYMOUS | ||
| #define MAP_ANONYMOUS MAP_ANON | ||
| #endif | ||
| #ifdef __linux__ | ||
| #include <sys/syscall.h> | ||
| // Huge-page constants — defined here as fallback for older glibc headers. | ||
| #ifndef MFD_HUGETLB | ||
| #define MFD_HUGETLB 0x0004U | ||
| #endif | ||
| #ifndef MFD_HUGE_2MB | ||
| #define MFD_HUGE_2MB (21U << 26) | ||
| #endif | ||
| #ifndef MAP_HUGETLB | ||
| #define MAP_HUGETLB 0x040000 | ||
| #endif | ||
| #ifndef MAP_HUGE_2MB | ||
| #define MAP_HUGE_2MB (21 << 26) | ||
| #endif | ||
| #define RING_HUGE_2MB (2UL * 1024 * 1024) | ||
| static int open_memfd(size_t size) | ||
| { | ||
| // MFD_CLOEXEC: prevent the fd from leaking into execve'd child processes. | ||
| int fd = (int)syscall(SYS_memfd_create, "ring", (unsigned)MFD_CLOEXEC); | ||
| if (fd < 0) | ||
| return -1; | ||
| if (ftruncate(fd, (off_t)size) < 0) | ||
| { | ||
| close(fd); | ||
| return -1; | ||
| } | ||
| return fd; | ||
| } | ||
| static int open_memfd_huge(size_t size) | ||
| { | ||
| // MFD_CLOEXEC: prevent the fd from leaking into execve'd child processes. | ||
| int fd = | ||
| (int)syscall(SYS_memfd_create, "ring", (unsigned)(MFD_CLOEXEC | MFD_HUGETLB | MFD_HUGE_2MB)); | ||
| if (fd < 0) | ||
| return -1; | ||
| if (ftruncate(fd, (off_t)size) < 0) | ||
| { | ||
| close(fd); | ||
| return -1; | ||
| } | ||
| return fd; | ||
| } | ||
| #elif defined(__APPLE__) | ||
| #include <sys/stat.h> | ||
| static std::atomic<int> shm_seq{0}; | ||
| static int open_memfd(size_t size) | ||
| { | ||
| char name[64]; | ||
| snprintf(name, sizeof(name), "/ring_%d_%d", (int)getpid(), | ||
| shm_seq.fetch_add(1, std::memory_order_relaxed)); | ||
| int fd = shm_open(name, O_RDWR | O_CREAT | O_EXCL, 0600); | ||
| if (fd < 0) | ||
| return -1; | ||
| shm_unlink(name); | ||
| if (ftruncate(fd, (off_t)size) < 0) | ||
| { | ||
| close(fd); | ||
| return -1; | ||
| } | ||
| return fd; | ||
| } | ||
| #else | ||
| #error "Unsupported platform" | ||
| #endif | ||
| // MAP_POPULATE is Linux-specific; define as 0 on platforms that lack it. | ||
| #ifndef MAP_POPULATE | ||
| #define MAP_POPULATE 0 | ||
| #endif | ||
| struct ring_buf_t | ||
| { | ||
| std::atomic<int> refcount; | ||
| size_t size; | ||
| void *addr; | ||
| }; | ||
| static void ring_buf_free(ring_buf_t *rb) | ||
| { | ||
| munmap(rb->addr, rb->size * 2); | ||
| free(rb); | ||
| } | ||
| // Maps fd (already ftruncated to size) into a contiguous 2*size virtual region. | ||
| // extra_flags is ORed into the data mmap calls only (e.g. MAP_HUGETLB|MAP_HUGE_2MB). | ||
| // Always consumes (closes) fd. | ||
| static ring_buf_t *ring_buf_alloc_from_fd(int fd, size_t size, int extra_flags) | ||
| { | ||
| // Reserve address space without huge-page flags — the reservation only needs | ||
| // virtual address range; applying MAP_HUGETLB here can cause spurious failures | ||
| // when the huge-page pool is exhausted even though no pages are faulted in yet. | ||
| void *addr = mmap(nullptr, size * 2, PROT_NONE, MAP_PRIVATE | MAP_ANONYMOUS, -1, 0); | ||
| if (addr == MAP_FAILED) | ||
| { | ||
| close(fd); | ||
| return nullptr; | ||
| } | ||
| // MAP_POPULATE: fault in all pages at mmap time so the hot path never takes a page-fault. | ||
| if (mmap(addr, size, PROT_READ | PROT_WRITE, MAP_FIXED | MAP_SHARED | MAP_POPULATE | extra_flags, | ||
| fd, 0) == MAP_FAILED || | ||
| mmap((char *)addr + size, size, PROT_READ | PROT_WRITE, | ||
| MAP_FIXED | MAP_SHARED | MAP_POPULATE | extra_flags, fd, 0) == MAP_FAILED) | ||
| { | ||
| munmap(addr, size * 2); | ||
| close(fd); | ||
| return nullptr; | ||
| } | ||
| close(fd); | ||
| ring_buf_t *rb = (ring_buf_t *)malloc(sizeof(ring_buf_t)); | ||
| if (!rb) | ||
| { | ||
| munmap(addr, size * 2); | ||
| return nullptr; | ||
| } | ||
| rb->refcount.store(1, std::memory_order_relaxed); | ||
| rb->size = size; | ||
| rb->addr = addr; | ||
| return rb; | ||
| } | ||
| static ring_buf_t *ring_buf_alloc(size_t requested_size) | ||
| { | ||
| #ifdef __linux__ | ||
| // Try 2 MiB explicit huge pages first (requires pre-allocated huge page pool). | ||
| // Falls back silently if the kernel/system doesn't support it. | ||
| // Skip if the allocation is much smaller than a huge page to avoid wasting memory. | ||
| size_t huge_size = (requested_size + RING_HUGE_2MB - 1) & ~(RING_HUGE_2MB - 1); | ||
| int huge_fd = (requested_size >= RING_HUGE_2MB / 2) ? open_memfd_huge(huge_size) : -1; | ||
| if (huge_fd >= 0) | ||
| { | ||
| ring_buf_t *rb = ring_buf_alloc_from_fd(huge_fd, huge_size, MAP_HUGETLB | MAP_HUGE_2MB); | ||
| if (rb) | ||
| { | ||
| // MADV_DONTFORK: exclude from forked children (see normal path below for rationale). | ||
| madvise(rb->addr, rb->size * 2, MADV_DONTFORK); | ||
| return rb; | ||
| } | ||
| } | ||
| #endif | ||
| long page = sysconf(_SC_PAGESIZE); | ||
| if (page <= 0) | ||
| page = 4096; | ||
| size_t size = (requested_size + (size_t)page - 1) & ~((size_t)page - 1); | ||
| int fd = open_memfd(size); | ||
| if (fd < 0) | ||
| return nullptr; | ||
| ring_buf_t *rb = ring_buf_alloc_from_fd(fd, size, 0); | ||
| #ifdef __linux__ | ||
| // Hint the kernel to back this region with transparent huge pages opportunistically. | ||
| if (rb) | ||
| madvise(rb->addr, rb->size * 2, MADV_HUGEPAGE); | ||
| #endif | ||
| // MADV_DONTFORK: exclude the ring buffer from forked child processes. | ||
| // Without this, fork() marks all pages copy-on-write; a single write in the | ||
| // child triggers physical page duplication even though the child has no use | ||
| // for the ring buffer. Guard with #ifdef because macOS SDK versions vary in | ||
| // whether MADV_DONTFORK is defined. | ||
| #ifdef MADV_DONTFORK | ||
| if (rb) | ||
| madvise(rb->addr, rb->size * 2, MADV_DONTFORK); | ||
| #endif | ||
| return rb; | ||
| } | ||
| static const char *ring_buf_errmsg(char *buf, size_t n) | ||
| { | ||
| snprintf(buf, n, "ring_alloc failed: %s", strerror(errno)); | ||
| return buf; | ||
| } | ||
| #endif // _WIN32 | ||
| // ─── Reference counting ────────────────────────────────────────────────────── | ||
| static void ring_buf_acquire(ring_buf_t *rb) | ||
| { | ||
| rb->refcount.fetch_add(1, std::memory_order_relaxed); | ||
| } | ||
| static void ring_buf_release(ring_buf_t *rb) | ||
| { | ||
| if (rb->refcount.fetch_sub(1, std::memory_order_acq_rel) == 1) | ||
| { | ||
| ring_buf_free(rb); | ||
| } | ||
| } | ||
| // ─── State buffer layout ───────────────────────────────────────────────────── | ||
| // | ||
| // The SharedArrayBuffer returned by ring_alloc uses a 128-byte state block | ||
| // allocated here. Layout (each slot = one uint32): | ||
| // | ||
| // Cache line 0 (bytes 0–63): | ||
| // [0] WRITE_INDEX (written by JS Writer, zero-initialised here) | ||
| // [1] MAGIC1 = 0x52494E47 ('RING') | ||
| // [2] MAGIC2 = 0x42554646 ('BUFF') | ||
| // [3] PTR_LO lower 32 bits of ring_buf_t* | ||
| // [4] PTR_HI upper 32 bits of ring_buf_t* | ||
| // [5..15] padding | ||
| // | ||
| // Cache line 1 (bytes 64–127): | ||
| // [16] READ_INDEX (written by JS Reader, zero-initialised here) | ||
| // [17..31] padding | ||
| // | ||
| // Total: 32 × 4 = 128 bytes — matches STATE_BYTES in index.ts. | ||
| static const size_t STATE_BYTES = 128; | ||
| static const uint32_t MAGIC1 = 0x52494E47; | ||
| static const uint32_t MAGIC2 = 0x42554646; | ||
| static const size_t MAGIC1_INDEX = 1; | ||
| static const size_t MAGIC2_INDEX = 2; | ||
| static const size_t PTR_LO_INDEX = 3; | ||
| static const size_t PTR_HI_INDEX = 4; | ||
| // BackingStore deleter for the state SharedArrayBuffer. Called by V8 when the | ||
| // last isolate that holds the SAB drops its reference (cross-thread ref-count | ||
| // reaches zero). Frees the state block and releases the native ring buffer. | ||
| // Safe to call from any thread — only C stdlib and atomic ops are used. | ||
| static void state_deleter(void *data, size_t /*length*/, void *hint) | ||
| { | ||
| free(data); | ||
| ring_buf_release(static_cast<ring_buf_t *>(hint)); | ||
| } | ||
| // BackingStore deleter for per-instance ArrayBuffers returned by | ||
| // ring_get_array_buffer. Called by the isolate's GC when the ArrayBuffer | ||
| // (and all its views) are collected. | ||
| static void data_deleter(void * /*data*/, size_t /*length*/, void *hint) | ||
| { | ||
| ring_buf_release(static_cast<ring_buf_t *>(hint)); | ||
| } | ||
| // ─── Helper: extract ring_buf_t* from a SharedHandle ───────────────────────── | ||
| // Reads and validates ring_buf_t* from a SharedArrayBuffer created by | ||
| // ring_alloc. Returns nullptr and throws a TypeError on failure. | ||
| static ring_buf_t *rb_from_handle(v8::Local<v8::Value> val, const char *ctx) | ||
| { | ||
| if (!val->IsSharedArrayBuffer()) | ||
| { | ||
| char msg[128]; | ||
| snprintf(msg, sizeof(msg), "%s: expected SharedArrayBuffer handle", ctx); | ||
| Nan::ThrowTypeError(msg); | ||
| return nullptr; | ||
| } | ||
| auto sab = val.As<v8::SharedArrayBuffer>(); | ||
| auto bs = sab->GetBackingStore(); | ||
| if (!bs || bs->ByteLength() < STATE_BYTES) | ||
| { | ||
| char msg[128]; | ||
| snprintf(msg, sizeof(msg), "%s: invalid handle (too small)", ctx); | ||
| Nan::ThrowTypeError(msg); | ||
| return nullptr; | ||
| } | ||
| const uint32_t *s = static_cast<const uint32_t *>(bs->Data()); | ||
| if (s[MAGIC1_INDEX] != MAGIC1 || s[MAGIC2_INDEX] != MAGIC2) | ||
| { | ||
| char msg[128]; | ||
| snprintf(msg, sizeof(msg), "%s: invalid handle (bad magic)", ctx); | ||
| Nan::ThrowTypeError(msg); | ||
| return nullptr; | ||
| } | ||
| uint64_t lo = s[PTR_LO_INDEX]; | ||
| uint64_t hi = s[PTR_HI_INDEX]; | ||
| return reinterpret_cast<ring_buf_t *>(static_cast<uintptr_t>((hi << 32) | lo)); | ||
| } | ||
| // ─── NAN methods ───────────────────────────────────────────────────────────── | ||
| // ring_alloc(size: number): SharedArrayBuffer | ||
| // | ||
| // Allocates a double-mapped ring buffer and a 192-byte state block. | ||
| // Returns a SharedArrayBuffer wrapping the state block. The SAB's | ||
| // BackingStore uses V8's cross-isolate ref-count: when the last thread that | ||
| // holds the SAB drops it, state_deleter fires — freeing the state block and | ||
| // releasing the native ring_buf_t. No explicit ring_free needed from JS. | ||
| NAN_METHOD(ring_alloc) | ||
| { | ||
| if (info.Length() < 1 || !info[0]->IsUint32()) | ||
| { | ||
| Nan::ThrowTypeError("ring_alloc: size must be a positive integer"); | ||
| return; | ||
| } | ||
| uint32_t req_size = Nan::To<uint32_t>(info[0]).FromMaybe(0u); | ||
| if (req_size == 0) | ||
| { | ||
| Nan::ThrowRangeError("ring_alloc: size must be positive"); | ||
| return; | ||
| } | ||
| ring_buf_t *rb = ring_buf_alloc((size_t)req_size); | ||
| if (!rb) | ||
| { | ||
| char msg[128]; | ||
| Nan::ThrowError(ring_buf_errmsg(msg, sizeof(msg))); | ||
| return; | ||
| } | ||
| // Allocate the state block. calloc zero-initialises it so WRITE_INDEX and | ||
| // READ_INDEX start at 0 without any extra memset. | ||
| void *state = calloc(1, STATE_BYTES); | ||
| if (!state) | ||
| { | ||
| ring_buf_release(rb); | ||
| Nan::ThrowError("ring_alloc: out of memory for state buffer"); | ||
| return; | ||
| } | ||
| uint32_t *s = static_cast<uint32_t *>(state); | ||
| s[MAGIC1_INDEX] = MAGIC1; | ||
| s[MAGIC2_INDEX] = MAGIC2; | ||
| uintptr_t p = reinterpret_cast<uintptr_t>(rb); | ||
| s[PTR_LO_INDEX] = static_cast<uint32_t>(p & 0xFFFFFFFFu); | ||
| s[PTR_HI_INDEX] = static_cast<uint32_t>(static_cast<uint64_t>(p) >> 32); | ||
| // V8 ref-counts the BackingStore across isolates. When the last isolate | ||
| // that holds this SAB GC's it, state_deleter is called — regardless of | ||
| // which thread runs that GC. state_deleter only performs C stdlib calls | ||
| // and atomic operations, so it is safe to invoke from any thread. | ||
| auto backing = v8::SharedArrayBuffer::NewBackingStore(state, STATE_BYTES, state_deleter, rb); | ||
| auto sab = v8::SharedArrayBuffer::New(info.GetIsolate(), std::move(backing)); | ||
| info.GetReturnValue().Set(sab); | ||
| } | ||
| // ring_get_array_buffer(handle: SharedArrayBuffer): ArrayBuffer | ||
| // | ||
| // Returns an ArrayBuffer backed by the double-mapped data region (2 * size | ||
| // bytes). Bumps the native refcount; data_deleter releases it when the | ||
| // ArrayBuffer (and all views derived from it) is GC'd in this isolate. | ||
| NAN_METHOD(ring_get_array_buffer) | ||
| { | ||
| if (info.Length() < 1) | ||
| { | ||
| Nan::ThrowTypeError("ring_get_array_buffer: expected 1 argument"); | ||
| return; | ||
| } | ||
| ring_buf_t *rb = rb_from_handle(info[0], "ring_get_array_buffer"); | ||
| if (!rb) | ||
| return; | ||
| ring_buf_acquire(rb); | ||
| auto backing = v8::ArrayBuffer::NewBackingStore(rb->addr, rb->size * 2, data_deleter, rb); | ||
| auto ab = v8::ArrayBuffer::New(info.GetIsolate(), std::move(backing)); | ||
| info.GetReturnValue().Set(ab); | ||
| } | ||
| // ring_is_int32_lock_free(): boolean | ||
| // | ||
| // Returns true if std::atomic<uint32_t> is always lock-free on this | ||
| // architecture, meaning aligned 32-bit loads/stores are single hardware | ||
| // instructions and cannot tear. Safe to skip Atomics.load/store when this | ||
| // returns true and the access is aligned. | ||
| NAN_METHOD(ring_is_int32_lock_free) | ||
| { | ||
| info.GetReturnValue().Set(std::atomic<uint32_t>::is_always_lock_free); | ||
| } | ||
| // NODE_MODULE_INIT() registers a context-aware addon that can be loaded in | ||
| // both the main thread and worker threads. NODE_MODULE() (non-context-aware) | ||
| // would fail with "Module did not self-register" in worker threads. | ||
| NODE_MODULE_INIT() | ||
| { | ||
| NAN_EXPORT(exports, ring_alloc); | ||
| NAN_EXPORT(exports, ring_get_array_buffer); | ||
| NAN_EXPORT(exports, ring_is_int32_lock_free); | ||
| } |
+6
-9
@@ -13,9 +13,9 @@ export interface BufferRegion { | ||
| } | ||
| declare const __sharedHandle: unique symbol; | ||
| declare const _handle: unique symbol; | ||
| /** | ||
| * Opaque handle to a ring buffer's SharedArrayBuffer. Use `alloc` | ||
| * to create one; pass it directly to `Reader` and `Writer`. | ||
| * Opaque handle to a ring buffer. A branded SharedArrayBuffer that embeds the | ||
| * native pointer and state indices. Pass between threads via `workerData`. | ||
| */ | ||
| export type SharedHandle = SharedArrayBuffer & { | ||
| readonly [__sharedHandle]: void; | ||
| readonly [_handle]: void; | ||
| }; | ||
@@ -30,7 +30,5 @@ /** | ||
| readBytes: number; | ||
| wrapCount: number; | ||
| }; | ||
| get handle(): SharedHandle; | ||
| constructor(handleOrSize: SharedHandle | number); | ||
| [Symbol.dispose](): void; | ||
| readSome(next: (data: BufferRegion) => void | boolean): number; | ||
@@ -49,8 +47,6 @@ readSome<U>(next: (data: BufferRegion, opaque: U) => void | boolean, opaque: U): number; | ||
| writeBytes: number; | ||
| wrapCount: number; | ||
| wrapBytes: number; | ||
| }; | ||
| get handle(): SharedHandle; | ||
| get maxMessageSize(): number; | ||
| constructor(handleOrSize: SharedHandle | number, { yield: onYield, logger }?: WriterOptions); | ||
| [Symbol.dispose](): void; | ||
| /** | ||
@@ -74,2 +70,3 @@ * Synchronously writes a message. Blocks (via `Atomics.wait`) until buffer space is available. | ||
| cork<T>(callback: () => T): T; | ||
| cork<T, O>(callback: (opaque: O) => T, opaque: O): T; | ||
| /** | ||
@@ -76,0 +73,0 @@ * Publishes the pending write position to the reader. |
+188
-193
@@ -7,2 +7,19 @@ // By placing the read and write indices far apart (multiples of a common | ||
| // SharedArrayBuffer layout (each unit = one Int32 = 4 bytes). | ||
| // The native ring_alloc writes slots [1..4]; JS owns [0] and [16]. | ||
| // | ||
| // Cache line 0 (bytes 0–63): | ||
| // [0] WRITE_INDEX ─ cache-line aligned (written by Writer) | ||
| // [1] MAGIC1 = 0x52494E47 ('RING') (written by native, read by native) | ||
| // [2] MAGIC2 = 0x42554646 ('BUFF') | ||
| // [3] PTR_LO lower 32 bits of native ptr | ||
| // [4] PTR_HI upper 32 bits of native ptr | ||
| // [5..15] padding | ||
| // | ||
| // Cache line 1 (bytes 64–127): | ||
| // [16] READ_INDEX ─ cache-line aligned (written by Reader) | ||
| // [17..31] padding | ||
| // | ||
| // Total: 32 × 4 = 128 bytes | ||
| // Int32 is 4 bytes, so an index of 16 means 16 * 4 = 64 bytes offset. | ||
@@ -12,13 +29,52 @@ const WRITE_INDEX = 0 | ||
| // The first 128 bytes of the buffer are reserved for state (read/write pointers). | ||
| // Data starts at byte offset 128. | ||
| const STATE_BYTES = 128 | ||
| // High-Water Mark for batching operations to reduce the frequency | ||
| // of expensive atomic writes. | ||
| const HWM_BYTES = 256 * 1024 // 256 KiB | ||
| const HWM_COUNT = 1024 // 1024 items | ||
| const isProduction = process.env.NODE_ENV === 'production' | ||
| // Dynamic import via Function to prevent TypeScript from following the module | ||
| // path for rootDir computation. binding.js uses node-gyp-build to load the .node addon. | ||
| // oxlint-disable-next-line no-implied-eval -- intentional: prevents TS static module resolution | ||
| const _dynamicImport = new Function('url', 'return import(url)') | ||
| const native = ( | ||
| (await _dynamicImport(new URL('../binding.js', import.meta.url).href)) | ||
| ).default | ||
| // This module intentionally uses plain (non-atomic) reads and writes to | ||
| // SharedArrayBuffer indices in several hot paths to avoid the memory-barrier | ||
| // overhead of Atomics.load / Atomics.store. Two architectural properties must | ||
| // hold for this to be correct: | ||
| // | ||
| // 1. No tearing: a plain 32-bit aligned store/load must be a single | ||
| // indivisible hardware instruction, so the other side never observes a | ||
| // half-written value. Verified at startup by ring_is_int32_lock_free() | ||
| // below (true on x86-64 and ARM64). | ||
| // | ||
| // 2. Visibility: x86 / x86-64 implements Total Store Order (TSO), which | ||
| // guarantees that a plain store is observed by a subsequent Atomics.load | ||
| // on another core without any explicit fence. ARM64 has a weaker memory | ||
| // model (plain stores emit bare `str`, not `stlr`), so a plain store is | ||
| // not formally guaranteed to be seen by a remote `ldar` (Atomics.load). | ||
| // In practice V8 on ARM64 relies on cache-coherence hardware to make | ||
| // plain SAB stores visible quickly, and the protocol is designed so that | ||
| // a missed update only causes an extra yield/wait loop, never corruption. | ||
| // This is therefore safe but is not spec-guaranteed on ARM64. | ||
| if (!native.ring_is_int32_lock_free()) { | ||
| throw new Error( | ||
| 'nxtedition/shared: std::atomic<uint32_t> is not always lock-free on this architecture — ' + | ||
| 'plain (non-atomic) 32-bit reads/writes may tear. Unsupported platform.', | ||
| ) | ||
| } | ||
@@ -36,16 +92,14 @@ | ||
| /** | ||
| * Opaque handle to a ring buffer's SharedArrayBuffer. Use `alloc` | ||
| * to create one; pass it directly to `Reader` and `Writer`. | ||
| * Opaque handle to a ring buffer. A branded SharedArrayBuffer that embeds the | ||
| * native pointer and state indices. Pass between threads via `workerData`. | ||
| */ | ||
| /** | ||
| * Allocates a ring buffer handle. The first 128 bytes are reserved for | ||
| * read/write pointers; the rest is the data region. | ||
| * | ||
| * The `size` parameter is the guaranteed max payload for a single write. | ||
| * Overhead (length header + alignment + next-header slot) is added automatically. | ||
| * Allocates a ring buffer handle. The `size` parameter is the guaranteed max | ||
| * payload for a single write. Overhead (length header + alignment) is added | ||
| * automatically, and the data region is rounded up to a page boundary. | ||
| */ | ||
@@ -59,9 +113,16 @@ function alloc(size ) { | ||
| } | ||
| // Total allocation: STATE_BYTES (128) + aligned payload + 4-byte header + 4-byte next-header slot. | ||
| // SharedArrayBuffer max is 2GB (2**31), so the payload limit is 2**31 - STATE_BYTES - 8. | ||
| if (size > 2 ** 31 - STATE_BYTES - 8) { | ||
| // dataSize is a lower bound passed to the native allocator, which rounds it | ||
| // up to a page boundary. The extra 8 bytes are: 4-byte length header + | ||
| // 4-byte minimum gap ensuring writePos can never equal readPos (full vs. empty). | ||
| // maxMessageSize = physicalSize - 8 >= size. | ||
| const dataSize = ((size + 3) & ~3) + 8 | ||
| // Max size: data region must fit in a signed 32-bit index. | ||
| if (dataSize > 2 ** 31) { | ||
| throw new RangeError('size exceeds maximum of 2GB') | ||
| } | ||
| // Data region: aligned payload + 4-byte length header + 4-byte next-header slot. | ||
| return new SharedArrayBuffer(STATE_BYTES + ((size + 3) & ~3) + 8) | ||
| // ring_alloc returns a SharedArrayBuffer whose BackingStore holds the state | ||
| // block and a custom V8 deleter. V8 ref-counts the BackingStore across all | ||
| // isolates that hold the SAB; when the last one drops it, the deleter fires | ||
| // and releases the native ring_buf_t — no FinalizationRegistry needed. | ||
| return native.ring_alloc(dataSize) | ||
| } | ||
@@ -78,2 +139,4 @@ | ||
| #handle | ||
| #readPos | ||
| #writePos | ||
@@ -83,6 +146,5 @@ #stats = { | ||
| readBytes: 0, | ||
| wrapCount: 0, | ||
| } | ||
| get stats() { | ||
| get stats() { | ||
| return this.#stats | ||
@@ -96,14 +158,15 @@ } | ||
| constructor(handleOrSize ) { | ||
| if (typeof handleOrSize === 'number') { | ||
| this.#handle = alloc(handleOrSize) | ||
| } else { | ||
| this.#handle = handleOrSize | ||
| } | ||
| this.#handle = typeof handleOrSize === 'number' ? alloc(handleOrSize) : handleOrSize | ||
| const sharedBuffer = this.#handle | ||
| const size = sharedBuffer.byteLength - STATE_BYTES | ||
| // ring_get_array_buffer bumps the native refcount and registers a V8 | ||
| // ArrayBuffer finalizer that releases it, so the native ring_buf_t stays | ||
| // alive for at least as long as this Reader's dataBuffer is reachable. | ||
| const dataBuffer = native.ring_get_array_buffer(this.#handle) | ||
| this.#state = new Int32Array(sharedBuffer, 0, STATE_BYTES >> 2) | ||
| // The native buffer is 2 * physicalSize bytes (double-mapped). | ||
| const size = dataBuffer.byteLength >> 1 | ||
| this.#state = new Int32Array(this.#handle) | ||
| this.#size = size | ||
| this.#int32 = new Int32Array(sharedBuffer, STATE_BYTES) | ||
| this.#int32 = new Int32Array(dataBuffer) | ||
@@ -114,13 +177,10 @@ // This object is reused to avoid creating new objects in a hot path. | ||
| this.#data = { | ||
| buffer: Buffer.from(sharedBuffer, STATE_BYTES, size), | ||
| view: new DataView(sharedBuffer, STATE_BYTES, size), | ||
| buffer: Buffer.from(dataBuffer), | ||
| view: new DataView(dataBuffer), | ||
| byteOffset: 0, | ||
| byteLength: 0, | ||
| } | ||
| } | ||
| [Symbol.dispose]() { | ||
| // No resources to clean up in this implementation, but this method is defined | ||
| // to allow for future enhancements (e.g. if we add event listeners or other | ||
| // resources that need explicit cleanup). | ||
| this.#readPos = this.#state[READ_INDEX] | 0 | ||
| this.#writePos = this.#state[WRITE_INDEX] | 0 | ||
| } | ||
@@ -139,50 +199,54 @@ | ||
| let readPos = state[READ_INDEX] | 0 | ||
| let writePos = state[WRITE_INDEX] | 0 | ||
| if (this.#readPos === this.#writePos) { | ||
| // Intentional non-atomic plain read (see TSO note at top of file). | ||
| // A stale WRITE_INDEX at worst means returning 0 messages; the reader | ||
| // will be called again on the next tick. The writer always publishes via | ||
| // Atomics.store (release), so property 1 (no tearing) suffices here — | ||
| // the reader simply re-checks on the next call if the store hasn't | ||
| // propagated yet (property 2). | ||
| this.#writePos = state[WRITE_INDEX] | 0 | ||
| } | ||
| // Process messages in a batch to minimize loop and atomic operation overhead. | ||
| while (count < HWM_COUNT && bytes < HWM_BYTES && readPos !== writePos) { | ||
| const dataLen = int32[readPos >> 2] | 0 | ||
| const dataPos = readPos + 4 | ||
| while (bytes < HWM_BYTES && this.#readPos !== this.#writePos) { | ||
| const dataLen = int32[this.#readPos >> 2] | 0 | ||
| if (!isProduction && (dataLen < 0 || dataLen > size - 8)) { | ||
| throw new Error( | ||
| `Corrupt ring buffer: invalid message length ${dataLen} at position ${this.#readPos}`, | ||
| ) | ||
| } | ||
| const alignedLen = (dataLen + 3) & ~3 | ||
| const dataPos = this.#readPos + 4 | ||
| bytes += 4 | ||
| // Advance read position with modulo wrap — the double-mapped virtual | ||
| // buffer makes the data access at dataPos physically contiguous even | ||
| // when it spans the end of the logical ring. | ||
| this.#readPos = (this.#readPos + 4 + alignedLen) % size | ||
| // A length of -1 is a special marker indicating the writer has | ||
| // wrapped around to the beginning of the buffer. | ||
| if (dataLen === -1) { | ||
| this.#stats.wrapCount += 1 | ||
| readPos = 0 | ||
| // After wrapping, we must re-check against the writer's position. | ||
| // It's possible the writer is now at a position > 0. | ||
| writePos = state[WRITE_INDEX] | 0 | ||
| } else { | ||
| if (dataLen < 0) { | ||
| throw new Error('Invalid data length') | ||
| } | ||
| if (dataPos + dataLen > size) { | ||
| throw new Error('Data exceeds buffer size') | ||
| } | ||
| bytes += 4 + alignedLen | ||
| count += 1 | ||
| // Advance by aligned length so next header is on a 4-byte boundary. | ||
| const alignedLen = (dataLen + 3) & ~3 | ||
| readPos += 4 + alignedLen | ||
| // This is a "zero-copy" operation. We don't copy the data out. | ||
| // Instead, we pass a "view" into the shared buffer. | ||
| // NOTE: byteOffset can be >= physicalSize (i.e. >= dataBuffer.byteLength / 2) for | ||
| // messages that span the wrap boundary — the double-mapped virtual buffer makes | ||
| // those bytes physically contiguous. Do not compare byteOffset against the physical | ||
| // ring size; the full double-mapped range is always valid. | ||
| data.byteOffset = dataPos | ||
| data.byteLength = dataLen | ||
| bytes += alignedLen | ||
| count += 1 | ||
| // This is a "zero-copy" operation. We don't copy the data out. | ||
| // Instead, we pass a "view" into the shared buffer. | ||
| data.byteOffset = dataPos | ||
| data.byteLength = dataLen | ||
| if (next(data, opaque) === false) { | ||
| break | ||
| } | ||
| if (next(data, opaque) === false) { | ||
| break | ||
| } | ||
| } | ||
| // IMPORTANT: The reader only updates its shared `readPos` after a batch | ||
| // is processed. This significantly reduces shared memory overhead. | ||
| // Intentional non-atomic plain write (see TSO note at top of file). | ||
| // The writer reads READ_INDEX either via Atomics.wait (which wakes on | ||
| // notify, not used here) with a timeout, or via a plain read in the fast | ||
| // path and Atomics.load in the slow path. A delayed store propagation | ||
| // (property 2, ARM64) only causes the writer to loop one extra time — | ||
| // correctness is never affected. Atomics.store is deliberately avoided | ||
| // to keep the reader hot path free of memory-barrier overhead. | ||
| if (bytes > 0) { | ||
| state[READ_INDEX] = readPos | 0 | ||
| state[READ_INDEX] = this.#readPos | 0 | ||
| } | ||
@@ -220,7 +284,5 @@ | ||
| writeBytes: 0, | ||
| wrapCount: 0, | ||
| wrapBytes: 0, | ||
| } | ||
| get stats() { | ||
| get stats() { | ||
| return this.#stats | ||
@@ -233,9 +295,7 @@ } | ||
| get maxMessageSize() { | ||
| return this.#size - 8 | ||
| } | ||
| constructor(handleOrSize , { yield: onYield, logger } = {}) { | ||
| if (typeof handleOrSize === 'number') { | ||
| this.#handle = alloc(handleOrSize) | ||
| } else { | ||
| this.#handle = handleOrSize | ||
| } | ||
| if (onYield != null && typeof onYield !== 'function') { | ||
@@ -245,8 +305,14 @@ throw new TypeError('onYield must be a function') | ||
| const sharedBuffer = this.#handle | ||
| const size = sharedBuffer.byteLength - STATE_BYTES | ||
| this.#handle = typeof handleOrSize === 'number' ? alloc(handleOrSize) : handleOrSize | ||
| this.#state = new Int32Array(sharedBuffer, 0, STATE_BYTES >> 2) | ||
| // ring_get_array_buffer bumps the native refcount and registers a V8 | ||
| // ArrayBuffer finalizer that releases it, so the native ring_buf_t stays | ||
| // alive for at least as long as this Writer's dataBuffer is reachable. | ||
| const dataBuffer = native.ring_get_array_buffer(this.#handle) | ||
| const size = dataBuffer.byteLength >> 1 | ||
| this.#state = new Int32Array(this.#handle) | ||
| this.#size = size | ||
| this.#int32 = new Int32Array(sharedBuffer, STATE_BYTES) | ||
| this.#int32 = new Int32Array(dataBuffer) | ||
@@ -257,4 +323,4 @@ // This object is reused to avoid creating new objects in a hot path. | ||
| this.#data = { | ||
| buffer: Buffer.from(sharedBuffer, STATE_BYTES, size), | ||
| view: new DataView(sharedBuffer, STATE_BYTES, size), | ||
| buffer: Buffer.from(dataBuffer), | ||
| view: new DataView(dataBuffer), | ||
| byteOffset: 0, | ||
@@ -277,9 +343,2 @@ byteLength: 0, | ||
| [Symbol.dispose]() { | ||
| this.flushSync() | ||
| // No resources to clean up in this implementation, but this method is defined | ||
| // to allow for future enhancements (e.g. if we add event listeners or other | ||
| // resources that need explicit cleanup). | ||
| } | ||
| /** | ||
@@ -302,5 +361,4 @@ * Pauses the writer thread to wait for the reader to catch up. | ||
| try { | ||
| // Call the user-provided yield function, if any. This can be important | ||
| // if the writer is waiting for the reader to process data which would | ||
| // otherwise deadlock. | ||
| // Call the user-provided yield function to allow the reader to consume | ||
| // data and advance the read position, freeing space for the writer. | ||
| this.#onYield() | ||
@@ -321,3 +379,3 @@ } finally { | ||
| // After waking up, refresh the local view of the reader's position. | ||
| this.#readPos = this.#state[READ_INDEX] | 0 | ||
| this.#readPos = Atomics.load(this.#state, READ_INDEX) | 0 | ||
| } | ||
@@ -327,66 +385,28 @@ | ||
| * Tries to acquire enough space in the buffer for a new message. | ||
| * | ||
| * With the virtual memory double-mapping, writes can span the physical end | ||
| * of the buffer — no sentinel value is needed. The free-space check is a | ||
| * single modular formula valid for all writer/reader position combinations. | ||
| */ | ||
| #acquire(len ) { | ||
| // Total space required: aligned payload + its 4-byte length header + a potential | ||
| // 4-byte header for the *next* message (for wrap-around check). | ||
| const required = ((len + 3) & ~3) + 4 + 4 | ||
| // Total space required: 4-byte length header + aligned payload. | ||
| // No extra sentinel slot needed — the double-mapped region handles wrap. | ||
| const required = ((len + 3) & ~3) + 4 | ||
| const size = this.#size | ||
| const state = this.#state | ||
| const int32 = this.#int32 | ||
| if (this.#writePos >= this.#readPos) { | ||
| // Case 1: The writer is ahead of the reader. [ 0 - R ... W - size ] | ||
| // There is free space from W to the end (s) and from 0 to R. | ||
| if (size - this.#writePos >= required) { | ||
| // Enough space at the end of the buffer. | ||
| return true | ||
| } | ||
| this.#readPos = state[READ_INDEX] | 0 | ||
| if (this.#readPos < 4) { | ||
| this.#yield(0) | ||
| } | ||
| // Not enough space at the end. Check if there's space at the beginning. | ||
| if (this.#readPos < 4) { | ||
| // Reader is at the beginning, so no space to wrap around into. | ||
| return false | ||
| } | ||
| // Mark the current position with a wrap-around signal (-1). | ||
| int32[this.#writePos >> 2] = -1 | ||
| this.#stats.wrapCount += 1 | ||
| this.#stats.wrapBytes += size - this.#writePos | ||
| // Reset writer position to the beginning. | ||
| this.#writePos = 0 | ||
| if (!isProduction && this.#writePos + 4 > size) { | ||
| // assertion | ||
| throw new Error( | ||
| `Write position ${this.#writePos} with next header exceeds buffer size ${size}`, | ||
| ) | ||
| } | ||
| if (!isProduction && this.#writePos === this.#readPos) { | ||
| // assertion | ||
| throw new Error( | ||
| `Write position ${this.#writePos} cannot equal read position ${this.#readPos}`, | ||
| ) | ||
| } | ||
| Atomics.store(state, WRITE_INDEX, this.#writePos) | ||
| this.#pending = 0 | ||
| // Fast path: check with the locally cached readPos first. | ||
| const usedCached = (this.#writePos - this.#readPos + size) % size | ||
| if (size - usedCached > required) { | ||
| return true | ||
| } | ||
| // Case 2: The writer has wrapped around. [ 0 ... W - R ... s ] | ||
| // The only free space is between W and R. | ||
| // Slow path: refresh readPos from shared state and re-check. | ||
| // Intentional non-atomic plain read (see TSO note at top of file). | ||
| // A stale value (property 2, ARM64) only causes one extra yield; the | ||
| // definitive re-read after Atomics.wait uses Atomics.load (line below in | ||
| // #yield), which has acquire semantics and will see the latest value. | ||
| this.#readPos = state[READ_INDEX] | 0 | ||
| if (this.#readPos - this.#writePos < required) { | ||
| this.#yield(0) | ||
| } | ||
| return this.#readPos - this.#writePos >= required | ||
| const used = (this.#writePos - this.#readPos + size) % size | ||
| return size - used > required | ||
| } | ||
@@ -419,15 +439,8 @@ | ||
| const size = this.#size | ||
| if (!isProduction && dataPos + dataLen > size) { | ||
| // assertion | ||
| throw new Error(`Data position ${dataPos} with length ${dataLen} exceeds buffer size ${size}`) | ||
| } | ||
| const alignedLen = (dataLen + 3) & ~3 | ||
| const nextPos = this.#writePos + 4 + alignedLen | ||
| // Advance write position with modulo wrap — the double-mapped virtual | ||
| // buffer ensures the data bytes are physically contiguous even when they | ||
| // span the end of the logical ring. | ||
| const nextPos = (this.#writePos + 4 + alignedLen) % this.#size | ||
| if (!isProduction && nextPos + 4 > size) { | ||
| // assertion | ||
| throw new Error(`Write position ${nextPos} with next header exceeds buffer size ${size}`) | ||
| } | ||
| if (!isProduction && nextPos === this.#readPos) { | ||
@@ -440,3 +453,3 @@ // assertion | ||
| this.#int32[this.#writePos >> 2] = dataLen | ||
| this.#writePos += 4 + alignedLen | ||
| this.#writePos = nextPos | ||
| this.#pending += 4 + alignedLen | ||
@@ -473,6 +486,3 @@ | ||
| } | ||
| const size = this.#size | ||
| // Each message occupies: 4-byte length header + aligned payload + 4-byte slot for the | ||
| // next message's header (needed for wrap-around sentinel). So the maximum payload is size - 8. | ||
| const maxLen = size - 8 | ||
| const maxLen = this.#size - 8 | ||
| if (len > maxLen) { | ||
@@ -487,2 +497,3 @@ throw new Error(`"len" ${len} exceeds maximum allowed size ${maxLen}`) | ||
| const startTime = performance.now() | ||
| this.#yield(0) | ||
| let yieldCount = 0 | ||
@@ -513,9 +524,2 @@ let yieldTime = 0 | ||
| this.#write(len, fn, opaque) | ||
| if (!isProduction && this.#writePos === this.#readPos) { | ||
| // assertion | ||
| throw new Error( | ||
| `Write position ${this.#writePos} cannot equal read position ${this.#readPos}`, | ||
| ) | ||
| } | ||
| } | ||
@@ -536,6 +540,3 @@ | ||
| } | ||
| const size = this.#size | ||
| // Each message occupies: 4-byte length header + aligned payload + 4-byte slot for the | ||
| // next message's header (needed for wrap-around sentinel). So the maximum payload is size - 8. | ||
| const maxLen = size - 8 | ||
| const maxLen = this.#size - 8 | ||
| if (len > maxLen) { | ||
@@ -554,9 +555,2 @@ throw new Error(`"len" ${len} exceeds maximum allowed size ${maxLen}`) | ||
| if (!isProduction && this.#writePos === this.#readPos) { | ||
| // assertion | ||
| throw new Error( | ||
| `Write position ${this.#writePos} cannot equal read position ${this.#readPos}`, | ||
| ) | ||
| } | ||
| return true | ||
@@ -571,7 +565,8 @@ } | ||
| cork (callback ) { | ||
| cork (callback , opaque ) { | ||
| this.#corked += 1 | ||
| if (callback != null) { | ||
| try { | ||
| return callback() | ||
| return callback(opaque) | ||
| } finally { | ||
@@ -578,0 +573,0 @@ this.uncork() |
+24
-6
| { | ||
| "name": "@nxtedition/shared", | ||
| "version": "5.0.2", | ||
| "version": "5.0.3", | ||
| "type": "module", | ||
@@ -9,2 +9,6 @@ "main": "lib/index.js", | ||
| "lib", | ||
| "binding.js", | ||
| "binding.gyp", | ||
| "src/ring.cc", | ||
| "prebuilds", | ||
| "README.md", | ||
@@ -17,14 +21,28 @@ "LICENSE" | ||
| }, | ||
| "engines": { | ||
| "node": ">=21.2.0" | ||
| }, | ||
| "gypfile": true, | ||
| "scripts": { | ||
| "install": "node-gyp-build", | ||
| "build": "rimraf lib && tsc && amaroc ./src/index.ts && mv src/index.js lib/", | ||
| "rebuild": "JOBS=max npm run install --build-from-source", | ||
| "prepublishOnly": "yarn build", | ||
| "typecheck": "tsc --noEmit", | ||
| "test": "node --test", | ||
| "test": "JOBS=max npm run install --build-from-source && node --test", | ||
| "test:ci": "node --test", | ||
| "test:types": "tsd" | ||
| "test:types": "tsd", | ||
| "format:native": "clang-format -i src/*.cc", | ||
| "format:native:check": "clang-format -n --Werror src/*.cc" | ||
| }, | ||
| "dependencies": { | ||
| "nan": "^2.25.0", | ||
| "node-gyp-build": "^4.8.2" | ||
| }, | ||
| "devDependencies": { | ||
| "@types/node": "^25.2.3", | ||
| "@types/node": "^25.5.0", | ||
| "amaroc": "^1.0.1", | ||
| "oxlint-tsgolint": "^0.13.0", | ||
| "clang-format-node": "^3.0.0", | ||
| "node-gyp": "^12.1.0", | ||
| "oxlint-tsgolint": "^0.17.0", | ||
| "rimraf": "^6.1.3", | ||
@@ -34,3 +52,3 @@ "tsd": "^0.33.0", | ||
| }, | ||
| "gitHead": "9547032a3fa363bb353777a1b19b0852013459c5" | ||
| "gitHead": "7c9c7457c885c644c7a1e70ef894d4727ce240d6" | ||
| } |
+47
-45
@@ -13,2 +13,10 @@ # @nxtedition/shared | ||
| ## Native binding | ||
| The ring buffer relies on a native C++ addon for two capabilities that are not available from pure JavaScript. | ||
| **Double-mapped virtual memory.** The key trick that eliminates wrap-around copies is mapping the same physical memory region into two consecutive virtual address ranges. From the CPU's perspective the buffer appears twice as large: a message that starts near the end of the physical ring and would normally wrap around is simply read or written as a contiguous span across the boundary. On POSIX systems this uses `mmap` twice with `MAP_FIXED | MAP_SHARED` onto the same `memfd`; on Windows it uses `VirtualAlloc2` / `MapViewOfFile3`. Neither is expressible in JavaScript, which has no way to create an `ArrayBuffer` backed by a custom virtual memory layout. | ||
| **Huge pages (Linux).** When the requested buffer size is at least 1 MiB, the native allocator first attempts to back the memory with 2 MiB explicit huge pages (via `memfd_create` with `MFD_HUGETLB | MFD_HUGE_2MB`). Huge pages reduce TLB pressure substantially for large ring buffers — the CPU needs far fewer TLB entries to cover the same address range, which means fewer TLB misses on hot read/write paths. If explicit huge pages are unavailable (e.g. the huge-page pool is empty or the kernel does not support it), the allocator falls back to regular 4 KiB pages and advises the kernel to back the region with transparent huge pages (`madvise(MADV_HUGEPAGE)`). | ||
| ## Platform Assumptions | ||
@@ -30,3 +38,3 @@ | ||
| // Create writer — allocates the ring buffer internally | ||
| using w = new Writer(1024 * 1024) // 1 MB ring buffer | ||
| const w = new Writer(1024 * 1024) // 1 MB ring buffer | ||
@@ -40,3 +48,3 @@ const payload = Buffer.from('hello world') | ||
| // Create reader from the same handle (pass w.handle to the other thread) | ||
| using r = new Reader(w.handle) | ||
| const r = new Reader(w.handle) | ||
@@ -93,3 +101,3 @@ r.readSome((data) => { | ||
| using w = new Writer(1024 * 1024) | ||
| const w = new Writer(1024 * 1024) | ||
| const worker = new Worker('./reader-worker.js', { | ||
@@ -106,3 +114,3 @@ workerData: w.handle, | ||
| using r = new Reader(workerData) | ||
| const r = new Reader(workerData) | ||
@@ -128,3 +136,3 @@ function poll() { | ||
| The underlying `SharedHandle` (`SharedArrayBuffer`). Pass this to another thread via `workerData` or to `new Writer(handle)` / `new Reader(handle)` to share the buffer. | ||
| The underlying `SharedHandle` (a branded `SharedArrayBuffer`). Pass this to another thread via `workerData` or to `new Writer(handle)` / `new Reader(handle)` to share the buffer. | ||
@@ -137,3 +145,3 @@ #### `reader.readSome(next, opaque?)` | ||
| - `view: DataView` — A DataView over the shared buffer | ||
| - `byteOffset: number` — Start offset of the message payload | ||
| - `byteOffset: number` — Start offset of the message payload. Due to the double-mapped virtual memory layout, this value can be greater than or equal to the physical ring size for messages that span the wrap boundary — the bytes are always contiguous and valid within `buffer`. Do not compare `byteOffset` against the physical ring size. | ||
| - `byteLength: number` — Length of the message payload in bytes | ||
@@ -145,8 +153,4 @@ | ||
| Messages are batched: up to 1024 items or 256 KiB per call. | ||
| Messages are batched: up to 256 KiB of data per call. | ||
| #### `reader[Symbol.dispose]()` | ||
| Implements the [explicit resource management](https://github.com/tc39/proposal-explicit-resource-management) protocol. Currently a no-op; enables use with `using` declarations for forward compatibility. | ||
| ### `new Writer(handleOrSize, options?)` | ||
@@ -165,3 +169,3 @@ | ||
| The underlying `SharedHandle` (`SharedArrayBuffer`). Pass this to another thread via `workerData` or to `new Reader(handle)` to share the buffer. | ||
| The underlying `SharedHandle` (a branded `SharedArrayBuffer`). Pass this to another thread via `workerData` or to `new Reader(handle)` to share the buffer. | ||
@@ -196,51 +200,49 @@ #### `writer.writeSync(len, fn, opaque?)` | ||
| #### `writer[Symbol.dispose]()` | ||
| Implements the [explicit resource management](https://github.com/tc39/proposal-explicit-resource-management) protocol. Calls `flushSync()` to publish any pending writes, then releases held resources. Enables use with `using` declarations. | ||
| ## Benchmarks | ||
| Measured on AMD EPYC 9355P (4.29 GHz), Node.js 25.6.1, 8 MiB ring buffer, Docker (x64-linux). | ||
| Measured on AMD EPYC 9355P 32-Core Processor, Node.js 25.8.1, 8 MiB ring buffer, Docker (x64-linux). | ||
| Each benchmark writes batches of fixed-size messages from the main thread and | ||
| reads them in a worker thread. The shared ring buffer is compared against | ||
| Node.js `postMessage` (structured clone). | ||
| Node.js `postMessage` (structured clone). "shared (string)" uses the latin1 | ||
| fast path; UTF-8 strings are slower. | ||
| ### Throughput | ||
| | Size | shared (buffer) | shared (string) | postMessage (buffer) | postMessage (string) | | ||
| | -----: | --------------: | --------------: | -------------------: | -------------------: | | ||
| | 64 B | **838 MiB/s** | 388 MiB/s | 24 MiB/s | 42 MiB/s | | ||
| | 256 B | **2.65 GiB/s** | 1.46 GiB/s | 89 MiB/s | 168 MiB/s | | ||
| | 1 KiB | **4.95 GiB/s** | 4.86 GiB/s | 339 MiB/s | 525 MiB/s | | ||
| | 4 KiB | 8.42 GiB/s | **15.11 GiB/s** | 1.12 GiB/s | 1.86 GiB/s | | ||
| | 16 KiB | 12.02 GiB/s | **33.27 GiB/s** | 4.12 GiB/s | 6.02 GiB/s | | ||
| | 64 KiB | 12.96 GiB/s | **43.66 GiB/s** | 9.33 GiB/s | 14.73 GiB/s | | ||
| | Size | shared (buffer) | shared (latin1 str) | postMessage (buffer) | postMessage (string) | | ||
| | -----: | --------------: | ------------------: | -------------------: | -------------------: | | ||
| | 64 B | **2.00 GiB/s** | 549 MiB/s | 22.78 MiB/s | 39.58 MiB/s | | ||
| | 256 B | **3.62 GiB/s** | 1.73 GiB/s | 89.90 MiB/s | 174.62 MiB/s | | ||
| | 1 KiB | **11.23 GiB/s** | 6.00 GiB/s | 341.78 MiB/s | 521.39 MiB/s | | ||
| | 4 KiB | **24.36 GiB/s** | 20.04 GiB/s | 1.13 GiB/s | 1.59 GiB/s | | ||
| | 16 KiB | 44.16 GiB/s | **50.76 GiB/s** | 3.70 GiB/s | 7.45 GiB/s | | ||
| | 64 KiB | **90.02 GiB/s** | 78.88 GiB/s | 9.49 GiB/s | 15.09 GiB/s | | ||
| ### Message rate | ||
| | Size | shared (buffer) | shared (string) | postMessage (buffer) | postMessage (string) | | ||
| | -----: | --------------: | --------------: | -------------------: | -------------------: | | ||
| | 64 B | **13.73 M/s** | 6.35 M/s | 391 K/s | 693 K/s | | ||
| | 256 B | **11.14 M/s** | 6.14 M/s | 366 K/s | 689 K/s | | ||
| | 1 KiB | **5.19 M/s** | 5.09 M/s | 348 K/s | 538 K/s | | ||
| | 4 KiB | 2.21 M/s | **3.96 M/s** | 295 K/s | 488 K/s | | ||
| | 16 KiB | 788 K/s | **2.18 M/s** | 270 K/s | 395 K/s | | ||
| | 64 KiB | 212 K/s | **715 K/s** | 153 K/s | 241 K/s | | ||
| | Size | shared (buffer) | shared (latin1 str) | postMessage (buffer) | postMessage (string) | | ||
| | -----: | --------------: | ------------------: | -------------------: | -------------------: | | ||
| | 64 B | **33.61 M/s** | 8.99 M/s | 373 K/s | 648 K/s | | ||
| | 256 B | **15.16 M/s** | 7.27 M/s | 368 K/s | 715 K/s | | ||
| | 1 KiB | **11.78 M/s** | 6.29 M/s | 350 K/s | 534 K/s | | ||
| | 4 KiB | **6.39 M/s** | 5.25 M/s | 297 K/s | 417 K/s | | ||
| | 16 KiB | 2.89 M/s | **3.33 M/s** | 242 K/s | 488 K/s | | ||
| | 64 KiB | **1.47 M/s** | 1.29 M/s | 155 K/s | 247 K/s | | ||
| ### Key findings | ||
| - **Small messages (64–256 B):** The shared ring buffer with `Buffer.set` delivers | ||
| **13.7–11.1 M msg/s** — up to **35x faster** than `postMessage` (buffer) and | ||
| **20x faster** than `postMessage` (string). Per-message overhead dominates at | ||
| these sizes, and avoiding structured cloning makes the biggest difference. | ||
| - **Small messages (64–256 B):** `Buffer.set` delivers **33.6–15.2 M msg/s** — | ||
| up to **90x faster** than `postMessage` (buffer) and **52x faster** than | ||
| `postMessage` (string). Per-message overhead dominates at these sizes, and | ||
| avoiding structured cloning makes the biggest difference. | ||
| - **Medium messages (1 KiB):** `Buffer.set` and string are nearly identical | ||
| (**4.95 vs 4.86 GiB/s**), both **~9x faster** than the best `postMessage` | ||
| variant. | ||
| - **Medium messages (1 KiB):** `Buffer.set` pulls ahead at **11.23 GiB/s** — | ||
| roughly **1.9x faster** than the latin1 string path and **~22x faster** than | ||
| the best `postMessage` variant. | ||
| - **Large messages (4–64 KiB):** Shared string overtakes `Buffer.set` and | ||
| scales to **43.7 GiB/s** at 64 KiB — **3.4x faster** than `Buffer.set` and | ||
| **3.0x faster** than `postMessage` (string). At every size, the shared ring | ||
| buffer outperforms `postMessage`. | ||
| - **Large messages (4–64 KiB):** Both shared paths dominate. At 16 KiB the | ||
| latin1 string path (**50.76 GiB/s**) overtakes `Buffer.set` (44.16 GiB/s) — | ||
| V8's string-to-buffer fast path becomes more cache-efficient at this size. At | ||
| 64 KiB `Buffer.set` reclaims the lead at **90.02 GiB/s** — **6x faster** | ||
| than `postMessage` (string). | ||
@@ -247,0 +249,0 @@ - **Caveat:** The string benchmark uses ASCII-only content. Multi-byte UTF-8 |
Native code
Supply chain riskContains native code (e.g., compiled binaries or shared libraries). Including native code can obscure malicious behavior.
Uses eval
Supply chain riskPackage uses dynamic code execution (e.g., eval()), which is a dangerous practice. This can prevent the code from running in certain environments and increases the risk that the code may contain exploits or malicious behavior.
AI-detected potential code anomaly
Supply chain riskAI has identified unusual behaviors that may pose a security risk.
54515
65.11%11
37.5%251
0.8%2
Infinity%8
33.33%544
-1.27%3
50%2
Infinity%+ Added
+ Added
+ Added
+ Added