Skip to content
Open
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
3 changes: 2 additions & 1 deletion boxlite/src/litebox/box_impl.rs
Original file line number Diff line number Diff line change
Expand Up @@ -136,14 +136,15 @@ impl BoxImpl {
state: BoxState,
runtime: SharedRuntimeImpl,
shutdown_token: CancellationToken,
event_listeners: Vec<std::sync::Arc<dyn EventListener>>,
) -> Self {
Self {
config,
state: Arc::new(RwLock::new(state)),
runtime,
shutdown_token,
disk_ops: tokio::sync::Mutex::new(()),
event_listeners: Vec::new(), // populated from runtime options
event_listeners,
live: OnceCell::new(),
health_check_task: RwLock::new(None),
}
Expand Down
127 changes: 126 additions & 1 deletion boxlite/src/runtime/options.rs
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
//! Configuration for Boxlite.

use crate::event_listener::EventListener;
use crate::runtime::constants::envs as const_envs;
use crate::runtime::layout::dirs as const_dirs;
use boxlite_shared::errors::BoxliteResult;
use dirs::home_dir;
use serde::{Deserialize, Serialize};
use std::path::{Path, PathBuf};
use std::sync::Arc;

use crate::runtime::advanced_options::{AdvancedBoxOptions, SecurityOptions};

Expand All @@ -15,7 +17,7 @@ use crate::runtime::advanced_options::{AdvancedBoxOptions, SecurityOptions};
/// Configuration options for BoxliteRuntime.
///
/// Users can create it with defaults and modify fields as needed.
#[derive(Clone, Debug, Serialize, Deserialize)]
#[derive(Clone, Serialize, Deserialize)]
pub struct BoxliteOptions {
#[serde(default = "default_home_dir")]
pub home_dir: PathBuf,
Expand All @@ -42,6 +44,46 @@ pub struct BoxliteOptions {
/// ```
#[serde(default)]
pub image_registries: Vec<String>,

/// Event listeners for box lifecycle notifications.
///
/// Listeners receive callbacks when boxes are created, started, stopped,
/// removed, and when commands are executed or files are copied.
///
/// All callbacks default to no-op — implement only what you need.
///
/// # Example
///
/// ```ignore
/// use boxlite::event_listener::EventListener;
///
/// struct Logger;
/// impl EventListener for Logger {
/// fn on_box_started(&self, box_id: &BoxID) {
/// println!("Box {} started", box_id);
/// }
/// }
///
/// let opts = BoxliteOptions {
/// event_listeners: vec![Arc::new(Logger)],
/// ..Default::default()
/// };
/// ```
#[serde(skip, default)]
pub event_listeners: Vec<Arc<dyn EventListener>>,
}

impl std::fmt::Debug for BoxliteOptions {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("BoxliteOptions")
.field("home_dir", &self.home_dir)
.field("image_registries", &self.image_registries)
.field(
"event_listeners",
&format_args!("[{} listeners]", self.event_listeners.len()),
)
.finish()
}
}

fn default_home_dir() -> PathBuf {
Expand All @@ -59,6 +101,7 @@ impl Default for BoxliteOptions {
Self {
home_dir: default_home_dir(),
image_registries: Vec::new(),
event_listeners: Vec::new(),
}
}
}
Expand Down Expand Up @@ -628,4 +671,86 @@ mod tests {
assert!(opts1.resource_limits.max_processes.is_none());
assert_eq!(opts2.resource_limits.max_processes, Some(50));
}

// ========================================================================
// event_listeners tests
// ========================================================================

#[test]
fn test_boxlite_options_default_has_no_listeners() {
let opts = BoxliteOptions::default();
assert!(
opts.event_listeners.is_empty(),
"Default options should have no event listeners"
);
}

#[test]
fn test_boxlite_options_with_event_listeners() {
use crate::event_listener::EventListener;

struct TestListener;
impl EventListener for TestListener {}

let opts = BoxliteOptions {
event_listeners: vec![Arc::new(TestListener)],
..Default::default()
};
assert_eq!(opts.event_listeners.len(), 1);
}

#[test]
fn test_boxlite_options_clone_preserves_listeners() {
use crate::event_listener::EventListener;

struct TestListener;
impl EventListener for TestListener {}

let opts = BoxliteOptions {
event_listeners: vec![Arc::new(TestListener), Arc::new(TestListener)],
..Default::default()
};
let cloned = opts.clone();
assert_eq!(cloned.event_listeners.len(), 2);
}

#[test]
fn test_boxlite_options_serde_skips_listeners() {
use crate::event_listener::EventListener;

struct TestListener;
impl EventListener for TestListener {}

let opts = BoxliteOptions {
event_listeners: vec![Arc::new(TestListener)],
..Default::default()
};

// Serialization should succeed (event_listeners skipped)
let json = serde_json::to_string(&opts).unwrap();
assert!(!json.contains("event_listeners"));

// Deserialization should default to empty vec
let deserialized: BoxliteOptions = serde_json::from_str(&json).unwrap();
assert!(deserialized.event_listeners.is_empty());
}

#[test]
fn test_boxlite_options_debug_shows_listener_count() {
use crate::event_listener::EventListener;

struct TestListener;
impl EventListener for TestListener {}

let opts = BoxliteOptions {
event_listeners: vec![Arc::new(TestListener), Arc::new(TestListener)],
..Default::default()
};
let debug_str = format!("{:?}", opts);
assert!(
debug_str.contains("[2 listeners]"),
"Debug should show listener count, got: {}",
debug_str
);
}
}
80 changes: 79 additions & 1 deletion boxlite/src/runtime/rt_impl.rs
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,10 @@ pub struct RuntimeImpl {
/// Use `.is_cancelled()` for sync checks, `.cancelled()` for async select!.
/// Child tokens are passed to each box via `.child_token()`.
pub(crate) shutdown_token: CancellationToken,

/// Event listeners registered at runtime level.
/// Cloned into each BoxImpl on construction.
pub(crate) event_listeners: Vec<Arc<dyn crate::event_listener::EventListener>>,
}

