From 15d5da489b623575719839101d2aedc9cf5fd17e Mon Sep 17 00:00:00 2001 From: Max Goisser Date: Wed, 14 Oct 2026 15:07:41 +0200 Subject: [PATCH] Make worker memory limits configurable Related Linear issue: ERMCP-38 --- README.md | 10 +++++ generated/config-reference.json | 1 + src/config.rs | 77 +++++++++++++++++++++++++++++++++ src/runtime.rs | 31 ++++++++++++- 4 files changed, 118 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index a1e65c5..0aed135 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/generated/config-reference.json b/generated/config-reference.json index 68c2ecd..7b10d8e 100644 --- a/generated/config-reference.json +++ b/generated/config-reference.json @@ -20,6 +20,7 @@ "search.min_hits": {}, "skip_host_header_verification": {}, "workers.max_workers": {}, + "workers.memory_limit_mb": {}, "workers.prewarmed_workers": {}, "workers.queue_capacity": {} } diff --git a/src/config.rs b/src/config.rs index 2955051..4ed3b60 100644 --- a/src/config.rs +++ b/src/config.rs @@ -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 { @@ -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)] @@ -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"); } @@ -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 { + 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 { @@ -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::(&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(); diff --git a/src/runtime.rs b/src/runtime.rs index db19c8f..5c5dc0a 100644 --- a/src/runtime.rs +++ b/src/runtime.rs @@ -36,6 +36,7 @@ const MAX_DOWNLOAD_BYTES: usize = 100 * 1024 * 1024; #[derive(Clone)] struct RuntimeConfig { + memory_limit_bytes: usize, allowed_hosts: Arc>, asset_dir: Arc, client: reqwest::Client, @@ -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()), @@ -460,7 +462,7 @@ fn create_runtime(config: &RuntimeConfig) -> Result { .collect::>>()?; 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") @@ -882,6 +884,33 @@ async fn fetch_url(config: &RuntimeConfig, raw_url: &str) -> Result> 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 {