From f9230c91e9d8abcbbc90450fb5cd1594a876b0b4 Mon Sep 17 00:00:00 2001 From: Oleksandr Zarudnyi Date: Thu, 9 Jul 2026 19:11:44 +0800 Subject: [PATCH 1/5] perf(core): reuse persistent worker subprocesses across translation units Replace the fork/exec-per-unit model with a pool of persistent workers fed over pipes. The per-unit CBOR payload is split into a Session (project-wide data, sent once per worker) and a slim per-unit Job, and idle workers are held in a std Mutex and reused. The process module is one entity per file: channel (the length-prefixed CBOR frame codec, via FrameRead/FrameWrite extension traits), session, job, output, pool (parent-side scheduler), worker (subprocess handle), and child (the subprocess loop). The child runs one long-lived stack-sized thread that owns the deserialized Session and compiles jobs until stdin closes, instead of spawning a thread and re-cloning the session per job. Worker stderr is inherited, so subprocess diagnostics stream straight to the parent. Because LLVM command-line options are process-global and now survive across units in one worker, always emit -evm-metadata-size to heal a stale value, and carry metadata_size into the size-fallback settings; reset IS_SIZE_FALLBACK per job. A worker is retired when its job set a spill-area size (occurrence- gated cl-option) or carried extra llvm_options, and never reused after a stack-too-deep response since its handler has already exited the process. When a reused worker dies mid-job, the job is retried once on a fresh worker so the death is not mis-attributed to the contract. Install the stack-error handler once per worker process instead of per unit, and build the rayon pool only in the parent, after the recursive-process branch, since workers compile a single unit and never use it. --- solx-codegen-evm/src/codegen/context/mod.rs | 12 +- solx-codegen-evm/src/target_machine.rs | 17 +- solx-core/src/arguments.rs | 6 +- solx-core/src/compiler.rs | 25 +-- solx-core/src/lib.rs | 6 +- solx-core/src/process/channel.rs | 68 ++++++++ solx-core/src/process/child.rs | 85 ++++++++++ solx-core/src/process/{input.rs => job.rs} | 36 +--- solx-core/src/process/mod.rs | 179 +------------------- solx-core/src/process/output.rs | 2 - solx-core/src/process/pool.rs | 95 +++++++++++ solx-core/src/process/session.rs | 47 +++++ solx-core/src/process/worker.rs | 79 +++++++++ solx-core/src/project/contract/mod.rs | 14 +- solx-core/src/project/mod.rs | 65 +++---- solx/tests/cli/recursive_process.rs | 12 +- 16 files changed, 465 insertions(+), 283 deletions(-) create mode 100644 solx-core/src/process/channel.rs create mode 100644 solx-core/src/process/child.rs rename solx-core/src/process/{input.rs => job.rs} (53%) create mode 100644 solx-core/src/process/pool.rs create mode 100644 solx-core/src/process/session.rs create mode 100644 solx-core/src/process/worker.rs diff --git a/solx-codegen-evm/src/codegen/context/mod.rs b/solx-codegen-evm/src/codegen/context/mod.rs index 9134da0a2..82c9879a1 100644 --- a/solx-codegen-evm/src/codegen/context/mod.rs +++ b/solx-codegen-evm/src/codegen/context/mod.rs @@ -323,14 +323,10 @@ impl<'ctx> Context<'ctx> { && self.optimizer.settings().is_fallback_to_size_enabled() { crate::codegen::IS_SIZE_FALLBACK - .compare_exchange( - false, - true, - std::sync::atomic::Ordering::Relaxed, - std::sync::atomic::Ordering::Relaxed, - ) - .expect("Failed to set the global size fallback flag"); - self.optimizer = Optimizer::new(OptimizerSettings::size()); + .store(true, std::sync::atomic::Ordering::Relaxed); + let mut size_fallback_settings = OptimizerSettings::size(); + size_fallback_settings.metadata_size = self.optimizer.settings().metadata_size; + self.optimizer = Optimizer::new(size_fallback_settings); self.module = module_size_fallback; for function in self.module.get_functions() { Function::set_size_attributes(self.llvm, function); diff --git a/solx-codegen-evm/src/target_machine.rs b/solx-codegen-evm/src/target_machine.rs index 5aea7e2d5..1372cb3cc 100644 --- a/solx-codegen-evm/src/target_machine.rs +++ b/solx-codegen-evm/src/target_machine.rs @@ -27,12 +27,21 @@ impl TargetMachine { /// `-evm-stack-region-offset ` /// `-evm-metadata-size ` /// + /// LLVM command line options are process-global and survive across translation units + /// compiled in the same worker process, so `-evm-metadata-size` is always passed: + /// the explicit default heals a stale value left by a previous unit. It is passed + /// before `llvm_options` when unset (so user options keep overriding the default) + /// and after them when set (so the computed value keeps overriding user options). + /// pub fn new( optimizer_settings: &OptimizerSettings, llvm_options: &[String], ) -> anyhow::Result { - let mut arguments = Vec::with_capacity(1 + llvm_options.len()); + let mut arguments = Vec::with_capacity(4 + llvm_options.len()); arguments.push(Self::TARGET.to_string()); + if optimizer_settings.metadata_size.is_none() { + arguments.push("-evm-metadata-size=0".to_owned()); + } arguments.extend_from_slice(llvm_options); if let Some(size) = optimizer_settings.spill_area_size { arguments.push(format!( @@ -44,10 +53,8 @@ impl TargetMachine { if let Some(size) = optimizer_settings.metadata_size { arguments.push(format!("-evm-metadata-size={size}")); } - if arguments.len() > 1 { - let arguments: Vec<&str> = arguments.iter().map(|argument| argument.as_str()).collect(); - inkwell::support::parse_command_line_options(arguments.as_slice(), "LLVM options"); - } + let arguments: Vec<&str> = arguments.iter().map(|argument| argument.as_str()).collect(); + inkwell::support::parse_command_line_options(arguments.as_slice(), "LLVM options"); let target_machine = inkwell::targets::Target::from_name(Self::TARGET.to_string().as_str()) .ok_or_else(|| anyhow::anyhow!("LLVM target machine `{}` not found", Self::TARGET))? diff --git a/solx-core/src/arguments.rs b/solx-core/src/arguments.rs index 9f69845ff..964efb718 100644 --- a/solx-core/src/arguments.rs +++ b/solx-core/src/arguments.rs @@ -244,15 +244,15 @@ pub struct Arguments { #[arg(long, help_heading = "Debug Options")] pub llvm_debug_logging: bool, - /// Run this process recursively and provide JSON input to compile a single contract. + /// Run this process as a persistent worker compiling contracts fed via `stdin`. /// Only for usage from within the compiler. #[arg(long, hide = true)] pub recursive_process: bool, } impl Arguments { - /// Expected argument count for `--recursive-process` (binary name + flag + value). - const RECURSIVE_PROCESS_MAX_ARGS: usize = 3; + /// Expected argument count for `--recursive-process` (binary name + flag). + const RECURSIVE_PROCESS_MAX_ARGS: usize = 2; /// Expected argument count for `--version` (binary name + flag). const VERSION_MAX_ARGS: usize = 2; diff --git a/solx-core/src/compiler.rs b/solx-core/src/compiler.rs index 84f710b74..f3d9e9a0e 100644 --- a/solx-core/src/compiler.rs +++ b/solx-core/src/compiler.rs @@ -29,14 +29,25 @@ impl<'arguments> Compiler<'arguments> { } /// - /// Initialize the compiler runtime: rayon thread pool, LLVM stack trace, and - /// EVM target. + /// Initialize the compiler runtime: LLVM stack trace, EVM target, and + /// rayon thread pool. /// - /// If `arguments.recursive_process` is set, runs the subprocess handler and + /// If `arguments.recursive_process` is set, runs the worker subprocess loop and /// returns `Ok(true)` -- the caller should return immediately. /// Otherwise returns `Ok(false)`. /// + /// The rayon thread pool is built after the worker branch: workers compile + /// one translation unit at a time and never use it. + /// pub fn initialize(&self) -> anyhow::Result { + inkwell::support::enable_llvm_pretty_stack_trace(); + solx_codegen_evm::initialize_target(); + + if self.arguments.recursive_process { + crate::run_subprocess()?; + return Ok(true); + } + let mut thread_pool_builder = rayon::ThreadPoolBuilder::new(); if let Some(threads) = self.arguments.threads { thread_pool_builder = thread_pool_builder.num_threads(threads); @@ -46,14 +57,6 @@ impl<'arguments> Compiler<'arguments> { .build_global() .expect("rayon thread pool parameters are valid"); - inkwell::support::enable_llvm_pretty_stack_trace(); - solx_codegen_evm::initialize_target(); - - if self.arguments.recursive_process { - crate::run_subprocess()?; - return Ok(true); - } - Ok(false) } diff --git a/solx-core/src/lib.rs b/solx-core/src/lib.rs index 566dfdfb9..2d9de9880 100644 --- a/solx-core/src/lib.rs +++ b/solx-core/src/lib.rs @@ -28,9 +28,11 @@ pub use self::error::Error; pub use self::error::stack_too_deep::StackTooDeep as StackTooDeepError; pub use self::frontend::Frontend; pub use self::process::EXECUTABLE; -pub use self::process::input::Input as EVMProcessInput; +pub use self::process::child::run as run_subprocess; +pub use self::process::job::Job as EVMProcessJob; pub use self::process::output::Output as EVMProcessOutput; -pub use self::process::run as run_subprocess; +pub use self::process::pool::Pool as EVMProcessPool; +pub use self::process::session::Session as EVMProcessSession; pub use self::project::Project; pub use self::project::contract::Contract as ProjectContract; diff --git a/solx-core/src/process/channel.rs b/solx-core/src/process/channel.rs new file mode 100644 index 000000000..0a9d9c7de --- /dev/null +++ b/solx-core/src/process/channel.rs @@ -0,0 +1,68 @@ +//! +//! The length-prefixed CBOR frame protocol shared by both sides of the worker channel. +//! + +use std::io::BufRead; +use std::io::Write; + +/// The size of the frame length prefix. +const LENGTH_PREFIX_SIZE: usize = size_of::(); + +/// +/// Writes length-prefixed CBOR frames. +/// +pub trait FrameWrite: Write { + /// + /// Serializes `value` into a length-prefixed CBOR frame and flushes it. + /// + fn send(&mut self, value: &T) -> anyhow::Result<()> + where + T: serde::Serialize, + { + let mut frame = vec![0u8; LENGTH_PREFIX_SIZE]; + ciborium::into_writer(value, &mut frame) + .map_err(|error| anyhow::anyhow!("Frame serializing error: {error}"))?; + let body_length = (frame.len() - LENGTH_PREFIX_SIZE).to_le_bytes(); + frame[..LENGTH_PREFIX_SIZE].copy_from_slice(body_length.as_slice()); + self.write_all(frame.as_slice()) + .and_then(|()| self.flush()) + .map_err(|error| anyhow::anyhow!("Frame writing error: {error}")) + } +} + +impl FrameWrite for W {} + +/// +/// Reads length-prefixed CBOR frames. +/// +pub trait FrameRead: BufRead { + /// + /// Reads one length-prefixed CBOR frame, or `None` when the stream is at a frame boundary EOF. + /// + /// An empty buffer means the stream ended cleanly between frames; once a frame has + /// started, `read_exact` turns any short read into a truncation error. + /// + fn recv(&mut self) -> anyhow::Result> + where + T: serde::de::DeserializeOwned, + { + if self + .fill_buf() + .map_err(|error| anyhow::anyhow!("Frame reading error: {error}"))? + .is_empty() + { + return Ok(None); + } + let mut length_bytes = [0u8; LENGTH_PREFIX_SIZE]; + self.read_exact(length_bytes.as_mut_slice()) + .map_err(|error| anyhow::anyhow!("Frame length prefix reading error: {error}"))?; + let mut body = vec![0u8; usize::from_le_bytes(length_bytes)]; + self.read_exact(body.as_mut_slice()) + .map_err(|error| anyhow::anyhow!("Frame body reading error: {error}"))?; + ciborium::de::from_reader_with_recursion_limit(body.as_slice(), usize::MAX) + .map(Some) + .map_err(|error| anyhow::anyhow!("Frame deserializing error: {error}")) + } +} + +impl FrameRead for R {} diff --git a/solx-core/src/process/child.rs b/solx-core/src/process/child.rs new file mode 100644 index 000000000..07dfc2ed1 --- /dev/null +++ b/solx-core/src/process/child.rs @@ -0,0 +1,85 @@ +//! +//! The subprocess-side worker: reads a session, then compiles jobs until `stdin` closes. +//! + +use std::sync::atomic::Ordering; +use std::thread::Builder; + +use crate::error::Error; +use crate::process::channel::FrameRead; +use crate::process::channel::FrameWrite; +use crate::process::job::Job; +use crate::process::output::Output as EVMOutput; +use crate::process::session::Session; +use crate::project::contract::Contract; + +/// +/// Runs the worker loop on a dedicated stack-sized thread until `stdin` closes. +/// +pub fn run() -> anyhow::Result<()> { + Builder::new() + .stack_size(crate::WORKER_THREAD_STACK_SIZE) + .spawn(|| -> anyhow::Result<()> { + let mut stdin = std::io::stdin().lock(); + let session: Session = stdin + .recv()? + .ok_or_else(|| anyhow::anyhow!("The worker received no session"))?; + + inkwell::support::error_handling::install_stack_error_handler(evm_stack_error_handler); + + while let Some(job) = stdin.recv::()? { + solx_codegen_evm::IS_SIZE_FALLBACK.store(false, Ordering::Relaxed); + let result = Contract::compile_to_evm( + session.language, + session.solc_version.clone(), + job.contract_name.clone(), + job.contract_ir, + job.code_segment, + session.evm_version, + job.debug_info, + &session.output_selection, + job.immutables, + job.metadata_bytes, + job.optimizer_settings, + session.llvm_options.clone(), + session.output_config.clone(), + ) + .map(EVMOutput::new) + .map_err(|error| match error { + Error::Generic(error) => solx_standard_json::OutputError::new_error_contract( + Some(job.contract_name.path.as_str()), + error, + ) + .into(), + error => error, + }); + std::io::stdout().send(&result)?; + } + + unsafe { inkwell::support::shutdown_llvm() }; + Ok(()) + }) + .expect("Threading error") + .join() + .expect("Threading error") +} + +/// +/// Handles LLVM stack-too-deep errors. +/// +/// # Safety +/// +/// This function is unsafe because it is called from the LLVM stackifier. +/// The function must terminate the process after handling the error. +/// +unsafe extern "C" fn evm_stack_error_handler(spill_area_size: u64) { + let result: crate::Result = Err(Error::stack_too_deep( + spill_area_size, + solx_codegen_evm::IS_SIZE_FALLBACK.load(Ordering::Relaxed), + )); + std::io::stdout() + .send(&result) + .unwrap_or_else(|error| panic!("Stack-too-deep response writing error: {error}")); + unsafe { inkwell::support::shutdown_llvm() }; + std::process::exit(solx_utils::EXIT_CODE_SUCCESS); +} diff --git a/solx-core/src/process/input.rs b/solx-core/src/process/job.rs similarity index 53% rename from solx-core/src/process/input.rs rename to solx-core/src/process/job.rs index c5808d41c..b86715ff4 100644 --- a/solx-core/src/process/input.rs +++ b/solx-core/src/process/job.rs @@ -1,7 +1,5 @@ //! -//! Process for compiling a single compilation unit. -//! -//! The EVM input data. +//! The per-unit job data. //! use std::collections::BTreeMap; @@ -10,71 +8,49 @@ use std::collections::BTreeSet; use crate::project::contract::ir::IR as ContractIR; /// -/// The EVM input data. +/// The per-unit job data. +/// +/// Sent to a worker subprocess for every translation unit, complementing the session data. /// #[derive(Debug, serde::Serialize, serde::Deserialize)] -pub struct Input { - /// The input contract language. - pub language: solx_standard_json::InputLanguage, - /// The `solc` compiler version, used only for Solidity and Yul projects. - pub solc_version: Option, +pub struct Job { /// The input contract name. pub contract_name: solx_utils::ContractName, /// The input contract IR. pub contract_ir: ContractIR, /// The code segment. pub code_segment: solx_utils::CodeSegment, - /// The EVM version to produce bytecode for. - pub evm_version: Option, /// Solidity debug info. pub debug_info: Option, - /// Output selection for the compilation. - pub output_selection: solx_standard_json::InputSelection, /// Immutables produced by the runtime code run. pub immutables: Option>>, /// The metadata bytes. pub metadata_bytes: Option>, /// The optimizer settings. pub optimizer_settings: solx_codegen_evm::OptimizerSettings, - /// The extra LLVM arguments. - pub llvm_options: Vec, - /// The output config for IR artifacts. - pub output_config: Option, } -impl Input { +impl Job { /// /// A shortcut constructor. /// pub fn new( - language: solx_standard_json::InputLanguage, - solc_version: Option, contract_name: solx_utils::ContractName, contract_ir: ContractIR, code_segment: solx_utils::CodeSegment, - evm_version: Option, debug_info: Option, - output_selection: solx_standard_json::InputSelection, immutables: Option>>, metadata_bytes: Option>, optimizer_settings: solx_codegen_evm::OptimizerSettings, - llvm_options: Vec, - output_config: Option, ) -> Self { Self { - language, - solc_version, contract_name, contract_ir, code_segment, - evm_version, debug_info, - output_selection, immutables, metadata_bytes, optimizer_settings, - llvm_options, - output_config, } } } diff --git a/solx-core/src/process/mod.rs b/solx-core/src/process/mod.rs index ed42e7ede..96f36d43b 100644 --- a/solx-core/src/process/mod.rs +++ b/solx-core/src/process/mod.rs @@ -1,182 +1,17 @@ //! -//! Process for compiling a single compilation unit. +//! The persistent worker subprocess pool and its framed session/job protocol. //! -pub mod input; +pub mod channel; +pub mod child; +pub mod job; pub mod output; +pub mod pool; +pub mod session; +pub mod worker; -use std::io::Read; -use std::io::Write; use std::path::PathBuf; -use std::process::Command; use std::sync::OnceLock; -use std::thread::Builder; - -use crate::error::Error; -use crate::project::contract::Contract; - -use self::input::Input as EVMInput; -use self::output::Output as EVMOutput; /// The overridden executable name used when the compiler is run as a library. pub static EXECUTABLE: OnceLock = OnceLock::new(); - -/// -/// Read input from `stdin`, compile a contract, and write the output to `stdout`. -/// -pub fn run() -> anyhow::Result<()> { - let length_bytes = { - let mut buffer = [0u8; 8]; - std::io::stdin() - .read_exact(&mut buffer) - .map_err(|error| anyhow::anyhow!("Input length prefix reading error: {error}"))?; - usize::from_le_bytes(buffer) - }; - let mut buffer = Vec::with_capacity(length_bytes); - std::io::stdin() - .read_to_end(&mut buffer) - .map_err(|error| anyhow::anyhow!("Input reading error: {error}"))?; - let input: EVMInput = - ciborium::de::from_reader_with_recursion_limit(buffer.as_slice(), usize::MAX) - .map_err(|error| anyhow::anyhow!("Input deserialziing error: {error}"))?; - - let result = Builder::new() - .stack_size(crate::WORKER_THREAD_STACK_SIZE) - .spawn(move || { - Contract::compile_to_evm( - input.language, - input.solc_version, - input.contract_name.clone(), - input.contract_ir, - input.code_segment, - input.evm_version, - input.debug_info, - input.output_selection, - input.immutables, - input.metadata_bytes, - input.optimizer_settings, - input.llvm_options, - input.output_config, - ) - .map(EVMOutput::new) - .map_err(|error| match error { - Error::Generic(error) => solx_standard_json::OutputError::new_error_contract( - Some(input.contract_name.path.as_str()), - error, - ) - .into(), - error => error, - }) - }) - .expect("Threading error") - .join() - .expect("Threading error"); - - ciborium::into_writer(&result, &mut std::io::stdout()) - .map_err(|error| anyhow::anyhow!("Result serializing and writing error: {error}"))?; - unsafe { inkwell::support::shutdown_llvm() }; - Ok(()) -} - -/// -/// Runs this process recursively to compile a single contract. -/// -pub fn call(contract_name: &solx_utils::ContractName, input: &I) -> crate::Result -where - I: serde::Serialize, - O: serde::de::DeserializeOwned, -{ - let executable = EXECUTABLE - .get() - .cloned() - .unwrap_or_else(|| std::env::current_exe().expect("Current executable path getting error")); - - let mut command = Command::new(executable.as_path()); - command.stdin(std::process::Stdio::piped()); - command.stdout(std::process::Stdio::piped()); - command.stderr(std::process::Stdio::piped()); - command.arg("--recursive-process"); - command.arg(contract_name.path.as_str()); - - let mut process = command - .spawn() - .map_err(|error| anyhow::anyhow!("{executable:?} subprocess spawning error: {error:?}"))?; - - let stdin = process - .stdin - .as_mut() - .ok_or_else(|| anyhow::anyhow!("{executable:?} subprocess stdin getting error"))?; - let mut buffer = Vec::with_capacity(crate::r#const::DEFAULT_SERDE_BUFFER_SIZE); - ciborium::into_writer(input, &mut buffer).map_err(|error| { - anyhow::anyhow!("{executable:?} subprocess input serializing error: {error:?}") - })?; - stdin - .write_all(buffer.len().to_le_bytes().as_slice()) - .map_err(|error| { - anyhow::anyhow!("{executable:?} subprocess length prefix writing error: {error:?}") - })?; - stdin.write_all(buffer.as_slice()).map_err(|error| { - anyhow::anyhow!("{executable:?} subprocess input writing error: {error:?}") - })?; - - let result = process.wait_with_output().map_err(|error| { - anyhow::anyhow!("{executable:?} subprocess output reading error: {error:?}") - })?; - - if result.status.code() != Some(solx_utils::EXIT_CODE_SUCCESS) { - let message = format!( - "{executable:?} subprocess failed {}:\n{}\n{}", - match result.status.code() { - Some(code) => format!("with exit code {code:?}"), - None => "without exit code".to_owned(), - }, - String::from_utf8_lossy(result.stdout.as_slice()), - String::from_utf8_lossy(result.stderr.as_slice()), - ); - Err(solx_standard_json::OutputError::new_error_contract( - Some(contract_name.path.as_str()), - message, - ))?; - } - - if !result.stderr.is_empty() { - let stderr = std::io::stderr(); - let mut handle = stderr.lock(); - let _ = handle.write_all(result.stderr.as_slice()); - } - - ciborium::de::from_reader_with_recursion_limit(result.stdout.as_slice(), usize::MAX).map_err( - |error| { - anyhow::anyhow!( - "{executable:?} subprocess stdout deserializing error: {error:?}\n{}\n{}", - String::from_utf8_lossy(result.stdout.as_slice()), - String::from_utf8_lossy(result.stderr.as_slice()), - ) - }, - )? -} - -/// -/// Handles LLVM stack-too-deep errors. -/// -/// # Safety -/// -/// This function is unsafe because it is called from the LLVM stackifier. -/// The function must terminate the process after handling the error. -/// -pub unsafe extern "C" fn evm_stack_error_handler(spill_area_size: u64) { - let result: Result = Err(Error::stack_too_deep( - spill_area_size, - solx_codegen_evm::IS_SIZE_FALLBACK.load(std::sync::atomic::Ordering::Relaxed), - )); - let mut buffer = Vec::with_capacity(crate::r#const::DEFAULT_SERDE_BUFFER_SIZE); - ciborium::into_writer(&result, &mut buffer) - .unwrap_or_else(|error| panic!("Stdout stack-too-deep error serializing error: {error}")); - std::io::stdout() - .write_all(buffer.as_slice()) - .unwrap_or_else(|error| panic!("Stdout stack-too-deep error writing error: {error}")); - std::io::Write::flush(&mut std::io::stdout()) - .unwrap_or_else(|error| panic!("Stdout flush error: {error}")); - unsafe { inkwell::support::shutdown_llvm() }; - std::process::exit(solx_utils::EXIT_CODE_SUCCESS); -} diff --git a/solx-core/src/process/output.rs b/solx-core/src/process/output.rs index 6ee6dc375..adc42fbee 100644 --- a/solx-core/src/process/output.rs +++ b/solx-core/src/process/output.rs @@ -1,6 +1,4 @@ //! -//! Process for compiling a single compilation unit. -//! //! The EVM output data. //! diff --git a/solx-core/src/process/pool.rs b/solx-core/src/process/pool.rs new file mode 100644 index 000000000..ec1bba6ef --- /dev/null +++ b/solx-core/src/process/pool.rs @@ -0,0 +1,95 @@ +//! +//! The pool of persistent worker subprocesses. +//! + +use std::path::PathBuf; +use std::sync::Mutex; + +use crate::error::Error; +use crate::process::job::Job; +use crate::process::output::Output as EVMOutput; +use crate::process::session::Session; +use crate::process::worker::Worker; + +/// The lock-poisoning invariant shared by the idle-pool accessors. +const POISON: &str = "lock is never poisoned because worker threads do not panic"; + +/// +/// The pool of persistent worker subprocesses. +/// +/// Workers are spawned on demand and returned to the idle list after each job, +/// so the number of live workers never exceeds the number of dispatching threads. +/// +pub struct Pool { + /// The worker executable path. + executable: PathBuf, + /// The project-wide data sent to every spawned worker. + session: Session, + /// Whether workers may outlive a single job. + /// LLVM list-typed command line options accumulate occurrences with every parsed unit, + /// so sessions carrying extra LLVM arguments run every job in a fresh worker. + reuse_workers: bool, + /// The idle workers available for checkout. + idle: Mutex>, +} + +impl Pool { + /// + /// Creates a pool that dispatches jobs of `session` to worker subprocesses. + /// + pub fn new(session: Session) -> anyhow::Result { + let executable = crate::process::EXECUTABLE + .get() + .cloned() + .unwrap_or_else(|| { + std::env::current_exe().expect("Current executable path getting error") + }); + Ok(Self { + executable, + reuse_workers: session.llvm_options.is_empty(), + session, + idle: Mutex::new(Vec::new()), + }) + } + + /// + /// Compiles a single translation unit, retrying once on a fresh worker if a reused one dies. + /// + pub fn execute( + &self, + contract_name: &solx_utils::ContractName, + job: &Job, + ) -> crate::Result { + let idle = self.idle.lock().expect(POISON).pop(); + if let Some(worker) = idle + && let Ok(result) = self.dispatch(worker, job) + { + return result; + } + self.dispatch( + Worker::spawn(self.executable.as_path(), &self.session)?, + job, + ) + .unwrap_or_else(|error| { + Err(solx_standard_json::OutputError::new_error_contract( + Some(contract_name.path.as_str()), + format!("{:?} subprocess error: {error}", self.executable), + ) + .into()) + }) + } + + /// + /// Runs `job` on `worker`, returning it to the idle list when it stays reusable. + /// + fn dispatch(&self, mut worker: Worker, job: &Job) -> anyhow::Result> { + let result = worker.execute(job)?; + if self.reuse_workers + && job.optimizer_settings.spill_area_size.is_none() + && !matches!(result, Err(Error::StackTooDeep(_))) + { + self.idle.lock().expect(POISON).push(worker); + } + Ok(result) + } +} diff --git a/solx-core/src/process/session.rs b/solx-core/src/process/session.rs new file mode 100644 index 000000000..c4af014aa --- /dev/null +++ b/solx-core/src/process/session.rs @@ -0,0 +1,47 @@ +//! +//! The project-wide data shared by all translation units. +//! + +/// +/// The project-wide data shared by all translation units. +/// +/// Sent to every worker subprocess once, before the per-unit jobs. +/// +#[derive(Debug, serde::Serialize, serde::Deserialize)] +pub struct Session { + /// The input contract language. + pub language: solx_standard_json::InputLanguage, + /// The `solc` compiler version, used only for Solidity and Yul projects. + pub solc_version: Option, + /// The EVM version to produce bytecode for. + pub evm_version: Option, + /// Output selection for the compilation. + pub output_selection: solx_standard_json::InputSelection, + /// The extra LLVM arguments. + pub llvm_options: Vec, + /// The output config for IR artifacts. + pub output_config: Option, +} + +impl Session { + /// + /// A shortcut constructor. + /// + pub fn new( + language: solx_standard_json::InputLanguage, + solc_version: Option, + evm_version: Option, + output_selection: solx_standard_json::InputSelection, + llvm_options: Vec, + output_config: Option, + ) -> Self { + Self { + language, + solc_version, + evm_version, + output_selection, + llvm_options, + output_config, + } + } +} diff --git a/solx-core/src/process/worker.rs b/solx-core/src/process/worker.rs new file mode 100644 index 000000000..cf256dbca --- /dev/null +++ b/solx-core/src/process/worker.rs @@ -0,0 +1,79 @@ +//! +//! A persistent worker subprocess owned by the pool. +//! + +use std::io::BufReader; +use std::path::Path; +use std::process::Child; +use std::process::ChildStdout; +use std::process::Command; +use std::process::Stdio; + +use crate::process::channel::FrameRead; +use crate::process::channel::FrameWrite; +use crate::process::job::Job; +use crate::process::output::Output as EVMOutput; +use crate::process::session::Session; + +/// +/// A persistent worker subprocess with its framed I/O channel. +/// +/// Its `stderr` is inherited, so subprocess diagnostics stream directly to the parent. +/// Dropping a worker closes its `stdin`, which the worker loop treats as a shutdown request. +/// +pub struct Worker { + /// The worker subprocess handle. + child: Child, + /// The buffered response stream. + stdout: BufReader, +} + +impl Worker { + /// + /// Spawns a worker subprocess and sends it the session frame. + /// + pub fn spawn(executable: &Path, session: &Session) -> anyhow::Result { + let mut command = Command::new(executable); + command.stdin(Stdio::piped()); + command.stdout(Stdio::piped()); + command.stderr(Stdio::inherit()); + command.arg("--recursive-process"); + + let mut child = command.spawn().map_err(|error| { + anyhow::anyhow!("{executable:?} subprocess spawning error: {error:?}") + })?; + child + .stdin + .as_mut() + .expect("The worker stdin is always piped") + .send(session)?; + let stdout = BufReader::new( + child + .stdout + .take() + .expect("The worker stdout is always piped"), + ); + Ok(Self { child, stdout }) + } + + /// + /// Sends `job` and receives the compilation result, or an error if the worker died. + /// + pub fn execute(&mut self, job: &Job) -> anyhow::Result> { + self.child + .stdin + .as_mut() + .expect("The worker stdin is always piped") + .send(job)?; + self.stdout + .recv()? + .ok_or_else(|| anyhow::anyhow!("The worker closed the response channel")) + } +} + +impl Drop for Worker { + fn drop(&mut self) { + drop(self.child.stdin.take()); + let _ = self.child.wait(); + } +} diff --git a/solx-core/src/project/contract/mod.rs b/solx-core/src/project/contract/mod.rs index f3b3416c9..efbf7c5ed 100644 --- a/solx-core/src/project/contract/mod.rs +++ b/solx-core/src/project/contract/mod.rs @@ -126,7 +126,7 @@ impl Contract { code_segment: solx_utils::CodeSegment, evm_version: Option, debug_info: Option, - output_selection: solx_standard_json::InputSelection, + output_selection: &solx_standard_json::InputSelection, immutables: Option>>, metadata_bytes: Option>, mut optimizer_settings: solx_codegen_evm::OptimizerSettings, @@ -202,9 +202,6 @@ impl Contract { solidity_data, output_config, ); - inkwell::support::error_handling::install_stack_error_handler( - crate::process::evm_stack_error_handler, - ); let run_yul_lowering = profiler.start_evm_translation_unit( contract_name.full_path.as_str(), code_segment, @@ -341,9 +338,6 @@ impl Contract { solidity_data, output_config, ); - inkwell::support::error_handling::install_stack_error_handler( - crate::process::evm_stack_error_handler, - ); context.set_evmla_data(evmla_data); context.set_capture_evmla(output_selection.check_selection( contract_name.path.as_str(), @@ -462,9 +456,6 @@ impl Contract { None, output_config, ); - inkwell::support::error_handling::install_stack_error_handler( - crate::process::evm_stack_error_handler, - ); if output_selection.check_selection( contract_name.path.as_str(), contract_name.name.as_deref(), @@ -574,9 +565,6 @@ impl Contract { None, output_config, ); - inkwell::support::error_handling::install_stack_error_handler( - crate::process::evm_stack_error_handler, - ); if output_selection.check_selection( contract_name.path.as_str(), contract_name.name.as_deref(), diff --git a/solx-core/src/project/mod.rs b/solx-core/src/project/mod.rs index 6365f07de..e9833defb 100644 --- a/solx-core/src/project/mod.rs +++ b/solx-core/src/project/mod.rs @@ -16,8 +16,10 @@ use rayon::iter::ParallelIterator; use crate::build::Build as EVMBuild; use crate::build::contract::Contract as EVMContractBuild; use crate::error::Error; -use crate::process::input::Input as EVMProcessInput; +use crate::process::job::Job as EVMProcessJob; use crate::process::output::Output as EVMProcessOutput; +use crate::process::pool::Pool as EVMProcessPool; +use crate::process::session::Session as EVMProcessSession; use self::contract::Contract; use self::contract::ir::IR as ContractIR; @@ -515,7 +517,24 @@ impl Project { llvm_options: Vec, output_config: Option, ) -> anyhow::Result { - let mut contracts: Vec<(String, Contract)> = self.contracts.into_iter().collect(); + let Self { + language, + solc_version, + contracts, + ast_jsons, + libraries: _, + debug_info, + } = self; + let pool = EVMProcessPool::new(EVMProcessSession::new( + language, + solc_version.clone(), + evm_version, + output_selection.clone(), + llvm_options.clone(), + output_config, + ))?; + + let mut contracts: Vec<(String, Contract)> = contracts.into_iter().collect(); contracts.sort_unstable_by(|(_, left), (_, right)| { right .estimated_compilation_cost() @@ -548,7 +567,7 @@ impl Project { let runtime_code: ContractYul = *deploy_code.runtime_code.take().expect("Always exists"); - deploy_debug_info = self.debug_info.as_ref().and_then(|debug_info| { + deploy_debug_info = debug_info.as_ref().and_then(|debug_info| { output_selection .check_selection( path.as_str(), @@ -562,7 +581,7 @@ impl Project { ) }) }); - runtime_debug_info = self.debug_info.as_ref().and_then(|debug_info| { + runtime_debug_info = debug_info.as_ref().and_then(|debug_info| { output_selection .check_selection( path.as_str(), @@ -583,7 +602,7 @@ impl Project { let runtime_code: ContractEVMLegacyAssembly = *deploy_code.runtime_code.take().expect("Always exists"); - deploy_debug_info = self.debug_info.as_ref().and_then(|debug_info| { + deploy_debug_info = debug_info.as_ref().and_then(|debug_info| { output_selection .check_selection( path.as_str(), @@ -597,7 +616,7 @@ impl Project { ) }) }); - runtime_debug_info = self.debug_info.as_ref().and_then(|debug_info| { + runtime_debug_info = debug_info.as_ref().and_then(|debug_info| { output_selection .check_selection( path.as_str(), @@ -661,30 +680,24 @@ impl Project { let (runtime_object_result, metadata) = { let metadata_bytes = Self::cbor_metadata( metadata.as_deref(), - self.solc_version.as_ref(), + solc_version.as_ref(), &optimizer_settings, llvm_options.as_slice(), metadata_hash_type, append_cbor, ); - let mut input = EVMProcessInput::new( - self.language, - self.solc_version.clone(), + let mut job = EVMProcessJob::new( contract_name.clone(), runtime_code_ir, solx_utils::CodeSegment::Runtime, - evm_version, runtime_debug_info, - output_selection.to_owned(), None, metadata_bytes, optimizer_settings.clone(), - llvm_options.clone(), - output_config.clone(), ); - let result = Self::run_multi_pass_pipeline(&contract_name, &mut input); + let result = Self::run_multi_pass_pipeline(&pool, &contract_name, &mut job); (result, metadata) }; @@ -693,23 +706,17 @@ impl Project { .ok() .and_then(|output| output.object.immutables.to_owned()); let deploy_object_result: crate::Result = { - let mut input = EVMProcessInput::new( - self.language, - self.solc_version.clone(), + let mut job = EVMProcessJob::new( contract_name.clone(), deploy_code_ir, solx_utils::CodeSegment::Deploy, - evm_version, deploy_debug_info, - output_selection.to_owned(), immutables, None, optimizer_settings.clone(), - llvm_options.clone(), - output_config.clone(), ); - Self::run_multi_pass_pipeline(&contract_name, &mut input) + Self::run_multi_pass_pipeline(&pool, &contract_name, &mut job) }; let build = EVMContractBuild::new( @@ -734,7 +741,7 @@ impl Project { }) .collect::>(); - Ok(EVMBuild::new(results, self.ast_jsons, messages)) + Ok(EVMBuild::new(results, ast_jsons, messages)) } /// @@ -806,13 +813,14 @@ impl Project { /// and turning on the size fallback to overcome the EVM bytecode size limit. /// fn run_multi_pass_pipeline( + pool: &EVMProcessPool, contract_name: &solx_utils::ContractName, - input: &mut EVMProcessInput, + job: &mut EVMProcessJob, ) -> crate::Result { let mut result: crate::Result; let mut pass_count = 0; loop { - result = crate::process::call(contract_name, input); + result = pool.execute(contract_name, job); pass_count += 1; match result { Err(Error::StackTooDeep(stack_too_deep)) => { @@ -822,10 +830,9 @@ impl Project { ); if stack_too_deep.is_size_fallback { - input.optimizer_settings.switch_to_size_fallback(); + job.optimizer_settings.switch_to_size_fallback(); } - input - .optimizer_settings + job.optimizer_settings .set_spill_area_size(stack_too_deep.spill_area_size); continue; diff --git a/solx/tests/cli/recursive_process.rs b/solx/tests/cli/recursive_process.rs index 019309c99..c92ca8934 100644 --- a/solx/tests/cli/recursive_process.rs +++ b/solx/tests/cli/recursive_process.rs @@ -11,9 +11,9 @@ fn missing_input() -> anyhow::Result<()> { let args = &["--recursive-process"]; let result = crate::cli::execute_solx(args)?; - result.failure().stderr(predicate::str::contains( - "Input length prefix reading error: failed to fill whole buffer", - )); + result + .failure() + .stderr(predicate::str::contains("The worker received no session")); Ok(()) } @@ -22,11 +22,7 @@ fn missing_input() -> anyhow::Result<()> { fn excess_args() -> anyhow::Result<()> { crate::common::setup()?; - let args = &[ - "--recursive-process", - crate::common::TEST_SOLIDITY_CONTRACT, - "excess", - ]; + let args = &["--recursive-process", crate::common::TEST_SOLIDITY_CONTRACT]; let result = crate::cli::execute_solx(args)?; result.failure().stderr(predicate::str::contains( From 8276ba75ca673ccfcbfb934a9effd57c08efe43b Mon Sep 17 00:00:00 2001 From: Oleksandr Zarudnyi Date: Mon, 13 Jul 2026 22:32:05 +0800 Subject: [PATCH 2/5] style: satisfy clippy collapsible-conditional lints on Rust 1.96 --- solx-codegen-evm/src/context/traits/evmla_stack.rs | 11 +++++------ .../parser/lexical/stream/integer/mod.rs | 9 ++++----- 2 files changed, 9 insertions(+), 11 deletions(-) diff --git a/solx-codegen-evm/src/context/traits/evmla_stack.rs b/solx-codegen-evm/src/context/traits/evmla_stack.rs index 795032430..1a75885ba 100644 --- a/solx-codegen-evm/src/context/traits/evmla_stack.rs +++ b/solx-codegen-evm/src/context/traits/evmla_stack.rs @@ -46,13 +46,12 @@ pub trait IEVMLAStack<'ctx>: IContext<'ctx> + Sized { for position in 0..depth { if let ShadowSlot::Memory(index) = self.evmla().expect("Always exists").shadow_peek(position) + && index != position { - if index != position { - let value = self.evmla_stack_read(position)?; - self.evmla_mut() - .expect("Always exists") - .shadow_write(position, value); - } + let value = self.evmla_stack_read(position)?; + self.evmla_mut() + .expect("Always exists") + .shadow_write(position, value); } } for position in 0..depth { diff --git a/solx-solc-test-adapter/src/test/function_call/parser/lexical/stream/integer/mod.rs b/solx-solc-test-adapter/src/test/function_call/parser/lexical/stream/integer/mod.rs index 762bbbf2c..3a3ffef6d 100644 --- a/solx-solc-test-adapter/src/test/function_call/parser/lexical/stream/integer/mod.rs +++ b/solx-solc-test-adapter/src/test/function_call/parser/lexical/stream/integer/mod.rs @@ -72,13 +72,12 @@ pub fn parse(input: &str) -> Result { }, State::Minus => match character { Some(character) => { - if Integer::CHARACTERS_DECIMAL.contains(&character) { - integer.push(character); - size += 1; - state = State::Decimal; - } else { + if !Integer::CHARACTERS_DECIMAL.contains(&character) { return Err(Error::NotAnInteger); } + integer.push(character); + size += 1; + state = State::Decimal; } None => return Err(Error::NotAnInteger), }, From 970a70af4afb1a2d65a2f8b7ebc135366a58e69a Mon Sep 17 00:00:00 2001 From: Oleksandr Zarudnyi Date: Mon, 13 Jul 2026 21:27:34 +0400 Subject: [PATCH 3/5] refactor(core): reduce the worker pool to a reuse predicate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Worker::execute now returns a plain crate::Result — a subprocess I/O failure folds into Error::Generic — so the ? drops a dead or errored worker and the pool is just pop-or-spawn, run, and return. This removes the retry, the WorkerError enum, dispatch, and worker_failed. Whether a worker may be reused lives on the data owners: Session::allows_worker_reuse (no extra llvm_options) and Job::allows_worker_reuse (no stack-too-deep spill area), each documenting the process-global cl-option it guards against. The cached reuse_workers field is removed. run_multi_pass_pipeline breaks the loop with its value instead of a declare-then-assign, and no longer threads the contract name. --- solx-core/src/process/job.rs | 10 ++++++ solx-core/src/process/pool.rs | 54 ++++++++------------------------ solx-core/src/process/session.rs | 10 ++++++ solx-core/src/process/worker.rs | 8 ++--- solx-core/src/project/mod.rs | 14 +++------ 5 files changed, 41 insertions(+), 55 deletions(-) diff --git a/solx-core/src/process/job.rs b/solx-core/src/process/job.rs index b86715ff4..1d330c350 100644 --- a/solx-core/src/process/job.rs +++ b/solx-core/src/process/job.rs @@ -53,4 +53,14 @@ impl Job { optimizer_settings, } } + + /// + /// Whether a worker may be reused after this unit. + /// + /// A stack-too-deep spill area emits the process-global `-evm-stack-region-*` cl-options, + /// which would leak into the next unit, so a spilling unit runs on a throwaway worker. + /// + pub fn allows_worker_reuse(&self) -> bool { + self.optimizer_settings.spill_area_size.is_none() + } } diff --git a/solx-core/src/process/pool.rs b/solx-core/src/process/pool.rs index ec1bba6ef..61cef192d 100644 --- a/solx-core/src/process/pool.rs +++ b/solx-core/src/process/pool.rs @@ -5,7 +5,6 @@ use std::path::PathBuf; use std::sync::Mutex; -use crate::error::Error; use crate::process::job::Job; use crate::process::output::Output as EVMOutput; use crate::process::session::Session; @@ -17,7 +16,7 @@ const POISON: &str = "lock is never poisoned because worker threads do not panic /// /// The pool of persistent worker subprocesses. /// -/// Workers are spawned on demand and returned to the idle list after each job, +/// Workers are spawned on demand and returned to the idle list after each successful job, /// so the number of live workers never exceeds the number of dispatching threads. /// pub struct Pool { @@ -25,10 +24,6 @@ pub struct Pool { executable: PathBuf, /// The project-wide data sent to every spawned worker. session: Session, - /// Whether workers may outlive a single job. - /// LLVM list-typed command line options accumulate occurrences with every parsed unit, - /// so sessions carrying extra LLVM arguments run every job in a fresh worker. - reuse_workers: bool, /// The idle workers available for checkout. idle: Mutex>, } @@ -46,50 +41,27 @@ impl Pool { }); Ok(Self { executable, - reuse_workers: session.llvm_options.is_empty(), session, idle: Mutex::new(Vec::new()), }) } /// - /// Compiles a single translation unit, retrying once on a fresh worker if a reused one dies. + /// Compiles one translation unit on a pooled or freshly spawned worker. /// - pub fn execute( - &self, - contract_name: &solx_utils::ContractName, - job: &Job, - ) -> crate::Result { - let idle = self.idle.lock().expect(POISON).pop(); - if let Some(worker) = idle - && let Ok(result) = self.dispatch(worker, job) - { - return result; - } - self.dispatch( - Worker::spawn(self.executable.as_path(), &self.session)?, - job, - ) - .unwrap_or_else(|error| { - Err(solx_standard_json::OutputError::new_error_contract( - Some(contract_name.path.as_str()), - format!("{:?} subprocess error: {error}", self.executable), - ) - .into()) - }) - } - - /// - /// Runs `job` on `worker`, returning it to the idle list when it stays reusable. + /// The worker rejoins the pool only after a clean run that leaves it reusable; any error + /// drops it through the early return. /// - fn dispatch(&self, mut worker: Worker, job: &Job) -> anyhow::Result> { - let result = worker.execute(job)?; - if self.reuse_workers - && job.optimizer_settings.spill_area_size.is_none() - && !matches!(result, Err(Error::StackTooDeep(_))) - { + pub fn execute(&self, job: &Job) -> crate::Result { + let idle = self.idle.lock().expect(POISON).pop(); + let mut worker = match idle { + Some(worker) => worker, + None => Worker::spawn(self.executable.as_path(), &self.session)?, + }; + let output = worker.execute(job)?; + if self.session.allows_worker_reuse() && job.allows_worker_reuse() { self.idle.lock().expect(POISON).push(worker); } - Ok(result) + Ok(output) } } diff --git a/solx-core/src/process/session.rs b/solx-core/src/process/session.rs index c4af014aa..9018a9802 100644 --- a/solx-core/src/process/session.rs +++ b/solx-core/src/process/session.rs @@ -44,4 +44,14 @@ impl Session { output_config, } } + + /// + /// Whether a worker may be reused across this session's units. + /// + /// Extra `llvm_options` accumulate in a worker's process-global LLVM cl-option state on + /// every parse, so a session that carries them runs each unit on a fresh worker. + /// + pub fn allows_worker_reuse(&self) -> bool { + self.llvm_options.is_empty() + } } diff --git a/solx-core/src/process/worker.rs b/solx-core/src/process/worker.rs index cf256dbca..ad06eaa40 100644 --- a/solx-core/src/process/worker.rs +++ b/solx-core/src/process/worker.rs @@ -57,17 +57,17 @@ impl Worker { } /// - /// Sends `job` and receives the compilation result, or an error if the worker died. + /// Sends `job` to the worker and returns the compilation result it replies with. /// - pub fn execute(&mut self, job: &Job) -> anyhow::Result> { + pub fn execute(&mut self, job: &Job) -> crate::Result { self.child .stdin .as_mut() .expect("The worker stdin is always piped") .send(job)?; self.stdout - .recv()? - .ok_or_else(|| anyhow::anyhow!("The worker closed the response channel")) + .recv::>()? + .ok_or_else(|| anyhow::anyhow!("The worker closed the response channel"))? } } diff --git a/solx-core/src/project/mod.rs b/solx-core/src/project/mod.rs index e9833defb..c247f654d 100644 --- a/solx-core/src/project/mod.rs +++ b/solx-core/src/project/mod.rs @@ -697,7 +697,7 @@ impl Project { optimizer_settings.clone(), ); - let result = Self::run_multi_pass_pipeline(&pool, &contract_name, &mut job); + let result = Self::run_multi_pass_pipeline(&pool, &mut job); (result, metadata) }; @@ -716,7 +716,7 @@ impl Project { optimizer_settings.clone(), ); - Self::run_multi_pass_pipeline(&pool, &contract_name, &mut job) + Self::run_multi_pass_pipeline(&pool, &mut job) }; let build = EVMContractBuild::new( @@ -814,15 +814,12 @@ impl Project { /// fn run_multi_pass_pipeline( pool: &EVMProcessPool, - contract_name: &solx_utils::ContractName, job: &mut EVMProcessJob, ) -> crate::Result { - let mut result: crate::Result; let mut pass_count = 0; loop { - result = pool.execute(contract_name, job); pass_count += 1; - match result { + match pool.execute(job) { Err(Error::StackTooDeep(stack_too_deep)) => { assert!( pass_count <= 2, @@ -834,12 +831,9 @@ impl Project { } job.optimizer_settings .set_spill_area_size(stack_too_deep.spill_area_size); - - continue; } - _ => break, + result => break result, } } - result } } From 6bd1976a853977b0a932e4239b3b53036e98633a Mon Sep 17 00:00:00 2001 From: Oleksandr Zarudnyi Date: Mon, 13 Jul 2026 23:40:32 +0400 Subject: [PATCH 4/5] refactor(core): reset LLVM cl-options per unit, drop worker-reuse gating MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reset the process-global LLVM command-line option occurrences before every parse (new LLVMResetAllOptionOccurrences, exposed through llvm-sys and inkwell), so a translation unit never inherits an option a previous one set in the same persistent worker. This removes the whole option-leak layer: the -evm-metadata-size=0 heal is gone, and the pool reuses a worker unconditionally on success — Session/Job::allows_worker_reuse and the spill/llvm_options gating are deleted. Points solx-llvm, llvm-sys, and inkwell at their az-reset-option-occurrences branches. Byte-identical on the EVMLA smoke; CLI suite 397/397. --- Cargo.lock | 6 +++--- Cargo.toml | 2 +- solx-codegen-evm/src/target_machine.rs | 11 +++-------- solx-core/src/process/job.rs | 10 ---------- solx-core/src/process/pool.rs | 7 ++----- solx-core/src/process/session.rs | 10 ---------- solx-llvm | 2 +- 7 files changed, 10 insertions(+), 38 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 804e3a475..49d77658d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2341,7 +2341,7 @@ dependencies = [ [[package]] name = "inkwell" version = "0.7.1" -source = "git+https://github.com/NomicFoundation/inkwell?rev=4f9f86e15f43dc8555b8b51d4dda01e60f756f22#4f9f86e15f43dc8555b8b51d4dda01e60f756f22" +source = "git+https://github.com/NomicFoundation/inkwell?branch=az-reset-option-occurrences#dd7c761865dc985a07a6c63f6de2614388d49cfd" dependencies = [ "inkwell_internals", "libc", @@ -2354,7 +2354,7 @@ dependencies = [ [[package]] name = "inkwell_internals" version = "0.12.0" -source = "git+https://github.com/NomicFoundation/inkwell?rev=4f9f86e15f43dc8555b8b51d4dda01e60f756f22#4f9f86e15f43dc8555b8b51d4dda01e60f756f22" +source = "git+https://github.com/NomicFoundation/inkwell?branch=az-reset-option-occurrences#dd7c761865dc985a07a6c63f6de2614388d49cfd" dependencies = [ "proc-macro2", "quote", @@ -2602,7 +2602,7 @@ checksum = "6373607a59f0be73a39b6fe456b8192fcc3585f602af20751600e974dd455e77" [[package]] name = "llvm-sys" version = "211.0.0" -source = "git+https://github.com/NomicFoundation/llvm-sys.rs?branch=main#afdfa3146ca4dcc4f9bf023fb5caa3fe14f5d908" +source = "git+https://github.com/NomicFoundation/llvm-sys.rs?branch=az-reset-option-occurrences#bfc06932fc84da16a36017a77a1b7f940813adf0" dependencies = [ "anyhow", "cc", diff --git a/Cargo.toml b/Cargo.toml index 763a25ef3..03ef39467 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -87,7 +87,7 @@ features = ["http-rustls-tls", "test", "signing"] # LLVM [workspace.dependencies.inkwell] git = "https://github.com/NomicFoundation/inkwell" -rev = "4f9f86e15f43dc8555b8b51d4dda01e60f756f22" +branch = "az-reset-option-occurrences" default-features = false features = [ "llvm21-1", diff --git a/solx-codegen-evm/src/target_machine.rs b/solx-codegen-evm/src/target_machine.rs index 1372cb3cc..26fd2464d 100644 --- a/solx-codegen-evm/src/target_machine.rs +++ b/solx-codegen-evm/src/target_machine.rs @@ -27,11 +27,8 @@ impl TargetMachine { /// `-evm-stack-region-offset ` /// `-evm-metadata-size ` /// - /// LLVM command line options are process-global and survive across translation units - /// compiled in the same worker process, so `-evm-metadata-size` is always passed: - /// the explicit default heals a stale value left by a previous unit. It is passed - /// before `llvm_options` when unset (so user options keep overriding the default) - /// and after them when set (so the computed value keeps overriding user options). + /// LLVM command line options are process-global, so their occurrences are reset before + /// each parse: a unit never inherits an option set by a previous one in the same worker. /// pub fn new( optimizer_settings: &OptimizerSettings, @@ -39,9 +36,6 @@ impl TargetMachine { ) -> anyhow::Result { let mut arguments = Vec::with_capacity(4 + llvm_options.len()); arguments.push(Self::TARGET.to_string()); - if optimizer_settings.metadata_size.is_none() { - arguments.push("-evm-metadata-size=0".to_owned()); - } arguments.extend_from_slice(llvm_options); if let Some(size) = optimizer_settings.spill_area_size { arguments.push(format!( @@ -54,6 +48,7 @@ impl TargetMachine { arguments.push(format!("-evm-metadata-size={size}")); } let arguments: Vec<&str> = arguments.iter().map(|argument| argument.as_str()).collect(); + inkwell::support::reset_all_option_occurrences(); inkwell::support::parse_command_line_options(arguments.as_slice(), "LLVM options"); let target_machine = inkwell::targets::Target::from_name(Self::TARGET.to_string().as_str()) diff --git a/solx-core/src/process/job.rs b/solx-core/src/process/job.rs index 1d330c350..b86715ff4 100644 --- a/solx-core/src/process/job.rs +++ b/solx-core/src/process/job.rs @@ -53,14 +53,4 @@ impl Job { optimizer_settings, } } - - /// - /// Whether a worker may be reused after this unit. - /// - /// A stack-too-deep spill area emits the process-global `-evm-stack-region-*` cl-options, - /// which would leak into the next unit, so a spilling unit runs on a throwaway worker. - /// - pub fn allows_worker_reuse(&self) -> bool { - self.optimizer_settings.spill_area_size.is_none() - } } diff --git a/solx-core/src/process/pool.rs b/solx-core/src/process/pool.rs index 61cef192d..32a80531d 100644 --- a/solx-core/src/process/pool.rs +++ b/solx-core/src/process/pool.rs @@ -49,8 +49,7 @@ impl Pool { /// /// Compiles one translation unit on a pooled or freshly spawned worker. /// - /// The worker rejoins the pool only after a clean run that leaves it reusable; any error - /// drops it through the early return. + /// On success the worker rejoins the pool; any error drops it through the early return. /// pub fn execute(&self, job: &Job) -> crate::Result { let idle = self.idle.lock().expect(POISON).pop(); @@ -59,9 +58,7 @@ impl Pool { None => Worker::spawn(self.executable.as_path(), &self.session)?, }; let output = worker.execute(job)?; - if self.session.allows_worker_reuse() && job.allows_worker_reuse() { - self.idle.lock().expect(POISON).push(worker); - } + self.idle.lock().expect(POISON).push(worker); Ok(output) } } diff --git a/solx-core/src/process/session.rs b/solx-core/src/process/session.rs index 9018a9802..c4af014aa 100644 --- a/solx-core/src/process/session.rs +++ b/solx-core/src/process/session.rs @@ -44,14 +44,4 @@ impl Session { output_config, } } - - /// - /// Whether a worker may be reused across this session's units. - /// - /// Extra `llvm_options` accumulate in a worker's process-global LLVM cl-option state on - /// every parse, so a session that carries them runs each unit on a fresh worker. - /// - pub fn allows_worker_reuse(&self) -> bool { - self.llvm_options.is_empty() - } } diff --git a/solx-llvm b/solx-llvm index cec9acbdc..9537dbed1 160000 --- a/solx-llvm +++ b/solx-llvm @@ -1 +1 @@ -Subproject commit cec9acbdc2799e5607ea57c4f1617ca8579afbf9 +Subproject commit 9537dbed1cab68b483aba62a4560ba6a9dd78231 From 6ac861ae84f4c0dde8f57389db2c68adba00437c Mon Sep 17 00:00:00 2001 From: Bas van Gijzel Date: Tue, 14 Jul 2026 16:15:46 +0000 Subject: [PATCH 5/5] ci: HACK run integration tests on large macOS runners Temporary hack to exercise PR #524 (persistent worker subprocess pool) on GitHub's large macOS runners instead of the self-hosted Linux container. - Matrix the integration job over macos-15-large (Intel) and macos-15-xlarge (Apple Silicon); drop the `container:` block since macOS runners can't run job containers. LLVM/solc build from source via the same build-llvm/build-solc actions test.yaml uses on macOS. - Add test.yaml's macOS free-disk-space step (LLVM builds twice). - Suffix artifact names and PR-comment message-ids with the runner label so the two matrix legs don't clobber each other's uploads and comments. Not for merge. --- .github/workflows/integration-tests.yaml | 109 ++++++++++++++++++++--- 1 file changed, 96 insertions(+), 13 deletions(-) diff --git a/.github/workflows/integration-tests.yaml b/.github/workflows/integration-tests.yaml index 0ad844483..8974b5daf 100644 --- a/.github/workflows/integration-tests.yaml +++ b/.github/workflows/integration-tests.yaml @@ -48,10 +48,16 @@ jobs: contents: read pull-requests: write packages: read - runs-on: solx-linux-amd64-self-hosted - container: - image: ghcr.io/nomicfoundation/solx-ci-runner@sha256:a3e9312d6442e028a4b9eed17ea597380ae86abf3ff4d45ba957d5a10a805790 - options: -m 110g + # HACK (temporary, for testing PR #524 on large macOS runners): run the + # integration suite on GitHub's large macOS runners instead of the + # self-hosted Linux container. macOS runners can't use a job `container:`, + # so it is dropped and LLVM/solc are built from source on the runner (same + # build-llvm/build-solc actions used by test.yaml's macOS legs). + strategy: + fail-fast: false + matrix: + runner: [macos-15-large, macos-15-xlarge] + runs-on: ${{ matrix.runner }} steps: - name: Checkout PR uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 @@ -84,6 +90,83 @@ jobs: with: working-directory: temp-solx-main + # LLVM is built from source here (up to twice: PR + main baseline) and + # will exhaust the runner disk without this. Copied verbatim from + # test.yaml's macOS legs. + - name: Free disk space (macOS) + if: runner.os == 'macOS' + shell: bash + run: | + set -euo pipefail + echo "=== Before macOS cleanup ===" && df -h . + + # 1. Simulator runtimes — stored on read-only APFS snapshot volumes, + # so plain `rm` fails. `simctl runtime delete all` unmounts them. + # May warn on already-deleted runtimes; that is harmless. + echo "--- Removing simulator runtimes ---" + xcrun simctl delete all 2>&1 || true + xcrun simctl runtime delete all 2>&1 || true + + # 2. Xcode — remove every versioned copy EXCEPT the one that + # xcode-select points to (we need its toolchain for C/C++ builds). + # Active Xcode path looks like /Applications/Xcode_16.2.app/Contents/Developer. + echo "--- Removing inactive Xcode versions ---" + ACTIVE_XCODE="$(xcode-select -p 2>/dev/null | sed 's|/Contents/Developer/*$||' || true)" + ACTIVE_XCODE="${ACTIVE_XCODE%/}" + removed=0 + if [ -z "${ACTIVE_XCODE}" ] || [[ "${ACTIVE_XCODE}" != /Applications/Xcode*.app ]]; then + echo " warning: active Xcode path '${ACTIVE_XCODE}' is not an Xcode app; skipping Xcode removal" + else + echo "Active Xcode (keeping): ${ACTIVE_XCODE}" + to_remove=() + for app in /Applications/Xcode_*.app; do + [ -d "$app" ] || continue + if [ "$app" = "$ACTIVE_XCODE" ]; then + echo " skip (active): $app" + else + echo " removing: $app" + to_remove+=("$app") + fi + done + # Each Xcode bundle is ~15 GB of small files; `rm -rf` is I/O-bound + # per inode, so run them concurrently (one worker per bundle). + # Soft-fail: a stray rm error shouldn't sink the whole job — rm's + # stderr will pinpoint the bad path above. + # + # `removed` reflects the attempt count, not per-bundle success: + # xargs returns non-zero if *any* child failed, so gating the + # count on xargs success would print "Removed 0" even after + # ~30 GB was freed. The count is off by the number of failed + # bundles — usually one in practice — which is still a much + # better signal than zero. + if [ "${#to_remove[@]}" -gt 0 ]; then + removed=${#to_remove[@]} + if ! printf '%s\0' "${to_remove[@]}" \ + | xargs -0 -n1 -P "${#to_remove[@]}" sudo rm -rf; then + echo " warning: one or more Xcode removals failed (see rm stderr above)" + fi + fi + fi + echo "Removed ${removed} inactive Xcode version(s)" + + # 3. Remaining large packages that this project never uses. + # Each path is removed individually so a missing path doesn't + # mask a real permission error on another. + echo "--- Removing unused SDKs and caches ---" + for dir in \ + /Library/Developer/CoreSimulator \ + /usr/local/lib/android \ + "${RUNNER_TOOL_CACHE:-/Users/runner/hostedtoolcache}"; do + if [ -d "$dir" ]; then + echo " removing: $dir" + sudo rm -rf "$dir" || echo " warning: failed to remove $dir" + else + echo " not found (skipped): $dir" + fi + done + + echo "=== After macOS cleanup ===" && df -h . + - name: Setup SFW uses: ./.github/actions/setup-sfw @@ -281,16 +364,16 @@ jobs: id: solx-tester-report uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: - name: solx-tester-report + name: solx-tester-report-${{ matrix.runner }} path: solx-tester-report.xlsx - name: Post solx-tester report comment if: always() && steps.solx-tester-report.outcome == 'success' uses: mshick/add-pr-comment@ec328af66588ab8f77cdeb2c264f14aba45bbf59 # v3.12 with: - message-id: 'solx-tester-report' + message-id: 'solx-tester-report-${{ matrix.runner }}' message: | - 📊 **solx Tester Report** + 📊 **solx Tester Report** (`${{ matrix.runner }}`) ➡️ [**Download**](${{ steps.solx-tester-report.outputs.artifact-url }}) @@ -323,16 +406,16 @@ jobs: id: hardhat-report uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: - name: hardhat-report + name: hardhat-report-${{ matrix.runner }} path: ./temp-hardhat-reports/hardhat-report.xlsx - name: Post Hardhat report comment if: always() && steps.hardhat-report.outcome == 'success' uses: mshick/add-pr-comment@ec328af66588ab8f77cdeb2c264f14aba45bbf59 # v3.12 with: - message-id: 'hardhat-report' + message-id: 'hardhat-report-${{ matrix.runner }}' message: | - 📊 **Hardhat Projects Report** + 📊 **Hardhat Projects Report** (`${{ matrix.runner }}`) ➡️ [**Download**](${{ steps.hardhat-report.outputs.artifact-url }}) @@ -346,16 +429,16 @@ jobs: id: foundry-report uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: - name: foundry-report + name: foundry-report-${{ matrix.runner }} path: ./temp-foundry-reports/foundry-report.xlsx - name: Post Foundry report comment if: always() && steps.foundry-report.outcome == 'success' uses: mshick/add-pr-comment@ec328af66588ab8f77cdeb2c264f14aba45bbf59 # v3.12 with: - message-id: 'foundry-report' + message-id: 'foundry-report-${{ matrix.runner }}' message: | - 📊 **Foundry Projects Report** + 📊 **Foundry Projects Report** (`${{ matrix.runner }}`) ➡️ [**Download**](${{ steps.foundry-report.outputs.artifact-url }})