mirror of
https://github.com/HeyPuter/gex.git
synced 2026-11-13 20:12:43 -10:00
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:
commit
467beaaca8
64 files changed
+3886
-275
No files matched your search
@@ -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/
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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) = ®ion {
|
||||
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) = ®ion {
|
||||
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
|
||||
|
||||
@@ -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
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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}");
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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`).
|
||||
@@ -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.
@@ -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
@@ -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
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
@@ -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,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;
|
||||
}
|
||||
}
|
||||
@@ -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. */
|
||||
|
||||
@@ -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
@@ -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
@@ -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) };
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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)"
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
Executable
+53
@@ -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
|
||||
Executable
+35
@@ -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
|
||||
@@ -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
|
||||
|
||||
Executable
+174
@@ -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
@@ -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
|
||||
|
||||
Executable
+47
@@ -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"
|
||||
@@ -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/"
|
||||
|
||||
@@ -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
Binary file not shown.
Binary file not shown.
Regular → Executable
BIN
Binary file not shown.
Binary file not shown.
Binary file not shown.
+19
-7
@@ -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
@@ -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)`);
|
||||
});
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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'));
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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).
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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' },
|
||||
|
||||
@@ -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); });
|
||||
|
||||
|
||||
Reference in new issue
Block a user