blob: 826d4fd56844449c9f6777caacb8436befebb55b [file]
/*
* 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())
}