Add a bounded V8 runtime for UI plugin processes (#57508)

## What changed

- Add `--codex-ui-plugin-process` to `codex-code-mode-host` and a typed protocol for initializing, invoking, and closing UI plugin isolates.
- Support persistent module state and string results from synchronous or promise-based exports across up to 32 isolates in one helper process.
- Require sandbox-enabled V8 with JIT disabled, restrict executable loading and globals, and enforce execution deadlines and per-instance queue, source, result, and promise limits.
- Close affected isolates on script failures, cancellation, or overload, and return bounded script diagnostics.
- Bump the helper handshake to version 3 and increase the maximum frame size to 1 MiB.

## Testing

Add wire-level integration tests and runtime unit tests covering promise settlement, cancellation, loading restrictions, diagnostics, failure isolation, capacity reuse, transport bounds, and watchdog synchronization. Add CLI tests for argument validation before helper startup.

GitOrigin-RevId: bad6a57c84d9197c1fd76a78cf060502c5bc8bb8
This commit is contained in:
Joe Savona
2026-10-27 16:36:32 +00:00
committed by copyberry
parent a43fffca74
commit 2d1984b9f7
22 changed files with 1856 additions and 6 deletions
+3 -2
View File
@@ -15,13 +15,14 @@ workspace = true
[dependencies]
base64 = { workspace = true }
codex-code-mode-protocol = { workspace = true }
codex-plugin-host = { workspace = true }
codex-protocol = { workspace = true }
deno_core_icudata = { workspace = true }
futures = { workspace = true }
futures = { workspace = true, features = ["executor", "std"] }
opentelemetry = { workspace = true }
serde_json = { workspace = true }
tokio = { workspace = true, features = ["macros", "rt", "sync", "time"] }
tokio-util = { workspace = true, features = ["rt"] }
tokio-util = { workspace = true, features = ["codec", "compat", "rt"] }
tracing = { workspace = true }
v8 = { workspace = true, features = ["v8_enable_sandbox"] }
+2
View File
@@ -2,11 +2,13 @@ mod cell_actor;
mod runtime;
mod service;
mod session_runtime;
mod ui;
mod v8_init;
pub(crate) type TaskFailureHandler = std::sync::Arc<dyn Fn(String) + Send + Sync>;
pub use codex_code_mode_protocol::*;
pub use service::InProcessCodeModeSession;
pub use ui::run as run_ui_sandbox;
pub use v8_init::V8JitMode;
pub use v8_init::initialize_v8;
+278
View File
@@ -0,0 +1,278 @@
//! Persistent module state with explicit microtask pumping and no dynamic executable loading.
use std::collections::HashMap;
use std::io;
use codex_plugin_host::protocol::*;
use super::failures;
use super::transport::Transport;
pub(super) struct Engine {
context: v8::Global<v8::Context>,
namespace: Option<v8::Global<v8::Object>>,
pending_promises: HashMap<u64, v8::Global<v8::Promise>>,
pub(super) transport: Transport,
entered: bool,
isolate: v8::OwnedIsolate,
}
impl Engine {
/// Construct an isolate; user code runs only in watchdog-protected turns.
pub(super) fn new(transport: Transport) -> io::Result<Self> {
// V8 heap exhaustion is fatal to the shared helper; the parent survives and can restart it.
let mut isolate =
v8::Isolate::new(v8::CreateParams::default().heap_limits(0, 64 * 1024 * 1024));
isolate.set_microtasks_policy(v8::MicrotasksPolicy::Explicit);
failures::install(&mut isolate);
// V8 rejects dynamic imports when no host loader is installed.
isolate.set_allow_wasm_code_generation_callback(reject_wasm);
let context = {
v8::scope!(let scope, &mut isolate);
let context = v8::Context::new(scope, Default::default());
context.set_allow_generation_from_strings(false);
let scope = &mut v8::ContextScope::new(scope, context);
let global = context.global(scope);
// Binary buffers are omitted until the sandbox-enabled allocator supports quotas.
for name in [
"console",
"ArrayBuffer",
"SharedArrayBuffer",
"Atomics",
"WebAssembly",
"DataView",
"Int8Array",
"Uint8Array",
"Uint8ClampedArray",
"Int16Array",
"Uint16Array",
"Int32Array",
"Uint32Array",
"Float16Array",
"Float32Array",
"Float64Array",
"BigInt64Array",
"BigUint64Array",
] {
let key = v8::String::new(scope, name)
.ok_or_else(|| io::Error::other("V8 allocation failed"))?;
if global.delete(scope, key.into()) != Some(true) {
return Err(io::Error::other("failed to restrict UI globals"));
}
}
v8::Global::new(scope, context)
};
let mut runtime = Self {
entered: true,
transport,
isolate,
context,
namespace: None,
pending_promises: HashMap::new(),
};
// SAFETY: no handle scopes remain; the executor enters only the isolate being polled.
unsafe {
runtime.isolate.exit();
}
runtime.entered = false;
Ok(runtime)
}
pub(super) fn handle(&self) -> v8::IsolateHandle {
self.isolate.thread_safe_handle()
}
pub(super) fn turn(
&mut self,
message: HostMessage,
) -> Result<Vec<RuntimeMessage>, RuntimeMessage> {
// SAFETY: all isolates stay on this executor; no scopes survive a turn.
unsafe {
self.isolate.enter();
}
self.entered = true;
let result = match message {
HostMessage::Initialize { source } => self
.load(&source)
.map(|()| vec![RuntimeMessage::Initialized]),
HostMessage::Dispatch {
id,
export,
return_mode,
} => self.dispatch(id, &export, return_mode),
HostMessage::Close => Err(ErrorCode::InvalidProgram),
};
let script_error = failures::take(&mut self.isolate);
// SAFETY: load/dispatch have released all scopes before yielding the executor.
unsafe {
self.isolate.exit();
}
self.entered = false;
result.map_err(|code| RuntimeMessage::Error { code, script_error })
}
pub(super) fn load(&mut self, source: &str) -> Result<(), ErrorCode> {
if self.namespace.is_some() || source.len() > MAX_SOURCE {
return Err(ErrorCode::InvalidProgram);
}
v8::scope!(let scope, &mut self.isolate);
let context = v8::Local::new(scope, &self.context);
let scope = &mut v8::ContextScope::new(scope, context);
let tc = std::pin::pin!(v8::TryCatch::new(scope));
let mut tc = tc.init();
let result = (|| {
let source = v8::String::new(&tc, source).ok_or(ErrorCode::QuotaExceeded)?;
let name = v8::String::new(&tc, "plugin.mjs").ok_or(ErrorCode::QuotaExceeded)?;
let origin = v8::ScriptOrigin::new(
&tc,
name.into(),
/*resource_line_offset*/ 0,
/*resource_column_offset*/ 0,
/*resource_is_shared_cross_origin*/ false,
/*script_id*/ 0,
/*source_map_url*/ None,
/*resource_is_opaque*/ false,
/*is_wasm*/ false,
/*is_module*/ true,
/*host_defined_options*/ None,
);
let mut source = v8::script_compiler::Source::new(source, Some(&origin));
let module = v8::script_compiler::compile_module(&tc, &mut source)
.ok_or(ErrorCode::InvalidProgram)?;
// Reject imports before V8 invokes its platform-specific resolver callback.
if module.get_module_requests().length() != 0 {
let text = v8::String::new(&tc, "module imports are unavailable")
.ok_or(ErrorCode::QuotaExceeded)?;
let error = v8::Exception::type_error(&tc, text);
let message = v8::Exception::create_message(&tc, error);
failures::record(&mut tc, Some(message));
return Err(ErrorCode::InvalidProgram);
}
module
.instantiate_module(&tc, |_, _, _, _| None)
.ok_or(ErrorCode::InvalidProgram)?;
let evaluation = module.evaluate(&tc).ok_or(ErrorCode::ExecutionFailed)?;
let evaluation = v8::Local::<v8::Promise>::try_from(evaluation)
.map_err(|_| ErrorCode::InvalidProgram)?;
failures::checkpoint(&mut tc)?;
if evaluation.state() != v8::PromiseState::Fulfilled {
return Err(ErrorCode::ExecutionFailed);
}
let namespace = v8::Local::<v8::Object>::try_from(module.get_module_namespace())
.map_err(|_| ErrorCode::InvalidProgram)?;
self.namespace = Some(v8::Global::new(&tc, namespace));
Ok(())
})();
if result.is_err() {
let message = tc.message();
failures::record(&mut tc, message);
}
result
}
/// One JS turn; unresolved invocations yield back to the IPC dispatcher.
pub(super) fn dispatch(
&mut self,
id: u64,
export: &str,
return_mode: ReturnMode,
) -> Result<Vec<RuntimeMessage>, ErrorCode> {
let namespace = self.namespace.as_ref().ok_or(ErrorCode::InvalidProgram)?;
v8::scope!(let scope, &mut self.isolate);
let context = v8::Local::new(scope, &self.context);
let scope = &mut v8::ContextScope::new(scope, context);
let tc = std::pin::pin!(v8::TryCatch::new(scope));
let mut tc = tc.init();
let result = (|| {
let mut replies = Vec::new();
if export.is_empty()
|| export.len() > MAX_METHOD
|| self.pending_promises.contains_key(&id)
{
return Err(ErrorCode::QuotaExceeded);
}
let namespace = v8::Local::new(&tc, namespace);
let key = v8::String::new(&tc, export).ok_or(ErrorCode::QuotaExceeded)?;
let function = namespace
.get(&tc, key.into())
.ok_or(ErrorCode::InvalidProgram)?;
let function = v8::Local::<v8::Function>::try_from(function)
.map_err(|_| ErrorCode::InvalidProgram)?;
let receiver = v8::undefined(&tc).into();
let result = function
.call(&tc, receiver, &[])
.ok_or(ErrorCode::ExecutionFailed)?;
if return_mode == ReturnMode::MaybeAsync && result.is_promise() {
if self.pending_promises.len() >= MAX_PENDING_PROMISES {
return Err(ErrorCode::QuotaExceeded);
}
let promise = v8::Local::<v8::Promise>::try_from(result)
.map_err(|_| ErrorCode::InvalidResult)?;
self.pending_promises
.insert(id, v8::Global::new(&tc, promise));
} else {
replies.push(RuntimeMessage::Reply {
id,
value: result_string(&mut tc, result)?,
});
}
failures::checkpoint(&mut tc)?;
let completed: Vec<_> = self
.pending_promises
.extract_if(|_, promise| {
v8::Local::new(&tc, &*promise).state() != v8::PromiseState::Pending
})
.collect();
for (id, promise) in completed {
let promise = v8::Local::new(&tc, promise);
if promise.state() != v8::PromiseState::Fulfilled {
failures::record_rejection(&mut tc, promise);
return Err(ErrorCode::ExecutionFailed);
}
let value = promise.result(&tc);
replies.push(RuntimeMessage::Reply {
id,
value: result_string(&mut tc, value)?,
});
}
if self.pending_promises.contains_key(&id) {
replies.push(RuntimeMessage::Pending { id });
}
Ok(replies)
})();
if result.is_err() {
let message = tc.message();
failures::record(&mut tc, message);
}
result
}
}
impl Drop for Engine {
fn drop(&mut self) {
// SAFETY: the executor drops only exited engines. OwnedIsolate's destructor exits it.
if !self.entered {
unsafe {
self.isolate.enter();
}
}
}
}
fn result_string(
scope: &mut v8::PinScope<'_, '_>,
result: v8::Local<v8::Value>,
) -> Result<String, ErrorCode> {
let value = v8::Local::<v8::String>::try_from(result).map_err(|_| ErrorCode::InvalidResult)?;
if value.length() > MAX_RESULT || value.utf8_length(scope) > MAX_RESULT {
return Err(ErrorCode::QuotaExceeded);
}
Ok(value.to_rust_string_lossy(scope))
}
unsafe extern "C" fn reject_wasm(
_context: v8::Local<v8::Context>,
_source: v8::Local<v8::String>,
) -> bool {
false
}
@@ -0,0 +1,99 @@
//! Bounded per-isolate rejection tracking and script error capture.
use codex_plugin_host::protocol::ErrorCode;
use codex_plugin_host::protocol::MAX_SCRIPT_ERROR_BYTES;
use codex_plugin_host::protocol::PluginScriptError;
const MAX_REJECTIONS: usize = 32;
#[derive(Default)]
struct Failures {
rejected: Vec<v8::Global<v8::Promise>>,
overflowed: bool,
script_error: Option<PluginScriptError>,
}
pub(super) fn install(isolate: &mut v8::OwnedIsolate) {
isolate.set_slot(Failures::default());
isolate.set_promise_reject_callback(rejected);
}
extern "C" fn rejected(message: v8::PromiseRejectMessage) {
// SAFETY: V8 supplies the callback's active isolate and scope.
v8::callback_scope!(unsafe scope, &message);
match message.get_event() {
v8::PromiseRejectEvent::PromiseRejectWithNoHandler => {
let promise = v8::Global::new(scope, message.get_promise());
let Some(state) = scope.get_slot_mut::<Failures>() else {
return;
};
if state.rejected.len() == MAX_REJECTIONS {
state.overflowed = true;
} else {
state.rejected.push(promise);
}
}
v8::PromiseRejectEvent::PromiseHandlerAddedAfterReject => {
let promise = message.get_promise();
if let Some(state) = scope.get_slot_mut::<Failures>() {
state.rejected.retain(|candidate| candidate != &promise);
}
}
v8::PromiseRejectEvent::PromiseRejectAfterResolved
| v8::PromiseRejectEvent::PromiseResolveAfterResolved => {}
}
}
pub(super) fn checkpoint(scope: &mut v8::PinScope<'_, '_>) -> Result<(), ErrorCode> {
scope.perform_microtask_checkpoint();
if scope.is_execution_terminating() {
return Err(ErrorCode::ExecutionFailed);
}
let (rejected, overflowed) = {
let state = scope
.get_slot_mut::<Failures>()
.ok_or(ErrorCode::ExecutionFailed)?;
(
std::mem::take(&mut state.rejected),
std::mem::take(&mut state.overflowed),
)
};
if let Some(promise) = rejected.first() {
let promise = v8::Local::new(scope, promise);
record_rejection(scope, promise);
return Err(ErrorCode::ExecutionFailed);
}
if overflowed {
return Err(ErrorCode::QuotaExceeded);
}
Ok(())
}
pub(super) fn record_rejection(scope: &mut v8::PinScope<'_, '_>, promise: v8::Local<v8::Promise>) {
let reason = promise.result(scope);
let message = v8::Exception::create_message(scope, reason);
record(scope, Some(message));
}
pub(super) fn record(scope: &mut v8::PinScope<'_, '_>, message: Option<v8::Local<v8::Message>>) {
let Some(message) = message else { return };
let text = message.get(scope);
let full_len = text.utf8_length(scope);
let mut bytes = vec![0; full_len.min(MAX_SCRIPT_ERROR_BYTES)];
let len = text.write_utf8_v2(scope, &mut bytes, v8::WriteFlags::kReplaceInvalidUtf8, None);
let script_error = PluginScriptError {
message: String::from_utf8_lossy(&bytes[..len]).into_owned(),
line: message.get_line_number(scope),
column: message.get_start_column().checked_add(1),
truncated: len < full_len,
};
if let Some(state) = scope.get_slot_mut::<Failures>() {
state.script_error = Some(script_error);
}
}
pub(super) fn take(isolate: &mut v8::Isolate) -> Option<PluginScriptError> {
isolate
.get_slot_mut::<Failures>()
.and_then(|state| state.script_error.take())
}
+23
View File
@@ -0,0 +1,23 @@
//! Lightweight UI isolates share one bounded executor in the plugin process.
//! No ordinary CodeMode tools or globals.
//! Script failures discard their isolate without affecting its peers.
mod engine;
mod failures;
mod transport;
mod watchdog;
mod workers;
use std::io;
pub fn run() -> io::Result<()> {
crate::initialize_v8(crate::V8JitMode::Disabled).map_err(io::Error::other)?;
unsafe extern "C" {
fn v8__V8__IsSandboxEnabled() -> bool;
}
// SAFETY: linked V8 exposes this query and has already been initialized.
if !unsafe { v8__V8__IsSandboxEnabled() } {
return Err(io::Error::other("sandbox-enabled V8 required"));
}
workers::run()
}
@@ -0,0 +1,133 @@
//! Framed I/O and per-instance queue admission for the plugin child.
use codex_plugin_host::framing;
use codex_plugin_host::protocol::MAX_IN_FLIGHT_TURNS;
use codex_plugin_host::protocol::Packet;
use codex_plugin_host::protocol::RuntimeMessage;
use futures::io::AllowStdIo;
use std::io;
use std::sync::Arc;
use std::sync::OnceLock;
use std::sync::atomic::AtomicBool;
use std::sync::atomic::Ordering;
use std::sync::mpsc;
use tokio::sync::OwnedSemaphorePermit;
use tokio::sync::Semaphore;
use tokio_util::codec::FramedRead;
use tokio_util::codec::FramedWrite;
use tokio_util::codec::LengthDelimitedCodec;
use tokio_util::compat::Compat;
use tokio_util::compat::FuturesAsyncReadCompatExt;
use tokio_util::compat::FuturesAsyncWriteCompatExt;
// A reply may reach the parent just before its writer releases the output slot.
pub(super) const OUTPUT_SLOTS_PER_INSTANCE: usize = MAX_IN_FLIGHT_TURNS + 1;
/// Only poll on a dedicated blocking thread: AllowStdIo blocks inside poll_read.
pub(super) fn blocking_reader<R: io::Read>(
input: R,
) -> FramedRead<Compat<AllowStdIo<R>>, LengthDelimitedCodec> {
FramedRead::new(AllowStdIo::new(input).compat(), framing::codec())
}
/// Only poll on a dedicated blocking thread: AllowStdIo blocks inside poll_write.
pub(super) fn blocking_writer<W: io::Write>(
output: W,
) -> FramedWrite<Compat<AllowStdIo<W>>, LengthDelimitedCodec> {
FramedWrite::new(AllowStdIo::new(output).compat_write(), framing::codec())
}
pub(super) struct Instance {
pub(super) id: u64,
pub(super) retired: Arc<AtomicBool>,
pub(super) close_output: OnceLock<(mpsc::SyncSender<QueuedOutput>, QueuedOutput)>,
pub(super) closed: AtomicBool,
pub(super) input_slots: Arc<Semaphore>,
output_slots: Arc<Semaphore>,
}
impl Instance {
pub(super) fn new(id: u64) -> Self {
Self {
id,
retired: Arc::new(AtomicBool::new(false)),
close_output: OnceLock::new(),
closed: AtomicBool::new(false),
input_slots: Arc::new(Semaphore::new(MAX_IN_FLIGHT_TURNS)),
output_slots: Arc::new(Semaphore::new(OUTPUT_SLOTS_PER_INSTANCE)),
}
}
}
impl Drop for Instance {
fn drop(&mut self) {
// The engine, queued turns and earlier output have all released this instance.
if let Some((outgoing_tx, output)) = self.close_output.take()
&& let Err(error) = outgoing_tx.try_send(output)
{
super::workers::fatal(io::Error::other(error));
}
}
}
pub(super) enum OutputReservation {
Turn {
_instance: Arc<Instance>,
_permit: OwnedSemaphorePermit,
},
Close {
retired: Arc<AtomicBool>,
},
Rejected {
_permit: OwnedSemaphorePermit,
},
}
pub(super) struct QueuedOutput {
pub(super) packets: Vec<Packet<RuntimeMessage>>,
pub(super) _reservation: OutputReservation,
}
pub(super) struct Transport {
pub(super) instance: Arc<Instance>,
pub(super) outgoing_tx: mpsc::SyncSender<QueuedOutput>,
}
impl Transport {
pub(super) fn ensure_open(&self) -> io::Result<()> {
if self.instance.closed.load(Ordering::Acquire) {
return Err(io::Error::other("plugin instance closed"));
}
Ok(())
}
pub(super) fn write(&self, messages: Vec<RuntimeMessage>) -> io::Result<()> {
self.ensure_open()?;
if messages.is_empty() {
return Ok(());
}
let permit = Arc::clone(&self.instance.output_slots)
.try_acquire_owned()
.map_err(|_| io::Error::other("plugin instance output full"))?;
let packets = messages
.into_iter()
.map(|message| Packet {
instance: self.instance.id,
message,
})
.collect();
self.outgoing_tx
.try_send(QueuedOutput {
packets,
_reservation: OutputReservation::Turn {
_instance: Arc::clone(&self.instance),
_permit: permit,
},
})
.map_err(io::Error::other)
}
}
#[cfg(test)]
#[path = "transport_tests.rs"]
mod tests;
@@ -0,0 +1,71 @@
#![cfg(test)]
//! Native sends fail immediately on saturation or cancellation.
use super::*;
use futures::SinkExt;
use futures::StreamExt;
use futures::executor::block_on;
use pretty_assertions::assert_eq;
#[test]
fn a_full_instance_output_does_not_block_peers_or_the_executor() {
let (outgoing_tx, outgoing_rx) = mpsc::sync_channel(OUTPUT_SLOTS_PER_INSTANCE + 1);
let first = Transport {
instance: Arc::new(Instance::new(/*id*/ 1)),
outgoing_tx: outgoing_tx.clone(),
};
let peer = Transport {
instance: Arc::new(Instance::new(/*id*/ 2)),
outgoing_tx,
};
for _ in 0..OUTPUT_SLOTS_PER_INSTANCE {
first.write(vec![RuntimeMessage::Initialized]).unwrap();
}
assert!(first.write(vec![RuntimeMessage::Initialized]).is_err());
peer.write(vec![RuntimeMessage::Initialized]).unwrap();
assert_eq!(outgoing_rx.recv().unwrap().packets[0].instance, 1);
first.write(vec![RuntimeMessage::Initialized]).unwrap();
first.instance.closed.store(true, Ordering::Release);
assert!(first.write(vec![RuntimeMessage::Initialized]).is_err());
}
#[test]
fn rejects_length_before_reading_payload() {
let bytes = u32::MAX.to_le_bytes();
assert_eq!(
block_on(blocking_reader(bytes.as_slice()).next())
.unwrap()
.unwrap_err()
.kind(),
io::ErrorKind::InvalidData
);
}
#[test]
fn rejects_truncated_payload() {
assert_eq!(
block_on(blocking_reader([1, 0, 0, 0].as_slice()).next())
.unwrap_or_else(|| Err(io::ErrorKind::UnexpectedEof.into()))
.unwrap_err()
.kind(),
io::ErrorKind::UnexpectedEof
);
}
#[test]
fn blocking_transport_preserves_wire_format_and_buffered_frames() -> io::Result<()> {
let payloads = [b"abc".as_slice(), b"", b"hi\0"];
let mut bytes = Vec::new();
{
let mut output = blocking_writer(&mut bytes);
for payload in payloads {
block_on(output.send(payload.to_vec().into()))?;
}
}
assert_eq!(bytes, b"\x03\0\0\0abc\0\0\0\0\x03\0\0\0hi\0");
let mut input = blocking_reader(bytes.as_slice());
for payload in payloads {
assert_eq!(block_on(input.next()).unwrap()?.as_ref(), payload);
}
Ok(())
}
@@ -0,0 +1,143 @@
//! One observer interrupts the active JS turn; disarming and interruption share a lock.
use std::io;
use std::sync::Arc;
use std::sync::Condvar;
use std::sync::Mutex;
use std::sync::MutexGuard;
use std::thread::JoinHandle;
use std::time::Duration;
use std::time::Instant;
struct Active {
instance: u64,
handle: v8::IsolateHandle,
deadline: Instant,
terminated: bool,
}
#[derive(Default)]
struct Execution {
active: Option<Active>,
stopped: bool,
}
type Shared = Arc<(Mutex<Execution>, Condvar)>;
#[derive(Clone)]
pub(super) struct Observer(Shared);
pub(super) struct IsolateExecutionWatchdog {
pub(super) observer: Observer,
thread: Option<JoinHandle<()>>,
}
impl IsolateExecutionWatchdog {
pub(super) fn new() -> io::Result<Self> {
let shared = Arc::new((Mutex::new(Execution::default()), Condvar::new()));
let observer = Observer(Arc::clone(&shared));
let thread = std::thread::Builder::new()
.name("ui-plugin-watchdog".into())
.spawn(move || {
let (lock, changed) = &*shared;
let mut state = lock
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
while !state.stopped {
if let Some(active) = &mut state.active
&& !active.terminated
{
let remaining = active.deadline.saturating_duration_since(Instant::now());
if remaining.is_zero() {
active.handle.terminate_execution();
active.terminated = true;
} else {
(state, _) = changed
.wait_timeout(state, remaining)
.unwrap_or_else(std::sync::PoisonError::into_inner);
continue;
}
}
state = changed
.wait(state)
.unwrap_or_else(std::sync::PoisonError::into_inner);
}
})?;
Ok(Self {
observer,
thread: Some(thread),
})
}
pub(super) fn arm(&self, instance: u64, handle: v8::IsolateHandle) {
let (lock, changed) = &*self.observer.0;
let mut state = lock
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
assert!(state.active.is_none());
state.active = Some(Active {
instance,
handle,
deadline: Instant::now() + Duration::from_millis(100),
terminated: false,
});
changed.notify_one();
}
pub(super) fn disarm(&self) -> io::Result<()> {
self.disarm_with(|lock| {
lock.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
})
}
fn disarm_with<'a>(
&'a self,
acquire: impl FnOnce(&'a Mutex<Execution>) -> MutexGuard<'a, Execution>,
) -> io::Result<()> {
let (lock, changed) = &*self.observer.0;
let active = acquire(lock).active.take();
changed.notify_one();
if active.is_some_and(|active| active.terminated) {
Err(io::Error::new(
io::ErrorKind::TimedOut,
"plugin execution deadline exceeded",
))
} else {
Ok(())
}
}
}
#[cfg(test)]
#[path = "watchdog_tests.rs"]
mod tests;
impl Observer {
pub(super) fn cancel(&self, instance: u64) {
let (lock, changed) = &*self.0;
let mut state = lock
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
if let Some(active) = &mut state.active
&& active.instance == instance
{
active.handle.terminate_execution();
active.terminated = true;
}
changed.notify_one();
}
}
impl Drop for IsolateExecutionWatchdog {
fn drop(&mut self) {
let (lock, changed) = &*self.observer.0;
lock.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.stopped = true;
changed.notify_one();
if let Some(thread) = self.thread.take() {
let _ = thread.join();
}
}
}
@@ -0,0 +1,38 @@
#![cfg(test)]
use super::*;
use pretty_assertions::assert_eq;
use std::sync::mpsc;
#[test]
fn disarm_waits_for_an_interrupt_in_progress() {
crate::v8_init::ensure_v8_initialized().unwrap();
let isolate = v8::Isolate::new(Default::default());
let watchdog = IsolateExecutionWatchdog {
observer: Observer(Arc::new((Mutex::new(Execution::default()), Condvar::new()))),
thread: None,
};
watchdog.arm(/*instance*/ 1, isolate.thread_safe_handle());
let (lock, _) = &*watchdog.observer.0;
let mut interrupt = lock.lock().unwrap();
std::thread::scope(|scope| {
let (attempt_tx, attempt_rx) = mpsc::sync_channel(0);
let watchdog = &watchdog;
let disarming = scope.spawn(move || {
watchdog.disarm_with(|lock| {
assert!(matches!(
lock.try_lock(),
Err(std::sync::TryLockError::WouldBlock)
));
attempt_tx.send(()).unwrap();
lock.lock().unwrap()
})
});
attempt_rx.recv().unwrap();
interrupt.active.as_mut().unwrap().terminated = true;
drop(interrupt);
assert_eq!(
disarming.join().unwrap().unwrap_err().kind(),
io::ErrorKind::TimedOut
);
});
}
@@ -0,0 +1,286 @@
//! Dedicated stdin, stdout, JS execution and watchdog threads. Queue admission is
//! per instance, and cancellation bypasses queued JS turns; V8 manages its own workers.
use std::collections::HashMap;
use std::io;
use std::sync::Arc;
use std::sync::Weak;
use std::sync::atomic::AtomicBool;
use std::sync::atomic::Ordering;
use std::sync::mpsc;
use std::thread;
use super::engine::Engine;
use super::transport::Instance;
use super::transport::OUTPUT_SLOTS_PER_INSTANCE;
use super::transport::OutputReservation;
use super::transport::QueuedOutput;
use super::transport::Transport;
use super::transport::blocking_reader;
use super::transport::blocking_writer;
use super::watchdog::IsolateExecutionWatchdog;
use codex_plugin_host::MAX_INSTANCES;
use codex_plugin_host::protocol::ErrorCode;
use codex_plugin_host::protocol::HostMessage;
use codex_plugin_host::protocol::MAX_IN_FLIGHT_TURNS;
use codex_plugin_host::protocol::Packet;
use codex_plugin_host::protocol::RuntimeMessage;
use futures::SinkExt;
use futures::StreamExt;
use futures::executor::block_on;
use tokio::sync::OwnedSemaphorePermit;
use tokio::sync::Semaphore;
// Every instance reserves space for its turns and one close; output also reserves
// one slot for a rejected open.
const COMMAND_QUEUE_CAPACITY: usize = MAX_INSTANCES * (MAX_IN_FLIGHT_TURNS + 1);
const OUTPUT_QUEUE_CAPACITY: usize = MAX_INSTANCES * (OUTPUT_SLOTS_PER_INSTANCE + 1) + 1;
enum Command {
Turn {
packet: Packet<HostMessage>,
instance: Arc<Instance>,
_permit: OwnedSemaphorePermit,
},
Close(Arc<Instance>),
}
pub(super) fn run() -> io::Result<()> {
let (outgoing_tx, outgoing_rx) = mpsc::sync_channel::<QueuedOutput>(OUTPUT_QUEUE_CAPACITY);
thread::Builder::new()
.name("ui-plugin-output".into())
.spawn(move || {
let mut writer = blocking_writer(io::stdout().lock());
if let Err(error) = block_on(writer.send(codex_plugin_host::READY.to_vec().into())) {
fatal(error);
}
for output in outgoing_rx {
// The parent can send a replacement as soon as it reads Closed.
if let OutputReservation::Close { retired } = &output._reservation {
retired.store(true, Ordering::Release);
}
for packet in &output.packets {
let bytes =
serde_json::to_vec(packet).unwrap_or_else(|error| fatal(error.into()));
if let Err(error) = block_on(writer.send(bytes.into())) {
fatal(error);
}
}
}
})?;
let watchdog = IsolateExecutionWatchdog::new()?;
let observer = watchdog.observer.clone();
let (command_tx, command_rx) = mpsc::sync_channel(COMMAND_QUEUE_CAPACITY);
let executor_outgoing_tx = outgoing_tx.clone();
thread::Builder::new()
.name("ui-plugin-executor".into())
.spawn(move || {
if let Err(error) = execute(command_rx, executor_outgoing_tx, watchdog) {
fatal(error);
}
})?;
let mut live = HashMap::<u64, (Arc<AtomicBool>, Weak<Instance>)>::new();
let rejected_slot = Arc::new(Semaphore::new(1));
let mut last_id = None;
let mut input = blocking_reader(io::stdin().lock());
loop {
let packet: Packet<HostMessage> =
serde_json::from_slice(&block_on(input.next()).ok_or(io::ErrorKind::UnexpectedEof)??)?;
// An instance keeps its registration until its engine, queued turns and output drain.
live.retain(|_, (retired, _)| !retired.load(Ordering::Acquire));
let instance = match &packet.message {
HostMessage::Initialize { .. } => {
if last_id.is_some_and(|last| packet.instance <= last) {
return Err(io::Error::other("invalid plugin instance ID"));
}
last_id = Some(packet.instance);
if live.len() >= MAX_INSTANCES {
// Backpressure excess opens instead of accumulating unbounded close frames.
let permit = block_on(Arc::clone(&rejected_slot).acquire_owned())
.map_err(io::Error::other)?;
outgoing_tx
.try_send(QueuedOutput {
packets: vec![close_packet(packet.instance)],
_reservation: OutputReservation::Rejected { _permit: permit },
})
.map_err(io::Error::other)?;
continue;
}
let instance = Arc::new(Instance::new(packet.instance));
live.insert(
instance.id,
(Arc::clone(&instance.retired), Arc::downgrade(&instance)),
);
instance
}
HostMessage::Dispatch { .. } | HostMessage::Close => {
let Some(instance) = live
.get(&packet.instance)
.and_then(|(_, instance)| instance.upgrade())
else {
continue;
};
instance
}
};
admit_turn(packet, instance, &observer, &command_tx)?;
}
}
fn admit_turn(
packet: Packet<HostMessage>,
instance: Arc<Instance>,
observer: &super::watchdog::Observer,
command_tx: &mpsc::SyncSender<Command>,
) -> io::Result<()> {
if instance.closed.load(Ordering::Acquire) {
return Ok(());
}
let permit = if matches!(packet.message, HostMessage::Close) {
None
} else {
Arc::clone(&instance.input_slots).try_acquire_owned().ok()
};
let command = if let Some(permit) = permit {
Command::Turn {
packet,
instance,
_permit: permit,
}
} else {
if instance.closed.swap(true, Ordering::AcqRel) {
return Ok(());
}
observer.cancel(instance.id);
Command::Close(instance)
};
command_tx.try_send(command).map_err(io::Error::other)
}
fn execute(
command_rx: mpsc::Receiver<Command>,
outgoing_tx: mpsc::SyncSender<QueuedOutput>,
watchdog: IsolateExecutionWatchdog,
) -> io::Result<()> {
let mut instances = HashMap::<u64, Engine>::new();
for command in command_rx {
let (packet, instance, _permit) = match command {
Command::Close(instance) => {
if instances.remove(&instance.id).is_some() {
send_close(&outgoing_tx, instance, /*error*/ None)?;
}
continue;
}
Command::Turn {
packet,
instance,
_permit,
} => (packet, instance, _permit),
};
if instance.closed.load(Ordering::Acquire) {
if matches!(packet.message, HostMessage::Initialize { .. })
|| instances.remove(&instance.id).is_some()
{
send_close(&outgoing_tx, instance, /*error*/ None)?;
}
continue;
}
if matches!(packet.message, HostMessage::Initialize { .. }) {
let transport = Transport {
instance: Arc::clone(&instance),
outgoing_tx: outgoing_tx.clone(),
};
match Engine::new(transport) {
Ok(engine) => {
instances.insert(instance.id, engine);
}
Err(error) => {
eprintln!("plugin isolate initialization failed: {error}");
instance.closed.store(true, Ordering::Release);
send_close(&outgoing_tx, instance, /*error*/ None)?;
continue;
}
}
}
let Some(engine) = instances.get_mut(&instance.id) else {
let was_closed = instance.closed.swap(true, Ordering::AcqRel);
let error = (!was_closed).then_some(RuntimeMessage::Error {
code: ErrorCode::ExecutionFailed,
script_error: None,
});
send_close(&outgoing_tx, instance, error)?;
continue;
};
watchdog.arm(instance.id, engine.handle());
let result = engine
.transport
.ensure_open()
.map(|()| engine.turn(packet.message));
drop(_permit);
let error = match (watchdog.disarm(), result) {
(Ok(()), Ok(Ok(messages))) => {
if engine.transport.write(messages).is_ok() {
continue;
}
None
}
(Ok(()), Ok(Err(error))) => Some(error),
(Ok(()), Err(_)) => None,
(Err(_), _) => Some(RuntimeMessage::Error {
code: ErrorCode::ExecutionTimedOut,
script_error: None,
}),
};
let error = if instance.closed.swap(true, Ordering::AcqRel) {
None
} else {
error
};
instances.remove(&instance.id);
send_close(&outgoing_tx, instance, error)?;
}
Err(io::Error::other("plugin input stopped"))
}
fn close_packet(instance: u64) -> Packet<RuntimeMessage> {
Packet {
instance,
message: RuntimeMessage::Closed,
}
}
fn send_close(
outgoing_tx: &mpsc::SyncSender<QueuedOutput>,
instance: Arc<Instance>,
error: Option<RuntimeMessage>,
) -> io::Result<()> {
let mut packets = Vec::new();
if let Some(error) = error {
packets.push(Packet {
instance: instance.id,
message: error,
});
}
packets.push(close_packet(instance.id));
instance
.close_output
.set((
outgoing_tx.clone(),
QueuedOutput {
packets,
_reservation: OutputReservation::Close {
retired: Arc::clone(&instance.retired),
},
},
))
.map_err(|_| io::Error::other("plugin close already scheduled"))
}
pub(super) fn fatal(error: io::Error) -> ! {
eprintln!("plugin helper failed: {error}");
std::process::exit(1);
}
#[cfg(test)]
#[path = "workers_tests.rs"]
mod tests;
@@ -0,0 +1,126 @@
#![cfg(test)]
//! Exercise executor invariants that the stdin reader normally enforces.
use super::*;
use codex_plugin_host::protocol::ReturnMode;
use pretty_assertions::assert_eq;
use serde_json::json;
#[test]
fn missing_engine_fails_only_its_instance() {
crate::v8_init::ensure_v8_initialized().unwrap();
let peer = Arc::new(Instance::new(/*id*/ 1));
let missing = Arc::new(Instance::new(/*id*/ 2));
let dispatch = || HostMessage::Dispatch {
id: 0,
export: "default".into(),
return_mode: ReturnMode::Sync,
};
let (command_tx, command_rx) = mpsc::channel();
for (instance, message) in [
(
&peer,
HostMessage::Initialize {
source: "export default () => 'alive'".into(),
},
),
(&missing, dispatch()),
(&peer, dispatch()),
] {
command_tx
.send(Command::Turn {
packet: Packet {
instance: instance.id,
message,
},
instance: Arc::clone(instance),
_permit: Arc::clone(&instance.input_slots)
.try_acquire_owned()
.unwrap(),
})
.unwrap();
}
drop(command_tx);
let (outgoing_tx, outgoing_rx) = mpsc::sync_channel(OUTPUT_QUEUE_CAPACITY);
let error = execute(
command_rx,
outgoing_tx,
IsolateExecutionWatchdog::new().unwrap(),
)
.unwrap_err();
assert_eq!(error.to_string(), "plugin input stopped");
assert!(missing.closed.load(Ordering::Acquire));
drop(missing);
let packets: Vec<_> = outgoing_rx
.into_iter()
.flat_map(|output| output.packets)
.collect();
assert_eq!(
serde_json::to_value(packets).unwrap(),
json!([
{"instance":1,"message":{"type":"initialized"}},
{"instance":1,"message":{"type":"reply","id":0,"value":"alive"}},
{"instance":2,"message":{"type":"error","code":"execution_failed","scriptError":null}},
{"instance":2,"message":{"type":"closed"}}
])
);
}
#[test]
fn close_waits_for_the_last_queued_turn() {
let (outgoing_tx, outgoing_rx) = mpsc::sync_channel(OUTPUT_QUEUE_CAPACITY);
let instance = Arc::new(Instance::new(/*id*/ 1));
let queued_turn = Arc::clone(&instance);
send_close(&outgoing_tx, instance, /*error*/ None).unwrap();
assert!(matches!(
outgoing_rx.try_recv(),
Err(mpsc::TryRecvError::Empty)
));
drop(queued_turn);
let output = outgoing_rx.try_recv().unwrap();
assert_eq!(
serde_json::to_value(output.packets).unwrap(),
json!([{"instance":1,"message":{"type":"closed"}}])
);
}
#[test]
fn input_overload_closes_only_the_busy_instance() {
let busy = Arc::new(Instance::new(/*id*/ 1));
let peer = Arc::new(Instance::new(/*id*/ 2));
let watchdog = IsolateExecutionWatchdog::new().unwrap();
let (command_tx, command_rx) = mpsc::sync_channel(COMMAND_QUEUE_CAPACITY);
let dispatch = |instance: &Arc<Instance>, id| {
let packet = Packet {
instance: instance.id,
message: HostMessage::Dispatch {
id,
export: "run".into(),
return_mode: ReturnMode::Sync,
},
};
admit_turn(
packet,
Arc::clone(instance),
&watchdog.observer,
&command_tx,
)
.unwrap();
};
// Leave every turn queued so its permit stays in flight.
for id in 0..=MAX_IN_FLIGHT_TURNS as u64 {
dispatch(&busy, id);
}
dispatch(&peer, 0);
for _ in 0..MAX_IN_FLIGHT_TURNS {
assert!(
matches!(command_rx.try_recv(), Ok(Command::Turn { instance, .. }) if instance.id == busy.id)
);
}
assert!(
matches!(command_rx.try_recv(), Ok(Command::Close(instance)) if instance.id == busy.id)
);
assert!(
matches!(command_rx.try_recv(), Ok(Command::Turn { instance, .. }) if instance.id == peer.id)
);
}