Merge master 07f1ed19 into merge/gexcc-c-1011 (ffe8a54e)

Conflicts:
- ftab.rs: master's INST_ALIGN guard on the rebridge test.
- syscalls.rs: eagain_park's first_iovec (same as sp-iov's inline read_iovecs) and its test.
- content key: T's word-wise moduleContentKey with isShared, and the shim's content_key to match;
  #registryKey on install, with master's unit-cache fallback.
- exec-reuse-page.sh: "exec-ed" (an apostrophe ends the single-quoted awk program).

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_781bad2ec05143db9067c86b4b2678e2
This commit is contained in:
velzieandClaude Opus 5.5 committed 2026-10-11 03:04:29 -04:00
commit 467beaaca8
64 files changed
+3886 -275

No files matched your search

+1
View File
@@ -3,6 +3,7 @@ target/
tests/corpus/bin/
web/node_modules/
web/dist/
web/dist-sp/
web/public/gex/
web/public/rootfs/
web/public/closure/
+25 -1
View File
@@ -132,7 +132,7 @@ pub const WEB_NT_DIR: &str = "runtime/web/nt";
/// The files of [`WEB_NT_DIR`] besides `js/`.
pub const WEB_NT_WASM: [&str; 3] = ["gex_browser.x86.shared.wasm", "gex_kernel.x86.wasm", "seed.x86.shared.wasm"];
/// The host JavaScript a Windows program's web run needs (shims/browser/js).
pub const WEB_NT_JS: [(&str, &str); 9] = [
pub const WEB_NT_JS: [(&str, &str); 10] = [
("run-node.mjs", include_str!("../../../shims/browser/js/run-node.mjs")),
("host.mjs", include_str!("../../../shims/browser/js/host.mjs")),
("worker.mjs", include_str!("../../../shims/browser/js/worker.mjs")),
@@ -142,6 +142,7 @@ pub const WEB_NT_JS: [(&str, &str); 9] = [
("net-node.mjs", include_str!("../../../shims/browser/js/net-node.mjs")),
("net-bridge.mjs", include_str!("../../../shims/browser/js/net-bridge.mjs")),
("lazy-fetch.mjs", include_str!("../../../shims/browser/js/lazy-fetch.mjs")),
("single.mjs", include_str!("../../../shims/browser/js/single.mjs")),
];
/// `run.mjs` of a Windows program's project: the web build under Node, on
/// the browser runtime a page loads (run-node.mjs), AOT-only.
@@ -856,3 +857,26 @@ pub fn run(dir: &Path, flags: &crate::run::RunFlags) -> Result<Exit> {
let (exit, _) = gex_native::nt::run_nt_program(&opts, &gex_xlate::Options::default(), &setup)?;
Ok(exit)
}
#[cfg(test)]
mod tests {
use super::WEB_NT_JS;
/// Every module the vendored runtime imports (`'./x.mjs'`) is vendored
/// too: a new import the list missed broke every ejected web run.
#[test]
fn the_web_runtime_vendors_every_module_it_imports() {
let have: Vec<&str> = WEB_NT_JS.iter().map(|(f, _)| *f).collect();
for (f, text) in WEB_NT_JS {
for q in ['\'', '"'] {
for part in text.split(&format!("{q}./")).skip(1) {
let Some(end) = part.find(q) else { continue };
let m = &part[..end];
if m.ends_with(".mjs") {
assert!(have.contains(&m), "{f} imports ./{m}, which WEB_NT_JS leaves out");
}
}
}
}
}
}
+18 -6
View File
@@ -73,6 +73,11 @@ pub const TABLE_INDEX: u32 = gex_abi::DISPATCH_TABLES as u32;
/// [`set_base`].
pub const SECTION: &str = "gex.dense";
/// The imported i32 global a runtime module's dense element segment is
/// placed at, when it has one (rather than a constant offset): its value is
/// the section's `base`, which the host passes at instantiation.
pub const GLOBAL: &str = "dense_base";
static ON: AtomicBool = AtomicBool::new(false);
/// Emit dense dispatch and dense element segments from now on.
@@ -125,7 +130,9 @@ impl Section {
#[derive(Clone, Copy, Debug)]
pub struct Located {
pub section: (usize, usize),
pub offset_imm: usize,
/// The segment's constant offset, or `None` where it is
/// [`GLOBAL`]'s value.
pub offset_imm: Option<usize>,
}
fn leb(b: &[u8], p: &mut usize) -> Option<u32> {
@@ -156,8 +163,10 @@ pub fn has_section(wasm: &[u8]) -> bool {
locate_parts(wasm).is_none_or(|(s, _)| s.is_some())
}
/// The dense section's payload range, and the dense segment's constant
/// offset (`Some(None)` for one at [`GLOBAL`]), each `None` when absent.
#[allow(clippy::type_complexity)]
fn locate_parts(wasm: &[u8]) -> Option<(Option<(usize, usize)>, Option<usize>)> {
fn locate_parts(wasm: &[u8]) -> Option<(Option<(usize, usize)>, Option<Option<usize>>)> {
if wasm.len() < 8 || &wasm[..4] != b"\0asm" {
return None;
}
@@ -216,7 +225,7 @@ fn locate_parts(wasm: &[u8]) -> Option<(Option<(usize, usize)>, Option<usize>)>
leb(wasm, &mut q)?;
}
if flags == 2 && table == TABLE_INDEX {
offset_imm = imm;
offset_imm = Some(imm);
}
}
}
@@ -229,7 +238,10 @@ fn locate_parts(wasm: &[u8]) -> Option<(Option<(usize, usize)>, Option<usize>)>
/// and the segment's offset.
pub fn set_base(wasm: &mut [u8], at: &Located, base: u32) {
wasm[at.section.0..at.section.0 + 4].copy_from_slice(&base.to_le_bytes());
let imm = &mut wasm[at.offset_imm..at.offset_imm + 5];
let Some(imm) = at.offset_imm else {
return;
};
let imm = &mut wasm[imm..imm + 5];
let mut v = base as i32;
for (i, b) in imm.iter_mut().enumerate() {
let byte = (v & 0x7f) as u8;
@@ -355,7 +367,7 @@ mod tests {
}
let f = m.declare_func(t);
m.define_func(f, vec![2, 0, 0x0b]);
let seg = |table, offset, padded| ElementSegment { table, offset, page_base: 0, funcs: vec![f], passive: false, guest_page: 0, padded };
let seg = |table, offset, padded| ElementSegment { table, offset, page_base: 0, funcs: vec![f], passive: false, guest_page: 0, padded, offset_global: None };
m.element(seg(0, 11, false));
m.element(seg(4, 0x0b0b, false));
m.element(seg(TABLE_INDEX, 0, true));
@@ -364,7 +376,7 @@ mod tests {
set_base(&mut wasm, &at, 0x1234_567);
let again = locate(&wasm).expect("located again");
assert_eq!(section(&wasm, &again).unwrap().base, 0x1234_567);
let mut q = again.offset_imm;
let mut q = again.offset_imm.expect("a constant offset");
let mut v = 0u32;
let mut shift = 0;
loop {
+44 -6
View File
@@ -565,6 +565,11 @@ pub struct ModuleBuilder {
miss_stub: u32,
/// `enter(ctx, pc)`, which the miss stub re-dispatches through.
enter: u32,
/// `dispatch(ctx, pc)`: `enter`'s lookup and tail call without its stores
/// of `ctx::PC` and `ctx::LAST_TARGET`, which is what a block's indirect
/// dispatch tail-calls (tier-1's inline dispatch never stored them on
/// A64, and a cgen bridge reads `LAST_TARGET`).
dispatch: u32,
/// Guest block address -> wasm function index.
blocks: HashMap<u64, u32>,
/// Blocks awaiting code generation, paired with their function index.
@@ -582,6 +587,8 @@ pub struct ModuleBuilder {
/// module was started: the helper functions and the region-base global.
/// `None` leaves the module exactly as it would be without profiling.
profile: Option<ProfileFns>,
/// The imported global holding this module's dense base, if any.
dense_global: Option<u32>,
}
/// The helpers a profiled module defines. See `crate::profile`.
@@ -738,11 +745,19 @@ impl ModuleBuilder {
m.import_table(IMPORT_MODULE, crate::dense::TABLE_NAME, 0);
}
}
// Where this module's dense blocks go, given at instantiation rather
// than written into the bytes: the bytes are then the same in every
// process, and a module another process compiled is reused as it is
// (`crate::dense::GLOBAL`). Not with profiling, whose module defines
// a global of its own at index 0.
let dense_global = (linkage == Linkage::Shared && crate::dense::on() && !profiling)
.then(|| m.import_global(IMPORT_MODULE, crate::dense::GLOBAL, ValType::I32));
// The miss stub and `enter` are the first two local functions, so
// their indices are known before any block is declared.
let miss_stub = m.declare_func(block_ty);
let enter = m.declare_func(t_syscall);
let dispatch = m.declare_func(t_syscall);
let mut b = ModuleBuilder {
m,
@@ -753,6 +768,7 @@ impl ModuleBuilder {
block_ty,
miss_stub,
enter,
dispatch,
blocks: HashMap::new(),
pending: Vec::new(),
imports,
@@ -761,6 +777,7 @@ impl ModuleBuilder {
exports_by_addr: Vec::new(),
demand_fill: false,
profile: None,
dense_global,
};
if profiling {
@@ -789,7 +806,9 @@ impl ModuleBuilder {
let stub_body = b.build_miss_stub();
b.m.define_func(miss_stub, stub_body);
let enter_body = b.build_enter();
b.m.define_func(enter, enter_body);
b.m.define_func_with_relocs(enter, enter_body.0, enter_body.1);
let dispatch_body = b.build_dispatch();
b.m.define_func(dispatch, dispatch_body);
if linkage == Linkage::Own {
// Re-export the imported memory (index 0, imports coming first),
@@ -903,6 +922,7 @@ impl ModuleBuilder {
}
}
}
let heads = tier1::loop_heads(self.pending.iter().map(|(b, _)| b));
let cx = tier1::Context {
syscall: self.imports.syscall,
illegal: self.imports.illegal,
@@ -920,6 +940,8 @@ impl ModuleBuilder {
profile: profile_emit.as_ref(),
page_bases: Some(&page_bases),
module_id,
loop_heads: Some(&heads),
enter: Some(self.dispatch),
};
// The single largest serial phase there was: one wasm body per
// guest block, hundreds of thousands for a large library.
@@ -1020,6 +1042,7 @@ impl ModuleBuilder {
// not a page, and is never demand filled.
guest_page: 0,
padded: false,
offset_global: None,
});
// The dense block table, its entry 0 the miss stub as a slot
// nobody installed reads 0 (`crate::dense`).
@@ -1033,6 +1056,7 @@ impl ModuleBuilder {
passive: false,
guest_page: 0,
padded: false,
offset_global: None,
});
}
}
@@ -1134,6 +1158,7 @@ impl ModuleBuilder {
passive,
guest_page: page as u32,
padded: false,
offset_global: None,
});
} else {
// A small runtime unit shares its page with a closure whose
@@ -1159,6 +1184,7 @@ impl ModuleBuilder {
passive,
guest_page: page as u32,
padded: false,
offset_global: None,
});
i = j;
}
@@ -1175,6 +1201,7 @@ impl ModuleBuilder {
passive: false,
guest_page: 0,
padded: true,
offset_global: self.dense_global,
});
dense_section = Some(crate::dense::Section { base: 0, entries: dense_slots }.encode());
}
@@ -1243,7 +1270,7 @@ impl ModuleBuilder {
table_slots,
dir_entries,
covered,
page_table: self.page_table.clone(),
page_table: std::mem::take(&mut self.page_table),
module_id,
n_blocks,
exported_blocks,
@@ -1317,11 +1344,10 @@ impl ModuleBuilder {
f.finish()
}
/// `(ctx, pc) -> ()`: the host's entry point into translated code.
fn build_enter(&self) -> Vec<u8> {
/// `(ctx, pc) -> ()`: the host's entry point into translated code:
/// record the PC and the target, then [`Self::build_dispatch`]'s lookup.
fn build_enter(&self) -> (Vec<u8>, Vec<u32>) {
let mut f = FuncBody::new(2);
let page = f.add_local(ValType::I64);
// Record the entry PC so a miss inside `enter` reports it correctly,
// and the exact target: for a shared slot's trampoline (x86-64), and
// for the function table's bridge (merge-plan §9.8.1), which serves
@@ -1331,6 +1357,18 @@ impl ModuleBuilder {
f.local_get(1);
f.store(0x37, 3, ctx::PC as u32);
f.local_get(0);
f.local_get(1);
f.return_call_local(self.dispatch);
f.finish_with_relocs()
}
/// `(ctx, pc) -> ()`: record the target in `ctx::LAST_TARGET`, then the
/// two-level lookup and the tail call to the block -- what tier-1's inline
/// dispatch does, leaving `ctx::PC` alone.
fn build_dispatch(&self) -> Vec<u8> {
let mut f = FuncBody::new(2);
let page = f.add_local(ValType::I64);
f.local_get(0);
f.addr_i32();
f.local_get(1);
f.store(0x37, 3, ctx::LAST_TARGET as u32);
+205 -28
View File
@@ -21,6 +21,44 @@ use gex_abi::{ctx, DISPATCH_DIR, DISPATCH_MAX_PAGES, DISPATCH_PAGE, GUEST_LIMIT,
use gex_ir::*;
use std::collections::HashMap;
/// A multiply-rotate hasher for small keys (registers, addresses): the
/// standard SipHash was a few percent of translating at run time, and
/// nothing here hashes keys an adversary chooses.
#[derive(Default, Clone, Copy)]
pub struct FastHasher(u64);
impl std::hash::Hasher for FastHasher {
fn write(&mut self, bytes: &[u8]) {
for &b in bytes {
self.write_u64(b as u64);
}
}
fn write_u8(&mut self, n: u8) {
self.write_u64(n as u64);
}
fn write_u16(&mut self, n: u16) {
self.write_u64(n as u64);
}
fn write_u32(&mut self, n: u32) {
self.write_u64(n as u64);
}
fn write_u64(&mut self, n: u64) {
self.0 = (self.0.rotate_left(5) ^ n).wrapping_mul(0x51_7cc1_b727_220a_95);
}
fn write_usize(&mut self, n: usize) {
self.write_u64(n as u64);
}
fn write_isize(&mut self, n: isize) {
self.write_u64(n as u64);
}
fn finish(&self) -> u64 {
self.0
}
}
/// A `HashMap` with [`FastHasher`].
pub type FastMap<K, V> = HashMap<K, V, std::hash::BuildHasherDefault<FastHasher>>;
pub mod fmode;
/// SIMD sub-opcodes (the 0xFD prefix space).
@@ -158,6 +196,91 @@ pub struct Context<'a> {
pub page_bases: Option<&'a HashMap<u64, u32>>,
/// `TranslatedModule::module_id` of the module being built.
pub module_id: u64,
/// Every address some block of `gir` branches back to (a successor at or
/// below the branching block's start). A region needs a back edge to its
/// entry, so a block not in here grows none and the walk is skipped.
/// `None`: no filter.
pub loop_heads: Option<&'a std::collections::HashSet<u64>>,
/// The module's `dispatch(ctx, pc)` (a local function, relocated: `enter`
/// without its context stores), when it has one: an indirect dispatch
/// tail-calls it instead of carrying the whole table selection inline.
/// See [`set_inline_dispatch`].
pub enter: Option<u32>,
}
/// `GEX_INLINE_DISPATCH=1` natively, `set_inline_dispatch(true)` in the
/// browser: every dispatch carries the directory lookup and the selection over
/// the eight tables inline, as before, instead of tail-calling the module's
/// `enter`. Inline, dispatch was 40% of a module's bytes (`gexc build` of
/// Debian's xz: 687,595 of 1,228 KiB were dispatch and boundary code), on a
/// path that a direct tail call reaches for the price of one call.
static INLINE_DISPATCH: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
pub fn set_inline_dispatch(on: bool) {
INLINE_DISPATCH.store(on, std::sync::atomic::Ordering::Relaxed);
}
fn inline_dispatch() -> bool {
static ENV: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
*ENV.get_or_init(|| std::env::var_os("GEX_INLINE_DISPATCH").is_some())
|| INLINE_DISPATCH.load(std::sync::atomic::Ordering::Relaxed)
}
/// Fold `local.set x; local.get x` into `local.tee x` and strip the tees
/// of temporaries nothing reads (the function JIT's peephole, `fmode`) in
/// tier-1 blocks too: `set_t1_fold` / GEX_T1_FOLD.
static T1_FOLD: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
pub fn set_t1_fold(on: bool) {
T1_FOLD.store(on, std::sync::atomic::Ordering::Relaxed);
}
fn t1_fold() -> bool {
static ENV: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
*ENV.get_or_init(|| std::env::var_os("GEX_T1_FOLD").is_some()) || T1_FOLD.load(std::sync::atomic::Ordering::Relaxed)
}
/// The emitter switches that change tier-1's bytes without showing in the
/// translation options, for a cache key (GEX_INLINE_DISPATCH, GEX_T1_FOLD):
/// natively they are read from the environment, which the module cache's key
/// did not see, so a run with one set served its modules to runs without.
pub fn knobs() -> u8 {
inline_dispatch() as u8 | (t1_fold() as u8) << 1
}
/// The end of a tier-1 block's function with [`t1_fold`]: its dead tees
/// of temporaries removed.
fn strip_tees(e: &mut Emitter) {
if !t1_fold() {
return;
}
let n = e.temps.iter().copied().max().map_or(0, |m| m as usize + 1);
let mut temp = vec![false; n];
for &t in &e.temps {
temp[t as usize] = true;
}
e.f.strip_dead_tees(&|l| temp.get(l as usize).copied().unwrap_or(false), true);
}
/// The back-edge targets of `gir`, for [`Context::loop_heads`].
pub fn loop_heads<'b>(blocks: impl Iterator<Item = &'b Block>) -> std::collections::HashSet<u64> {
let mut out = std::collections::HashSet::new();
for b in blocks {
for s in b.successors() {
if s <= b.start {
out.insert(s);
}
}
}
out
}
/// The region grown at `block`, if any (and regions are on).
fn region_at(block: &Block, cx: &Context<'_>) -> Option<crate::region::Region> {
if region_limit() <= 1 || cx.loop_heads.is_some_and(|h| !h.contains(&block.start)) {
return None;
}
crate::region::grow(block.start, cx.gir, region_limit())
}
/// `GEX_NO_PAGE_CHECK=1`: blocks do not check their page's directory entry
@@ -381,6 +504,10 @@ fn trap_pc() -> u64 {
/// a patch cannot change a body's length.
pub fn compile_with_relocs(block: &Block, cx: &Context<'_>) -> (Vec<u8>, Vec<u32>) {
let mut e = Emitter::new(cx, block.start);
if t1_fold() {
e.f.fold_tees();
}
let region = region_at(block, cx);
// First, before the page check: a block entered for a neighbour in its
// dispatch slot (x86-64's 4-byte slots) must hand over to that address.
// The page check's stale path (and the guard's) dispatch to this block's
@@ -393,12 +520,10 @@ pub fn compile_with_relocs(block: &Block, cx: &Context<'_>) -> (Vec<u8>, Vec<u32
if let Some(bases) = cx.page_bases.filter(|_| page_check()) {
let mut pages = checked_pages(bases, block.start, block.end);
// A region carries its members: their pages too.
if region_limit() > 1 {
if let Some(region) = crate::region::grow(block.start, cx.gir, region_limit()) {
for a in region.iter() {
if let Some(b) = cx.gir.get(a) {
pages.extend(checked_pages(bases, b.start, b.end));
}
if let Some(region) = &region {
for a in region.iter() {
if let Some(b) = cx.gir.get(a) {
pages.extend(checked_pages(bases, b.start, b.end));
}
}
}
@@ -430,12 +555,17 @@ pub fn compile_with_relocs(block: &Block, cx: &Context<'_>) -> (Vec<u8>, Vec<u32
if let Some(g) = &block.guard {
e.emit_guard(block.start, g);
}
run_one(&mut e, block);
run_one(&mut e, block, region);
strip_tees(&mut e);
e.f.finish_with_relocs()
}
pub fn compile(block: &Block, cx: &Context<'_>) -> Vec<u8> {
let mut e = Emitter::new(cx, block.start);
if t1_fold() {
e.f.fold_tees();
}
let region = region_at(block, cx);
// First, before the page check: a block entered for a neighbour in its
// dispatch slot (x86-64's 4-byte slots) must hand over to that address.
// The page check's stale path (and the guard's) dispatch to this block's
@@ -448,12 +578,10 @@ pub fn compile(block: &Block, cx: &Context<'_>) -> Vec<u8> {
if let Some(bases) = cx.page_bases.filter(|_| page_check()) {
let mut pages = checked_pages(bases, block.start, block.end);
// A region carries its members: their pages too.
if region_limit() > 1 {
if let Some(region) = crate::region::grow(block.start, cx.gir, region_limit()) {
for a in region.iter() {
if let Some(b) = cx.gir.get(a) {
pages.extend(checked_pages(bases, b.start, b.end));
}
if let Some(region) = &region {
for a in region.iter() {
if let Some(b) = cx.gir.get(a) {
pages.extend(checked_pages(bases, b.start, b.end));
}
}
}
@@ -485,15 +613,16 @@ pub fn compile(block: &Block, cx: &Context<'_>) -> Vec<u8> {
if let Some(g) = &block.guard {
e.emit_guard(block.start, g);
}
run_one(&mut e, block);
run_one(&mut e, block, region);
strip_tees(&mut e);
e.f.finish()
}
/// The body of both entry points: a region if one can be grown here,
/// otherwise the block on its own.
fn run_one(e: &mut Emitter<'_>, block: &Block) {
match crate::region::grow(block.start, e.cx.gir, region_limit()) {
Some(region) if region_limit() > 1 => {
fn run_one(e: &mut Emitter<'_>, block: &Block, region: Option<crate::region::Region>) {
match region {
Some(region) => {
if std::env::var_os("GEX_REGION_DEBUG").is_some() {
let members: Vec<String> = region.iter().map(|a| format!("{a:#x}")).collect();
eprintln!("[region] {}", members.join(" "));
@@ -561,7 +690,7 @@ struct Emitter<'a> {
free_i64: Vec<u32>,
free_v128: Vec<u32>,
/// Guest register cache.
regs: HashMap<GuestReg, Cached>,
regs: FastMap<GuestReg, Cached>,
/// Set while emitting a block fused into a `wasm` loop: how many nested
/// `if`s sit between here and the loop label, so a back-edge knows what
/// depth to branch to. `None` when the block is not fused.
@@ -573,7 +702,7 @@ struct Emitter<'a> {
region: Option<RegionState>,
/// Which registers the region writes, and so has to store on the way out.
/// The ones it only reads are already correct in the context.
region_writes: std::collections::HashSet<GuestReg>,
region_writes: std::collections::HashSet<GuestReg, std::hash::BuildHasherDefault<FastHasher>>,
/// Function mode (`fmode`, the function JIT): every register the
/// function uses has a local for its whole body, bound up front, and a
/// clobbering host call reloads them all rather than forgetting them.
@@ -639,7 +768,7 @@ impl<'a> Emitter<'a> {
free_i32: Vec::new(),
free_i64: Vec::new(),
free_v128: Vec::new(),
regs: HashMap::new(),
regs: FastMap::default(),
loop_depth: None,
reenter_local: None,
region: None,
@@ -839,7 +968,7 @@ impl<'a> Emitter<'a> {
self.prof_entry(block);
for (i, stmt) in block.stmts.iter().enumerate() {
self.stmt(stmt, block);
for &v in &dying[i] {
for &v in dying[i].iter() {
self.release(Value(v));
}
}
@@ -933,7 +1062,7 @@ impl<'a> Emitter<'a> {
for (i, stmt) in block.stmts.iter().enumerate() {
self.stmt(stmt, block);
// Recycle locals whose values are dead after this statement.
for &v in &dying[i] {
for &v in dying[i].iter() {
self.release(Value(v));
}
}
@@ -4905,6 +5034,25 @@ impl<'a> Emitter<'a> {
}
fn emit_dispatch_tail_call_inner(&mut self) {
// Outlined: the module's `dispatch(ctx, target)` stores `LAST_TARGET`
// and does the same lookup and selection, so the context is left
// exactly as the inline form leaves it, `ctx::PC` as the caller left
// it. (Re-storing the PC as `enter` does is not the
// same: after an indirect branch's safepoint the PC is the branch
// itself, to be re-executed, and a provider's IFUNC address then
// reached the miss path, lost its bridge in a loop and panicked
// tier-1 in the native-debian page.)
if let Some(enter) = self.cx.enter.filter(|_| !inline_dispatch() && gex_abi::DISPATCH_TABLES > 1) {
if gex_abi::X86 {
self.ctx_base();
self.f.i64_const(self.block_start as i64);
self.f.store(0x37, 3, ctx::LAST_SOURCE as u32);
}
self.f.local_get(self.ctx_local);
self.f.local_get(self.scratch_i64);
self.f.return_call_local(enter);
return;
}
if gex_abi::DISPATCH_TABLES <= 1 {
self.f.local_get(self.ctx_local);
self.emit_dispatch_index();
@@ -4972,16 +5120,45 @@ impl<'a> Emitter<'a> {
/// order the releases always ran in). Was a scan of every value after every
/// statement: quadratic, and most of a Unity start-up's time in Mono's large
/// JIT-generated blocks.
fn dying_after(block: &Block) -> Vec<Vec<u32>> {
let mut out = vec![Vec::new(); block.stmts.len()];
for (v, lu) in compute_last_use(block).into_iter().enumerate() {
fn dying_after(block: &Block) -> Dying {
let last = compute_last_use(block);
// Counted, then filled in place: one allocation for every statement's
// list, where a `Vec` per statement was the emitter's largest single
// source of allocator traffic.
let mut start = vec![0u32; block.stmts.len() + 1];
for &i in last.iter().flatten() {
if let Some(c) = start.get_mut(i + 1) {
*c += 1;
}
}
for i in 1..start.len() {
start[i] += start[i - 1];
}
let mut fill = start.clone();
let mut vals = vec![0u32; *start.last().unwrap_or(&0) as usize];
for (v, lu) in last.into_iter().enumerate() {
if let Some(i) = lu {
if let Some(d) = out.get_mut(i) {
d.push(v as u32);
if let Some(at) = fill.get_mut(i) {
vals[*at as usize] = v as u32;
*at += 1;
}
}
}
out
Dying { start, vals }
}
/// The values whose last use is each statement (see [`dying_after`]), in
/// value order per statement.
struct Dying {
start: Vec<u32>,
vals: Vec<u32>,
}
impl std::ops::Index<usize> for Dying {
type Output = [u32];
fn index(&self, i: usize) -> &[u32] {
&self.vals[self.start[i] as usize..self.start[i + 1] as usize]
}
}
/// Index of the last statement that reads each value, or `None` when the
+1 -1
View File
@@ -492,7 +492,7 @@ impl<'a> FnBody<'a> {
pub fn finish(mut self) -> Vec<u8> {
if std::env::var("GEX_FJIT_TEES").as_deref() != Ok("0") {
let temps: std::collections::HashSet<u32> = self.e.temps.iter().copied().collect();
self.e.f.strip_dead_tees(&|l| temps.contains(&l));
self.e.f.strip_dead_tees(&|l| temps.contains(&l), false);
}
self.e.f.finish()
}
+88 -23
View File
@@ -266,15 +266,33 @@ impl FuncBody {
/// it (or there is none). Sound for locals that never carry a value
/// backwards (around a loop) -- a block-local temporary is set before
/// any read in its block. The value the tee left on the stack stays.
pub fn strip_dead_tees(&mut self, temp: &dyn Fn(u32) -> bool) {
///
/// `loop_safe`: also keep a tee when a read of its local comes before
/// it in code order, which a back-edge could reach with the tee's value
/// (tier 1's fused loops keep their iteration counter and member index
/// in pooled locals: stripping the counter's tee made a spinning loop
/// never reach its signal poll).
pub fn strip_dead_tees(&mut self, temp: &dyn Fn(u32) -> bool, loop_safe: bool) {
if !self.fold {
return;
}
let log = std::mem::take(&mut self.local_log);
// First read of each local, in code order (`loop_safe`).
let mut first_get: HashMap<u32, u32> = HashMap::new();
if loop_safe {
for &(at, idx, _) in &log {
if self.code[at as usize] == op::LOCAL_GET {
first_get.entry(idx).or_insert(at);
}
}
}
let mut next_of: HashMap<u32, usize> = HashMap::new();
let mut dead = vec![false; log.len()];
for (k, &(at, idx, _)) in log.iter().enumerate().rev() {
if self.code[at as usize] == op::LOCAL_TEE && temp(idx) {
if self.code[at as usize] == op::LOCAL_TEE
&& temp(idx)
&& first_get.get(&idx).is_none_or(|&g| g > at)
{
dead[k] = match next_of.get(&idx) {
None => true,
Some(&n) => self.code[log[n].0 as usize] == op::LOCAL_SET,
@@ -633,36 +651,45 @@ impl FuncBody {
/// The offsets [`Self::relocs`] holds are into `code`; the encoding puts a
/// size prefix and the local declarations in front of it, so they all move
/// by the same amount.
pub fn finish_with_relocs(self) -> (Vec<u8>, Vec<u32>) {
let relocs = self.relocs.clone();
pub fn finish_with_relocs(mut self) -> (Vec<u8>, Vec<u32>) {
let mut relocs = std::mem::take(&mut self.relocs);
let code_len = self.code.len();
let out = self.finish();
let shift = (out.len() - code_len - 1) as u32; // -1 for the trailing `end`
(out, relocs.into_iter().map(|r| r + shift).collect())
for r in &mut relocs {
*r += shift;
}
(out, relocs)
}
/// Serialise the body into the code-section entry form.
pub fn finish(self) -> Vec<u8> {
let mut body = Vec::with_capacity(self.code.len() + 16);
// Run-length encode the local declarations.
let mut runs: Vec<(u32, ValType)> = Vec::new();
for ty in &self.locals {
match runs.last_mut() {
Some((n, t)) if *t == *ty => *n += 1,
_ => runs.push((1, *ty)),
let mut runs = 0u32;
let mut decl = Vec::with_capacity(32);
let mut i = 0;
while i < self.locals.len() {
let ty = self.locals[i];
let mut j = i + 1;
while j < self.locals.len() && self.locals[j] == ty {
j += 1;
}
write_u32(&mut decl, (j - i) as u32);
decl.push(ty.byte());
runs += 1;
i = j;
}
write_u32(&mut body, runs.len() as u32);
for (n, ty) in runs {
write_u32(&mut body, n);
body.push(ty.byte());
}
body.extend_from_slice(&self.code);
body.push(op::END);
let mut out = Vec::with_capacity(body.len() + 5);
write_u32(&mut out, body.len() as u32);
out.extend_from_slice(&body);
let mut head = Vec::with_capacity(5);
write_u32(&mut head, runs);
// Once, at its final size: the code was copied twice before (into a
// body, then behind its length), each a fresh allocation.
let body_len = head.len() + decl.len() + self.code.len() + 1;
let mut out = Vec::with_capacity(body_len + 5);
write_u32(&mut out, body_len as u32);
out.extend_from_slice(&head);
out.extend_from_slice(&decl);
out.extend_from_slice(&self.code);
out.push(op::END);
out
}
}
@@ -690,6 +717,10 @@ pub enum ImportKind {
Table {
min: u32,
},
/// An immutable global of the given type.
Global {
ty: ValType,
},
}
/// An import.
@@ -729,6 +760,9 @@ pub struct ElementSegment {
/// The offset is written as a five-byte LEB, so a host can rewrite it in
/// place (`crate::dense::set_base`).
pub padded: bool,
/// The offset is this (imported) global's value instead of `offset`:
/// given at instantiation, so the module's bytes do not depend on it.
pub offset_global: Option<u32>,
}
/// A memory initialiser.
@@ -843,6 +877,20 @@ impl Module {
});
}
/// Import an immutable global, returning its index. Imported globals
/// come before defined ones, so this must not be mixed with
/// [`Module::add_global`].
pub fn import_global(&mut self, module: &str, name: &str, ty: ValType) -> u32 {
assert!(self.globals.is_empty(), "a global import after a defined global");
let n = self.imports.iter().filter(|i| matches!(i.kind, ImportKind::Global { .. })).count() as u32;
self.imports.push(Import {
module: module.to_string(),
name: name.to_string(),
kind: ImportKind::Global { ty },
});
n
}
/// Number of imported *functions*, which is what local function indices
/// are offset by. Memory and table imports do not shift them.
pub fn n_imports(&self) -> u32 {
@@ -1237,6 +1285,11 @@ impl Module {
s.push(0x00);
write_u32(&mut s, *min);
}
ImportKind::Global { ty } => {
s.push(0x03);
s.push(ty.byte());
s.push(0x00); // immutable
}
}
}
section(&mut out, 2, &s);
@@ -1335,6 +1388,13 @@ impl Module {
s.push(op::I32_CONST);
write_i32(&mut s, e.offset as i32);
s.push(op::END);
} else if let Some(g) = e.offset_global {
s.push(0x02);
write_u32(&mut s, e.table);
s.push(0x23); // global.get
write_u32(&mut s, g);
s.push(op::END);
s.push(0x00); // elemkind: funcref
} else {
s.push(0x02);
write_u32(&mut s, e.table);
@@ -1424,7 +1484,12 @@ impl Module {
// Where this module names its own functions. A custom section, so it
// travels with the bytes -- the shared store keeps wasm and nothing
// else -- and an engine ignores it.
if !code_relocs.is_empty() {
//
// Not in a module with a dense segment (placed by an imported global,
// `crate::dense`): `merge` refuses those, so nothing would read it,
// and it was 3.5% of what node's start-up translates (340 B a unit).
let unmergeable = self.elements.iter().any(|e| e.offset_global.is_some());
if !code_relocs.is_empty() && !unmergeable {
let mut sec = Vec::with_capacity(code_relocs.len() * 4 + 16);
write_name(&mut sec, RELOC_SECTION);
write_u32(&mut sec, code_relocs.len() as u32);
+4
View File
@@ -130,6 +130,8 @@ pub fn build_module(
profile: None,
page_bases: None,
module_id: 0,
loop_heads: None,
enter: None,
};
let mcx = ModuleCx {
cx: &cx,
@@ -417,6 +419,8 @@ pub fn build_runtime(
profile: None,
page_bases: None,
module_id: 0,
loop_heads: None,
enter: None,
};
let mcx = ModuleCx {
cx: &cx,
+4 -1
View File
@@ -425,7 +425,10 @@ mod tests {
#[test]
fn the_rebridge_guard_never_gives_up_an_unliftable_address() {
// SP's page crash: provider address 0x71dc1a1 (libc +0x9c1a1).
for pc in [0x71dc1a1u64, 0xa12c1a1, 0xa12cac1] {
// Every x86 address is instruction-aligned, so only aarch64 has
// addresses the guard must keep.
let unaligned: &[u64] = if gex_abi::INST_ALIGN > 1 { &[0x71dc1a1, 0xa12c1a1, 0xa12cac1] } else { &[] };
for &pc in unaligned {
for n in [1, REBRIDGE_LIMIT, REBRIDGE_LIMIT + 1, 1000, u32::MAX] {
assert!(!rebridge_gives_up(pc, n), "{pc:#x} after {n}");
}
+7 -3
View File
@@ -103,9 +103,13 @@ impl Pipe {
loop {
if !s.buf.is_empty() {
let n = buf.len().min(s.buf.len());
for slot in buf.iter_mut().take(n) {
*slot = s.buf.pop_front().unwrap();
}
// Two slice copies, not a pop per byte: a pipeline moves
// megabytes through here.
let (front, back) = s.buf.as_slices();
let k = n.min(front.len());
buf[..k].copy_from_slice(&front[..k]);
buf[k..n].copy_from_slice(&back[..n - k]);
s.buf.drain(..n);
self.ready.notify_all();
return Ok(n as u64);
}
+2
View File
@@ -35,6 +35,8 @@ fn main() {
profile: None,
page_bases: None,
module_id: 0,
loop_heads: None,
enter: None,
};
let wasm = gex_emit::tier1::compile(&block, &cx);
println!("=== WASM ({} bytes) ===", wasm.len());
+60 -7
View File
@@ -1029,7 +1029,7 @@ fn emit_modules(
text_base,
0,
Linkage::Shared,
pages.clone(),
std::mem::take(pages),
shared_memory,
);
builder.set_demand_fill(demand_fill);
@@ -1232,6 +1232,28 @@ fn translate_seeded(
(module, stats)
}
/// Edges discovery does not follow (an experiment knob, `set_discover_skip`):
/// bit 0 call returns, bit 1 call targets folded through memory, bit 2 the
/// block's own successors; bit 8 the discovery log; bits 16-23 a depth limit.
static DISCOVER_SKIP: std::sync::atomic::AtomicU32 = std::sync::atomic::AtomicU32::new(0);
/// With `DISCOVER_SKIP` bit 8: every block discovery lifts, as
/// `(address, why, depth)`, for [`take_discover_log`]. `why`: 0 a seed, 1 a
/// conditional branch's taken side, 2 its fall-through, 3 a sole successor
/// (goto, direct call, syscall return), 4 a call's return address, 5 a call
/// target folded through memory. Depth is the breadth-first level.
static DISCOVER_LOG: std::sync::Mutex<Vec<(u64, u8, u16)>> = std::sync::Mutex::new(Vec::new());
/// The discovery log so far (`DISCOVER_SKIP` bit 8), emptied.
pub fn take_discover_log() -> Vec<(u64, u8, u16)> {
std::mem::take(&mut *DISCOVER_LOG.lock().unwrap_or_else(|e| e.into_inner()))
}
/// See `DISCOVER_SKIP`.
pub fn set_discover_skip(bits: u32) {
DISCOVER_SKIP.store(bits, std::sync::atomic::Ordering::Relaxed);
}
/// Lift every block reachable from the seeds.
///
/// Separate from encoding because one object's blocks may not fit in one
@@ -1262,8 +1284,16 @@ fn discover(
frontier.sort_unstable();
frontier.dedup();
seen.extend(frontier.iter().copied());
let logging = DISCOVER_SKIP.load(std::sync::atomic::Ordering::Relaxed) & 256 != 0;
// Why each frontier address was queued (`DISCOVER_LOG`).
let mut why: HashMap<u64, u8> = HashMap::new();
let mut depth: u16 = 0;
while !frontier.is_empty() && stats.blocks < max_blocks {
// Bits 16-23 of `DISCOVER_SKIP`: the deepest breadth-first level lifted
// past the seeds (0: no limit). An experiment: 63% of the blocks node's
// start-up lifts never run, and the deeper the level the fewer run.
let max_depth = (DISCOVER_SKIP.load(std::sync::atomic::Ordering::Relaxed) >> 16) & 0xff;
while !frontier.is_empty() && stats.blocks < max_blocks && (max_depth == 0 || u32::from(depth) <= max_depth) {
if frontier.len() + stats.blocks > max_blocks {
frontier.truncate(max_blocks - stats.blocks);
}
@@ -1277,6 +1307,13 @@ fn discover(
});
let mut next: Vec<u64> = Vec::new();
if logging {
let mut log = DISCOVER_LOG.lock().unwrap_or_else(|e| e.into_inner());
for (block, _) in &lifted {
log.push((block.start, why.get(&block.start).copied().unwrap_or(0), depth));
}
}
depth = depth.saturating_add(1);
for (block, folded) in lifted {
stats.blocks += 1;
stats.instructions += ((block.end - block.start) / INST_ALIGN) as usize;
@@ -1292,12 +1329,25 @@ fn discover(
// store has not reached yet -- and code that bakes it in is wrong
// the moment it changes. Discovery can afford a wrong guess (an
// unneeded block); execution cannot.
let mut targets: Vec<u64> = block.successors().chain(block.call_return).collect();
if let Some(t) = folded {
stats.discovered_from_data += 1;
targets.push(t);
let mask = DISCOVER_SKIP.load(std::sync::atomic::Ordering::Relaxed);
let mut targets: Vec<(u64, u8)> = Vec::new();
if mask & 4 == 0 {
match block.term {
Term::Branch { taken, not_taken, .. } => {
targets.push((taken, 1));
targets.push((not_taken, 2));
}
_ => targets.extend(block.successors().map(|t| (t, 3))),
}
}
for succ in targets {
if mask & 1 == 0 {
targets.extend(block.call_return.map(|t| (t, 4)));
}
if let Some(t) = folded.filter(|_| mask & 2 == 0) {
stats.discovered_from_data += 1;
targets.push((t, 5));
}
for (succ, kind) in targets {
// Deliberately *not* skipping an address inside a block
// already lifted: an interior address is still a legal
// indirect-branch target, and dispatch needs a block starting
@@ -1311,6 +1361,9 @@ fn discover(
{
seen.insert(succ);
next.push(succ);
if logging {
why.insert(succ, kind);
}
}
}
collected.push(block);
+242
View File
@@ -0,0 +1,242 @@
# Stream T: translating code that guest programs generate at run time
Branch `merge/jitguest` (worktree `wt/jitguest`), off master 0bcce81c and
fast-forwarded to stream C's c47bbff1 (kernel fixes node needs in the page:
`F_GETFL`, fork-safe socketpairs, socket kinds).
Problem (coordinator, 2026-10-10): translating code a guest generates at run
time (V8, Mono's JIT) is the biggest cost in three places: node in the
native-debian page (`node -e 0` about 30 s), Muck's menu in the page, and
Hollow Knight on the Mono interpreter (about 3.5 MiB/s translated).
## 1. Measuring
### Tools
- `GEX_LIFT_MAX=N` (run-node) / `?liftmax=N` (page): the most blocks one
dispatch miss lifts. The default is 2048: discovery follows successors, call
returns and call targets folded through memory, breadth-first. With 1 only
the block that missed is lifted, so the blocks lifted are the blocks run.
- `xlate[...]` on each process's exit line (run-node `GEX_TIME_JIT=1`, and the
page's console line): guest instructions lifted, wasm KiB emitted,
translations (units), SMC page invalidations, pages retired by
munmap/mprotect, retire calls, and blocks lifted from anonymous memory (a
guest JIT's code).
- `scratch/T/t1.sh TAG MODE...` (edger, under bench.sh): node start-up under
`run-node.mjs` (the browser runtime on Node 26's V8), modes `t1`, `fjit`,
`t1prof`, `fjitprof` (`--cpu-prof` of every Worker), summarised by
`scratch/T/prof.mjs`.
- Rootfs: `scratch/T/ndnode.tar` = native-debian.tar plus Debian's nodejs and
its libraries (stream C's `scratch/CC/demo/noderoot`), so no apt run is
needed.
### Counts (gooner, run-node, `exec node -e 0`, one fresh runtime; counts only)
| | blocks lifted | guest insns | wasm emitted | units (modules) |
|---|---|---|---|---|
| default (2048 per miss) | 246,955 | 1,102,158 | 158.5 MiB | 7,414 |
| `GEX_LIFT_MAX=1` | 90,375 | 453,161 | 160.6 MiB | 90,375 |
- libnode.so.115 is 201,491 of the 246,955 blocks; node's own JIT code
(anonymous memory) is 1,721. Code generated at run time is under 1% of the
blocks node translates at start-up.
- About 90k distinct blocks run; the default lifts 2.7 times that (63% of the
lifted blocks never run).
- About 640 bytes of wasm per lifted block (about 144 per guest instruction).
- SMC: 9 page invalidations; 13 pages retired by munmap/mprotect. Node's
V8 maps its code space RWX and does not flip protections.
### Baseline under run-node on edger (b-base, 2026-10-10, before any change)
`node --version`, then `node -e 0` twice, in one fresh runtime (the first
`node -e 0` is cold: nothing translated yet; the second is warm: it is served
the first one's modules from the kernel's store).
| | `node --version` | `node -e 0` cold | `node -e 0` warm |
|---|---|---|---|
| tier 1 | 3.21 s | 32.29 s | 20.76 s |
| `GEX_FJIT=1` | 2.72 s | 32.33 s | 20.33 s |
Cold: 10.1 s in the shim's translator (224k blocks), 2.1 s compiling.
Warm: 0.13 s translating, but 8,855 modules served, 2.1 s compiling them
again and each of the process's six thread Workers instantiating all of them.
Profile of the cold process's main Worker (39.8 s sampled, `--cpu-prof`):
| where | ms | share |
|---|---|---|
| guest code (shows as `js-to-wasm`: tail calls hide the frames) | 11,690 | 29% |
| `gex_dispatch_miss` (inclusive) | 18,111 | 46% |
| - `translate_runtime` | 8,366 | 21% |
| -- emission (`ModuleBuilder::finish`) | 6,038 | 15% |
| --- `region::grow` (twice per block) | 2,029 | 5% |
| -- discovery and lifting | 2,059 | 5% |
| - installing (`jit_install`: compile, instantiate, hash) | 4,298 | 11% |
| - kernel module store (`memory.grow` of the shared kernel memory) | 2,099 | 5% |
| - page table clones and drops | about 1,000 | 3% |
| waiting on a futex | 2,269 | 6% |
Warm process's main Worker (26.4 s sampled): guest code 11.0 s (42%),
installing served modules 4.0 s (15%), futex waits 2.7 s (10%), syscalls
0.9 s.
So for node the code V8 generates is not the cost; translating and installing
libnode (V8 itself, its builtins included) is, and then the speed of the
translated V8.
### Changes under run-node on edger (b4, 2026-10-10, branch 64b129ce)
Same commands as the baseline. One run per mode, in order; `new` and `new2`
are the same mode at the start and end of the run.
| mode | `node --version` | `node -e 0` cold | `node -e 0` warm | cold wasm |
|---|---|---|---|---|
| baseline (b-base, before any change) | 3.21 s | 32.29 s | 20.76 s | 158.5 MiB |
| new (all changes, defaults) | 2.23 s | 19.27 s | 13.61 s | 68.5 MiB |
| new2 (same, last in the run) | 2.32 s | 18.76 s | 12.90 s | 68.5 MiB |
| `GEX_INLINE_DISPATCH=1` (old dispatch form) | 2.61 s | 24.58 s | 19.62 s | 140.1 MiB |
| `GEX_LIFT_MAX=64` | 2.14 s | 19.17 s | 14.68 s | 53.6 MiB |
| `GEX_DISCOVER_SKIP=1` (no call-return discovery) | 3.45 s | 34.41 s | 28.64 s | 68.3 MiB |
| V8 `--liftoff-only` | 3.35 s | 24.09 s | 14.54 s | 68.5 MiB |
| V8 `--no-wasm-dynamic-tiering` (TurboFan up front) | 2.19 s | 16.81 s | 11.87 s | 68.5 MiB |
- Cold `node -e 0`: 40% faster than the baseline (19.27 s against 32.29 s);
warm 34% faster (13.61 s against 20.76 s).
- Outlined dispatch alone: cold 22% faster than inline (19.27 s against
24.58 s), warm 31% faster (13.61 s against 19.62 s).
- Not lifting call returns ahead of time costs far more than it saves: 4.5
times the modules (39,213 against 8,680 installed) and cold 79% slower
(34.41 s against 19.27 s).
- A lift cap of 64 changes cold little (19.17 s) and makes warm 8% slower
(14.68 s against 13.61 s): more, smaller modules to install.
- V8's tiering matters: compiling with TurboFan from the start is 13% faster
cold (16.81 s against 19.27 s) and 13% faster warm (11.87 s against
13.61 s); Liftoff only is 25% slower cold. The page cannot set V8 flags, so
this only says the translated code's optimised speed is what counts.
### Discovery overshoot (gooner, run-node, `exec node -e 0`, counts only)
`GEX_DISCOVER_SKIP=256 GEX_DL_FILE=PATH` logs why and at which breadth-first
depth each block was lifted. The blocks that run are the ones a
`GEX_LIFT_MAX=1` run lifts (90,277).
| reason | lifted | of them run | run share |
|---|---|---|---|
| seed (the block that missed) | 7,414 | 7,373 | 99.4% |
| conditional branch, taken side | 69,363 | 18,388 | 26.5% |
| conditional branch, fall-through | 86,169 | 27,519 | 31.9% |
| sole successor (goto, direct call, syscall return) | 22,455 | 10,501 | 46.8% |
| call return address | 61,554 | 25,489 | 41.4% |
| all | 246,955 | 89,270 | 36.1% |
The run share falls with depth: 78.7% at depth 1, 50.2% at 4, 34.2% at 8,
22.0% at 19; depth 20 and beyond is 88,927 blocks at 25.9%. No single edge
kind is the waste. Cutting discovery short trades blocks for units:
| discovery | blocks lifted | units |
|---|---|---|
| default | 246,955 | 7,414 |
| depth at most 16 | 194,737 | 9,482 |
| depth at most 8 | 157,886 | 13,027 |
| depth at most 4 | 131,430 | 20,312 |
| `GEX_LIFT_MAX=64` | 161,028 | 8,946 (11,678 installed) |
`GEX_LIFT_MAX=64` was no faster cold on edger (19.17 s against 19.27 s) and
8% slower warm, and skipping call returns was 79% slower cold: a unit costs
about as much as the blocks it saves. Depth limits 8 and 16 are queued (b5).
### Per-unit fixed cost (7,414 units of node's start-up)
| part | bytes a unit | share of the wasm |
|---|---|---|
| imports (21, all of them in every unit) | 351 | 3.6% |
| `gex.reloc` (removed for dense units: 11b1b601) | 340 | 3.5% |
| `enter` | 285 | 2.9% |
| `gex.dense` | 153 | 1.6% |
| types, elem, func, export, `gex.meta` | 227 | 2.3% |
| miss stub | 53 | 0.5% |
Code is 88.9%. With `gex.reloc` gone (77,397 -> 74,938 KiB for `exec node -e 0`),
about 7% of the bytes are still fixed per unit. Since f469c15a, `enter` is
split: a store-free `dispatch` that blocks tail-call, plus a small `enter`
(75,050 KiB). Importing the seed's `enter` instead was tried (34cf5db9) and
reverted (4ca3b163): the seed's `enter` stores `ctx::PC`, which blocks must not
do (see f469c15a). Dropping unused imports would save most of the 3.6% they
take. Neither is likely to move start-up by more than a few percent, because
compiling is about 1.5 s of a 19 s cold start.
### Node in the native-debian page (p1, edger, 2026-10-10)
`apt-get install nodejs`, then `node --version` and `node -e 0` three times,
in the page (`web/test/site.mjs native-debian-run`).
| leg | `node --version` | `node -e 0` #1 (cold) | #2 | #3 |
|---|---|---|---|---|
| base (c47bbff1) | 3.48 s | 38.87 s | 27.79 s | 26.14 s |
| base again | 3.85 s | 38.73 s | 27.55 s | 26.00 s |
| branch at 64b129ce | 2.63 s | crashed | - | - |
| branch at 64b129ce, `unitcache=1` | 2.79 s | 23.15 s | crashed | - |
- With `unitcache=1`, the branch started node cold 40% faster than base
(23.15 s against 38.80 s, the mean of the two base legs).
- The plain branch leg crashed before it finished a cold start, so p1 has no
number for the unit cache on its own, and no warm number at all.
- The crash was the outlined `enter`, fixed in f469c15a. On gooner the page
run reproduced it in 3 of 6 runs before the fix and 0 of 4 after (inline
dispatch: 0 of 3).
- The branch has not been timed in the page since the fix.
## 2. Final state
Stream T ends here (coordinator, 2026-10-10).
**On by default:**
- Dispatch through the module's `dispatch` instead of inline (43fa9fc5,
f469c15a). Under run-node on edger, node's cold start went from 24.58 s
inline to 19.27 s, and warm from 19.62 s to 13.61 s (b4).
- Region grown at back-edge targets only; no page-table clones per miss; the
slab module store; word-wise content keys; fewer allocations in tier-1
emission. Together with the dispatch change: cold `node -e 0` from 32.29 s to
19.27 s, and warm from 20.76 s to 13.61 s (b4).
- No `gex.reloc` in dense units.
- The dense base comes from an imported global (`gex.dense_base`), so a unit's
bytes are the same in every process.
**Off by default (knobs kept, not proven faster in the browser):**
- Tier-1 tee folding: `GEX_T1_FOLD` / `?t1fold=1`. Loop-safe; 8% less wasm.
- The page-wide unit cache: `?unitcache=1` / `GEX_UNIT_CACHE`.
- Lift cap `?liftmax=` / `GEX_LIFT_MAX`.
- `GEX_DISCOVER_SKIP`: bit 8 is the discovery log (`GEX_DL_FILE`); bits 16-23
are a depth limit.
- Inline dispatch: `GEX_INLINE_DISPATCH` / `?inlinedispatch=1`.
- `?xlatereport=N`.
- The emitter switches are in the native module cache key, with a test
(`cache_key_tests`).
**Tests at the final head:**
- gex-emit 17, gex-xlate 22 + 3, `cache_key_tests` 1: all pass.
- Integration ladder (bbb7f237, folding on): no failure that the base tree
does not also have.
- The page reproduction on gooner: 4 runs, no crash.
**Open items:**
- **Discovery overshoot.** 63% of the blocks lifted for node never run.
Shallower discovery only trades blocks for units (§1).
- **Per-unit overhead.** About 7% of the bytes. Options: imports a unit uses
only; a store-free `dispatch` shared from the seed.
- **Warm translated-code speed.** Warm `node -e 0` spends 0.1 s translating
out of 13.6 s. The main Worker's profile: guest code about 42%, compiling
served modules about 1.4 s (what the unit cache removes), futex waits about
2 s.
- **Hollow Knight translation volume** on U's .NET provider path is
unmeasured. `hk1.sh` (`?xlatereport=500`) was cancelled before it ran.
- **A persistent translation cache** across page loads (compiled units
surviving a reload) is not started.
- **The unit cache's default** needs a page timing at f469c15a or later. One
thing to check before turning it on: for a cached Module, the dense base has
to come from this process's bytes, never from the cached Module's custom
section (catch-up and fork adopt instantiate from `mod`).
+760
View File
@@ -0,0 +1,760 @@
# The single-thread build (no SharedArrayBuffer)
Stream SP. Branch `merge/singleproc`, worktree `wt/singleproc`.
The goal is a page that runs the native Debian demo without
SharedArrayBuffer. Such a page needs no COOP/COEP headers, so any static
host can serve it. It has to keep:
- apt over Wisp;
- fork/exec, pipes and signals;
- multi-threaded guests;
- the terminal;
- the Wayland compositor.
The default build (a Worker per guest thread, SAB everywhere) is
unchanged, and runs as before wherever the page is cross-origin isolated.
## 1. Choice: one JS thread, a JSPI stack per guest thread
Every guest thread and process is a **fiber**: the guest runs on a JSPI
stack (`WebAssembly.promising`), and a call that would block suspends that
stack (`WebAssembly.Suspending`). All fibers share one JS thread, the page's
**system Worker** (`web/src/system-worker.js`). Memories are plain, not
shared:
- A process's threads are instances on one memory.
- Every process's kernel instance is on the one global kernel memory.
- They are all in one realm and are passed by reference, never cloned.
### Measurements
Microbenchmarks are in `scratch/SP/mb`. They ran in headless Chrome 153 on
edger under bench.sh (cores 2-8 plus siblings), on a page with no COOP/COEP
(`crossOriginIsolated=false`, no SAB). The raw JSON is in
`scratch/SP/mb/r1-{main,worker}{,-fx}.json`. All figures are ns per
operation.
| operation | main thread | dedicated Worker |
|---|---:|---:|
| plain import call | 17.5 | 16.4 |
| Suspending import, no suspend (inside promising) | 174.1 | 165.7 |
| JSPI suspend+resume round trip, depth 1 | 168.6 | 170.0 |
| … 20 wasm frames deep | 168.2 | 166.8 |
| … 200 wasm frames deep | 166.0 | 171.0 |
| promising export call (new stack), awaited | 1037.6 | 939.0 |
| JSPI context switch, 2 fibers round robin | 311.8 | 271.0 |
| … 8 fibers | 307.0 | 249.0 |
| … 64 fibers | 348.5 | 318.5 |
| hot loop, direct (per iteration) | 2.06 | 2.70 |
| hot loop on a JSPI stack | 2.10 | 2.71 |
| hot loop + back-edge budget poll, refill 65536 | 2.32 | 3.15 |
| hot loop + back-edge budget poll, refill 4096 (`now()` at 0) | 4.47 | 5.82 |
| hot loop + flag-word check per back edge | 3.82 | 4.93 |
| 2000 fibers suspended 20 frames deep: create+suspend, each | 17750 | 19050 |
| 2000 fibers: resume+finish, each | 1700 | 1000 |
| wasm trap caught in JS (stackTraceLimit=0) | 4311 | 4066 |
| MessageChannel round trip (macrotask) | 15355 | 11830 |
| `scheduler.yield()` | 22580 | 18435 |
| `setTimeout(0)`, nested (clamped) | 4070500 | 4026500 |
| `await` (microtask) | 165.1 | 144.9 |
| asyncify unwind+rewind, 1 frame | 302.5 | 525.5 |
| asyncify unwind+rewind, 20 frames | 257.5 | 632.5 |
| WasmFX resume+suspend, depth 1 (`--experimental-wasm-wasmfx`) | 110.8 | 88.0 |
| WasmFX, 21 frames deep | 88.1 | 70.0 |
| WasmFX switch, 8 fibers via a scheduler loop | 124.6 | 93.2 |
Maximum recursion depth (8 i64 locals per frame):
| stack | main thread | Worker |
|---|---:|---:|
| the thread's own stack | 7804 | 3977 |
| a JSPI stack | 7558 | 7550 |
A JSPI stack is about twice as deep as a Worker's own stack.
### What the numbers decide
- **JSPI.** A suspend/resume round trip costs about 170 ns whatever the
depth: 166-171 ns from 1 to 200 frames. A switch between fibers costs
250-350 ns, rising only slightly from 2 to 64 fibers. A blocking guest
syscall already costs microseconds in the SAB build, so this is noise next
to it.
- **Hot code is free on a JSPI stack.**
- Main thread: 2.10 ns/iteration on a JSPI stack against 2.06 direct.
- Worker: 2.71 against 2.70.
- Hence tier 1, fjit and the LP64 libraries run at full speed under JSPI;
the page comparison in §6 confirms this on real programs.
- **WasmFX is about 1.9× faster per switch** than JSPI on the main thread
(88-111 ns against 166-175 ns), and about 2.4× faster on a Worker (70-88 ns
against 167-171 ns). It still sits behind a flag in Chrome 153, though.
Node 26 (V8 14.6) crashes compiling it in Turbofan. JSPI ships, so JSPI is
the choice. WasmFX is the upgrade path once it ships: the fiber layer is
small (`Sched` in single.mjs), so moving to it later is cheap.
- **asyncify** costs about as much as JSPI on the main thread (258-303 ns)
and 2-4× more on a Worker (526-633 ns). Its real cost is the
instrumentation of everything it unwinds through, so it is kept only where
it already is: wasm32 fork (§4).
- **Traps** cost 4.1-4.3 µs. Translated guests already leave wasm by trap.
The single build adds nothing to that path.
- **System Worker, not the main thread.** Per-operation costs are close
(the Worker is a little faster at switches and macrotasks). The main
thread refuses two things the runtime does:
- synchronous `WebAssembly.Module` compiles above 8 MB;
- synchronous XHR with an arraybuffer response.
Running the guests on the page thread would also stall input and drawing.
So the page keeps xterm and the compositor's listeners, and the whole
system runs in one dedicated Worker.
- **Yielding to the event loop** costs 12-15 µs per MessageChannel turn and
18-23 µs per `scheduler.yield()`. `setTimeout(0)` nests to a 4 ms clamp
and must never be the yield. The design therefore wakes parked fibers with
microtasks while an 8 ms slice lasts, and uses one MessageChannel turn
after it.
## 2. How it works
### Binaries
- The single build uses the shared build's binaries:
- `gex_browser.shared.wasm`: per-instance stacks and TLS via
`gex_worker_init`;
- `seed.shared.wasm`;
- `gex_kernel.wasm`.
- `enableSingle()` (shims/browser/js/single.mjs) clears the `shared` flag of
every memory import and definition as the binaries are compiled. It does
this by patching `WebAssembly.compile`, `validate`, `instantiate`, the
streaming variants, and `Module`/`Memory`; `instanceof` keeps working.
- Atomic instructions are valid on a plain memory. The one that is not,
`memory.atomic.wait32`, is never reached: a fiber never waits inside wasm.
- No second build of anything is needed.
### Fibers
- `FiberWorker` runs `worker.mjs`'s `workerMain` in this realm, behind the
same `postMessage` / `addEventListener` / `terminate` surface, so
`ProcessHost` is unchanged.
- Messages are not cloned.
- All host↔fiber traffic goes through one FIFO queue drained by microtasks,
bounded to 8 ms per turn. A child's output and its parent's therefore
arrive in order, which separate ports never promised.
### Blocking
Where the SAB build waits, the single build suspends:
| SAB build | single build |
|---|---|
| `k_park` / `k_park_futex` (kernel imports, `Atomics.wait` on wake words) | `Suspending` imports that return `sched.park(ns)` / `sched.parkFutex(addr, ns)` promises. `k_wake` / `k_futex_wake` call the kernel and then resolve the matching parked fibers. A parked fiber re-checks `k_wake_seq` / `k_futex_gen` first, so a wake that raced the park is not lost. |
| the guest's run loop calls `enter(ctx, pc)` | `await promising(enter)`: each guest thread's run loop is a JSPI stack |
| translated syscall → `gex_syscall` (may park in the kernel) | the `syscall` import is a `Suspending` that runs `promising(gex_syscall)` |
| a wasm32 program's syscall, a provider's call-out | the same Suspending path. For fork, the image is captured in the `after` hook before the parent resumes (§4). |
| park in `#runLoop` (`gex_park(20)`) | `await promising(gex_park)` |
### Scheduling (`Sched` in single.mjs)
- A parked fiber is a promise held in a global set, or in a per-address
futex map.
- A wake resolves it:
- with a microtask while the current 8 ms slice lasts;
- otherwise it is held for one MessageChannel turn, so the event loop gets
keyboard, network and paint in between.
- Timeouts use `setTimeout` (`timeouts` in the stats).
### Network
- The fibers share the realm, so they also share the one `WispNetwork`
(`wispNet` in `extra`). No net-bridge is needed.
- Each stream event calls `onActivity`, which wakes the parked fibers.
`NodeNetwork` (the node harness) does the same.
- A process waiting on the network **parks**: a bounded `gex_park`, i.e. a
promise.
- The SAB build instead takes a MessageChannel turn per loop, because its
bytes arrive on the Worker's own event loop.
- Copied into the single build, that turn spun the one thread. An exec'd
`node` child installing about 30k translated modules got a turn only
between thirteen spinning node threads. `child_process.spawn` then took
minutes, and the page run hit its 900 s cap.
### Terminal
- `PageTty` sits on the kernel memory as in the SAB build, because the
kernel is in this realm.
- The page forwards keys and resizes to the system Worker. Every input,
resize and signal pokes the scheduler.
### Compositor
- `compositor-worker.js` runs in the system Worker's realm via `inRealm`,
because only that realm can read the guests' memories.
- The page keeps the canvas listeners. It transfers the canvas
(OffscreenCanvas) once, then talks through `comp` / `comp-out` messages.
- The scene arena is a plain ArrayBuffer.
- The compositor draws on `requestAnimationFrame` in the Worker, between
fiber slices.
## 3. Preemption and yield points
A fiber runs until it suspends. To keep one guest from starving the others
and the page, the single build yields at two kinds of point:
- **Safepoints.** Translated code already calls `gex.safepoint` every 1024
loop back edges (`SAFEPOINT_INTERVAL`, shims/browser/src/lib.rs). gexcc
code polls (`__gex_poll`) every 65536 iterations.
- In the single build the import is a `Suspending`, which costs about
174 ns when it does not suspend, against 17.5 ns for a plain import.
- Amortised over 1024 back edges, that is about 0.17 ns per back edge.
- It yields (`sched.maybeYield()`) only once the 8 ms slice has run out.
- **System calls.** wasm32 programs and providers yield after each syscall
once the slice has run out.
- Each such syscall goes through the Suspending path.
- That adds about 1.2 µs (a `promising` entry at about 1 µs plus a
suspend at about 170 ns) on top of what the syscall costs.
No new poll was added to generated code. The microbenchmark prices one:
- a per-back-edge budget counter with a refill of 65536 costs 0.26 ns per
iteration on an empty loop (2.32 against 2.06 ns);
- a flag-word load costs 1.8 ns (3.82 ns).
Both are measurable on tight loops. The existing 1024-edge safepoint is the
better place to yield.
### Wakes wait for the waker to park
Every system call bumps the kernel's wake counter (`k_wake`, after every
call, so that no wake is ever missed). The SAB build pays for that in
parallel: each parked Worker wakes on its own core, re-checks and parks
again. In the single build every guest system call is itself a promise
(the Suspending path above). Resolving the woken fibers at once queued
their continuations ahead of the waker's own return from that call. So a
writer and its reader traded places on every write, and every other parked
fiber (the shell in `wait4`) re-checked each time.
`Sched` therefore keeps the woken in a ready list, and runs them only when:
- the running fiber parks;
- it calls `sched_yield` (`Sched#yieldNow`); or
- the event loop turns.
A turn is posted on every wake. It runs only once the microtask queue is
empty, i.e. when every fiber is blocked on something other than the
scheduler, and at once for a wake from a timer, the network or a key.
| `yes \| head -c 20M \| wc -c`, node, single build (counts only, gooner) | parks | wakes |
|---|---|---|
| woken at once | 48 877 | 29 341 |
| woken when the waker parks (0a9b89f3) | 2 528 | 1 809 |
Filling the pipe before the reader ran exposed a kernel bug, in both
builds:
- `writev` to a full blocking pipe answered `EAGAIN`, and `readv` on an
empty one did the same. `blocking_op` knew only `write` and `read`.
- coreutils' `head` writes with `writev`, and died "Resource temporarily
unavailable".
- Both now park on their first non-empty vector and finish with a short
count.
## 4. Fork
- A **translated** (arm64/x86) process forks as in the SAB build. The child
is a new fiber with a copied memory.
- A **wasm32** program forks with asyncify, as on the SAB build. The stack is
unwound to the syscall wrapper, the image is captured, and both parent and
child rewind. Under the single build, the image capture runs in the
Suspending wrapper's `after` hook (a `.then`), before the outer fiber
resumes, so the parent cannot run ahead of its own snapshot.
- No musl patches were added.
## 5. SAB and Worker uses, and their single-build paths
| where | what (SAB build) | single build |
|---|---|---|
| shims/browser/js/gex.mjs | shared process memory, `Atomics.wait` in kernel imports | plain memory; Suspending kernel imports |
| gex.mjs module store | growable SAB | `null` (falls back to the per-process registry, as already designed) |
| worker.mjs | a Worker per guest thread; `outputAck` SAB | `FiberWorker`; no ack (one queue keeps the order) |
| host.mjs `#outputAck` | SAB + `Atomics.wait` | off |
| net-bridge.mjs | SAB ring to the page's Wisp | not used: `wispNet` shared by reference |
| tty.mjs | shared scratch memory | plain (kernel is in-realm) |
| wl-bridge.mjs | SAB scene arena | plain ArrayBuffer |
| compositor.js / compositor-worker.js | Worker reading SAB memories, stats SAB | in-realm in the system Worker; stats mirrored to the page every 500 ms |
| runtime.js kernel | shared kernel memory | plain kernel memory in the system Worker |
| gl-bridge.mjs, mesa-gl.js | SAB ring + `Atomics.wait`; Mesa is an Emscripten pthread build | `gl: 'compositor'`: the non-pthread Mesa (`/gex/st/`) in the system Worker beside the compositor. `gl_submit` is a Suspending import calling `glInRealm.submit` on the guest's own memory, with no ring copy. A page-side device (`gl` as an object) is not supported |
| xwayland-worker.js / hostx.js | Xwayland pthread build, own Worker on kernel SAB | HostX in the system Worker, on the non-pthread JSPI Xwayland (`/gex/xwayland-st/`): `main` suspends at each wait, which parks on the scheduler |
| dom-compositor.js | page reads SAB memories | **not yet** (the canvas compositor works) |
| persist.js, nt-page.js | persist Worker on the kernel SAB; SAB bundle | NT page only, which is not part of this build (the NT layer is legacy) |
The default build is selected exactly as before. Every single-build path
sits behind `single.on`, and `single.on` is true only when the realm runs
`enableSingle()`:
- in the system Worker;
- or under node with `GEX_SINGLE=1`.
The page picks the single build when either holds:
- `?single=1` is given;
- the page has no SAB and the engine has JSPI.
`?single=0` refuses it.
## 6. Results
### Correctness (gooner, untimed)
**Node harness** (`GEX_SINGLE=1`, `SharedArrayBuffer` deleted;
`scratch/SP/nrun.sh`):
- bash, pipes, command substitution, fork, `trap`/`kill` USR1/TERM,
`sleep & wait`;
- python3 with 4 threads;
- `apt-get update` / `install` (real TCP, `GEX_NETWORK=1`);
- `rg`;
- nodejs 20.19.2: `node -e "console.log(1+1)"` prints 2;
- neovim 0.10.4:
- headless edit and write;
- `vim.fn.system({"echo","hi"})`, which forks and execs from inside nvim.
**Chrome page without COOP/COEP** (`GEX_NO_COI=1`):
- `apt-get update` over `wss://wisp.mercurywork.shop/`;
- `apt install ripgrep tcc libc6-dev`, then `tcc -run` and `rg`;
- foot in the compositor (`?gui=1`):
- it draws;
- typed input reaches it (`seq 40 42`).
The same foot run on the SAB build still passes, so the default
compositor is unchanged.
### SAB against single, same commands, edger
Run with `scratch/SP/page/cmp.sh` in headless Chrome 153 under bench.sh.
- The page is the native Debian demo over `wss://wisp.mercurywork.shop/`.
- "single" is served with no COOP/COEP; "SAB" is the same branch served
cross-origin isolated.
- Runs c1 (before e4779cca and e10ec855) and c2 (after).
- Raw output is in `scratch/SP/page/out/c{1,2}-{single,sab}.txt`.
- All values are ms of wall time as measured in the guest, except where the
row says s.
- Network-bound rows (apt) swing 2-4× from run to run with the public Wisp
proxy, in both builds.
| command | single c1 | single c2 | SAB c1 | SAB c2 |
|---|---:|---:|---:|---:|
| `true` | 135 | 124 | 175 | 156 |
| `for i in $(seq 100); do x=$(echo $i); done` (100 forks) | 7216 | 6646 | 10417 | 10104 |
| `yes \| head -c 20000000 \| wc -c` | 2205 | 2072 | 441 | 391 |
| python3, 2M-iteration loop (its own clock, s) | 2.53 | 2.05 | 2.44 | 2.39 |
| `apt-get update` | 26266 | 11361 | 10183 | 39357 |
| `apt-get install ripgrep tcc libc6-dev` | 52342 | 50783 | 47936 | 109482 |
| `rg --version` | 1408 | 1402 | 1541 | 1531 |
| `tcc -run hello.c` | 1474 | 1829 | 1839 | 1679 |
| `rg -c define stdio.h` | 2922 | 3154 | 3221 | 2416 |
| `apt-get install neovim` | 17051 | 16450 | 18653 | 31483 |
| `nvim --version` | 3672 | 4135 | 3345 | 3728 |
| `nvim --headless +q` | 6765 | 7163 | 6755 | 6611 |
| `apt-get install nodejs` | 78290 | 104249 | 141265 | 169786 |
| `node --version` | 3380 | 3235 | 3130 | 3464 |
| `node -e 0` | 59699 | 62812 | 57113 | 56873 |
| node, 3e7-iteration loop (its own clock) | 8427 | 8434 | crashed | 9980 |
What the table shows:
- **Hot code.** It is not slower on a JSPI stack:
- the python loop takes 2.05-2.53 s single against 2.39-2.44 s SAB;
- the node loop takes 8427-8434 ms single against 9980 ms SAB.
- **fork and exec are faster.** 100 command substitutions take 6.6-7.2 s
single against 10.1-10.4 s SAB, about 1.5× faster. A fork in the single
build makes a fiber, not a Worker.
- **Pipelines are slower.** A three-process pipeline takes 2072-2205 ms
single against 391-441 ms SAB, about 5× slower. It loses the cores its
processes ran on, and every byte crosses the kernel on one thread.
- Profile (node harness, edger, single): `Pipe::read` popped a byte at a
time (182 ms), and `k_pipe_read` zeroed the whole request on every call
(`alloc_zeroed`, 240 ms), of 2970 ms in total. Both are fixed in
4fcc6417, which helps both builds.
- Scheduling itself is small: 48.9k parks and 29.3k wakes for the 20 MB,
with `wasm-to-js` at 72 ms.
- **node start-up** (`node -e 0`) takes 59.7-62.8 s single against
56.9-57.1 s SAB, 5-10% slower. It installs about 30k translated modules
with an async compile each, on one thread instead of seven.
- **Not single-specific.** node's child_process calls fail in **both**
builds on the page from the provider bug in §7. In single mode it already
fires on `node -e "console.log(1+1)"` (rc 137).
**The default build is not slower.** C's head e4779cca (same wasm,
without this branch's JS; run c3, leg 1, cut short) against this branch's
SAB build (c2). Raw ms:
| command | C's head | this branch, SAB |
|---|---:|---:|
| `true` | 139 | 156 |
| pipeline (20 MB) | 407 | 391 |
| `nvim --version` | 3921 | 3728 |
| `nvim --headless +q` | 6873 | 6611 |
| `node --version` | 2760 | 3464 |
| `node -e "console.log(1+1)"` | 38949 | 40063 |
| `node -e 0` | 57034 | 56873 |
| node 3e7 loop (guest clock) | 10216 | 9980 |
| `node t.js` | 80718 | 81141 |
Node harness, edger (whole harness run, ms; `scratch/SP/prof`):
| command | SAB | single |
|---|---:|---:|
| `yes \| head -c 20000000 \| wc -c` | 1562, 1653 | 2453, 2377 |
| 100 command substitutions | 7495, 7538 | 5204, 4927 |
### c4: ee504238, before the wake fix (edger, `GEX_WALL=900`)
Times in ms. Both modes hit the 900 s wall after `spawnSync nvim`.
| command | single | SAB |
|---|---:|---:|
| `true` | 127 | 145 |
| 100 forks | 6369 | 10087 |
| pipeline (20 MB) | 1772 | 404 |
| python loop | 2370 | 2500 |
| `apt-get update` | 9822 | 9088 |
| `apt-get install ripgrep tcc libc6-dev` | 77077 | 18282 |
| `apt-get install neovim` | 29778 | 30462 |
| `apt-get install nodejs` | 122984 | 114600 |
| `node -e 'console.log(1+1)'` | 39950 | 38726 |
| `node -e 0` | 63538 | 56513 |
| node loop | 8945 | 9761 |
| `node /tmp/t.js` | 96536 | 81067 |
The pipeline row is the ping-pong that §3's wake deferral removes; the
timed rerun is pending (see §8).
## 7. Limitations and next steps
- **Provider bug (C), both builds, page only: fixed.** The panic was
"provider address 0x71dc1a1 keeps losing its bridge", then
`gex-emit/src/module.rs:661`. C's fe08c546/bc679c69/4f1d86e4 fix it, and
they are merged here. While it lasted, a dead fiber holding the process's
lock made its siblings trap in `memory.atomic.wait32` in the single build
(and hang in the SAB build).
- **Blocking inside a nested call-out suspends.** A function-tier unit's
call to code that no unit holds (the `call_out` and `ftab_call` imports)
is a Suspending import in the single build:
- `#callOutSteps` is a generator holding the old `#callOut` loop.
- `#drive` runs it synchronously (SAB build, or under a frame that cannot
suspend).
- `#driveAsync` runs it with `enter` and each guest function as
`WebAssembly.promising`, so the nested guest has its own stack.
- Its `#nest` entry ends in `~`. `#hardNested()` ignores those, so a park,
futex wait or syscall inside it suspends the whole chain.
Test: `tests/single-callout/run.sh`. The unit `libcb_run` calls `cb`,
which reads a pipe a forked child fills 100 ms later and sleeps 2 ms per
call. Before the change it trapped with "trying to suspend JS frames …
inside syscall 101".
Still synchronous: `nt_callback`, `exc_dispatch` and `provider_try`. A
block under one of those throws an explicit error in the single build.
None of the runs here hit them.
- **Provider atomic waits on contention.** These are the `crt_heap` /
`glibc_malloc` `heap_lock_slow` and `gp_wait` waits, and the libprov
`wait_epoch`. They would trap (`memory.atomic.wait32` on an unshared
memory) if they ever wait. In the single build only one fiber runs at a
time, so a lock is never contended while its holder is suspended inside
it, except across a suspension. This is not seen so far.
- **GL and the X server work.** Both are built without `-pthread` (plain
memories), locally from the `wink` pin; nothing was pushed.
- **Mesa:** `GEX_MESA_THREADS=0 web/build-mesa-wasm.sh`.
- Uses `web/mesa-wasm-st-cross.ini` and Wink's `st` Tint; no glthread.
- Output: `web/public/gex/st/`.
- **Xwayland:** `GEX_XWL_THREADS=0 web/build-xwayland-wasm.sh`.
- Built with `-sJSPI`, and gexos.c's wait is an `EM_ASYNC_JS`.
- `strip_pthread` removes the `-pthread` that meson's threads dependency
adds.
- Output: `web/public/gex/xwayland-st/`.
- Host packages a distro may lack come from
`web/build-xwayland-host-tools.sh` (§9).
- **Checked on gooner** (software WebGPU, correctness only):
- **gears** (single): triangles 640, frames 30, gl error 0, covered
15%.
- **native-debian `?gui=1`** (`scratch/SP/page/x.sh`), apt over Wisp:
- `xdpyinfo` on `:0` reports X.Org 24.1.9;
- xlogo and xeyes are managed and drawn;
- the screenshot is identical to the SAB build's.
- `web/public/fs/libGLESv2.so.2` and `libEGL.so.1` were rebuilt. The
checked-in ones spoke GL protocol 12, and gl-proto.js speaks 13.
- **Wakes are wake-all.** Every parked fiber re-checks its word, though
only once the running fiber parks (§3). This is fine for tens of fibers;
a node run peaked at 272.
- **The kernel has 128 stack slots**, which caps the number of fibers alive
at once.
- Dead fibers' memories are freed by GC.
## 8. Final state (stream SP stopped 2026-10-10)
The stream is stopped and will not be restarted. merge/singleproc lands in
master with the multi-process build (MP) the default everywhere. The single
build (SP) stays available behind its option. `web/build-all.sh --sp` (or
`--both`) builds its own dist, `web/dist-sp`, and serves it without
COOP/COEP (§9). Any page without SharedArrayBuffer, or with `?single=1`,
runs SP.
### What works in SP
These run in a page with no COOP/COEP:
- the native Debian demo, with apt over Wisp (update and install);
- fork, exec, pipes and signals;
- multithreaded guests;
- the xterm terminal;
- the Wayland compositor (foot);
- GL through the non-pthread Mesa (gears: 640 triangles, 30 frames, gl error
0, 15% covered);
- Xwayland in-realm (`xdpyinfo :0`, xlogo, xeyes; the screenshot matches
MP's).
### Measured numbers
All timings below are edger, under bench.sh. The table is the c4 run in §6,
taken before the wake fix (ee504238):
- the `true` command and 100 forks are faster in SP than in MP;
- apt-get update, neovim and the node rows are within 20% either way (SP is 19% slower on `node /tmp/t.js`, 96536 vs 81067 ms);
- pipelines and the first big apt install are slower in SP.
The wake fix (0a9b89f3, §3) addresses those two slow rows. Its timed rerun
never ran, because the stream stopped first. What it changes, counted under
node on gooner (untimed):
| run | parks | wakes |
|---|---:|---:|
| 3-process pipeline, before | 48 877 | 29 341 |
| 3-process pipeline, after | 2 528 | 1 809 |
| `apt-get install ripgrep tcc libc6-dev`, before | 310 845 | 90 973 |
| `apt-get install ripgrep tcc libc6-dev`, after | 11 238 | 3 337 |
The apt install gap is the same ping-pong as the pipeline. dpkg and apt talk
over pipes in small writes, and before the fix every write resumed the
reader at once. The fix cuts parks 28-fold (310 845 to 11 238).
After the fix, a CPU profile of the install (node, single mode) has no
scheduler entry near the top. The largest are exec and fork work, which MP
pays as well:
- `moduleContentKey` is 16% of samples. It is a byte-at-a-time JS hash of
each native wasm program's bytes (dpkg, apt and their methods), run on
every exec by `#cgenInstall`.
- `adoptProcess`, `writeSparse` and `#processImage` (fork images) make up
about 12%.
In MP those run on the child's own Worker. In SP they run on the one
thread.
### Known gaps
- `nt_callback`, `exc_dispatch` and `provider_try` still call nested guest
code synchronously. A block under one of them throws an explicit error in
SP (§7). Wine-style callbacks are therefore MP-only.
- Provider atomic waits on contention would trap on an unshared memory
(§7). None was seen.
- The apt and pipeline rows have not been re-timed after the wake fix on
edger. Under node the counts above show the cause is gone.
- On gooner (SwiftShader), the gears demo through drive.mjs reports
"covered 0%" in MP, on master as well. SP reports 15%. Treat that as a
headless-presentation artefact of MP, not an SP property.
### Next steps (for whoever picks it up)
1. Time `scratch/SP/page/pipe.sh` and `scratch/SP/page/cmp.sh TAG single sab`
on edger at the merged head (`GEX_WALL=900`, `BENCH_TIMEOUT`).
2. Hash wasm programs once per file (cache the key on the path and mtime,
or hash in wasm), not on every exec. This helps both builds.
3. Make `nt_callback`, `exc_dispatch` and `provider_try` suspendable, the
way `call_out` is (§7).
### Branches
- `merge/singleproc`: this work.
- `merge/sp-iov` 37604a2f: the writev/readv park, kernel only, also in
merge/singleproc.
- `merge/sp-gllibs` c02088a0: the guest GL libraries rebuilt for protocol 13.
## 9. Building, serving and testing both builds
### Layout
Each flavour has its own dist, and neither holds the other's Mesa or
Xwayland:
| | MP (default) | SP |
|---|---|---|
| dist | `web/dist`, served with COOP/COEP | `web/dist-sp`, served without |
| shim, kernel, seed | `gex/gex_browser.shared.wasm`, `gex/gex_kernel.wasm`, `gex/seed.shared.wasm` | the same three files |
| Mesa build tree | `target/web-build/mesa-mt` | `target/web-build/mesa-st` |
| Mesa in the dist | `gex/gex-wgpu-replay.*` | `gex/st/gex-wgpu-replay.*` |
| Xwayland build tree | `target/web-build/xwl-mt` | `target/web-build/xwl-st` |
| Xwayland in the dist | `gex/xwayland/` | `gex/xwayland-st/` |
| unstripped wasm | `target/web-debug/mp/` | `target/web-debug/sp/` |
| rootfs, `fs/`, providers | copied into both | copied into both |
The shim, kernel and seed are built once (`web/build-assets.sh`) and copied
into both dists. SP loads the shared binaries and clears their memories'
`shared` flag at compile time (§2).
An SP-only shim would not be smaller by much:
- Its fibers need per-instance stacks and TLS. In Rust on wasm those come
only from the `+atomics` build-std build that MP uses.
- Without the `shared-memory` feature that build does not compile: talc's
`TalcSyncCell` refuses a threaded target with no heap lock. The heap's
spinlock is what the feature adds.
- The atomic instructions are the rest. There are 15 738 of them in the
stripped shim, about 2 bytes each over a plain access, so at most about
31 KB (0.6% of 5 159 727 bytes). They sit mostly in std's TLS and locks,
which SP needs anyway.
So there is one build.
No page loads these, so neither dist ships them:
- the plain `gex_browser.wasm`;
- `seed.wasm`;
- `gex_kernel.plain.wasm`.
`run-node.mjs` and the check scripts use them from `web/public`.
### Sizes
Measured on gooner from this tree. "Runtime" is the dist without `rootfs/`
and `fs/` (177.6 MB and 7.6 MB, the same in both).
| | MP `web/dist` | SP `web/dist-sp` |
|---|---:|---:|
| whole dist | 213 377 145 B | 213 094 572 B |
| runtime, raw (tar) | 28 200 960 B | 27 914 240 B |
| runtime, zstd -19 | 5 369 300 B | 5 400 608 B |
These are the large files, stripped:
| file | raw | zstd -19 |
|---|---:|---:|
| `gex_browser.shared.wasm` (52 107 296 B with DWARF) | 5 159 727 B | 1 153 655 B |
| `gex_kernel.wasm` (14 815 564 B with DWARF) | 259 673 B | 69 140 B |
| Mesa `gex-wgpu-replay.wasm` (20 599 167 B with its name section) | 18 891 407 B | 3 305 586 B |
| `Xwayland.wasm` | 2 064 538 B | 573 528 B |
| `gexhostwl.wasm` (12 265 903 B with DWARF) | 144 639 B | 42 643 B |
What a page fetches until the demo has passed. This is counted by
`GEX_SERVE_LOG` behind `serve-dist.mjs --sp-port`, on gooner, with every
response `no-store`:
| demo | MP (`web/dist`, COOP/COEP) | SP (`web/dist-sp`, none) |
|---|---:|---:|
| busybox (to the shell prompt) | 13 files, 7 527 568 B | 16 files, 7 450 725 B |
| gears (30 frames) | 24 files, 28 205 364 B | 26 files, 27 176 339 B |
In both builds the large items are the same:
- the shim (5 159 727 B);
- the busybox rootfs (1 464 320 B);
- for gears, Mesa (18.9 MB MP, 18.7 MB SP) and the guest's libc and GL
libraries.
SP once fetched the shim twice, from the page and again from the system
Worker. The page now hands its copy to the Worker (`primeRuntime`).
Keeping the name section would add 712 183 B to the shim (5 871 910 B).
Nothing in the runtime reads it, so the dists strip every custom section
(`wasm-tools strip -a`), except on the seed and the providers, whose `gex.*`
sections are read. `--debug` ships them unstripped.
### Build
```sh
git submodule update --init third_party/mesa third_party/opengl-registry
(cd web && npm ci)
web/build-all.sh --both # or --mp (the default) / --sp
```
`build-all.sh` runs these steps:
1. `web/build-assets.sh`: shim (plain and shared), kernel, seeds, providers,
busybox rootfs and guest GL libraries, into `web/public`. Cargo makes this
step incremental.
2. Mesa for each flavour asked for:
`GEX_MESA_THREADS=1|0 web/build-mesa-wasm.sh target/web-build/mesa-mt|st`.
- Incremental through meson and ninja.
- Wink caches its Tint per variant.
3. Xwayland for each flavour:
`GEX_XWL_THREADS=1|0 web/build-xwayland-wasm.sh target/web-build/xwl-mt|st`.
- The script always builds from scratch, so build-all skips it while a
stamp of its inputs is unchanged:
- the script;
- `web/xwayland/`;
- `shims/hostwl/`;
- the tags.
- If the distro lacks a piece, it first runs
`web/build-xwayland-host-tools.sh target/web-build/xwl-host` and passes
that prefix as `GEX_XWL_HOST_TOOLS`. The pieces it checks:
- wayland-scanner, wayland-protocols or xwaylandproto (through
`pkg-config`);
- xorg-macros;
- `X11/xshmfence.h`.
The host-tools build is cached by a `.done` stamp.
4. For each flavour, the dist:
- a hard-linked copy of `web/public` in `target/web-build/public-<f>`,
pruned to that flavour;
- the large wasm files replaced by stripped copies, with the unstripped
ones linked into `target/web-debug/<f>/`;
- `vite build` from that tree (`GEX_PUBLIC_DIR`) into `web/dist` or
`web/dist-sp` (`GEX_OUT_DIR`).
Flags:
- `--no-assets`, `--no-gl` and `--no-x` skip steps 1-3 one by one.
- `--dist-only` skips all three.
- `--debug` keeps the debug sections.
- `GEX_WEB_BUILD=DIR` moves `target/web-build`.
`web/refresh-dist.sh`, which every shim and kernel build calls, rebuilds
whichever of the two dists exist the same way (`build-all.sh --<f>
--dist-only`). After any source change, `web/build-all.sh --both` rebuilds:
- the runtime, through cargo;
- Mesa, through ninja;
- Xwayland, when its stamp changes;
- both dists.
The native Debian rootfs and its GUI variant come from their own scripts
(`web/build-native-debian.sh`) into `web/public/rootfs`, and so into both
dists.
### Serve
```sh
node web/serve-dist.mjs 8088 --sp-port 8089
```
This serves `web/dist` (MP, with COOP/COEP) on 8088 and `web/dist-sp` (SP,
without them) on 8089, at the same time. `GEX_SERVE_LOG=FILE` logs each file
served with its size.
### Test
- MP: the default page smoke test, `node web/test/site.mjs` (busybox,
debian, gears), against `web/dist`.
- SP leg, for the gate:
```sh
GEX_WEBGPU_SOFTWARE=1 tests/single/run.sh "$PWD/target/release/gexc"
```
It runs these:
1. `tests/single-callout/run.sh` (single and SAB under node; it adds
`--experimental-wasm-jspi` on a node that needs it).
2. `GEX_DIST=web/dist-sp GEX_NO_COI=1 node web/test/site.mjs <demo>` for
each of `SP_DEMOS` (default: busybox, debian, gears).
Each demo must pass, and its terminal must contain "single-thread build
(no SharedArrayBuffer)", so a silent fallback cannot pass.
`GEX_WEBGPU_SOFTWARE=1` puts WebGPU on SwiftShader for hosts without a
GPU.
- Kernel: `cargo test --release -p gex-kernel`
(`vectored_io_on_a_blocked_pipe_parks` covers the writev/readv park).
- Under node: `GEX_SINGLE=1 node --import <delete SharedArrayBuffer>
shims/browser/js/run-node.mjs ...`. `GEX_SINGLE_STATS=1` prints the
scheduler counts.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+4 -2
View File
@@ -124,8 +124,10 @@ function suppliedArities(source) {
}
const body = source.slice(at, end);
const out = new Map();
// `name: (a, b) => ...` and `name: () => ...`, at any indentation.
for (const m of body.matchAll(/([A-Za-z_][A-Za-z0-9_]*)\s*:\s*\(([^)]*)\)\s*=>/g)) {
// `name: (a, b) => ...` and `name: () => ...`, at any indentation, and the
// single-thread build's `name: single.on ? new WebAssembly.Suspending((a, b)
// => ...) : (a, b) => ...`, read by its first arrow (both take the same).
for (const m of body.matchAll(/([A-Za-z_][A-Za-z0-9_]*)\s*:\s*(?:single\.on\s*\?\s*new WebAssembly\.Suspending\(\s*)?\(([^)]*)\)\s*=>/g)) {
const params = m[2].trim();
out.set(m[1], params ? params.split(',').length : 0);
}
+411 -61
View File
@@ -12,6 +12,7 @@ const GUEST_BYTES = 0xffff0000;
// 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.
import { lazyFetch } from "./lazy-fetch.mjs";
import { single, sched } from "./single.mjs";
// Read when used, not at import: a Worker learns it from its first message.
const m64 = () => !!globalThis.GEX_MEM64;
@@ -143,7 +144,14 @@ const tIdx = (v) => (m64() ? BigInt(v) : v);
*/
const DECODER = new TextDecoder();
export function utf8(view) {
return DECODER.decode(view.buffer instanceof SharedArrayBuffer ? view.slice() : view);
return DECODER.decode(isShared(view.buffer) ? view.slice() : view);
}
/** Whether `buf` is a SharedArrayBuffer -- asked without naming the global,
* which a page that is not cross-origin isolated does not have. */
const SAB = globalThis.SharedArrayBuffer;
function isShared(buf) {
return typeof SAB === 'function' && buf instanceof SAB;
}
// Registry indices describe a shared queue, not module identity: two Workers
@@ -152,22 +160,33 @@ export function utf8(view) {
// unrelated functions into otherwise-correct dispatch slots. Two independent
// 32-bit accumulators plus the exact length make a cheap content key; this is
// validation against an index race, not a persistent content-addressed cache.
/** The most the module store (`Gex#moduleStore`) may grow to. */
const MODULE_STORE_MAX = 4 * 1024 * 1024 * 1024;
/** The most the module store (`Gex#moduleStore`) may grow to, tried in turn:
* the first a runtime accepts. 4 GiB filled up in 22 minutes of Hollow Knight
* on Unity's own Mono (the translations of Mono's JIT output), after which
* the registry kept its bytes in the shim's heap again and that ran out.
* A growable SharedArrayBuffer only commits what it has grown to. */
const MODULE_STORE_MAX = [32, 16, 8, 4].map((g) => g * 1024 * 1024 * 1024);
/** A new module store: a growable SharedArrayBuffer whose first eight bytes
* are the bump cursor. Null where SharedArrayBuffer cannot grow. */
function makeModuleStore() {
if (typeof SharedArrayBuffer === 'undefined' || typeof SharedArrayBuffer.prototype.grow !== 'function') return null;
// Off, for comparing against the registry keeping its own bytes.
if (globalThis.GEX_NO_MODULE_STORE || globalThis.process?.env?.GEX_NO_MODULE_STORE) return null;
try {
const sab = new SharedArrayBuffer(1 << 20, { maxByteLength: MODULE_STORE_MAX });
Atomics.store(new BigInt64Array(sab, 0, 1), 0, 8n);
return sab;
} catch {
return null;
for (const max of MODULE_STORE_MAX) {
try {
const sab = new SharedArrayBuffer(1 << 20, { maxByteLength: max });
Atomics.store(new BigInt64Array(sab, 0, 1), 0, 8n);
if (globalThis.GEX_REGISTRY_TRACE || globalThis.process?.env?.GEX_REGISTRY_TRACE) {
globalThis.process?.getBuiltinModule?.('fs')?.writeSync(2, `[gex] module store max ${max / 2 ** 30} GiB\n`);
}
return sab;
} catch {
// Too big for this runtime: the next.
}
}
return null;
}
let storeFullSaid = false;
/** Append `bytes` to a module store: their offset, or -1 when it is full.
* Any Worker of the process may append at once: the cursor is atomic and a
* grow that loses a race to a bigger one is simply retried. */
@@ -175,7 +194,15 @@ function storeAppend(sab, bytes) {
const len = bytes.length;
const off = Number(Atomics.add(new BigInt64Array(sab, 0, 1), 0, BigInt((len + 7) & ~7)));
const end = off + len;
if (end > sab.maxByteLength) return -1;
if (end > sab.maxByteLength) {
// Said once: from here the registry keeps bytes in the shim's heap, which
// shares the guest's 2 GiB arena and will run out in a long run.
if (!storeFullSaid) {
storeFullSaid = true;
console.error(`[gex] module store full at ${(sab.maxByteLength / 2 ** 30).toFixed(0)} GiB: the translated-module registry keeps its bytes in the shim heap from here`);
}
return -1;
}
while (sab.byteLength < end) {
try {
sab.grow(Math.min(sab.maxByteLength, Math.max(end, sab.byteLength * 2)));
@@ -186,35 +213,31 @@ function storeAppend(sab, bytes) {
new Uint8Array(sab, off, len).set(bytes);
return off;
}
/** A module's content key, `LENGTH:HASH:HASH` (host.mjs reads the length).
* Four bytes a step: the byte-at-a-time loop was 16% of the samples of an
* `apt-get install` in the page, every exec and every catch-up hashing each
* module it installs. Two 32-bit lanes over the words, then the tail. */
function moduleContentKey(bytes) {
if (!(bytes instanceof Uint8Array)) return null;
const n = bytes.length;
const w = n >>> 2;
// A little-endian word at a time, then the tail bytes (the shim's
// `content_key` is the same function): a byte at a time was 0.65 s of a
// node start-up per Worker.
let a = 0x811c9dc5;
let b = 0x9e3779b9;
if (w) {
const words = bytes.byteOffset % 4 === 0
? new Int32Array(bytes.buffer, bytes.byteOffset, w)
: new Int32Array(bytes.slice(0, w * 4).buffer);
for (let i = 0; i < w; i++) {
const x = words[i];
a = Math.imul(a ^ x, 0x01000193);
b = Math.imul(b ^ (x + i), 0x85ebca6b);
b ^= b >>> 13;
}
}
for (let i = w * 4; i < n; i++) {
const x = bytes[i];
const n = bytes.length >>> 2;
const words = (bytes.byteOffset & 3) === 0 && !isShared(bytes.buffer)
? new Uint32Array(bytes.buffer, bytes.byteOffset, n)
: null;
for (let i = 0; i < n; i++) {
const x = words ? words[i]
: (bytes[4 * i] | (bytes[4 * i + 1] << 8) | (bytes[4 * i + 2] << 16) | (bytes[4 * i + 3] << 24)) >>> 0;
a = Math.imul(a ^ x, 0x01000193);
b = Math.imul(b ^ (x + i), 0x85ebca6b);
b = Math.imul(b ^ ((x + i) | 0), 0x85ebca6b);
b ^= b >>> 13;
}
a ^= a >>> 16;
return `${n}:${a >>> 0}:${b >>> 0}`;
for (let i = 4 * n; i < bytes.length; i++) {
const x = bytes[i];
a = Math.imul(a ^ x, 0x01000193);
b = Math.imul(b ^ (x + (n + i - 4 * n)), 0x85ebca6b);
b ^= b >>> 13;
}
return `${bytes.length}:${a >>> 0}:${b >>> 0}`;
}
const GUEST_INITIAL_BYTES = 0x07000000;
// The shim's stack sits at the bottom of its region, below its data.
@@ -525,6 +548,27 @@ export function elementTableNeeds(wasm) {
return needs;
}
/** Where a module's `gex.dense` section payload (its base word) starts, or -1. */
function denseBaseAt(wasm) {
let i = 8;
const leb = () => {
let n = 0, shift = 0, b;
do { b = wasm[i++]; n |= (b & 0x7f) << shift; shift += 7; } while (b & 0x80);
return n >>> 0;
};
while (i < wasm.length) {
const id = wasm[i++];
const size = leb();
const end = i + size;
if (id === 0) {
const n = leb();
if (n === 9 && utf8(wasm.subarray(i, i + n)) === 'gex.dense') return i + n;
}
i = end;
}
return -1;
}
/** A module's `gex.dense` section (gex_emit::dense::SECTION), or null. */
export function denseSection(wasm) {
if (wasm instanceof WebAssembly.Module) {
@@ -738,6 +782,9 @@ export class Gex {
// shared kernel memory. Null is a run of one process, which needs none of it.
#kernelSpec = null;
#census = null;
/** The single-thread build: where this fiber's JS last was, for the census
* (a fiber that waits in JS is otherwise invisible to it). */
#where = '';
#kernel = null;
// This Worker's shim stack, claimed on the way in. See `#workerStack`.
#stackTop = null;
@@ -812,7 +859,9 @@ export class Gex {
// bootstrap slice of the shim's own stack band below SHIM_STACK_TOP,
// one of 32 handed out round-robin through a counter in the reserved
// low region -- Workers starting together get different ones.
if (typeof this.#shim.exports.gex_worker_init?.call === 'function' && this.#memory.buffer instanceof SharedArrayBuffer) {
// The single-thread build runs the shared build's shim on a plain
// memory: one memory, several instances (threads), so the same claim.
if (typeof this.#shim.exports.gex_worker_init?.call === 'function' && (isShared(this.#memory.buffer) || single.on)) {
const k = Atomics.add(new Int32Array(this.#memory.buffer), BOOT_STACK_COUNTER >> 2, 1) & 31;
this.#shim.exports.__stack_pointer.value = addrVal(SHIM_STACK_TOP - k * 0x40000);
}
@@ -911,6 +960,12 @@ export class Gex {
* exited. Closed here rather than `unref`'d so it holds the run while in use.
*/
close() {
// The census timer too: a fiber (the single-thread build) is not a
// Worker whose end takes its timers with it.
if (this.#census) {
clearInterval(this.#census);
this.#census = null;
}
if (this.#tick) {
this.#tick.port1.close();
this.#tick.port2.close();
@@ -1087,10 +1142,21 @@ export class Gex {
// The function table's fallback (cgen.rs `gex_ftab_fallback`): tier-1
// runs nested until the guest returns to the function body that
// called it.
call_out: (ctx, target, ret, spWant) => this.#callOut(ctx >>> 0, target, ret, spWant),
call_out: single.on
? new WebAssembly.Suspending((ctx, target, ret, spWant) => (this.#hardNested()
? this.#callOut(ctx >>> 0, target, ret, spWant)
: this.#callOutAsync(ctx >>> 0, target, ret, spWant)))
: (ctx, target, ret, spWant) => this.#callOut(ctx >>> 0, target, ret, spWant),
// An AOT-only fallback whose target this Worker has just installed
// (cgen.rs `gex_ftab_fallback`): called as a function body.
ftab_call: (idx, ctx, depth, need, ret) => this.#cgen.ftab.get(tIdx(idx >>> 0))(ctx, depth, need, ret),
// In the single build, on a stack of its own, so the body (and guest
// code it calls) can block.
ftab_call: single.on
? new WebAssembly.Suspending((idx, ctx, depth, need, ret) => {
const f = this.#cgen.ftab.get(tIdx(idx >>> 0));
return this.#hardNested() ? f(ctx, depth, need, ret) : this.#promisingOf(f)(ctx, depth, need, ret);
})
: (idx, ctx, depth, need, ret) => this.#cgen.ftab.get(tIdx(idx >>> 0))(ctx, depth, need, ret),
// A dispatch miss installs in place (lib.rs `in_place`).
jit_install: (_ctx) => this.#jitInstall(),
// An exception dispatched nested (nt.rs `raise_nested`).
@@ -1127,17 +1193,27 @@ export class Gex {
// The token as well as the ring: the host keeps a GL context per
// process, because the object names in the stream are this process's
// own and another process hands out the same ones.
gl_submit: (ring) =>
// In the single build a promise (the compositor's Mesa runs in this
// realm), so the import suspends; never from a nested call-out.
gl_submit: single.on ? new WebAssembly.Suspending((ring) =>
(this.#gl
? this.#gl.submit(this.#memory, ring >>> 0, this.wlToken(), false, this.#glWaitNote)
: -1),
: -1))
: (ring) =>
(this.#gl
? this.#gl.submit(this.#memory, ring >>> 0, this.wlToken(), false, this.#glWaitNote)
: -1),
// The same, not waited for (a frame's last batch): the copy is taken
// and the guest draws on; the next submit waits for this one, and
// says how long through `gex_gl_note_wait_ns`.
gl_submit_async: (ring) =>
gl_submit_async: single.on ? new WebAssembly.Suspending((ring) =>
(this.#gl
? this.#gl.submit(this.#memory, ring >>> 0, this.wlToken(), true, this.#glWaitNote)
: -1),
: -1))
: (ring) =>
(this.#gl
? this.#gl.submit(this.#memory, ring >>> 0, this.wlToken(), true, this.#glWaitNote)
: -1),
// This pump's scene: `count` surface records at `ptr` in this memory,
// saying where each surface's pixels are rather than carrying them. The
// compositor reads those addresses out of this same memory, so a frame
@@ -1224,6 +1300,10 @@ export class Gex {
traceBlocks: opts.traceBlocks, traceMisses: opts.traceMisses, checkSp: opts.checkSp,
watchMem: opts.watchMem,
noIntrinsics: opts.noIntrinsics, intrinsicMask: opts.intrinsicMask,
// The translator's knobs, for every process of the run: set only here,
// a forked child (node under `bash -c`) ran with the defaults.
perMiss: opts.perMiss, liftMax: opts.liftMax, inlineDispatch: opts.inlineDispatch,
xlateReport: opts.xlateReport, discoverSkip: opts.discoverSkip, t1Fold: opts.t1Fold,
};
syncSlotShift(this.#shim.exports);
if (opts.strace) this.#shim.exports.gex_set_trace(1);
@@ -1235,6 +1315,12 @@ export class Gex {
}
// How many of an object's stored units one miss may take.
if (opts.perMiss) this.#shim.exports.gex_set_per_miss?.(opts.perMiss);
// How many blocks one dispatch miss lifts (default 2048).
if (opts.liftMax) this.#shim.exports.gex_set_lift_max?.(opts.liftMax);
if (opts.inlineDispatch) this.#shim.exports.gex_set_inline_dispatch?.(1);
if (opts.xlateReport) this.#shim.exports.gex_set_xlate_report?.(opts.xlateReport);
if (opts.discoverSkip) this.#shim.exports.gex_set_discover_skip?.(opts.discoverSkip);
if (opts.t1Fold) this.#shim.exports.gex_set_t1_fold?.(1);
// Keep what the guest's dynamic linker produces, and start the next process
// of the same program from it instead of linking again.
if (opts.link) this.#shim.exports.gex_set_snapshot?.(Number(opts.link));
@@ -1471,7 +1557,7 @@ export class Gex {
: e.gex_sysreg_write(ctx, pc, reg, v))
: e.gex_sysreg_write,
cache_op: e.gex_cache_op,
safepoint: e.gex_safepoint,
safepoint: single.on ? this.#singleSafepoint(e) : e.gex_safepoint,
fma: e.gex_fma,
x87: e.gex_x87,
};
@@ -1689,7 +1775,7 @@ export class Gex {
sysreg_read: e.gex_sysreg_read,
sysreg_write: e.gex_sysreg_write,
cache_op: e.gex_cache_op,
safepoint: e.gex_safepoint,
safepoint: single.on ? this.#singleSafepoint(e) : e.gex_safepoint,
fma: e.gex_fma,
x87: e.gex_x87,
};
@@ -1881,7 +1967,7 @@ export class Gex {
const text = utf8(this.#bytes().slice(p, p + n));
// Only when it has stopped changing: a wedged process repeats the same
// wait forever, which is the signal; a busy one churns through them.
if (text === last) narrate(`[gex] quiet: ${text}`);
if (text === last) narrate(`[gex] quiet: ${text}${single.on ? ` js=${this.#where}/installs=${this.#installs}/pending=${e.gex_jit_pending_len() >>> 0}` : ''}`);
last = text;
}
e.gex_free?.(p, 512);
@@ -1963,6 +2049,36 @@ export class Gex {
}
// Nobody has asked for a halt, rather than "halt with status 0".
if (!k) out.k_halt_code = () => -1;
// The single-thread build: the wait words are promises (single.mjs
// `Sched`). A park that need not wait answers at once, as
// `memory.atomic.wait32` does when the value has moved; one that must
// suspends this fiber's stack. Every wake also wakes the fibers.
if (k && single.on) {
const nested = () => {
throw new Error('gex (single-thread build): a blocking call inside a nested guest call '
+ `(${this.#nest.join(' > ')}) cannot suspend`);
};
out.k_park = new WebAssembly.Suspending((seq, ns) => {
if (k.k_wake_seq() !== seq) return 1;
if (this.#hardNested()) nested();
this.#where = `park(${ns})`;
return sched.park(ns);
});
out.k_park_futex = new WebAssembly.Suspending((addr, gen, ns) => {
if ((k.k_futex_gen(addr) >>> 0) !== (gen >>> 0)) return 1;
if (this.#hardNested()) nested();
this.#where = `futex(${addr},${ns})`;
return sched.parkFutex(addr, ns);
});
out.k_wake = () => {
k.k_wake();
sched.wake();
};
out.k_futex_wake = (addr) => {
k.k_futex_wake(addr);
sched.wakeFutex(addr);
};
}
return out;
}
@@ -2190,6 +2306,11 @@ export class Gex {
if (o.noIntrinsics) e.gex_set_intrinsics?.(0);
if (o.intrinsicMask !== undefined) e.gex_set_intrinsic_mask?.(o.intrinsicMask);
if (o.perMiss) e.gex_set_per_miss?.(o.perMiss);
if (o.liftMax) e.gex_set_lift_max?.(o.liftMax);
if (o.inlineDispatch) e.gex_set_inline_dispatch?.(1);
if (o.xlateReport) e.gex_set_xlate_report?.(o.xlateReport);
if (o.discoverSkip) e.gex_set_discover_skip?.(o.discoverSkip);
if (o.t1Fold) e.gex_set_t1_fold?.(1);
if (o.tty) e.gex_set_tty(1);
// Identity was already said above, before the kernel was built.
// The map, which the bytes do not carry: without it the child's first
@@ -2373,7 +2494,9 @@ export class Gex {
// A thread needs a memory two Workers can both hold. Posting a non-shared
// memory does not fail -- the structured clone copies it, and the thread
// runs on a private image nothing else can see -- so it is refused here.
if (!(this.#memory?.buffer instanceof SharedArrayBuffer)) {
// In the single-thread build a thread is a fiber of this realm, handed
// the memory object itself.
if (!isShared(this.#memory?.buffer) && !single.on) {
throw new Error(
`the guest started thread ${tid} on a memory that is not shared. ` +
'A guest thread is a Worker and a Worker needs a SharedArrayBuffer: ' +
@@ -2589,7 +2712,8 @@ export class Gex {
// paying `WebAssembly.compile` again. The bytes are still needed for the
// metadata and table-need computation the install does regardless.
const key = this.#registryKey(i, bytes);
const cached = key ? this.#sharedModules.get(key) : undefined;
// Or one the page's unit cache has (another process's).
const cached = (key ? this.#sharedModules.get(key) : undefined) ?? this.#unitGet(this.#unitKey(bytes)) ?? undefined;
let who = 'jit2049';
if (globalThis.GEX_DUMP_WHERE ?? true) who = `jit2049-${i}-at-${ptr.toString(16)}`;
try {
@@ -3497,14 +3621,18 @@ export class Gex {
else f();
return 0;
};
const staged = () => {
if (e.gex_wasmprog_staged()) {
this.#drainFork().catch((err) => console.error(`[gex] starting a process: ${err.message ?? err}`));
this.#drainThread().catch((err) => console.error(`[gex] starting a thread: ${err.message ?? err}`));
}
};
const sync = (ctx, pc) => {
e.gex_syscall(ctx, pc);
staged();
};
return {
syscall: (ctx, pc) => {
e.gex_syscall(ctx, pc);
if (e.gex_wasmprog_staged()) {
this.#drainFork().catch((err) => console.error(`[gex] starting a process: ${err.message ?? err}`));
this.#drainThread().catch((err) => console.error(`[gex] starting a thread: ${err.message ?? err}`));
}
},
syscall: single.on ? this.#suspendingSyscall(sync, staged) : sync,
asyncify,
fork_point: (rec) => {
const st = asyncify(0, 0);
@@ -3550,26 +3678,40 @@ export class Gex {
* thread or a child rather than trapping, and the host starts it here. */
#guestSyscall(e) {
if (!Gex.wasmDlls) return e.gex_syscall;
return (ctx, pc) => {
e.gex_syscall(ctx, pc);
const staged = () => {
if (e.gex_wasmprog_staged()) {
this.#drainFork().catch((err) => console.error(`[gex] starting a process: ${err.message ?? err}`));
this.#drainThread().catch((err) => console.error(`[gex] starting a thread: ${err.message ?? err}`));
}
};
const sync = (ctx, pc) => {
e.gex_syscall(ctx, pc);
staged();
};
return single.on ? this.#suspendingSyscall(sync, staged) : sync;
}
/** A wasm64 provider's `gex.syscall(ctx, pc)` (cgen/libprov.rs): marked, so the shim
* serves it without leaving the provider's frames (a blocking call waits in place, a
* thread is staged); the host then starts what it staged, and the call returns. */
#providerSyscall = (ctx, pc) => {
#providerSyscallSync = (ctx, pc) => {
const e = this.#shim.exports;
e.gex_syscall(ctx, BigInt.asIntN(64, BigInt(pc) | (1n << 61n)));
this.#providerStaged();
};
#providerStaged = () => {
const e = this.#shim.exports;
if (e.gex_wasmprog_staged()) {
this.#drainFork().catch((err) => console.error(`[gex] starting a process: ${err.message ?? err}`));
this.#drainThread().catch((err) => console.error(`[gex] starting a thread: ${err.message ?? err}`));
}
};
#providerSyscallS = null;
/** The single-thread build's: see `#suspendingSyscall`. */
get #providerSyscall() {
if (!single.on) return this.#providerSyscallSync;
return (this.#providerSyscallS ??= this.#suspendingSyscall(this.#providerSyscallSync, this.#providerStaged, 1n << 61n));
}
#nativeSyscall = (ctx, pc) => {
const e = this.#shim.exports;
@@ -3824,7 +3966,14 @@ export class Gex {
);
}
this.#shim.exports.gex_callout_leave_ok?.(0);
this.#guest.exports.enter(ctx, pc);
// The single-thread build enters on a stack of the guest's own
// (JSPI), which a blocking call suspends; see single.mjs.
if (single.on) {
this.#where = 'enter';
await this.#enterP(ctx, pc);
this.#where = 'trap';
}
else this.#guest.exports.enter(ctx, pc);
// `enter` returning means the guest gave the stack back for good,
// normally because the last process exited.
if (globalThis.GEX_DEBUG_EXIT) {
@@ -3833,6 +3982,7 @@ export class Gex {
return this.#shim.exports.gex_exit_code();
} catch (err) {
trappedAt = performance.now();
this.#where = 'trap';
// First, before asking the shim anything: was this a handoff or a
// crash? Every way out is the same `unreachable` to the engine, and
// treating a crash as a handoff reported a crashed runtime as a clean
@@ -4010,7 +4160,9 @@ export class Gex {
let answer = quick;
if (answer === null || answer === undefined) {
const t0 = performance.now();
this.#where = `fillSlot(${want >>> 0})`;
answer = await this.fillSlot(want >>> 0);
this.#where = 'trap';
this.#slowFills++;
this.#slowMs += performance.now() - t0;
}
@@ -4024,7 +4176,9 @@ export class Gex {
const need = (e.gex_jit_pending_slots?.() ?? 0) >>> 0;
if (need) this.#growTables(need, 'a2358');
}
this.#where = 'install';
while (e.gex_jit_pending_len() >>> 0) await this.#installTranslation();
this.#where = 'adopt';
// The parent's modules go in after this Worker's own queue, never
// between a translation and its install: a module's page assignments
// are the next one's starting point, so interleaving two orderings
@@ -4035,14 +4189,18 @@ export class Gex {
// drains it into its own tables and the asker never sees it. `#catchUp`
// is a no-op comparison when there is nothing to do.
this.#armCensus();
this.#where = 'catchUp';
await this.#catchUp();
this.#cgenServe();
this.#where = 'drainFork';
// A `fork` leaves a child here for the host to start. Drained before
// the parent runs on, so the two are only ever one process apart --
// and before the memory is touched again, because what the child gets
// is a copy of it as `fork` left it.
await this.#drainFork();
this.#where = 'drainThread';
await this.#drainThread();
this.#where = 'resume';
// A process that has exited is not resumed, whatever the shim left in
// the resume slot. The shim clears it in `exit_process` now, and this
// is the same rule stated on the side that acts on it: re-entering a
@@ -4095,7 +4253,11 @@ export class Gex {
// asleep and the turn below runs that queue. -1 is "no global
// kernel to sleep on", and a network wait cannot sleep at all --
// its bytes arrive on this Worker's own event loop.
if (e.gex_paused_for_host() || e.gex_park(20) < 0) {
// The single-thread build parks for the network as well: the
// park is a promise, so the event loop delivers the bytes, and
// the network's activity wakes it (`onActivity`). A turn per
// waiting fiber instead spins the one thread every fiber shares.
if (single.on ? (await this.#parkP(20)) < 0 : (e.gex_paused_for_host() || e.gex_park(20) < 0)) {
// A network wait runs on this Worker's event loop, so it cannot
// sleep -- but it needs a turn, not a timer: a MessageChannel
// turn returns to the loop in microseconds where a nested
@@ -4522,6 +4684,68 @@ export class Gex {
* 'callback'): a watch stub reached inside an NT callback is not its
* call-out's business. */
#nest = [];
// --- The single-thread build (single.mjs, docs/single-thread.md) ---------
//
// Every guest thread is a fiber of one realm, its guest on a JSPI stack. A
// blocking call suspends that stack (`k_park`, below) where the default
// build waits in `Atomics.wait`, so wherever JS calls into wasm that may
// block it calls through `WebAssembly.promising`, and wherever wasm calls
// JS that calls back into wasm that may block, that JS is a
// `WebAssembly.Suspending` returning the inner call's promise.
#enterPf = null;
#enterPOf = null;
/** `enter`, on a stack of its own. */
#enterP(ctx, pc) {
if (this.#enterPOf !== this.#guest) {
this.#enterPf = WebAssembly.promising(this.#guest.exports.enter);
this.#enterPOf = this.#guest;
}
return this.#enterPf(ctx, pc);
}
#parkPf = null;
/** `gex_park`, on a stack of its own (it suspends in `k_park`). */
#parkP(ms) {
this.#parkPf ??= WebAssembly.promising(this.#shim.exports.gex_park);
return this.#parkPf(ms);
}
#sysPf = null;
/**
* A system call made from JS that wasm called (a wasm32 program's, a
* provider's): the shim runs on a stack of its own, so a wait inside it
* suspends that stack, and the returned promise suspends the caller's.
* `after` runs when it is done, before the caller goes on (a fork's image
* is taken there). Nested inside a call-out (JS frames below), nothing can
* suspend, so it runs as the default build's does.
*/
#suspendingSyscall(sync, after, mark = 0n) {
const e = this.#shim.exports;
return new WebAssembly.Suspending((ctx, pc) => {
if (this.#hardNested()) return sync(ctx, pc);
this.#sysPf ??= WebAssembly.promising(e.gex_syscall);
this.#where = 'syscall';
const p = this.#sysPf(ctx, mark ? BigInt.asIntN(64, BigInt(pc) | mark) : pc);
return p.then(() => {
this.#where = 'syscall-done';
after?.();
// A preemption point as well: a program that only makes system
// calls gives the other fibers a turn here, and `sched_yield` gives
// the woken one at once (single.mjs `Sched#settle`).
if (e.gex_last_syscall() === (globalThis.GEX_ARCH === 'x86' ? 24 : 124)) return sched.yieldNow();
return sched.maybeYield();
});
});
}
/** A safepoint (every 1024 back edges of translated code): the shim's,
* then a turn for the other fibers if the slice is over. */
#safepointS = null;
#singleSafepoint(e) {
return (this.#safepointS ??= new WebAssembly.Suspending((ctx, pc) => {
e.gex_safepoint(ctx, pc);
this.#where = 'safepoint';
return this.#hardNested() ? 0 : sched.maybeYield();
}));
}
#watchStub = null;
/** The function a watched table entry holds: it tells JS (`hit`) and
@@ -4623,6 +4847,70 @@ export class Gex {
* `#ntCallback`: a shim frame is below this one.
*/
#callOut(ctx, target, ret, spWant) {
return this.#drive(this.#callOutSteps(ctx, target, ret, spWant, false));
}
/**
* The single-thread build's call-out: the same loop, with the guest on a
* stack of its own (`WebAssembly.promising`), so guest code the call-out
* runs can block -- the import that asked is a `Suspending`, and the
* whole fiber waits (docs/single-thread.md §7).
*/
#callOutAsync(ctx, target, ret, spWant) {
return this.#driveAsync(this.#callOutSteps(ctx, target, ret, spWant, true));
}
/** Run a nested loop's steps (`['enter', ctx, pc]` / `['call', f, ...args]`)
* synchronously: an exception goes back into the loop at its step. */
#drive(g) {
let r = g.next();
while (!r.done) {
let v;
try {
const [op, a, b, c, d, x] = r.value;
v = op === 'enter' ? this.#guest.exports.enter(a, b) : a(b, c, d, x);
} catch (err) {
r = g.throw(err);
continue;
}
r = g.next(v);
}
return r.value;
}
/** The same, each step on a JSPI stack of its own that may suspend. */
async #driveAsync(g) {
let r = g.next();
while (!r.done) {
let v;
try {
const [op, a, b, c, d, x] = r.value;
v = op === 'enter' ? await this.#enterP(a, b) : await this.#promisingOf(a)(b, c, d, x);
} catch (err) {
r = g.throw(err);
continue;
}
r = g.next(v);
}
return r.value;
}
/** `WebAssembly.promising` of a wasm function, made once per function. */
#promising = new WeakMap();
#promisingOf(f) {
let p = this.#promising.get(f);
if (!p) this.#promising.set(f, (p = WebAssembly.promising(f)));
return p;
}
/** Whether a JS frame that cannot suspend is open (a nested loop run
* synchronously); `~` marks one that can (single.mjs). */
#hardNested() {
for (const n of this.#nest) if (!n.endsWith('~')) return true;
return false;
}
*#callOutSteps(ctx, target, ret, spWant, suspendable) {
const e = this.#shim.exports;
const sp0 = e.__stack_pointer.value;
const me = this.#callouts.length;
@@ -4632,7 +4920,7 @@ export class Gex {
// callee's arguments (cgen.rs NATIVE_UNWOUND, as natively).
c.unwound = c.ret === NATIVE_RETURN_PAD ? NATIVE_UNWOUND : CALLOUT_UNWOUND;
this.#callouts.push(c);
this.#nest.push('callout');
this.#nest.push(suspendable ? 'callout~' : 'callout');
this.#calloutsMade++;
if (globalThis.GEX_CALLOUT_TRACE || (globalThis.GEX_DENSE_DEBUG && ((this.#calloutsMade & (this.#calloutsMade - 1)) === 0 || this.#calloutsMade % 4096 === 0))) {
console.error(`[callout] #${this.#calloutsMade} depth ${me}: ${BigInt.asUintN(64, target).toString(16)} returning to ${c.ret.toString(16)}`);
@@ -4647,10 +4935,10 @@ export class Gex {
const f = next;
next = null;
e.gex_callout_leave_ok?.(me + 1);
f(ctx);
yield ['call', f, ctx];
} else {
e.gex_callout_leave_ok?.(me + 1);
this.#guest.exports.enter(ctx, pc);
yield ['enter', ctx, pc];
}
// A miss at a call-out's return with only tail calls since this
// `enter` returns instead of trapping (DISPATCH_MISS_LEAVE).
@@ -4945,7 +5233,9 @@ export class Gex {
if (len === 0) return;
const bytes = this.#bytes().slice(ptr, ptr + len);
const key = moduleContentKey(bytes);
this.#installModule(bytes, e.gex_jit_slots() >>> 0, 64, null, 'nt-callback', null, true);
const ukey = this.#unitKey(bytes);
this.#installModule(bytes, e.gex_jit_slots() >>> 0, 64, null, 'nt-callback', this.#unitGet(ukey), true);
this.#unitShare(ukey);
this.#noteExternal(bytes, key);
e.gex_jit_installed();
const count = e.gex_module_count ? e.gex_module_count() >>> 0 : this.#installed;
@@ -4959,6 +5249,42 @@ export class Gex {
}
}
/**
* With the page's unit cache (`?unitcache=1`, GEX_UNIT_CACHE): a
* translation's key in it, `u:` and its content key with the dense base
* word zeroed -- the one part of a unit's bytes that differs between the
* processes it is installed in (gex_emit::dense::GLOBAL) -- else null.
* `bytes` is a private copy; the word is put back.
*/
#unitKey(bytes) {
if (!globalThis.GEX_UNIT_CACHE || !(bytes instanceof Uint8Array)) return null;
const at = denseBaseAt(bytes);
if (at < 0) return `u:${moduleContentKey(bytes)}`;
const w = bytes.slice(at, at + 4);
bytes.fill(0, at, at + 4);
const k = moduleContentKey(bytes);
bytes.set(w, at);
return `u:${k}`;
}
/** The compiled module for `ukey`, counted (`unitHits`), or null. */
#unitGet(ukey) {
const m = (ukey && this.#sharedModules.get(ukey)) || null;
if (m) this.#unitHits++;
return m;
}
#unitHits = 0;
get unitHits() { return this.#unitHits; }
/** The module just installed, to the page's unit cache under `ukey`. */
#unitShare(ukey) {
if (!ukey || this.#sharedModules.has(ukey)) return;
const mod = this.#compiled[this.#compiled.length - 1]?.mod;
if (!mod) return;
this.#sharedModules.set(ukey, mod);
this.#env.shareModule?.(ukey, mod);
}
/** `#catchUp` (eager) without yielding, for `#ntCallback`. */
#catchUpSync() {
const e = this.#shim.exports;
@@ -5006,7 +5332,11 @@ export class Gex {
const check = globalThis.GEX_CHECK_INSTALL || (typeof process !== 'undefined' && process.env?.GEX_CHECK_INSTALL);
const bytes = this.#bytes().slice(ptr, ptr + len);
const key = moduleContentKey(bytes);
await this.#installModule(bytes, e.gex_jit_slots() >>> 0, 64, null, 'jit2878');
// A module another process compiled (a served unit) comes from the
// page's unit cache: instantiated, not compiled again.
const ukey = this.#unitKey(bytes);
await this.#installModule(bytes, e.gex_jit_slots() >>> 0, 64, null, 'jit2878', this.#unitGet(ukey));
this.#unitShare(ukey);
this.#noteExternal(bytes, key);
e.gex_jit_installed();
if (check && e.gex_miss_pc && e.gex_dispatch_slot && (e.gex_jit_pending_len() >>> 0) === 0) {
@@ -5054,6 +5384,15 @@ export class Gex {
async #installModule(bytes, need, segments = 64, meta = null, who = '?', precompiled = null, sync = false) {
const t0 = performance.now();
this.#installs++;
if (sync) {
// Timed here: the inner call has done all its work when it returns,
// and a `finally` after an `await` would run only once the guest
// next yields, counting everything it ran meanwhile (a node start-up
// reported hours of "installing").
const done = this.#installModuleInner(bytes, need, segments, meta, who, precompiled, sync);
this.#installMs += performance.now() - t0;
return done;
}
try {
// With `sync` the inner call never awaits, so the install is complete
// when this returns its promise -- what a nested loop inside an import
@@ -5116,6 +5455,11 @@ export class Gex {
throw err;
}
this.#compileMs += performance.now() - tc;
// Under Node, `GEX_DUMP_WASM=DIR` keeps every unit compiled, for
// looking at what a translation is made of (docs/jit-guest.md).
if (bytes instanceof Uint8Array && globalThis.process?.env?.GEX_DUMP_WASM) {
try { globalThis.process.getBuiltinModule('fs').writeFileSync(`${globalThis.process.env.GEX_DUMP_WASM}/u-${this.#shim.exports.gex_my_pid?.() ?? 0}-${moduleContentKey(bytes).replaceAll(':', '-')}.wasm`, bytes); } catch {}
}
// With its metadata: a fork hands the child `#compiled`, and without the
// metadata the child cannot tell which module covers which page and must
// instantiate all of them. The section is in the bytes; keeping it is a
@@ -5127,6 +5471,9 @@ export class Gex {
});
const imports = { ...this.#imports };
for (let i = 0; i < tables.length; i++) imports[TABLE_NAMES[i]] = tables[i];
// A runtime module imports its dense base (gex_emit::dense::GLOBAL)
// whether or not it has dense blocks; set below when it has.
imports.dense_base = 0;
// Dense: the module's blocks go to `blocks` at the base its bytes name,
// within what the process has numbered.
if (this.#guest?.exports?.blocks) {
@@ -5142,6 +5489,9 @@ export class Gex {
// Base 0 is a module nobody numbered: its blocks would land on the
// miss stub and its neighbours.
if (base === 0) throw new Error(`gex: a translation with unnumbered dense blocks reached the install (${who})`);
// Where a module places its dense segment by import rather than by a
// constant in its bytes (gex_emit::dense::GLOBAL).
imports.dense_base = base;
}
}
// What this module's own entries need per table, where known. `need` alone
+33 -4
View File
@@ -21,6 +21,8 @@ function narrate(text) {
* that already matched its output has stopped reading. A hook, not prose
* scraping, which fails open. See task 146.
*/
import { single } from './single.mjs';
function crashed(what) {
try {
globalThis.GEX_ON_CRASH?.(what);
@@ -59,6 +61,8 @@ const EARLY = Symbol('gex.early');
* code is larger still; python3.13 alone is 24 MiB of wasm). */
const PAGE_MODULES = 64;
const PAGE_MODULE_BYTES = 192 << 20;
/** The most wasm the page-wide unit cache (`#unitModules`) keeps compiled. */
const UNIT_MODULE_BYTES = 256 << 20;
/** A `wasm:` key's module size: the key is `wasm:LENGTH:HASH:HASH`. */
const keyBytes = (k) => Number(String(k).split(':')[1]) || 0;
@@ -71,7 +75,9 @@ export class ProcessHost {
/// parent that waited for its child could otherwise print first.
#outputAcks = new WeakMap();
#outputAck(w) {
if (typeof SharedArrayBuffer === 'undefined') return undefined;
// Fibers (the single-thread build) deliver in order through one queue,
// and could not wait for an ack on the thread that delivers it anyway.
if (single.on || typeof SharedArrayBuffer === 'undefined') return undefined;
const a = new Int32Array(new SharedArrayBuffer(4));
this.#outputAcks.set(w, a);
return a.buffer;
@@ -97,10 +103,17 @@ export class ProcessHost {
// content (length plus two 32-bit hashes of every byte), never by path, so
// a file apt replaced in place is compiled afresh.
#pageModules = new Map();
// Compiled translation units (content keys `len:a:b`) for every process of
// the page, with `extra.unitCache`: a process served units another one
// built instantiates the other's compiled code instead of compiling the
// same bytes again. Most recent first out, to UNIT_MODULE_BYTES of wasm.
#unitModules = new Map();
#unitBytes = 0;
/** What a starting Worker gets: its process's modules and the page's. */
#sharedFor(pid, withProcess) {
const own = withProcess ? [...(this.#moduleCache.get(String(pid).split(':')[0])?.entries() ?? [])] : [];
return [...own, ...this.#pageModules.entries()];
// And the page's translation units (empty unless `extra.unitCache`).
return [...own, ...this.#pageModules.entries(), ...this.#unitModules.entries()];
}
/// When each process was asked for. See `#spawn`.
#asked = new Map();
@@ -479,6 +492,21 @@ export class ProcessHost {
this.#pageModules.delete(oldest);
}
}
// A translation unit for the page's cache (`Gex#unitKey`): kept
// for processes to come, not relayed -- the process's own Workers
// share it by its content key.
if (typeof m.key === 'string' && m.key.startsWith('u:')) {
if (this.#extra?.unitCache && !this.#unitModules.has(m.key)) {
this.#unitModules.set(m.key, m.mod);
this.#unitBytes += Number(m.key.split(':')[1]);
while (this.#unitBytes > UNIT_MODULE_BYTES && this.#unitModules.size > 1) {
const oldest = this.#unitModules.keys().next().value;
this.#unitBytes -= Number(oldest.split(':')[1]);
this.#unitModules.delete(oldest);
}
}
break;
}
if (typeof m.key === 'string' && !cache.has(m.key)) {
cache.set(m.key, m.mod);
if (this.#extra?.shareTrace) console.warn(`[share] host cached ${m.key.slice(0, 30)} from ${pid} (${cache.size} for process ${proc})`);
@@ -494,7 +522,7 @@ export class ProcessHost {
const c = m.child?.forkCost;
if (c) {
const hop1 = Math.round(performance.timeOrigin + performance.now() - c.sentAt);
console.log(
narrate(
`[gex] fork -> pid ${m.child.pid}: image ${(c.bytes / 1048576).toFixed(1)} MiB`
+ ` in ${c.regions} regions, built ${c.buildMs}ms,`
+ ` ${hop1}ms to reach the host`,
@@ -656,8 +684,9 @@ export class ProcessHost {
? `, waited park=${m.waits.park} turn=${m.waits.turn} nap=${m.waits.nap}`
: '') +
(m.byObject ? ` [${m.byObject}]` : '') +
(m.xlate ? ` xlate[insns=${m.xlate[0]} wasmKiB=${Math.round(m.xlate[1] / 1024)} units=${m.xlate[2]} smc=${m.xlate[3]} retiredPages=${m.xlate[4]} retires=${m.xlate[5]} relift=${m.xlate[6]}blk/${Math.round((m.xlate[8] ?? 0) / 1024)}KiB anon=${m.xlate[7]}blk/${Math.round((m.xlate[9] ?? 0) / 1024)}KiB]` : '') +
(m.installs ? `, ${m.installMs}ms installing ${m.installs} modules` +
` (${m.compileMs}ms compile, ${m.instantiateMs}ms instantiate, ${m.growMs}ms grow)` : '') +
` (${m.compileMs}ms compile${m.unitHits ? `, ${m.unitHits} from the unit cache` : ''}, ${m.instantiateMs}ms instantiate, ${m.growMs}ms grow)` : '') +
`, worker start ${(m.shimCompileMs ?? 0) + (m.shimInstMs ?? 0) + (m.guestInstMs ?? 0)}ms` + ` (shim ${m.shimCompileMs}c/${m.shimInstMs}i, guest ${m.guestInstMs}i)` +
(m.loader && m.loader.calls
? `, ld.so ${m.loader.calls} syscalls, last at ${m.loader.doneMs}ms`
+11 -1
View File
@@ -17,6 +17,9 @@ const CLOSED = 3;
export class NodeNetwork {
#sockets = new Map();
#next = 1;
/** Called on every socket event: the single-thread build wakes its parked
* fibers with it (as web/src/net.js's WispNetwork does). */
onActivity = () => {};
/** A fresh identifier; the guest connects it next. */
open() {
@@ -30,20 +33,27 @@ export class NodeNetwork {
if (!s) return;
const sock = createConnection({ host, port }, () => {
s.status = OPEN;
this.onActivity();
});
sock.on('data', (d) => {
s.rx.push(d);
this.onActivity();
});
sock.on('data', (d) => s.rx.push(d));
sock.on('end', () => {
s.eof = true;
this.onActivity();
});
// A refusal and a reset are the same to the guest: the connection is not
// there. Distinguishing them would only matter if anything asked.
sock.on('error', () => {
s.status = s.status === CONNECTING ? REFUSED : CLOSED;
s.eof = true;
this.onActivity();
});
sock.on('close', () => {
if (s.status === CONNECTING) s.status = REFUSED;
s.eof = true;
this.onActivity();
});
s.sock = sock;
}
+38 -4
View File
@@ -31,10 +31,19 @@
// GEX_WEB_DIR: the directory holding the shim, kernel and seed (default
// web/public/gex of this tree; an ejected project's runtime/web).
import { lstatSync, readdirSync, readFileSync, statSync, writeFileSync, writeSync } from 'node:fs';
import { appendFileSync, lstatSync, readdirSync, readFileSync, statSync, writeFileSync, writeSync } from 'node:fs';
import { basename, dirname, join } from 'node:path';
import { Worker } from 'node:worker_threads';
import { ProcessHost } from './host.mjs';
import { enableSingle, FiberWorker, sched } from './single.mjs';
// GEX_SINGLE=1: the single-thread build (docs/single-thread.md) -- every
// process and thread a fiber on this thread, no SharedArrayBuffer, the shared
// build's binaries on plain memories. Before anything is compiled.
const SINGLE = !!process.env.GEX_SINGLE;
if (SINGLE) {
enableSingle();
await import('./worker.mjs');
}
// `GEX_ARCH=x86` runs the x86-64 guest: the `.x86`-infixed shim and kernel
// (see shims/browser/build.sh), and `globalThis.GEX_ARCH` for the glue.
const ARCH_INFIX = process.env.GEX_ARCH === 'x86' ? '.x86' : '';
@@ -198,7 +207,7 @@ function readStdin() {
// `shared` flag is part of its type, so the shim, seed, kernel and every
// closure module must be the shared ones together. That is the configuration
// the page ships and the one a fork breaks in.
const SHARED = !!process.env.GEX_SHARED;
const SHARED = !!process.env.GEX_SHARED || SINGLE;
const PUB = process.env.GEX_WEB_DIR
? new URL(`file://${process.env.GEX_WEB_DIR.replace(/\/?$/, '/')}`)
: new URL('../../../web/public/gex/', import.meta.url);
@@ -277,7 +286,7 @@ function readClosure() {
}
const host = new ProcessHost({
makeWorker: () => new Worker(new URL('./worker.mjs', import.meta.url),
makeWorker: SINGLE ? () => new FiberWorker() : () => new Worker(new URL('./worker.mjs', import.meta.url),
// GEX_CPU_PROF=<dir>: write a V8 CPU profile per Worker, to see where the
// guest's time actually goes (which translated library functions, JS
// overhead, GC). Off by default -- it slows the run and writes a file each.
@@ -332,11 +341,29 @@ const host = new ProcessHost({
inPlace: process.env.GEX_IN_PLACE === '0' ? 0 : undefined,
excTrace: !!process.env.GEX_EXC_TRACE,
// GEX_FJIT=1: the function JIT (gexc run --fjit's browser half).
// GEX_LIFT_MAX=N: blocks one dispatch miss may lift (default 2048).
liftMax: process.env.GEX_LIFT_MAX ? Number(process.env.GEX_LIFT_MAX) : undefined,
// GEX_INLINE_DISPATCH=1: dispatch inline in every block (gex-emit's old form).
inlineDispatch: process.env.GEX_INLINE_DISPATCH ? 1 : undefined,
// GEX_XLATE_REPORT=N: a translator progress line every N translations.
xlateReport: process.env.GEX_XLATE_REPORT ? Number(process.env.GEX_XLATE_REPORT) : undefined,
// GEX_DISCOVER_SKIP=bits: edges discovery does not follow (1 call returns,
// 2 folded call targets, 4 successors).
discoverSkip: process.env.GEX_DISCOVER_SKIP ? Number(process.env.GEX_DISCOVER_SKIP) : undefined,
// GEX_T1_FOLD=1: tier-1's tee folding (gex-emit `set_t1_fold`).
t1Fold: process.env.GEX_T1_FOLD ? 1 : undefined,
// GEX_UNIT_CACHE=1: compiled translation units shared by every process.
unitCache: process.env.GEX_UNIT_CACHE ? 1 : undefined,
// GEX_SHARE_TRACE=1: how compiled modules are shared, host side too.
shareTrace: process.env.GEX_SHARE_TRACE ? 1 : undefined,
fjit: process.env.GEX_FJIT ? Number(process.env.GEX_FJIT) || 1 : undefined,
fjitMax: process.env.GEX_FJIT_MAX ? Number(process.env.GEX_FJIT_MAX) : undefined,
fjitOnly: process.env.GEX_FJIT_ONLY ? Number(process.env.GEX_FJIT_ONLY) : undefined,
// GEX_PROVIDERS=0: no ELF export providers (A/B runs; cgen/elfprov.rs).
providers: process.env.GEX_PROVIDERS === '0' ? 0 : undefined,
// GEX_ELF_UNITS=name,...: shared objects that get their function-tier
// unit from the cache at the bias they map at (the page's `?elfunits=`).
elfUnits: process.env.GEX_ELF_UNITS || undefined,
// GEX_LAZY_THREAD: spawned threads register the process's modules lazily
// (instance on first branch) instead of installing them all up front.
lazyThread: !!process.env.GEX_LAZY_THREAD,
@@ -412,6 +439,8 @@ const host = new ProcessHost({
else writeSync(fd, typeof bytes === 'string' ? Buffer.from(bytes) : bytes);
},
onExit: (m, pid) => {
// GEX_DL_FILE=PATH: the discovery log (GEX_DISCOVER_SKIP=256), to PATH.PID.
if (process.env.GEX_DL_FILE && m.dl) appendFileSync(`${process.env.GEX_DL_FILE}.${pid}`, m.dl);
// How much of the run was translating vs. running translated code. Per
// process, because a program that forks pays it again.
if (process.env.GEX_TIME_JIT) {
@@ -425,7 +454,7 @@ const host = new ProcessHost({
(m.liftWhy && m.liftWhy.some((n) => n) ? ` why[nofill=${m.liftWhy[0]} unowned=${m.liftWhy[1]} asked=${m.liftWhy[2]} fresh=${m.liftWhy[3]}]` : '') +
(m.byObject ? ` [${m.byObject}]` : '') +
(m.installs ? `, ${m.installMs}ms installing ${m.installs} modules` +
` (${m.compileMs}ms compile, ${m.instantiateMs}ms instantiate, ${m.growMs}ms grow)` : '') +
` (${m.compileMs}ms compile${m.unitHits ? `, ${m.unitHits} from the unit cache` : ''}, ${m.instantiateMs}ms instantiate, ${m.growMs}ms grow)` : '') +
`, worker start ${(m.shimCompileMs ?? 0) + (m.shimInstMs ?? 0) + (m.guestInstMs ?? 0)}ms` + ` (shim ${m.shimCompileMs}c/${m.shimInstMs}i, guest ${m.guestInstMs}i)` +
// Where the shared-slot lookup went, to say why a forked and exec'd
// child lifts its parent's libraries again.
@@ -433,6 +462,8 @@ const host = new ProcessHost({
? `, reuse have=${m.reuse[0]} hit=${m.reuse[1]} miss=${m.reuse[2]} noident=${m.reuse[3]}` +
` served=${m.reuse[4]} stored=${m.reuse[5]} refused=${m.reuse[6]} unclaimed=${m.reuse[7]} allmine=${m.reuse[8]}`
: '') +
(m.byObject ? ` [${m.byObject}]` : '') +
(m.xlate ? ` xlate[insns=${m.xlate[0]} wasmKiB=${Math.round(m.xlate[1] / 1024)} units=${m.xlate[2]} smc=${m.xlate[3]} retiredPages=${m.xlate[4]} retires=${m.xlate[5]} relift=${m.xlate[6]}blk/${Math.round((m.xlate[8] ?? 0) / 1024)}KiB anon=${m.xlate[7]}blk/${Math.round((m.xlate[9] ?? 0) / 1024)}KiB]` : '') +
'\n',
);
}
@@ -541,4 +572,7 @@ try {
// `process.exitCode`, not `process.exit`: the guest's output goes through
// `process.stdout`/`process.stderr` (async when a pipe), and `process.exit`
// does not wait for them to drain. Setting the code lets node finish the writes.
if (SINGLE && process.env.GEX_SINGLE_STATS) {
process.stderr.write(`[single] fibers ${FiberWorker.count}, sched ${JSON.stringify(sched.stats)}\n`);
}
process.exitCode = status;
+483
View File
@@ -0,0 +1,483 @@
// The single-thread build: every guest process and thread on one JS thread,
// with no SharedArrayBuffer (docs/single-thread.md).
//
// The default build gives each guest thread a Worker and shares state through
// SharedArrayBuffers: the process's memory between its threads' Workers, the
// global kernel's memory between every Worker, and `Atomics.wait` for every
// blocking call. A page that is not cross-origin isolated has none of that.
// Here instead:
//
// - every memory is a plain one, held by reference: a process's threads are
// instances on one memory, every process's kernel instance is on the one
// kernel memory, all in this realm;
// - a guest thread is a fiber: a `FiberWorker` runs the same Worker code
// (process.mjs) in this realm, and its guest runs on a JSPI stack
// (`WebAssembly.promising`), so a blocking call suspends that stack
// (`WebAssembly.Suspending`) where the default build would `Atomics.wait`;
// - `Sched` stands in for the wait/notify words: a parked fiber is a
// promise, a wake resolves it.
//
// The binaries are the shared build's (threads need its per-instance stacks
// and thread-locals), with the memory imports' `shared` flag cleared as they
// are compiled: an atomic instruction is valid on a plain memory, and the
// only one that is not (`memory.atomic.wait32`) is never reached, because a
// fiber never waits inside wasm.
export const single = {
/** Whether this realm runs the single-thread build. */
on: false,
};
const SUSPENDING = typeof WebAssembly.Suspending === 'function';
/** Whether this engine can run the single-thread build at all (JSPI). */
export function singleSupported() {
return SUSPENDING && typeof WebAssembly.promising === 'function';
}
// ---------------------------------------------------------------------------
// Binaries: the shared build's modules on plain memories.
function leb(bytes, at) {
let v = 0, shift = 0, b;
do {
b = bytes[at++];
v += (b & 0x7f) * 2 ** shift;
shift += 7;
} while (b & 0x80);
return [v, at];
}
/** Skip a `limits` (memory type), returning the offset after it. */
function skipLimits(bytes, at) {
const flags = bytes[at++];
let x;
[x, at] = leb(bytes, at);
if (flags & 1) [x, at] = leb(bytes, at);
// A custom page size (0x08): one more LEB.
if (flags & 8) [x, at] = leb(bytes, at);
return at;
}
function skipTableType(bytes, at) {
// reftype: a byte (funcref 0x70, externref 0x6f) or a (ref null? ht) form.
const t = bytes[at++];
if (t === 0x63 || t === 0x64) [, at] = leb(bytes, at);
return skipLimits(bytes, at);
}
function skipGlobalType(bytes, at) {
const t = bytes[at++];
if (t === 0x63 || t === 0x64) [, at] = leb(bytes, at);
return at + 1; // mutability
}
/**
* The offsets of every memory type's flags byte in `bytes` (imported and
* defined memories), or null when the module cannot be read. Only the import
* and memory sections are parsed.
*/
function memoryFlagOffsets(bytes) {
if (bytes.length < 8 || bytes[0] !== 0 || bytes[1] !== 0x61 || bytes[2] !== 0x73 || bytes[3] !== 0x6d) return null;
const out = [];
let at = 8;
while (at < bytes.length) {
const id = bytes[at++];
let size;
[size, at] = leb(bytes, at);
const end = at + size;
if (id === 2) {
let n, p = at;
[n, p] = leb(bytes, p);
for (let i = 0; i < n; i++) {
let len;
[len, p] = leb(bytes, p); p += len; // module
[len, p] = leb(bytes, p); p += len; // name
const kind = bytes[p++];
if (kind === 0) [, p] = leb(bytes, p);
else if (kind === 1) p = skipTableType(bytes, p);
else if (kind === 2) { out.push(p); p = skipLimits(bytes, p); }
else if (kind === 3) p = skipGlobalType(bytes, p);
else if (kind === 4) { p++; [, p] = leb(bytes, p); }
else return null;
}
} else if (id === 5) {
let n, p = at;
[n, p] = leb(bytes, p);
for (let i = 0; i < n; i++) { out.push(p); p = skipLimits(bytes, p); }
}
// Memories come before code; nothing past the code section matters.
if (id === 10 || id === 11) break;
at = end;
}
return out;
}
/**
* `bytes` with every memory made plain: the same module if none was shared,
* a patched copy otherwise. Accepts any BufferSource.
*/
export function unshare(bytes) {
let u8;
if (bytes instanceof Uint8Array) u8 = bytes;
else if (ArrayBuffer.isView(bytes)) u8 = new Uint8Array(bytes.buffer, bytes.byteOffset, bytes.byteLength);
else if (bytes instanceof ArrayBuffer) u8 = new Uint8Array(bytes);
else return bytes;
const offs = memoryFlagOffsets(u8);
if (!offs || !offs.some((o) => u8[o] & 2)) return bytes;
const copy = u8.slice();
for (const o of offs) copy[o] &= ~2;
return copy;
}
/**
* Turn the single-thread build on for this realm: every module compiled here
* is unshared on the way in, and every memory made here is plain. Patched at
* the engine's door rather than at the fifty-odd places that compile, so no
* path can forget.
*/
export function enableSingle() {
if (single.on) return;
if (!singleSupported()) throw new Error('the single-thread build needs JSPI (WebAssembly.Suspending)');
single.on = true;
const W = WebAssembly;
const { compile, instantiate, validate } = W;
const Module = W.Module;
const Memory = W.Memory;
W.compile = (b, ...rest) => compile.call(W, unshare(b), ...rest);
W.validate = (b, ...rest) => validate.call(W, unshare(b), ...rest);
W.instantiate = (b, ...rest) =>
instantiate.call(W, b instanceof Module ? b : unshare(b), ...rest);
if (W.compileStreaming) {
W.compileStreaming = async (src, ...rest) =>
compile.call(W, unshare(new Uint8Array(await (await src).arrayBuffer())), ...rest);
W.instantiateStreaming = async (src, ...rest) =>
instantiate.call(W, unshare(new Uint8Array(await (await src).arrayBuffer())), ...rest);
}
// `new WebAssembly.Module(bytes)` and `instanceof WebAssembly.Module` both
// have to keep working: a subclass does both.
// `Symbol.hasInstance`, because the modules the engine makes (`compile`)
// are instances of the original class, not of the subclass.
class PlainModule extends Module {
constructor(b, ...rest) { super(unshare(b), ...rest); }
static [Symbol.hasInstance](x) { return x instanceof Module; }
}
W.Module = PlainModule;
class PlainMemory extends Memory {
constructor(desc) { super(desc?.shared ? { ...desc, shared: false } : desc); }
static [Symbol.hasInstance](x) { return x instanceof Memory; }
}
W.Memory = PlainMemory;
}
// ---------------------------------------------------------------------------
// The scheduler: what `Atomics.wait`/`notify` on the kernel's wake words were.
/** Longest a run of fibers may keep the event loop from turning, in ms. */
const SLICE_MS = 8;
class Waiter {
constructor(resolve, futex, fiber) {
this.resolve = resolve;
this.futex = futex; // a BigInt address, or null for the global word
this.fiber = fiber;
this.timer = 0;
this.done = false;
}
}
export class Sched {
/** Fibers parked on the global wake counter. */
#global = new Set();
/** Fibers parked on a futex, by address. */
#futex = new Map();
/** Resolutions held back for a macrotask (see `#resume`). */
#held = [];
/** Woken waiters not yet resumed: they run when the running fiber parks,
* or at the next turn of the event loop (see `#settle`). */
#ready = [];
#turnPosted = false;
#mc = null;
/** When the event loop last had a turn. */
#lastTurn = performance.now();
/** Counters, for the doc and for a stuck run. */
stats = { parks: 0, futexParks: 0, wakes: 0, yields: 0, macroTurns: 0, timeouts: 0, flushes: 0 };
constructor() {
if (typeof MessageChannel === 'function') {
this.#mc = new MessageChannel();
this.#mc.port1.onmessage = () => this.#onTurn();
this.#mc.port1.start?.();
// Under Node, a port keeps the process up only while a turn is posted
// (see `#postTurn`): never the reason it stays up after the guest.
this.#mc.port1.unref?.();
}
}
#onTurn() {
this.#turnPosted = false;
this.#mc?.port1.unref?.();
this.#lastTurn = performance.now();
this.stats.macroTurns++;
const held = this.#held;
const ready = this.#ready;
this.#held = [];
this.#ready = [];
for (const f of ready) f();
for (const f of held) f();
}
#postTurn() {
if (this.#turnPosted) return;
this.#turnPosted = true;
// Held while posted: an unref'd port lets Node exit with the turn (and
// every fiber waiting on it) still in the queue.
this.#mc?.port1.ref?.();
if (this.#mc) this.#mc.port2.postMessage(0);
else setTimeout(() => this.#onTurn(), 0);
}
/** Whether the fibers have held the event loop for a whole slice. */
sliceOver() {
return performance.now() - this.#lastTurn >= SLICE_MS;
}
/**
* Resume a fiber: at once (a microtask, after the running fiber suspends)
* while the slice lasts, and after a turn of the event loop once it has
* run out -- or a ping-pong between two processes would never let a key,
* a WebSocket message or a timer through.
*/
#resume(f) {
if (!this.sliceOver()) f();
else {
this.#held.push(f);
this.#postTurn();
}
}
#settle(w, why) {
if (w.done) return;
w.done = true;
if (w.timer) clearTimeout(w.timer);
if (w.futex === null) this.#global.delete(w);
else {
const s = this.#futex.get(w.futex);
s?.delete(w);
if (s && !s.size) this.#futex.delete(w.futex);
}
// Not resumed yet. A wake comes from the running fiber's system call
// (every one bumps the counter), and resolving here would queue the
// woken fiber's microtask ahead of the waker's own return from that call:
// a writer and its reader would trade places on every write instead of
// the writer filling the pipe first. So the woken wait until the running
// fiber parks (`#flush`), or until every fiber is blocked on something
// else and the event loop turns -- the posted turn. A wake from outside
// any fiber (a timer, the network, a key) gets that turn at once.
this.#ready.push(() => w.resolve(why));
this.#postTurn();
}
/** The running fiber is about to suspend: the woken go now. */
#flush() {
if (!this.#ready.length) return;
this.stats.flushes++;
const ready = this.#ready;
this.#ready = [];
for (const f of ready) this.#resume(f);
}
#park(futex, ns, fiber) {
return new Promise((resolve) => {
const w = new Waiter(resolve, futex, fiber);
if (futex === null) this.#global.add(w);
else {
let s = this.#futex.get(futex);
if (!s) this.#futex.set(futex, (s = new Set()));
s.add(w);
}
// `ns` bounds every wait, as it bounds `memory.atomic.wait32`.
const ms = Math.max(0, Number(ns) / 1e6);
if (Number.isFinite(ms) && ms < 2 ** 31) {
w.timer = setTimeout(() => { this.stats.timeouts++; this.#settle(w, 2); }, ms);
}
this.#flush();
});
}
/** Sleep until the global counter is bumped, or `ns` pass: 0 woken, 2 timed out. */
park(ns, fiber = null) {
this.stats.parks++;
return this.#park(null, ns, fiber);
}
/** Sleep until futex `addr` is woken, or `ns` pass. */
parkFutex(addr, ns, fiber = null) {
this.stats.futexParks++;
return this.#park(BigInt.asUintN(64, BigInt(addr)), ns, fiber);
}
/** The global counter moved: everyone parked on it re-checks. */
wake() {
if (!this.#global.size) return;
this.stats.wakes++;
for (const w of [...this.#global]) this.#settle(w, 0);
}
/** A futex was woken (and with it the global counter). */
wakeFutex(addr) {
const s = this.#futex.get(BigInt.asUintN(64, BigInt(addr)));
if (s) for (const w of [...s]) this.#settle(w, 0);
this.wake();
}
/** Forget a fiber's waits (its Worker was terminated). */
drop(fiber) {
for (const w of [...this.#global]) if (w.fiber === fiber) { w.done = true; clearTimeout(w.timer); this.#global.delete(w); }
for (const [k, s] of this.#futex) {
for (const w of [...s]) if (w.fiber === fiber) { w.done = true; clearTimeout(w.timer); s.delete(w); }
if (!s.size) this.#futex.delete(k);
}
}
/**
* A preemption point (a guest's safepoint, a wasm32 program's system call):
* nothing when the slice lasts, a promise for the next turn of the event
* loop when it has run out.
*/
maybeYield() {
if (!this.sliceOver()) return 0;
this.stats.yields++;
return new Promise((resolve) => {
this.#held.push(() => resolve(0));
this.#postTurn();
});
}
/**
* The running fiber asked to give way (`sched_yield`): the woken go first,
* then it. Without this a guest spinning on `sched_yield` for a fiber it
* just woke would wait out the slice.
*/
yieldNow() {
if (!this.#ready.length) return this.maybeYield();
this.stats.yields++;
this.#flush();
return Promise.resolve(0);
}
/** How many fibers are parked, for a census. */
get parked() {
let n = this.#global.size;
for (const s of this.#futex.values()) n += s.size;
return n;
}
}
/** The one scheduler of this realm. */
export const sched = new Sched();
// ---------------------------------------------------------------------------
// Fibers: a Worker's code, run in this realm.
/**
* Every message between a host and its fibers, in one queue: a child's
* output and its parent's must reach the host in the order they were sent,
* which separate Worker ports never promised (and `drainOutput` existed for).
*/
const queue = [];
let draining = false;
function deliver(f) {
queue.push(f);
if (draining) return;
draining = true;
queueMicrotask(drain);
}
function drain() {
// Bounded per turn: a guest that writes without end must not starve the
// event loop of its own keyboard.
const t0 = performance.now();
while (queue.length) {
const f = queue.shift();
try {
f();
} catch (e) {
console.error('[gex] a fiber message handler threw:', e);
}
if (performance.now() - t0 > SLICE_MS && queue.length) {
setTimeout(drain, 0);
return;
}
}
draining = false;
}
/** What `workerMain` (process.mjs) runs a fiber's side through. */
let workerMain = null;
/**
* A Worker that is a fiber of this realm: the same `postMessage` /
* `addEventListener` / `terminate` as a Web Worker, so `ProcessHost` drives
* it unchanged. Messages are not cloned: everything (a memory, a module, a
* fork's image) is passed by reference, which is the point.
*/
export class FiberWorker {
#onMessage = [];
#onError = [];
#toFiber = null;
#early = [];
dead = false;
static count = 0;
constructor() {
FiberWorker.count++;
const port = {
post: (m) => deliver(() => {
if (this.dead) return;
for (const f of this.#onMessage) f({ data: m });
}),
on: (f) => {
this.#toFiber = f;
const early = this.#early;
this.#early = [];
for (const m of early) deliver(() => f(m));
},
fiber: this,
};
if (!workerMain) throw new Error('fiber.mjs: process.mjs has not registered workerMain');
Promise.resolve().then(() => workerMain(port)).catch((e) => {
for (const f of this.#onError) f(e);
});
}
postMessage(m) {
if (this.dead) return;
if (!this.#toFiber) { this.#early.push(m); return; }
const f = this.#toFiber;
deliver(() => f(m));
}
addEventListener(type, f) {
if (type === 'message') this.#onMessage.push(f);
else if (type === 'error') this.#onError.push(f);
}
removeEventListener(type, f) {
const l = type === 'message' ? this.#onMessage : this.#onError;
const i = l.indexOf(f);
if (i >= 0) l.splice(i, 1);
}
terminate() {
if (this.dead) return;
this.dead = true;
sched.drop(this);
}
}
/** process.mjs hands itself over here, so this file does not import it. */
export function registerWorkerMain(f) {
workerMain = f;
}
+13 -2
View File
@@ -13,6 +13,11 @@
// Without a `SharedArrayBuffer` there is no global kernel: `open` answers null
// and the caller keeps its own line discipline.
import { lazyFetch } from './lazy-fetch.mjs';
import { single, sched } from './single.mjs';
/** The single-thread build's fibers park on the scheduler, not on the
* kernel's wake word: whatever this file changes in the kernel wakes them. */
const poke = () => { if (single.on) sched.wake(); };
/// Where the page writes bytes for the kernel to copy out of. The kernel
/// imports the calling process's memory and addresses it with `memory.copy`;
@@ -125,6 +130,7 @@ export class PageTty {
this.#bytes().set(chunk, SCRATCH);
queued = this.#k.k_tty_input(SCRATCH, chunk.length);
}
poke();
return queued;
}
@@ -161,6 +167,7 @@ export class PageTty {
eof() {
this.#k.k_stdin_eof();
this.#k.k_wake?.();
poke();
}
/** `c_lflag`, which says whether the guest has taken over line editing. */
@@ -186,7 +193,9 @@ export class PageTty {
*/
resize(rows, cols) {
const packed = (BigInt(rows & 0xffff) << 48n) | (BigInt(cols & 0xffff) << 32n);
return this.#k.k_tty_resize(packed) !== 0;
const r = this.#k.k_tty_resize(packed) !== 0;
poke();
return r;
}
/** The foreground process group, or 0 before a shell has claimed one. */
@@ -196,6 +205,8 @@ export class PageTty {
/** Raise a signal on the foreground group, for what no key stands for. */
signal(signo) {
return this.#k.k_tty_signal(signo >>> 0);
const r = this.#k.k_tty_signal(signo >>> 0);
poke();
return r;
}
}
+4 -1
View File
@@ -115,7 +115,10 @@ const ringBase = (b) => M_WORDS + BLOCKS * BLOCK_WORDS + b * RING_WORDS;
/** The shared arena a compositor and its guests share. */
export function makeWlChannel() {
return { arena: new SharedArrayBuffer(TOTAL_WORDS * 4) };
// A plain buffer in the single-thread build, where the compositor and every
// guest are one realm (docs/single-thread.md).
const Buf = typeof SharedArrayBuffer === 'function' ? SharedArrayBuffer : ArrayBuffer;
return { arena: new Buf(TOTAL_WORDS * 4) };
}
/** Tell the guests how large the output is, so a toplevel is configured to it. */
+58 -6
View File
@@ -66,7 +66,8 @@ function forkTransfer(child) {
// Static, not a run-time specifier: a bundler cannot resolve `import(msg.url)`,
// so the built Worker chunk 404'd on a bare `./gex.mjs` and rejected where
// nobody listened. This file and `gex.mjs` are siblings everywhere.
import { Gex, Env, utf8 } from './gex.mjs';
import { Gex, Env, utf8, narrate } from './gex.mjs';
import { single, registerWorkerMain, sched } from './single.mjs';
// GEX_WATCH_DIR=<page>: narrate every write to that page's directory entry.
if (typeof process !== 'undefined' && process.env?.GEX_WATCH_DIR) {
Gex.watchDir = Number(process.env.GEX_WATCH_DIR);
@@ -88,7 +89,11 @@ if (typeof process !== 'undefined' && (process.env?.GEX_TRACE_BLOCKS || process.
console.debug = sync;
}
const port = await channel();
// Everything below is one Worker's: its port, its process, its state. A
// function rather than module scope so the single-thread build (single.mjs)
// can run many of these in one realm, one per fiber; a real Worker runs one.
async function workerMain(port) {
// This Worker's runtime, once its start message builds it. Module-scope so a
// `sharedModule` message (relayed sibling-Worker compiles) can reach it.
@@ -419,6 +424,7 @@ async function handle(msg) {
if (msg.fjitLibs) Gex.fjitLibs = msg.fjitLibs;
if (msg.calloutTrace) globalThis.GEX_CALLOUT_TRACE = 1;
if (msg.lazyThread) globalThis.GEX_LAZY_THREAD = 1;
if (msg.unitCache) globalThis.GEX_UNIT_CACHE = 1;
// `kernelStack` is this Worker's slice of the kernel's stack array. Absent for
// the first Worker, which keeps the linked-in stack and reads the numbers out.
const gex = new Gex(new WorkerEnv(), {
@@ -442,7 +448,16 @@ async function handle(msg) {
// The GL device (gles-gears): the page's GlDevice, reached over a ring in a
// SharedArrayBuffer. `useGl` sets the flag `gex_set_gl` reads at load, so it
// must run before `load` -- which mounts /dev/gex-gl only when the flag is on.
if (msg.glChannel) {
if (msg.glChannel?.inRealm) {
// The single-thread build: the compositor's Mesa is in this realm
// (web/src/compositor-worker.js `glInRealm`), and a submit is a promise
// the Suspending `gl_submit` waits on. A batch is run straight out of
// this process's memory: no copy, and no batch in flight unwaited.
gex.useGl({
submit: (memory, ring, client) =>
globalThis.__gexGlInRealm ? globalThis.__gexGlInRealm.submit(memory, ring, client) : -1,
});
} else if (msg.glChannel) {
const { GlProxy } = await import('./gl-bridge.mjs');
gex.useGl(new GlProxy(msg.glChannel, () => {
// An OffscreenCanvas renderer waits on the control word directly. Keep
@@ -566,9 +581,16 @@ async function handle(msg) {
// minutes instead of twenty.
if (msg.network) {
const { NodeNetwork } = await import('./net-node.mjs');
gex.useNetwork(new NodeNetwork());
const net = new NodeNetwork();
if (single.on) net.onActivity = () => sched.wake();
gex.useNetwork(net);
}
if (msg.netChannel) {
if (msg.wispNet) {
// The single-thread build: the system's one Wisp connection, the object
// itself (every fiber is in its realm). Its traffic wakes parked fibers.
msg.wispNet.onActivity = () => sched.wake();
gex.useNetwork(msg.wispNet);
} else if (msg.netChannel) {
// The page's Wisp connection, called across shared memory (net-bridge.mjs):
// a wasm32 program never hands this Worker's event loop back, so a
// WebSocket of its own here would never deliver.
@@ -600,7 +622,7 @@ async function handle(msg) {
({ entry, ctx } = await gex.adoptThread(msg.shim, msg.guest, msg));
_t(`adopt done entry=0x${(entry??0n).toString(16)}, entering run`);
} else if (msg.kind === 'adopt') {
console.debug(`[worker] pid ${msg.child?.pid}: adopt arrived`);
narrate(`[worker] pid ${msg.child?.pid}: adopt arrived`);
// With the closure, as a fresh process gets it: a child inherits its
// parent's code through the fork record but no record that those pages
// are a closure's, so without this an exec'd `apt-get` throws every
@@ -638,6 +660,11 @@ async function handle(msg) {
fjit: msg.fjit ?? msg.opts?.fjit,
fjitMax: msg.fjitMax ?? msg.opts?.fjitMax,
fjitOnly: msg.fjitOnly ?? msg.opts?.fjitOnly,
liftMax: msg.liftMax ?? msg.opts?.liftMax,
inlineDispatch: msg.inlineDispatch ?? msg.opts?.inlineDispatch,
xlateReport: msg.xlateReport ?? msg.opts?.xlateReport,
discoverSkip: msg.discoverSkip ?? msg.opts?.discoverSkip,
t1Fold: msg.t1Fold ?? msg.opts?.t1Fold,
providers: msg.providers ?? msg.opts?.providers,
wantLibrary,
},
@@ -765,12 +792,29 @@ async function handle(msg) {
// the slot it dispatches through is written now. See #136.
liftSlots: gex.liftSlots?.(),
millis: Number(gex.shim.exports.gex_jit_millis?.() ?? 0),
// What the translator did: instructions lifted, wasm bytes, translations,
// SMC page invalidations, pages retired, retire calls, -, anonymous blocks.
// The discovery log (GEX_DISCOVER_SKIP bit 8, run-node's GEX_DL_FILE).
dl: (() => {
const e = gex.shim.exports;
const n = e.gex_dl_text?.(0, 0) >>> 0;
if (!n) return undefined;
const p = e.gex_alloc(n) >>> 0;
e.gex_dl_text(p, n);
const t = new TextDecoder().decode(new Uint8Array(gex.memory.buffer, p, n).slice());
e.gex_free?.(p, n);
return t;
})(),
xlate: gex.shim.exports.gex_xlate_stat
? [0, 1, 2, 3, 4, 5, 6, 7, 8, 9].map((i) => Number(gex.shim.exports.gex_xlate_stat(i)))
: undefined,
// What the engine cost, which `millis` does not: compiling and
// instantiating every module this process installed.
installMs: gex.installMillis,
// Split: compiling is per bytes and V8 can skip seen bytes, instantiating
// is per module and paid every time.
compileMs: gex.compileMillis,
unitHits: gex.unitHits,
instantiateMs: gex.instantiateMillis,
growMs: gex.growMillis,
byObject: gex.blocksByObject,
@@ -867,6 +911,8 @@ async function handle(msg) {
} catch {
// The shim is what just fell over; there may be nothing left to call.
}
// A fiber's timers outlive it unless stopped (single.mjs).
if (single.on) gex?.close?.();
port.post({
kind: 'threw',
message: (e?.message ?? String(e)) + where,
@@ -893,3 +939,9 @@ const handed = await (async () => {
port.post({ kind: 'ready' });
if (handed) handle(handed);
}
// A real Worker runs itself; the single-thread build registers the function
// and runs it once per fiber (single.mjs `FiberWorker`).
if (single.on) registerWorkerMain(workerMain);
else await workerMain(await channel());
+191 -25
View File
@@ -329,6 +329,7 @@ extern "C" {
fn k_obj_translated(obj: u64, produced: u32);
fn k_obj_is_translated(obj: u64) -> u32;
fn k_mod_for_object(obj: u64, n: u32) -> u32;
fn k_mod_ids_for_object(obj: u64, from: u32, out: gex_kernel::PAddr, cap: u32) -> u32;
fn k_mod_slots(id: u32) -> u64;
fn k_mod_len(id: u32) -> u32;
fn k_mod_dirs_len(id: u32) -> u32;
@@ -1949,6 +1950,85 @@ pub extern "C" fn gex_ctx_exited(ctx: u32) -> i32 {
static PER_MISS_N: std::sync::atomic::AtomicUsize =
std::sync::atomic::AtomicUsize::new(16);
/// The most blocks one dispatch miss lifts (discovery follows successors,
/// call returns and folded call targets breadth-first up to this). 1 lifts
/// only the block that missed, so the blocks lifted are the blocks run.
static LIFT_MAX: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(2048);
/// What the runtime translator did in this process, for `gex_xlate_stat`:
/// 0 guest instructions lifted, 1 wasm bytes emitted, 2 translations (misses
/// that lifted), 3 self-modifying-code page invalidations, 4 pages retired by
/// unmap/reprotect, 5 retire calls, 6 blocks lifted by misses on a page that
/// was retired or invalidated before (re-lifts), 7 blocks lifted outside any
/// named object (a guest JIT's code), 8 wasm bytes of the re-lifts, 9 wasm
/// bytes of the blocks outside any named object.
pub(crate) static XLATE_STAT: [std::sync::atomic::AtomicU64; 10] = [const { std::sync::atomic::AtomicU64::new(0) }; 10];
/// `gex_set_xlate_report(n)` (`?xlatereport=N`, `GEX_XLATE_REPORT=N`): a
/// progress line every `n` translations (0: none), so a run that never exits
/// (a game) still says what it translated, from where, and how much again.
static XLATE_REPORT: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
/// `gex_set_t1_fold(1)` (`?t1fold=1`, GEX_T1_FOLD): tier-1's tee folding
/// (gex-emit `set_t1_fold`).
#[no_mangle]
pub extern "C" fn gex_set_t1_fold(on: u32) {
gex_emit::tier1::set_t1_fold(on != 0);
}
/// `gex_set_discover_skip(bits)` (`GEX_DISCOVER_SKIP`): edges the runtime
/// translator's discovery does not follow (gex-xlate `set_discover_skip`).
#[no_mangle]
pub extern "C" fn gex_set_discover_skip(bits: u32) {
gex_xlate::set_discover_skip(bits);
}
/// The discovery log's text (`GEX_DISCOVER_SKIP` bit 8), for `gex_dl_text`.
static DL_TEXT: std::sync::Mutex<String> = std::sync::Mutex::new(String::new());
/// Copy the discovery log's text to `ptr` (at most `cap` bytes) and return its
/// length; with `cap` 0 only the length.
#[no_mangle]
pub extern "C" fn gex_dl_text(ptr: u32, cap: u32) -> u32 {
let t = DL_TEXT.lock().unwrap_or_else(|e| e.into_inner());
let n = t.len().min(cap as usize);
if n > 0 {
// SAFETY: the caller allocated `cap` bytes at `ptr` (gex_alloc).
unsafe { std::ptr::copy_nonoverlapping(t.as_ptr(), ptr as usize as *mut u8, n) };
}
t.len() as u32
}
#[no_mangle]
pub extern "C" fn gex_set_xlate_report(n: u32) {
XLATE_REPORT.store(n as u64, std::sync::atomic::Ordering::Relaxed);
}
pub(crate) fn xlate_add(i: usize, n: u64) {
XLATE_STAT[i].fetch_add(n, std::sync::atomic::Ordering::Relaxed);
}
#[no_mangle]
pub extern "C" fn gex_xlate_stat(i: u32) -> i64 {
XLATE_STAT.get(i as usize).map_or(-1, |c| c.load(std::sync::atomic::Ordering::Relaxed) as i64)
}
/// `gex_set_inline_dispatch(1)` (`?inlinedispatch=1`, `GEX_INLINE_DISPATCH=1`
/// under run-node): blocks translated after this carry their dispatch inline
/// instead of tail-calling their module's `enter` (gex-emit
/// `set_inline_dispatch`).
#[no_mangle]
pub extern "C" fn gex_set_inline_dispatch(on: i32) {
gex_emit::tier1::set_inline_dispatch(on != 0);
}
/// Set that, from the host (`?liftmax=N`, `GEX_LIFT_MAX`).
#[no_mangle]
pub extern "C" fn gex_set_lift_max(n: u32) {
if n > 0 {
LIFT_MAX.store(n as usize, std::sync::atomic::Ordering::Relaxed);
}
}
/// Set that, from the host.
#[no_mangle]
pub extern "C" fn gex_set_per_miss(n: u32) {
@@ -2562,6 +2642,10 @@ struct Jit {
/// Shared modules this Worker has put into its own tables. Describes the
/// Worker, not the program, and survives `execve` as the tables do.
installed_mods: std::collections::HashSet<u32>,
/// Per object, how many of the kernel's modules for it (in its order) are
/// all in `installed_mods`: a miss reads the list from there on, not from
/// the start. See `shared_modules_of_object`.
obj_cursor: std::collections::HashMap<u64, u32>,
/// The dense base each stored module (by store id) was given here: the
/// store keeps the bytes as built, and `backfill_registry` must keep
/// them as installed (`dense`).
@@ -2614,6 +2698,8 @@ struct Jit {
/// Blocks lifted per object, with the address range: the range says whether
/// a closure is missing an object or only part of one.
by_object: std::collections::BTreeMap<String, (u64, u64, u64)>,
/// Wasm bytes emitted, by object (as `by_object`). See `xlate_report`.
obj_wasm: std::collections::BTreeMap<String, u64>,
compiles: u64,
/// Nanoseconds lifting and encoding -- the shim's share of a miss; the
/// engine's compile of the output is JS's.
@@ -4041,6 +4127,7 @@ pub extern "C" fn gex_load(ptr: u32, len: u32, argv: u32, argv_len: u32) -> u64
ever_threaded: false,
shared: Default::default(),
installed_mods: Default::default(),
obj_cursor: Default::default(),
dense_base: Default::default(),
retired_pages: Default::default(),
remiss: Default::default(),
@@ -4058,6 +4145,7 @@ pub extern "C" fn gex_load(ptr: u32, len: u32, argv: u32, argv_len: u32) -> u64
blocks: 0,
lift_in_closure: 0,
by_object: Default::default(),
obj_wasm: Default::default(),
compiles: 0,
nanos: 0,
},
@@ -7844,6 +7932,7 @@ pub extern "C" fn gex_adopt_child(
ever_threaded: false,
shared: Default::default(),
installed_mods: Default::default(),
obj_cursor: Default::default(),
dense_base: Default::default(),
retired_pages: Default::default(),
remiss: Default::default(),
@@ -7861,6 +7950,7 @@ pub extern "C" fn gex_adopt_child(
blocks: 0,
lift_in_closure: 0,
by_object: Default::default(),
obj_wasm: Default::default(),
compiles: 0,
nanos: 0,
}},
@@ -8956,24 +9046,45 @@ fn shared_modules_of_object(
obj: u64,
max_mods: usize,
max_bytes: u64,
) -> Vec<(u32, u64, Vec<(u64, u32)>, Vec<u8>)> {
) -> (Vec<(u32, u64, Vec<(u64, u32)>, Vec<u8>)>, u32) {
phase(13);
let mut out = Vec::new();
let mut took = 0u64;
let mut left_behind = 0usize;
let mut left_bytes = 0u64;
let from = h.jit.obj_cursor.get(&obj).copied().unwrap_or(0);
let mut lead = 0u32;
// SAFETY: scalars in, and the readers below size their buffers from the
// kernel's own answer before asking it to fill them.
unsafe {
if k_present() == 0 {
return out;
return (out, 0);
}
for n in 0.. {
let id = k_mod_for_object(obj, n);
if id == 0 {
// The ids from the first one not known installed, in one call.
let mut ids: Vec<u32> = Vec::new();
loop {
let mut chunk = vec![0u32; 4096];
let got = k_mod_ids_for_object(obj, from + ids.len() as u32, chunk.as_mut_ptr() as gex_kernel::PAddr, 4096);
ids.extend_from_slice(&chunk[..got as usize]);
if got < 4096 {
break;
}
if h.jit.installed_mods.contains(&id) || out.iter().any(|(i, ..)| *i == id) {
}
let mut leading = true;
for &id in &ids {
if h.jit.installed_mods.contains(&id) {
if leading {
lead += 1;
}
continue;
}
leading = false;
// Full: the rest are the next miss's (only the trace below
// counts them).
if out.len() >= max_mods && !tracing() {
break;
}
if out.iter().any(|(i, ..)| *i == id) {
continue;
}
let len = k_mod_len(id) as usize;
@@ -9036,7 +9147,7 @@ fn shared_modules_of_object(
left_bytes / 1024,
));
}
out
(out, from + lead)
}
/// Fill the registry with every module this process has already installed, so
@@ -10812,6 +10923,7 @@ pub extern "C" fn gex_cache_op(_ctx: i32, _pc: i64, op: i32, addr: i64) {
let lo = page * gex_abi::DISPATCH_PAGE;
let hi = lo + gex_abi::DISPATCH_PAGE;
h.jit.translated.retain(|&a| a < lo || a >= hi);
xlate_add(3, 1);
let n = SMC_INVALIDATIONS.fetch_add(1, std::sync::atomic::Ordering::Relaxed) + 1;
// Informational: a JIT (V8 under node) rewrites its code all the
// time and this is the runtime keeping up, not a fault.
@@ -11734,12 +11846,14 @@ pub extern "C" fn gex_dispatch_miss(_ctx: i32, pc: i64) -> i32 {
// size behind it, and the lot is however many megabytes of wasm
// somebody stored: a finished object served 317 MiB in one miss.
// What this leaves is taken by the next miss.
let ready = shared_modules_of_object(
let obj = object_identity(h, page * gex_abi::DISPATCH_PAGE).unwrap_or(0);
let (ready, cursor) = shared_modules_of_object(
h,
object_identity(h, page * gex_abi::DISPATCH_PAGE).unwrap_or(0),
obj,
if whole { usize::MAX } else { per_miss },
SERVE_MAX_BYTES,
);
h.jit.obj_cursor.insert(obj, cursor);
// Only modules whose every page base agrees with this process's
// numbering (or names a page not numbered here yet). A module is
// written at the bases the process that BUILT it gave its pages;
@@ -11932,16 +12046,19 @@ pub extern "C" fn gex_dispatch_miss(_ctx: i32, pc: i64) -> i32 {
);
}
phase(11);
let (unit, stats) = gex_xlate::translate_runtime(
// The page table moves in and back out: cloning it (a BTreeMap of every
// page the process has numbered) on each miss, twice, was a second of a
// node start-up's 7,400 misses.
let (mut unit, stats) = gex_xlate::translate_runtime(
guest_bytes(),
pc,
&|a| exec.iter().any(|(s, e)| a >= *s && a + gex_abi::INST_ALIGN <= *e),
&|a| known.contains(&a),
h.jit.page_table.clone(),
std::mem::take(&mut h.jit.page_table),
Default::default(),
// A cap keeps one miss from translating a whole library at once;
// the next miss picks up where this stopped.
2048,
LIFT_MAX.load(std::sync::atomic::Ordering::Relaxed),
// Must match the memory this shim was built against: a module the
// translator emits is instantiated against the same memory, and an
// import that disagrees about sharing does not link.
@@ -11949,6 +12066,7 @@ pub extern "C" fn gex_dispatch_miss(_ctx: i32, pc: i64) -> i32 {
&h.jit.intrinsics,
);
h.jit.translated = known;
h.jit.page_table = std::mem::take(&mut unit.page_table);
if pc == 0x10f6d24 {
let ok = exec.iter().any(|(s, e)| pc >= *s && pc + gex_abi::INST_ALIGN <= *e);
let has_block = unit.covered.iter().any(|(p, _)| *p == 0x10f6);
@@ -11960,7 +12078,9 @@ pub extern "C" fn gex_dispatch_miss(_ctx: i32, pc: i64) -> i32 {
if stats.blocks == 0 {
return false;
}
h.jit.page_table = unit.page_table.clone();
xlate_add(0, stats.instructions as u64);
xlate_add(1, stats.wasm_bytes as u64);
xlate_add(2, 1);
// Into the system-wide store, so a sibling installs it rather than
// re-lifting. Published by what the unit *covers*, not what it allocated
// (handed the allocation list, nine of ten units published nothing); the
@@ -12065,7 +12185,30 @@ pub extern "C" fn gex_dispatch_miss(_ctx: i32, pc: i64) -> i32 {
}
h.jit.blocks += stats.blocks as u64;
let whose = object_name_at(h, pc);
if whose.is_empty() {
xlate_add(7, stats.blocks as u64);
xlate_add(9, stats.wasm_bytes as u64);
}
if h.jit.retired_pages.contains(&page) {
xlate_add(6, stats.blocks as u64);
xlate_add(8, stats.wasm_bytes as u64);
}
*h.jit.obj_wasm.entry(whose.clone()).or_insert(0) += stats.wasm_bytes as u64;
note_blocks(&mut h.jit.by_object, whose, pc, stats.blocks as u64);
// `GEX_DISCOVER_SKIP` bit 8: what discovery lifted and why, one
// `address why depth object` line a block, read at exit
// (`gex_dl_text`, run-node's GEX_DL_FILE; docs/jit-guest.md).
let dlog = gex_xlate::take_discover_log();
if !dlog.is_empty() {
let mut t = DL_TEXT.lock().unwrap_or_else(|e| e.into_inner());
for (a, why, depth) in dlog {
t.push_str(&format!("{a:x} {why} {depth} {}\n", object_name_at(h, a)));
}
}
let every = XLATE_REPORT.load(std::sync::atomic::Ordering::Relaxed);
if every != 0 && XLATE_STAT[2].load(std::sync::atomic::Ordering::Relaxed) % every == 0 {
xlate_report(h);
}
h.jit.compiles += 1;
h.jit.nanos += now_ns().saturating_sub(started);
if tracing() {
@@ -12234,10 +12377,14 @@ pub(crate) fn retire_code(h: &mut Host, ranges: &[(u64, u64)]) {
continue;
}
h.jit.translated.retain(|a| *a < start || *a >= end);
xlate_add(5, 1);
for page in start / gex_abi::DISPATCH_PAGE..=(end - 1) / gex_abi::DISPATCH_PAGE {
if page >= gex_abi::DISPATCH_MAX_PAGES {
break;
}
if h.jit.page_table.lookup(page).is_some() {
xlate_add(4, 1);
}
h.jit.page_table.forget(page);
h.jit.retired_pages.insert(page);
let at = gex_abi::DISPATCH_DIR + page * 4;
@@ -12826,6 +12973,27 @@ fn note_process_syscall(base: u64, pc: i64) {
}
/// Add `n` blocks lifted at `a` to what `whose` has cost so far.
/// One progress line: the translator's counters and the objects it lifted
/// most from, by wasm emitted. See `XLATE_REPORT`.
fn xlate_report(h: &Host) {
let x = |i: usize| XLATE_STAT[i].load(std::sync::atomic::Ordering::Relaxed);
let mut v: Vec<(&String, &u64)> = h.jit.obj_wasm.iter().collect();
v.sort_by(|a, b| b.1.cmp(a.1));
let objs = v
.iter()
.take(6)
.map(|(k, w)| {
let n = h.jit.by_object.get(*k).map_or(0, |e| e.0);
format!("{}={}blk/{}KiB", if k.is_empty() { "(anon)" } else { k.as_str() }, n, **w >> 10)
})
.collect::<Vec<_>>()
.join(" ");
warn_line(&format!(
"[gex] xlate: units={} insns={} wasm={}KiB smc={} retiredPages={} relift={}blk/{}KiB anon={}blk/{}KiB [{objs}]\n",
x(2), x(0), x(1) >> 10, x(3), x(4), x(6), x(8) >> 10, x(7), x(9) >> 10,
));
}
fn note_blocks(
m: &mut std::collections::BTreeMap<String, (u64, u64, u64)>,
whose: String,
@@ -13692,25 +13860,23 @@ static EXTERNAL_BYTES: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU
/// `moduleContentKey` in gex.mjs, as one u64 (`a` high, `b` low): what a
/// module's store slot is checked against.
fn content_key(bytes: &[u8]) -> u64 {
// gex.mjs `moduleContentKey`, exactly: little-endian words, then the
// tail bytes, then a final mix of `a`.
// A word at a time (little-endian), then the tail bytes: the same as
// gex.mjs `moduleContentKey`, which hashes every module a Worker installs
// and spent 0.65 s of a node start-up doing it a byte at a time.
let mut a: u32 = 0x811c_9dc5;
let mut b: u32 = 0x9e37_79b9;
let mut step = |i: u32, x: u32| {
let mut step = |i: usize, x: u32| {
a = (a ^ x).wrapping_mul(0x0100_0193);
b = (b ^ x.wrapping_add(i)).wrapping_mul(0x85eb_ca6b);
b = (b ^ x.wrapping_add(i as u32)).wrapping_mul(0x85eb_ca6b);
b ^= b >> 13;
};
let words = bytes.chunks_exact(4);
let tail = words.remainder();
for (i, w) in words.enumerate() {
step(i as u32, u32::from_le_bytes([w[0], w[1], w[2], w[3]]));
let words = bytes.len() / 4;
for i in 0..words {
step(i, u32::from_le_bytes(bytes[4 * i..4 * i + 4].try_into().unwrap()));
}
let base = (bytes.len() & !3) as u32;
for (j, &x) in tail.iter().enumerate() {
step(base + j as u32, x as u32);
for (i, &x) in bytes[4 * words..].iter().enumerate() {
step(words + i, x as u32);
}
a ^= a >> 16;
((a as u64) << 32) | b as u64
}
+67 -10
View File
@@ -624,7 +624,8 @@ pub extern "C" fn k_slot_count() -> u32 {
/// compiled code (that is a JS object): the Worker feeds them to the same
/// `pending` queue a fresh translation uses.
struct SharedModule {
wasm: Vec<u8>,
/// Where its bytes are in [`ModStore::slab`]: chunk, offset, length.
at: (u32, u32, u32),
/// How large the dispatch table must be. A fact about the *producing*
/// process; the installer's own high-water mark understates it, and the
/// install then throws.
@@ -655,6 +656,37 @@ struct ModStore {
translated: BTreeSet<u64>,
/// What has been stored, against [`MOD_BUDGET`].
total: usize,
/// The modules' bytes, appended into chunks of [`SLAB_CHUNK`] (a larger
/// module gets a chunk of its own). One allocation per module grew the
/// kernel's shared memory every few modules -- dlmalloc asks `memory.grow`
/// for little more than each request -- and growing a shared memory is
/// slow: 1.9 s of a 40 s node start-up in the page's runtime.
slab: Vec<Vec<u8>>,
}
const SLAB_CHUNK: usize = 16 << 20;
impl ModStore {
fn bytes(&self, sm: &SharedModule) -> &[u8] {
let (c, off, len) = sm.at;
self.slab
.get(c as usize)
.and_then(|v| v.get(off as usize..off as usize + len as usize))
.unwrap_or(&[])
}
/// Room for `len` bytes: the chunk and offset they go at (written by the caller).
fn reserve(&mut self, len: usize) -> (u32, u32) {
let fits = self.slab.last().is_some_and(|v| v.capacity() - v.len() >= len);
if !fits {
self.slab.push(Vec::with_capacity(len.max(SLAB_CHUNK)));
}
let c = self.slab.len() - 1;
let v = &mut self.slab[c];
let off = v.len();
v.resize(off + len, 0);
(c as u32, off as u32)
}
}
/// How much translated wasm to keep for sharing. The kernel memory is capped at
@@ -669,7 +701,7 @@ fn modules() -> &'static Mutex<ModStore> {
MODS.get_or_init(|| {
Mutex::new(ModStore {
mods: vec![SharedModule {
wasm: Vec::new(),
at: (u32::MAX, 0, 0),
slots: 0,
dirs: Vec::new(),
}],
@@ -678,6 +710,7 @@ fn modules() -> &'static Mutex<ModStore> {
translating: BTreeMap::new(),
translated: BTreeSet::new(),
total: 0,
slab: Vec::new(),
})
})
}
@@ -699,8 +732,9 @@ pub unsafe extern "C" fn k_mod_store(
if len == 0 || m.total + len as usize > MOD_BUDGET {
return 0;
}
let mut wasm = vec![0u8; len as usize];
unsafe { gex_kmem::pull_checked(wasm.as_mut_ptr(), ptr, len) };
let (c, off) = m.reserve(len as usize);
let dst = m.slab[c as usize][off as usize..].as_mut_ptr();
unsafe { gex_kmem::pull_checked(dst, ptr, len) };
let mut raw = vec![0u8; dirs_len as usize];
if dirs_len > 0 {
unsafe { gex_kmem::pull_checked(raw.as_mut_ptr(), dirs_ptr, dirs_len) };
@@ -712,7 +746,7 @@ pub unsafe extern "C" fn k_mod_store(
dirs.push((page, base));
}
m.total += len as usize;
m.mods.push(SharedModule { wasm, slots, dirs });
m.mods.push(SharedModule { at: (c, off, len), slots, dirs });
// One-based: zero is how every caller says "no module".
m.mods.len() as u32
}
@@ -800,6 +834,24 @@ pub extern "C" fn k_mod_for_object(obj: u64, n: u32) -> u32 {
.unwrap_or(0)
}
/// The modules holding code for this object from the `from`th on, as
/// little-endian u32 ids into `out` (at most `cap` of them); how many. One
/// call where walking `k_mod_for_object` took a lock per module, on every
/// miss: a node start-up's libnode has thousands.
///
/// SAFETY: `out` must have room for `cap` u32s.
#[no_mangle]
pub unsafe extern "C" fn k_mod_ids_for_object(obj: u64, from: u32, out: gex_kmem::PAddr, cap: u32) -> u32 {
let m = modules().lock().unwrap_or_else(|e| e.into_inner());
let Some(v) = m.by_object.get(&obj) else {
return 0;
};
let ids = v.get(from as usize..).unwrap_or(&[]);
let n = ids.len().min(cap as usize);
unsafe { gex_kmem::push_checked(out, ids.as_ptr() as *const u8, (n * 4) as u32) };
n as u32
}
/// The `n`th module holding code for this page, or zero past the end. The
/// caller walks `n` upwards for every unit that touched the page, since one
/// page accumulates a claim per translation unit.
@@ -835,7 +887,7 @@ pub extern "C" fn k_mod_len(id: u32) -> u32 {
.unwrap_or_else(|e| e.into_inner())
.mods
.get((id as usize).wrapping_sub(1))
.map(|m| m.wasm.len() as u32)
.map(|m| m.at.2)
.unwrap_or(0)
}
@@ -860,11 +912,12 @@ pub unsafe extern "C" fn k_mod_read(id: u32, out: gex_kmem::PAddr, cap: u32) ->
let Some(sm) = m.mods.get((id as usize).wrapping_sub(1)) else {
return 0;
};
if sm.wasm.len() > cap as usize {
let b = m.bytes(sm);
if b.len() > cap as usize {
return 0;
}
unsafe { gex_kmem::push_checked(out, sm.wasm.as_ptr(), sm.wasm.len() as u32) };
sm.wasm.len() as u32
unsafe { gex_kmem::push_checked(out, b.as_ptr(), b.len() as u32) };
b.len() as u32
}
/// And the `dirs`, in the same twelve-byte encoding they arrived in.
@@ -1442,7 +1495,11 @@ pub extern "C" fn k_pipe_write(id: i32, at: gex_kmem::PAddr, len: u32) -> i32 {
#[no_mangle]
pub extern "C" fn k_pipe_read(id: i32, at: gex_kmem::PAddr, len: u32) -> i32 {
let Some(p) = pipe_at(id) else { return -9 };
let mut buf = vec![0u8; len as usize];
// No bigger than what is buffered (at least one byte, so an empty pipe
// still answers EOF or EAGAIN): a reader asking for 128 KiB of a pipe
// holding a few bytes zeroed the whole request on every call.
let want = (len as usize).min(p.buffered().max(1));
let mut buf = vec![0u8; want];
match p.read(&mut buf) {
Ok(n) => {
unsafe { gex_kmem::push_checked(at, buf.as_ptr(), n as u32) };
+29
View File
@@ -553,6 +553,8 @@ fn cache_key(image: &Image, xlate: &Options, build: &str) -> String {
// Symbols steer discovery, so two images with the same code but
// different symbol tables translate differently.
(image.symbols.len() as u64).to_le_bytes().into(),
// Emitter switches read from the environment.
vec![gex_emit::tier1::knobs()],
];
for seg in &image.segments {
fields.push(seg.vaddr.to_le_bytes().into());
@@ -1509,6 +1511,33 @@ fn time_left(deadline: std::time::Duration, elapsed: std::time::Duration) -> Opt
deadline.checked_sub(elapsed).filter(|d| !d.is_zero())
}
#[cfg(test)]
mod cache_key_tests {
use super::{cache_key, Options};
/// GEX_INLINE_DISPATCH and GEX_T1_FOLD change tier-1's bytes without
/// showing in `Options`: a key that ignored them served one setting's
/// modules to the other (a GEX_T1_FOLD=1 run's modules, which then hung
/// signal-kill, to default runs).
#[test]
fn emitter_switches_change_the_key() {
let image = gex_ld::elf::headers_only(0x40, 56, 1, 0x1000);
let o = Options::default();
let base = cache_key(&image, &o, "b");
assert_eq!(base, cache_key(&image, &o, "b"));
gex_emit::tier1::set_t1_fold(true);
let fold = cache_key(&image, &o, "b");
gex_emit::tier1::set_t1_fold(false);
gex_emit::tier1::set_inline_dispatch(true);
let inline = cache_key(&image, &o, "b");
gex_emit::tier1::set_inline_dispatch(false);
assert_ne!(base, fold);
assert_ne!(base, inline);
assert_ne!(fold, inline);
assert_eq!(base, cache_key(&image, &o, "b"));
}
}
#[cfg(test)]
mod time_left_tests {
use super::time_left;
+7 -1
View File
@@ -101,4 +101,10 @@ fi
if [ -z "$store" ] || [ "$store" -lt 2 ] || [ "$heap" -gt 2 ]; then
echo "FAIL browser-registry: module store ${store:-none} MiB, registry in the shim heap ${heap:-?} MiB"; tail -5 "$WORK/err"; exit 1
fi
echo "ok browser-registry ($out; module store $store MiB, registry in the shim heap $heap MiB)"
# Room past 4 GiB: Hollow Knight on Unity's own Mono filled a 4 GiB store in
# 22 minutes, and the shim's heap ran out once the registry fell back to it.
max=$(sed -n 's/.*module store max \([0-9]*\) GiB.*/\1/p' "$WORK/err" | head -1)
if [ -z "$max" ] || [ "$max" -le 4 ]; then
echo "FAIL browser-registry: the module store may grow to ${max:-?} GiB, not past 4"; exit 1
fi
echo "ok browser-registry ($out; module store $store MiB of at most $max GiB, registry in the shim heap $heap MiB)"
+11
View File
@@ -0,0 +1,11 @@
/* A function-tier unit (GEX_ELF_UNITS=libcb.so) whose only job is to call
back into code no unit holds, so every call goes out through call_out /
ftab_call. */
typedef int (*cb_t)(int);
__attribute__((noinline)) int libcb_run(cb_t f, int n)
{
int s = 0;
for (int i = 0; i < n; i++)
s += f(i);
return s;
}
+41
View File
@@ -0,0 +1,41 @@
/* libcb_run (a unit) calls cb (tier-1), and cb blocks: on i == 2 it reads a
pipe that a forked child fills 100 ms later, and every call sleeps 2 ms.
In the single-thread build each block parks this guest thread inside a
nested call-out, which must suspend (docs/single-thread.md §7) rather
than trap with "trying to suspend JS frames". */
#include <stdio.h>
#include <sys/wait.h>
#include <time.h>
#include <unistd.h>
int libcb_run(int (*f)(int), int n);
static int fds[2];
static int cb(int i)
{
if (i == 2) {
char c = 0;
if (read(fds[0], &c, 1) != 1)
return -1000;
printf("callback read %c\n", c);
fflush(stdout);
}
nanosleep(&(struct timespec){0, 2000000}, 0);
return i * 10;
}
int main(void)
{
if (pipe(fds))
return 2;
pid_t pid = fork();
if (pid == 0) {
nanosleep(&(struct timespec){0, 100000000}, 0);
write(fds[1], "w", 1);
_exit(0);
}
int s = libcb_run(cb, 5);
waitpid(pid, 0, 0);
printf("libcb_run = %d %s\n", s, s == 100 ? "ok" : "wrong");
return s == 100 ? 0 : 1;
}
+53
View File
@@ -0,0 +1,53 @@
#!/bin/bash
# Blocking inside a nested call-out (docs/single-thread.md §2, "Call-outs"):
# libcb_run is a function-tier unit (GEX_ELF_UNITS=libcb.so) and calls cb,
# which no unit holds and which blocks (a pipe read, nanosleep). In the
# single-thread build call_out / ftab_call are Suspending imports that run
# the nested guest on its own promising stack, so the block parks only this
# fiber; before that it trapped with "trying to suspend JS frames". Runs the
# program under run-node.mjs in single mode (SharedArrayBuffer deleted) and
# in the SAB build; both must place the unit and print the right sum.
#
# tests/single-callout/run.sh [gexc]
#
# Needs ~/toolchains/aarch64-linux-musl-cross (GEX_TOOLCHAIN) and the shared
# browser build in web/public/gex (shims/browser/build.sh with GEX_SHARED=1).
set -u
here=$(cd "$(dirname "$0")" && pwd)
root=$(cd "$here/../.." && pwd)
gexc=$(realpath "${1:-$root/target/release/gexc}")
tc=${GEX_TOOLCHAIN:-$HOME/toolchains/aarch64-linux-musl-cross}
cc=$tc/bin/aarch64-linux-musl-gcc
seed=$root/web/public/gex/seed.shared.wasm
for t in "$gexc" "$cc" "$seed"; do [ -e "$t" ] || { echo "skip: $t is not there"; exit 0; }; done
w=${WORK:-$root/target/single-callout-test}
rm -rf "$w"; mkdir -p "$w"
"$cc" -O2 -fPIC -shared -Wl,-soname,libcb.so -o "$w/libcb.so" "$here/lib.c" || exit 1
"$cc" -O1 -o "$w/main" "$here/main.c" -L"$w" -lcb -Wl,-rpath,"$w" || exit 1
export GEX_CGEN_CACHE=$w/cgen GEX_ELF_UNITS=libcb.so
# The single build needs JSPI: on by default from node 25, behind a flag before.
jspi=
node -e 'process.exit(typeof WebAssembly.Suspending === "function" ? 0 : 1)' 2>/dev/null ||
jspi=--experimental-wasm-jspi
run() { # MODE-VAR LOG
(cd "$w" && env "$1=1" timeout "${TMO:-300}" node $jspi --import 'data:text/javascript,if(process.env.GEX_SINGLE)delete globalThis.SharedArrayBuffer' \
"$root/shims/browser/js/run-node.mjs" --cgen-prefer \
--file "/lib/ld-musl-aarch64.so.1=$tc/aarch64-linux-musl/lib/libc.so" \
--file "$w/libcb.so=$w/libcb.so" --file "$w/main=$w/main" "$seed" "$w/main" > "$2.out" 2> "$2.err")
}
# A first run finds where the loader maps libcb.so; its unit is built for that base.
run GEX_SINGLE "$w/probe"
base=$(grep -ao 'libcb\.so at 0x[0-9a-f]*' "$w/probe.err" | head -1 | sed 's/.* at //')
[ -n "$base" ] || { echo "FAIL single-callout (libcb.so never mapped)"; tail -20 "$w/probe.err"; exit 1; }
GEX_CGEN_GUARD=1 "$gexc" cgen-image --elf "$w/libcb.so" --base "$base" --jobs 2 > "$w/cgen.log" 2>&1 || { cat "$w/cgen.log"; exit 1; }
fail=0
for m in GEX_SINGLE GEX_SHARED; do
run $m "$w/$m"; rc=$?
placed=$(grep -ac 'function tier for .*libcb\.so' "$w/$m.err")
if [ $rc = 0 ] && [ "$placed" -ge 1 ] && grep -q '^callback read w$' "$w/$m.out" && grep -q '^libcb_run = 100 ok$' "$w/$m.out"; then
echo "ok single-callout $m"
else
echo "FAIL single-callout $m (rc=$rc, unit placed $placed times)"; cat "$w/$m.out"; grep -a 'trap\|rror' "$w/$m.err" | head -5; fail=1
fi
done
exit $fail
+35
View File
@@ -0,0 +1,35 @@
#!/bin/bash
# The single-thread build's smoke leg (docs/single-thread.md §9):
# 1. tests/single-callout: blocking inside a nested call-out, under node, in
# single mode and in the SAB build.
# 2. The page smoke test (web/test/site.mjs, the same demos as the default
# leg) served without COOP/COEP. That page has no SharedArrayBuffer, so it
# must run the single build. Each demo must pass, and the terminal must
# say it was the single build, so a silent fallback cannot pass.
#
# tests/single/run.sh [gexc] # demos: SP_DEMOS (default busybox native-debian gears)
#
# Needs web/build-all.sh --sp (or --both) first: web/dist-sp, served
# without COOP/COEP from its own dist. The gears demo also needs a
# WebGPU adapter; on a host without a GPU set GEX_WEBGPU_SOFTWARE=1.
set -u
here=$(cd "$(dirname "$0")" && pwd)
root=$(cd "$here/../.." && pwd)
fail=0
"$root/tests/single-callout/run.sh" "$@" || fail=1
out=${WORK:-$root/target/single-test}
mkdir -p "$out"
[ -d "$root/web/dist-sp" ] || { echo "FAIL single-page (no web/dist-sp: run web/build-all.sh --sp)"; exit 1; }
for d in ${SP_DEMOS:-busybox native-debian gears}; do
(cd "$root" && GEX_DIST="$root/web/dist-sp" GEX_NO_COI=1 GEX_TAIL=0 GEX_STDERR=all node web/test/site.mjs "$d") > "$out/site-$d.txt" 2>&1; rc=$?
if [ $rc = 0 ] && grep -aq 'single-thread build (no SharedArrayBuffer)' "$out/site-$d.txt"; then
echo "ok single-page $d"
else
echo "FAIL single-page $d (rc=$rc; $out/site-$d.txt)"
grep -a '^FAIL\|single-thread' "$out/site-$d.txt" | head -3
fail=1
fi
done
exit $fail
+37
View File
@@ -24,6 +24,43 @@ web/build-mesa-wasm.sh # gex-wgpu-replay (needs emdawnwebgpu and Tint)
The first `build-mesa-wasm.sh` run builds Tint (`web/tint/build-tint.sh`) and is
substantially slower than subsequent incremental builds.
## The two builds: multi-process (default) and single-thread
The page has two builds, each with its own dist:
- **MP** (`web/dist`), the multi-process build, is the default. Every guest
thread is its own Worker on a shared memory. It needs SharedArrayBuffer, so
it needs COOP/COEP.
- **SP** (`web/dist-sp`), the single-thread build
(`docs/single-thread.md`), runs on any static host. Every guest thread is
a JSPI fiber in one Worker.
Both load the same shim, kernel and seed, which are built once and copied
into both dists. Mesa and Xwayland are built per flavour, with or without
`-pthread`, and each dist carries only its own. The dists strip DWARF and
name sections from the large wasm files. The unstripped copies are in
`target/web-debug/<mp|sp>/`, and `--debug` ships them.
```sh
web/build-all.sh # MP (the default)
web/build-all.sh --sp # SP
web/build-all.sh --both # both
```
Serve both at once:
```sh
node web/serve-dist.mjs 8088 --sp-port 8089
```
This serves MP on 8088 and SP on 8089. Directory layout, sizes and flags are
in `docs/single-thread.md` §9.
Tests:
- the MP page smoke test: `node web/test/site.mjs`;
- the SP leg: `tests/single/run.sh`, which runs `tests/single-callout` and then
the same demos against `web/dist-sp` without COOP/COEP.
## OpenGL 4.4 validation
The complete 543-command core-4.4 command surface and the guest library's
+174
View File
@@ -0,0 +1,174 @@
#!/bin/sh
# Build the page for the multi-process build (MP, the default), the
# single-thread build (SP, docs/single-thread.md), or both, from one tree.
#
# web/build-all.sh [--mp | --sp | --both] [--debug]
# [--no-assets] [--no-gl] [--no-x] [--dist-only]
#
# Each flavour gets its own dist:
#
# MP web/dist served with COOP/COEP; threaded Mesa and Xwayland
# SP web/dist-sp served without them; non-pthread Mesa and Xwayland
#
# Neither dist holds the other flavour's Mesa or Xwayland. They are about
# 35 MB a flavour (Mesa 20 MB, Xwayland and its bridge the rest).
#
# THE STEPS
#
# 1. The runtime both flavours run, built once into web/public by
# web/build-assets.sh:
# - the shim, kernel and seed, which the page loads as
# gex_browser.shared.wasm, gex_kernel.wasm and seed.shared.wasm;
# - the providers, rootfs and guest GL libraries.
# SP loads the same three files and clears their memories' `shared` flag
# as it compiles them (single.mjs enableSingle). An SP-only shim cannot be
# smaller: its per-fiber stacks and TLS are what the atomics build provides
# (docs/single-thread.md §9).
# 2. Mesa and Xwayland for each flavour, each in its own build tree:
#
# Mesa MP: $CACHE/mesa-mt -> web/public/gex/
# SP: $CACHE/mesa-st -> web/public/gex/st/
# Xwayland MP: $CACHE/xwl-mt -> web/public/gex/xwayland/
# SP: $CACHE/xwl-st -> web/public/gex/xwayland-st/
#
# Mesa rebuilds incrementally through ninja. The Xwayland script always
# builds from scratch, so it is skipped while a stamp of its inputs
# matches.
# 3. For each flavour, a staged public tree in $CACHE/public-<flavour>:
# - hard links to web/public;
# - without the other flavour's Mesa and Xwayland;
# - without the files no page loads: the plain shim, seed and kernel,
# which run-node.mjs and the check scripts use from web/public.
# Its large wasm files are stripped of custom sections (DWARF, names) unless
# --debug is given. The unstripped files stay in web/public and are
# linked into target/web-debug/<flavour>/ for symbolizing. vite then
# builds the flavour's dist from that tree.
#
# $CACHE is GEX_WEB_BUILD, by default target/web-build.
#
# To serve both at once:
#
# node web/serve-dist.mjs 8088 --sp-port 8089
#
# This serves web/dist (MP) on 8088 and web/dist-sp (SP) on 8089.
set -eu
HERE="$(cd "$(dirname "$0")" && pwd)"
ROOT="$(cd "$HERE/.." && pwd)"
CACHE="${GEX_WEB_BUILD:-$ROOT/target/web-build}"
DEBUGDIR="$ROOT/target/web-debug"
MP=1 SP=0 ASSETS=1 GL=1 X=1 DEBUG=0
for a in "$@"; do
case $a in
--mp) MP=1 SP=0 ;;
--sp) MP=0 SP=1 ;;
--both) MP=1 SP=1 ;;
--debug) DEBUG=1 ;;
--no-assets) ASSETS=0 ;;
--no-gl) GL=0 ;;
--no-x) X=0 ;;
--dist-only) ASSETS=0 GL=0 X=0 ;;
*) echo "usage: web/build-all.sh [--mp | --sp | --both] [--debug] [--no-assets] [--no-gl] [--no-x] [--dist-only]" >&2; exit 2 ;;
esac
done
mkdir -p "$CACHE"
say() { printf '\n== %s\n' "$*"; }
# -- 1. shared by both: shim, kernel, seeds, providers, rootfs ----------------
if [ "$ASSETS" = 1 ]; then
say "runtime and assets (both flavours)"
# build-assets.sh ends by refreshing web/dist through refresh-dist.sh,
# which would call back into this script.
GEX_NO_REFRESH_DIST=1 "$HERE/build-assets.sh"
fi
# -- 2. Mesa and Xwayland, per flavour ----------------------------------------
mesa() { # mesa <mt|st>
t=1; [ "$1" = st ] && t=0
say "Mesa ($1)"
GEX_MESA_THREADS=$t "$HERE/build-mesa-wasm.sh" "$CACHE/mesa-$1" > "$CACHE/mesa-$1.log" 2>&1 ||
{ tail -30 "$CACHE/mesa-$1.log"; echo "Mesa ($1) failed; log in $CACHE/mesa-$1.log" >&2; exit 1; }
grep -a "published" "$CACHE/mesa-$1.log" || true
}
xwl_key() { # xwl_key <mt|st>
{
echo "$1 ${XWAYLAND_TAG:-} ${WAYLAND_TAG:-}"
cat "$HERE/build-xwayland-wasm.sh"
find "$HERE/xwayland" "$ROOT/shims/hostwl" -type f -not -path '*/target/*' | LC_ALL=C sort | xargs cat
} | sha256sum | cut -c1-32
}
xwl_host_tools() {
# The distro's, when it has them all; otherwise built once into the cache.
if pkg-config --exists wayland-scanner wayland-protocols xwaylandproto 2>/dev/null &&
[ -f /usr/share/aclocal/xorg-macros.m4 ] && [ -f /usr/include/X11/xshmfence.h ]; then
return 0
fi
"$HERE/build-xwayland-host-tools.sh" "$CACHE/xwl-host"
GEX_XWL_HOST_TOOLS="$CACHE/xwl-host"; export GEX_XWL_HOST_TOOLS
}
xwl() { # xwl <mt|st>
t=1 pub=xwayland; [ "$1" = st ] && t=0 pub=xwayland-st
key=$(xwl_key "$1")
if [ "$(cat "$CACHE/xwl-$1.stamp" 2>/dev/null)" = "$key" ] && [ -f "$ROOT/web/public/gex/$pub/Xwayland.wasm" ]; then
say "Xwayland ($1): unchanged, web/public/gex/$pub kept"
return 0
fi
say "Xwayland ($1)"
xwl_host_tools
GEX_XWL_THREADS=$t "$HERE/build-xwayland-wasm.sh" "$CACHE/xwl-$1" > "$CACHE/xwl-$1.log" 2>&1 ||
{ tail -30 "$CACHE/xwl-$1.log"; echo "Xwayland ($1) failed; log in $CACHE/xwl-$1.log" >&2; exit 1; }
echo "$key" > "$CACHE/xwl-$1.stamp"
tail -2 "$CACHE/xwl-$1.log"
}
[ "$MP" = 1 ] && [ "$GL" = 1 ] && mesa mt
[ "$SP" = 1 ] && [ "$GL" = 1 ] && mesa st
[ "$MP" = 1 ] && [ "$X" = 1 ] && xwl mt
[ "$SP" = 1 ] && [ "$X" = 1 ] && xwl st
# -- 3. one dist per flavour ---------------------------------------------------
dist() { # dist <mp|sp>
f=$1
out=$HERE/dist; [ "$f" = sp ] && out=$HERE/dist-sp
stage=$CACHE/public-$f
say "web/${out##*/} ($f)"
rm -rf "$stage" "$DEBUGDIR/$f"
cp -al "$HERE/public" "$stage"
# Only what this flavour's page loads (runtime.js: the shared shim, the
# shared seed, gex_kernel.wasm), and only its own Mesa and Xwayland.
rm -f "$stage"/gex/gex_kernel*.plain.wasm
for w in "$stage"/gex/gex_browser*.wasm "$stage"/gex/seed*.wasm; do
case $w in *.shared.wasm) ;; *) rm -f "$w" ;; esac
done
if [ "$f" = mp ]; then
rm -rf "$stage/gex/st" "$stage/gex/xwayland-st"
else
rm -rf "$stage/gex/xwayland" "$stage"/gex/gex-wgpu-replay.*
fi
if [ "$DEBUG" = 0 ]; then
# Custom sections out: DWARF, and the name section, which nothing in
# the runtime reads (a trap then names wasm-function[N]; symbolize
# with the copies in target/web-debug). Not the seed or the providers,
# whose gex.* sections the runtime does read.
mkdir -p "$DEBUGDIR/$f"
for w in "$stage"/gex/gex_browser*.shared.wasm "$stage"/gex/gex_kernel*.wasm \
"$stage"/gex/gex-wgpu-replay.wasm "$stage"/gex/st/gex-wgpu-replay.wasm \
"$stage"/gex/xwayland*/Xwayland.wasm "$stage"/gex/xwayland*/gexhostwl.wasm; do
[ -f "$w" ] || continue
rel=${w#"$stage"/}
mkdir -p "$DEBUGDIR/$f/$(dirname "$rel")"
ln -f "$w" "$DEBUGDIR/$f/$rel" 2>/dev/null || cp "$w" "$DEBUGDIR/$f/$rel"
# A new file, not a write through the hard link into web/public.
wasm-tools strip -a "$w" -o "$w.strip" && mv -f "$w.strip" "$w"
done
fi
(cd "$HERE" && GEX_PUBLIC_DIR="$stage" GEX_OUT_DIR="$out" npm run --silent build > "$CACHE/vite-$f.log" 2>&1) ||
{ tail -20 "$CACHE/vite-$f.log"; exit 1; }
rm -rf "$stage"
echo " web/${out##*/}: $(du -sh "$out" | cut -f1)"
}
if [ "$DEBUG" = 0 ] && ! command -v wasm-tools >/dev/null 2>&1; then
echo "need wasm-tools to strip the dists (cargo install wasm-tools), or pass --debug" >&2
exit 1
fi
[ "$MP" = 1 ] && dist mp
[ "$SP" = 1 ] && dist sp
exit 0
+21 -4
View File
@@ -7,6 +7,13 @@ set -eu
ROOT=$(CDPATH='' cd -- "$(dirname "$0")/.." && pwd)
MESA=$ROOT/third_party/mesa
SANITIZE=${GEX_MESA_SANITIZE:-0}
# GEX_MESA_THREADS=0: the single-thread build's Mesa (docs/single-thread.md),
# without -pthread -- a plain memory, no glthread -- and Wink's `st` Tint.
# Built in its own directory and published under web/public/gex/st/, beside
# the threaded one.
THREADS=${GEX_MESA_THREADS:-1}
VARIANT=mt
[ "$THREADS" = 0 ] && VARIANT=st
if [ "$SANITIZE" = 1 ]; then
DEFAULT_OUT=${TMPDIR:-/tmp}/gex-mesa-wasm-sanitize
CROSS=$ROOT/web/mesa-wasm-sanitize-cross.ini
@@ -15,6 +22,10 @@ if [ "$SANITIZE" = 1 ]; then
else
DEFAULT_OUT=${TMPDIR:-/tmp}/gex-mesa-wasm
CROSS=$ROOT/web/mesa-wasm-cross.ini
if [ "$THREADS" = 0 ]; then
DEFAULT_OUT=${TMPDIR:-/tmp}/gex-mesa-wasm-st
CROSS=$ROOT/web/mesa-wasm-st-cross.ini
fi
BUILD_TYPE=release
GEX_SANITIZE=false
fi
@@ -40,7 +51,11 @@ if [ -z "${GEX_EMDAWN_DIR:-}" ] && [ -f "$MESA/wink/lib/emdawn.sh" ]; then
else
EMDAWN=${GEX_EMDAWN_DIR:-$($ROOT/web/fetch-emdawnwebgpu.sh)}
fi
TINT_OUT=${GEX_MESA_TINT:-${TMPDIR:-/tmp}/gex-mesa-wasm/mesa-tint-pthread}
if [ "$THREADS" = 0 ]; then
TINT_OUT=${GEX_MESA_TINT:-${TMPDIR:-/tmp}/gex-mesa-wasm-st/mesa-tint}
else
TINT_OUT=${GEX_MESA_TINT:-${TMPDIR:-/tmp}/gex-mesa-wasm/mesa-tint-pthread}
fi
TINT_TMP=${GEX_MESA_TINT_TMP:-${TMPDIR:-/tmp}/gex-mesa-tint-build}
command -v meson >/dev/null 2>&1 || { echo "need meson" >&2; exit 1; }
@@ -60,14 +75,15 @@ if [ -z "${GEX_MESA_TINT:-}" ] && [ -f "$MESA/wink/lib/tint.sh" ]; then
WINK_CACHE=${GEX_MESA_WINK_CACHE:-${TMPDIR:-/tmp}/gex-mesa-wink-cache}
EMSDK_ROOT=$(CDPATH='' cd -- "$(dirname "$(command -v emcc)")/../.." && pwd)
tint_log=$(EMCC_CFLAGS="-Wno-c2y-extensions -Wno-deprecated-pragma -Wno-lifetime-safety-intra-tu-suggestions" \
WINK_ALLOW_ANY_EMSDK=1 bash "$MESA/wink/build.sh" --variant=mt --until=tint \
WINK_ALLOW_ANY_EMSDK=1 bash "$MESA/wink/build.sh" --variant=$VARIANT --until=tint \
--cache="$WINK_CACHE" --build-dir="$WINK_CACHE/build" --jobs="${JOBS:-$(nproc)}" \
--emsdk="$EMSDK_ROOT" 2>&1) || { echo "$tint_log" | tail -20 >&2; exit 1; }
TINT_OUT=$(echo "$tint_log" | sed -n 's/^\[tint\/mt\] \(ready\|cached\) at //p' | tail -1)
TINT_OUT=$(echo "$tint_log" | sed -n "s/^\[tint\/$VARIANT\] \(ready\|cached\) at //p" | tail -1)
[ -f "$TINT_OUT/libtint.a" ] || { echo "$tint_log" | tail -20 >&2; echo "no Tint from wink/build.sh" >&2; exit 1; }
fi
[ -f "$TINT_OUT/libtint.a" ] || {
OUTPUT_DIR="$TINT_OUT" TMP_DIR="$TINT_TMP" KEEP_TMP=true WASM_THREADS=true \
WT=true; [ "$THREADS" = 0 ] && WT=false
OUTPUT_DIR="$TINT_OUT" TMP_DIR="$TINT_TMP-$VARIANT" KEEP_TMP=true WASM_THREADS=$WT \
"$ROOT/web/tint/build-tint.sh"
}
@@ -133,6 +149,7 @@ fi
# the build directory while the site served whatever had last been copied by
# hand -- which looks exactly like a change that did not work.
PUB="$ROOT/web/public/gex"
[ "$THREADS" = 0 ] && [ -d "$PUB" ] && PUB="$PUB/st" && mkdir -p "$PUB"
if [ -d "$PUB" ]; then
for f in gex-wgpu-replay.js gex-wgpu-replay.wasm; do
[ -f "$BUILD/src/gallium/targets/gex-wasm/$f" ] || continue
+47
View File
@@ -0,0 +1,47 @@
#!/bin/sh
# Host-side pieces web/build-xwayland-wasm.sh needs that a distro may lack:
# wayland-scanner (a build tool), wayland-protocols (XML), xorgproto's newer
# protocol headers (xwaylandproto), xorg-macros (for the autotools libraries'
# autoreconf) and X11/xshmfence.h (miext/sync/misyncshm.c includes it; nothing
# links its code). Built from source into a prefix, for either flavour of the
# Xwayland build:
#
# web/build-xwayland-host-tools.sh [prefix] # default target/web-build/xwl-host
# GEX_XWL_HOST_TOOLS=<prefix> web/build-xwayland-wasm.sh
#
# Skips everything when <prefix>/.done matches this script, so it is cheap to
# call on every build (web/build-all.sh does).
set -eu
HERE="$(cd "$(dirname "$0")" && pwd)"
ROOT="$(cd "$HERE/.." && pwd)"
P="${1:-$ROOT/target/web-build/xwl-host}"
S="$P/src"
KEY=$(sha256sum "$0" | cut -c1-16)
[ "$(cat "$P/.done" 2>/dev/null)" = "$KEY" ] && { echo "xwayland host tools: cached in $P"; exit 0; }
rm -rf "$P"; mkdir -p "$S"
cd "$S"
clone() { git clone -q --depth 1 --branch "$2" "$1" "$3"; }
clone https://gitlab.freedesktop.org/wayland/wayland.git 1.23.1 wayland
clone https://gitlab.freedesktop.org/wayland/wayland-protocols.git 1.41 wayland-protocols
clone https://gitlab.freedesktop.org/xorg/proto/xorgproto.git xorgproto-2024.1 xorgproto
clone https://gitlab.freedesktop.org/xorg/util/macros.git util-macros-1.20.1 util-macros
clone https://gitlab.freedesktop.org/xorg/lib/libxshmfence.git libxshmfence-1.3.2 libxshmfence
b() {
d=$1; shift
meson setup "$S/build-$d" "$S/$d" --prefix="$P" "$@" > "$S/log-$d.txt" 2>&1 &&
ninja -C "$S/build-$d" install >> "$S/log-$d.txt" 2>&1 ||
{ tail -20 "$S/log-$d.txt"; exit 1; }
echo " built $d"
}
b wayland -Dlibraries=false -Ddocumentation=false -Dtests=false -Ddtd_validation=false
export PKG_CONFIG_PATH="$P/lib/x86_64-linux-gnu/pkgconfig:$P/share/pkgconfig:$P/lib/pkgconfig${PKG_CONFIG_PATH:+:$PKG_CONFIG_PATH}"
export PATH="$P/bin:$PATH"
b wayland-protocols -Dtests=false
b xorgproto
(cd util-macros && autoreconf -fi && ./configure --prefix="$P" && make install) > "$S/log-util-macros.txt" 2>&1 ||
{ tail -20 "$S/log-util-macros.txt"; exit 1; }
echo " built util-macros"
mkdir -p "$P/include/X11"
cp libxshmfence/src/xshmfence.h "$P/include/X11/"
echo "$KEY" > "$P/.done"
echo "xwayland host tools: built into $P"
+53 -8
View File
@@ -71,7 +71,19 @@
# transport. What the page loads is built by the link at the end of this file.
set -eu
OUT="${1:-${TMPDIR:-/tmp}/gex-xwl}"
# GEX_XWL_THREADS=0: the single-thread build's server (docs/single-thread.md).
# Without -pthread anywhere, so its memory is a plain one: the compositor runs
# in the same realm there and reads the pixels by reference, not through a
# SharedArrayBuffer. Its own output directory, published as xwayland-st/.
THREADS=${GEX_XWL_THREADS:-1}
if [ "$THREADS" = 0 ]; then
# And its one wait (gexos.c gexjs_wait) suspends through JSPI instead of
# blocking: GEX_XWL_JSPI, linked with -sJSPI.
PT=""; PTQ="'-DGEX_XWL_JSPI', "; DEFAULT_OUT=${TMPDIR:-/tmp}/gex-xwl-st; PUBDIR=xwayland-st
else
PT="-pthread"; PTQ="'-pthread', "; DEFAULT_OUT=${TMPDIR:-/tmp}/gex-xwl; PUBDIR=xwayland
fi
OUT="${1:-$DEFAULT_OUT}"
PREFIX="$OUT/prefix"
SRC="$OUT/src"
EMSDK="${EMSDK:-/usr/lib/emsdk}"
@@ -80,6 +92,16 @@ WAYLAND_TAG="${WAYLAND_TAG:-}"
HERE="$(cd "$(dirname "$0")" && pwd)"
PATH="$HOME/.local/bin:$PATH"
# GEX_XWL_HOST_TOOLS=<prefix>: the host pieces web/build-xwayland-host-tools.sh
# builds when the distro lacks them (wayland-scanner, wayland-protocols,
# xorgproto, xorg-macros, X11/xshmfence.h).
HT="${GEX_XWL_HOST_TOOLS:-}"
if [ -n "$HT" ]; then
PATH="$HT/bin:$PATH"
PKG_CONFIG_PATH="$HT/lib/x86_64-linux-gnu/pkgconfig:$HT/share/pkgconfig:$HT/lib/pkgconfig${PKG_CONFIG_PATH:+:$PKG_CONFIG_PATH}"
ACLOCAL_PATH="$HT/share/aclocal:/usr/share/aclocal${ACLOCAL_PATH:+:$ACLOCAL_PATH}"
export PKG_CONFIG_PATH ACLOCAL_PATH
fi
command -v meson >/dev/null 2>&1 || { echo "need meson (pip install meson ninja)"; exit 1; }
command -v ninja >/dev/null 2>&1 || { echo "need ninja"; exit 1; }
[ -d "$EMSDK/upstream/emscripten" ] || { echo "no emscripten at $EMSDK"; exit 1; }
@@ -100,6 +122,8 @@ say() { printf '\n== %s\n' "$*"; }
# and the .pc files re-pointed at the copy.
say "staging the X protocol headers"
[ -d "$PREFIX/include/X11" ] || cp -r /usr/include/X11 "$PREFIX/include/"
# And the host tools' newer protocol headers over the distro's.
[ -z "$HT" ] || [ ! -d "$HT/include/X11" ] || cp -r "$HT/include/X11/." "$PREFIX/include/X11/"
mkdir -p "$PREFIX/include/linux"
cp /usr/include/linux/input-event-codes.h "$PREFIX/include/linux/"
for p in xproto randrproto renderproto xextproto inputproto kbproto fontsproto \
@@ -183,8 +207,8 @@ strip = '$EMCC/emstrip'
pkg-config = 'pkg-config'
[built-in options]
c_args = ['-pthread', '-I$PREFIX/include', '-Wno-error', '-Wno-pedantic', '-Wno-c23-extensions', '-Wno-unused-command-line-argument']
c_link_args = ['-pthread', '-L$PREFIX/lib', '-lz']
c_args = [$PTQ'-I$PREFIX/include', '-Wno-error', '-Wno-pedantic', '-Wno-c23-extensions', '-Wno-unused-command-line-argument']
c_link_args = [$PTQ'-L$PREFIX/lib', '-lz']
prefix = '$PREFIX'
libdir = 'lib'
@@ -208,6 +232,17 @@ clone() { # clone <dir> <url> [tag]
fi
}
# The single-thread build: meson's dependency('threads') is -pthread on an
# emscripten target whatever the cross file says, and a library's .pc then
# carries it into every link after it. Taken out of the generated build and the
# installed .pc files, so no object is built for, and no link asks for, a
# shared memory.
strip_pthread() { # strip_pthread <build dir>
[ "$THREADS" = 0 ] || return 0
sed -i -e 's/ -pthread\b//g' -e 's/ -sPTHREAD_POOL_SIZE=[0-9]*//g' "$1/build.ninja"
sed -i -e 's/ *-pthread\b//g' -e 's/ *-sPTHREAD_POOL_SIZE=[0-9]*//g' "$PREFIX"/lib/pkgconfig/*.pc 2>/dev/null || true
}
meson_build() { # meson_build <dir> <extra meson args...>
d=$1; shift
rm -rf "$OUT/build/$d"
@@ -221,7 +256,9 @@ meson_build() { # meson_build <dir> <extra meson args...>
--prefix="$PREFIX" --buildtype=release -Ddefault_library=static \
-Dc_thread_count=0 "$@" \
> "$OUT/log-$d.txt" 2>&1
strip_pthread "$OUT/build/$d"
ninja -C "$OUT/build/$d" install >> "$OUT/log-$d.txt" 2>&1
strip_pthread "$OUT/build/$d"
}
auto_build() { # auto_build <dir> <extra configure args...>
@@ -231,8 +268,8 @@ auto_build() { # auto_build <dir> <extra configure args...>
autoreconf -fiv > "$OUT/log-$d.txt" 2>&1; }
emconfigure ./configure --host=wasm32-unknown-emscripten --prefix="$PREFIX" \
--enable-static --disable-shared \
CFLAGS="-pthread -O2 -Wno-error -I$PREFIX/include" \
LDFLAGS="-pthread -L$PREFIX/lib" "$@" >> "$OUT/log-$d.txt" 2>&1
CFLAGS="$PT -O2 -Wno-error -I$PREFIX/include" \
LDFLAGS="$PT -L$PREFIX/lib" "$@" >> "$OUT/log-$d.txt" 2>&1
emmake make -j"$(nproc)" install >> "$OUT/log-$d.txt" 2>&1
cd "$OUT"
# xtrans and libXau put their .pc in share/pkgconfig; pkg-config here is
@@ -442,6 +479,7 @@ meson setup "$OUT/build/wayland" "$SRC/wayland" --cross-file "$OUT/emscripten.in
> "$OUT/log-wayland.txt" 2>&1
# The client library alone: `ninja install` would also make the server, which
# wants epoll.
strip_pthread "$OUT/build/wayland"
ninja -C "$OUT/build/wayland" src/libwayland-client.a >> "$OUT/log-wayland.txt" 2>&1
cp "$OUT/build/wayland/src/libwayland-client.a" "$PREFIX/lib/"
cp "$SRC/wayland/src/wayland-client.h" "$SRC/wayland/src/wayland-client-core.h" \
@@ -559,7 +597,7 @@ void SHA1Final(unsigned char digest[20], SHA1_CTX *ctx)
digest[i] = (unsigned char) (ctx->state[i / 4] >> (24 - 8 * (i % 4)));
}
EOF
(cd "$SRC/sha1" && "$EMCC/emcc" -O2 -pthread -c sha1.c -o sha1.o && \
(cd "$SRC/sha1" && "$EMCC/emcc" -O2 $PT -c sha1.c -o sha1.o && \
"$EMCC/emar" rcs "$PREFIX/lib/libmd.a" sha1.o && cp sha1.h "$PREFIX/include/")
# A wrong SHA-1 links as happily as a right one, so check the digest natively.
(cd "$SRC/sha1" && cat > check.c <<'EOF'
@@ -656,7 +694,11 @@ say "Xwayland $XWAYLAND_TAG"
# built-ins is libXfont2's built-in `fixed` and `cursor`: the server needs a
# default font to start at all, and shipping font files for that would be
# silly when the library already carries the two that matter.
XWL_LINK="-pthread -sPTHREAD_POOL_SIZE=0 -sALLOW_MEMORY_GROWTH=1"
if [ "$THREADS" = 0 ]; then
XWL_LINK="-sJSPI=1 -sALLOW_MEMORY_GROWTH=1"
else
XWL_LINK="-pthread -sPTHREAD_POOL_SIZE=0 -sALLOW_MEMORY_GROWTH=1"
fi
XWL_LINK="$XWL_LINK -sINITIAL_MEMORY=134217728 -sMAXIMUM_MEMORY=1610612736"
XWL_LINK="$XWL_LINK -sSTACK_SIZE=4194304 -sMODULARIZE=1 -sEXPORT_ES6=1"
XWL_LINK="$XWL_LINK -sENVIRONMENT=web,worker,node -sINVOKE_RUN=0 -sEXIT_RUNTIME=0"
@@ -669,6 +711,8 @@ s = open(p).read()
if '-sMODULARIZE=1' not in s:
words = ', '.join(f"'{w}'" for w in extra.split())
s = s.replace("c_link_args = ['-pthread', ", "c_link_args = [" + words + ", ")
if '-sMODULARIZE=1' not in s:
s = s.replace("c_link_args = [", "c_link_args = [" + words + ", ", 1)
assert '-sMODULARIZE=1' in s, 'the cross file link args did not match'
open(p, 'w').write(s)
PATCH
@@ -683,6 +727,7 @@ meson setup "$OUT/build/xserver" "$SRC/xserver" --cross-file "$OUT/emscripten.in
-Ddpms=false -Dscreensaver=false -Dxv=false -Dmitshm=false \
-Ddefault_font_path=built-ins -Dc_thread_count=0 \
> "$OUT/log-xserver.txt" 2>&1 || { tail -20 "$OUT/log-xserver.txt"; exit 1; }
strip_pthread "$OUT/build/xserver"
ninja -C "$OUT/build/xserver" >> "$OUT/log-xserver.txt" 2>&1 || {
grep -E "error:|undefined symbol" "$OUT/log-xserver.txt" | head -20; exit 1; }
@@ -700,7 +745,7 @@ GEX_ROOT="$(cd "$HERE/.." && pwd)"
HOSTWL="$GEX_ROOT/target/wasm32-unknown-unknown/release/gex_hostwl.wasm"
# -- publish -----------------------------------------------------------------
PUB="$GEX_ROOT/web/public/gex/xwayland"
PUB="$GEX_ROOT/web/public/gex/$PUBDIR"
mkdir -p "$PUB"
cp "$OUT/build/xserver/hw/xwayland/Xwayland.js" "$PUB/Xwayland.mjs"
cp "$OUT/build/xserver/hw/xwayland/Xwayland.wasm" "$PUB/"
+29
View File
@@ -0,0 +1,29 @@
# The single-thread build's (docs/single-thread.md): as mesa-wasm-cross.ini
# without -pthread, so the module's memory is not shared and it runs in a
# page that is not cross-origin isolated. Mesa follows the cross file
# (-Dgex-wasm-threads=auto): no glthread, no driver thread.
[binaries]
c = 'emcc'
cpp = 'em++'
ar = 'emar'
strip = 'emstrip'
pkg-config = 'false'
[host_machine]
system = 'emscripten'
cpu_family = 'wasm32'
cpu = 'wasm32'
endian = 'little'
[properties]
needs_exe_wrapper = true
# SIMD and ThinLTO, and binaryen at -O3 when linking (emcc runs it for a
# linking -O3): pewasm's Mesa overlay (tools/optimization_config.py --opt 3
# --simd --thin-lto). A replayed Noita frame costs 7% less CPU in
# gex_gl_submit (5.09 and 4.99 ms against 5.45 and 5.47; docs/gl-path.md).
[built-in options]
c_args = ['-D_GNU_SOURCE', '-D_POSIX_C_SOURCE=200809L', '-msimd128', '-flto=thin']
cpp_args = ['-D_GNU_SOURCE', '-D_POSIX_C_SOURCE=200809L', '-msimd128', '-flto=thin']
c_link_args = ['-msimd128', '-flto=thin', '-O3']
cpp_link_args = ['-msimd128', '-flto=thin', '-O3']
Binary file not shown.
Binary file not shown.
Binary file not shown.
Regular → Executable
BIN
View File
Binary file not shown.
Binary file not shown.
Regular → Executable
BIN
View File
Binary file not shown.
Binary file not shown.
Binary file not shown.
+19 -7
View File
@@ -16,16 +16,28 @@ set -eu
HERE="$(cd "$(dirname "$0")" && pwd)"
[ -d "$HERE/dist" ] || exit 0
# web/build-all.sh rebuilds the dists itself once its steps are done.
[ -z "${GEX_NO_REFRESH_DIST:-}" ] || exit 0
# One dist per flavour (web/build-all.sh): web/dist (MP) and web/dist-sp (SP).
# Each that exists is rebuilt from web/public the way build-all.sh makes it.
flavours=""
[ -d "$HERE/dist" ] && flavours="$flavours mp"
[ -d "$HERE/dist-sp" ] && flavours="$flavours sp"
[ -n "$flavours" ] || exit 0
if [ -d "$HERE/node_modules" ] && command -v npm >/dev/null 2>&1; then
if (cd "$HERE" && npm run --silent build >/dev/null 2>&1); then
echo " web/dist: rebuilt"
ok=1
for f in $flavours; do
"$HERE/build-all.sh" --$f --dist-only >/dev/null 2>&1 || ok=0
done
if [ $ok = 1 ]; then
echo " web/dist*: rebuilt ($flavours )"
exit 0
fi
echo " web/dist: vite build failed" >&2
echo " web/dist*: build failed" >&2
fi
rm -rf "$HERE/dist"
echo " web/dist: removed -- it was older than what was just installed." >&2
echo " Run 'npm run build' in web/ to make it again." >&2
rm -rf "$HERE/dist" "$HERE/dist-sp"
echo " web/dist, web/dist-sp: removed -- they were older than what was just installed." >&2
echo " Run web/build-all.sh to make them again." >&2
+39 -13
View File
@@ -1,6 +1,11 @@
// Serve the built site, cross-origin isolated, on a fixed port.
//
// node web/serve-dist.mjs [port]
// node web/serve-dist.mjs [port] [--sp-port N]
//
// `--sp-port N` also serves web/dist-sp, the single-thread build's dist
// (web/build-all.sh --sp; docs/single-thread.md), on N without the isolation
// headers: MP on [port], SP on N, side by side. `GEX_NO_COI=1` drops the
// headers on [port] too.
//
// Not `vite dev`/`preview`: the dev server has served stale JavaScript against
// a freshly built shim, so a measurement against it means nothing. `web/dist`
@@ -12,12 +17,18 @@
// `[gex] pid N: lived Xms (guest Yms)` -- guest-side timing reads zero (#134).
import { createServer } from 'node:http';
import { appendFileSync } from 'node:fs';
import { readFile, stat } from 'node:fs/promises';
import { extname, join, normalize } from 'node:path';
import { fileURLToPath } from 'node:url';
const root = join(fileURLToPath(new URL('.', import.meta.url)), 'dist');
const port = Number(process.argv[2] ?? 8088);
const web = fileURLToPath(new URL('.', import.meta.url));
const root = join(web, 'dist');
const spRoot = join(web, 'dist-sp');
const args = process.argv.slice(2);
const spAt = args.indexOf('--sp-port');
const spPort = spAt >= 0 ? Number(args.splice(spAt, 2)[1]) : 0;
const port = Number(args[0] ?? 8088);
const TYPES = {
'.html': 'text/html; charset=utf-8',
@@ -33,7 +44,7 @@ const TYPES = {
'.ico': 'image/x-icon',
};
const server = createServer(async (req, res) => {
const serve = (root, coi) => async (req, res) => {
const url = new URL(req.url, 'http://x');
// `normalize` and then reject anything that climbs out: this serves a
// directory, not the filesystem.
@@ -45,9 +56,13 @@ const server = createServer(async (req, res) => {
// A missing path is answered below, once, rather than here and there.
}
const headers = {
// What buys `SharedArrayBuffer`. See the note at the top.
'Cross-Origin-Opener-Policy': 'same-origin',
'Cross-Origin-Embedder-Policy': 'require-corp',
// What buys `SharedArrayBuffer`. See the note at the top. `GEX_NO_COI=1`
// leaves them off, as any static host would: the page then runs the
// single-thread build (docs/single-thread.md).
...(!coi ? {} : {
'Cross-Origin-Opener-Policy': 'same-origin',
'Cross-Origin-Embedder-Policy': 'require-corp',
}),
// The closure is hundreds of MB and not content-hashed, so a cache would
// serve yesterday's libc against today's shim.
'Cache-Control': 'no-store',
@@ -60,15 +75,26 @@ const server = createServer(async (req, res) => {
'content-length': body.length,
});
res.end(body);
// GEX_SERVE_LOG=FILE: one line per file served, `<dist> <path> <bytes>`,
// to account for what a page fetches.
if (process.env.GEX_SERVE_LOG) appendFileSync(process.env.GEX_SERVE_LOG, `${root} ${rel} ${body.length}\n`);
} catch {
res.writeHead(404, { ...headers, 'content-type': 'text/plain' }).end(`no ${rel}\n`);
}
});
};
server.listen(port, '0.0.0.0', () => {
const coi = !process.env.GEX_NO_COI;
createServer(serve(root, coi)).listen(port, '0.0.0.0', () => {
console.log(`serving ${root}`);
console.log(` http://127.0.0.1:${port}/`);
console.log('');
console.log(' cross-origin isolated, so SharedArrayBuffer works');
console.log(' open the console for `[gex] pid N: lived Xms (guest Yms)`');
console.log(` http://127.0.0.1:${port}/ ${coi ? 'multi-process (cross-origin isolated, SharedArrayBuffer)' : 'single-thread (no COOP/COEP)'}`);
if (!spPort) {
console.log('');
console.log(' open the console for `[gex] pid N: lived Xms (guest Yms)`');
}
});
if (spPort) {
createServer(serve(spRoot, false)).listen(spPort, '0.0.0.0', () => {
console.log(`serving ${spRoot}`);
console.log(` http://127.0.0.1:${spPort}/ single-thread (no COOP/COEP)`);
});
}
+72 -14
View File
@@ -904,7 +904,7 @@ class Compositor {
this.#canvas.height = d.h;
this.#directSerial = -1;
this.#cursorSerial = -1;
postMessage({ kind: 'direct', w: d.w, h: d.h });
post({ kind: 'direct', w: d.w, h: d.h });
this.#note(`direct present ${d.w}x${d.h} (${d.k})`);
}
this.#direct = { k: d.k, w: d.w, h: d.h, ox: d.ox, oy: d.oy };
@@ -928,7 +928,7 @@ class Compositor {
this.#canvas.width = this.#outW;
this.#canvas.height = this.#outH;
this.#lastSig = null;
postMessage({ kind: 'direct', w: 0, h: 0 });
post({ kind: 'direct', w: 0, h: 0 });
this.#note('direct present off: compositing');
}
@@ -939,7 +939,7 @@ class Compositor {
let found = null;
for (const [k, win] of this.#windows) if (win.role === ROLE_CURSOR) { found = [k, win]; break; }
if (!found) {
if (this.#cursorSerial !== 0) postMessage({ kind: 'cursor', css: 'none' });
if (this.#cursorSerial !== 0) post({ kind: 'cursor', css: 'none' });
this.#cursorSerial = 0;
return;
}
@@ -961,7 +961,7 @@ class Compositor {
cv.getContext('2d').putImageData(new ImageData(px, s.width, s.height), 0, 0);
const hx = Math.max(0, win.relX | 0), hy = Math.max(0, win.relY | 0);
cv.convertToBlob().then((blob) => {
postMessage({ kind: 'cursor', css: `url(${URL.createObjectURL(blob)}) ${hx} ${hy}, default` });
post({ kind: 'cursor', css: `url(${URL.createObjectURL(blob)}) ${hx} ${hy}, default` });
}).catch(() => {});
}
@@ -1307,6 +1307,10 @@ class Compositor {
// -- the worker's protocol --------------------------------------------------
/** Where this side's messages go: the page, from this Worker -- or, in the
* single-thread build, whatever `inRealm` was given. */
let post = (m, transfer) => self.postMessage(m, transfer ?? []);
let comp = null;
let canvas = null;
let running = false;
@@ -1322,7 +1326,7 @@ function loop() {
glDevice?.display?.();
comp.frame();
} catch (e) {
postMessage({ kind: 'error', error: e?.stack || String(e) });
post({ kind: 'error', error: e?.stack || String(e) });
running = false;
return;
}
@@ -1418,10 +1422,49 @@ async function startGl(m, canvas) {
});
if (!glDevice.ok) throw new Error(glDevice.error || 'the GL device would not start');
glChannel = m.channel;
postMessage({ kind: 'gl-ready', describe: glDevice.describe() });
post({ kind: 'gl-ready', describe: glDevice.describe() });
// The single-thread build (docs/single-thread.md): no ring and no
// SharedArrayBuffer. The guests are fibers of this same realm, and their
// `gl_submit` calls `glInRealm.submit` on their own memory (gex.mjs).
if (!glChannel) {
glInRealm.resolve?.(glInRealm);
return;
}
serveGlRing();
}
/**
* The single-thread build's GL service: `submit(memory, ring, client)` runs
* a guest's ring straight out of its memory, one submission at a time (the
* device is one wasm module, and a JSPI submit that suspends must not be
* re-entered: see `serveGl`). Resolves to the device's answer. Submissions
* made before the device is up wait for it.
*/
export const glInRealm = {
resolve: null,
ready: null,
tail: Promise.resolve(),
submit(memory, ring, client) {
const run = async () => {
await glInRealm.ready;
if (!glDevice?.ok) return -1;
try {
return await glDevice.submit(memory, ring, client);
} catch (e) {
console.error('[gl] the device threw', e);
return -1;
}
};
const r = glInRealm.tail.then(run);
glInRealm.tail = r.then(() => {}, () => {});
return r;
},
};
glInRealm.ready = new Promise((resolve) => (glInRealm.resolve = resolve));
// Where the fibers find it (shims/browser/js/worker.mjs), without importing
// the page's sources.
if (globalThis.GEX_COMPOSITOR_IN_REALM) globalThis.__gexGlInRealm = glInRealm;
/** Run the guest's submissions as they arrive.
*
* `serveGl` drives itself where `Atomics.waitAsync` exists, which is a worker
@@ -1434,7 +1477,7 @@ async function serveGlRing() {
serveGl(glDevice, glChannel);
}
self.onmessage = ({ data: m }) => {
const onMessage = ({ data: m }) => {
try {
if (m.kind === 'init') {
canvas = m.canvas;
@@ -1446,14 +1489,14 @@ self.onmessage = ({ data: m }) => {
comp.setDirect(m.direct);
running = true;
requestAnimationFrame(loop);
postMessage({ kind: 'ready' });
}).catch((e) => postMessage({ kind: 'error', error: e?.stack || String(e) }));
post({ kind: 'ready' });
}).catch((e) => post({ kind: 'error', error: e?.stack || String(e) }));
} else if (m.kind === 'gl') {
// The guest's GL, hosted here for the reason above. Reported rather
// than thrown: a session whose GL will not start should still show its
// other windows.
startGl(m, canvas).catch((e) =>
postMessage({ kind: 'gl-failed', error: e?.message || String(e) }));
post({ kind: 'gl-failed', error: e?.message || String(e) }));
} else if (m.kind === 'memory') {
comp?.addMemory(m.token, m.memory);
} else if (m.kind === 'drop-memory') {
@@ -1476,16 +1519,31 @@ self.onmessage = ({ data: m }) => {
} else if (m.kind === 'wgsl-cache') {
// The shader translation cache, for a training run to record.
const blob = glDevice?.wgslCache?.() ?? null;
postMessage({ kind: 'wgsl-cache', id: m.id, blob, stats: glDevice?.wgslStats?.() ?? null }, blob ? [blob] : []);
post({ kind: 'wgsl-cache', id: m.id, blob, stats: glDevice?.wgslStats?.() ?? null }, blob ? [blob] : []);
} else if (m.kind === 'readout') {
// The composited output, read back (RGBA rows, top first): what a test saves as a picture.
Promise.resolve(comp?.readout?.() ?? null).then((r) => postMessage({ kind: 'readout', id: m.id, state: r }, r ? [r.px.buffer] : []));
Promise.resolve(comp?.readout?.() ?? null).then((r) => post({ kind: 'readout', id: m.id, state: r }, r ? [r.px.buffer] : []));
} else if (m.kind === 'describe') {
Promise.resolve(comp?.describe() ?? null).then((state) => postMessage({ kind: 'describe', id: m.id, state }));
Promise.resolve(comp?.describe() ?? null).then((state) => post({ kind: 'describe', id: m.id, state }));
} else if (m.kind === 'stop') {
running = false;
}
} catch (e) {
postMessage({ kind: 'error', error: e?.stack || String(e) });
post({ kind: 'error', error: e?.stack || String(e) });
}
};
/**
* The single-thread build (docs/single-thread.md): this compositor in the
* realm that holds the guests, since their memories are plain ones no other
* Worker can read. `send(m, transfer)` carries what this side would post to
* the page; the result takes the page's messages, as `onmessage` would.
*/
export function inRealm(send) {
post = send;
return (m) => onMessage({ data: m });
}
// Imported for `inRealm` (by the system Worker), this module must not take
// that Worker's own messages.
if (!globalThis.GEX_COMPOSITOR_IN_REALM) self.onmessage = onMessage;
+11 -7
View File
@@ -43,9 +43,11 @@ export class Compositor {
#next = 1;
#stats;
static async create(canvas, channel, inputTarget = canvas) {
/** `makeWorker`: what stands in for compositor-worker.js's Worker (the
* single-thread build's system Worker hosts it; see runtime.js). */
static async create(canvas, channel, inputTarget = canvas, { makeWorker = null } = {}) {
const c = new Compositor();
await c.#start(canvas, channel, inputTarget);
await c.#start(canvas, channel, inputTarget, makeWorker);
return c;
}
@@ -82,7 +84,7 @@ export class Compositor {
});
}
async #start(canvas, channel, inputTarget) {
async #start(canvas, channel, inputTarget, makeWorker) {
const sameTarget = inputTarget === canvas;
// A canvas can be handed to a worker once and never again, and a page
// that runs a session twice hands over the same element the second time:
@@ -99,10 +101,12 @@ export class Compositor {
offscreen = canvas.transferControlToOffscreen();
}
this.#canvas = canvas;
this.#stats = new SharedArrayBuffer(16);
const worker = new Worker(new URL('./compositor-worker.js', import.meta.url), {
type: 'module',
});
// Without a SharedArrayBuffer (the single-thread build) the counters are
// a copy `makeWorker`'s stand-in keeps current.
this.#stats = typeof SharedArrayBuffer === 'function' ? new SharedArrayBuffer(16) : new ArrayBuffer(16);
const worker = makeWorker
? makeWorker()
: new Worker(new URL('./compositor-worker.js', import.meta.url), { type: 'module' });
this.#worker = worker;
const ready = new Promise((resolve, reject) => {
const onMessage = ({ data: m }) => {
+14 -7
View File
@@ -1,7 +1,7 @@
// The demos. Each has an `enter(ctx)` that owns the terminal until the next is
// chosen; `ctx` carries the page's chrome so a demo never touches the DOM.
import { archAsset, multiarch, elfMachine, guestArch } from './runtime.js';
import { archAsset, multiarch, elfMachine, guestArch, singleMode } from './runtime.js';
const running = (on) => window.dispatchEvent(new CustomEvent('gex:running', { detail: on }));
@@ -559,9 +559,16 @@ const gears = {
// st/mesa and the gex_wgpu Gallium driver below the decoder: the only
// browser GL backend (there is no WebGL path).
const gl = await (await import('./mesa-gl.js')).MesaGlDevice.create(
//
// The single-thread build (docs/single-thread.md) has no device on the
// page: the canvas becomes the compositor's output, and the non-pthread
// Mesa runs beside the compositor in the system Worker (`gl: 'compositor'`).
// The guest reports its own verdict from a readback, so a run is checked
// whether or not a frame reaches this canvas.
const singleGl = singleMode();
const gl = singleGl ? null : await (await import('./mesa-gl.js')).MesaGlDevice.create(
canvas, mesaBackend(query, 'es', 'wgpu'));
if (!gl.ok) {
if (gl && !gl.ok) {
ctx.term.writeln(ctx.ansi.red(gl.error || 'This browser has no usable GPU API.'));
ctx.status('no GPU', 'bad');
return;
@@ -569,7 +576,7 @@ const gears = {
ctx.term.writeln('Three interlocking gears, drawn by an aarch64 binary.');
ctx.term.writeln('');
ctx.term.writeln(ctx.ansi.dim(` ${gl.describe()}`));
ctx.term.writeln(ctx.ansi.dim(` ${gl ? gl.describe() : 'Mesa (gex_wgpu, single-thread build) in the system Worker'}`));
ctx.term.writeln('');
ctx.term.writeln(
ctx.ansi.dim('The guest runs no graphics driver. Mesa would cost tens of thousands of'),
@@ -596,7 +603,7 @@ const gears = {
// so nothing noticed.
let displayRaf = 0;
const pump = () => {
gl.display?.();
gl?.display?.();
displayRaf = requestAnimationFrame(pump);
};
const stopPump = () => {
@@ -667,13 +674,13 @@ const gears = {
env: guestEnv,
cwd: '/',
tty: true,
gl,
...(gl ? { gl } : { gl: 'compositor', wl: canvas, wlInput: canvas }),
});
// Once more now the guest has exited: its last swapped frame is
// produced but not yet copied, and the loop above stops with it. A
// short run would otherwise leave the canvas on an earlier frame, or
// on nothing at all.
gl.display?.();
gl?.display?.();
stopPump();
const ms = (performance.now() - t0).toFixed(0);
ctx.term.writeln('');
+29 -1
View File
@@ -28,6 +28,7 @@
import { WlProxy, outputSize, REC, EV, SLOTS } from '../../shims/browser/js/wl-bridge.mjs';
import { lazyFetch } from '../../shims/browser/js/lazy-fetch.mjs';
import { single, sched } from '../../shims/browser/js/single.mjs';
/** Events taken from the page in one turn. */
const MAX_INPUT = 64;
@@ -211,7 +212,16 @@ export class HostX {
this.emit({ kind: 'ready' });
// And from here the X server owns this thread: `main` does not return, and
// everything else happens inside `tick`.
this.#x.callMain(argv);
const ran = this.#x.callMain(argv);
// Built with JSPI (the single-thread build), `main` is a promise that
// suspends at every wait and this thread is free between them.
if (ran instanceof Promise) {
ran.then(
() => this.log('[hostx] the X server returned, which it should not'),
(e) => this.log(`[hostx] the X server failed: ${e?.stack || e}`),
);
return;
}
this.log('[hostx] the X server returned, which it should not');
}
@@ -513,11 +523,29 @@ export class HostX {
* what makes the X server's loop look exactly like a native one.
*/
wait(ms) {
// The single-thread build (docs/single-thread.md): the server is a
// fiber of the system Worker, so it parks on the scheduler, and the
// wait is a promise its stack suspends on (gexos.c GEX_XWL_JSPI).
if (single.on) return sched.park(Math.max(1, ms) * 1e6).then(() => 0);
const seq = self.#k.k_wake_seq();
self.#k.k_park(seq, BigInt(Math.max(1, ms)) * 1000000n);
return 0;
},
};
// And what it does to a guest's socket is a wake the guests' fibers must
// see: a guest process bumps the scheduler after every system call
// (gex.mjs `k_wake`), and this server makes its socket calls straight
// into the kernel module, which has no way to say so.
if (single.on) {
for (const name of ['connect', 'accept', 'close', 'send', 'recv']) {
const fn = raw[name];
raw[name] = (...a) => {
const r = fn(...a);
sched.wake();
return r;
};
}
}
if (!this.#trace) return raw;
// `?hostxtrace=1`: narrate the transport. A server that gives up without
// saying why has usually asked for one thing and been told another, and
+5
View File
@@ -291,6 +291,11 @@ for (const [event, describe] of [
// fault to one host.
if (params.has('recompile')) globalThis.GEX_RECOMPILE_OBJECTS = Number(params.get('recompile'));
if (params.has('permiss')) globalThis.GEX_PER_MISS = Number(params.get('permiss'));
if (params.has('liftmax')) globalThis.GEX_LIFT_MAX = Number(params.get('liftmax'));
if (params.has('unitcache')) globalThis.GEX_UNIT_CACHE = Number(params.get('unitcache'));
if (params.has('t1fold')) globalThis.GEX_T1_FOLD = Number(params.get('t1fold'));
if (params.has('xlatereport')) globalThis.GEX_XLATE_REPORT = Number(params.get('xlatereport'));
if (params.has('inlinedispatch')) globalThis.GEX_INLINE_DISPATCH = Number(params.get('inlinedispatch'));
// `?link=1`: keep the address space the guest's `ld.so` produced and start
// the next process of the same program from it.
if (params.has('link')) globalThis.GEX_LINK = Number(params.get('link'));
+5
View File
@@ -440,6 +440,11 @@ export class MesaGlDevice {
if (tail) this.#module.HEAPU8.set(src, ptr);
const finish = (rc) => {
// Taken again: a JSPI submit suspends, and a plain guest memory (the
// single-thread build's) that another thread grew meanwhile has a new
// buffer -- the old view is detached and writes into it are lost.
// eslint-disable-next-line no-shadow
const words = new Uint32Array(memory.buffer, ring, HEADER_BYTES / 4);
const elapsed = performance.now() - started;
this.#submitMs += elapsed;
// A frame's readback (the NT host reads every frame back to blit it
+9
View File
@@ -58,6 +58,11 @@ export class WispNetwork {
/** `[id, host, port]` of connects made while redialling. */
#waiting = [];
/** Called when a stream opens, receives or closes. The single-thread
* build wakes its parked fibers here (a guest waiting on a socket sleeps
* on the scheduler, not on this thread's event loop). */
onActivity = () => {};
constructor(wispUrl, onEvent = () => {}) {
this.#url = wispUrl.endsWith('/') ? wispUrl : `${wispUrl}/`;
this.#onEvent = onEvent;
@@ -151,6 +156,7 @@ export class WispNetwork {
// The Wisp client reports the connection as open once the proxy has it.
w.onopen = () => {
s.state = OPEN;
this.onActivity();
};
w.onmessage = (data) => {
const bytes = data instanceof Uint8Array ? data : new Uint8Array(data);
@@ -181,12 +187,15 @@ export class WispNetwork {
}
s.chunks.push(bytes);
s.buffered += bytes.length;
this.onActivity();
};
w.onclose = () => {
s.state = s.state === CONNECTING ? REFUSED : CLOSED;
this.onActivity();
};
w.onerror = () => {
s.state = s.state === CONNECTING ? REFUSED : CLOSED;
this.onActivity();
};
// Some builds of the client open synchronously and never fire `onopen`.
if (w.open === true || s.wisp?.open === true) s.state = OPEN;
+6
View File
@@ -144,6 +144,12 @@ async function main() {
if (q.get('attachtrace')) globalThis.GEX_ATTACH_TRACE = 1;
// `?strace=1`: narrate every syscall (`gex_set_trace`), as main.js does.
if (q.get('strace')) globalThis.GEX_STRACE = 1;
// The translator's switches, as main.js reads them (docs/jit-guest.md).
if (q.get('liftmax')) globalThis.GEX_LIFT_MAX = Number(q.get('liftmax'));
if (q.get('unitcache')) globalThis.GEX_UNIT_CACHE = Number(q.get('unitcache'));
if (q.get('t1fold')) globalThis.GEX_T1_FOLD = Number(q.get('t1fold'));
if (q.get('xlatereport')) globalThis.GEX_XLATE_REPORT = Number(q.get('xlatereport'));
if (q.get('inlinedispatch')) globalThis.GEX_INLINE_DISPATCH = Number(q.get('inlinedispatch'));
const [w, h] = (q.get('size') || '1024x768').split('x').map(Number);
const canvas = document.getElementById('out');
canvas.width = w;
+185 -11
View File
@@ -8,6 +8,7 @@
import { Env } from '../../shims/browser/js/gex.mjs';
import { ProcessHost } from '../../shims/browser/js/host.mjs';
import { PageTty } from '../../shims/browser/js/tty.mjs';
import { single, singleSupported, FiberWorker } from '../../shims/browser/js/single.mjs';
const ICANON = 0x2;
const ECHO = 0x8;
@@ -92,6 +93,13 @@ async function fetchBytes(url, onProgress) {
return checkWasm(url, res, out);
}
/** The page's copies, handed to the system Worker so the single build does
* not fetch the 5 MB shim a second time. */
export function primeRuntime(r) {
if (r?.shimBytes && !shimBytes) shimBytes = r.shimBytes;
if (r?.seedBytes && !seedBytes) seedBytes = r.seedBytes;
}
/** The two wasm modules every demo needs, fetched once for the page's life. */
export async function loadRuntime(onProgress) {
if (!shimBytes) shimBytes = await fetchBytes(SHIM_URL(), onProgress);
@@ -107,7 +115,9 @@ export { fetchBytes };
* arrive there as `GEX_*` environment variables.
*/
function flag(name) {
const search = globalThis.location?.search;
// The single-thread build's system Worker is handed the page's query: its
// own `location` is the script's.
const search = globalThis.GEX_PAGE_SEARCH ?? globalThis.location?.search;
if (search) return new URLSearchParams(search).get(name);
return globalThis.process?.env?.[`GEX_${name.toUpperCase()}`] ?? null;
}
@@ -310,8 +320,10 @@ async function loadKernel() {
if (kernelSpec !== null) return kernelSpec || null;
// Test for `SharedArrayBuffer` directly, not `crossOriginIsolated`: it is
// what is actually needed, and asking for the constructor works under Node
// too (which lets the terminal test drive this same file).
if (typeof SharedArrayBuffer !== 'function') {
// too (which lets the terminal test drive this same file). The
// single-thread build needs none: its kernel memory is plain, held by
// every fiber.
if (typeof SharedArrayBuffer !== 'function' && !single.on) {
kernelSpec = false;
return null;
}
@@ -340,6 +352,8 @@ async function loadKernel() {
* path is kept out of a literal so the bundler leaves it alone.
*/
async function guestWorkerFactory() {
// The single-thread build: a process is a fiber of this realm.
if (single.on) return () => new FiberWorker();
if (typeof Worker === 'function') {
return () =>
new Worker(new URL('../../shims/browser/js/worker.mjs', import.meta.url), {
@@ -360,7 +374,129 @@ async function guestWorkerFactory() {
* replaced the snapshot switcher. Each process has its own memory; what they
* share is in the global kernel, instantiated against one shared memory.
*/
export async function runProgram(
/**
* Whether this page runs the single-thread build (docs/single-thread.md):
* `?single=1` asks for it, `?single=0` refuses it, and otherwise a page that
* has no SharedArrayBuffer (not cross-origin isolated) gets it when the engine
* has JSPI. A cross-origin isolated page keeps the Worker build.
*/
export function singleMode() {
if (single.on) return true;
const want = flag('single');
if (want === '0') return false;
if (!singleSupported()) return false;
return want === '1' || typeof SharedArrayBuffer !== 'function';
}
/** What of the page's state the system Worker needs: its query and every
* `GEX_*` switch a demo or `main.js` set. */
function pageGlobals() {
const out = { GEX_PAGE_SEARCH: globalThis.location?.search ?? '' };
for (const k of Object.keys(globalThis)) {
if (!k.startsWith('GEX_')) continue;
const v = globalThis[k];
if (v === null || ['string', 'number', 'boolean', 'undefined'].includes(typeof v)) out[k] = v;
}
return out;
}
/**
* `runProgram` for the single-thread build, from the page: the whole system
* runs in one Worker (web/src/system-worker.js); this side holds the terminal
* and forwards keys, sizes and output.
*/
async function runInSystemWorker(term, opts) {
const { network, onNetEvent, wl, wlInput, gl, hostX, preModules, closure, ...rest } = opts;
// Said once, so a person (and tests/single/run.sh) can tell which build ran.
term.writeln?.('gex: single-thread build (no SharedArrayBuffer): every guest thread in one Worker');
// The compositor: a canvas only (not the DOM desktop), drawn by the system
// Worker, which holds every guest memory it reads pixels out of.
const canvas = typeof wl?.getContext === 'function' && typeof wl.transferControlToOffscreen === 'function' ? wl : null;
// GL: the compositor's own (`gl: 'compositor'`) runs in the system Worker
// beside it, the non-pthread Mesa (web/public/gex/st/); a page-side device
// does not exist there.
const glHere = gl === 'compositor' && !!canvas;
// The X server too: in the system Worker, beside the compositor.
const xHere = !!hostX && !!canvas;
if ((wl && !canvas) || (gl && !glHere) || (hostX && !xHere)) {
term.writeln?.(`gex: the single-thread build has no ${[wl && !canvas && 'DOM desktop', gl && !glHere && 'GL', hostX && !xHere && 'X server'].filter(Boolean).join(', ')} yet${canvas ? '' : '; running without the screen'}`);
}
const w = new Worker(new URL('./system-worker.js', import.meta.url), { type: 'module' });
globalThis.__gexSystemWorker = w;
const ready = new Promise((resolve, reject) => {
w.addEventListener('message', (e) => { if (e.data?.kind === 'ready') resolve(); });
w.addEventListener('error', (e) => reject(new Error(e.message || 'the system Worker failed to start')));
});
await ready;
// The compositor's page side (listeners, canvas size) talks to its worker
// half through the system Worker: `comp` messages in, `comp-out` back.
const compListeners = new Set();
let compStats = null;
const compPort = {
postMessage(m, transfer = []) {
if (m?.kind === 'init' && m.stats) compStats = new Int32Array(m.stats);
w.postMessage({ kind: 'comp', m }, transfer);
},
addEventListener(type, f) { if (type === 'message') compListeners.add(f); },
removeEventListener(type, f) { compListeners.delete(f); },
terminate() {},
};
w.addEventListener('message', (e) => {
const m = e.data;
if (m?.kind === 'comp-out') for (const f of [...compListeners]) f({ data: m.m });
else if (m?.kind === 'comp-stats' && compStats) compStats.set(m.stats.subarray(0, compStats.length));
});
let compositor = null;
if (canvas) {
const { Compositor } = await import('./compositor.js');
// Before `run`: the Worker takes the canvas first, then starts the guest.
w.postMessage({ kind: 'comp-open', width: canvas.width, height: canvas.height });
compositor = await Compositor.create(canvas, null, wlInput || canvas, { makeWorker: () => compPort });
globalThis.__gexCompositor = compositor;
}
const keys = term.onData((data) => w.postMessage({ kind: 'key', data }));
const resize = term.onResize(({ rows, cols }) => w.postMessage({ kind: 'resize', rows, cols }));
term.focus?.();
try {
return await new Promise((resolve, reject) => {
w.addEventListener('message', (e) => {
const m = e.data;
if (m.kind === 'write') term.write(m.data);
else if (m.kind === 'net') onNetEvent?.(m.event);
else if (m.kind === 'done') resolve({ status: m.status, host: null, tenv: null });
else if (m.kind === 'failed') reject(Object.assign(new Error(m.message), { workerStack: m.stack }));
});
w.postMessage({
kind: 'run',
globals: pageGlobals(),
// Already fetched by the page (main.js loadRuntime): copied, not
// fetched again by the Worker.
runtime: shimBytes ? { shimBytes, seedBytes } : undefined,
rows: term.rows,
cols: term.cols,
opts: {
...rest,
networkUrl: network?.url ?? null,
wl: canvas ? { inRealm: true } : undefined,
gl: glHere ? 'compositor' : undefined,
hostX: xHere || undefined,
},
});
});
} finally {
keys.dispose();
resize.dispose();
compositor?.stop();
w.terminate();
}
}
export async function runProgram(term, opts) {
if (singleMode() && !single.on) return runInSystemWorker(term, opts);
return runProgramHere(term, opts);
}
async function runProgramHere(
term,
{
elf,
@@ -482,7 +618,12 @@ export async function runProgram(
// gated on `have_gl()`) and gles-gears died at `eglInitialize`/`ring_open`.
let glChannel = null;
let glServe = null;
if (gl) {
if (gl && single.on) {
// The single-thread build: no ring. Each process's `gl_submit` calls the
// compositor's Mesa in this realm on its own memory (compositor-worker.js
// `glInRealm`), started by the system Worker.
glChannel = { inRealm: true };
} else if (gl) {
if (!globalThis.crossOriginIsolated) {
throw new Error('the GL demo needs a cross-origin isolated page (SharedArrayBuffer bridge).');
}
@@ -518,7 +659,11 @@ export async function runProgram(
// a shared arena of surface descriptors -- never a frame.
let wlChannel = null;
let compositor = null;
if (wl) {
if (wl?.inRealm) {
// The single-thread build: the compositor is in this realm, set up by the
// system Worker before this run (system-worker.js).
({ channel: wlChannel, compositor } = wl.inRealm);
} else if (wl) {
if (!globalThis.crossOriginIsolated) {
throw new Error('the Wayland demo needs a cross-origin isolated page (SharedArrayBuffer bridge).');
}
@@ -566,7 +711,23 @@ export async function runProgram(
let xServer = null;
if (hostX && wlChannel) {
if (!kernel) throw new Error('the host X server needs the global kernel');
xServer = new Worker(new URL('./xwayland-worker.js', import.meta.url), { type: 'module' });
// The single-thread build (docs/single-thread.md): the server is a fiber of
// this realm, the non-pthread Xwayland (web/public/gex/xwayland-st/) whose
// waits suspend. The same messages, without a Worker between.
const xDir = single.on ? '/gex/xwayland-st/' : '/gex/xwayland/';
if (single.on) {
const { HostX } = await import('./hostx.js');
let deliver = null;
const hx = new HostX((m) => deliver?.({ data: m }));
xServer = {
set onmessage(f) { deliver = f; },
postMessage: (m) => hx.start(m).catch((err) => {
hx.log(`[hostx] ${err?.stack || err}`);
deliver?.({ data: { kind: 'failed', why: String(err?.message || err) } });
}),
terminate() {},
};
} else xServer = new Worker(new URL('./xwayland-worker.js', import.meta.url), { type: 'module' });
// Waited for below: a guest that starts before the display is listening
// gets "Can't open display" and dies, and the display here is a name in the
// global kernel that only exists once the server has bound it.
@@ -584,9 +745,9 @@ export async function runProgram(
};
xServer.postMessage({
kind: 'start',
xwaylandUrl: new URL('/gex/xwayland/Xwayland.mjs', location.href).href,
hostwlUrl: new URL('/gex/xwayland/gexhostwl.wasm', location.href).href,
keymapUrl: new URL('/gex/xwayland/gex.xkm', location.href).href,
xwaylandUrl: new URL(`${xDir}Xwayland.mjs`, location.href).href,
hostwlUrl: new URL(`${xDir}gexhostwl.wasm`, location.href).href,
keymapUrl: new URL(`${xDir}gex.xkm`, location.href).href,
// The X server's own memory is a wasm32 one, so with `?mem64=1` it takes the
// wasm32 build of the kernel (same kernel memory, other process-memory type).
kernel: { module: m64Infix() ? await WebAssembly.compile(await fetchBytes(`/gex/gex_kernel${archInfix()}.wasm`)) : kernel.module, memory: kernel.memory },
@@ -655,7 +816,10 @@ export async function runProgram(
// The page's network, served to every Worker over shared memory
// (net-bridge.mjs); `wispUrl` only where the page cannot serve it.
netChannel: netChannel ?? undefined,
wispUrl: netChannel ? undefined : network?.url ?? undefined,
// The single-thread build's fibers share this realm, and so the one
// Wisp connection itself.
wispNet: single.on ? network ?? undefined : undefined,
wispUrl: netChannel || single.on ? undefined : network?.url ?? undefined,
// Only when there is no shared terminal to hold the size. With one this
// must not be sent: it is captured at `runProgram` time and every Worker
// writes it on startup, so a process forked after a resize would reset the
@@ -694,6 +858,16 @@ export async function runProgram(
mem64: globalThis.GEX_MEM64 ? 1 : undefined,
// `?fjit=1`: the function JIT in every process.
fjit: globalThis.GEX_FJIT || undefined,
// `?liftmax=N`: blocks one dispatch miss may lift (default 2048).
liftMax: globalThis.GEX_LIFT_MAX || undefined,
// `?inlinedispatch=1`: dispatch inline in every block, as before.
inlineDispatch: globalThis.GEX_INLINE_DISPATCH || undefined,
// `?xlatereport=N`: a translator progress line every N translations.
xlateReport: globalThis.GEX_XLATE_REPORT || undefined,
// `?t1fold=1`: tier-1's tee folding (gex-emit `set_t1_fold`).
t1Fold: globalThis.GEX_T1_FOLD || undefined,
// `?unitcache=1`: compiled translation units shared by every process.
unitCache: globalThis.GEX_UNIT_CACHE || undefined,
// `?wasmdlls=soname=path,...`: libraries replaced by wasm64 providers (cgen/libprov.rs).
wasmDlls: globalThis.GEX_WASM_DLLS || undefined,
// `?elfunits=name,...`: shared objects that get function-tier units (cgen/libprov.rs).
+145
View File
@@ -0,0 +1,145 @@
// The single-thread build's one Worker: the whole guest system -- the global
// kernel, every process and thread as a fiber, the Wisp connection -- on this
// thread, with no SharedArrayBuffer (docs/single-thread.md). The page keeps
// the terminal and talks to this by messages.
//
// A Worker rather than the page's own thread because a Worker may compile a
// large module synchronously and make synchronous requests (the page refuses
// both above 8 MB / at all), and the runtime does both; and because a guest
// that runs long stalls this thread, not the page's input and drawing.
import { enableSingle } from '../../shims/browser/js/single.mjs';
enableSingle();
// After `enableSingle`: these compile modules as they load.
await import('../../shims/browser/js/worker.mjs');
const { runProgram, primeRuntime } = await import('./runtime.js');
/** xterm's surface, as far as `runProgram` uses it, over messages. */
class TermProxy {
rows = 24;
cols = 80;
#data = [];
#resize = [];
write(data) {
// Bytes are transferred, not copied: they are always a fresh slice.
if (data instanceof Uint8Array) self.postMessage({ kind: 'write', data }, [data.buffer]);
else self.postMessage({ kind: 'write', data: String(data) });
}
writeln(text) {
self.postMessage({ kind: 'write', data: `${text}\r\n` });
}
onData(f) {
this.#data.push(f);
return { dispose: () => this.#data.splice(this.#data.indexOf(f), 1) };
}
onResize(f) {
this.#resize.push(f);
return { dispose: () => this.#resize.splice(this.#resize.indexOf(f), 1) };
}
focus() {}
reset() {}
key(d) {
for (const f of this.#data) f(d);
}
resized(rows, cols) {
this.rows = rows;
this.cols = cols;
for (const f of this.#resize) f({ rows, cols });
}
}
const term = new TermProxy();
/**
* The compositor, in this realm (compositor-worker.js's `inRealm`): it reads
* every guest's pixels out of memories only this thread has. The page keeps
* its listeners and sends what it would post to a compositor Worker as `comp`
* messages; what that Worker would post back goes out as `comp-out`.
*/
let comp = null;
async function openCompositor(width, height) {
globalThis.GEX_COMPOSITOR_IN_REALM = true;
const [{ inRealm }, { makeWlChannel, setOutputSize }] = await Promise.all([
import('./compositor-worker.js'),
import('../../shims/browser/js/wl-bridge.mjs'),
]);
const channel = makeWlChannel();
setOutputSize(channel, width, height);
const stats = new Int32Array(4);
const send = inRealm((m, transfer) => self.postMessage({ kind: 'comp-out', m }, transfer ?? []));
// The page reads frames and windows without asking (`counters`): a copy.
const timer = setInterval(() => self.postMessage({ kind: 'comp-stats', stats: stats.slice() }), 500);
return {
channel,
stats,
send,
// What `runProgram` calls on a compositor.
compositor: {
addMemory: (token, memory) => send({ kind: 'memory', token, memory }),
stop: () => {
clearInterval(timer);
send({ kind: 'stop' });
},
},
};
}
self.onmessage = async (ev) => {
const m = ev.data;
if (m.kind === 'comp-open') comp = openCompositor(m.width, m.height);
else if (m.kind === 'comp') {
const c = await comp;
// The page's own stats and channel mean nothing here: these are the ones
// the guests write.
if (m.m.kind === 'init') c.send({ ...m.m, channel: c.channel, stats: c.stats.buffer });
else c.send(m.m);
} else if (m.kind === 'key') term.key(m.data);
else if (m.kind === 'resize') term.resized(m.rows, m.cols);
else if (m.kind === 'run') {
// The page's switches: `?name=` (read through `GEX_PAGE_SEARCH`, since
// this thread's `location` is the script's) and its `GEX_*` globals.
Object.assign(globalThis, m.globals ?? {});
primeRuntime(m.runtime);
term.rows = m.rows ?? term.rows;
term.cols = m.cols ?? term.cols;
try {
const opts = { ...m.opts };
if (opts.wl?.inRealm) {
const c = await comp;
opts.wl = { inRealm: { channel: c.channel, compositor: c.compositor } };
// The guest's GL, in the compositor (its frames are textures in the
// scene's device): the non-pthread Mesa, with no ring to serve.
if (opts.gl === 'compositor') {
const q = new URLSearchParams(globalThis.GEX_PAGE_SEARCH ?? '');
c.send({
kind: 'gl',
channel: null,
api: 'core',
url: q.get('glurl') || '/gex/st/gex-wgpu-replay.js',
...(q.get('mesaenv') ? { mesaEnv: q.get('mesaenv') } : {}),
...(q.get('wgslcache') ? { wgslCache: q.get('wgslcache') } : {}),
});
}
} else delete opts.gl;
if (opts.networkUrl) {
const { WispNetwork } = await import('./net.js');
const net = new WispNetwork(opts.networkUrl, (e) => self.postMessage({ kind: 'net', event: e }));
try {
await net.start();
} catch (e) {
self.postMessage({ kind: 'net', event: `error ${e?.message ?? e}` });
}
opts.network = net;
}
delete opts.networkUrl;
opts.onNetEvent = (e) => self.postMessage({ kind: 'net', event: e });
const { status } = await runProgram(term, opts);
self.postMessage({ kind: 'done', status });
} catch (e) {
self.postMessage({ kind: 'failed', message: e?.message ?? String(e), stack: e?.stack ?? '' });
}
}
};
console.info('[gex] single-thread build: the system runs in this Worker, no SharedArrayBuffer (crossOriginIsolated=' + globalThis.crossOriginIsolated + ')');
self.postMessage({ kind: 'ready' });
+3 -1
View File
@@ -205,7 +205,9 @@ async function serverHealthy(port) {
});
// The isolation header is the thing that actually matters: without it there
// is no SharedArrayBuffer and threads/the global kernel vanish silently.
return r.ok && r.headers.get('cross-origin-opener-policy') === 'same-origin';
// With `GEX_NO_COI=1` (the single-thread build) it is the header's absence.
const coi = r.headers.get('cross-origin-opener-policy') === 'same-origin';
return r.ok && coi === !process.env.GEX_NO_COI;
} catch {
return false;
}
+22 -8
View File
@@ -14,7 +14,8 @@ import { Cdp, browserWsUrl } from './cdp.mjs';
const HERE = dirname(fileURLToPath(import.meta.url));
const WEB = join(HERE, '..');
const DIST = join(WEB, 'dist');
// `GEX_DIST=web/dist-sp` tests the single-thread build's dist.
const DIST = process.env.GEX_DIST || join(WEB, 'dist');
const CHROME = process.env.CHROME || '/usr/bin/chromium';
// A site to test other than the built one; requests are passed through and
// only the HTML is touched, to add the reporter.
@@ -201,8 +202,8 @@ if (!(await exists(CHROME))) skip(`no browser at ${CHROME}`);
// A stale `dist` proves nothing: `vite build` copies `web/public` into `dist`,
// so refuse to run if the dist shim is older than the one in public.
if (!UPSTREAM) {
const distShim = join(DIST, 'gex/gex_browser.wasm');
const pubShim = fileURLToPath(new URL('../public/gex/gex_browser.wasm', import.meta.url));
const distShim = join(DIST, 'gex/gex_browser.shared.wasm');
const pubShim = fileURLToPath(new URL('../public/gex/gex_browser.shared.wasm', import.meta.url));
const [d, p] = await Promise.all([
stat(distShim).catch(() => null),
stat(pubShim).catch(() => null),
@@ -211,7 +212,7 @@ if (!UPSTREAM) {
skip(
`${DIST} is older than the runtime it should contain `
+ `(dist ${new Date(d.mtimeMs).toISOString()}, `
+ `public ${new Date(p.mtimeMs).toISOString()}) -- run \`npm run build\`, `
+ `public ${new Date(p.mtimeMs).toISOString()}) -- run web/build-all.sh, `
+ 'or point this at a dev server with GEX_SITE=http://localhost:5173',
);
}
@@ -445,8 +446,11 @@ const server = createServer(async (req, res) => {
'content-type': type,
// Added here so a dev server that forgets them still gets a
// `SharedArrayBuffer` -- a different failure, worth telling apart.
'Cross-Origin-Opener-Policy': 'same-origin',
'Cross-Origin-Embedder-Policy': 'require-corp',
// Not with `GEX_NO_COI=1`, as for the built site below.
...(process.env.GEX_NO_COI ? {} : {
'Cross-Origin-Opener-Policy': 'same-origin',
'Cross-Origin-Embedder-Policy': 'require-corp',
}),
});
res.end(html ? Buffer.from(body.toString() + reporter()) : body);
} catch (e) {
@@ -466,10 +470,15 @@ const server = createServer(async (req, res) => {
if (name === '/index.html') body = Buffer.from(body.toString() + reporter());
// Cross-origin isolation: the page needs a `SharedArrayBuffer` for the
// global kernel's memory, and without these headers it is not defined.
// `GEX_NO_COI=1` leaves them off, as any static host would: the page then
// has no SharedArrayBuffer and runs the single-thread build
// (docs/single-thread.md).
res.writeHead(200, {
'content-type': TYPES[extname(name)] || 'application/octet-stream',
'Cross-Origin-Opener-Policy': 'same-origin',
'Cross-Origin-Embedder-Policy': 'require-corp',
...(process.env.GEX_NO_COI ? {} : {
'Cross-Origin-Opener-Policy': 'same-origin',
'Cross-Origin-Embedder-Policy': 'require-corp',
}),
});
res.end(body);
} catch {
@@ -596,6 +605,11 @@ for (const id of list) {
'--no-sandbox',
'--disable-gpu-sandbox',
'--enable-unsafe-swiftshader',
// GEX_WEBGPU_SOFTWARE=1: WebGPU on SwiftShader, as drive.mjs does, so
// gears runs on a host with no GPU.
...(process.env.GEX_WEBGPU_SOFTWARE === '1'
? ['--enable-unsafe-webgpu', '--use-angle=swiftshader-webgl', '--use-webgpu-adapter=swiftshader']
: []),
// Without this the page's console goes nowhere, so a demo that throws
// before running a byte fails as "timeout, last status: (none)".
'--enable-logging=stderr',
+4 -1
View File
@@ -21,7 +21,10 @@ export default defineConfig({
fs: { allow: [fileURLToPath(new URL('..', import.meta.url))] },
},
preview: { headers: ISOLATED },
build: { target: 'es2022', chunkSizeWarningLimit: 2048 },
// web/build-all.sh builds each flavour's dist from its own staged public
// tree: GEX_PUBLIC_DIR (default public/) into GEX_OUT_DIR (default dist/).
publicDir: process.env.GEX_PUBLIC_DIR || 'public',
build: { target: 'es2022', chunkSizeWarningLimit: 2048, outDir: process.env.GEX_OUT_DIR || 'dist' },
// `worker.mjs` is an ES module with top-level await; the default `iife`
// worker output cannot express that.
worker: { format: 'es' },
+7
View File
@@ -47,7 +47,14 @@ EM_JS(void, gexjs_tick, (void), { Module.gexos.tick(); });
/* Sleep until something might have happened, or `ms` passes. This is the one
* place the Worker blocks, and it blocks the way a guest process does: on the
* global kernel's wake word. */
#ifdef GEX_XWL_JSPI
/* The single-thread build's (docs/single-thread.md): nothing may block, so the
* wait is a promise and the server's stack suspends on it (JSPI) while the
* guests run. */
EM_ASYNC_JS(int, gexjs_wait, (int ms), { return await Module.gexos.wait(ms); });
#else
EM_JS(int, gexjs_wait, (int ms), { return Module.gexos.wait(ms); });
#endif
EM_JS(int, gexjs_shm, (int addr, int size), { return Module.gexos.shm(addr, size); });
EM_JS(int, gexjs_shm_addr, (int fd), { return Module.gexos.shmAddr(fd); });