Browser memory64 (docs/mem64.md step 6): the wasm64 shim runs x86-64 programs under node

- shims/browser builds for wasm64-unknown-unknown (GEX_MEM64=1 build.sh):
  gex_browser.x86.m64.shared.wasm, a 16 GiB memory64; its JS exports and
  imports have the same types as the wasm32 shim's.
- gexc seed --mem64; gex-emit's dense dispatch addresses memory in its index
  type (wasm32 output byte-identical).
- The global kernel for such processes (GEX_MEM64=1 shims/kernel/build.sh,
  gex_kernel.x86.m64.wasm): gex-kmem's process64 feature addresses the
  process memory with i64; add-memory.mjs can declare a memory64.
- Glue: GEX_MEM64 (run-node, carried to Workers) creates the memory64, grows
  it in BigInt pages and sets the wasm64 shim's i64 __stack_pointer.
  GEX_DUMP_BAD_WASM=DIR keeps a rejected module under node.

ack, bits and branchy (gx64) print the same as on the wasm32 shim.
Guest addresses passed to the kernel are still u32 (buffers below 4 GiB).

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01PeRutwFgZ66XpxjasKrPn1
This commit is contained in:
velzieandClaude Opus 5.5 committed 2026-10-05 00:32:03 -04:00
1 parent 3bd52232a3
commit 36e98cd5c5
14 files changed
+112 -16

No files matched your search

