Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Binary file modified crates/eryx-runtime/prebuilt/liberyx_runtime.so.zst
Binary file not shown.
5 changes: 4 additions & 1 deletion crates/eryx-runtime/wit/runtime.wit
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,10 @@ world sandbox {
record execution-options {
/// Whether the guest should install Python tracing.
python-tracing: bool,
/// Whether a fresh instance may reuse its pre-initialized empty callbacks.
/// Historical hint that a fresh instance may keep its pre-initialized
/// empty callbacks. The guest now compares the host's declarations with
/// what is installed and ignores this field; it is kept so existing
/// hosts stay compatible.
reuse-empty-callbacks: bool,
}

Expand Down
27 changes: 9 additions & 18 deletions crates/eryx-wasm-runtime/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -719,13 +719,11 @@ fn call_invoke(name: &str, args_json: &str) -> Result<String, String> {
#[derive(Clone, Copy)]
struct ExecutionOptions {
python_tracing: bool,
reuse_empty_callbacks: bool,
}

impl ExecutionOptions {
const CONSERVATIVE: Self = Self {
python_tracing: true,
reuse_empty_callbacks: false,
};
}

Expand All @@ -745,12 +743,11 @@ fn call_get_execution_options(wit: Wit) -> ExecutionOptions {
match cx.stack.pop() {
Some(Value::Record(fields)) if fields.len() == 2 => {
let mut fields = fields.into_iter();
// The second field, `reuse-empty-callbacks`, is kept in the WIT
// for compatibility; the guest now tracks installed callbacks itself.
match (fields.next(), fields.next()) {
(Some(Value::Bool(python_tracing)), Some(Value::Bool(reuse_empty_callbacks))) => {
ExecutionOptions {
python_tracing,
reuse_empty_callbacks,
}
(Some(Value::Bool(python_tracing)), Some(Value::Bool(_))) => {
ExecutionOptions { python_tracing }
}
_ => {
eprintln!("call_get_execution_options: unexpected record fields");
Expand Down Expand Up @@ -1476,18 +1473,12 @@ pub fn do_tls_close(handle: u32) {
/// - Session reuse: callbacks may change between executions
/// - Error recovery: a previous failed setup won't prevent future attempts
///
/// Fresh instances can reuse empty callback infrastructure captured during
/// initialization. Persistent sessions must perform setup on every execution
/// because their callback state may have changed since the previous request.
fn initialize_callbacks(wit: Wit, reuse_empty_callbacks: bool) {
/// The host's callback set can change between executions of a persistent
/// session, so it is fetched every time; `setup_callbacks` only reinstalls
/// when it differs from what is already installed.
fn initialize_callbacks(wit: Wit) {
let callbacks = call_list_callbacks(wit);

let skip_setup =
reuse_empty_callbacks && callbacks.is_empty() && python::callbacks_pre_initialized();
if skip_setup {
return;
}

if let Err(e) = python::setup_callbacks(&callbacks) {
eprintln!(
"ERROR: Failed to set up callbacks ({} declarations): {e}",
Expand Down Expand Up @@ -1543,7 +1534,7 @@ fn handle_export(wit: Wit, func_index: usize, cx: &mut EryxCall) -> HandleExport
// Set up callbacks from the host's current per-request state.
// This runs on every execute to stay in sync with the host.
let options = call_get_execution_options(wit);
initialize_callbacks(wit, options.reuse_empty_callbacks);
initialize_callbacks(wit);

// Execute Python with Wit handle available for callbacks
let result = with_wit(wit, || {
Expand Down
69 changes: 47 additions & 22 deletions crates/eryx-wasm-runtime/src/python.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
#![allow(missing_debug_implementations)]

use std::ffi::c_char;
use std::sync::Mutex;
use std::sync::atomic::{AtomicBool, Ordering};

// Re-export pyo3::ffi types and functions available in the stable ABI
Expand Down Expand Up @@ -1313,13 +1314,20 @@ sys.modules['_eryx_async'] = _eryx_async
/// Track whether we've initialized Python.
static PYTHON_INITIALIZED: AtomicBool = AtomicBool::new(false);

/// True when initialization installed the empty-callback infrastructure.
/// A failed installation degrades to per-execution callback setup.
static CALLBACKS_PRE_INITIALIZED: AtomicBool = AtomicBool::new(false);

/// Whether the pre-init snapshot contains the empty-callback infrastructure.
pub fn callbacks_pre_initialized() -> bool {
CALLBACKS_PRE_INITIALIZED.load(Ordering::SeqCst)
/// The callback declarations whose wrappers are currently installed, in the
/// JSON form handed to the setup script.
///
/// `None` means the installation is unknown or may have been disturbed, so
/// the next [`setup_callbacks`] reinstalls unconditionally. Pre-initialization
/// installs the empty set, and that value is carried in the snapshot, so a
/// fresh instance with no callbacks never runs the setup script.
static INSTALLED_CALLBACKS: Mutex<Option<String>> = Mutex::new(None);

/// Forget which callbacks are installed so the next setup reinstalls them.
fn invalidate_installed_callbacks() {
if let Ok(mut installed) = INSTALLED_CALLBACKS.lock() {
*installed = None;
}
}

/// Initialize Python interpreter.
Expand Down Expand Up @@ -2677,15 +2685,10 @@ pub fn initialize_python() {
PyErr_Clear();
}

// Install empty callback infrastructure once so fresh instances with no
// callbacks do not have to recreate it for every execution. Non-empty
// callback lists still reinstall their request-specific definitions.
let callbacks_ok = setup_callbacks(&[]).is_ok();
CALLBACKS_PRE_INITIALIZED.store(callbacks_ok, Ordering::SeqCst);
if !callbacks_ok {
eprintln!(
"WARNING: pre-init setup_callbacks([]) failed; empty-callback fast path disabled"
);
// Install the empty callback infrastructure once so fresh instances
// with no callbacks do not have to recreate it for every execution.
if let Err(e) = setup_callbacks(&[]) {
eprintln!("WARNING: pre-init setup_callbacks([]) failed: {e}");
}

// Note: We do NOT call reset_wasi_state() here!
Expand Down Expand Up @@ -3261,9 +3264,11 @@ del _eryx_restore_bytes, _eryx_restored_dict, _eryx_dill, _eryx_types, _eryx_reb
let _ = PyRun_SimpleString(c"del _eryx_restore_bytes".as_ptr());
return Err(format!("Failed to restore state: {err}"));
}

Ok(())
}

// The restored globals may have replaced callback wrappers.
invalidate_installed_callbacks();
Ok(())
}

/// Clear all user-defined state from `_eryx_user_globals`.
Expand Down Expand Up @@ -3319,6 +3324,9 @@ del _eryx_keep, _eryx_should_keep, _eryx_to_delete, _k
PyErr_Clear();
}
}

// The keep-list above is a heuristic; make the next execution reinstall.
invalidate_installed_callbacks();
}

// =============================================================================
Expand All @@ -3340,15 +3348,29 @@ pub struct CallbackInfo {
/// 2. A `list_callbacks()` function for introspection
/// 3. Direct wrapper functions for each callback (e.g., `sleep(ms=100)`)
/// 4. Namespace objects for dotted callbacks (e.g., `http.get(url="...")`)
///
/// Installing is idempotent: the declarations are compared with the set that
/// is already installed and the setup script only runs when they differ. The
/// script compiles a few hundred lines of Python, which is several times the
/// cost of a short execution, so this is what keeps per-execution overhead
/// low for sessions and for sandboxes with callbacks.
pub fn setup_callbacks(callbacks: &[CallbackInfo]) -> Result<(), String> {
if !is_python_initialized() {
return Err("Python not initialized".to_string());
}

unsafe {
// Serialize callbacks to JSON for Python to parse
let callbacks_json = serde_json_mini_serialize_callbacks(callbacks);
// Serialize callbacks to JSON for Python to parse
let callbacks_json = serde_json_mini_serialize_callbacks(callbacks);

let already_installed = INSTALLED_CALLBACKS
.lock()
.is_ok_and(|installed| installed.as_deref() == Some(callbacks_json.as_str()));
if already_installed {
return Ok(());
}
invalidate_installed_callbacks();

unsafe {
// Inject the callback setup code
let setup_code = format!(
r#"
Expand Down Expand Up @@ -3563,9 +3585,12 @@ except NameError:
let err = get_last_error_message();
return Err(format!("Failed to set up callbacks: {err}"));
}
}

Ok(())
if let Ok(mut installed) = INSTALLED_CALLBACKS.lock() {
*installed = Some(callbacks_json);
}
Ok(())
}

/// Simple JSON serialization for callbacks (avoiding serde dependency in WASM)
Expand Down
4 changes: 4 additions & 0 deletions crates/eryx/src/wasm.rs
Original file line number Diff line number Diff line change
Expand Up @@ -618,6 +618,10 @@ pub struct ExecutorState {
pub(crate) suspended: Option<String>,
/// Whether this execution uses a fresh instance whose initialized empty
/// callback state can be reused safely.
///
/// Reported to the guest as `reuse-empty-callbacks`. Current guests ignore
/// it and reinstall callbacks only when the declarations change; older
/// guests use it to skip setup on fresh, callback-free instances.
pub(crate) reuse_empty_callbacks: bool,
}

Expand Down
Loading
Loading