1use murmur_artifact::WitContracts;
9use wasmtime::component::{Component, Linker, ResourceTable};
10use wasmtime::{Engine, Store};
11use wasmtime_wasi::{WasiCtx, WasiCtxBuilder, WasiCtxView, WasiView};
12
13use crate::bindings::process_driver::ProcessDriver as ProcessDriverBindings;
14use crate::errors::RuntimeError;
15use crate::limits::{classify_guest_failure, EpochTicker, ExecutionLimiter, GuestFailure};
16use crate::ExecutionLimits;
17
18pub use crate::bindings::process_driver::exports::murmur::driver::process::{
19 Bridge, Description, DriverFile, Event, ExitStatus, FailureKind, InterruptMethod, LaunchPlan,
20 LaunchRequest, RetryInfo, Session, SessionInfo, SessionMode, ToolCallInfo, ToolResultInfo,
21 TurnFailure, Usage,
22};
23
24pub const PROCESS_DRIVER_IFACE: &str = "murmur:driver/process@0.2.0";
26
27const PROCESS_DRIVER_IFACE_PREFIX: &str = "murmur:driver/process@";
30
31pub fn check_driver_interface(
39 transport: &str,
40 name: &str,
41 version: &str,
42 contracts: Option<&WitContracts>,
43) -> Result<(), RuntimeError> {
44 let process_exports: Vec<String> = contracts
45 .map(|c| {
46 c.exports
47 .iter()
48 .filter(|export| export.starts_with(PROCESS_DRIVER_IFACE_PREFIX))
49 .cloned()
50 .collect()
51 })
52 .unwrap_or_default();
53 match transport {
54 "process" if process_exports.iter().any(|e| e == PROCESS_DRIVER_IFACE) => Ok(()),
55 "process" => Err(RuntimeError::ProcessDriverInterfaceMissing {
56 name: name.to_string(),
57 version: version.to_string(),
58 expected: PROCESS_DRIVER_IFACE.to_string(),
59 found: process_exports,
60 }),
61 "http" if !process_exports.is_empty() => {
62 Err(RuntimeError::HttpDriverExportsProcessInterface {
63 name: name.to_string(),
64 version: version.to_string(),
65 exported: process_exports,
66 })
67 }
68 _ => Ok(()),
69 }
70}
71
72pub fn check_required_env(
77 name: &str,
78 version: &str,
79 description: &Description,
80 env_allow: &[String],
81) -> Result<(), RuntimeError> {
82 let mut missing: Vec<String> = Vec::new();
83 for var in &description.required_env {
84 if !env_allow.contains(var) && !missing.contains(var) {
85 missing.push(var.clone());
86 }
87 }
88 if missing.is_empty() {
89 Ok(())
90 } else {
91 Err(RuntimeError::ProcessDriverRequiredEnvNotAllowed {
92 name: name.to_string(),
93 version: version.to_string(),
94 missing,
95 })
96 }
97}
98
99struct ProcessDriverStoreState {
101 wasi: WasiCtx,
102 table: ResourceTable,
103 limits: ExecutionLimiter,
104}
105
106impl WasiView for ProcessDriverStoreState {
107 fn ctx(&mut self) -> WasiCtxView<'_> {
108 WasiCtxView {
109 ctx: &mut self.wasi,
110 table: &mut self.table,
111 }
112 }
113}
114
115pub struct ProcessDriver {
118 name: String,
119 version: String,
120 store: Store<ProcessDriverStoreState>,
121 bindings: ProcessDriverBindings,
122}
123
124impl ProcessDriver {
125 pub async fn instantiate(
134 engine: &Engine,
135 component: &Component,
136 name: &str,
137 version: &str,
138 ) -> Result<Self, RuntimeError> {
139 let load_error = |message: String| RuntimeError::ProcessDriverLoad {
140 name: name.to_string(),
141 version: version.to_string(),
142 message,
143 };
144 let limits = ExecutionLimits::default().for_hook_calls(None);
145
146 let mut linker: Linker<ProcessDriverStoreState> = Linker::new(engine);
147 wasmtime_wasi::p2::add_to_linker_async(&mut linker)
148 .map_err(|e| load_error(e.to_string()))?;
149
150 let state = ProcessDriverStoreState {
151 wasi: WasiCtxBuilder::new().build(),
154 table: ResourceTable::new(),
155 limits: limits.limiter(),
156 };
157 let mut store = Store::new(engine, state);
158 store.limiter(|state| &mut state.limits);
159 store.set_epoch_deadline(limits.deadline_ticks());
160
161 let bindings = ProcessDriverBindings::instantiate_async(&mut store, component, &linker)
162 .await
163 .map_err(|e| load_error(format!("{e:#}")))?;
164 Ok(Self {
165 name: name.to_string(),
166 version: version.to_string(),
167 store,
168 bindings,
169 })
170 }
171
172 pub async fn describe(&mut self) -> Result<Description, RuntimeError> {
177 self.reset_deadline();
178 let description = self
179 .bindings
180 .murmur_driver_process()
181 .call_describe(&mut self.store)
182 .await
183 .map_err(|e| {
184 let message = match classify_guest_failure(&e, &self.store.data().limits) {
185 GuestFailure::DeadlineExceeded { seconds } => {
186 format!("describe() exceeded its {seconds}s deadline and was interrupted")
187 }
188 GuestFailure::ResourceLimit { message } => {
189 format!("describe() exceeded its resource limits: {message}")
190 }
191 GuestFailure::Other => format!("describe() trapped: {e:#}"),
192 };
193 self.load_error(message)
194 })?;
195 if let Some(bad) = unusable_env_name(&description) {
196 return Err(self.load_error(format!(
197 "describe() names {bad:?} in required-env, which is not a usable variable name"
198 )));
199 }
200 Ok(description)
201 }
202
203 pub async fn launch(
206 &mut self,
207 request: LaunchRequest,
208 ) -> Result<Result<LaunchPlan, String>, RuntimeError> {
209 self.reset_deadline();
210 self.bindings
211 .murmur_driver_process()
212 .call_launch(&mut self.store, &request)
213 .await
214 .map_err(|e| self.call_error("launch", &e))
215 }
216
217 pub async fn parse(&mut self, lines: Vec<String>) -> Result<Vec<Event>, RuntimeError> {
220 self.reset_deadline();
221 self.bindings
222 .murmur_driver_process()
223 .call_parse(&mut self.store, &lines)
224 .await
225 .map_err(|e| self.call_error("parse", &e))
226 }
227
228 pub async fn classify_exit(&mut self, exit: ExitStatus) -> Result<Event, RuntimeError> {
230 self.reset_deadline();
231 self.bindings
232 .murmur_driver_process()
233 .call_classify_exit(&mut self.store, &exit)
234 .await
235 .map_err(|e| self.call_error("classify-exit", &e))
236 }
237
238 fn reset_deadline(&mut self) {
242 let deadline = self.store.data().limits.limits().deadline_ticks();
243 self.store.set_epoch_deadline(deadline);
244 }
245
246 fn call_error(&self, call: &str, error: &wasmtime::Error) -> RuntimeError {
247 let message = match classify_guest_failure(error, &self.store.data().limits) {
248 GuestFailure::DeadlineExceeded { seconds } => {
249 format!("exceeded its {seconds}s deadline and was interrupted")
250 }
251 GuestFailure::ResourceLimit { message } => {
252 format!("exceeded its resource limits: {message}")
253 }
254 GuestFailure::Other => format!("trapped: {error:#}"),
255 };
256 RuntimeError::ProcessDriverCallFailed {
257 name: self.name.clone(),
258 version: self.version.clone(),
259 call: call.to_string(),
260 message,
261 }
262 }
263
264 fn load_error(&self, message: String) -> RuntimeError {
265 RuntimeError::ProcessDriverLoad {
266 name: self.name.clone(),
267 version: self.version.clone(),
268 message,
269 }
270 }
271}
272
273pub(crate) fn is_usable_env_name(name: &str) -> bool {
280 !name.is_empty() && !name.contains('=') && !name.contains('\0')
281}
282
283fn unusable_env_name(description: &Description) -> Option<&str> {
286 description
287 .required_env
288 .iter()
289 .find(|var| !is_usable_env_name(var))
290 .map(String::as_str)
291}
292
293pub fn describe_process_driver_wasm(wasm: &[u8]) -> Result<Description, RuntimeError> {
300 const NAME: &str = "<wasm>";
301 const VERSION: &str = "";
302 let engine = crate::runtime::build_engine()?;
303 let _ticker = EpochTicker::spawn(&engine);
304 let component =
305 Component::new(&engine, wasm).map_err(|e| RuntimeError::ToolComponentCompile {
306 name: NAME.to_string(),
307 version: VERSION.to_string(),
308 message: e.to_string(),
309 })?;
310 let rt = tokio::runtime::Builder::new_current_thread()
311 .enable_all()
312 .build()
313 .map_err(|e| {
314 RuntimeError::Runtime(format!("failed to build process driver runtime: {e}"))
315 })?;
316 rt.block_on(async {
317 let mut driver = ProcessDriver::instantiate(&engine, &component, NAME, VERSION).await?;
318 driver.describe().await
319 })
320}
321
322#[cfg(test)]
323mod tests {
324 use super::*;
325
326 fn contracts(exports: &[&str]) -> WitContracts {
327 WitContracts {
328 exports: exports.iter().map(|e| e.to_string()).collect(),
329 imports: Vec::new(),
330 }
331 }
332
333 #[test]
334 fn process_driver_interface_process_transport_accepts_the_process_export() {
335 let c = contracts(&[PROCESS_DRIVER_IFACE]);
336 assert!(check_driver_interface("process", "d", "1.0.0", Some(&c)).is_ok());
337 }
338
339 #[test]
340 fn process_driver_interface_process_transport_refuses_a_tool_export() {
341 let c = contracts(&["murmur:tool/run@0.1.0"]);
342 let err = check_driver_interface("process", "d", "1.0.0", Some(&c)).unwrap_err();
343 match &err {
344 RuntimeError::ProcessDriverInterfaceMissing {
345 expected, found, ..
346 } => {
347 assert_eq!(expected, PROCESS_DRIVER_IFACE);
348 assert!(found.is_empty());
349 }
350 other => panic!("unexpected error: {other:?}"),
351 }
352 let message = err.to_string();
353 assert!(message.contains("'d@1.0.0'"), "{message}");
354 assert!(message.contains(PROCESS_DRIVER_IFACE), "{message}");
355 }
356
357 #[test]
358 fn process_driver_interface_process_transport_names_the_version_found() {
359 let c = contracts(&["murmur:driver/process@0.0.1"]);
360 let err = check_driver_interface("process", "d", "1.0.0", Some(&c)).unwrap_err();
361 match &err {
362 RuntimeError::ProcessDriverInterfaceMissing { found, .. } => {
363 assert_eq!(found, &vec!["murmur:driver/process@0.0.1".to_string()]);
364 }
365 other => panic!("unexpected error: {other:?}"),
366 }
367 let message = err.to_string();
368 assert!(message.contains("murmur:driver/process@0.0.1"), "{message}");
369 assert!(message.contains("rebuild it"), "{message}");
370 }
371
372 #[test]
373 fn process_driver_interface_process_transport_refuses_no_contracts() {
374 let err = check_driver_interface("process", "d", "1.0.0", None).unwrap_err();
375 assert!(matches!(
376 err,
377 RuntimeError::ProcessDriverInterfaceMissing { ref found, .. } if found.is_empty()
378 ));
379 }
380
381 #[test]
382 fn process_driver_interface_http_transport_refuses_the_process_export() {
383 let c = contracts(&[PROCESS_DRIVER_IFACE]);
384 let err = check_driver_interface("http", "d", "1.0.0", Some(&c)).unwrap_err();
385 match &err {
386 RuntimeError::HttpDriverExportsProcessInterface { name, exported, .. } => {
387 assert_eq!(name, "d");
388 assert_eq!(exported, &vec![PROCESS_DRIVER_IFACE.to_string()]);
389 }
390 other => panic!("unexpected error: {other:?}"),
391 }
392 }
393
394 #[test]
395 fn process_driver_interface_http_transport_accepts_a_tool_export() {
396 let c = contracts(&["murmur:tool/run@0.1.0"]);
397 assert!(check_driver_interface("http", "d", "1.0.0", Some(&c)).is_ok());
398 }
399
400 fn description(required_env: &[&str]) -> Description {
401 Description {
402 harness: "h".to_string(),
403 binary: "b".to_string(),
404 version_args: Vec::new(),
405 tested_versions: Vec::new(),
406 interrupt: InterruptMethod::Unsupported,
407 required_env: required_env.iter().map(|v| v.to_string()).collect(),
408 streams_text: false,
409 reports_usage: false,
410 }
411 }
412
413 #[test]
414 fn required_env_all_declared_passes() {
415 let allow = vec!["A".to_string(), "B".to_string(), "C".to_string()];
416 assert!(check_required_env("d", "1.0.0", &description(&["B", "A"]), &allow).is_ok());
417 }
418
419 #[test]
420 fn required_env_reports_missing_names_in_order_once_each() {
421 let allow = vec!["B".to_string()];
422 let err = check_required_env("d", "1.0.0", &description(&["Z", "B", "A", "Z"]), &allow)
423 .unwrap_err();
424 match &err {
425 RuntimeError::ProcessDriverRequiredEnvNotAllowed { missing, .. } => {
426 assert_eq!(missing, &vec!["Z".to_string(), "A".to_string()]);
427 }
428 other => panic!("unexpected error: {other:?}"),
429 }
430 let message = err.to_string();
431 assert!(message.contains("'d@1.0.0'"), "{message}");
432 assert!(message.contains("Z, A"), "{message}");
433 }
434
435 #[test]
436 fn required_env_usable_names_pass() {
437 assert_eq!(
438 unusable_env_name(&description(&["HOME", "PROFILE_2"])),
439 None
440 );
441 assert_eq!(unusable_env_name(&description(&[])), None);
442 }
443
444 #[test]
445 fn required_env_refuses_a_name_that_is_not_a_variable() {
446 for bad in ["", "A=B", "A\0B"] {
447 assert_eq!(
448 unusable_env_name(&description(&["HOME", bad, "TAIL"])),
449 Some(bad),
450 "{bad:?} should be refused"
451 );
452 }
453 }
454
455 #[test]
461 #[ignore = "a measurement, not an assertion; run with --release --ignored --nocapture"]
462 fn process_driver_parse_cost() {
463 use std::time::Instant;
464
465 const WASM: &[u8] = include_bytes!(concat!(
466 env!("CARGO_MANIFEST_DIR"),
467 "/../murmur-cli/tests/fixtures/process-driver/tool/process-driver.wasm"
468 ));
469 let engine = crate::runtime::build_engine().expect("engine");
470 let _ticker = EpochTicker::spawn(&engine);
471 let component = Component::new(&engine, WASM).expect("fixture compiles");
472 let rt = tokio::runtime::Builder::new_current_thread()
473 .enable_all()
474 .build()
475 .expect("runtime");
476
477 let instantiations = 50;
478 let start = Instant::now();
479 for _ in 0..instantiations {
480 rt.block_on(ProcessDriver::instantiate(
481 &engine, &component, "d", "0.1.0",
482 ))
483 .expect("instantiates");
484 }
485 let per_instantiation = start.elapsed() / instantiations;
486
487 let mut driver = rt
488 .block_on(ProcessDriver::instantiate(
489 &engine, &component, "d", "0.1.0",
490 ))
491 .expect("instantiates");
492 for lines in [1usize, 64] {
493 let batch: Vec<String> = (0..lines).map(|i| format!("text line {i}")).collect();
494 let runs = 2_000;
495 let mut samples = Vec::with_capacity(runs);
496 for _ in 0..runs {
497 let start = Instant::now();
498 let events = rt.block_on(driver.parse(batch.clone())).expect("parses");
499 samples.push(start.elapsed());
500 assert_eq!(events.len(), lines);
501 }
502 samples.sort_unstable();
503 let median = samples[samples.len() / 2];
504 let p95 = samples[samples.len() * 95 / 100];
505 crate::diagnostic::raw_to_stdout(&format!(
506 "parse {lines}-line batch on one reused instance: median {median:?}, p95 {p95:?} \
507 ({runs} calls)\n"
508 ));
509 }
510 crate::diagnostic::raw_to_stdout(&format!(
511 "instantiate one driver: {per_instantiation:?} (mean of {instantiations})\n"
512 ));
513 }
514
515 #[test]
516 fn process_driver_granted_nothing() {
517 let wat = r#"(component
518 (import "murmur:text/chunks@0.1.0" (instance
519 (export "emit-chunk" (func (param "chunk" string)))
520 ))
521 )"#;
522 let bytes = wat::parse_str(wat).expect("component WAT parses");
523 let engine = crate::runtime::build_engine().expect("engine");
524 let _ticker = EpochTicker::spawn(&engine);
525 let component = Component::new(&engine, &bytes).expect("component compiles");
526 let rt = tokio::runtime::Builder::new_current_thread()
527 .enable_all()
528 .build()
529 .expect("runtime");
530 let result = rt.block_on(ProcessDriver::instantiate(
531 &engine, &component, "d", "1.0.0",
532 ));
533 match result {
534 Err(RuntimeError::ProcessDriverLoad { name, message, .. }) => {
535 assert_eq!(name, "d");
536 assert!(message.contains("murmur:text/chunks@0.1.0"), "{message}");
537 }
538 Err(other) => panic!("unexpected error: {other:?}"),
539 Ok(_) => panic!("a driver importing a Murmur interface instantiated"),
540 }
541 }
542}