diff --git a/packages/core/src/emnapi/index.d.ts b/packages/core/src/emnapi/index.d.ts index 7d1af6e6..9ca85e72 100644 --- a/packages/core/src/emnapi/index.d.ts +++ b/packages/core/src/emnapi/index.d.ts @@ -28,12 +28,21 @@ export declare interface NapiModule { filename: string childThread: boolean emnapi: { + /** + * Synchronize data between the wasm memory and an ArrayBuffer / view. + * + * Note: when a non-shared wasm memory has grown, a view over the + * detached old buffer is re-created over the current buffer through the + * intrinsic base-class constructor, so for view inputs the returned view + * may be a base-class instance (e.g. `Uint8Array` or `DataView`) + * instead of the subclass that was passed in. + */ syncMemory ( js_to_wasm: boolean, arrayBufferOrView: T, offset?: number, len?: number - ): T + ): T extends ArrayBufferView ? ArrayBufferView : T getMemoryAddress (arrayBufferOrView: ArrayBuffer | ArrayBufferView): PointerInfo addSendListener (worker: any): boolean } diff --git a/packages/core/src/emnapi/sync-memory.type-test.ts b/packages/core/src/emnapi/sync-memory.type-test.ts new file mode 100644 index 00000000..25969f32 --- /dev/null +++ b/packages/core/src/emnapi/sync-memory.type-test.ts @@ -0,0 +1,47 @@ +// Compile-time assertions for the NapiModule['emnapi'].syncMemory return type +// (F12). Under the repo's ES2021 lib a typed array structurally satisfies +// ArrayBuffer, so a buffer-first conditional would hand a subclass input back +// as its subclass type while the runtime returns a base-class view after a +// growth. The signature therefore tests ArrayBufferView FIRST. This file emits +// no runtime code; a reverted conditional makes it fail to typecheck. +import type { NapiModule } from './index' + +type SyncMemory = NapiModule['emnapi']['syncMemory'] + +type Equal = (() => T extends A ? 1 : 2) extends (() => T extends B ? 1 : 2) ? true : false +type Expect = T + +// the actual public syncMemory, resolved through NapiModule +declare const syncMemory: SyncMemory + +class MyU8 extends Uint8Array { extra (): number { return 1 } } +class MyDV extends DataView { extra (): number { return 1 } } + +declare const myU8: MyU8 +declare const myDV: MyDV +declare const u8: Uint8Array +declare const ab: ArrayBuffer +declare const sab: SharedArrayBuffer + +const rSubTA = syncMemory(true, myU8) +const rSubDV = syncMemory(true, myDV) +const rU8 = syncMemory(true, u8) +const rAB = syncMemory(true, ab) +const rSAB = syncMemory(true, sab) + +// @ts-expect-error a typed-array subclass method must NOT be promised on the +// result (the runtime may return a base Uint8Array after growth) +rSubTA.extra() + +export type Assertions = [ + // typed-array subclass -> wide ArrayBufferView, not the subclass + Expect>, + // DataView subclass -> wide ArrayBufferView + Expect>, + // base-class view -> wide ArrayBufferView + Expect>, + // ArrayBuffer input keeps its identity (never reconstructed) + Expect>, + // SharedArrayBuffer input keeps its identity + Expect> +] diff --git a/packages/emnapi/src/emnapi.ts b/packages/emnapi/src/emnapi.ts index f872babb..dd8c169e 100644 --- a/packages/emnapi/src/emnapi.ts +++ b/packages/emnapi/src/emnapi.ts @@ -114,7 +114,8 @@ export function emnapi_create_memory_view ( } const Ctor = viewDescriptor.Ctor const typedArray = typedarray_type === emnapi_memory_view_type.emnapi_buffer - ? emnapiCtx.features.Buffer!.from(wasmMemory.buffer, viewDescriptor.address, viewDescriptor.length) + // capture Buffer.from at creation so a later refreshed view uses it + ? emnapiExternalMemory.getBufferFrom()(wasmMemory.buffer, viewDescriptor.address, viewDescriptor.length) : new Ctor(wasmMemory.buffer, viewDescriptor.address, viewDescriptor.length) value = emnapiCtx.napiValueFromJsValue(typedArray) emnapiExternalMemory.wasmMemoryViewTable.set(typedArray, viewDescriptor) @@ -155,12 +156,21 @@ export function emnapi_is_node_binding_available (): int { return emnapiNodeBinding ? 1 : 0 } +/** + * Synchronize data between the wasm memory and an ArrayBuffer / view. + * + * Note: when a non-shared wasm memory has grown, a view over the detached + * old buffer is re-created over the current buffer through the intrinsic + * base-class constructor, so for view inputs the returned view may be a + * base-class instance (e.g. `Uint8Array` or `DataView`) instead of the + * subclass that was passed in. + */ export function $emnapiSyncMemory ( js_to_wasm: boolean, arrayBufferOrView: T, offset?: number, len?: int -): T { +): T extends ArrayBufferView ? ArrayBufferView : T { offset = offset ?? 0 offset = offset >>> 0 let view: Uint8Array @@ -171,7 +181,7 @@ export function $emnapiSyncMemory ( len = arrayBufferOrView.byteLength - offset } len = len >>> 0 - if (len === 0) return arrayBufferOrView + if (len === 0) return arrayBufferOrView as (T extends ArrayBufferView ? ArrayBufferView : T) view = new Uint8Array(arrayBufferOrView, offset, len) const wasmMemoryU8 = new Uint8Array(wasmMemory.buffer) @@ -181,7 +191,7 @@ export function $emnapiSyncMemory ( wasmMemoryU8.set(view, pointer as number) } - return arrayBufferOrView + return arrayBufferOrView as (T extends ArrayBufferView ? ArrayBufferView : T) } if (ArrayBuffer.isView(arrayBufferOrView)) { @@ -193,7 +203,7 @@ export function $emnapiSyncMemory ( len = latestView.byteLength - offset } len = len >>> 0 - if (len === 0) return latestView + if (len === 0) return latestView as (T extends ArrayBufferView ? ArrayBufferView : T) view = new Uint8Array(latestView.buffer, latestView.byteOffset + offset, len) const wasmMemoryU8 = new Uint8Array(wasmMemory.buffer) @@ -203,7 +213,7 @@ export function $emnapiSyncMemory ( wasmMemoryU8.set(view, pointer as number) } - return latestView + return latestView as (T extends ArrayBufferView ? ArrayBufferView : T) } throw new TypeError('emnapiSyncMemory expect ArrayBuffer or ArrayBufferView as first parameter') } diff --git a/packages/emnapi/src/function.ts b/packages/emnapi/src/function.ts index d1a43b69..9d6526b7 100644 --- a/packages/emnapi/src/function.ts +++ b/packages/emnapi/src/function.ts @@ -109,6 +109,9 @@ export function napi_call_function ( } const ret = v8func.apply(v8recv, args) if (result) { + // resolving v BEFORE the write matters: the store target is evaluated + // before the RHS, so an RHS running user JS could otherwise write + // through a stale heap view after a growth v = emnapiCtx.napiValueFromJsValue(ret) makeSetValue('result', 0, 'v', '*') } @@ -161,6 +164,7 @@ export function napi_new_instance ( ret = new BoundCtor() } if (result) { + // see napi_call_function: resolve v before the write v = emnapiCtx.napiValueFromJsValue(ret) makeSetValue('result', 0, 'v', '*') } diff --git a/packages/emnapi/src/memory.ts b/packages/emnapi/src/memory.ts index 2dd328b6..64070514 100644 --- a/packages/emnapi/src/memory.ts +++ b/packages/emnapi/src/memory.ts @@ -31,9 +31,39 @@ export const emnapiExternalMemory: { registry: FinalizationRegistry | undefined table: WeakMap wasmMemoryViewTable: WeakMap + intrinsics: { + isView: (value: any) => value is ArrayBufferView + typedArray: { + buffer: (this: any) => ArrayBufferLike + byteOffset: (this: any) => number + length: (this: any) => number + tag: (this: any) => string | undefined + } + dataView: { + buffer: (this: any) => ArrayBufferLike + byteOffset: (this: any) => number + byteLength: (this: any) => number + } + arrayBuffer: { + byteLength: (this: any) => number + } + sharedArrayBuffer: { + byteLength: (this: any) => number + } | undefined + ctors: { [name: string]: ViewConstuctor | undefined } + // captured once from the runtime Buffer at the first Buffer-descriptor + // creation (see getBufferFrom): reconstruction must not read a + // user-replaceable live Buffer.from + bufferFrom: ((buffer: ArrayBufferLike, byteOffset?: number, length?: number) => ArrayBufferView) | undefined + } init: () => void isSharedArrayBuffer: (value: any) => value is SharedArrayBuffer isDetachedArrayBuffer: (arrayBuffer: ArrayBufferLike) => boolean + bufferByteLength: (buffer: ArrayBufferLike) => number + viewBuffer: (view: ArrayBufferView) => ArrayBufferLike + viewByteOffset: (view: ArrayBufferView) => number + viewLength: (view: ArrayBufferView) => number + getBufferFrom: () => (buffer: ArrayBufferLike, byteOffset?: number, length?: number) => ArrayBufferView getOrUpdateMemoryView: (view: T) => T getArrayBufferPointer: (arrayBuffer: ArrayBufferLike, shouldCopy: boolean) => ArrayBufferPointer getViewPointer: (view: T, shouldCopy: boolean) => ViewPointer @@ -42,21 +72,96 @@ export const emnapiExternalMemory: { table: new WeakMap(), wasmMemoryViewTable: new WeakMap(), + // populated by init(): live intrinsic function references must not exist at + // library definition time — the emscripten jsifier re-serializes this object + // and native functions do not stringify to parseable source + intrinsics: undefined!, + init: function () { emnapiExternalMemory.registry = typeof FinalizationRegistry === 'function' ? new FinalizationRegistry(function (_pointer) { _free(to64('_pointer') as number) }) : undefined emnapiExternalMemory.table = new WeakMap() emnapiExternalMemory.wasmMemoryViewTable = new WeakMap() + + const sharedArrayBufferByteLength = typeof SharedArrayBuffer === 'function' + ? Object.getOwnPropertyDescriptor(SharedArrayBuffer.prototype, 'byteLength')?.get + : undefined + + // cached intrinsic prototype getters and element-type constructors: they + // read the internal slots directly like native Node-API does, so user + // accessors and user subclass constructors can never run inside get-info + // metadata reads, descriptor registration or view reconstruction, and they + // work for instances from any realm + emnapiExternalMemory.intrinsics = { + // ArrayBuffer.isView ignores its `this`, so a bare cached reference is + // safe and cannot be swapped out by user code replacing the global + isView: ArrayBuffer.isView, + typedArray: { + buffer: Object.getOwnPropertyDescriptor(Object.getPrototypeOf(Uint8Array.prototype), 'buffer')!.get!, + byteOffset: Object.getOwnPropertyDescriptor(Object.getPrototypeOf(Uint8Array.prototype), 'byteOffset')!.get!, + length: Object.getOwnPropertyDescriptor(Object.getPrototypeOf(Uint8Array.prototype), 'length')!.get!, + tag: Object.getOwnPropertyDescriptor(Object.getPrototypeOf(Uint8Array.prototype), Symbol.toStringTag)!.get! + }, + dataView: { + buffer: Object.getOwnPropertyDescriptor(DataView.prototype, 'buffer')!.get!, + byteOffset: Object.getOwnPropertyDescriptor(DataView.prototype, 'byteOffset')!.get!, + byteLength: Object.getOwnPropertyDescriptor(DataView.prototype, 'byteLength')!.get! + }, + arrayBuffer: { + byteLength: Object.getOwnPropertyDescriptor(ArrayBuffer.prototype, 'byteLength')!.get! + }, + // a SharedArrayBuffer polyfill may lack the byteLength accessor: degrade + // to the hidden-global classification path instead of throwing here + sharedArrayBuffer: sharedArrayBufferByteLength !== undefined + ? { + byteLength: sharedArrayBufferByteLength + } + : undefined, + ctors: { + Int8Array, + Uint8Array, + Uint8ClampedArray, + Int16Array, + Uint16Array, + Int32Array, + Uint32Array, + Float16Array: typeof Float16Array === 'function' ? Float16Array : undefined, + Float32Array, + Float64Array, + BigInt64Array, + BigUint64Array + }, + bufferFrom: undefined + } }, isSharedArrayBuffer (value: any): value is SharedArrayBuffer { - return ( - (typeof SharedArrayBuffer === 'function' && value instanceof SharedArrayBuffer) || - (Object.prototype.toString.call(value) === '[object SharedArrayBuffer]') - ) + const sharedArrayBuffer = emnapiExternalMemory.intrinsics.sharedArrayBuffer + if (sharedArrayBuffer !== undefined) { + if (typeof SharedArrayBuffer === 'function' && value instanceof SharedArrayBuffer) return true + // the intrinsic byteLength getter reads the internal slot: it accepts + // SharedArrayBuffer instances from any realm and throws for anything + // else, without consulting user-controllable properties + try { + sharedArrayBuffer.byteLength.call(value) + return true + } catch (_) { + return false + } + } + // hidden-global environment (see #143): the SharedArrayBuffer global was + // absent at init time, but the engine can still hand out genuine SABs + // (e.g. a shared WebAssembly.Memory buffer). A genuine ArrayBuffer + // answers the intrinsic byteLength probe, settling the common case + // without the (spoofable, user-JS-running) toString tag read below. + try { + emnapiExternalMemory.intrinsics.arrayBuffer.byteLength.call(value) + return false + } catch (_) {} + return Object.prototype.toString.call(value) === '[object SharedArrayBuffer]' }, isDetachedArrayBuffer: function (arrayBuffer: ArrayBufferLike): boolean { - if (arrayBuffer.byteLength === 0) { + if (emnapiExternalMemory.bufferByteLength(arrayBuffer) === 0) { try { // eslint-disable-next-line no-new new Uint8Array(arrayBuffer) @@ -67,6 +172,60 @@ export const emnapiExternalMemory: { return false }, + bufferByteLength: function (buffer: ArrayBufferLike): number { + const intrinsics = emnapiExternalMemory.intrinsics + try { + return intrinsics.arrayBuffer.byteLength.call(buffer) + } catch (err) { + const sharedArrayBuffer = intrinsics.sharedArrayBuffer + if (sharedArrayBuffer !== undefined) { + return sharedArrayBuffer.byteLength.call(buffer) + } + // hidden-global environment (see isSharedArrayBuffer): no SAB getter + // was capturable at init, recover it from the instance's own prototype + // (for a genuine SAB that is the hidden SharedArrayBuffer.prototype) + const proto = Object.getPrototypeOf(buffer) + const get = proto !== null && proto !== undefined + ? Object.getOwnPropertyDescriptor(proto, 'byteLength')?.get + : undefined + if (get === undefined) throw err + return get.call(buffer) + } + }, + + viewBuffer: function (view: ArrayBufferView): ArrayBufferLike { + const intrinsics = emnapiExternalMemory.intrinsics + return intrinsics.typedArray.tag.call(view) === undefined + ? intrinsics.dataView.buffer.call(view) + : intrinsics.typedArray.buffer.call(view) + }, + + viewByteOffset: function (view: ArrayBufferView): number { + const intrinsics = emnapiExternalMemory.intrinsics + return intrinsics.typedArray.tag.call(view) === undefined + ? intrinsics.dataView.byteOffset.call(view) + : intrinsics.typedArray.byteOffset.call(view) + }, + + // element count for typed arrays, byteLength for DataViews (the unit used + // by the napi_get_typedarray_info / napi_get_dataview_info length outputs + // and by the view constructors at reconstruction) + viewLength: function (view: ArrayBufferView): number { + const intrinsics = emnapiExternalMemory.intrinsics + return intrinsics.typedArray.tag.call(view) === undefined + ? intrinsics.dataView.byteLength.call(view) + : intrinsics.typedArray.length.call(view) + }, + + // Buffer descriptors are only ever created by emnapi's own buffer creation + // paths; capturing Buffer.from the first time one is created snapshots the + // legitimate function before any later reconstruction, so a user replacing + // Buffer.from afterwards cannot influence a refreshed view + getBufferFrom: function () { + return emnapiExternalMemory.intrinsics.bufferFrom ?? + (emnapiExternalMemory.intrinsics.bufferFrom = emnapiCtx.features.Buffer!.from as any) + }, + getArrayBufferPointer: function (arrayBuffer: ArrayBufferLike, shouldCopy: boolean): ArrayBufferPointer { const info: ArrayBufferPointer = { address: 0, @@ -90,7 +249,8 @@ export const emnapiExternalMemory: { return cachedInfo } - if (isDetached || (arrayBuffer.byteLength === 0)) { + const byteLength = emnapiExternalMemory.bufferByteLength(arrayBuffer) + if (isDetached || (byteLength === 0)) { return info } @@ -98,7 +258,7 @@ export const emnapiExternalMemory: { return info } - let pointer = _malloc(to64('arrayBuffer.byteLength')) + let pointer = _malloc(to64('byteLength')) if (!pointer) throw new Error('Out of memory') from64('pointer') new Uint8Array(wasmMemory.buffer).set(new Uint8Array(arrayBuffer), pointer as number) @@ -113,12 +273,30 @@ export const emnapiExternalMemory: { }, getOrUpdateMemoryView: function (view: T): T { - if (view.buffer === wasmMemory.buffer) { + // classify and read the view state through the cached intrinsic + // prototype getters only: own accessors on the instance could otherwise + // run user JS that grows (and detaches) a non-shared memory in the + // middle of registration, poisoning the stored descriptor; the intrinsic + // reads return primitives from the internal slots and run zero user JS. + // The @@toStringTag slot getter classifies views from any realm: it + // returns the element-type name for typed arrays (seeing through user + // subclasses) and undefined for DataViews + const intrinsics = emnapiExternalMemory.intrinsics + const tag = intrinsics.typedArray.tag.call(view) + const isDataView = tag === undefined + const buffer: ArrayBufferLike = isDataView ? intrinsics.dataView.buffer.call(view) : intrinsics.typedArray.buffer.call(view) + if (buffer === wasmMemory.buffer) { if (!emnapiExternalMemory.wasmMemoryViewTable.has(view)) { emnapiExternalMemory.wasmMemoryViewTable.set(view, { - Ctor: view.constructor as any, - address: view.byteOffset, - length: view instanceof DataView ? view.byteLength : (view as any).length, + // store the intrinsic element-type constructor keyed by the + // @@toStringTag slot (never view.constructor and never a + // user-definable Buffer @@hasInstance check): reconstruction after a + // memory growth must not run any user code, because it could grow + // again after the fresh buffer was captured as an argument. Buffers + // are tagged 'Uint8Array' and reconstruct as base Uint8Array views + Ctor: isDataView ? DataView : intrinsics.ctors[tag]!, + address: isDataView ? intrinsics.dataView.byteOffset.call(view) : intrinsics.typedArray.byteOffset.call(view), + length: isDataView ? intrinsics.dataView.byteLength.call(view) : intrinsics.typedArray.length.call(view), ownership: ReferenceOwnership.kUserland, runtimeAllocated: 0 }) @@ -126,14 +304,15 @@ export const emnapiExternalMemory: { return view } - const maybeOldWasmMemory = emnapiExternalMemory.isDetachedArrayBuffer(view.buffer) || emnapiExternalMemory.isSharedArrayBuffer(view.buffer) + const maybeOldWasmMemory = emnapiExternalMemory.isDetachedArrayBuffer(buffer) || emnapiExternalMemory.isSharedArrayBuffer(buffer) if (maybeOldWasmMemory && emnapiExternalMemory.wasmMemoryViewTable.has(view)) { const info = emnapiExternalMemory.wasmMemoryViewTable.get(view)! const Ctor = info.Ctor let newView: ArrayBufferView const Buffer = emnapiCtx.features.Buffer if (typeof Buffer === 'function' && Ctor === Buffer) { - newView = Buffer.from(wasmMemory.buffer, info.address, info.length) + // reconstruct through the Buffer.from captured at creation time + newView = emnapiExternalMemory.getBufferFrom()(wasmMemory.buffer, info.address, info.length) } else { newView = new Ctor(wasmMemory.buffer, info.address, info.length) } @@ -146,16 +325,17 @@ export const emnapiExternalMemory: { getViewPointer: function (view: T, shouldCopy: boolean): ViewPointer { view = emnapiExternalMemory.getOrUpdateMemoryView(view) - if (view.buffer === wasmMemory.buffer) { + const buffer = emnapiExternalMemory.viewBuffer(view) + if (buffer === wasmMemory.buffer) { if (emnapiExternalMemory.wasmMemoryViewTable.has(view)) { const { address, ownership, runtimeAllocated } = emnapiExternalMemory.wasmMemoryViewTable.get(view)! return { address, ownership, runtimeAllocated, view } } - return { address: view.byteOffset, ownership: ReferenceOwnership.kUserland, runtimeAllocated: 0, view } + return { address: emnapiExternalMemory.viewByteOffset(view), ownership: ReferenceOwnership.kUserland, runtimeAllocated: 0, view } } - const { address, ownership, runtimeAllocated } = emnapiExternalMemory.getArrayBufferPointer(view.buffer, shouldCopy) - return { address: address === 0 ? 0 : (address + view.byteOffset), ownership, runtimeAllocated, view } + const { address, ownership, runtimeAllocated } = emnapiExternalMemory.getArrayBufferPointer(buffer, shouldCopy) + return { address: address === 0 ? 0 : (address + emnapiExternalMemory.viewByteOffset(view)), ownership, runtimeAllocated, view } } } diff --git a/packages/emnapi/src/node.ts b/packages/emnapi/src/node.ts index a8cfc6bc..c5bba8b5 100644 --- a/packages/emnapi/src/node.ts +++ b/packages/emnapi/src/node.ts @@ -189,6 +189,9 @@ export function napi_make_callback (env: napi_env, async_context: Pointer>> 2 while (has_more && --iterations_left !== 0) { - Atomics.store(ui32a, index, 1) - has_more = emnapiTSFN.dispatchOne(func) + Atomics.store( + new Uint32Array(emnapiTSFN.ensureBufferFor(dispatchStateAddress + 4)), + index, + 1 + ) + try { + has_more = emnapiTSFN.dispatchOne(func) + } catch (err) { + // A throwing callback must not leave dispatch_state marked as + // dispatching, or every later send() would be swallowed. Re-arm + // the state and schedule another drain before rethrowing. + if (emnapiTSFN._liveSet.has(func)) { + Atomics.exchange( + new Uint32Array(emnapiTSFN.ensureBufferFor(dispatchStateAddress + 4)), + index, + 0 + ) + emnapiTSFN.send(func) + } + throw err + } - if (Atomics.exchange(ui32a, index, 0) !== 1) { + if (Atomics.exchange( + new Uint32Array(emnapiTSFN.ensureBufferFor(dispatchStateAddress + 4)), + index, + 0 + ) !== 1) { has_more = true } } @@ -766,8 +788,9 @@ const emnapiTSFN = { // `scheduled` prevents queueing the same main-thread drain chain more than // once while a previous wakeup is still in flight. const scheduled = func + emnapiTSFN.offset.async_u_fd - const i32a = new Int32Array(emnapiTSFN.ensureBufferFor(Math.max(pending, scheduled) + 4)) - if (Atomics.exchange(i32a, scheduled >>> 2, 1) !== 0) { + const end = Math.max(pending, scheduled) + 4 + const state = (): Int32Array => new Int32Array(emnapiTSFN.ensureBufferFor(end)) + if (Atomics.exchange(state(), scheduled >>> 2, 1) !== 0) { return } @@ -779,21 +802,22 @@ const emnapiTSFN = { if (!emnapiTSFN._liveSet.has(func)) { return } - if (Atomics.load(i32a, pending >>> 2) === 0) { - Atomics.store(i32a, scheduled >>> 2, 0) + if (Atomics.load(state(), pending >>> 2) === 0) { + Atomics.store(state(), scheduled >>> 2, 0) return } emnapiCtx.features.setImmediate(() => { + // After destroy(), the func address is freed. Skip the atomics + // on that address entirely to avoid use-after-free (JS-side + // lifecycle check must run before touching the state words). + if (!emnapiTSFN._liveSet.has(func)) { + return + } try { // Consume the coalesced wakeup once, then let dispatch() observe any // queue mutations through dispatch_state like the C implementation. - if (Atomics.exchange(i32a, pending >>> 2, 0) === 0) { - return - } - // After destroy(), the func address is freed. Skip dispatch - // to avoid use-after-free (JS-side lifecycle check). - if (!emnapiTSFN._liveSet.has(func)) { + if (Atomics.exchange(state(), pending >>> 2, 0) === 0) { return } emnapiTSFN.dispatch(func) @@ -801,8 +825,8 @@ const emnapiTSFN = { // Allow a later wakeup to schedule a new drain chain. If another // worker-thread send raced with this drain, enqueue one more turn. if (emnapiTSFN._liveSet.has(func)) { - Atomics.store(i32a, scheduled >>> 2, 0) - if (Atomics.load(i32a, pending >>> 2) !== 0) { + Atomics.store(state(), scheduled >>> 2, 0) + if (Atomics.load(state(), pending >>> 2) !== 0) { emnapiTSFN.enqueue(func) } } diff --git a/packages/emnapi/src/value/convert2c.ts b/packages/emnapi/src/value/convert2c.ts index 65f82728..de452406 100644 --- a/packages/emnapi/src/value/convert2c.ts +++ b/packages/emnapi/src/value/convert2c.ts @@ -34,6 +34,12 @@ export function napi_get_arraybuffer_info (env: napi_env, arraybuffer: napi_valu if (!(jsValue instanceof ArrayBuffer) && !emnapiExternalMemory.isSharedArrayBuffer(jsValue)) { return envObject.setLastError(napi_status.napi_invalid_arg) } + // metadata is read through cached intrinsic getters (internal slots, like + // native Node-API): no user JS can run inside this call. Only emnapi's own + // getArrayBufferPointer() can still grow the memory (malloc for the copy of + // an external buffer), which detaches the previous non-shared buffer, so + // each value is resolved into a local BEFORE its makeSetValue write (the + // store target is evaluated before the RHS). if (data) { from64('data') @@ -42,7 +48,9 @@ export function napi_get_arraybuffer_info (env: napi_env, arraybuffer: napi_valu } if (byte_length) { from64('byte_length') - makeSetValue('byte_length', 0, 'jsValue.byteLength', SIZE_TYPE) + + const len = emnapiExternalMemory.bufferByteLength(jsValue) + makeSetValue('byte_length', 0, 'len', SIZE_TYPE) } return envObject.clearLastError() } @@ -112,65 +120,68 @@ export function napi_get_typedarray_info ( const envObject: Env = $CHECK_ENV_NOT_IN_GC!(env) $CHECK_ARG!(envObject, typedarray) const jsValue = emnapiCtx.jsValueFromNapiValue(typedarray)! - if (!(ArrayBuffer.isView(jsValue)) && !(jsValue instanceof DataView)) { + if (!(emnapiExternalMemory.intrinsics.isView(jsValue))) { return envObject.setLastError(napi_status.napi_invalid_arg) } let v: ArrayBufferView = jsValue + // all view metadata (kind, buffer, byteOffset, length) is read through + // cached intrinsic prototype getters: like native Node-API they read the + // internal slots, never consult user accessors (no user JS can run inside + // this call) and work for instances from any realm. Only emnapi's own + // getViewPointer() can still grow the memory (malloc for the copy of an + // external buffer), which detaches the previous non-shared buffer, so each + // value is resolved into a local BEFORE its makeSetValue write (the store + // target is evaluated before the RHS). if (type) { from64('type') let t: napi_typedarray_type - if (v instanceof Int8Array) { - t = napi_typedarray_type.napi_int8_array - } else if (v instanceof Uint8Array) { - t = napi_typedarray_type.napi_uint8_array - } else if (v instanceof Uint8ClampedArray) { - t = napi_typedarray_type.napi_uint8_clamped_array - } else if (v instanceof Int16Array) { - t = napi_typedarray_type.napi_int16_array - } else if (v instanceof Uint16Array) { - t = napi_typedarray_type.napi_uint16_array - } else if (v instanceof Int32Array) { - t = napi_typedarray_type.napi_int32_array - } else if (v instanceof Uint32Array) { - t = napi_typedarray_type.napi_uint32_array - } else if (typeof Float16Array === 'function' && v instanceof Float16Array) { - t = napi_typedarray_type.napi_float16_array - } else if (v instanceof Float32Array) { - t = napi_typedarray_type.napi_float32_array - } else if (v instanceof Float64Array) { - t = napi_typedarray_type.napi_float64_array - } else if (v instanceof BigInt64Array) { - t = napi_typedarray_type.napi_bigint64_array - } else if (v instanceof BigUint64Array) { - t = napi_typedarray_type.napi_biguint64_array - } else { - return envObject.setLastError(napi_status.napi_generic_failure) + switch (emnapiExternalMemory.intrinsics.typedArray.tag.call(v)) { + case 'Int8Array': t = napi_typedarray_type.napi_int8_array; break + case 'Uint8Array': t = napi_typedarray_type.napi_uint8_array; break + case 'Uint8ClampedArray': t = napi_typedarray_type.napi_uint8_clamped_array; break + case 'Int16Array': t = napi_typedarray_type.napi_int16_array; break + case 'Uint16Array': t = napi_typedarray_type.napi_uint16_array; break + case 'Int32Array': t = napi_typedarray_type.napi_int32_array; break + case 'Uint32Array': t = napi_typedarray_type.napi_uint32_array; break + case 'Float16Array': t = napi_typedarray_type.napi_float16_array; break + case 'Float32Array': t = napi_typedarray_type.napi_float32_array; break + case 'Float64Array': t = napi_typedarray_type.napi_float64_array; break + case 'BigInt64Array': t = napi_typedarray_type.napi_bigint64_array; break + case 'BigUint64Array': t = napi_typedarray_type.napi_biguint64_array; break + default: + return envObject.setLastError(napi_status.napi_generic_failure) } makeSetValue('type', 0, 't', 'i32') } v = emnapiExternalMemory.getOrUpdateMemoryView(v) if (length) { from64('length') - makeSetValue('length', 0, 'v.length', SIZE_TYPE) - } + const len = emnapiExternalMemory.viewLength(v) + makeSetValue('length', 0, 'len', SIZE_TYPE) + } if (data || arraybuffer) { if (data) { from64('data') - const p = emnapiExternalMemory.getViewPointer(v, true).address + const vp = emnapiExternalMemory.getViewPointer(v, true) + v = vp.view + + const p = vp.address makeSetValue('data', 0, 'p', '*') } if (arraybuffer) { from64('arraybuffer') - const ab = emnapiCtx.napiValueFromJsValue(v.buffer) + const ab = emnapiCtx.napiValueFromJsValue(emnapiExternalMemory.viewBuffer(v)) makeSetValue('arraybuffer', 0, 'ab', '*') } } if (byte_offset) { from64('byte_offset') - makeSetValue('byte_offset', 0, 'v.byteOffset', SIZE_TYPE) + + const offset = emnapiExternalMemory.viewByteOffset(v) + makeSetValue('byte_offset', 0, 'offset', SIZE_TYPE) } return envObject.clearLastError() } @@ -188,9 +199,11 @@ export function napi_get_buffer_info ( $CHECK_ARG!(envObject, buffer) const jsValue = emnapiCtx.jsValueFromNapiValue(buffer)! const Buffer = emnapiCtx.features.Buffer - const bool = (ArrayBuffer.isView(jsValue) || (typeof Buffer === 'function' && Buffer.isBuffer(jsValue))) + const bool = (emnapiExternalMemory.intrinsics.isView(jsValue) || (typeof Buffer === 'function' && Buffer.isBuffer(jsValue))) $RETURN_STATUS_IF_FALSE!(envObject, bool, napi_status.napi_invalid_arg) - if (jsValue instanceof DataView) { + // any-realm slot-based classification: the intrinsic @@toStringTag getter + // returns undefined exactly for DataViews among ArrayBuffer views + if (emnapiExternalMemory.intrinsics.typedArray.tag.call(jsValue) === undefined) { return napi_get_dataview_info(env, buffer, length, data, 0, 0) } return napi_get_typedarray_info(env, buffer, 0, length, data, 0, 0) @@ -210,32 +223,47 @@ export function napi_get_dataview_info ( const envObject: Env = $CHECK_ENV_NOT_IN_GC!(env) $CHECK_ARG!(envObject, dataview) const jsValue = emnapiCtx.jsValueFromNapiValue(dataview)! - if (!(jsValue instanceof DataView)) { + // any-realm slot-based classification (see napi_get_buffer_info) + if (!(emnapiExternalMemory.intrinsics.isView(jsValue)) || emnapiExternalMemory.intrinsics.typedArray.tag.call(jsValue) !== undefined) { return envObject.setLastError(napi_status.napi_invalid_arg) } - const v = emnapiExternalMemory.getOrUpdateMemoryView(jsValue as DataView) + let v: ArrayBufferView = emnapiExternalMemory.getOrUpdateMemoryView(jsValue as DataView) + // all view metadata is read through cached intrinsic prototype getters: + // like native Node-API they read the internal slots, never consult user + // accessors (no user JS can run inside this call) and work for instances + // from any realm. Only emnapi's own getViewPointer() can still grow the + // memory (malloc for the copy of an external buffer), which detaches the + // previous non-shared buffer, so each value is resolved into a local + // BEFORE its makeSetValue write (the store target is evaluated before + // the RHS). if (byte_length) { from64('byte_length') - makeSetValue('byte_length', 0, 'v.byteLength', SIZE_TYPE) - } + const len = emnapiExternalMemory.viewLength(v) + makeSetValue('byte_length', 0, 'len', SIZE_TYPE) + } if (data || arraybuffer) { if (data) { from64('data') - const p = emnapiExternalMemory.getViewPointer(v, true).address + const vp = emnapiExternalMemory.getViewPointer(v, true) + v = vp.view + + const p = vp.address makeSetValue('data', 0, 'p', '*') } if (arraybuffer) { from64('arraybuffer') - const ab = emnapiCtx.napiValueFromJsValue(v.buffer) + const ab = emnapiCtx.napiValueFromJsValue(emnapiExternalMemory.viewBuffer(v)) makeSetValue('arraybuffer', 0, 'ab', '*') } } if (byte_offset) { from64('byte_offset') - makeSetValue('byte_offset', 0, 'v.byteOffset', SIZE_TYPE) + + const offset = emnapiExternalMemory.viewByteOffset(v) + makeSetValue('byte_offset', 0, 'offset', SIZE_TYPE) } return envObject.clearLastError() } diff --git a/packages/emnapi/src/value/create.ts b/packages/emnapi/src/value/create.ts index 04e6e548..310cebcb 100644 --- a/packages/emnapi/src/value/create.ts +++ b/packages/emnapi/src/value/create.ts @@ -464,7 +464,8 @@ export function napi_create_buffer ( if (!pointer) throw new Error('Out of memory') from64('pointer') new Uint8Array(wasmMemory.buffer).subarray(pointer, pointer + size).fill(0) - const buffer = Buffer.from(wasmMemory.buffer, pointer, size) + // capture Buffer.from at creation so a later refreshed view uses it + const buffer = emnapiExternalMemory.getBufferFrom()(wasmMemory.buffer, pointer, size) as Uint8Array const viewDescriptor: MemoryViewDescriptor = { Ctor: Buffer, address: pointer, @@ -571,7 +572,8 @@ export function node_api_create_buffer_from_arraybuffer ( if (!Buffer) { throw emnapiCtx.createNotSupportBufferError('node_api_create_buffer_from_arraybuffer', '') } - const out = Buffer.from(buffer, byte_offset, byte_length) + // capture Buffer.from at creation so a later refreshed view uses it + const out = emnapiExternalMemory.getBufferFrom()(buffer, byte_offset, byte_length) as Uint8Array if (buffer === wasmMemory.buffer) { if (!emnapiExternalMemory.wasmMemoryViewTable.has(out)) { emnapiExternalMemory.wasmMemoryViewTable.set(out, { diff --git a/packages/test/buffer/binding.c b/packages/test/buffer/binding.c index ebc337aa..d6429630 100644 --- a/packages/test/buffer/binding.c +++ b/packages/test/buffer/binding.c @@ -202,6 +202,142 @@ static napi_value getMemoryDataAsArray(napi_env env, napi_callback_info info) { return ret; } +static napi_value getMemoryFirstByte(napi_env env, napi_callback_info info) { + size_t argc = 1; + napi_value args[1]; + NODE_API_CALL(env, napi_get_cb_info(env, info, &argc, args, NULL, NULL)); + NODE_API_ASSERT(env, argc == 1, "Wrong number of arguments"); + uint8_t* data; + size_t size; + bool is_arraybuffer, is_dataview; + NODE_API_CALL(env, napi_is_arraybuffer(env, args[0], &is_arraybuffer)); + NODE_API_CALL(env, napi_is_dataview(env, args[0], &is_dataview)); + if (is_arraybuffer) { + NODE_API_CALL(env, + napi_get_arraybuffer_info(env, args[0], (void**)(&data), &size)); + } else if (is_dataview) { + NODE_API_CALL(env, + napi_get_dataview_info( + env, args[0], &size, (void**)(&data), NULL, NULL)); + } else { + NODE_API_CALL(env, + napi_get_buffer_info(env, args[0], (void**)(&data), &size)); + } + NODE_API_ASSERT(env, size > 0, "Expected non-empty data"); + + napi_value returnValue; + NODE_API_CALL(env, napi_create_uint32(env, data[0], &returnValue)); + return returnValue; +} + +static napi_value getInfoOutputs(napi_env env, napi_callback_info info) { + size_t argc = 2; + napi_value args[2]; + NODE_API_CALL(env, napi_get_cb_info(env, info, &argc, args, NULL, NULL)); + NODE_API_ASSERT(env, argc >= 1, "Wrong number of arguments"); + bool with_type = false; + if (argc > 1) { + NODE_API_CALL(env, napi_get_value_bool(env, args[1], &with_type)); + } + bool is_dataview; + NODE_API_CALL(env, napi_is_dataview(env, args[0], &is_dataview)); + void* data = NULL; + napi_value arraybuffer = NULL; + size_t byte_offset = (size_t)-1; + size_t length = 0; + napi_typedarray_type type = (napi_typedarray_type)-1; + if (is_dataview) { + NODE_API_CALL(env, + napi_get_dataview_info( + env, args[0], &length, &data, &arraybuffer, &byte_offset)); + } else { + NODE_API_CALL(env, + napi_get_typedarray_info( + env, args[0], with_type ? &type : NULL, &length, &data, + &arraybuffer, &byte_offset)); + } + + napi_value ret, data_value, length_value, byte_offset_value; + NODE_API_CALL(env, napi_create_object(env, &ret)); + NODE_API_CALL(env, + napi_create_double(env, (double)(size_t)data, &data_value)); + NODE_API_CALL(env, napi_create_double(env, (double)length, &length_value)); + NODE_API_CALL(env, + napi_create_double(env, (double)byte_offset, &byte_offset_value)); + NODE_API_CALL(env, napi_set_named_property(env, ret, "data", data_value)); + NODE_API_CALL(env, napi_set_named_property(env, ret, "length", length_value)); + NODE_API_CALL(env, + napi_set_named_property(env, ret, "byteOffset", byte_offset_value)); + NODE_API_CALL(env, + napi_set_named_property(env, ret, "arraybuffer", arraybuffer)); + if (with_type && !is_dataview) { + napi_value type_value; + NODE_API_CALL(env, napi_create_int32(env, (int32_t)type, &type_value)); + NODE_API_CALL(env, napi_set_named_property(env, ret, "type", type_value)); + } + return ret; +} + +// Calls napi_get_dataview_info UNCONDITIONALLY (no realm-local napi_is_dataview +// dispatch), so a cross-realm DataView actually exercises that entry point. +// Returns the raw status plus the four outputs. +static napi_value getDataViewInfoOutputs(napi_env env, napi_callback_info info) { + size_t argc = 1; + napi_value args[1]; + NODE_API_CALL(env, napi_get_cb_info(env, info, &argc, args, NULL, NULL)); + NODE_API_ASSERT(env, argc == 1, "Wrong number of arguments"); + void* data = NULL; + napi_value arraybuffer = NULL; + size_t byte_offset = (size_t)-1; + size_t length = 0; + napi_status status = napi_get_dataview_info( + env, args[0], &length, &data, &arraybuffer, &byte_offset); + + napi_value ret, status_value, data_value, length_value, byte_offset_value; + NODE_API_CALL(env, napi_create_object(env, &ret)); + NODE_API_CALL(env, napi_create_int32(env, (int32_t)status, &status_value)); + NODE_API_CALL(env, napi_set_named_property(env, ret, "status", status_value)); + if (status == napi_ok) { + NODE_API_CALL(env, + napi_create_double(env, (double)(size_t)data, &data_value)); + NODE_API_CALL(env, napi_create_double(env, (double)length, &length_value)); + NODE_API_CALL(env, + napi_create_double(env, (double)byte_offset, &byte_offset_value)); + NODE_API_CALL(env, napi_set_named_property(env, ret, "data", data_value)); + NODE_API_CALL(env, + napi_set_named_property(env, ret, "length", length_value)); + NODE_API_CALL(env, + napi_set_named_property(env, ret, "byteOffset", byte_offset_value)); + NODE_API_CALL(env, + napi_set_named_property(env, ret, "arraybuffer", arraybuffer)); + } + return ret; +} + +static napi_value getArrayBufferInfoOutputs(napi_env env, + napi_callback_info info) { + size_t argc = 1; + napi_value args[1]; + NODE_API_CALL(env, napi_get_cb_info(env, info, &argc, args, NULL, NULL)); + NODE_API_ASSERT(env, argc == 1, "Wrong number of arguments"); + void* data = NULL; + size_t byte_length = 0; + napi_status status = + napi_get_arraybuffer_info(env, args[0], &data, &byte_length); + + napi_value ret, status_value, byte_length_value; + NODE_API_CALL(env, napi_create_object(env, &ret)); + NODE_API_CALL(env, napi_create_int32(env, (int32_t)status, &status_value)); + NODE_API_CALL(env, napi_set_named_property(env, ret, "status", status_value)); + if (status == napi_ok) { + NODE_API_CALL(env, + napi_create_double(env, (double)byte_length, &byte_length_value)); + NODE_API_CALL(env, + napi_set_named_property(env, ret, "byteLength", byte_length_value)); + } + return ret; +} + static napi_value bufferFromArrayBuffer(napi_env env, napi_callback_info info) { napi_status status; @@ -246,6 +382,10 @@ static napi_value Init(napi_env env, napi_value exports) { DECLARE_NODE_API_PROPERTY("staticBuffer", staticBuffer), DECLARE_NODE_API_PROPERTY("invalidObjectAsBuffer", invalidObjectAsBuffer), DECLARE_NODE_API_PROPERTY("getMemoryDataAsArray", getMemoryDataAsArray), + DECLARE_NODE_API_PROPERTY("getMemoryFirstByte", getMemoryFirstByte), + DECLARE_NODE_API_PROPERTY("getInfoOutputs", getInfoOutputs), + DECLARE_NODE_API_PROPERTY("getDataViewInfoOutputs", getDataViewInfoOutputs), + DECLARE_NODE_API_PROPERTY("getArrayBufferInfoOutputs", getArrayBufferInfoOutputs), DECLARE_NODE_API_PROPERTY("bufferFromArrayBuffer", bufferFromArrayBuffer), }; diff --git a/packages/test/buffer/buffer.test.js b/packages/test/buffer/buffer.test.js index 1d7f95dd..b78e6c26 100644 --- a/packages/test/buffer/buffer.test.js +++ b/packages/test/buffer/buffer.test.js @@ -4,8 +4,10 @@ const common = require('../common') const { load } = require('../util') // const tick = require('util').promisify(require('../tick')) +const loadPromise = load('buffer') + // eslint-disable-next-line camelcase -module.exports = load('buffer').then(async binding => { +module.exports = loadPromise.then(async binding => { await (async function () { assert.strictEqual(binding.newBuffer().toString(), binding.theText) assert.strictEqual(binding.newExternalBuffer().toString(), binding.theText) @@ -61,4 +63,231 @@ module.exports = load('buffer').then(async binding => { assert.deepStrictEqual(binding.getMemoryDataAsArray(buffer), Array(6).fill(99)) assert.deepStrictEqual(binding.getMemoryDataAsArray(typedArray), Array(6).fill(99)) assert.deepStrictEqual(binding.getMemoryDataAsArray(dataView), Array(6).fill(99)) + + const wasmMemory = loadPromise.Module && loadPromise.Module.wasmMemory + if (wasmMemory && wasmMemory.buffer instanceof ArrayBuffer && !process.env.EMNAPI_TEST_4GB) { + const growOnePage = () => { + // Module.growMemory handles the index type of the memory + // (wasmMemory.grow(1) throws on i64-indexed MEMORY64 memories) + if (loadPromise.Module.growMemory) { + loadPromise.Module.growMemory(wasmMemory.buffer.byteLength + 65536) + } else { + wasmMemory.grow(1) + } + } + + // Copying an external source bigger than the whole wasm memory into it must + // grow (and detach) the non-shared memory inside the get-info call, before + // the results are written back. The 4GB lane reserves 2GB before each test + // module is initialized, so a larger-than-memory block cannot fit in wasm32. + const sources = [ + (size) => Buffer.alloc(size, 44), + (size) => new Uint8Array(size).fill(45).buffer, + (size) => new DataView(new Uint8Array(size).fill(46).buffer) + ] + for (let i = 0; i < sources.length; ++i) { + const initialMemoryByteLength = wasmMemory.buffer.byteLength + assert.strictEqual(binding.getMemoryFirstByte(sources[i](initialMemoryByteLength + 1)), 44 + i) + assert(wasmMemory.buffer.byteLength > initialMemoryByteLength) + } + + // Native Node-API reads view metadata from the internal slots and never + // consults user-installed accessors, so no user JS (which could grow and + // detach a non-shared memory mid-call) can run inside a get-info call. + // The blocks below pin that native-parity behavior: accessors are never + // invoked, the memory does not grow, and the outputs report the true + // slot values. + const BYTE_OFFSET = 8 + const LEN = 4 + + // own length/byteLength accessor (returning either a number or an + // object whose valueOf() would grow the memory) must never be consulted + for (const [Ctor, lengthProp] of [[Uint8Array, 'length'], [DataView, 'byteLength']]) { + for (const returnValue of [LEN, { valueOf () { growOnePage(); return LEN } }]) { + const view = new (class extends Ctor {})(wasmMemory.buffer, BYTE_OFFSET, LEN) + let getterCalls = 0 + Object.defineProperty(view, lengthProp, { + get () { + getterCalls++ + growOnePage() + return returnValue + } + }) + const bufferBeforeCall = wasmMemory.buffer + const r = binding.getInfoOutputs(view) + assert.strictEqual(getterCalls, 0, `the own ${lengthProp} accessor must not be consulted`) + assert.strictEqual(wasmMemory.buffer, bufferBeforeCall, 'the memory must not grow during the call') + assert.strictEqual(r.arraybuffer, wasmMemory.buffer) + assert.strictEqual(r.byteOffset, BYTE_OFFSET) + assert.strictEqual(r.length, LEN) + assert.strictEqual(r.data, BYTE_OFFSET) + } + } + + // a lying (non-growing) length accessor is ignored: the outputs report + // the true slot values, exactly like native Node-API + { + const view = new Uint8Array(wasmMemory.buffer, BYTE_OFFSET, LEN) + let lengthReads = 0 + Object.defineProperty(view, 'length', { + get () { + lengthReads++ + return 2 // lie: the true length is 4 + } + }) + const r = binding.getInfoOutputs(view) + assert.strictEqual(lengthReads, 0, 'the own length accessor must not be consulted') + assert.strictEqual(r.length, LEN) + assert.strictEqual(r.byteOffset, BYTE_OFFSET) + assert.strictEqual(r.data, BYTE_OFFSET) + } + + // own byteOffset accessor must never be consulted + for (const Ctor of [Uint8Array, DataView]) { + const view = new Ctor(wasmMemory.buffer, BYTE_OFFSET, LEN) + let byteOffsetReads = 0 + Object.defineProperty(view, 'byteOffset', { + get () { + byteOffsetReads++ + growOnePage() + return BYTE_OFFSET + } + }) + const bufferBeforeCall = wasmMemory.buffer + const r = binding.getInfoOutputs(view) + assert.strictEqual(byteOffsetReads, 0, 'the own byteOffset accessor must not be consulted') + assert.strictEqual(wasmMemory.buffer, bufferBeforeCall, 'the memory must not grow during the call') + assert.strictEqual(r.arraybuffer, wasmMemory.buffer) + assert.strictEqual(r.byteOffset, BYTE_OFFSET) + assert.strictEqual(r.length, LEN) + assert.strictEqual(r.data, BYTE_OFFSET) + } + + // own buffer accessor (returning a cached buffer it detached by growing) + // must never be consulted; the arraybuffer output is the real buffer + for (const Ctor of [Uint8Array, DataView]) { + const view = new Ctor(wasmMemory.buffer, BYTE_OFFSET, LEN) + let bufferReads = 0 + let cached = null + Object.defineProperty(view, 'buffer', { + get () { + bufferReads++ + if (cached === null) { + cached = wasmMemory.buffer + growOnePage() + } + return cached + } + }) + const bufferBeforeCall = wasmMemory.buffer + const r = binding.getInfoOutputs(view) + assert.strictEqual(bufferReads, 0, 'the own buffer accessor must not be consulted') + assert.strictEqual(wasmMemory.buffer, bufferBeforeCall, 'the memory must not grow during the call') + assert.strictEqual(r.arraybuffer, wasmMemory.buffer) + assert.strictEqual(r.byteOffset, BYTE_OFFSET) + assert.strictEqual(r.length, LEN) + assert.strictEqual(r.data, BYTE_OFFSET) + } + + // own byteLength accessor on an ArrayBuffer must never be consulted by + // napi_get_arraybuffer_info; the byte_length output is the slot value + { + const ab = new ArrayBuffer(8) + new Uint8Array(ab).fill(66) + let byteLengthReads = 0 + Object.defineProperty(ab, 'byteLength', { + get () { + byteLengthReads++ + return 4 // lie: the true byteLength is 8 + } + }) + const arr = binding.getMemoryDataAsArray(ab) + assert.strictEqual(byteLengthReads, 0, 'the own byteLength accessor must not be consulted') + assert.deepStrictEqual(arr, Array(8).fill(66)) + } + + // cross-realm views over the wasm memory: internal-slot reads and + // classification are realm-independent (native parity) + { + const vm = require('vm') + const realm = { buffer: wasmMemory.buffer, BYTE_OFFSET, LEN } + const crossTypedArray = vm.runInNewContext('new Uint16Array(buffer, BYTE_OFFSET, LEN)', realm) + const rTypedArray = binding.getInfoOutputs(crossTypedArray, true) + assert.strictEqual(rTypedArray.type, 4 /* napi_uint16_array */) + assert.strictEqual(rTypedArray.length, LEN) + assert.strictEqual(rTypedArray.byteOffset, BYTE_OFFSET) + assert.strictEqual(rTypedArray.data, BYTE_OFFSET) + assert.strictEqual(rTypedArray.arraybuffer, wasmMemory.buffer) + + // the routed path (napi_is_dataview is realm-local -> false -> typed + // array info) covers the tag-rule half + const crossDataView = vm.runInNewContext('new DataView(buffer, BYTE_OFFSET, LEN)', realm) + const rDataView = binding.getInfoOutputs(crossDataView) + assert.strictEqual(rDataView.length, LEN) + assert.strictEqual(rDataView.byteOffset, BYTE_OFFSET) + assert.strictEqual(rDataView.data, BYTE_OFFSET) + assert.strictEqual(rDataView.arraybuffer, wasmMemory.buffer) + + // and napi_get_dataview_info itself must accept the cross-realm DataView + // (unconditional entry, no realm-local instanceof guard) + const rDirect = binding.getDataViewInfoOutputs(crossDataView) + assert.strictEqual(rDirect.status, 0 /* napi_ok */) + assert.strictEqual(rDirect.length, LEN) + assert.strictEqual(rDirect.byteOffset, BYTE_OFFSET) + assert.strictEqual(rDirect.data, BYTE_OFFSET) + assert.strictEqual(rDirect.arraybuffer, wasmMemory.buffer) + } + + // Reconstructing a stale registered view after a growth must not run a + // user subclass constructor: it would run AFTER the fresh buffer was + // captured as an argument, so a growth inside it would hand back + // stale/detached outputs with napi_ok. + for (const Base of [Uint8Array, DataView]) { + let armed = false + let userCtorCalls = 0 + class GrowingView extends Base { + constructor (...args) { + super(...args) + if (armed) { + userCtorCalls++ + growOnePage() + } + } + } + const view = new GrowingView(wasmMemory.buffer, BYTE_OFFSET, LEN) + // register the descriptor while the subclass constructor is inert + const r1 = binding.getInfoOutputs(view) + assert.strictEqual(r1.length, LEN) + // detach the registered view's buffer, then arm the constructor so a + // (wrong) reconstruction through it would grow and detach again + growOnePage() + armed = true + const bufferBeforeCall = wasmMemory.buffer + const r = binding.getInfoOutputs(view) + assert.strictEqual(userCtorCalls, 0, 'emnapi must not invoke the user subclass constructor') + assert.strictEqual(wasmMemory.buffer, bufferBeforeCall, 'the memory must not grow during the call') + assert.strictEqual(r.arraybuffer, wasmMemory.buffer) + assert.strictEqual(r.byteOffset, BYTE_OFFSET) + assert.strictEqual(r.length, LEN) + assert.strictEqual(r.data, BYTE_OFFSET) + } + + // issue #143 / hidden-global realm: a genuine SharedArrayBuffer must be + // accepted by napi_get_arraybuffer_info even when the SharedArrayBuffer + // global was deleted before emnapi initialized (the child deletes it + // before loading the module). Non-shared builds only — on a shared-memory + // build the threaded worker bootstrap itself needs the global. + const { spawnSync } = require('child_process') + const path = require('path') + const child = spawnSync(process.execPath, [path.join(__dirname, 'hidden-sab-global.js')], { + env: process.env, + encoding: 'utf8' + }) + assert.strictEqual(child.status, 0, `hidden-sab-global child failed\nstdout: ${child.stdout}\nstderr: ${child.stderr}`) + } + + // sanity pin for the unconditional napi_get_arraybuffer_info helper + const abInfo = binding.getArrayBufferInfoOutputs(new ArrayBuffer(8)) + assert.strictEqual(abInfo.status, 0 /* napi_ok */) + assert.strictEqual(abInfo.byteLength, 8) }) diff --git a/packages/test/buffer/hidden-sab-global.js b/packages/test/buffer/hidden-sab-global.js new file mode 100644 index 00000000..008c4e3d --- /dev/null +++ b/packages/test/buffer/hidden-sab-global.js @@ -0,0 +1,38 @@ +'use strict' + +// Child helper for buffer.test.js (not a test file itself). +// +// Simulates a hidden-global environment (issue #143): the SharedArrayBuffer +// global is removed BEFORE emnapi loads, while the engine still hands out +// genuine SABs — a shared WebAssembly.Memory buffer is one regardless of the +// global. napi_get_arraybuffer_info on it must still succeed. +// +// This scenario is single-threaded by construction: on a threaded (shared +// wasm memory) build the worker bootstrap itself needs the global, so the +// parent only spawns this child on non-shared builds. +delete globalThis.SharedArrayBuffer + +const assert = require('assert') + +// the engine can still create genuine SABs without the global +const sharedMemory = new WebAssembly.Memory({ initial: 1, maximum: 4, shared: true }) +const sab = sharedMemory.buffer +assert.strictEqual(typeof SharedArrayBuffer, 'undefined', 'the global must stay hidden') +assert.strictEqual( + Object.prototype.toString.call(sab), + '[object SharedArrayBuffer]', + 'precondition: the shared memory buffer must be a genuine SAB' +) + +const { load } = require('../util') + +load('buffer').then((binding) => { + const r = binding.getArrayBufferInfoOutputs(sab) + assert.strictEqual(r.status, 0 /* napi_ok */, 'napi_get_arraybuffer_info must accept a genuine SAB in a hidden-global realm') + assert.strictEqual(r.byteLength, sab.byteLength) + console.log('hidden-sab-global OK') + process.exit(0) +}).catch((err) => { + console.error(err) + process.exit(1) +}) diff --git a/packages/test/function/function.test.js b/packages/test/function/function.test.js index f17e3e80..57fa338f 100644 --- a/packages/test/function/function.test.js +++ b/packages/test/function/function.test.js @@ -4,7 +4,9 @@ const assert = require('assert') const common = require('../common') const { load } = require('../util') -module.exports = load('function').then(async test_function => { +const loadPromise = load('function') + +module.exports = loadPromise.then(async test_function => { function func1 () { return 1 } @@ -26,6 +28,20 @@ module.exports = load('function').then(async test_function => { } assert.strictEqual(test_function.TestCall(func4, 1), 2) + const wasmMemory = loadPromise.Module && loadPromise.Module.wasmMemory + if (wasmMemory && wasmMemory.buffer instanceof ArrayBuffer) { + assert.strictEqual(test_function.TestCall(() => { + // Module.growMemory handles the index type of the memory + // (wasmMemory.grow(1) throws on i64-indexed MEMORY64 memories) + if (loadPromise.Module.growMemory) { + loadPromise.Module.growMemory(wasmMemory.buffer.byteLength + 65536) + } else { + wasmMemory.grow(1) + } + return 3 + }), 3) + } + assert.strictEqual(test_function.TestName.name, 'Name') assert.strictEqual(test_function.TestNameShort.name, 'Name_') let called = 0 diff --git a/packages/test/tsfn2/binding.c b/packages/test/tsfn2/binding.c index 6f0ff6f1..f26d70c1 100644 --- a/packages/test/tsfn2/binding.c +++ b/packages/test/tsfn2/binding.c @@ -22,6 +22,7 @@ void free(void* p); struct ctx { int in; int count; + int raw; napi_async_work work; napi_ref ok_callback; napi_threadsafe_function progress_callback; @@ -37,9 +38,12 @@ static void Execute(napi_env env, void* user_data) { #else sleep(1); #endif - int* index = (int*)malloc(sizeof(int)); + int* index = NULL; data->out++; - *index = data->out; + if (!data->raw) { + index = (int*)malloc(sizeof(int)); + *index = data->out; + } if (napi_ok != napi_call_threadsafe_function(data->progress_callback, index, napi_tsfn_blocking)) { data->status = 1; return; @@ -85,19 +89,23 @@ static void call_js(napi_env env, napi_value cb, void* hint, void* data) { } static napi_value Test(napi_env env, napi_callback_info info) { - size_t argc = 4; - napi_value argv[4]; + size_t argc = 5; + napi_value argv[5]; napi_value resname1, resname2; NODE_API_CALL(env, napi_get_cb_info(env, info, &argc, argv, NULL, NULL)); NODE_API_CALL(env, napi_create_string_utf8(env, "progress_callback", -1, &resname1)); NODE_API_CALL(env, napi_create_string_utf8(env, "tsfntest", -1, &resname2)); int32_t in, count; + bool raw = false; napi_ref ok_callback; - + NODE_API_CALL(env, napi_get_value_int32(env, argv[0], &in)); NODE_API_CALL(env, napi_get_value_int32(env, argv[1], &count)); NODE_API_CALL(env, napi_create_reference(env, argv[2], 1, &ok_callback)); + if (argc > 4) { + NODE_API_CALL(env, napi_get_value_bool(env, argv[4], &raw)); + } struct ctx* data = (struct ctx*)malloc(sizeof(struct ctx)); if (!data) { @@ -106,6 +114,7 @@ static napi_value Test(napi_env env, napi_callback_info info) { } data->in = in; data->count = count; + data->raw = raw ? 1 : 0; data->ok_callback = ok_callback; data->out = in; data->status = 0; @@ -113,7 +122,7 @@ static napi_value Test(napi_env env, napi_callback_info info) { NODE_API_CALL(env, napi_create_async_work(env, NULL, resname2, Execute, Complete, data, &data->work)); NODE_API_CALL(env, napi_create_threadsafe_function(env, argv[3], NULL, resname1, 0, 1, - data, tsfn_finalize, NULL, call_js, &data->progress_callback)); + data, tsfn_finalize, NULL, raw ? NULL : call_js, &data->progress_callback)); NODE_API_CALL(env, napi_queue_async_work(env, data->work)); return NULL; } diff --git a/packages/test/tsfn2/main.js b/packages/test/tsfn2/main.js index 0d6b5256..7ee3dc9e 100644 --- a/packages/test/tsfn2/main.js +++ b/packages/test/tsfn2/main.js @@ -8,20 +8,73 @@ module.exports = async function main (loadPromise) { const input = 10 const count = 3 - const result = input + count - const progressData = [] - for (let i = 1; i <= count; ++i) { - progressData.push(input + i) - } - const actual = [] - binding.testTSFN(input, count, common.mustCall(function (status, out) { - console.log(status, out) - assert.strictEqual(status, 0) - assert.strictEqual(out, result) - assert.deepStrictEqual(actual, progressData) - }, 1), common.mustCall(function (current) { - actual.push(current) - console.log(current) - }, count)) - console.log('testTSFN') + + // Growing the memory inside the FIRST progress callback makes the growth + // span the views cached by the TSFN dispatch/enqueue machinery while a + // drain is in flight. + await new Promise((resolve) => { + const result = input + count + const progressData = [] + for (let i = 1; i <= count; ++i) { + progressData.push(input + i) + } + const actual = [] + let grown = false + binding.testTSFN(input, count, common.mustCall(function (status, out) { + console.log(status, out) + assert.strictEqual(status, 0) + assert.strictEqual(out, result) + assert.deepStrictEqual(actual, progressData) + resolve() + }, 1), common.mustCall(function (current) { + actual.push(current) + console.log(current) + if (!grown) { + grown = true + const wasmMemory = loadPromise.Module && loadPromise.Module.wasmMemory + if (wasmMemory && wasmMemory.buffer instanceof ArrayBuffer) { + // Module.growMemory handles the index type of the memory + // (wasmMemory.grow(1) throws on i64-indexed MEMORY64 memories) + if (loadPromise.Module.growMemory) { + loadPromise.Module.growMemory(wasmMemory.buffer.byteLength + 65536) + } else { + wasmMemory.grow(1) + } + } + } + }, count)) + console.log('testTSFN') + }) + + // A progress callback that throws must not leave the TSFN dispatch state + // stuck: the remaining queued calls and the completion must still arrive. + // The raw mode dispatches the JS function directly (no native call_js), + // so the throw propagates through the dispatch loop instead of the + // pending-exception machinery. + await new Promise((resolve) => { + let calls = 0 + // the JS threadsafe-function dispatch rethrows the callback exception to + // the event loop after re-arming (it surfaces as an uncaughtException); + // the native (C) implementation linked on the pthread lanes routes it + // through the pending-exception machinery instead, so the hook must + // tolerate the exception arriving either way + const onUncaughtException = (err) => { + assert.strictEqual(err.message, 'tsfn2 progress throw') + } + process.on('uncaughtException', onUncaughtException) + binding.testTSFN(input, count, common.mustCall(function (status, out) { + console.log('raw', status, out) + process.removeListener('uncaughtException', onUncaughtException) + assert.strictEqual(status, 0) + assert.strictEqual(out, input + count) + assert.strictEqual(calls, count) + resolve() + }, 1), common.mustCall(function () { + calls++ + console.log('raw progress', calls) + if (calls === 1) { + throw new Error('tsfn2 progress throw') + } + }, count), true) + }) } diff --git a/packages/ts-transform-emscripten-parse-tools/src/index.ts b/packages/ts-transform-emscripten-parse-tools/src/index.ts index a2a94d4d..261724ee 100644 --- a/packages/ts-transform-emscripten-parse-tools/src/index.ts +++ b/packages/ts-transform-emscripten-parse-tools/src/index.ts @@ -303,22 +303,67 @@ class Transform { } createHeapDataViewDeclaration (): VariableStatement { - return this.ctx.factory.createVariableStatement( + const factory = this.ctx.factory + const createDataViewOfWasmMemory = (): Expression => factory.createNewExpression( + factory.createIdentifier('DataView'), undefined, - this.ctx.factory.createVariableDeclarationList( - [this.ctx.factory.createVariableDeclaration( - this.ctx.factory.createIdentifier('HEAP_DATA_VIEW'), - undefined, - undefined, - this.ctx.factory.createNewExpression( - this.ctx.factory.createIdentifier('DataView'), + [factory.createPropertyAccessExpression( + this.createLazyEmscriptenRuntimeSymbol('wasmMemory'), + factory.createIdentifier('buffer') + )] + ) + // var HEAP_DATA_VIEW = new DataView(wasmMemory.buffer), + // GET_HEAP_DATA_VIEW = () => HEAP_DATA_VIEW.buffer === wasmMemory.buffer + // ? HEAP_DATA_VIEW + // : (HEAP_DATA_VIEW = new DataView(wasmMemory.buffer)); + // growing a non-shared wasm memory detaches the buffer captured by the + // hoisted view, so every access revalidates it by identity (steady state + // is a compare, a new DataView is only created after a growth) + return factory.createVariableStatement( + undefined, + factory.createVariableDeclarationList( + [ + factory.createVariableDeclaration( + factory.createIdentifier('HEAP_DATA_VIEW'), undefined, - [this.ctx.factory.createPropertyAccessExpression( - this.createLazyEmscriptenRuntimeSymbol('wasmMemory'), - this.ctx.factory.createIdentifier('buffer') - )] + undefined, + createDataViewOfWasmMemory() + ), + factory.createVariableDeclaration( + factory.createIdentifier('GET_HEAP_DATA_VIEW'), + undefined, + undefined, + factory.createArrowFunction( + undefined, + undefined, + [], + undefined, + factory.createToken(ts.SyntaxKind.EqualsGreaterThanToken), + factory.createConditionalExpression( + factory.createBinaryExpression( + factory.createPropertyAccessExpression( + factory.createIdentifier('HEAP_DATA_VIEW'), + factory.createIdentifier('buffer') + ), + factory.createToken(ts.SyntaxKind.EqualsEqualsEqualsToken), + factory.createPropertyAccessExpression( + this.createLazyEmscriptenRuntimeSymbol('wasmMemory'), + factory.createIdentifier('buffer') + ) + ), + factory.createToken(ts.SyntaxKind.QuestionToken), + factory.createIdentifier('HEAP_DATA_VIEW'), + factory.createToken(ts.SyntaxKind.ColonToken), + factory.createParenthesizedExpression( + factory.createAssignment( + factory.createIdentifier('HEAP_DATA_VIEW'), + createDataViewOfWasmMemory() + ) + ) + ) + ) ) - )], + ], ts.NodeFlags.None ) ) @@ -350,7 +395,7 @@ class Transform { if (!statement) return false let includeGetOrSet = false const statementVisitor: Visitor = (n) => { - if (ts.isIdentifier(n) && n.text === 'HEAP_DATA_VIEW') { + if (ts.isIdentifier(n) && (n.text === 'HEAP_DATA_VIEW' || n.text === 'GET_HEAP_DATA_VIEW')) { if (!this.insertWasmMemoryImport) { this.insertWasmMemoryImport = true } @@ -595,7 +640,11 @@ class Transform { return this.ctx.factory.createCallExpression( this.ctx.factory.createPropertyAccessExpression( - this.ctx.factory.createIdentifier('HEAP_DATA_VIEW'), + this.ctx.factory.createCallExpression( + this.ctx.factory.createIdentifier('GET_HEAP_DATA_VIEW'), + undefined, + [] + ), this.ctx.factory.createIdentifier(getDataViewGetMethod(this.defines, type)) ), undefined, @@ -642,7 +691,11 @@ class Transform { const methodName = getDataViewSetMethod(this.defines, type) return this.ctx.factory.createCallExpression( this.ctx.factory.createPropertyAccessExpression( - this.ctx.factory.createIdentifier('HEAP_DATA_VIEW'), + this.ctx.factory.createCallExpression( + this.ctx.factory.createIdentifier('GET_HEAP_DATA_VIEW'), + undefined, + [] + ), this.ctx.factory.createIdentifier(methodName) ), undefined, diff --git a/packages/wasi-threads/src/wasi-threads.ts b/packages/wasi-threads/src/wasi-threads.ts index 0478d949..ce6d307c 100644 --- a/packages/wasi-threads/src/wasi-threads.ts +++ b/packages/wasi-threads/src/wasi-threads.ts @@ -57,6 +57,55 @@ export interface StartResult { } const patchedWasiInstances = new WeakMap>() +const THREAD_SPAWN_RESULT_SIZE = Int32Array.BYTES_PER_ELEMENT * 2 + +// Intrinsic SharedArrayBuffer.prototype.byteLength getter: it reads the +// internal slot, so it is a conclusive brand check that a spoofed own +// Symbol.toStringTag (which fools Object.prototype.toString) cannot satisfy. +const sharedArrayBufferByteLength: ((this: any) => number) | undefined = + typeof SharedArrayBuffer === 'function' + ? Object.getOwnPropertyDescriptor(SharedArrayBuffer.prototype, 'byteLength')!.get! + : undefined + +function isConclusivelySharedArrayBuffer (buffer: ArrayBufferLike): boolean { + if (sharedArrayBufferByteLength === undefined) return false + try { + sharedArrayBufferByteLength.call(buffer) + return true + } catch (_) { + return false + } +} + +function getThreadSpawnResultView ( + memory: WebAssembly.Memory, + address: number | bigint, + wasm64: boolean +): Int32Array { + // The result pointer arrives across the wasm ABI: an i64 (memory64) address + // is a bigint, and an upper-half i32 address is a negative Number. Normalize + // it the way malloc results are normalized elsewhere in this file. + const offset = typeof address === 'bigint' ? Number(address) : address >>> 0 + let buffer = memory.buffer + if ( + offset + THREAD_SPAWN_RESULT_SIZE > buffer.byteLength && + // Only refresh shared memory. A conclusive brand check is required here: + // grow(0) detaches an *unshared* buffer (spec: even at delta 0), so a + // spoofed 'SharedArrayBuffer' tag must never be allowed to reach it. + isConclusivelySharedArrayBuffer(buffer) + ) { + // Another agent may have grown shared memory while this agent still sees + // the previous shorter buffer. A zero-page grow refreshes the local view + // without changing the memory size. + if (wasm64) { + ;(memory.grow as any)(BigInt(0)) + } else { + memory.grow(0) + } + buffer = memory.buffer + } + return new Int32Array(buffer, offset, 2) +} /** @public */ export class WASIThreads { @@ -126,7 +175,11 @@ export class WASIThreads { } catch (err) { this.PThread?.printErr(err.stack) if (isNewABI) { - const struct = new Int32Array(this.wasmMemory.buffer, errorOrTid!, 2) + const struct = getThreadSpawnResultView( + this.wasmMemory, + errorOrTid!, + wasm64 + ) Atomics.store(struct, 0, 1) Atomics.store(struct, 1, EAGAIN) Atomics.notify(struct, 1) @@ -145,7 +198,11 @@ export class WASIThreads { } const _free = this.wasmInstance.exports.free as (ptr: number | bigint) => void const free = wasm64 ? (ptr: number) => { _free(BigInt(ptr)) } : _free - const struct = new Int32Array(this.wasmMemory.buffer, errorOrTid!, 2) + const struct = getThreadSpawnResultView( + this.wasmMemory, + errorOrTid!, + wasm64 + ) Atomics.store(struct, 0, 0) Atomics.store(struct, 1, 0) diff --git a/packages/wasi-threads/test/grow-memory-worker.js b/packages/wasi-threads/test/grow-memory-worker.js new file mode 100644 index 00000000..cba8719d --- /dev/null +++ b/packages/wasi-threads/test/grow-memory-worker.js @@ -0,0 +1,4 @@ +import { parentPort, workerData } from 'node:worker_threads' + +workerData.memory.grow(1) +parentPort.postMessage(null) diff --git a/packages/wasi-threads/test/index.js b/packages/wasi-threads/test/index.js index f34b6469..85de8132 100644 --- a/packages/wasi-threads/test/index.js +++ b/packages/wasi-threads/test/index.js @@ -1,6 +1,204 @@ +import assert from 'node:assert/strict' import { WASI } from 'wasi' import { Worker } from 'worker_threads' import { WASIThreads } from '@emnapi/wasi-threads' import { main } from './run.js' -main(WASI, WASIThreads, Worker, process, './worker.js') +async function testThreadSpawnAfterCrossAgentMemoryGrowth () { + const memory = new WebAssembly.Memory({ + initial: 1, + maximum: 3, + shared: true + }) + const staleBuffer = memory.buffer + const worker = new Worker( + new URL('./grow-memory-worker.js', import.meta.url), + { + type: 'module', + workerData: { memory } + } + ) + await new Promise((resolve, reject) => { + worker.once('message', resolve) + worker.once('error', reject) + }) + await worker.terminate() + + const memoryBufferGetter = Object.getOwnPropertyDescriptor( + WebAssembly.Memory.prototype, + 'buffer' + ).get + const originalGrow = memory.grow.bind(memory) + let stale = true + let refreshDelta + Object.defineProperties(memory, { + buffer: { + configurable: true, + get () { + return stale ? staleBuffer : memoryBufferGetter.call(memory) + } + }, + grow: { + configurable: true, + value (delta) { + refreshDelta = delta + stale = false + return originalGrow(delta) + } + } + }) + + let spawnMessage + const wasiThreads = new WASIThreads({ + wasi: { + initialize () {}, + start () { + return 0 + } + }, + childThread: true, + postMessage (message) { + spawnMessage = message + const address = message.__emnapi__.payload.errorOrTid + const struct = new Int32Array( + memoryBufferGetter.call(memory), + address, + 2 + ) + Atomics.store(struct, 0, 1) + Atomics.store(struct, 1, 6) + Atomics.notify(struct, 1) + } + }) + wasiThreads.setup({ exports: { memory } }, {}, memory) + + const errorOrTid = staleBuffer.byteLength + try { + const result = wasiThreads.getImportObject().wasi['thread-spawn']( + 123, + errorOrTid + ) + const currentBuffer = memoryBufferGetter.call(memory) + const struct = new Int32Array(currentBuffer, errorOrTid, 2) + + assert.strictEqual(result, 1) + assert.strictEqual(refreshDelta, 0) + assert.strictEqual(stale, false) + assert.strictEqual( + spawnMessage.__emnapi__.payload.errorOrTid, + errorOrTid + ) + assert.deepStrictEqual(Array.from(struct), [1, 6]) + } finally { + delete memory.buffer + delete memory.grow + } +} + +// The thread-spawn result pointer crosses the wasm ABI: a memory64 address +// arrives as a bigint, and an upper-half wasm32 address arrives as a negative +// Number. The helper must normalize both before touching the memory. +async function testThreadSpawnNormalizesBigintAddress () { + const memory = new WebAssembly.Memory({ initial: 2, maximum: 3, shared: true }) + let spawnMessage + const wasiThreads = new WASIThreads({ + wasi: { initialize () {}, start () { return 0 } }, + childThread: true, + postMessage (message) { + spawnMessage = message + // the payload carries the raw (bigint) address; normalize to read it + const address = Number(message.__emnapi__.payload.errorOrTid) + const struct = new Int32Array(memory.buffer, address, 2) + Atomics.store(struct, 0, 1) + Atomics.store(struct, 1, 6) + Atomics.notify(struct, 1) + } + }) + wasiThreads.setup({ exports: { memory } }, {}, memory) + + // in-range address supplied as a bigint (memory64 ABI). Before the fix the + // helper evaluated `address + SIZE` mixing bigint and number and threw. + const result = wasiThreads.getImportObject().wasi['thread-spawn'](123, 64n) + const struct = new Int32Array(memory.buffer, 64, 2) + assert.strictEqual(result, 1) + assert.strictEqual(spawnMessage.__emnapi__.payload.errorOrTid, 64n) + assert.deepStrictEqual(Array.from(struct), [1, 6]) +} + +// An upper-half wasm32 address arrives negative; it must be read at +// `address >>> 0`. When normalized, the (now huge) offset exceeds the current +// buffer length, so the shared-memory refresh gate fires — observable as a +// grow() call. Before the fix the raw negative address failed the gate +// (`-8 + 8 > byteLength` is false), so no refresh was attempted. +async function testThreadSpawnNormalizesNegativeAddress () { + const memory = new WebAssembly.Memory({ initial: 1, maximum: 2, shared: true }) + let growCalls = 0 + const originalGrow = memory.grow.bind(memory) + Object.defineProperty(memory, 'grow', { + configurable: true, + value (delta) { growCalls++; return originalGrow(delta) } + }) + const wasiThreads = new WASIThreads({ + wasi: { initialize () {}, start () { return 0 } }, + childThread: true, + postMessage () {} + }) + wasiThreads.setup({ exports: { memory } }, {}, memory) + + try { + // -8 normalizes to 0xFFFFFFF8, far beyond the 1-page buffer, so the + // Int32Array construction still fails — but the refresh gate must have + // fired first, proving the address was read at `>>> 0`. + assert.throws( + () => wasiThreads.getImportObject().wasi['thread-spawn'](123, -8), + RangeError + ) + assert.strictEqual(growCalls, 1, 'the normalized (>>> 0) offset must trip the refresh gate') + } finally { + delete memory.grow + } +} + +// A buffer with a spoofed 'SharedArrayBuffer' @@toStringTag (which fools +// Object.prototype.toString) must NOT be treated as shared: growing it — +// even by zero — would detach an unshared buffer. +async function testThreadSpawnRejectsSpoofedSharedBrand () { + const memory = new WebAssembly.Memory({ initial: 1, maximum: 2 }) // unshared + Object.defineProperty(memory.buffer, Symbol.toStringTag, { + value: 'SharedArrayBuffer', + configurable: true + }) + const oldBuffer = memory.buffer + let growCalls = 0 + const originalGrow = memory.grow.bind(memory) + Object.defineProperty(memory, 'grow', { + configurable: true, + value (delta) { growCalls++; return originalGrow(delta) } + }) + + const wasiThreads = new WASIThreads({ + wasi: { initialize () {}, start () { return 0 } }, + childThread: true, + postMessage () {} + }) + wasiThreads.setup({ exports: { memory } }, {}, memory) + + const outOfRangeAddress = oldBuffer.byteLength + 64 + try { + assert.throws( + () => wasiThreads.getImportObject().wasi['thread-spawn'](123, outOfRangeAddress), + RangeError + ) + assert.strictEqual(growCalls, 0, 'must not grow a non-conclusively-shared buffer') + assert.strictEqual(oldBuffer.byteLength, 65536, 'the unshared buffer must not be detached') + assert.strictEqual(memory.buffer, oldBuffer, 'the memory buffer must be unchanged') + } finally { + delete memory.grow + } +} + +await testThreadSpawnAfterCrossAgentMemoryGrowth() +await testThreadSpawnNormalizesBigintAddress() +await testThreadSpawnNormalizesNegativeAddress() +await testThreadSpawnRejectsSpoofedSharedBrand() +await main(WASI, WASIThreads, Worker, process, './worker.js')