mod attach;
mod control;
mod ctl;
mod logging;
#[cfg(feature = "mcp")]
mod mcp;
mod options;
mod proxy;
mod session_env;
mod ui_settings;
use clap::Parser;
use codespan_reporting::diagnostic::Diagnostic;
use micromux::{Printer as DiagnosticsPrinter, ToDiagnostics};
#[derive(Debug, thiserror::Error)]
enum Error {
#[error(transparent)]
Io(#[from] std::io::Error),
#[error(transparent)]
Micromux(#[from] micromux::Error),
#[error(transparent)]
Tui(#[from] micromux_tui::Error),
#[error(transparent)]
Control(#[from] micromux_control::ControlError),
#[error(transparent)]
Session(#[from] micromux::SessionError),
#[cfg(unix)]
#[error(transparent)]
Proxy(#[from] micromux_proxy::ClientError),
#[error(transparent)]
Diagnostics(#[from] codespan_reporting::files::Error),
#[error(transparent)]
Logging(#[from] logging::Error),
#[error(transparent)]
Join(#[from] tokio::task::JoinError),
#[error(transparent)]
Json(#[from] serde_json::Error),
#[cfg(feature = "mcp")]
#[error("mcp server error: {0}")]
Mcp(Box<dyn std::error::Error + Send + Sync>),
#[error("{0}")]
Message(String),
#[error("configuration errors were reported")]
DiagnosticsEmitted,
#[error("the command failed with exit code {0}")]
Exit(i32),
#[error(transparent)]
CleanupWait(#[from] micromux::CleanupWaitError),
#[error(transparent)]
Claim(#[from] micromux_control::ClaimError),
}
const EXIT_INTERRUPTED: i32 = 130;
fn spawn_shutdown_handler(shutdown: micromux::CancellationToken) {
spawn_second_interrupt_handler(shutdown.clone());
tokio::spawn(async move {
let ctrl_c = async {
let _ = tokio::signal::ctrl_c().await;
};
#[cfg(unix)]
let terminate = async {
use tokio::signal::unix::{SignalKind, signal};
match signal(SignalKind::terminate()) {
Ok(mut sigterm) => {
sigterm.recv().await;
}
Err(err) => {
tracing::warn!(?err, "failed to install SIGTERM handler");
std::future::pending::<()>().await;
}
}
};
#[cfg(not(unix))]
let terminate = std::future::pending::<()>();
#[cfg(unix)]
let hangup = async {
use tokio::signal::unix::{SignalKind, signal};
match signal(SignalKind::hangup()) {
Ok(mut sighup) => {
sighup.recv().await;
}
Err(err) => {
tracing::warn!(?err, "failed to install SIGHUP handler");
std::future::pending::<()>().await;
}
}
};
#[cfg(not(unix))]
let hangup = std::future::pending::<()>();
tokio::select! {
() = ctrl_c => {},
() = terminate => {},
() = hangup => {},
}
shutdown.cancel();
});
}
fn spawn_second_interrupt_handler(shutdown: micromux::CancellationToken) {
tokio::spawn(async move {
shutdown.cancelled().await;
if tokio::signal::ctrl_c().await.is_ok() {
micromux_tui::release_terminal();
eprintln!("exiting; service shims finish the remaining stops and cleanups");
std::process::exit(EXIT_INTERRUPTED);
}
});
}
fn services_with_cleanup(config: µmux::ConfigFile<usize>) -> Vec<micromux::ServiceID> {
config
.config
.services
.iter()
.filter(|(_, service)| service.cleanup.is_some())
.map(|(id, _)| id.as_ref().clone())
.collect()
}
fn report_pending_cleanups(
with_cleanup: &[micromux::ServiceID],
reader: µmux::SessionModelReader,
) {
let pending = with_cleanup
.iter()
.filter(|id| {
reader.service(id).is_some_and(|snapshot| {
matches!(
snapshot.execution,
micromux::Execution::Starting
| micromux::Execution::Running
| micromux::Execution::Stopping
)
})
})
.map(String::as_str)
.collect::<Vec<_>>();
if !pending.is_empty() {
eprintln!(
"stopping; waiting for the cleanup of {} (press Ctrl-C again to exit at once)",
pending.join(", ")
);
}
}
#[cfg(unix)]
fn raise_file_descriptor_limit() {
use rustix::process::{Resource, Rlimit, getrlimit, setrlimit};
const TARGET: u64 = 4096;
let current = getrlimit(Resource::Nofile);
let desired = current
.maximum
.map_or(TARGET, |maximum| TARGET.min(maximum));
if current.current.is_none_or(|limit| limit >= desired) {
return;
}
if let Err(err) = setrlimit(
Resource::Nofile,
Rlimit {
current: Some(desired),
maximum: current.maximum,
},
) {
eprintln!("warning: could not raise the open-file limit to {desired}: {err}");
}
}
#[cfg(not(unix))]
fn raise_file_descriptor_limit() {}
fn effective_log_level(options: &options::Options) -> tracing::metadata::Level {
use tracing::metadata::Level;
if let Some(level) = options.log_level {
return level;
}
let delta = i16::from(options.verbosity.verbose) - i16::from(options.verbosity.quiet);
match delta {
i16::MIN..=-1 => Level::ERROR,
0 => Level::WARN,
1 => Level::INFO,
2 => Level::DEBUG,
_ => Level::TRACE,
}
}
fn setup_logging(
options: &options::Options,
) -> Result<tracing_appender::non_blocking::WorkerGuard, Error> {
let cache_dir = micromux::cache_directory()
.ok_or_else(|| Error::Message("failed to create project directory".to_string()))?;
let log_file = match options.log_file.as_deref() {
Some(path) => logging::LogFile::LogFile { path },
None => logging::LogFile::RollingLog {
cache_dir: &cache_dir,
},
};
let guard = logging::setup(Some(effective_log_level(options)), &log_file)?;
Ok(guard)
}
struct LoadedConfig {
config: micromux::ConfigFile<usize>,
printer: DiagnosticsPrinter,
}
fn build_supervisor(
config: µmux::ConfigFile<usize>,
printer: &DiagnosticsPrinter,
session: micromux::Session,
) -> Result<micromux::Micromux, Error> {
let diagnostics = match micromux::Micromux::with_session(config, session) {
Ok(mux) => return Ok(mux),
Err(micromux::Error::Services(failure)) => failure.to_diagnostics(config.file_id),
Err(micromux::Error::Routes(failure)) => failure.to_diagnostics(config.file_id),
Err(err) => return Err(err.into()),
};
for diagnostic in diagnostics {
printer.emit(&diagnostic)?;
}
Err(Error::DiagnosticsEmitted)
}
fn shim_executable() -> Result<std::path::PathBuf, Error> {
if cfg!(target_os = "linux") {
Ok(std::path::PathBuf::from("/proc/self/exe"))
} else {
Ok(std::env::current_exe()?)
}
}
struct Supervisor {
mux: std::sync::Arc<micromux::Micromux>,
routes: Option<proxy::RouteRegistration>,
claim: Option<micromux_control::ProjectClaim>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum ClaimPolicy {
Required,
BestEffort,
}
async fn start_supervisor(
config: µmux::ConfigFile<usize>,
printer: &DiagnosticsPrinter,
shutdown: µmux::CancellationToken,
claim_policy: ClaimPolicy,
) -> Result<Supervisor, Error> {
micromux::containment::enable(shim_executable()?);
let claim = match config
.config_path
.as_deref()
.map(micromux_control::CanonicalConfigPath::new)
.transpose()?
.map(|config_path| micromux_control::claim_project(&config_path))
.transpose()
{
Ok(claim) => claim,
Err(micromux_control::ClaimError::Control(err))
if claim_policy == ClaimPolicy::BestEffort =>
{
tracing::warn!(%err, "cannot claim the config; running without its owner lock");
None
}
Err(err) => return Err(err.into()),
};
micromux::wait_for_previous_cleanups(config, shutdown, |wait| eprintln!("{wait}")).await?;
let mut session = micromux::Session::establish(config, µmux::PortRegistry::user())?;
proxy::latch_port(&mut session).await?;
let mux = build_supervisor(config, printer, session)?;
let routes = match config.config_path.as_deref() {
Some(config_path) => proxy::register(&mux, config_path, shutdown).await?,
None => None,
};
Ok(Supervisor {
mux: std::sync::Arc::new(mux),
routes,
claim,
})
}
async fn load_config(
options: &options::Options,
color_choice: termcolor::ColorChoice,
) -> Result<LoadedConfig, Error> {
let working_dir = std::env::current_dir()?;
let config_path = match options.config_path.as_ref() {
Some(config_path) => Some(config_path.clone()),
None => micromux::find_config_file(&working_dir).await?,
};
let config_path = config_path
.ok_or_else(|| Error::Message("missing config file".to_string()))?
.canonicalize()?;
let config_dir = config_path
.parent()
.ok_or_else(|| Error::Message("failed to get config file".to_string()))?;
let raw_config = micromux::read_config_file_async(&config_path).await?;
let diagnostic_printer = DiagnosticsPrinter::new(color_choice);
let file_id = diagnostic_printer.add_source_file(&config_path, raw_config.clone());
let mut diagnostics: Vec<Diagnostic<usize>> = vec![];
let config = match micromux::from_str(
&raw_config,
config_dir,
file_id,
options.strict,
&mut diagnostics,
) {
Err(err) => {
diagnostics.extend(err.to_diagnostics(file_id));
None
}
Ok(config) => Some(config),
};
let has_error = diagnostics
.iter()
.any(|d| d.severity == codespan_reporting::diagnostic::Severity::Error);
for diagnostic in diagnostics {
diagnostic_printer.emit(&diagnostic)?;
}
let Some(mut config) = config else {
return Err(Error::DiagnosticsEmitted);
};
if has_error {
return Err(Error::DiagnosticsEmitted);
}
config.config_path = Some(config_path);
Ok(LoadedConfig {
config,
printer: diagnostic_printer,
})
}
async fn run_command(command: options::Command, options: options::Options) -> Result<(), Error> {
match command {
options::Command::Attach { session } => attach::run(&options, session.as_deref()).await,
options::Command::Ctl { action } => ctl::run(action, options.config_path.as_deref()).await,
options::Command::Env {
session,
service,
export,
format,
names,
} => {
let target = session_env::Target {
session: session.as_deref(),
service: service.as_deref(),
config: options.config_path.as_deref(),
};
let format = if export {
options::EnvFormat::Shell
} else {
format.unwrap_or(options::EnvFormat::Env)
};
session_env::print(
&target,
&session_env::Output {
format,
names: &names,
},
)
.await
}
options::Command::Exec {
session,
service,
command,
} => {
let target = session_env::Target {
session: session.as_deref(),
service: service.as_deref(),
config: options.config_path.as_deref(),
};
session_env::exec(&target, &command).await
}
#[cfg(feature = "mcp")]
options::Command::Mcp {
allow_session_start,
} => {
let _log_guard = setup_logging(&options).ok();
mcp::run(allow_session_start).await
}
options::Command::Serve => run_headless(options).await,
options::Command::Proxy {
port,
fallback_port,
idle_timeout_secs,
} => {
let _log_guard = setup_logging(&options).ok();
proxy::run(
port,
fallback_port,
std::time::Duration::from_secs(idle_timeout_secs),
)
.await
}
}
}
async fn start_tui_control(
options: &options::Options,
config: µmux::ConfigFile<usize>,
handles: µmux::Handles,
claim: Option<micromux_control::ProjectClaim>,
shutdown: µmux::CancellationToken,
) -> Result<Option<String>, Error> {
if options.no_control || !config.config.control.enabled {
control::release_at_shutdown(claim, shutdown.clone());
return Ok(None);
}
let unavailable = || Some("the control plane is unavailable for this session".to_string());
let working_dir = std::env::current_dir()?;
match control::resolve_config_path(options.config_path.as_deref(), &working_dir).await {
Ok(config_path) => Ok(
match control::spawn(
handles,
claim,
&config_path,
&working_dir,
config.config.name.clone(),
shutdown.clone(),
) {
control::SpawnStatus::Started => None,
control::SpawnStatus::OwnedByOther => Some(
"another micromux already owns this project; both copies may run services"
.to_string(),
),
control::SpawnStatus::Unavailable => unavailable(),
},
),
Err(err) => {
tracing::warn!(
?err,
"control plane disabled: could not resolve config path"
);
control::release_at_shutdown(claim, shutdown.clone());
Ok(unavailable())
}
}
}
fn check_state_dir() -> Result<(), Error> {
if let Some(state) = std::env::var_os(micromux::STATE_DIR_VARIABLE)
&& !state.is_empty()
&& !std::path::Path::new(&state).is_absolute()
{
return Err(Error::Message(format!(
"{} must be an absolute path, not `{}`",
micromux::STATE_DIR_VARIABLE,
std::path::Path::new(&state).display()
)));
}
Ok(())
}
async fn run() -> Result<(), Error> {
let mut options = options::Options::parse();
check_state_dir()?;
if !matches!(options.command, Some(options::Command::Exec { .. })) {
raise_file_descriptor_limit();
}
if let Some(command) = options.command.take() {
return run_command(command, options).await;
}
let shutdown = micromux::CancellationToken::new();
spawn_shutdown_handler(shutdown.clone());
let color_choice = options.color_choice.unwrap_or(termcolor::ColorChoice::Auto);
let _log_guard = setup_logging(&options)?;
let LoadedConfig { config, printer } = load_config(&options, color_choice).await?;
let mouse = ui_settings::mouse_options(
options.ui_setting_flags(),
Some(&config.config.ui_config),
&printer,
)
.await?;
let Supervisor { mux, routes, claim } =
start_supervisor(&config, &printer, &shutdown, ClaimPolicy::BestEffort).await?;
let (runner, handles) = mux.clone().start(shutdown.clone());
if let Some(routes) = &routes {
routes.report_to(handles.session.clone(), shutdown.clone());
}
let control_warning = start_tui_control(&options, &config, &handles, claim, &shutdown).await?;
let input = handles.terminal.clone();
let mut source =
micromux_tui::LocalSource::new(handles.reader.clone(), handles.commands.clone())
.with_session(handles.session.clone());
if let Some(warning) = control_warning {
source = source.with_notice(warning);
}
let tui = micromux_tui::App::new(
micromux_tui::SessionSource::Local(source),
Some(input),
shutdown.clone(),
micromux_tui::ViewOptions {
pretty_json: config.config.ui_config.pretty_json_logs && !options.no_pretty_json_logs,
mouse,
},
);
let tui_handle = tokio::task::spawn(async move { tui.render().await });
let mux_handle = tokio::task::spawn(runner);
let mut tui_handle = tui_handle;
let mut mux_handle = mux_handle;
tokio::select! {
render_res = &mut tui_handle => {
shutdown.cancel();
report_pending_cleanups(&services_with_cleanup(&config), &handles.reader);
let mux_res = mux_handle.await;
render_res??;
mux_res??;
}
mux_res = &mut mux_handle => {
shutdown.cancel();
let tui_res = tui_handle.await;
mux_res??;
tui_res??;
}
}
Ok(())
}
async fn run_headless(options: options::Options) -> Result<(), Error> {
let shutdown = micromux::CancellationToken::new();
spawn_shutdown_handler(shutdown.clone());
let _log_guard = setup_logging(&options)?;
let color_choice = options.color_choice.unwrap_or(termcolor::ColorChoice::Auto);
let LoadedConfig { config, printer } = load_config(&options, color_choice).await?;
let Supervisor { mux, routes, claim } =
start_supervisor(&config, &printer, &shutdown, ClaimPolicy::Required).await?;
let (runner, handles) = mux.clone().start(shutdown.clone());
if let Some(routes) = &routes {
routes.report_to(handles.session.clone(), shutdown.clone());
}
let working_dir = std::env::current_dir()?;
let config_path =
control::resolve_config_path(options.config_path.as_deref(), &working_dir).await?;
let status = control::spawn(
&handles,
claim,
&config_path,
&working_dir,
config.config.name.clone(),
shutdown.clone(),
);
if !matches!(status, control::SpawnStatus::Started) {
return Err(Error::Message(
"could not start the control plane for `serve` (another micromux may already own this project)"
.to_string(),
));
}
tracing::info!(config = %config_path.display(), "micromux serve: headless supervisor running");
tokio::spawn({
let shutdown = shutdown.clone();
let reader = handles.reader.clone();
let with_cleanup = services_with_cleanup(&config);
async move {
shutdown.cancelled().await;
report_pending_cleanups(&with_cleanup, &reader);
}
});
tokio::spawn(runner).await??;
Ok(())
}
fn main() -> color_eyre::Result<()> {
let mut args = std::env::args_os();
if args.nth(1).as_deref() == Some(std::ffi::OsStr::new(micromux::containment::SHIM_COMMAND)) {
std::process::exit(micromux::containment::run_shim(args));
}
let runtime = tokio::runtime::Runtime::new()?;
let result = runtime.block_on(async_main());
runtime.shutdown_timeout(RUNTIME_SHUTDOWN_TIMEOUT);
result
}
const RUNTIME_SHUTDOWN_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(1);
async fn async_main() -> color_eyre::Result<()> {
color_eyre::install()?;
match run().await {
Ok(()) => Ok(()),
Err(Error::DiagnosticsEmitted) => std::process::exit(1),
Err(Error::CleanupWait(micromux::CleanupWaitError::Cancelled)) => {
std::process::exit(EXIT_INTERRUPTED)
}
#[cfg(unix)]
Err(Error::Proxy(micromux_proxy::ClientError::Cancelled)) => {
std::process::exit(EXIT_INTERRUPTED)
}
Err(Error::Exit(code)) => std::process::exit(code),
Err(err) => Err(err.into()),
}
}