+4 -1
View File
@@ -255,7 +255,7 @@ pub fn emit_arm(f: &mut crate::wasm::FuncBody, k: usize, ctx: u32, index: u32, s
#[allow(clippy::too_many_arguments)]
pub fn emit_arm_at(f: &mut crate::wasm::FuncBody, k: usize, ctx: u32, index: u32, scratch: u32, block_ty: u32, classic: u32, blocks: u32) {
debug_assert!(k >= FIRST_TABLE);
f.i32_const(0);
f.addr_const(0);
f.load(0x28, 2, (MAPS + 4 * (k - FIRST_TABLE) as u64) as u32); // i32.load
f.local_tee(scratch);
f.if_void();
@@ -265,6 +265,7 @@ pub fn emit_arm_at(f: &mut crate::wasm::FuncBody, k: usize, ctx: u32, index: u32
f.i32_const(2);
f.byte(0x74); // i32.shl
f.byte(0x6a); // i32.add
f.addr_i32(); // the map is in the runtime's low memory
f.load(0x28, 2, 0); // i32.load: the block's index in `blocks`
f.return_call_indirect(block_ty, blocks);
f.end();
@@ -287,6 +288,7 @@ pub fn dispatch_body(block_ty: u32, first: u32, blocks: u32) -> Vec<u8> {
let entry = f.add_local(ValType::I32);
let index = f.add_local(ValType::I32);
f.local_get(0);
f.addr_i32();
f.load(0x29, 3, ctx::LAST_TARGET as u32);
f.local_set(target);
f.local_get(target);
@@ -297,6 +299,7 @@ pub fn dispatch_body(block_ty: u32, first: u32, blocks: u32) -> Vec<u8> {
f.byte(0xa7); // i32.wrap_i64
f.i32_const(4);
f.byte(0x6c); // i32.mul
f.addr_i32();
f.load(0x28, 2, DISPATCH_DIR as u32);
f.i32_const(0);
f.local_get(page);
+5 -1
View File
@@ -109,7 +109,11 @@ impl<'a> GuestMem<'a> {
fn limit(&self) -> usize {
#[cfg(target_family = "wasm")]
{
let live = (core::arch::wasm32::memory_size(0) as u64) << 16;
#[cfg(target_arch = "wasm32")]
let pages = core::arch::wasm32::memory_size(0);
#[cfg(target_arch = "wasm64")]
let pages = core::arch::wasm64::memory_size(0);
let live = (pages as u64) << 16;
(live as usize).min(self.view().len())
}
#[cfg(not(target_family = "wasm"))]
+5
View File
@@ -5,3 +5,8 @@ edition.workspace = true
[lib]
crate-type = ["rlib"]
[features]
# The process memory is a memory64 (an x86-64 guest with a 64-bit memory,
# docs/mem64.md step 6): its addresses are i64 in `memory.copy`/`memory.size`.
process64 = []
+10 -1
View File
@@ -25,7 +25,10 @@ pub const PROCESS_MEMORY: u32 = 1;
/// rather than trapping the Worker mid-syscall with the kernel lock held.
#[inline]
pub fn process_pages() -> u32 {
#[cfg(not(feature = "process64"))]
let n: u32;
#[cfg(feature = "process64")]
let n: u64;
unsafe {
core::arch::asm!(
"memory.size 1",
@@ -33,7 +36,7 @@ pub fn process_pages() -> u32 {
n = out(local) n,
);
}
n
n as u32
}
/// Whether `at..at+len` is inside the calling process's memory. Every `push`
@@ -57,6 +60,10 @@ pub fn in_process(at: u32, len: u32) -> bool {
/// syscall that has already taken the kernel lock.
#[inline]
pub unsafe fn pull(dst: *mut u8, src: u32, len: u32) {
// A memory64 process memory takes an i64 address; the kernel's own (and
// the length, the smaller of the two index types) stay i32.
#[cfg(feature = "process64")]
let src = src as u64;
unsafe {
core::arch::asm!(
"local.get {dst}",
@@ -76,6 +83,8 @@ pub unsafe fn pull(dst: *mut u8, src: u32, len: u32) {
/// As [`pull`], with the roles reversed.
#[inline]
pub unsafe fn push(dst: u32, src: *const u8, len: u32) {
#[cfg(feature = "process64")]
let dst = dst as u64;
unsafe {
core::arch::asm!(
"local.get {dst}",
+3 -1
View File
@@ -107,7 +107,7 @@ usage:
through DT_NEEDED, each at the base a
pinned run places it, writing one module
per object and the layout beside them)
gexc seed -o <out.wasm> write a module that translates nothing, for
gexc seed -o <out.wasm> [--mem64] write a module that translates nothing, for
a host that will translate at runtime and
does not know the program in advance
gexc closure <elf> --sysroot DIR
@@ -717,6 +717,8 @@ fn cmd_seed(args: &[String]) -> Result<()> {
out = Some(args.get(i).context("-o needs a path")?.clone());
}
"--no-shared-memory" => shared_memory = false,
// The seed for the wasm64 (memory64) browser shim.
"--mem64" => gex_abi::set_mem_flavor(gex_abi::MemFlavor::M64),
other => bail!("unexpected argument {other}"),
}
i += 1;
+22
View File
@@ -219,6 +219,28 @@ $GEXC seed -o web/public/gex/seed$INFIX.wasm --no-shared-memory >/dev/null
$GEXC seed -o web/public/gex/seed$INFIX.shared.wasm >/dev/null
echo " seed: rebuilt from this emitter"
# `GEX_MEM64=1`: also the wasm64 shim for a 64-bit guest memory (docs/mem64.md
# step 6; the Unity games on .NET, docs/unity-dotnet.md P5), x86-64 guests
# only. Same source, target wasm64-unknown-unknown (nightly, build-std),
# shared memory only; its memory is a memory64 of LIMIT64 (16 GiB). The
# layout below 4 GiB is the wasm32 one. Artefacts get `.m64` after the arch.
if [ -n "${GEX_MEM64:-}" ] && [ -n "$ARCH_FEAT" ]; then
M64OUT=target/w64$TSUF/wasm64-unknown-unknown/release/gex_browser.wasm
LINK64=$(echo "$LINK" | sed "s/--max-memory=$GUEST_BYTES/--max-memory=$((16 << 30))/")
if RUSTFLAGS="$LINK64 \
-C link-arg=--shared-memory \
-C target-feature=+atomics,+bulk-memory,+mutable-globals \
-Zcrate-attr=feature(simd_wasm64)" \
cargo +nightly build -p gex-browser --target wasm64-unknown-unknown \
--release -Z build-std=std,panic_abort --features "shared-memory$EXTRA" \
--target-dir target/w64$TSUF 2>&1 | grep -E "^error" -A5; then
echo "shims/browser: the wasm64 build FAILED"; exit 1
fi
cp "$M64OUT" "$PUB/gex_browser$INFIX.m64.shared.wasm"
$GEXC seed --mem64 -o web/public/gex/seed$INFIX.m64.shared.wasm >/dev/null
echo "shims/browser: $(stat -c%s "$M64OUT") bytes (wasm64, memory64), seed$INFIX.m64.shared.wasm"
fi
# And the site, which is a copy of public/ and will otherwise keep serving
# the shim that was here a moment ago.
web/refresh-dist.sh
+23 -7
View File
@@ -7,6 +7,16 @@
// `gex_abi::GUEST_MEM_INITIAL_BYTES`.
const GUEST_BYTES = 0xffff0000;
// A 64-bit guest memory (docs/mem64.md step 6): the wasm64 shim and seed, a
// memory64 of `gex_abi::LIMIT64` (16 GiB). The shim's own addresses (its
// stack, data and the low runtime words) stay below 4 GiB either way; only
// the memory's type and the i64 globals of a wasm64 module differ.
// Read when used, not at import: a Worker learns it from its first message.
const m64 = () => !!globalThis.GEX_MEM64;
const LIMIT64_BYTES = 16 * 2 ** 30;
/** A value for an address-typed global: i64 (BigInt) in a wasm64 module. */
const addrVal = (v) => (m64() ? BigInt(v >>> 0) : v | 0);
/**
* Decode UTF-8 out of guest memory.
*
@@ -593,7 +603,10 @@ export class Gex {
// new one -- which is what a *thread* is: a second Worker on the same
// memory, seeing the same guest and kernel. A new memory is a new process.
this.#memory =
memory ?? new WebAssembly.Memory({ initial: INITIAL_PAGES, maximum: PAGES, shared });
memory ??
(m64()
? new WebAssembly.Memory({ address: 'i64', initial: BigInt(INITIAL_PAGES), maximum: BigInt(LIMIT64_BYTES / 65536), shared })
: new WebAssembly.Memory({ initial: INITIAL_PAGES, maximum: PAGES, shared }));
this.#kernelSpec = kernel;
}
@@ -616,7 +629,7 @@ export class Gex {
// low region -- Workers starting together get different ones.
if (typeof this.#shim.exports.gex_worker_init?.call === 'function' && this.#memory.buffer instanceof SharedArrayBuffer) {
const k = Atomics.add(new Int32Array(this.#memory.buffer), BOOT_STACK_COUNTER >> 2, 1) & 31;
this.#shim.exports.__stack_pointer.value = (SHIM_STACK_TOP - k * 0x40000) | 0;
this.#shim.exports.__stack_pointer.value = addrVal(SHIM_STACK_TOP - k * 0x40000);
}
const at = this.#shim.exports.gex_worker_init?.();
this.#stackTop = at ? at | 0 : SHIM_STACK_TOP | 0;
@@ -634,7 +647,7 @@ export class Gex {
if (end <= have) return;
// A 64-bit memory counts its pages in BigInt, here as everywhere else.
const pages = Math.ceil((end - have) / 65536);
this.#memory.grow(pages);
this.#memory.grow(m64() ? BigInt(pages) : pages);
this.#u8 = null;
}
@@ -945,7 +958,7 @@ export class Gex {
// First, before any call into the shim: `gex_worker_init` gives this Worker
// its thread-local block, and until it runs `__tls_base` is zero, so a shim
// function touching a thread-local writes through a null base.
this.#shim.exports.__stack_pointer.value = this.#workerStack();
this.#shim.exports.__stack_pointer.value = addrVal(this.#workerStack());
// An older shim loads but fails on the first call to something it lacks,
// far from the mistake. Named here so the skew is a sentence, not a mystery.
const required = [
@@ -978,7 +991,7 @@ export class Gex {
if (Gex.watchDir >= 0) this.#shim.exports.gex_watch_dir?.(BigInt(Gex.watchDir));
// wasm-ld puts the shim's stack at address zero (the guest's image); move
// it into the shim's own region, above everything the guest maps.
this.#shim.exports.__stack_pointer.value = this.#workerStack();
this.#shim.exports.__stack_pointer.value = addrVal(this.#workerStack());
// Carried into every fork child's Worker (see the fork record's `opts`), so
// a debugging switch set for the run holds for the processes it spawns --
// the block trace of a child that crashes is otherwise silent.
@@ -1397,7 +1410,7 @@ export class Gex {
this.#u8 = null;
// The stack pointer is a per-instance global, so every Worker has its own
// and must be told where it goes.
this.#shim.exports.__stack_pointer.value = this.#workerStack();
this.#shim.exports.__stack_pointer.value = addrVal(this.#workerStack());
// The devices this process has -- process-wide flags in shared memory, so
// only a *process* sets them. A thread answering for itself would turn a
// GPU or network off underneath the threads using it. Before the kernel is
@@ -2925,7 +2938,7 @@ export class Gex {
// that restore the shim's shadow-stack global, leaking a few dozen
// bytes each. Nothing is live below here, so reset it exactly -- left
// alone it slid into the guest's stack and overwrote saved registers.
this.#shim.exports.__stack_pointer.value = this.#workerStack();
this.#shim.exports.__stack_pointer.value = addrVal(this.#workerStack());
const e = this.#shim.exports;
// A slot the shim wants written before the guest goes back to it: its
// module is instantiated with passive segments, so the code is present
@@ -3934,6 +3947,9 @@ export class Gex {
// the page (tests/nt's harness keeps them), synchronously, since the
// Worker may be about to die. Best effort.
if (bytes instanceof Uint8Array && err instanceof WebAssembly.CompileError) {
// Under Node, `GEX_DUMP_BAD_WASM=DIR` keeps it, as gexc does natively.
const dir = globalThis.process?.env?.GEX_DUMP_BAD_WASM;
if (dir) try { globalThis.process.getBuiltinModule('fs').writeFileSync(`${dir}/bad-${who}-${Date.now()}.wasm`, bytes); } catch {}
try {
const x = new XMLHttpRequest();
x.open('POST', `/__gex_dump?name=${encodeURIComponent(who)}`, false);
+9 -2
View File
@@ -24,6 +24,12 @@ import { ProcessHost } from './host.mjs';
// (see shims/browser/build.sh), and `globalThis.GEX_ARCH` for the glue.
const ARCH_INFIX = process.env.GEX_ARCH === 'x86' ? '.x86' : '';
if (process.env.GEX_ARCH === 'x86') globalThis.GEX_ARCH = 'x86';
// `GEX_MEM64=1` (with GEX_ARCH=x86 and GEX_SHARED=1): a 64-bit guest memory,
// the `.m64` shim and seed built by `GEX_MEM64=1 shims/browser/build.sh`
// (docs/mem64.md step 6).
const MEM64 = !!process.env.GEX_MEM64;
if (MEM64) globalThis.GEX_MEM64 = 1;
const SHIM_INFIX = ARCH_INFIX + (MEM64 ? '.m64' : '');
// The host runs on the main thread; give it the attach-trace flag too so
// thread spawn/adopt timing is visible from the host side, not just the Worker.
@@ -149,7 +155,7 @@ function readStdin() {
const SHARED = !!process.env.GEX_SHARED;
const shimWasm = readFileSync(
new URL(
SHARED ? `../../../web/public/gex/gex_browser${ARCH_INFIX}.shared.wasm`
SHARED ? `../../../web/public/gex/gex_browser${SHIM_INFIX}.shared.wasm`
: `../../../web/public/gex/gex_browser${ARCH_INFIX}.wasm`,
import.meta.url,
),
@@ -162,7 +168,7 @@ const shimWasm = readFileSync(
let kernel = null;
try {
const path = new URL(
SHARED ? `../../../web/public/gex/gex_kernel${ARCH_INFIX}.wasm`
SHARED ? `../../../web/public/gex/gex_kernel${SHIM_INFIX}.wasm`
: `../../../web/public/gex/gex_kernel${ARCH_INFIX}.plain.wasm`,
import.meta.url,
);
@@ -267,6 +273,7 @@ const host = new ProcessHost({
// GEX_DENSE=0: classic per-slot dispatch tables (gex_emit::dense off).
dense: process.env.GEX_DENSE === '0' ? false : process.env.GEX_DENSE === '2' ? 2 : undefined,
denseDebug: !!process.env.GEX_DENSE_DEBUG,
mem64: MEM64,
calloutTrace: !!process.env.GEX_CALLOUT_TRACE,
inPlace: process.env.GEX_IN_PLACE === '0' ? 0 : undefined,
excTrace: !!process.env.GEX_EXC_TRACE,
+2
View File
@@ -395,6 +395,8 @@ async function handle(msg) {
// default -- it is one line per phase per thread. See threads-worker note.
if (msg.attachTrace) globalThis.GEX_ATTACH_TRACE = 1;
if (msg.denseDebug) globalThis.GEX_DENSE_DEBUG = 1;
// A 64-bit guest memory (docs/mem64.md step 6): the wasm64 shim's process.
if (msg.mem64) globalThis.GEX_MEM64 = 1;
if (msg.calloutTrace) globalThis.GEX_CALLOUT_TRACE = 1;
if (msg.lazyThread) globalThis.GEX_LAZY_THREAD = 1;
// `kernelStack` is this Worker's slice of the kernel's stack array. Absent for
+5
View File
@@ -24,7 +24,12 @@
// already because a module importing a shared memory has to be.
#![cfg_attr(feature = "shared-memory", feature(stdarch_wasm_atomic_wait))]
#[cfg(target_arch = "wasm32")]
use core::arch::wasm32 as warch;
// The memory64 build (docs/mem64.md step 6): an x86-64 guest with a 64-bit
// memory, for the Unity games on .NET (docs/unity-dotnet.md P5).
#[cfg(target_arch = "wasm64")]
use core::arch::wasm64 as warch;
use gex_abi::ctx;
use gex_kernel::errno::{Errno, SysResult};
+1 -1
View File
@@ -161,7 +161,7 @@ pub fn claim(kind: Kind, owner: u32, start: u64, end: u64, what: &'static str) {
other & 0xffff_ffff
));
print_locked(n);
core::arch::wasm32::unreachable();
crate::warch::unreachable();
}
}
}
+2
View File
@@ -14,5 +14,7 @@ gex-kernel.workspace = true
[features]
# Serve an x86-64 guest. See `gex_abi::X86`.
x86 = ["gex-abi/x86", "gex-kernel/x86"]
# Processes with a 64-bit memory (the wasm64 browser shim, docs/mem64.md step 6).
process64 = ["gex-kmem/process64"]
# Host a 32-bit big-endian PowerPC guest (docs/ppc.md).
ppc = ["gex-abi/ppc", "gex-kernel/ppc"]
+16
View File
@@ -80,6 +80,22 @@ RUSTFLAGS="-C link-arg=--import-memory \
node tools/add-memory.mjs "$OUT" web/public/gex/gex_kernel$INFIX.wasm env process 1 1
node tools/add-memory.mjs "$OUT" web/public/gex/gex_kernel$INFIX.plain.wasm env process 1 0
# GEX_MEM64=1 (x86 only): also the kernel for processes with a 64-bit memory
# (the wasm64 shim, docs/mem64.md step 6): its process memory is a memory64 of
# 16 GiB, and gex-kmem addresses it with i64.
if [ -n "${GEX_MEM64:-}" ] && [ -n "$ARCH_FEATURES" ]; then
RUSTFLAGS="-C link-arg=--import-memory \
-C link-arg=--shared-memory \
-C link-arg=--max-memory=4294967296 \
-C link-arg=--export-table \
-C link-arg=--export=__stack_pointer \
-C target-feature=+atomics,+bulk-memory,+mutable-globals" \
cargo +nightly build -p gex-kernel-wasm --target wasm32-unknown-unknown \
--release -Z build-std=std,panic_abort --target-dir $TDIR-m64 --features x86,process64
node tools/add-memory.mjs $TDIR-m64/wasm32-unknown-unknown/release/gex_kernel_wasm.wasm \
web/public/gex/gex_kernel$INFIX.m64.wasm env process 1 1 $(((16 << 30) / 65536)) 64
fi
# And the site, which is a copy of public/ and will otherwise keep serving
# the module that was here a moment ago.
web/refresh-dist.sh
+5 -2
View File
@@ -32,10 +32,13 @@ const [
pages,
shared = '1',
maxPages = GUEST_MAX_PAGES,
// `64`: a memory64 (limits flag 0x04), for processes with a 64-bit guest
// memory (docs/mem64.md step 6).
index = '32',
] = process.argv.slice(2);
if (!inPath || !outPath) {
console.error(
'usage: add-memory.mjs <in.wasm> <out.wasm> [module] [name] [min] [shared] [max]',
'usage: add-memory.mjs <in.wasm> <out.wasm> [module] [name] [min] [shared] [max] [32|64]',
);
process.exit(2);
}
@@ -92,7 +95,7 @@ const str = (s) => [...enc(s.length), ...[...s].map((c) => c.charCodeAt(0))];
const min = pages ? Number(pages) : GUEST_MIN_PAGES;
const entry = [
...str(mod), ...str(name),
0x02, shared === '0' ? 0x01 : 0x03,
0x02, (shared === '0' ? 0x01 : 0x03) | (index === '64' ? 0x04 : 0),
...enc(min), ...enc(Number(maxPages)),
];