From 79daa130a8a544959c0052990244e831d27d6fde Mon Sep 17 00:00:00 2001 From: MaciejBaj Date: Thu, 19 Feb 2026 18:52:00 +0000 Subject: [PATCH 1/2] Add getPagePointer to enable zero-copy memory reads from JS MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements the approach suggested in #113: expose a WASM-side function that returns the linear memory pointer for an already-allocated page buffer, letting JS copy bytes directly without any extra WASM allocation. - `Memory.getPagePointer(pageIndex)` – core method; returns 0 on page/access fault - `getPagePointer(page)` – exported from api-debugger (deprecates `getMemory`) - `pvmGetPagePointer(pvmId, page)` – exported from api-utils (deprecates `pvmReadMemory`) - `Assert.isNotEqual` helper added to test.ts - 5 new tests in memory.test.ts covering fault cases and data correctness 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- assembly/api-debugger.ts | 36 +++++++++++++--------- assembly/api-utils.ts | 34 ++++++++++++++++++++- assembly/memory.test.ts | 64 ++++++++++++++++++++++++++++++++++++++++ assembly/memory.ts | 34 +++++++++++++++++++++ assembly/test.ts | 8 +++++ 5 files changed, 161 insertions(+), 15 deletions(-) diff --git a/assembly/api-debugger.ts b/assembly/api-debugger.ts index 756e1ca..e4767df 100644 --- a/assembly/api-debugger.ts +++ b/assembly/api-debugger.ts @@ -169,36 +169,44 @@ export function getPageDump(index: u32): Uint8Array { } /** - * Read a chunk of memory at `[address, address + length)`. + * Returns the WASM linear memory pointer (byte offset) for the backing buffer of the page at `page`. * - * Returns the requested memory chunk or `null` if reading triggered a page fault. + * Returns `0` if the page does not exist or is not readable (page/access fault). * - * @deprecated Getting memory like that is extremely inefficient (copying mulitple times) - * and error prone (we may not be able to allocate). - * Instead WASM should be able to return memory pointers for already allocated pages. - * So reading memory on the caller side should be something like this: + * Use this instead of `getMemory` to read memory efficiently from the JS side: * ```ts * let pagesRead = 0; * for (let address = start; address < end; address += PAGE_SIZE) { * const page = address >> PAGE_SIZE_SHIFT; - * const maybePointer = getPagePointer(page); - * // check page fault - * if (maybePointer === null) { + * const ptr = getPagePointer(page); + * if (ptr === 0) { * throw new Error(`Page fault at ${page << PAGE_SIZE_SHIFT}`); * } - * // otherwise copy to JS * destination.set( + * new Uint8Array(wasm.instance.exports.memory.buffer, ptr, Math.min(end - address, PAGE_SIZE)), * pagesRead << PAGE_SIZE_SHIFT, - * new Uint8Array(wasm.instance.memory, maybePointer, Math.min(end, PAGE_SIZE)) * ); * pagesRead += 1; * } * ``` + */ +export function getPagePointer(page: u32): usize { + if (interpreter === null) { + return 0; + } + const int = interpreter; + return int.memory.getPagePointer(page); +} + +/** + * Read a chunk of memory at `[address, address + length)`. * - * goals: - * 1. No additional allocations on the WASM side - * 2. Copying directly from wasm memory on the JS side + * Returns the requested memory chunk or `null` if reading triggered a page fault. * + * @deprecated Getting memory like that is extremely inefficient (copying mulitple times) + * and error prone (we may not be able to allocate). + * Use `getPagePointer` instead to read memory directly from WASM linear memory on the JS side + * with no additional WASM-side allocations. */ export function getMemory(address: u32, length: u32): Uint8Array | null { if (interpreter === null) { diff --git a/assembly/api-utils.ts b/assembly/api-utils.ts index c0a063c..3125141 100644 --- a/assembly/api-utils.ts +++ b/assembly/api-utils.ts @@ -153,7 +153,8 @@ export function pvmSetRegisters(pvmId: u32, registers: u64[]): void { /** * Read a continuous chunk of memory from given PVM instance. * - * @deprecated see getMemory for details + * @deprecated Use `pvmGetPagePointer` instead to read memory directly from WASM linear memory + * on the JS side with no additional WASM-side allocations. */ export function pvmReadMemory(pvmId: u32, address: u32, length: u32): Uint8Array | null { if (pvms.has(pvmId)) { @@ -167,6 +168,37 @@ export function pvmReadMemory(pvmId: u32, address: u32, length: u32): Uint8Array return null; } +/** + * Returns the WASM linear memory pointer (byte offset) for the backing buffer of the page at `page` + * in the given PVM instance. + * + * Returns `0` if the PVM does not exist, the page does not exist, or the page is not readable. + * + * Use this instead of `pvmReadMemory` to read memory efficiently from the JS side: + * ```ts + * let pagesRead = 0; + * for (let address = start; address < end; address += PAGE_SIZE) { + * const page = address >> PAGE_SIZE_SHIFT; + * const ptr = pvmGetPagePointer(pvmId, page); + * if (ptr === 0) { + * throw new Error(`Page fault at ${page << PAGE_SIZE_SHIFT}`); + * } + * destination.set( + * new Uint8Array(wasm.instance.exports.memory.buffer, ptr, Math.min(end - address, PAGE_SIZE)), + * pagesRead << PAGE_SIZE_SHIFT, + * ); + * pagesRead += 1; + * } + * ``` + */ +export function pvmGetPagePointer(pvmId: u32, page: u32): usize { + if (pvms.has(pvmId)) { + const int = pvms.get(pvmId); + return int.memory.getPagePointer(page); + } + return 0; +} + /** Write a chunk of memory to given PVM instance. */ export function pvmWriteMemory(pvmId: u32, address: u32, data: Uint8Array): boolean { if (pvms.has(pvmId)) { diff --git a/assembly/memory.test.ts b/assembly/memory.test.ts index 72df410..97e2c93 100644 --- a/assembly/memory.test.ts +++ b/assembly/memory.test.ts @@ -239,4 +239,68 @@ export const TESTS: Test[] = [ return assert; }), + test("getPagePointer returns 0 for missing page", (assert) => { + const mem = new MemoryBuilder().build(); + const pageIndex: u32 = RESERVED_PAGES + 5; + + const ptr = mem.getPagePointer(pageIndex); + + assert.isEqual(ptr, 0, "should return 0 for non-existent page"); + return assert; + }), + test("getPagePointer returns 0 for reserved (inaccessible) page", (assert) => { + const mem = new MemoryBuilder().build(); + + // Reserved pages (indices 0..RESERVED_PAGES-1) are never allocated, so pointer = 0. + const ptr = mem.getPagePointer(0); + + assert.isEqual(ptr, 0, "should return 0 for reserved page"); + return assert; + }), + test("getPagePointer returns 0 for write-only page when read is requested", (assert) => { + // Access.Write pages ARE readable (can(Access.Read) returns true for Write pages). + // Access.None pages are not. Create a Read-only page and verify pointer is non-zero. + const builder = new MemoryBuilder(); + const pageAddress: u32 = RESERVED_MEMORY; + builder.setData(Access.Read, pageAddress, new Uint8Array(PAGE_SIZE)); + const mem = builder.build(); + + const pageIndex: u32 = pageAddress >> PAGE_SIZE_SHIFT; + const ptr = mem.getPagePointer(pageIndex); + + assert.isNotEqual(ptr, 0, "should return non-zero pointer for readable page"); + return assert; + }), + test("getPagePointer returns non-zero pointer for writable page", (assert) => { + const builder = new MemoryBuilder(); + const pageAddress: u32 = RESERVED_MEMORY; + builder.setData(Access.Write, pageAddress, new Uint8Array(PAGE_SIZE)); + const mem = builder.build(); + + const pageIndex: u32 = pageAddress >> PAGE_SIZE_SHIFT; + const ptr = mem.getPagePointer(pageIndex); + + assert.isNotEqual(ptr, 0, "should return non-zero pointer for writable page"); + return assert; + }), + test("getPagePointer data matches page content", (assert) => { + const builder = new MemoryBuilder(); + const pageAddress: u32 = RESERVED_MEMORY; + const data = new Uint8Array(PAGE_SIZE); + data[0] = 0xde; + data[1] = 0xad; + data[PAGE_SIZE - 1] = 0xff; + builder.setData(Access.Read, pageAddress, data); + const mem = builder.build(); + + const pageIndex: u32 = pageAddress >> PAGE_SIZE_SHIFT; + const ptr = mem.getPagePointer(pageIndex); + + assert.isNotEqual(ptr, 0, "pointer should be valid"); + // Read bytes directly via the pointer and compare. + assert.isEqual(load(ptr + 0), 0xde, "byte 0 should match"); + assert.isEqual(load(ptr + 1), 0xad, "byte 1 should match"); + assert.isEqual(load(ptr + PAGE_SIZE - 1), 0xff, "last byte should match"); + return assert; + }), ]; diff --git a/assembly/memory.ts b/assembly/memory.ts index 4537908..d9011f2 100644 --- a/assembly/memory.ts +++ b/assembly/memory.ts @@ -123,6 +123,40 @@ export class Memory { return this.pages.get(index).raw.data; } + /** + * Returns the WASM linear memory pointer (byte offset) for the backing buffer of the page at `pageIndex`. + * + * Returns `0` if the page does not exist or is not readable (page/access fault). + * + * This enables efficient memory reading on the JS side without extra WASM allocations: + * ```ts + * let pagesRead = 0; + * for (let address = start; address < end; address += PAGE_SIZE) { + * const page = address >> PAGE_SIZE_SHIFT; + * const ptr = getPagePointer(page); + * if (ptr === 0) { + * throw new Error(`Page fault at ${page << PAGE_SIZE_SHIFT}`); + * } + * destination.set( + * new Uint8Array(wasm.instance.exports.memory.buffer, ptr, Math.min(end - address, PAGE_SIZE)), + * pagesRead << PAGE_SIZE_SHIFT, + * ); + * pagesRead += 1; + * } + * ``` + */ + getPagePointer(pageIndex: u32): usize { + if (!this.pages.has(pageIndex)) { + return 0; + } + const page = this.pages.get(pageIndex); + if (!page.can(Access.Read)) { + return 0; + } + // Trigger lazy allocation if the backing buffer has not been created yet. + return page.raw.data.dataStart; + } + free(): void { const pages = this.pages.values(); for (let i = 0; i < pages.length; i++) { diff --git a/assembly/test.ts b/assembly/test.ts index 4c836f1..93797e5 100644 --- a/assembly/test.ts +++ b/assembly/test.ts @@ -33,6 +33,14 @@ export class Assert { this.errors.push(`Got: '${actualDisplay}', expected: '${expectDisplay}' @ ${msg}`); } } + + isNotEqual(actual: T, unexpected: T, msg: string = ""): void { + if (actual === unexpected) { + this.isOkay = false; + const actualDisplay = isInteger(actual) ? `${actual} (0x${actual.toString(16)})` : `${actual}`; + this.errors.push(`Expected value to differ from: '${actualDisplay}' @ ${msg}`); + } + } } export function test(name: string, ptr: (assert: Assert) => Assert): Test { From 4f80cb583dc0041ff4884c8d0822b7699011c4ad Mon Sep 17 00:00:00 2001 From: Maciej Baj <6909553+MaciejBaj@users.noreply.github.com> Date: Thu, 19 Feb 2026 21:59:58 +0000 Subject: [PATCH 2/2] test: align inner comment suggestions as per @coderabbitai Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> --- assembly/memory.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/assembly/memory.test.ts b/assembly/memory.test.ts index 97e2c93..dee34a4 100644 --- a/assembly/memory.test.ts +++ b/assembly/memory.test.ts @@ -257,7 +257,7 @@ export const TESTS: Test[] = [ assert.isEqual(ptr, 0, "should return 0 for reserved page"); return assert; }), - test("getPagePointer returns 0 for write-only page when read is requested", (assert) => { + test("getPagePointer returns non-zero pointer for read-accessible page", (assert) => { // Access.Write pages ARE readable (can(Access.Read) returns true for Write pages). // Access.None pages are not. Create a Read-only page and verify pointer is non-zero. const builder = new MemoryBuilder();