Skip to main content

scripting/
executor.rs

1use nomiscript::SymbolTable;
2use tracing::debug;
3use wasmtime::{Engine, Linker, Module, Store};
4
5use crate::error::HookError;
6use crate::format::{BASE_OFFSET, GlobalHeader, OUTPUT_HEADER_SIZE, OutputHeader};
7use crate::host::{WasmHost, define_host_functions};
8use crate::parser::{OutputParser, ParsedEntity};
9use crate::runtime::{EngineOpts, build_engine};
10
11const DEFAULT_OUTPUT_SIZE: u32 = 64 * 1024;
12const WASM_PAGE_SIZE: u32 = 65536;
13/// Wall-clock budget for one script execution, in [`EPOCH_TICK_INTERVAL`] ticks.
14///
15/// This is a real-time bound, not a fuel bound, so it is sensitive to how fast
16/// the host happens to be. Coverage-instrumented builds run several times
17/// slower, which is why [`ScriptExecutor::with_epoch_deadline_ticks`] exists:
18/// tests that only care that a script *completes* raise the budget rather than
19/// racing the clock.
20const DEFAULT_EPOCH_DEADLINE_TICKS: u64 = 5;
21const EPOCH_TICK_INTERVAL: std::time::Duration = std::time::Duration::from_secs(1);
22
23/// Drives the engine's epoch clock for the lifetime of one execution.
24///
25/// Stopping matters: the epoch counter is shared by every execution on the same
26/// engine, so a ticker that outlives its call keeps consuming the budget of
27/// whatever runs next. A `JoinHandle` does not stop a thread when dropped, it
28/// detaches it — so the previous `drop(ticker)` left it ticking for up to
29/// `EPOCH_DEADLINE_TICKS` seconds, and every `?` between the spawn and the call
30/// skipped even that. Executors are reused (the account-script batch path runs
31/// one across many transactions), which is exactly where a stray bump lands.
32///
33/// The stop channel doubles as the sleep: `recv_timeout` returns `Timeout` for a
34/// real tick and `Disconnected` the moment the guard drops, so teardown is
35/// prompt rather than waiting out the current second.
36struct EpochTicker {
37    stop: Option<std::sync::mpsc::Sender<()>>,
38    handle: Option<std::thread::JoinHandle<()>>,
39}
40
41impl EpochTicker {
42    fn spawn(engine: Engine, ticks: u64) -> Self {
43        let (stop, rx) = std::sync::mpsc::channel::<()>();
44        let handle = std::thread::spawn(move || {
45            for _ in 0..ticks {
46                match rx.recv_timeout(EPOCH_TICK_INTERVAL) {
47                    Err(std::sync::mpsc::RecvTimeoutError::Timeout) => engine.increment_epoch(),
48                    _ => return,
49                }
50            }
51        });
52        Self {
53            stop: Some(stop),
54            handle: Some(handle),
55        }
56    }
57}
58
59impl Drop for EpochTicker {
60    fn drop(&mut self) {
61        self.stop.take();
62        if let Some(handle) = self.handle.take() {
63            let _ = handle.join();
64        }
65    }
66}
67
68pub struct ScriptExecutor {
69    host: WasmHost,
70    epoch_deadline_ticks: u64,
71}
72
73impl ScriptExecutor {
74    /// Builds an executor on the baseline engine.
75    ///
76    /// Fallible because engine construction is: `wasmtime` validates the
77    /// configuration and rejects unsupported combinations. There is no
78    /// infallible `new`/`Default` for the same reason — a constructor that
79    /// cannot report that failure can only paper over it.
80    pub fn try_new() -> Result<Self, HookError> {
81        Ok(Self {
82            host: WasmHost::new(build_engine(EngineOpts::baseline())?, SymbolTable::new()),
83            epoch_deadline_ticks: DEFAULT_EPOCH_DEADLINE_TICKS,
84        })
85    }
86
87    #[must_use]
88    pub fn with_engine(engine: Engine) -> Self {
89        Self {
90            host: WasmHost::new(engine, SymbolTable::new()),
91            epoch_deadline_ticks: DEFAULT_EPOCH_DEADLINE_TICKS,
92        }
93    }
94
95    /// Overrides the per-execution wall-clock budget.
96    ///
97    /// Production uses [`DEFAULT_EPOCH_DEADLINE_TICKS`]; a caller that runs
98    /// under coverage instrumentation, a debugger, or a loaded CI node raises
99    /// it so a slow-but-correct script is not cut off mid-execution.
100    #[must_use]
101    pub fn with_epoch_deadline_ticks(mut self, ticks: u64) -> Self {
102        self.epoch_deadline_ticks = ticks;
103        self
104    }
105
106    fn get_or_compile_module(&self, bytecode: &[u8]) -> Result<Module, HookError> {
107        self.host
108            .module_cache()
109            .get_or_compile(self.host.engine(), bytecode)
110            .map_err(HookError::from)
111    }
112
113    pub fn execute(
114        &self,
115        bytecode: &[u8],
116        input: &[u8],
117        output_size: Option<u32>,
118    ) -> Result<Vec<ParsedEntity>, HookError> {
119        debug!(bytecode_size = bytecode.len(), "script execution start");
120        let output_size = output_size.unwrap_or(DEFAULT_OUTPUT_SIZE);
121        let module = self.get_or_compile_module(bytecode)?;
122        debug!("module compiled");
123
124        let header = GlobalHeader::from_bytes(input)
125            .ok_or_else(|| HookError::Parse("Invalid input header".to_string()))?;
126
127        let input_offset = BASE_OFFSET;
128        let output_offset = input_offset + input.len() as u32;
129        let strings_offset = header.strings_pool_offset;
130
131        let exec_state = self
132            .host
133            .execution_state(input_offset, output_offset, strings_offset);
134        let mut store = Store::new(self.host.engine(), exec_state);
135        store.set_epoch_deadline(self.epoch_deadline_ticks);
136
137        let _ticker = EpochTicker::spawn(self.host.engine().clone(), self.epoch_deadline_ticks);
138
139        let mut linker = Linker::new(self.host.engine());
140        define_host_functions(&mut linker)?;
141
142        let instance = linker.instantiate(&mut store, &module)?;
143
144        let memory = instance
145            .get_memory(&mut store, "memory")
146            .ok_or(HookError::WASMMem)?;
147
148        store.data_mut().memory = Some(memory);
149
150        let total_size = input.len() + output_size as usize;
151        let required_pages = (BASE_OFFSET as usize + total_size).div_ceil(WASM_PAGE_SIZE as usize);
152        let current_pages = memory.size(&store) as usize;
153
154        if required_pages > current_pages {
155            memory.grow(&mut store, (required_pages - current_pages) as u64)?;
156        }
157
158        let mem_data = memory.data_mut(&mut store);
159        let input_start = BASE_OFFSET as usize;
160        mem_data[input_start..input_start + input.len()].copy_from_slice(input);
161
162        let output_start = output_offset as usize;
163        let input_entity_count = header.input_entity_count;
164        let output_header = OutputHeader::new(input_entity_count);
165        mem_data[output_start..output_start + OUTPUT_HEADER_SIZE]
166            .copy_from_slice(&output_header.to_bytes());
167
168        let should_apply = instance
169            .get_typed_func::<(), i32>(&mut store, "should_apply")
170            .map_err(|e| HookError::Script(format!("Missing should_apply export: {e}")))?;
171
172        let result = should_apply.call(&mut store, ())?;
173        debug!(should_apply = result, "should_apply result");
174        if result == 0 {
175            return Ok(Vec::new());
176        }
177
178        let process = instance
179            .get_typed_func::<(), ()>(&mut store, "process")
180            .map_err(|e| HookError::Script(format!("Missing process export: {e}")))?;
181
182        debug!("calling process");
183        process.call(&mut store, ())?;
184
185        let mem_data = memory.data(&store);
186        let output_data = &mem_data[output_start..output_start + output_size as usize];
187
188        let output_header = OutputHeader::from_bytes(output_data)
189            .ok_or_else(|| HookError::Parse("Invalid output header".to_string()))?;
190
191        let output_strings_offset = { output_header.strings_offset } as usize;
192
193        let parser = OutputParser::new(output_data, output_strings_offset)?;
194        let entities: Result<Vec<ParsedEntity>, HookError> = parser.entities().collect();
195        debug!(
196            entity_count = entities.as_ref().map_or(0, std::vec::Vec::len),
197            "output parse complete"
198        );
199        entities
200    }
201}
202
203#[cfg(test)]
204mod tests {
205    use super::*;
206
207    #[test]
208    fn test_executor_creation() {
209        let executor = ScriptExecutor::try_new().expect("baseline engine");
210        assert!(
211            executor
212                .host
213                .module_cache()
214                .is_empty()
215                .expect("cache lock must not be poisoned in fresh executor")
216        );
217    }
218}