| /* | |
| * Licensed to the Apache Software Foundation (ASF) under one | |
| * or more contributor license agreements. See the NOTICE file | |
| * distributed with this work for additional information | |
| * regarding copyright ownership. The ASF licenses this file | |
| * to you under the Apache License, Version 2.0 (the | |
| * "License"); you may not use this file except in compliance | |
| * with the License. You may obtain a copy of the License at | |
| * | |
| * http://www.apache.org/licenses/LICENSE-2.0 | |
| * | |
| * Unless required by applicable law or agreed to in writing, | |
| * software distributed under the License is distributed on an | |
| * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY | |
| * KIND, either express or implied. See the License for the | |
| * specific language governing permissions and limitations | |
| * under the License. | |
| */ | |
| //! Trusted plugin VMs, separate from the model runtime. | |
| //! A VM multiplexes modules and promises; module handles own code retention. | |
| mod captured; | |
| mod engine; | |
| mod foreground; | |
| mod json; | |
| mod request; | |
| mod worker; | |
| pub use captured::Captured; | |
| pub use foreground::{Foreground, ForegroundCallbacks, ForegroundModule}; | |
| pub use request::RequestBridge; | |
| use deno_core::v8; | |
| use serde_json::Value; | |
| use std::sync::{ | |
| Arc, Mutex, OnceLock, | |
| atomic::{AtomicBool, AtomicU64, Ordering}, | |
| }; | |
| use std::time::Duration; | |
| use tokio::sync::{OwnedSemaphorePermit, Semaphore, mpsc, oneshot, watch}; | |
| use tokio_util::sync::CancellationToken; | |
| #[derive(Clone, Debug, thiserror::Error)] | |
| #[error("plugin VM: {0}")] | |
| pub struct Error(pub String); | |
| type Result<T> = std::result::Result<T, Error>; | |
| const INPUT_WINDOW: usize = 32 * 1024 * 1024; | |
| fn input_charge(bytes: usize) -> Result<u32> { | |
| u32::try_from(bytes.clamp(1, INPUT_WINDOW)).map_err(|_| failed("VM input budget exhausted")) | |
| } | |
| #[derive(Clone)] | |
| pub struct Limits { | |
| pub heap_bytes: usize, | |
| /// Maximum uninterrupted synchronous poll, not time waiting on a Host service. | |
| pub synchronous_slice: Duration, | |
| } | |
| impl Default for Limits { | |
| fn default() -> Self { | |
| Self { | |
| heap_bytes: 128 * 1024 * 1024, | |
| synchronous_slice: Duration::from_secs(5), | |
| } | |
| } | |
| } | |
| #[derive(Clone, Debug)] | |
| enum State { | |
| Loading, | |
| Ready, | |
| Closed(Option<Error>), | |
| } | |
| #[derive(Clone, Copy, PartialEq, Eq)] | |
| enum Realm { | |
| Pure, | |
| Scoped, | |
| } | |
| #[derive(Clone)] | |
| pub struct Vm(Arc<Inner>); | |
| struct Inner { | |
| realm: Realm, | |
| commands: mpsc::UnboundedSender<Command>, | |
| health: Arc<Health>, | |
| bytes: Arc<Semaphore>, | |
| sequence: AtomicU64, | |
| } | |
| #[derive(Clone)] | |
| pub struct Module(Arc<ModuleOwner>); | |
| #[derive(Clone)] | |
| pub struct WeakModule(std::sync::Weak<ModuleOwner>); | |
| impl WeakModule { | |
| pub fn upgrade(&self) -> Option<Module> { | |
| self.0.upgrade().map(Module) | |
| } | |
| } | |
| struct ModuleOwner { | |
| vm: Vm, | |
| id: u64, | |
| closing: AtomicBool, | |
| state: watch::Receiver<State>, | |
| } | |
| impl Drop for ModuleOwner { | |
| fn drop(&mut self) { | |
| if !self.closing.swap(true, Ordering::SeqCst) { | |
| let _ = self.vm.0.commands.send(Command::Close(self.id)); | |
| } | |
| } | |
| } | |
| #[derive(Clone, Debug)] | |
| pub struct Statistics { | |
| pub modules: usize, | |
| pub pending_calls: usize, | |
| pub captured_callbacks: usize, | |
| pub used_heap_bytes: usize, | |
| } | |
| enum Command { | |
| Load { | |
| id: u64, | |
| name: String, | |
| source: String, | |
| state: watch::Sender<State>, | |
| bytes: OwnedSemaphorePermit, | |
| }, | |
| Call { | |
| module: Module, | |
| target: captured::Target, | |
| args: Vec<Value>, | |
| context: Option<Arc<request::Owner>>, | |
| reply: oneshot::Sender<Result<Value>>, | |
| bytes: OwnedSemaphorePermit, | |
| }, | |
| Capture { | |
| module: Module, | |
| namespace: Vec<String>, | |
| methods: Vec<String>, | |
| reply: oneshot::Sender<Result<Captured>>, | |
| bytes: OwnedSemaphorePermit, | |
| }, | |
| ReleaseCaptured { | |
| module: u64, | |
| callback: u64, | |
| }, | |
| Close(u64), | |
| Statistics { | |
| collect: bool, | |
| reply: oneshot::Sender<Statistics>, | |
| }, | |
| } | |
| struct Health { | |
| isolate: OnceLock<v8::IsolateHandle>, | |
| failure: Mutex<Option<Error>>, | |
| stopped: CancellationToken, | |
| closed: CancellationToken, | |
| initializing: AtomicBool, | |
| polling: watch::Sender<Option<PollClock>>, | |
| } | |
| #[derive(Clone, Copy)] | |
| struct PollClock { | |
| started: tokio::time::Instant, | |
| initializing: bool, | |
| } | |
| impl Health { | |
| fn fail(&self, error: Error) { | |
| self.failure.lock().unwrap().get_or_insert_with(|| { | |
| let phase = if self.initializing.load(Ordering::SeqCst) { | |
| "runtime initialization" | |
| } else { | |
| "script poll" | |
| }; | |
| failed(format!("{} (phase: {phase})", error.0)) | |
| }); | |
| self.stopped.cancel(); | |
| if let Some(isolate) = self.isolate.get() { | |
| isolate.terminate_execution(); | |
| } | |
| } | |
| fn error(&self) -> Error { | |
| self.failure | |
| .lock() | |
| .unwrap() | |
| .clone() | |
| .unwrap_or_else(|| failed("worker closed")) | |
| } | |
| } | |
| impl Vm { | |
| /// A background realm without Host ops or ambient Deno access. | |
| pub fn pure(limits: Limits) -> Result<Self> { | |
| Self::create(limits, Realm::Pure) | |
| } | |
| /// A background realm with only native request-private capabilities. | |
| /// No ambient Host router or Deno globals are installed. | |
| pub fn scoped(limits: Limits) -> Result<Self> { | |
| Self::create(limits, Realm::Scoped) | |
| } | |
| fn create(limits: Limits, realm: Realm) -> Result<Self> { | |
| if limits.heap_bytes < 16 * 1024 * 1024 || limits.synchronous_slice.is_zero() { | |
| return Err(failed("invalid VM limits")); | |
| } | |
| let scheduler = tokio::runtime::Handle::try_current().map_err(failed)?; | |
| super::initialize_platform(); | |
| let (commands, receiver) = mpsc::unbounded_channel(); | |
| let health = Arc::new(Health { | |
| isolate: OnceLock::new(), | |
| failure: Mutex::default(), | |
| stopped: CancellationToken::new(), | |
| closed: CancellationToken::new(), | |
| initializing: AtomicBool::new(true), | |
| polling: watch::channel(None).0, | |
| }); | |
| let observer = health.clone(); | |
| let slice = limits.synchronous_slice; | |
| scheduler.spawn(async move { | |
| monitor(observer, slice).await; | |
| }); | |
| let owner = health.clone(); | |
| if let Err(error) = std::thread::Builder::new() | |
| .name("maka-plugin-js".into()) | |
| .spawn(move || { | |
| let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { | |
| tokio::runtime::Builder::new_current_thread() | |
| .enable_all() | |
| .build() | |
| .map_err(failed)? | |
| .block_on(worker::run(receiver, owner.clone(), limits, realm)) | |
| })); | |
| match result { | |
| Ok(Ok(())) => {} | |
| Ok(Err(error)) => owner.fail(error), | |
| Err(_) => owner.fail(failed("worker panicked")), | |
| } | |
| owner.closed.cancel(); | |
| }) | |
| { | |
| health.fail(failed(error)); | |
| health.closed.cancel(); | |
| return Err(health.error()); | |
| } | |
| Ok(Self(Arc::new(Inner { | |
| realm, | |
| commands, | |
| health, | |
| bytes: Arc::new(Semaphore::new(INPUT_WINDOW)), | |
| sequence: AtomicU64::new(1), | |
| }))) | |
| } | |
| /// Reserve module custody before awaiting readiness. | |
| /// Entrypoints are prebuilt ESM bundles: no imports or top-level await. | |
| pub fn load(&self, name: String, source: String) -> Result<Module> { | |
| if self.0.health.stopped.is_cancelled() { | |
| return Err(failed("VM is stopped")); | |
| } | |
| let bytes = self | |
| .0 | |
| .bytes | |
| .clone() | |
| .try_acquire_many_owned(input_charge( | |
| source | |
| .len() | |
| .checked_add(name.len()) | |
| .ok_or_else(|| failed("module input length is not representable"))?, | |
| )?) | |
| .map_err(|_| failed("VM input budget exhausted"))?; | |
| let id = self | |
| .0 | |
| .sequence | |
| .try_update(Ordering::Relaxed, Ordering::Relaxed, |n| n.checked_add(1)) | |
| .map_err(|_| failed("module identity exhausted"))?; | |
| let (state, receiver) = watch::channel(State::Loading); | |
| self.0 | |
| .commands | |
| .send(Command::Load { | |
| id, | |
| name, | |
| source, | |
| state, | |
| bytes, | |
| }) | |
| .map_err(|_| self.0.health.error())?; | |
| Ok(Module(Arc::new(ModuleOwner { | |
| vm: self.clone(), | |
| id, | |
| closing: AtomicBool::new(false), | |
| state: receiver, | |
| }))) | |
| } | |
| pub fn terminate(&self, reason: impl ToString) { | |
| self.0.health.fail(failed(reason)); | |
| } | |
| pub fn is_terminated(&self) -> bool { | |
| self.0.health.stopped.is_cancelled() || self.0.health.closed.is_cancelled() | |
| } | |
| pub async fn failed(&self) -> Error { | |
| self.0.health.stopped.cancelled().await; | |
| self.0.health.error() | |
| } | |
| pub async fn shutdown(&self) { | |
| self.terminate("VM shut down"); | |
| self.0.health.closed.cancelled().await; | |
| } | |
| pub async fn statistics(&self, collect: bool) -> Result<Statistics> { | |
| let (reply, receive) = oneshot::channel(); | |
| self.0 | |
| .commands | |
| .send(Command::Statistics { collect, reply }) | |
| .map_err(|_| self.0.health.error())?; | |
| receive.await.map_err(|_| self.0.health.error()) | |
| } | |
| } | |
| impl Module { | |
| pub fn downgrade(&self) -> WeakModule { | |
| WeakModule(Arc::downgrade(&self.0)) | |
| } | |
| pub fn vm_failed(&self) -> bool { | |
| self.0.vm.is_terminated() | |
| } | |
| /// First recorded VM stop reason. Cleanup owners inspect this before their | |
| /// own intentional shutdown, which may record a normal stop when none exists. | |
| pub fn vm_failure(&self) -> Option<Error> { | |
| self.0.vm.0.health.failure.lock().unwrap().clone() | |
| } | |
| pub async fn ready(&self) -> Result<()> { | |
| let mut state = self.0.state.clone(); | |
| loop { | |
| match state.borrow_and_update().clone() { | |
| State::Ready if !self.0.closing.load(Ordering::SeqCst) => return Ok(()), | |
| State::Loading if !self.0.closing.load(Ordering::SeqCst) => {} | |
| State::Closed(Some(error)) => return Err(error), | |
| _ => return Err(failed("module is retired")), | |
| } | |
| state | |
| .changed() | |
| .await | |
| .map_err(|_| self.0.vm.0.health.error())?; | |
| } | |
| } | |
| /// Function paths are relative to the module namespace, not globalThis. | |
| pub async fn call(&self, path: Vec<String>, args: Vec<Value>) -> Result<Value> { | |
| self.call_inner(path, args).await | |
| } | |
| /// The fault domain is this module's entire VM, including shared peers. | |
| pub fn terminate_vm(&self, reason: impl ToString) { | |
| self.0.vm.terminate(reason); | |
| } | |
| async fn call_inner(&self, path: Vec<String>, args: Vec<Value>) -> Result<Value> { | |
| self.ready().await?; | |
| if path.is_empty() || path.iter().any(|name| name.is_empty()) { | |
| return Err(failed("invalid export path")); | |
| } | |
| let size = json::encoded_size(&(&path, &args), usize::MAX).map_err(failed)?; | |
| let bytes = self | |
| .0 | |
| .vm | |
| .0 | |
| .bytes | |
| .clone() | |
| .try_acquire_many_owned(input_charge(size)?) | |
| .map_err(|_| failed("VM input budget exhausted"))?; | |
| let (reply, result) = oneshot::channel(); | |
| self.0 | |
| .vm | |
| .0 | |
| .commands | |
| .send(Command::Call { | |
| module: self.clone(), | |
| target: captured::Target::Path(path), | |
| args, | |
| context: None, | |
| reply, | |
| bytes, | |
| }) | |
| .map_err(|_| self.0.vm.0.health.error())?; | |
| result.await.map_err(|_| self.0.vm.0.health.error())? | |
| } | |
| /// Closure stops new calls and acknowledges code reclamation after admitted | |
| /// calls drain. A caller deadline does not turn unfinished cleanup into success. | |
| pub async fn close(&self) -> Result<()> { | |
| if !self.0.closing.swap(true, Ordering::SeqCst) { | |
| let _ = self.0.vm.0.commands.send(Command::Close(self.0.id)); | |
| } | |
| let mut state = self.0.state.clone(); | |
| loop { | |
| if matches!(*state.borrow_and_update(), State::Closed(_)) { | |
| return Ok(()); | |
| } | |
| if state.changed().await.is_err() { | |
| self.0.vm.0.health.closed.cancelled().await; | |
| return Ok(()); | |
| } | |
| } | |
| } | |
| } | |
| async fn monitor(health: Arc<Health>, slice: Duration) { | |
| let mut polling = health.polling.subscribe(); | |
| loop { | |
| let deadline = polling | |
| .borrow_and_update() | |
| .map(|clock| clock.started + slice); | |
| tokio::select! { | |
| _ = health.closed.cancelled() => return, | |
| _ = polling.changed() => {}, | |
| _ = async { if let Some(deadline) = deadline { tokio::time::sleep_until(deadline).await } else { std::future::pending().await } } => { | |
| // Recheck under the watch read lock; the VM may already be idle. | |
| let clock = polling.borrow(); | |
| if let Some(clock) = *clock | |
| && clock.started + slice <= tokio::time::Instant::now() { | |
| let phase = if clock.initializing { "runtime initialization" } else { "script poll" }; | |
| health.fail(failed(format!( | |
| "synchronous execution budget exceeded (poll began in {phase}, wall elapsed {} ms)", | |
| clock.started.elapsed().as_millis(), | |
| ))); | |
| return; | |
| } | |
| } | |
| } | |
| } | |
| } | |
| fn failed(error: impl ToString) -> Error { | |
| Error(error.to_string()) | |
| } |