From 90da5f061faba5184c0e19e7945960640d627bdd Mon Sep 17 00:00:00 2001 From: Qianxi Chen Date: Fri, 5 Jun 2026 17:14:21 +0000 Subject: [PATCH 1/4] Enable grates to run under dynamic linking (shared build) Grates previously had to be compiled statically (-s): the runtime gated grate-worker registration to non-dylink modules. This lets grates run as shared/dynamic builds while keeping static working. - instance.rs: reserve the grate stack arena in the dynamic InstantiateFirst branch and always inherit it to the fork child; execve now unsets the arena base for all builds (fixes fork+exec 'already initialized' panic). - linker.rs: add build_dylink_child_store + LindDylinkChildStore, the per-store dynamic-linking replay used to construct grate workers (separate Store sharing the cage memory; fresh per-store linker/table/GOT; global-snapshot + GOT reloc + TLS replay). Mirrors the pthread thread-creation path. - lind-3i: GrateTemplate gains an optional worker_builder; create_worker uses it for dynamic grates and keeps the clone-linker path for static. - lind-multi-process / lind-boot: build the worker_builder at the initial-cage and fork registration sites; drop the static-only gate. - gratetestreport.py: add --grate-build {static,shared,both} (default shared). In-repo tests/grate-tests pass in both static and shared modes. --- scripts/harnesses/gratetestreport.py | 52 ++-- src/lind-boot/src/lind_wasmtime/execute.rs | 87 ++++-- src/wasmtime/crates/lind-3i/src/lib.rs | 66 ++++- .../crates/lind-multi-process/src/lib.rs | 134 +++++++--- .../crates/wasmtime/src/runtime/instance.rs | 64 ++++- .../crates/wasmtime/src/runtime/linker.rs | 251 +++++++++++++++++- 6 files changed, 563 insertions(+), 91 deletions(-) diff --git a/scripts/harnesses/gratetestreport.py b/scripts/harnesses/gratetestreport.py index 7745bbcb9..b4df2fcfb 100644 --- a/scripts/harnesses/gratetestreport.py +++ b/scripts/harnesses/gratetestreport.py @@ -118,6 +118,17 @@ def parse_arguments(argv: list[str] | None = None) -> argparse.Namespace: parser.add_argument("--debug", action="store_true", help="Enable debug logging") parser.add_argument("--testfiles", type=Path, nargs="+", help="Specific grate files (*_grate.c) to run") parser.add_argument("--clean-results", action="store_true", help="Delete output files and exit") + parser.add_argument( + "--grate-build", + choices=["static", "shared", "both"], + default=os.environ.get("GRATE_BUILD", "shared"), + help=( + "How to compile the grate: 'static' passes -s to lind-clang (the legacy " + "static grate build), 'shared' omits it (dynamically linked grate), 'both' " + "runs each test once per mode. Default: shared (override via GRATE_BUILD env). " + "Cages are always compiled shared." + ), + ) return parser.parse_args(argv) @@ -238,8 +249,13 @@ def run_subprocess(cmd: list[str], timeout: int | None = None, cwd: Path | None return subprocess.run(cmd, capture_output=True, text=True, timeout=timeout, cwd=cwd) -def compile_grate_test(test: GrateTestCase) -> tuple[bool, str]: - grate_compile_cmd = [GRATE_CLANG, "-s", "--compile-grate", "--output-dir", "grates", test.grate_source.name] +def compile_grate_test(test: GrateTestCase, static_build: bool = True) -> tuple[bool, str]: + # `-s` produces a statically linked grate; omitting it produces a dynamically linked + # (shared) grate. Both are supported at runtime. Cages are always built shared. + static_flag = ["-s"] if static_build else [] + grate_compile_cmd = ( + [GRATE_CLANG] + static_flag + ["--compile-grate", "--output-dir", "grates", test.grate_source.name] + ) cage_compile_cmd = [GRATE_CLANG, test.cage_source.name] try: @@ -405,20 +421,26 @@ def run_report(argv: list[str] | None = None) -> dict[str, Any]: if not tests_to_run: logger.warning("No grate tests found.") - for idx, test in enumerate(tests_to_run, start=1): - logger.info(f"[{idx}/{len(tests_to_run)}] {test.name}") - compile_ok, compile_output = compile_grate_test(test) - if not compile_ok: - add_test_result(result, test.name, "Failure", "Compile_Failure", compile_output) - continue + build_modes = ["static", "shared"] if args.grate_build == "both" else [args.grate_build] - status, output, _ = run_grate_test(test, args.timeout) - if status == "Success": - add_test_result(result, test.name, "Success", None, output) - elif status == "Timeout": - add_test_result(result, test.name, "Failure", "Timeout", output) - else: - add_test_result(result, test.name, "Failure", "Runtime_Failure", output) + for idx, test in enumerate(tests_to_run, start=1): + for mode in build_modes: + # Label results with the build mode only when running more than one, so + # single-mode runs keep their original test names. + label = f"{test.name} [{mode}]" if len(build_modes) > 1 else test.name + logger.info(f"[{idx}/{len(tests_to_run)}] {label}") + compile_ok, compile_output = compile_grate_test(test, static_build=(mode == "static")) + if not compile_ok: + add_test_result(result, label, "Failure", "Compile_Failure", compile_output) + continue + + status, output, _ = run_grate_test(test, args.timeout) + if status == "Success": + add_test_result(result, label, "Success", None, output) + elif status == "Timeout": + add_test_result(result, label, "Failure", "Timeout", output) + else: + add_test_result(result, label, "Failure", "Runtime_Failure", output) with open(output_json, "w", encoding="utf-8") as fp: json.dump(result, fp, indent=4) diff --git a/src/lind-boot/src/lind_wasmtime/execute.rs b/src/lind-boot/src/lind_wasmtime/execute.rs index 52d7cadca..47337eb4a 100644 --- a/src/lind-boot/src/lind_wasmtime/execute.rs +++ b/src/lind-boot/src/lind_wasmtime/execute.rs @@ -705,25 +705,80 @@ fn load_main_module( // This function will be called at either the first cage or exec-ed cages. set_vmctx_thread(cageid, THREAD_START_ID as u64, vmctx_wrapper); - // Grate calls only supports static linking for now, so we only initialize the grate pool and register - // grate workers when dylink is not enabled. - if !dylink_metadata.dylink_enabled { - // 4) register grate workers for this cage - let grate_template = GrateTemplate { - engine: module.engine().clone(), - module: module.clone(), - linker: linker_guard.clone(), + // 4) Register grate workers for this cage. Grates now work under both static and + // dynamic builds. + // + // Static grates: workers clone the template linker and instantiate the module directly + // (worker_builder = None). + // + // Dynamically linked grates: a worker is a separate Store sharing the cage's linear + // memory, and Wasmtime Table/Global objects are store-bound, so each worker must + // rebuild its own per-store linker, indirect function table, and GOT — exactly like a + // thread of the grate cage. We capture, from the fully-relocated main store, everything + // build_dylink_child_store needs and hand it to the worker pool as a worker_builder. + let worker_builder: Option> = if dylink_metadata.dylink_enabled { + let engine = module.engine().clone(); + let symbol_table = store.as_context_mut().get_library_symbol_table().clone(); + let (modules, dlopen_modules) = { + let ctx = store.data().lind_fork_ctx.as_ref().unwrap(); + (ctx.modules().to_vec(), ctx.dlopen_modules().to_vec()) }; - let host = store.data().clone(); + // Snapshot the parent linker's store-independent imports (GOT cells, host funcs, + // shared memory) and all global values AFTER GOT relocation has finalized them. + let snapshot = linker_guard.get_linker_snapshot_for_child(&mut *store, true); + let global_snapshots = store.as_context_mut().get_global_snapshot(); + + Some(Box::new( + move |cageid: u64, _worker_id: u64, host: HostCtx, slot_top: u32| { + let built = wasmtime::build_dylink_child_store( + &engine, + host, + symbol_table.clone(), + cageid, + &modules, + &dlopen_modules, + &snapshot, + &global_snapshots, + true, /* dylink_enabled */ + slot_top, + )?; + let wasmtime::LindDylinkChildStore { + mut store, + instance, + linker, + got, + stack_top, + .. + } = built; + // Give the worker's host context its own per-store linker and GOT so any + // later ctx.linker / ctx.got use stays consistent within this worker store. + { + let ctx = store.data_mut().lind_fork_ctx.as_mut().unwrap(); + ctx.attach_linker(linker); + ctx.attach_got_table(got); + } + Ok((store, instance, stack_top)) + }, + ) as WorkerBuilder) + } else { + None + }; - // initialize the grate pool for later use in grate calls and - // other syscalls that require re-entry into wasmtime runtime. - init_grate_pool(); - unregister_grate_handler(cageid); + let grate_template = GrateTemplate { + engine: module.engine().clone(), + module: module.clone(), + linker: linker_guard.clone(), + worker_builder, + }; + let host = store.data().clone(); - register_grate_handler_for_cage(&grate_template, host, cageid) - .with_context(|| format!("failed to register grate workers for cage {}", cageid))?; - } + // initialize the grate pool for later use in grate calls and + // other syscalls that require re-entry into wasmtime runtime. + init_grate_pool(); + unregister_grate_handler(cageid); + + register_grate_handler_for_cage(&grate_template, host, cageid) + .with_context(|| format!("failed to register grate workers for cage {}", cageid))?; // 5) Notify threei of the cage runtime type threei::set_cage_runtime(cageid, threei_const::RUNTIME_TYPE_WASMTIME); diff --git a/src/wasmtime/crates/lind-3i/src/lib.rs b/src/wasmtime/crates/lind-3i/src/lib.rs index 8ea9b262f..54c58d16f 100644 --- a/src/wasmtime/crates/lind-3i/src/lib.rs +++ b/src/wasmtime/crates/lind-3i/src/lib.rs @@ -80,7 +80,7 @@ use std::sync::{Condvar, Mutex, MutexGuard, OnceLock}; use sysdefs::constants::lind_platform_const; use sysdefs::constants::lind_platform_const::*; use wasmtime::error::Context as WasmtimeContext; -use wasmtime::{Engine, Global, Linker, Module, Store, TypedFunc, Val}; +use wasmtime::{Engine, Global, Instance, Linker, Module, Store, TypedFunc, Val}; type PassFptrTyped = TypedFunc< ( @@ -104,6 +104,24 @@ type PassFptrTyped = TypedFunc< type WorkerId = u64; +/// Optional per-worker store builder for dynamically linked grates. +/// +/// Static grates clone the template linker and instantiate the module directly (see the +/// `None` branch of [`create_worker`]). Dynamically linked grates cannot do that: each +/// worker owns a separate `Store`, and Wasmtime `Table`/`Global` objects are store-bound, +/// so a worker must rebuild its own per-store linker, indirect function table, and GOT — +/// exactly like a thread of the grate cage. That replay needs `LindGOT`/`LindCtx` +/// machinery that lives in crates above `lind-3i`, so it is injected here as an opaque +/// builder rather than depended upon directly. +/// +/// Arguments: `(cageid, worker_id, host, slot_top)`. Returns the worker's +/// `(Store, Instance, effective_stack_top)`, where `effective_stack_top` is the worker's +/// stack-slot top AFTER per-instance TLS has been reserved (TLS is carved downward from +/// `slot_top`). This is the value the worker resets `__stack_pointer` to before each call, +/// so it must sit below the TLS region — see [`GrateWorker::reset_worker_stack`]. +pub type WorkerBuilder = + Box anyhow::Result<(Store, Instance, u32)>>; + const DEFAULT_GRATE_WORKERS: usize = MAX_GRATE_WORKERS; const GRATE_WORKERS_ENV: &str = "LIND_GRATE_WORKERS"; @@ -127,7 +145,7 @@ pub enum ConcurrencyMode { /// construct worker-local execution contexts for the same grate module. /// Each worker clones or reuses these components to create its own /// `Store + Instance` runtime state. -pub struct GrateTemplate { +pub struct GrateTemplate { /// The Wasmtime engine used to create worker-local stores and instances. /// /// This is shared across all workers for the same grate. @@ -144,6 +162,14 @@ pub struct GrateTemplate { /// Each worker starts from this template linker and clones it during /// worker creation so that instantiation can proceed independently. pub linker: Linker, + + /// Per-worker store builder for dynamically linked grates. + /// + /// `None` for statically linked grates: `create_worker` clones [`Self::linker`] and + /// instantiates [`Self::module`] directly. `Some` for dynamically linked grates: each + /// worker store is rebuilt with full dynamic-linking replay (separate store sharing the + /// cage memory, fresh per-store linker/table/GOT). See [`WorkerBuilder`]. + pub worker_builder: Option>, } /// Marshalled arguments for one grate call. @@ -681,13 +707,32 @@ pub fn create_worker( where T: Clone + 'static, { - let mut store = Store::new(&template.engine, host); - - let linker: Linker = template.linker.clone(); - - let (instance, _, _) = linker - .instantiate_with_lind_thread(&mut store, &template.module, false) - .context("failed to instantiate grate module")?; + // Acquire this worker's store + instance + effective stack top. + // + // Static grates (no dylink section): clone the template linker and instantiate the + // module directly. All workers are byte-identical because globals are baked in. The + // worker's stack top is simply the slot top. + // + // Dynamically linked grates: delegate to the injected worker builder, which rebuilds a + // separate per-store linker/table/GOT (sharing the cage's linear memory) just like a + // thread of the grate cage. It returns the effective stack top AFTER per-instance TLS + // has been carved out of the slot, which the worker must use as its stack reset target. + let (mut store, instance, stack_top) = match &template.worker_builder { + Some(build_worker) => { + let slot_top = worker_stack_top(cageid, worker_id); + build_worker(cageid, worker_id, host, slot_top) + .context("failed to build dynamically linked grate worker")? + } + None => { + let mut store = Store::new(&template.engine, host); + let linker: Linker = template.linker.clone(); + let (instance, _, _) = linker + .instantiate_with_lind_thread(&mut store, &template.module, false) + .context("failed to instantiate grate module")?; + let stack_top = worker_stack_top(cageid, worker_id); + (store, instance, stack_top) + } + }; let pass_fptr_func = match instance.get_export(&mut store, "pass_fptr_to_wt") { Some(_) => Some(instance.get_typed_func::<( @@ -709,8 +754,9 @@ where None => None, }; + // `stack_top` is already determined above (slot top for static, post-TLS slot top for + // dynamic); only the slot base is computed here. let stack_base = worker_stack_base(cageid, worker_id); - let stack_top = worker_stack_top(cageid, worker_id); let stack_pointer = instance .get_global(&mut store, "__stack_pointer") .ok_or_else(|| anyhow::anyhow!("missing __stack_pointer"))?; diff --git a/src/wasmtime/crates/lind-multi-process/src/lib.rs b/src/wasmtime/crates/lind-multi-process/src/lib.rs index 3ee6c5aa3..908984371 100644 --- a/src/wasmtime/crates/lind-multi-process/src/lib.rs +++ b/src/wasmtime/crates/lind-multi-process/src/lib.rs @@ -25,8 +25,9 @@ use std::sync::{Arc, Barrier, Mutex}; use std::thread; use wasmtime::{ AsContext, AsContextMut, AsyncifyState, Caller, ChildLibraryType, Engine, ExternType, - InstanceId, InstantiateType, Linker, Module, OnCalledAction, SharedMemory, Store, StoreOpaque, - VMContext, VMOpaqueContext, Val, ValRaw, ValType, + InstanceId, InstantiateType, LindDylinkChildStore, Linker, Module, OnCalledAction, + SharedMemory, Store, StoreOpaque, VMContext, VMOpaqueContext, Val, ValRaw, ValType, + build_dylink_child_store, }; use cage::alloc_cage_id; @@ -210,6 +211,18 @@ impl &[(String, String, Module)] { + &self.modules + } + + /// The cage's dlopen'd module list. Exposed for the same reason as [`Self::modules`]. + pub fn dlopen_modules(&self) -> &[(String, String, Module)] { + &self.dlopen_modules + } + // Attach a LindGOT (Global Offset Table) to this context, wrapping it in // Arc> for shared, thread-safe access. The GOT maps symbol names to // the addresses of their GOT cells; it is shared across all modules within @@ -704,7 +717,7 @@ impl> = if dylink_enabled { + let wb_engine = engine.clone(); + // `symbol_table` was already consumed into the child store; read it + // back from the child store for the worker builder. + let wb_symbol_table = + store.as_context_mut().get_library_symbol_table().clone(); + let wb_modules = modules.clone(); + let wb_dlopen_modules = dlopen_modules.clone(); + let wb_global_snapshots = global_snapshots.clone(); + let wb_get_cx = get_cx.clone(); + let wb_snapshot = + cloned_linker.get_linker_snapshot_for_child(&mut store, true); + + Some(Box::new( + move |cageid: u64, _worker_id: u64, host: T, slot_top: u32| { + let built = build_dylink_child_store( + &wb_engine, + host, + wb_symbol_table.clone(), + cageid, + &wb_modules, + &wb_dlopen_modules, + &wb_snapshot, + &wb_global_snapshots, + true, /* dylink_enabled */ + slot_top, + )?; + let LindDylinkChildStore { + mut store, + instance, + linker, + got, + stack_top, + .. + } = built; + { + let ctx = wb_get_cx(store.data_mut()); + ctx.attach_linker(linker); + ctx.attach_got_table(got); + } + Ok((store, instance, stack_top)) + }, + ) as WorkerBuilder) + } else { + None + }; + + let grate_template = GrateTemplate { + engine: module.engine().clone(), + module: module.clone(), + linker: cloned_linker, + worker_builder, + }; + + // register grate workers for this cage + create_handler_for_cage( + &grate_template, + store.data().clone(), + child_cageid, + ConcurrencyMode::Parallel, + ) + .with_context(|| { + format!("failed to register grate workers for cage {}", child_cageid) + }) + .expect("create_handler_for_cage failed"); // get the asyncify_rewind_start and module start function let child_rewind_start; @@ -1512,17 +1575,18 @@ impl Linker { } } +/// Result of [`build_dylink_child_store`]: a fully set-up child Wasmtime store that +/// shares the parent cage's linear memory. +/// +/// This bundles everything a caller needs to finish wiring a *thread* or a *grate +/// worker* (both are separate `Store`s that share the cage's single linear memory): +/// the child [`Store`](crate::Store), its main-module [`Instance`], the per-store +/// [`Linker`] and [`LindGOT`] (which the caller attaches to its host context), the +/// epoch global handler, and the post-TLS stack top. +#[allow(missing_docs)] +pub struct LindDylinkChildStore { + pub store: crate::Store, + pub instance: Instance, + pub instance_id: InstanceId, + pub linker: Linker, + pub got: Option, + pub epoch_handler: Option<*mut u64>, + /// Stack top after per-instance TLS has been carved out of the input `stack_addr`. + /// Threads set `__stack_pointer` relative to this; grate workers use it as the + /// per-call stack-reset target. + pub stack_top: u32, +} + +/// Build a child Wasmtime store that shares the parent cage's linear memory, set up for +/// dynamic linking (or static, when `dylink_enabled` is `false`). +/// +/// This is the per-store dynamic-linking replay used to create **grate workers** (each +/// worker is a separate `Store` that shares the cage's single linear memory). It mirrors, +/// step for step, the thread-creation path in `lind-multi-process::pthread_create_call` +/// (each thread is likewise a separate `Store` sharing memory). That path currently keeps +/// its own inline copy of this logic; unifying it to call this helper is a deferred DRY +/// cleanup (kept separate for now to avoid destabilizing the proven thread path). If you +/// change one, change the other. Wasmtime `Table`/`Global` +/// objects are store-bound and cannot be reused across stores, so each child must: +/// rebuild a fresh per-store linker from `snapshot`, allocate a fresh indirect function +/// table, replay the preloaded and dlopen'd libraries into the new store (re-growing the +/// table in the same order and applying the parent's global snapshots so GOT cell values +/// and memory bases match), instantiate the main module, and carve per-instance TLS out +/// of `stack_addr` (growing downward). +/// +/// It deliberately does NOT touch the lind `LindCtx` (cageid/tid bookkeeping, +/// `attach_linker`/`attach_got_table`), perform signal init, register the vmctx, or run +/// `_start`/asyncify rewind — those are caller-specific and stay in `pthread_create` / +/// the grate worker builder. Keeping them out lets this helper live in the wasmtime crate +/// (where all the involved types are in scope) and remain free of lind-multi-process types. +/// +/// `stack_addr` is the high address of the child's stack region (stacks grow down); for a +/// thread it is the thread stack top, for a grate worker it is the worker's stack-slot top. +/// The returned `stack_top` is `stack_addr` after TLS has been reserved. +pub fn build_dylink_child_store( + engine: &Engine, + child_host: T, + symbol_table: SymbolTable, + cageid: u64, + modules: &[(String, String, Module)], + dlopen_modules: &[(String, String, Module)], + snapshot: &( + Vec<(String, String, GlobalType, Val)>, + Vec<(String, String, Arc)>, + Option<(String, String, ClonedMemory)>, + ), + global_snapshots: &HashMap>, + dylink_enabled: bool, + mut stack_addr: u32, +) -> Result> { + // The main module is the first entry in the module list. + let module = modules + .get(0) + .expect("module list must contain the main module") + .2 + .clone(); + + let store_inner = crate::Store::::new_inner(engine, symbol_table)?; + let mut store = crate::Store::new_with_inner(engine, child_host, store_inner)?; + + let mut child_got = if dylink_enabled { + Some(LindGOT::new()) + } else { + None + }; + + // Rebuild a fresh per-store linker from the parent's snapshot: store-local Globals + // (re-registering GOT cell pointers), the shared memory re-defined into this store, + // and store-independent host functions re-inserted. + let (mut linker, memory_base_table, epoch_handler, _) = Linker::new_child_linker( + &mut store, + engine, + &mut child_got, + &snapshot.0, + &snapshot.1, + &snapshot.2, + )?; + + let child_table = if dylink_enabled { + // The main module declares the minimal indirect-function-table size via its + // table import; start the fresh table there. + let mut table_size: u64 = 0; + for import in module.imports() { + if let ExternType::Table(table) = import.ty() { + table_size = table.minimum(); + } + } + let mut child_table = linker.attach_function_table(&mut store, table_size as u32)?; + linker.attach_asyncify(&mut store)?; + + // Replay each preloaded library into this store, re-growing the shared table in + // the SAME order as the parent so cloned GOT indices remain valid. + for (name, _path, lib_module) in modules.iter().skip(1) { + let dylink_info = lib_module.dylink_meminfo(); + let dylink_info = dylink_info.as_ref().unwrap(); + let table_start = child_table.size(&mut store) as i32; + + lind_log!( + DYLINK, + "[debug] library table_start: {}, grow: {}", + table_start, + dylink_info.table_size + ); + child_table.grow( + &mut store, + dylink_info.table_size as u64, + crate::Ref::Func(None), + )?; + + let module_name = lib_module.name().unwrap_or(""); + let module_memory_base = *memory_base_table + .get(module_name) + .expect("memory base not found for library"); + + linker.allow_shadowing(true); + linker.module_with_child( + &mut store, + cageid, + name, + lib_module, + &mut child_table, + table_start, + module_memory_base, + ChildLibraryType::Thread(&mut stack_addr), + global_snapshots + .get(module_name) + .map(Vec::as_slice) + .unwrap_or(&[]), + )?; + linker.allow_shadowing(false); + } + + Some(child_table) + } else { + None + }; + + // Workers/threads share the cage's linear memory. `is_thread` is only consulted on + // fork paths (which neither a freshly-created thread child nor a grate worker takes + // here), so this is a safe, behavior-neutral label that also matches the thread path + // this routine is modeled on. + store.set_is_thread(true); + + let (instance, _stack_arena, instance_id) = + linker.instantiate_with_lind_thread(&mut store, &module, false)?; + + // Register the main-module instance by name so global-snapshot lookups resolve it, + // then restore the parent's globals (GOT cell addresses, stack pointer, memory base). + let main_module_name = module.name().unwrap_or(""); + store + .as_context_mut() + .register_named_instance(main_module_name.to_string(), instance_id); + instance.apply_global_snapshots( + &mut store, + global_snapshots + .get(main_module_name) + .map(Vec::as_slice) + .unwrap_or(&[]), + ); + + if dylink_enabled { + let mut child_table = child_table.unwrap(); + let fpcast_enabled = engine.fpcast_enabled(); + // got=None: the table is rebuilt for this store but GOT cell values come from the + // applied global snapshot, so they must not be re-derived here. + instance.apply_GOT_relocs(&mut store, None, &child_table, None, fpcast_enabled)?; + + // Expose the main module's exports to this store's linker (skip signal_callback). + linker.instance_dylink(&mut store, "env", instance, vec!["signal_callback"])?; + + // Replay dlopen'd libraries the same way as preloaded ones. + for (name, _path, lib_module) in dlopen_modules.iter() { + let dylink_info = lib_module.dylink_meminfo(); + let dylink_info = dylink_info.as_ref().unwrap(); + let table_start = child_table.size(&mut store) as i32; + + lind_log!( + DYLINK, + "[debug] dlopen library table_start: {}, grow: {}", + table_start, + dylink_info.table_size + ); + child_table.grow( + &mut store, + dylink_info.table_size as u64, + crate::Ref::Func(None), + )?; + + let module_name = lib_module.name().unwrap_or(""); + let module_memory_base = *memory_base_table + .get(module_name) + .expect("memory base not found for library"); + + linker.allow_shadowing(true); + linker.module_with_child( + &mut store, + cageid, + name, + lib_module, + &mut child_table, + table_start, + module_memory_base, + ChildLibraryType::Thread(&mut stack_addr), + global_snapshots + .get(module_name) + .map(Vec::as_slice) + .unwrap_or(&[]), + )?; + linker.allow_shadowing(false); + } + } + + // Carve per-instance TLS out of the stack (grows downward). The remaining stack_addr + // becomes the effective stack top for this child. + if let Ok(init_tls) = + instance.get_typed_func::(store.as_context_mut(), "__wasm_init_tls") + { + let get_tls_size = + instance.get_typed_func::<(), i32>(store.as_context_mut(), "__get_aligned_tls_size")?; + let tls_size = get_tls_size.call(store.as_context_mut(), ())?; + stack_addr -= tls_size as u32; + init_tls.call(store.as_context_mut(), stack_addr as i32)?; + } + + Ok(LindDylinkChildStore { + store, + instance, + instance_id, + linker, + got: child_got, + epoch_handler, + stack_top: stack_addr, + }) +} + /// Additional APIs for attaching common imports used by the Lind runtime and toolchain. #[allow(missing_docs)] impl Linker { From 40b887235dc445baadb3f57c9e9e3ffb674d5b51 Mon Sep 17 00:00:00 2001 From: Qianxi Chen Date: Fri, 5 Jun 2026 17:55:13 +0000 Subject: [PATCH 2/4] Reduce default grate worker stack to 1 MiB, make it env-configurable Each grate worker reserves a stack slot in a per-cage arena sized MAX_GRATE_WORKERS * (guard + slot). At the previous 8 MiB slot that arena was ~256 MiB per cage, which is far more than grate handler code (shallow syscall interposition) needs. Replace the GRATE_STACK_SLOT_SIZE constant with DEFAULT_GRATE_STACK_SLOT_SIZE (1 MiB) plus grate_stack_slot_size(), which reads LIND_GRATE_STACK_SIZE (bytes, page-rounded) once and caches it. The arena reservation (instance.rs, both the static and dynamic branches) and the per-worker slot addressing (lind-3i) both call it, so they always agree on the layout. Default arena drops to ~32 MiB. --- .../src/constants/lind_platform_const.rs | 41 ++++++++++++++++--- src/wasmtime/crates/lind-3i/src/lib.rs | 5 ++- .../crates/wasmtime/src/runtime/instance.rs | 17 ++++---- 3 files changed, 48 insertions(+), 15 deletions(-) diff --git a/src/sysdefs/src/constants/lind_platform_const.rs b/src/sysdefs/src/constants/lind_platform_const.rs index bfe959f10..54ef8bfce 100644 --- a/src/sysdefs/src/constants/lind_platform_const.rs +++ b/src/sysdefs/src/constants/lind_platform_const.rs @@ -135,14 +135,45 @@ pub const FPCAST_FUNC_SIGNATURE: &str = "$fpcast_emu$"; /// instance reserves the same number of worker stack slots in linear memory. pub const MAX_GRATE_WORKERS: usize = 32; -/// Size in bytes of the usable stack region assigned to one grate worker. +/// Default size in bytes of the usable stack region assigned to one grate worker. /// /// Each worker executes in its own `Store + Instance` context, but workers may /// still attach to the same underlying linear memory. Therefore, every worker -/// must be given a disjoint stack slot inside the shared stack arena. -/// -/// This constant specifies the usable portion of that per-worker slot. -pub const GRATE_STACK_SLOT_SIZE: u32 = 8 * 1024 * 1024; +/// must be given a disjoint stack slot inside the shared stack arena. This is the +/// usable portion of that per-worker slot. +/// +/// Grate workers only ever run grate *handler* code (syscall interposition), which +/// is shallow compared to arbitrary user programs, so the default is modest (1 MiB). +/// Override at runtime with the `LIND_GRATE_STACK_SIZE` environment variable; see +/// [`grate_stack_slot_size`]. +pub const DEFAULT_GRATE_STACK_SLOT_SIZE: u32 = 1024 * 1024; + +/// Environment variable that overrides the per-worker grate stack slot size (bytes). +pub const GRATE_STACK_SIZE_ENV: &str = "LIND_GRATE_STACK_SIZE"; + +static GRATE_STACK_SLOT_SIZE_CACHED: OnceLock = OnceLock::new(); + +/// Per-worker grate stack slot size in bytes. +/// +/// Returns [`DEFAULT_GRATE_STACK_SLOT_SIZE`] (1 MiB) unless the `LIND_GRATE_STACK_SIZE` +/// environment variable is set to a positive integer number of bytes, in which case +/// that value (rounded up to a 4 KiB page) is used. +/// +/// The result is read from the environment once and cached for the process lifetime so +/// that every consumer agrees on the same arena geometry: the arena reservation in +/// `instance.rs` and the per-worker slot addressing in `lind-3i` must use the identical +/// value, otherwise worker stacks would not line up with the reserved region. +pub fn grate_stack_slot_size() -> u32 { + *GRATE_STACK_SLOT_SIZE_CACHED.get_or_init(|| { + std::env::var(GRATE_STACK_SIZE_ENV) + .ok() + .and_then(|v| v.trim().parse::().ok()) + .filter(|n| *n > 0) + // Keep slot boundaries page-aligned so the arena layout stays well-formed. + .map(|n| (n + 4095) & !4095) + .unwrap_or(DEFAULT_GRATE_STACK_SLOT_SIZE) + }) +} /// Size in bytes of the guard region placed before each grate-worker stack slot. /// diff --git a/src/wasmtime/crates/lind-3i/src/lib.rs b/src/wasmtime/crates/lind-3i/src/lib.rs index 54c58d16f..a378ca019 100644 --- a/src/wasmtime/crates/lind-3i/src/lib.rs +++ b/src/wasmtime/crates/lind-3i/src/lib.rs @@ -300,7 +300,8 @@ fn worker_stack_base(cageid: u64, workerid: WorkerId) -> u32 { panic!("STACK_ARENA_BASE is not initialized for cageid {}", cageid); }); stack_arena_base - + (workerid as u32 - 1) * (GRATE_STACK_GUARD_SIZE + GRATE_STACK_SLOT_SIZE) + + (workerid as u32 - 1) + * (GRATE_STACK_GUARD_SIZE + lind_platform_const::grate_stack_slot_size()) + GRATE_STACK_GUARD_SIZE } @@ -310,7 +311,7 @@ fn worker_stack_base(cageid: u64, workerid: WorkerId) -> u32 { /// starting a new grate call, ensuring that each invocation begins with a clean /// stack state inside that worker’s private stack slot. fn worker_stack_top(cageid: u64, workerid: WorkerId) -> u32 { - worker_stack_base(cageid, workerid) + GRATE_STACK_SLOT_SIZE + worker_stack_base(cageid, workerid) + lind_platform_const::grate_stack_slot_size() } fn configured_grate_workers() -> usize { diff --git a/src/wasmtime/crates/wasmtime/src/runtime/instance.rs b/src/wasmtime/crates/wasmtime/src/runtime/instance.rs index c276a1920..93b671333 100644 --- a/src/wasmtime/crates/wasmtime/src/runtime/instance.rs +++ b/src/wasmtime/crates/wasmtime/src/runtime/instance.rs @@ -478,7 +478,7 @@ impl Instance { let stack_arena_size = lind_platform_const::MAX_GRATE_WORKERS as usize * (lind_platform_const::GRATE_STACK_GUARD_SIZE as usize - + lind_platform_const::GRATE_STACK_SLOT_SIZE as usize); + + lind_platform_const::grate_stack_slot_size() as usize); lind_platform_const::init_stack_arena_base(cageid as usize, stack_arena_base) .unwrap_or_else(|e| { @@ -544,30 +544,31 @@ impl Instance { // // and each worker consumes exactly: // - // `GRATE_STACK_GUARD_SIZE + GRATE_STACK_SLOT_SIZE` + // `GRATE_STACK_GUARD_SIZE + grate_stack_slot_size()` // // bytes inside the arena. // // Therefore, the total reserved arena size is: // // `stack_arena_size = MAX_GRATE_WORKERS * - // (GRATE_STACK_GUARD_SIZE + GRATE_STACK_SLOT_SIZE)` + // (GRATE_STACK_GUARD_SIZE + grate_stack_slot_size())` // // The arena begins at `stack_arena_base`, which is chosen by rounding the // module's minimal required memory size upward to a host page boundary. // This guarantees that the worker-stack region starts at a page-aligned // location after the module's initial memory footprint. // - // Why can `stack_arena_size` be globally constant? + // Why is `stack_arena_size` the same for every cage? // // Because grate workers are created from one fixed runtime configuration: - // every grate instance uses the same + // every grate instance in this process uses the same // // - MAX_GRATE_WORKERS // - GRATE_STACK_GUARD_SIZE - // - GRATE_STACK_SLOT_SIZE + // - grate_stack_slot_size() (constant per process — read once from + // LIND_GRATE_STACK_SIZE and cached) // - // constants. + // values. // // In other words, the worker-pool width and the per-worker stack layout are // not instance-specific; they are part of the global lind-wasm platform @@ -583,7 +584,7 @@ impl Instance { let stack_arena_size = lind_platform_const::MAX_GRATE_WORKERS as usize * (lind_platform_const::GRATE_STACK_GUARD_SIZE as usize - + lind_platform_const::GRATE_STACK_SLOT_SIZE as usize); + + lind_platform_const::grate_stack_slot_size() as usize); lind_platform_const::init_stack_arena_base(cageid as usize, stack_arena_base) .unwrap_or_else(|e| { From 2443c15480d37167a0bbb46068020d8792921bf7 Mon Sep 17 00:00:00 2001 From: Qianxi Chen Date: Fri, 5 Jun 2026 17:55:21 +0000 Subject: [PATCH 3/4] memory tests: point invalid-access fault address above the grate stack arena The grate worker stack arena is mapped accessible at the low end of linear memory (after the stack/data region), so 0x1234567 (~18 MiB) now falls inside it and reads as valid instead of faulting. Move the probe to 0x40000000 (1 GiB): above the stack, data region, grate stack arena, and the tiny heap, yet within the 4 GiB linear memory, so it reliably hits a reserved PROT_NONE page and traps. Verified both tests fault under default settings. --- .../memory_tests/fail/invalid_access_direct.c | 11 ++++++++++- .../memory_tests/fail/invalid_access_fork.c | 11 +++++++++-- 2 files changed, 19 insertions(+), 3 deletions(-) diff --git a/tests/unit-tests/memory_tests/fail/invalid_access_direct.c b/tests/unit-tests/memory_tests/fail/invalid_access_direct.c index c16ebcaa1..ce46cc34c 100644 --- a/tests/unit-tests/memory_tests/fail/invalid_access_direct.c +++ b/tests/unit-tests/memory_tests/fail/invalid_access_direct.c @@ -8,7 +8,16 @@ #include int main(void) { - volatile int *addr = (volatile int *)0x1234567; + /* + * 1 GiB. This sits comfortably above everything rawposix maps for this cage + * -- the stack, the module data region, the grate worker stack arena (which + * scales with the worker count and per-worker stack size), and the (tiny) + * heap -- yet remains well within the 4 GiB wasm linear memory. The page is + * therefore reserved PROT_NONE and the access faults instead of reading + * mapped memory. (A lower address like 0x1234567 can fall inside the grate + * stack arena and read as valid.) + */ + volatile int *addr = (volatile int *)0x40000000; int val = *addr; /* expected to trap / fault */ printf("val=%d\n", val); return 0; diff --git a/tests/unit-tests/memory_tests/fail/invalid_access_fork.c b/tests/unit-tests/memory_tests/fail/invalid_access_fork.c index ab1ca5930..de4e74e5e 100644 --- a/tests/unit-tests/memory_tests/fail/invalid_access_fork.c +++ b/tests/unit-tests/memory_tests/fail/invalid_access_fork.c @@ -18,8 +18,15 @@ int main(void) { } if (pid == 0) { - /* child: dereference an unmapped address */ - volatile int *addr = (volatile int *)0x1234567; + /* + * child: dereference an unmapped address. 1 GiB sits above everything + * rawposix maps for this cage -- stack, data region, grate worker stack + * arena (scales with worker count and per-worker stack size), and the + * tiny heap -- yet stays within the 4 GiB wasm linear memory, so the + * page is reserved PROT_NONE and the access faults. (A lower address + * like 0x1234567 can fall inside the grate stack arena and read as valid.) + */ + volatile int *addr = (volatile int *)0x40000000; int val = *addr; /* expected to trap / fault */ printf("val=%d\n", val); exit(0); From 0d1e54fa697e096bbfe3cc36811ed9728c72c583 Mon Sep 17 00:00:00 2001 From: Qianxi Chen Date: Mon, 8 Jun 2026 17:38:41 +0000 Subject: [PATCH 4/4] grate tests: always build grates dynamically, drop --grate-build selector The grate test harness should exercise dynamically-linked grates only and expose no public switch to change that. Remove the --grate-build {static,shared,both} option (and GRATE_BUILD env), and always compile the grate without -s (dynamic). Cages are unchanged (lind-clang dynamic default). --- scripts/harnesses/gratetestreport.py | 52 +++++++++------------------- 1 file changed, 17 insertions(+), 35 deletions(-) diff --git a/scripts/harnesses/gratetestreport.py b/scripts/harnesses/gratetestreport.py index b4df2fcfb..f08cec6ce 100644 --- a/scripts/harnesses/gratetestreport.py +++ b/scripts/harnesses/gratetestreport.py @@ -118,17 +118,6 @@ def parse_arguments(argv: list[str] | None = None) -> argparse.Namespace: parser.add_argument("--debug", action="store_true", help="Enable debug logging") parser.add_argument("--testfiles", type=Path, nargs="+", help="Specific grate files (*_grate.c) to run") parser.add_argument("--clean-results", action="store_true", help="Delete output files and exit") - parser.add_argument( - "--grate-build", - choices=["static", "shared", "both"], - default=os.environ.get("GRATE_BUILD", "shared"), - help=( - "How to compile the grate: 'static' passes -s to lind-clang (the legacy " - "static grate build), 'shared' omits it (dynamically linked grate), 'both' " - "runs each test once per mode. Default: shared (override via GRATE_BUILD env). " - "Cages are always compiled shared." - ), - ) return parser.parse_args(argv) @@ -249,12 +238,11 @@ def run_subprocess(cmd: list[str], timeout: int | None = None, cwd: Path | None return subprocess.run(cmd, capture_output=True, text=True, timeout=timeout, cwd=cwd) -def compile_grate_test(test: GrateTestCase, static_build: bool = True) -> tuple[bool, str]: - # `-s` produces a statically linked grate; omitting it produces a dynamically linked - # (shared) grate. Both are supported at runtime. Cages are always built shared. - static_flag = ["-s"] if static_build else [] +def compile_grate_test(test: GrateTestCase) -> tuple[bool, str]: + # Grates are always built as dynamically linked (no -s); that is the only + # supported grate build mode for the test suite. grate_compile_cmd = ( - [GRATE_CLANG] + static_flag + ["--compile-grate", "--output-dir", "grates", test.grate_source.name] + [GRATE_CLANG, "--compile-grate", "--output-dir", "grates", test.grate_source.name] ) cage_compile_cmd = [GRATE_CLANG, test.cage_source.name] @@ -421,26 +409,20 @@ def run_report(argv: list[str] | None = None) -> dict[str, Any]: if not tests_to_run: logger.warning("No grate tests found.") - build_modes = ["static", "shared"] if args.grate_build == "both" else [args.grate_build] - for idx, test in enumerate(tests_to_run, start=1): - for mode in build_modes: - # Label results with the build mode only when running more than one, so - # single-mode runs keep their original test names. - label = f"{test.name} [{mode}]" if len(build_modes) > 1 else test.name - logger.info(f"[{idx}/{len(tests_to_run)}] {label}") - compile_ok, compile_output = compile_grate_test(test, static_build=(mode == "static")) - if not compile_ok: - add_test_result(result, label, "Failure", "Compile_Failure", compile_output) - continue - - status, output, _ = run_grate_test(test, args.timeout) - if status == "Success": - add_test_result(result, label, "Success", None, output) - elif status == "Timeout": - add_test_result(result, label, "Failure", "Timeout", output) - else: - add_test_result(result, label, "Failure", "Runtime_Failure", output) + logger.info(f"[{idx}/{len(tests_to_run)}] {test.name}") + compile_ok, compile_output = compile_grate_test(test) + if not compile_ok: + add_test_result(result, test.name, "Failure", "Compile_Failure", compile_output) + continue + + status, output, _ = run_grate_test(test, args.timeout) + if status == "Success": + add_test_result(result, test.name, "Success", None, output) + elif status == "Timeout": + add_test_result(result, test.name, "Failure", "Timeout", output) + else: + add_test_result(result, test.name, "Failure", "Runtime_Failure", output) with open(output_json, "w", encoding="utf-8") as fp: json.dump(result, fp, indent=4)