/// Synchronized state protected by RwLock.
Expand Down Expand Up @@ -233,6 +237,7 @@ impl RuntimeImpl {
lock_manager,
_runtime_lock: runtime_lock,
shutdown_token: CancellationToken::new(),
event_listeners: options.event_listeners,
});

tracing::debug!("initialized runtime");
Expand Down Expand Up @@ -1386,7 +1391,13 @@ impl RuntimeImpl {
// Create new BoxImpl and cache in both maps
// Pass a child token so box can be cancelled independently or via runtime shutdown
let box_token = self.shutdown_token.child_token();
let box_impl = Arc::new(BoxImpl::new(config, state, Arc::clone(self), box_token));
let box_impl = Arc::new(BoxImpl::new(
config,
state,
Arc::clone(self),
box_token,
self.event_listeners.clone(),
));
let weak = Arc::downgrade(&box_impl);

sync.active_boxes_by_id.insert(box_id.clone(), weak.clone());
Expand Down Expand Up @@ -1575,6 +1586,7 @@ mod tests {
let options = BoxliteOptions {
home_dir: temp_dir.path().to_path_buf(),
image_registries: vec![],
..Default::default()
};
let runtime = RuntimeImpl::new(options).expect("Failed to create runtime");
(runtime, temp_dir)
Expand Down Expand Up @@ -2372,4 +2384,70 @@ mod tests {
let result = runtime.remove_box(&config_a.id, true);
assert!(result.is_ok());
}

// ====================================================================
// event_listeners plumbing tests
// ====================================================================

#[test]
fn test_runtime_stores_event_listeners() {
use crate::event_listener::EventListener;

struct TestListener;
impl EventListener for TestListener {}

let temp_dir = TempDir::new_in("/tmp").expect("Failed to create temp dir");
let options = BoxliteOptions {
home_dir: temp_dir.path().to_path_buf(),
image_registries: vec![],
event_listeners: vec![std::sync::Arc::new(TestListener)],
};
let runtime = RuntimeImpl::new(options).expect("Failed to create runtime");
assert_eq!(
runtime.event_listeners.len(),
1,
"Runtime should store event listeners from options"
);
}

#[test]
fn test_runtime_passes_listeners_to_box_impl() {
use crate::event_listener::EventListener;
use std::sync::atomic::{AtomicUsize, Ordering};

static CALL_COUNT: AtomicUsize = AtomicUsize::new(0);

struct CountingListener;
impl EventListener for CountingListener {
fn on_box_created(&self, _box_id: &BoxID) {
CALL_COUNT.fetch_add(1, Ordering::SeqCst);
}
}

CALL_COUNT.store(0, Ordering::SeqCst);

let temp_dir = TempDir::new_in("/tmp").expect("Failed to create temp dir");
let options = BoxliteOptions {
home_dir: temp_dir.path().to_path_buf(),
image_registries: vec![],
event_listeners: vec![std::sync::Arc::new(CountingListener)],
};
let runtime = RuntimeImpl::new(options).expect("Failed to create runtime");

// Create a BoxImpl via get_or_create_box_impl — listeners should be cloned
let config = test_box_config(false);
let box_id = config.id.clone();
let state = BoxState::new();
let (box_impl, _created) = runtime.get_or_create_box_impl(config, state);

assert_eq!(
box_impl.event_listeners.len(),
1,
"BoxImpl should have listeners from runtime"
);

// Invoke the listener directly to verify it works
box_impl.event_listeners[0].on_box_created(&box_id);
assert_eq!(CALL_COUNT.load(Ordering::SeqCst), 1);
}
}
41 changes: 39 additions & 2 deletions sdks/python/boxlite/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,27 @@
# Import Python convenience wrappers (re-exported via __all__)
try:
from .codebox import CodeBox # noqa: F401
from .errors import BoxliteError, ExecError, ParseError, TimeoutError # noqa: F401
from .errors import ( # noqa: F401
AlreadyExistsError,
BoxliteError,
ConfigError,
DatabaseError,
EngineError,
ExecError,
ExecutionError,
ImageError,
InternalError,
InvalidArgumentError,
InvalidStateError,
NetworkError,
NotFoundError,
ParseError,
PortalError,
RpcError,
StoppedError,
StorageError,
TimeoutError,
)
from .exec import ExecResult # noqa: F401
from .simplebox import SimpleBox # noqa: F401
Comment on lines 65 to 90
Copy link

Copilot AI Mar 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This single try/except couples exporting pure-Python error classes to importing CodeBox/ExecResult/SimpleBox. If CodeBox (or another convenience wrapper) fails to import (e.g., missing native extension), the error types won’t be re-exported from boxlite, even though boxlite.errors itself is available. Consider importing/re-exporting errors in a separate try block (or before CodeBox) so typed exceptions remain available independently.

Copilot uses AI. Check for mistakes.

Expand All @@ -75,8 +95,25 @@
"SimpleBox",
"CodeBox",
"ExecResult",
# Error types
# Error types (base)
"BoxliteError",
# Error types (mapped from Rust)
"EngineError",
"ConfigError",
"StorageError",
"ImageError",
"PortalError",
"NetworkError",
"RpcError",
"InternalError",
"ExecutionError",
"NotFoundError",
"AlreadyExistsError",
"InvalidStateError",
"DatabaseError",
"InvalidArgumentError",
"StoppedError",
# Error types (Python convenience)
"ExecError",
"TimeoutError",
"ParseError",
Expand Down
Loading
Loading