use anyhow::{Context, Result};
use bmux_cli_schema::{LogLevel, RecordingEventKindArg, RecordingProfileArg};
use bmux_client::{BmuxClient, ClientError};
use bmux_clients_plugin_api::clients_state;
use bmux_config::{BmuxConfig, ConfigPaths};
use bmux_contexts_plugin_api::contexts_commands::{self, ContextAck as ContextAckRecord};
use bmux_diagnostic_log::{DiagnosticLogConfig, DiagnosticLogMode, DiagnosticLogWriter};
use bmux_recording_plugin_api::{ManualRecordingStartOptions, recording_commands, recording_state};
use bmux_recording_protocol::{
RecordingEventKind, RecordingRollingStartOptions, RecordingStatus, RecordingSummary,
};
use bmux_server::BmuxServer;
use bmux_sessions_plugin_api::sessions_state::{self, SessionSummary};
use std::collections::BTreeMap;
use std::path::PathBuf;
use std::process::{Command as ProcessCommand, Stdio};
use tracing::{Level, warn};
use uuid::Uuid;
const BOOTSTRAP_RECORDING_START_ENV: &str = "BMUX_BOOTSTRAP_RECORDING_START";
use super::plugin_kernel::RuntimeLoggingHandle;
use super::{
ConnectionContext, ConnectionPolicyScope, EFFECTIVE_LOG_LEVEL, LOG_CONTROL,
SERVER_START_TIMEOUT, activate_loaded_plugins, append_runtime_arg, cleanup_stale_pid_file,
connect_with_context, deactivate_loaded_plugins, dispatch_loaded_plugin_event,
load_enabled_plugins, map_client_connect_error, plugin_event_bridge_loop, plugin_system_event,
recording, register_plugin_service_handlers, remove_server_pid_file, resolve_log_level,
run_server_stop, run_session_attach_with_client, scan_available_plugins, server_is_running,
tracing_level, try_kill_pid, validate_enabled_plugins, wait_for_server_running,
write_server_pid_file, write_server_runtime_metadata,
};
#[derive(Debug, Clone)]
pub(super) struct DefaultAttachOptions {
pub(super) record: bool,
pub(super) capture_input: bool,
pub(super) profile: Option<RecordingProfileArg>,
pub(super) name: Option<String>,
pub(super) event_kinds: Vec<RecordingEventKindArg>,
pub(super) recording_id_file: Option<String>,
pub(super) stop_server_on_exit: bool,
}
#[allow(clippy::too_many_lines)] pub(super) async fn run_default_server_attach(
options: DefaultAttachOptions,
connection_context: ConnectionContext<'_>,
) -> Result<u8> {
tracing::info!(
record = options.record,
stop_server_on_exit = options.stop_server_on_exit,
"attach.bootstrap.default_start"
);
let startup_recording = options
.record
.then(|| manual_recording_start_options_from_default_attach(&options));
if options.record {
ensure_server_not_running_for_record_bootstrap(connection_context).await?;
}
ensure_server_running_for_default_attach(connection_context, startup_recording).await?;
tracing::info!("attach.bootstrap.server_ready");
let mut active_recording_id = None;
if options.record {
let started = active_startup_recording(connection_context).await?;
active_recording_id = Some(started.id);
let name_display = started.name.as_deref().unwrap_or("-");
println!(
"recording started: {} name={} (capture_input={})",
started.id, name_display, started.capture_input
);
if let Some(path) = options.recording_id_file.as_deref() {
std::fs::write(path, format!("{}\n", started.id))
.with_context(|| format!("failed writing recording id file {path}"))?;
}
}
tracing::info!("attach.bootstrap.connect_start");
let mut client = connect_with_context(
ConnectionPolicyScope::Normal,
"bmux-cli-default-attach",
connection_context,
)
.await?;
tracing::info!("attach.bootstrap.connect_done");
let target = resolve_default_attach_target(&mut client).await?;
tracing::info!(session_id = %target, "attach.bootstrap.target_resolved");
let target = target.to_string();
let attach_result =
run_session_attach_with_client(client, Some(target.as_str()), None, false, None)
.await
.map(|outcome| outcome.status_code);
if let Some(recording_id) = active_recording_id {
let mut stop_client = connect_with_context(
ConnectionPolicyScope::Normal,
"bmux-cli-default-attach-recording-stop",
connection_context,
)
.await?;
let stopped_id = recording_commands::client::stop(&mut stop_client, Some(recording_id))
.await?
.map_err(recording::recording_plugin_error)
.with_context(|| format!("failed stopping recording {recording_id}"))?;
let mut list_client = connect_with_context(
ConnectionPolicyScope::Normal,
"bmux-cli-default-attach-recording-list",
connection_context,
)
.await?;
let recording = recording_state::client::list_recordings(&mut list_client)
.await?
.into_iter()
.map(RecordingSummary::from)
.find(|summary| summary.id == stopped_id);
if let Some(recording) = recording {
let name_display = recording.name.as_deref().unwrap_or("-");
println!(
"recording stopped: {} name={} events={} bytes={} path={}",
recording.id,
name_display,
recording.event_count,
recording.payload_bytes,
recording.path
);
let recording_path = std::path::PathBuf::from(&recording.path);
recording::maybe_auto_export_recording(stopped_id, Some(&recording_path), None).await;
} else {
println!("recording stopped: {stopped_id}");
recording::maybe_auto_export_recording(stopped_id, None, None).await;
}
}
if options.record && options.stop_server_on_exit {
let _ = run_server_stop(connection_context).await;
}
attach_result
}
fn manual_recording_start_options_from_default_attach(
options: &DefaultAttachOptions,
) -> ManualRecordingStartOptions {
ManualRecordingStartOptions {
capture_input: options.capture_input,
name: options.name.clone(),
profile: recording::recording_profile_arg_to_ipc(options.profile),
event_kinds: recording::resolve_event_kind_override(
options.profile,
&options.event_kinds,
options.capture_input,
),
}
}
async fn active_startup_recording(
connection_context: ConnectionContext<'_>,
) -> Result<RecordingSummary> {
let mut recording_client = connect_with_context(
ConnectionPolicyScope::Normal,
"bmux-cli-default-attach-recording-status",
connection_context,
)
.await?;
let status: RecordingStatus = recording_state::client::status(&mut recording_client)
.await?
.into();
status
.active
.context("startup recording did not become active")
}
pub(super) async fn ensure_server_not_running_for_record_bootstrap(
connection_context: ConnectionContext<'_>,
) -> Result<()> {
if server_is_running(connection_context).await? {
anyhow::bail!(
"--record requires a fresh start but server is already running; stop it first or run without --record"
)
}
Ok(())
}
pub(super) async fn ensure_server_running_for_default_attach(
connection_context: ConnectionContext<'_>,
startup_recording: Option<ManualRecordingStartOptions>,
) -> Result<()> {
if server_is_running(connection_context).await? {
tracing::info!("attach.bootstrap.server_already_running");
return Ok(());
}
tracing::info!("attach.bootstrap.server_start_required");
let _ = run_server_start(
true,
false,
None,
RecordingRollingStartOptions::default(),
None,
startup_recording,
)
.await?;
if !server_is_running(connection_context).await? {
anyhow::bail!("bmux server failed to start for default attach")
}
tracing::info!("attach.bootstrap.server_started");
Ok(())
}
pub(super) async fn resolve_default_attach_target(client: &mut BmuxClient) -> Result<Uuid> {
let sessions = typed_list_sessions_for_bootstrap(client).await?;
if sessions.is_empty() {
let name = next_default_tab_name(&sessions);
let ack = typed_create_context_for_bootstrap(client, Some(name)).await?;
let session_id = ack.session_id.ok_or_else(|| {
anyhow::anyhow!(
"contexts-plugin create-context returned no session_id; contexts-plugin \
is required to allocate a session atomically"
)
})?;
return Ok(session_id);
}
let _client_id = clients_state::client::current_client(client)
.await?
.map_err(|err| anyhow::anyhow!("clients-state current-client failed: {err:?}"))?;
let writable_sessions = sessions.clone();
if writable_sessions.is_empty() {
let name = next_default_tab_name(&sessions);
let ack = typed_create_context_for_bootstrap(client, Some(name)).await?;
let session_id = ack.session_id.ok_or_else(|| {
anyhow::anyhow!(
"contexts-plugin create-context returned no session_id; contexts-plugin \
is required to allocate a session atomically"
)
})?;
return Ok(session_id);
}
let mut sorted = writable_sessions;
sorted.sort_by(|left, right| {
let left_key = left.name.as_deref().unwrap_or("");
let right_key = right.name.as_deref().unwrap_or("");
left_key.cmp(right_key).then_with(|| left.id.cmp(&right.id))
});
let session = sorted
.into_iter()
.next()
.expect("non-empty sessions should have first entry");
Ok(session.id)
}
async fn typed_list_sessions_for_bootstrap(client: &mut BmuxClient) -> Result<Vec<SessionSummary>> {
sessions_state::client::list_sessions(client)
.await
.context("sessions-state list-sessions dispatch failed")
}
async fn typed_create_context_for_bootstrap(
client: &mut BmuxClient,
name: Option<String>,
) -> Result<ContextAckRecord> {
contexts_commands::client::create_context(client, name, BTreeMap::new())
.await
.context("contexts-commands create-context dispatch failed")?
.map_err(|err| anyhow::anyhow!("failed to create context: {err:?}"))
}
pub(super) fn next_default_tab_name(sessions: &[SessionSummary]) -> String {
let mut next = 1_u32;
loop {
let candidate = format!("tab-{next}");
if sessions
.iter()
.all(|session| session.name.as_deref() != Some(candidate.as_str()))
{
return candidate;
}
next = next.saturating_add(1);
}
}
#[allow(clippy::too_many_lines)]
pub(super) async fn run_server_start(
daemon: bool,
foreground_internal: bool,
rolling_enabled_override: Option<bool>,
rolling_options: RecordingRollingStartOptions,
pane_shell_integration_override: Option<bool>,
startup_recording: Option<ManualRecordingStartOptions>,
) -> Result<u8> {
cleanup_stale_pid_file().await?;
let startup_recording = match startup_recording {
Some(options) => Some(options),
None => startup_recording_from_env()?,
};
if server_is_running(ConnectionContext::default()).await? {
println!("bmux server is already running");
return Ok(1);
}
let config = BmuxConfig::load()?;
if let Some(window_secs) = rolling_options.window_secs
&& window_secs == 0
{
anyhow::bail!("--rolling-window-secs must be greater than 0")
}
let base_rolling_settings = bmux_server::rolling_recording_settings_from_config(&config);
let effective_rolling_settings =
bmux_server::apply_rolling_start_options(&base_rolling_settings, &rolling_options);
let explicit_rolling_event_selection = rolling_options.event_kinds.is_some()
|| rolling_options.capture_input.is_some()
|| rolling_options.capture_output.is_some()
|| rolling_options.capture_events.is_some()
|| rolling_options.capture_protocol_replies.is_some()
|| rolling_options.capture_images.is_some();
let rolling_requested = rolling_enabled_override == Some(true)
|| rolling_options.window_secs.is_some()
|| explicit_rolling_event_selection
|| (rolling_enabled_override.is_none() && config.recording.enabled);
if rolling_requested
&& effective_rolling_settings.window_secs == 0
&& (rolling_enabled_override == Some(true)
|| rolling_options.window_secs.is_some()
|| explicit_rolling_event_selection)
{
anyhow::bail!(
"rolling recording was explicitly enabled but window is 0s; set `recording.rolling_window_secs` in config or pass `--rolling-window-secs <secs>`"
)
}
let effective_rolling_enabled = if rolling_enabled_override == Some(false) {
false
} else if rolling_options.window_secs.is_some() || explicit_rolling_event_selection {
true
} else {
rolling_enabled_override.unwrap_or(config.recording.enabled)
} && effective_rolling_settings.window_secs > 0;
if effective_rolling_enabled && effective_rolling_settings.event_kinds.is_empty() {
anyhow::bail!(
"rolling recording is enabled but no rolling event kinds are selected; enable rolling capture flags or pass --rolling-event-kind/--rolling-event-kind-all"
)
}
let paths = ConfigPaths::default();
let registry = scan_available_plugins(&config, &paths)?;
validate_enabled_plugins(&config, ®istry)?;
let _preloaded_plugins = load_enabled_plugins(&config, ®istry)?;
if daemon && !foreground_internal {
let executable =
std::env::current_exe().context("failed to resolve bmux executable path")?;
let mut child = ProcessCommand::new(executable);
append_runtime_arg(&mut child);
let log_level = EFFECTIVE_LOG_LEVEL.get().copied().unwrap_or(Level::INFO);
child
.arg("server")
.arg("start")
.arg("--foreground-internal")
.args(rolling_start_override_args(
rolling_enabled_override,
&rolling_options,
pane_shell_integration_override,
))
.env(
"BMUX_LOG_LEVEL",
match log_level {
Level::ERROR => "error",
Level::WARN => "warn",
Level::INFO => "info",
Level::DEBUG => "debug",
Level::TRACE => "trace",
},
);
if let Some(startup_recording) = startup_recording.as_ref() {
set_startup_recording_env(&mut child, startup_recording)?;
}
child
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null());
let child = child.spawn().context("failed to spawn background server")?;
write_server_pid_file(child.id())?;
write_server_runtime_metadata(child.id())?;
if !wait_for_server_running(SERVER_START_TIMEOUT, ConnectionContext::default()).await? {
let _ = try_kill_pid(child.id());
let _ = remove_server_pid_file();
anyhow::bail!("background server did not become ready before timeout")
}
println!("bmux server started in daemon mode (pid {})", child.id());
return Ok(0);
}
let loaded_plugins = load_enabled_plugins(&config, ®istry)?;
register_recording_plugin_config(
&config,
&paths,
effective_rolling_enabled,
&effective_rolling_settings,
startup_recording,
);
register_snapshot_plugin_config(&config, &paths);
register_pane_runtime_plugin_config(&config, &paths, pane_shell_integration_override);
activate_loaded_plugins(&loaded_plugins, &config, &paths)?;
dispatch_loaded_plugin_event(&loaded_plugins, &plugin_system_event("server_starting"))?;
let server = BmuxServer::from_config_paths_with_start_options(
&paths,
effective_rolling_enabled,
effective_rolling_settings.window_secs,
&effective_rolling_settings.event_kinds,
pane_shell_integration_override,
);
register_plugin_service_handlers(&server, &config, &paths, ®istry)?;
for plugin in &loaded_plugins {
for publication in &plugin.declaration.event_publications {
if !publication.forward_to_streaming_clients {
continue;
}
let kind = &publication.kind;
let spawn_result = match (kind.as_str(), publication.delivery) {
(k, bmux_plugin_sdk::PluginEventDelivery::State)
if k == bmux_scene_protocol::scene_protocol::STATE_KIND.as_str() =>
{
server.spawn_plugin_bus_state_forwarder::<
bmux_scene_protocol::scene_protocol::DecorationScene,
>(kind)
}
(k, bmux_plugin_sdk::PluginEventDelivery::Broadcast)
if k == bmux_contexts_plugin_api::contexts_events::EVENT_KIND.as_str() =>
{
server.spawn_plugin_bus_forwarder::<
bmux_contexts_plugin_api::contexts_events::ContextEvent,
>(kind)
}
(k, bmux_plugin_sdk::PluginEventDelivery::Broadcast)
if k == bmux_sessions_plugin_api::sessions_events::EVENT_KIND.as_str() =>
{
server.spawn_plugin_bus_forwarder::<
bmux_sessions_plugin_api::sessions_events::SessionEvent,
>(kind)
}
(k, bmux_plugin_sdk::PluginEventDelivery::Broadcast)
if k == bmux_clients_plugin_api::clients_events::EVENT_KIND.as_str() =>
{
server.spawn_plugin_bus_forwarder::<
bmux_clients_plugin_api::clients_events::ClientEvent,
>(kind)
}
(k, bmux_plugin_sdk::PluginEventDelivery::Broadcast)
if k == bmux_control_catalog_plugin_api::control_catalog_events::EVENT_KIND.as_str() =>
{
server.spawn_plugin_bus_forwarder::<
bmux_control_catalog_plugin_api::control_catalog_events::CatalogEvent,
>(kind)
}
(k, bmux_plugin_sdk::PluginEventDelivery::Broadcast)
if k == bmux_performance_plugin_api::EVENT_KIND.as_str() =>
{
server.spawn_plugin_bus_forwarder::<bmux_performance_plugin_api::PerformanceEvent>(kind)
}
(k, bmux_plugin_sdk::PluginEventDelivery::Broadcast)
if k == bmux_recording_plugin_api::recording_events::EVENT_KIND.as_str() =>
{
server.spawn_plugin_bus_forwarder::<
bmux_recording_plugin_api::recording_events::RecordingEvent,
>(kind)
}
(k, bmux_plugin_sdk::PluginEventDelivery::Broadcast)
if k == bmux_pane_runtime_plugin_api::pane_runtime_events::EVENT_KIND.as_str() =>
{
server.spawn_plugin_bus_forwarder::<
bmux_pane_runtime_plugin_api::pane_runtime_events::PaneEvent,
>(kind)
}
(k, bmux_plugin_sdk::PluginEventDelivery::State)
if k == bmux_windows_plugin_api::windows_list::STATE_KIND.as_str() =>
{
server.spawn_plugin_bus_state_forwarder::<
bmux_windows_plugin_api::windows_list::WindowListSnapshot,
>(kind)
}
(k, bmux_plugin_sdk::PluginEventDelivery::State)
if k == bmux_appearance::RUNTIME_APPEARANCE_STATE_KIND.as_str() =>
{
server.spawn_plugin_bus_state_forwarder::<bmux_appearance::RuntimeAppearance>(
kind,
)
}
_ => {
tracing::warn!(
plugin_id = plugin.declaration.id.as_str(),
kind = kind.as_str(),
delivery = ?publication.delivery,
"no plugin-bus forwarder specialization registered for this kind; \
streaming clients will miss emissions until one is added in bootstrap",
);
continue;
}
};
if let Err(error) = spawn_result {
tracing::warn!(
plugin_id = plugin.declaration.id.as_str(),
kind = publication.kind.as_str(),
error = %error,
"failed to spawn plugin-bus forwarder; streaming clients will miss emissions for this kind",
);
}
}
}
write_server_pid_file(std::process::id())?;
write_server_runtime_metadata(std::process::id())?;
dispatch_loaded_plugin_event(&loaded_plugins, &plugin_system_event("server_started"))?;
let run_result = if loaded_plugins.is_empty() {
server.run().await
} else {
let (plugin_bridge_shutdown_tx, plugin_bridge_shutdown_rx) =
tokio::sync::watch::channel(false);
let plugin_bridge = plugin_event_bridge_loop(&loaded_plugins, plugin_bridge_shutdown_rx);
tokio::pin!(plugin_bridge);
tokio::select! {
result = server.run() => {
let _ = plugin_bridge_shutdown_tx.send(true);
result
}
result = &mut plugin_bridge => {
let _ = plugin_bridge_shutdown_tx.send(true);
result?;
Ok(())
}
}
};
if let Err(error) =
dispatch_loaded_plugin_event(&loaded_plugins, &plugin_system_event("server_stopping"))
{
warn!("failed delivering server_stopping plugin event: {error}");
}
if let Err(error) = deactivate_loaded_plugins(&loaded_plugins, &config, &paths) {
warn!("failed deactivating plugins during server shutdown: {error}");
}
let _ = remove_server_pid_file();
run_result?;
Ok(0)
}
fn register_recording_plugin_config(
config: &BmuxConfig,
paths: &ConfigPaths,
rolling_auto_start: bool,
rolling_settings: &bmux_recording_plugin_api::RollingRecordingSettings,
startup_recording: Option<ManualRecordingStartOptions>,
) {
use bmux_recording_plugin_api::RecordingPluginConfig;
let plugin_config = RecordingPluginConfig {
recordings_dir: config.recordings_dir(paths),
rolling_recordings_dir: paths.rolling_recordings_dir(),
rolling_segment_mb: config.recording.segment_mb,
retention_days: config.recording.retention_days,
rolling_defaults: rolling_settings.clone(),
rolling_auto_start,
startup_recording,
};
let handle = std::sync::Arc::new(std::sync::RwLock::new(plugin_config));
bmux_plugin::global_plugin_state_registry().register::<RecordingPluginConfig>(&handle);
}
fn set_startup_recording_env(
command: &mut ProcessCommand,
options: &ManualRecordingStartOptions,
) -> Result<()> {
let value =
serde_json::to_string(options).context("failed to encode startup recording config")?;
command.env(BOOTSTRAP_RECORDING_START_ENV, value);
Ok(())
}
fn startup_recording_from_env() -> Result<Option<ManualRecordingStartOptions>> {
let Ok(value) = std::env::var(BOOTSTRAP_RECORDING_START_ENV) else {
return Ok(None);
};
serde_json::from_str(&value)
.map(Some)
.context("failed to decode startup recording config")
}
fn register_snapshot_plugin_config(config: &BmuxConfig, paths: &ConfigPaths) {
use bmux_snapshot_plugin_api::SnapshotPluginConfig;
if !config.behavior.restore_last_layout {
return;
}
let plugin_config = SnapshotPluginConfig {
snapshot_path: paths.data_dir.join("runtime").join("bmux-snapshot-v1.json"),
debounce_ms: 1_000,
};
let handle = std::sync::Arc::new(std::sync::RwLock::new(plugin_config));
bmux_plugin::global_plugin_state_registry().register::<SnapshotPluginConfig>(&handle);
}
fn register_pane_runtime_plugin_config(
config: &BmuxConfig,
paths: &ConfigPaths,
pane_shell_integration_override: Option<bool>,
) {
use bmux_pane_runtime_plugin_api::PaneRuntimePluginConfig;
let plugin_config = PaneRuntimePluginConfig {
shell: resolve_pane_runtime_shell(config),
pane_term: resolve_pane_runtime_term(config),
shell_integration_root: pane_shell_integration_override
.unwrap_or(config.behavior.pane_shell_integration)
.then(|| paths.state_dir().join("runtime").join("shell-integration")),
};
let handle = std::sync::Arc::new(std::sync::RwLock::new(plugin_config));
bmux_plugin::global_plugin_state_registry().register::<PaneRuntimePluginConfig>(&handle);
}
fn resolve_pane_runtime_shell(config: &BmuxConfig) -> String {
if let Some(shell) = config.general.default_shell.as_ref()
&& !shell.trim().is_empty()
{
return shell.clone();
}
if let Ok(shell) = std::env::var("SHELL")
&& !shell.trim().is_empty()
{
return shell;
}
if cfg!(windows) {
"cmd.exe".to_string()
} else {
"/bin/sh".to_string()
}
}
fn resolve_pane_runtime_term(config: &BmuxConfig) -> String {
const FALLBACKS: &[&str] = &["xterm-256color", "screen-256color"];
let configured = config.behavior.pane_term.trim();
let candidate = if configured.is_empty() {
"xterm-256color"
} else {
configured
};
if check_terminfo_available(candidate) {
return candidate.to_string();
}
for fallback in FALLBACKS {
if *fallback != candidate && check_terminfo_available(fallback) {
warn!(
"pane TERM '{}' terminfo not installed; falling back to '{}'",
candidate, fallback
);
return (*fallback).to_string();
}
}
warn!(
"pane TERM '{}' terminfo not installed and no fallback found; using as-is",
candidate
);
candidate.to_string()
}
fn check_terminfo_available(term: &str) -> bool {
std::process::Command::new("infocmp")
.arg(term)
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status()
.is_ok_and(|s| s.success())
}
fn rolling_start_override_args(
rolling_enabled_override: Option<bool>,
options: &RecordingRollingStartOptions,
pane_shell_integration_override: Option<bool>,
) -> Vec<String> {
let mut args = Vec::new();
if let Some(enabled) = rolling_enabled_override {
args.push(if enabled {
"--rolling-recording".to_string()
} else {
"--no-rolling-recording".to_string()
});
}
if let Some(window_secs) = options.window_secs {
args.push("--rolling-window-secs".to_string());
args.push(window_secs.to_string());
}
if let Some(event_kinds) = options.event_kinds.as_deref() {
for kind in event_kinds {
args.push("--rolling-event-kind".to_string());
args.push(recording_event_kind_flag_value(*kind).to_string());
}
}
push_bool_override_flag(
&mut args,
options.capture_input,
"--rolling-capture-input",
"--no-rolling-capture-input",
);
push_bool_override_flag(
&mut args,
options.capture_output,
"--rolling-capture-output",
"--no-rolling-capture-output",
);
push_bool_override_flag(
&mut args,
options.capture_events,
"--rolling-capture-events",
"--no-rolling-capture-events",
);
push_bool_override_flag(
&mut args,
options.capture_protocol_replies,
"--rolling-capture-protocol-replies",
"--no-rolling-capture-protocol-replies",
);
push_bool_override_flag(
&mut args,
options.capture_images,
"--rolling-capture-images",
"--no-rolling-capture-images",
);
push_bool_override_flag(
&mut args,
pane_shell_integration_override,
"--pane-shell-integration",
"--no-pane-shell-integration",
);
args
}
fn push_bool_override_flag(
args: &mut Vec<String>,
value: Option<bool>,
positive: &str,
negative: &str,
) {
if let Some(value) = value {
args.push(if value {
positive.to_string()
} else {
negative.to_string()
});
}
}
const fn recording_event_kind_flag_value(kind: RecordingEventKind) -> &'static str {
match kind {
RecordingEventKind::PaneInputRaw => "pane-input-raw",
RecordingEventKind::PaneOutputRaw => "pane-output-raw",
RecordingEventKind::ProtocolReplyRaw => "protocol-reply-raw",
RecordingEventKind::PaneImage => "pane-image",
RecordingEventKind::ServerEvent => "server-event",
RecordingEventKind::RequestStart => "request-start",
RecordingEventKind::RequestDone => "request-done",
RecordingEventKind::RequestError => "request-error",
RecordingEventKind::Custom => "custom",
}
}
pub(super) async fn run_session_attach(
target: Option<&str>,
follow: Option<&str>,
global: bool,
connection_context: ConnectionContext<'_>,
) -> Result<u8> {
tracing::info!(
target = target.unwrap_or(""),
follow = follow.unwrap_or(""),
global,
"attach.bootstrap.explicit_start"
);
let client = connect_with_context(
ConnectionPolicyScope::Normal,
"bmux-cli-attach",
connection_context,
)
.await?;
tracing::info!("attach.bootstrap.explicit_connected");
run_session_attach_with_client(client, target, follow, global, None)
.await
.map(|outcome| outcome.status_code)
}
pub(super) fn map_attach_client_error(error: ClientError) -> anyhow::Error {
match error {
ClientError::ServerError { code, message } => {
if matches!(code, bmux_ipc::ErrorCode::AlreadyExists) {
anyhow::anyhow!("attach failed: session already has an active attached client")
} else {
anyhow::anyhow!("attach failed: {message}")
}
}
other => map_client_connect_error(other),
}
}
pub(super) fn map_cli_client_error(error: ClientError) -> anyhow::Error {
map_client_connect_error(error)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(super) enum LogProcessScope {
Server,
Client,
Command,
}
pub(super) fn init_logging(
verbose: bool,
cli_level: Option<LogLevel>,
file_only: bool,
scope: LogProcessScope,
) -> Option<RuntimeLoggingHandle> {
let level = resolve_log_level(
verbose,
cli_level,
std::env::var("BMUX_LOG_LEVEL").ok().as_deref(),
);
let tracing_level = tracing_level(level);
let _ = EFFECTIVE_LOG_LEVEL.set(tracing_level);
let paths = resolve_logging_paths();
let runtime_level = match level {
LogLevel::Error => "error",
LogLevel::Warn => "warn",
LogLevel::Info => "info",
LogLevel::Debug => "debug",
LogLevel::Trace => "trace",
};
let config = BmuxConfig::load().unwrap_or_default();
let mode = log_mode_for_scope(&config, scope);
if mode == DiagnosticLogMode::Segmented {
match init_segmented_logging(&paths, &config, scope, runtime_level, file_only) {
Ok(handle) => {
if let RuntimeLoggingHandle::Diagnostic {
handle: control_handle,
..
} = &handle
{
let _ = LOG_CONTROL.set(control_handle.control());
}
tracing::info!(
scope = ?scope,
mode = ?mode,
file_only,
pid = std::process::id(),
"logging.initialized"
);
return Some(handle);
}
Err(error) => {
eprintln!("bmux warning: failed to initialize segmented logging: {error}");
}
}
return None;
}
let mut log_config = moosicbox_log_runtime::init::InitConfig::new(&paths);
log_config.default_env_filter = Some(format!("bmux={runtime_level}"));
log_config.sinks.file = match mode {
DiagnosticLogMode::Segmented => unreachable!("segmented mode handled above"),
DiagnosticLogMode::Unified => Some(moosicbox_log_runtime::init::FileSinkConfig {
mode: moosicbox_log_runtime::init::FileMode::Exact(unified_log_file_name(scope)),
}),
DiagnosticLogMode::Off => None,
};
if file_only {
log_config.sinks.stderr = false;
}
match moosicbox_log_runtime::init::init(log_config) {
Ok(handle) => {
tracing::info!(
scope = ?scope,
mode = ?mode,
file_only,
pid = std::process::id(),
"logging.initialized"
);
return Some(RuntimeLoggingHandle::Moosicbox { _handle: handle });
}
Err(error) => {
eprintln!("bmux warning: failed to initialize file logging: {error}");
}
}
None
}
const fn log_mode_for_scope(config: &BmuxConfig, scope: LogProcessScope) -> DiagnosticLogMode {
match scope {
LogProcessScope::Server => config_log_mode(config.logs.server.mode),
LogProcessScope::Client => config_log_mode(config.logs.client.mode),
LogProcessScope::Command => DiagnosticLogMode::Unified,
}
}
const fn config_log_mode(mode: bmux_config::DiagnosticLogModeConfig) -> DiagnosticLogMode {
match mode {
bmux_config::DiagnosticLogModeConfig::Segmented => DiagnosticLogMode::Segmented,
bmux_config::DiagnosticLogModeConfig::Unified => DiagnosticLogMode::Unified,
bmux_config::DiagnosticLogModeConfig::Off => DiagnosticLogMode::Off,
}
}
const fn unified_log_file_name(scope: LogProcessScope) -> &'static str {
match scope {
LogProcessScope::Server | LogProcessScope::Command => "bmux.log",
LogProcessScope::Client => "bmux-client.log",
}
}
fn init_segmented_logging(
paths: &moosicbox_log_runtime::LogRuntimePaths,
config: &BmuxConfig,
scope: LogProcessScope,
runtime_level: &str,
file_only: bool,
) -> anyhow::Result<RuntimeLoggingHandle> {
use tracing_subscriber::layer::SubscriberExt as _;
let (root_dir, file_prefix, segment_mb, retention_days, max_total_mb) = match scope {
LogProcessScope::Server | LogProcessScope::Command => (
paths.log_dir.join("server"),
"server",
config.logs.server.segment_mb,
config.logs.server.retention_days,
config.logs.server.max_total_mb,
),
LogProcessScope::Client => (
paths.log_dir.join("clients"),
"client",
config.logs.client.segment_mb,
config.logs.client.retention_days,
config.logs.client.max_total_mb,
),
};
let run_id = format!("{}-{}", std::process::id(), Uuid::new_v4());
let (writer, handle) = DiagnosticLogWriter::start(DiagnosticLogConfig {
root_dir,
kind: file_prefix.to_owned(),
file_prefix: file_prefix.to_owned(),
run_id,
mode: DiagnosticLogMode::Segmented,
segment_mb,
retention_days,
max_total_mb,
subject_id: None,
})?;
let (non_blocking_writer, guard) = tracing_appender::non_blocking(writer);
let env_filter = tracing_subscriber::EnvFilter::try_new(format!("bmux={runtime_level}"))?;
let file_layer = tracing_subscriber::fmt::layer()
.with_ansi(false)
.with_writer(non_blocking_writer);
let stderr_layer =
(!file_only).then(|| tracing_subscriber::fmt::layer().with_writer(std::io::stderr));
let subscriber = tracing_subscriber::registry()
.with(env_filter)
.with(file_layer)
.with(stderr_layer);
tracing::subscriber::set_global_default(subscriber)?;
let _ = tracing_log::LogTracer::init();
Ok(RuntimeLoggingHandle::Diagnostic {
_guard: guard,
handle,
})
}
fn resolve_logging_paths() -> moosicbox_log_runtime::LogRuntimePaths {
let mut paths =
moosicbox_log_runtime::resolve_paths(&moosicbox_log_runtime::LogRuntimePathsConfig {
app_name: "bmux",
state_dir_env: "BMUX_STATE_DIR",
log_dir_env: "BMUX_LOG_DIR",
});
if std::env::var_os("BMUX_LOG_DIR").is_none()
&& let Some(state_dir) = std::env::var_os("BMUX_STATE_DIR")
{
paths.log_dir = PathBuf::from(state_dir).join("logs");
}
paths
}
#[cfg(test)]
mod tests {
fn empty_cli() -> bmux_cli_schema::Cli {
bmux_cli_schema::Cli {
config: None,
record: false,
no_capture_input: false,
recording_id_file: None,
record_profile: None,
record_name: None,
record_event_kind: Vec::new(),
stop_server_on_exit: false,
recordings_dir: None,
recording_auto_export: false,
no_recording_auto_export: false,
recording_auto_export_dir: None,
target: None,
runtime: None,
core_builtins_only: false,
command: None,
verbose: false,
log_level: None,
}
}
#[allow(clippy::wildcard_imports)]
use super::*;
use crate::runtime::attach::runtime::{attach_keymap_from_config, initial_attach_status};
use crate::runtime::cli_parse::validate_record_bootstrap_flags;
use crate::runtime::session_cli::attach_quit_failure_status;
use bmux_cli_schema::Command;
use bmux_client::ClientError;
use bmux_config::BmuxConfig;
use bmux_ipc::ErrorCode;
use bmux_ipc::transport::IpcTransportError;
#[test]
fn validate_record_bootstrap_flags_accepts_plain_defaults() {
let cli = empty_cli();
assert!(validate_record_bootstrap_flags(&cli).is_ok());
}
#[test]
fn validate_record_bootstrap_flags_rejects_orphaned_record_flags() {
let mut cli = empty_cli();
cli.no_capture_input = true;
let error = validate_record_bootstrap_flags(&cli).expect_err("validation should fail");
assert!(
error
.to_string()
.contains("--no-capture-input requires --record"),
"unexpected error: {error}"
);
let mut cli = empty_cli();
cli.record_name = Some("demo".to_string());
let error = validate_record_bootstrap_flags(&cli).expect_err("validation should fail");
assert!(
error
.to_string()
.contains("--record-name requires --record"),
"unexpected error: {error}"
);
}
#[test]
fn validate_record_bootstrap_flags_rejects_record_with_subcommand() {
let mut cli = empty_cli();
cli.record = true;
cli.command = Some(Command::ListSessions { json: false });
let error = validate_record_bootstrap_flags(&cli).expect_err("validation should fail");
assert!(
error
.to_string()
.contains("--record is only supported for top-level interactive start"),
"unexpected error: {error}"
);
}
#[test]
fn default_attach_record_options_become_startup_recording() {
let options = DefaultAttachOptions {
record: true,
capture_input: true,
profile: Some(bmux_cli_schema::RecordingProfileArg::Full),
name: Some("restore-pane-repro".to_string()),
event_kinds: Vec::new(),
recording_id_file: None,
stop_server_on_exit: false,
};
let startup = manual_recording_start_options_from_default_attach(&options);
assert!(startup.capture_input);
assert_eq!(startup.name.as_deref(), Some("restore-pane-repro"));
assert_eq!(
startup.profile,
Some(bmux_recording_protocol::RecordingProfile::Full)
);
let event_kinds = startup.event_kinds.expect("full profile has event kinds");
assert!(event_kinds.contains(&RecordingEventKind::PaneInputRaw));
assert!(event_kinds.contains(&RecordingEventKind::ProtocolReplyRaw));
assert!(event_kinds.contains(&RecordingEventKind::ServerEvent));
}
#[test]
fn map_attach_client_error_formats_busy_session() {
let error = map_attach_client_error(ClientError::ServerError {
code: ErrorCode::AlreadyExists,
message: "session busy".to_string(),
});
assert!(
error
.to_string()
.contains("session already has an active attached client")
);
}
#[test]
fn map_cli_client_error_formats_transport_not_found() {
let error = map_cli_client_error(ClientError::Transport(IpcTransportError::Io(
std::io::Error::from(std::io::ErrorKind::NotFound),
)));
let message = error.to_string();
assert!(message.contains("bmux server is not running"));
assert!(message.contains("bmux server start --daemon"));
assert!(message.contains("XDG_RUNTIME_DIR"));
assert!(message.contains("TMPDIR"));
}
#[test]
fn map_cli_client_error_keeps_non_not_found_errors() {
let error = map_cli_client_error(ClientError::Transport(IpcTransportError::Io(
std::io::Error::from(std::io::ErrorKind::PermissionDenied),
)));
let message = error.to_string();
assert!(message.contains("transport error"));
assert!(!message.contains("bmux server is not running"));
}
#[test]
fn attach_quit_failure_status_is_actionable_for_policy_errors() {
let status = attach_quit_failure_status(&ClientError::ServerError {
code: ErrorCode::InvalidRequest,
message: "session policy denied for this operation".to_string(),
});
assert_eq!(status, "quit blocked by session policy");
}
#[test]
fn initial_attach_status_mentions_help_and_typing() {
let keymap = attach_keymap_from_config(&BmuxConfig::default());
let status = initial_attach_status(&keymap, "normal", true);
assert!(status.contains("help"));
assert!(status.contains("modal input enabled"));
}
}