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
10 changes: 10 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -246,8 +246,18 @@ package_cache_dir = ".code-interpreter-mcp-package-cache"
max_workers = 2
queue_capacity = 8
prewarmed_workers = 1
memory_limit_mb = 512
```

`workers.memory_limit_mb` sets the V8 heap budget for each isolate, including
startup and replacement isolates. It defaults to `512` and uses 1 MB = 1,048,576
bytes (512 MiB by default). Values must be positive integers whose byte conversion
fits the platform's address size; invalid values prevent startup. This is a
per-isolate V8 heap budget, not a total process memory limit. Size the container
for `workers.max_workers` concurrent heaps plus Pyodide/WebAssembly memory, input
buffers, caches, and other process overhead. Increasing the container memory
limit alone does not increase this heap budget.

Prewarmed workers initialize V8, Pyodide, and configured preinstalled packages
before accepting work. Each execution still gets a fresh isolate. The worker
discards that isolate after the call and prepares a clean replacement.
Expand Down
1 change: 1 addition & 0 deletions generated/config-reference.json
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
"search.min_hits": {},
"skip_host_header_verification": {},
"workers.max_workers": {},
"workers.memory_limit_mb": {},
"workers.prewarmed_workers": {},
"workers.queue_capacity": {}
}
77 changes: 77 additions & 0 deletions src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ const DEFAULT_BIND_ADDRESS: &str = "127.0.0.1:8000";
const DEFAULT_MAX_WORKERS: usize = 2;
const DEFAULT_QUEUE_CAPACITY: usize = 8;
const DEFAULT_PREWARMED_WORKERS: usize = 1;
const DEFAULT_MEMORY_LIMIT_MB: usize = 512;
const BYTES_PER_MB: usize = 1024 * 1024;

#[derive(Debug, Clone, Deserialize, Facet)]
pub struct AppConfig {
Expand Down Expand Up @@ -62,6 +64,8 @@ pub struct WorkersConfig {
pub queue_capacity: usize,
#[serde(default = "default_prewarmed_workers")]
pub prewarmed_workers: usize,
/// Per-isolate V8 heap budget in MB (1 MB = 1,048,576 bytes), default 512.
pub memory_limit_mb: usize,
}

#[derive(Debug, Clone, Deserialize, Facet)]
Expand Down Expand Up @@ -181,6 +185,7 @@ impl AppConfig {
}

pub fn validate_worker_settings(&self) -> Result<()> {
self.workers.memory_limit_bytes()?;
if self.workers.max_workers == 0 {
bail!("max_workers must be greater than zero");
}
Expand Down Expand Up @@ -214,10 +219,22 @@ impl Default for WorkersConfig {
max_workers: default_max_workers(),
queue_capacity: default_queue_capacity(),
prewarmed_workers: default_prewarmed_workers(),
memory_limit_mb: DEFAULT_MEMORY_LIMIT_MB,
}
}
}

impl WorkersConfig {
pub fn memory_limit_bytes(&self) -> Result<usize> {
if self.memory_limit_mb == 0 {
bail!("workers.memory_limit_mb must be greater than zero");
}
self.memory_limit_mb.checked_mul(BYTES_PER_MB).context(
"workers.memory_limit_mb is too large: conversion to bytes overflows (1 MB = 1,048,576 bytes)",
)
}
}

impl Default for CodeSnippetsConfig {
fn default() -> Self {
Self {
Expand Down Expand Up @@ -293,6 +310,66 @@ fn merge(base: &mut toml::Value, incoming: toml::Value) {
mod tests {
use super::*;

#[test]
fn worker_memory_limit_defaults_and_overrides() {
for source in ["", "[workers]", "[workers]\nmax_workers = 4"] {
let config: AppConfig = toml::from_str(source).unwrap();
assert_eq!(config.workers.memory_limit_mb, 512);
assert_eq!(
config.workers.memory_limit_bytes().unwrap(),
512 * 1024 * 1024
);
config.validate_worker_settings().unwrap();
}
let mut base: toml::Value =
toml::from_str("[workers]\nmemory_limit_mb = 512\nmax_workers = 4").unwrap();
merge(
&mut base,
toml::from_str("[workers]\nmemory_limit_mb = 1024").unwrap(),
);
let config: AppConfig = base.try_into().unwrap();
assert_eq!(config.workers.memory_limit_mb, 1024);
assert_eq!(
config.workers.memory_limit_bytes().unwrap(),
1024 * 1024 * 1024
);
assert_eq!(config.workers.max_workers, 4);
config.validate_worker_settings().unwrap();
assert!(
crate::config_reference::generate_config_reference()
.get("workers.memory_limit_mb")
.is_some()
);
}

#[test]
fn worker_memory_limit_rejects_invalid_values_and_overflow() {
for value in ["-1", "1.5", "'512'", "true", "18446744073709551616"] {
let error =
toml::from_str::<AppConfig>(&format!("[workers]\nmemory_limit_mb = {value}"))
.unwrap_err();
assert!(error.to_string().contains("memory_limit_mb"), "{error}");
}
for value in [0, usize::MAX / BYTES_PER_MB + 1] {
let config: AppConfig =
toml::from_str(&format!("[workers]\nmemory_limit_mb = {value}")).unwrap();
let error = config.validate_worker_settings().unwrap_err();
assert!(error.to_string().contains("workers.memory_limit_mb"));
assert!(error.to_string().contains(if value == 0 {
"greater than zero"
} else {
"overflows"
}));
}
for value in [1, usize::MAX / BYTES_PER_MB] {
let workers = WorkersConfig {
memory_limit_mb: value,
..WorkersConfig::default()
};
assert_eq!(workers.memory_limit_bytes().unwrap(), value * BYTES_PER_MB);
}
}

#[test]
fn input_limits_default_parse_merge_and_validate() {
let defaults = AppConfig::from_files(&[]).unwrap();
Expand Down
31 changes: 30 additions & 1 deletion src/runtime.rs
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ const MAX_DOWNLOAD_BYTES: usize = 100 * 1024 * 1024;

#[derive(Clone)]
struct RuntimeConfig {
memory_limit_bytes: usize,
allowed_hosts: Arc<HashSet<String>>,
asset_dir: Arc<PathBuf>,
client: reqwest::Client,
Expand Down Expand Up @@ -291,6 +292,7 @@ impl PyodideRuntime {
.expect("the static HTTP client configuration is valid");

let mut runtime_config = RuntimeConfig {
memory_limit_bytes: config.workers.memory_limit_bytes()?,
preinstall_packages: config.preinstall_packages.clone(),
runtime_package_installation: config.runtime_package_installation,
package_cache_dir: Arc::new(config.package_cache_dir()),
Expand Down Expand Up @@ -460,7 +462,7 @@ fn create_runtime(config: &RuntimeConfig) -> Result<JsRuntime> {
.collect::<Result<Vec<_>>>()?;
JsRuntime::try_new(RuntimeOptions {
extensions,
create_params: Some(v8::Isolate::create_params().heap_limits(0, 512 * 1024 * 1024)),
create_params: Some(v8::Isolate::create_params().heap_limits(0, config.memory_limit_bytes)),
..Default::default()
})
.context("could not initialize the embedded Deno runtime")
Expand Down Expand Up @@ -882,6 +884,33 @@ async fn fetch_url(config: &RuntimeConfig, raw_url: &str) -> Result<Arc<Vec<u8>>
mod tests {
use super::*;

#[tokio::test]
async fn runtime_applies_heap_limit_to_fresh_isolates() {
for source in ["", "[workers]\nmemory_limit_mb = 1024"] {
let config: AppConfig = toml::from_str(source).unwrap();
let runtime_config = RuntimeConfig {
memory_limit_bytes: config.workers.memory_limit_bytes().unwrap(),
allowed_hosts: Arc::new(HashSet::new()),
asset_dir: Arc::new(PathBuf::new()),
client: reqwest::Client::new(),
download_cache: Arc::new(Mutex::new(HashMap::new())),
content_types: Arc::new(Mutex::new(HashMap::new())),
preinstall_packages: Vec::new(),
runtime_package_installation: true,
package_cache_dir: Arc::new(PathBuf::new()),
package_layer: None,
};
// Startup, cold workers and warm replacements share this factory.
for _ in 0..2 {
let mut runtime = create_runtime(&runtime_config.clone()).unwrap();
assert_eq!(
runtime.v8_isolate().get_heap_statistics().heap_size_limit(),
runtime_config.memory_limit_bytes,
);
}
}
}

async fn run(runtime: &PyodideRuntime, code: &str) -> RunPythonCodeOutput {
runtime
.execute(RuntimeRequest {
Expand Down
Loading