From 28eb7407aa2c511952acf5a94300c43798126a81 Mon Sep 17 00:00:00 2001 From: Teng Lin Date: Sat, 27 Jun 2026 08:33:29 -0400 Subject: [PATCH 1/2] fix: load tmux config from symlink and honor set -q Two bugs caused rmux to silently ignore a user's tmux config (e.g. oh-my-tmux), both required to reproduce: 1. `set -q` was parsed but discarded, so options removed in newer tmux (`set -q -g status-utf8 on`, `setw -q -g utf8 on`) raised "invalid option" errors. A single config error discards the whole file, so the entire config was dropped. Capture `-q` and suppress unknown/ambiguous option errors (returning a no-op), matching tmux semantics. 2. The tmux-fallback reader rejected all symlinks via symlink_metadata + O_NOFOLLOW, silently skipping the symlinked ~/.config/tmux/tmux.conf that oh-my-tmux installs (the error is swallowed on the best-effort path). Follow symlinks but stat the target so FIFOs/dirs/devices are still skipped; keep O_NONBLOCK as a second guard against blocking. Add a regression test that the best-effort tmux fallback follows a symlink to a regular file. --- .../src/handler_scripting/config_parse.rs | 18 ++++++-- .../src/handler_scripting/source_files.rs | 44 +++++++++++++------ 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/crates/rmux-server/src/handler_scripting/config_parse.rs b/crates/rmux-server/src/handler_scripting/config_parse.rs index cfcf4821..2500d553 100644 --- a/crates/rmux-server/src/handler_scripting/config_parse.rs +++ b/crates/rmux-server/src/handler_scripting/config_parse.rs @@ -72,6 +72,7 @@ pub(super) fn parse_set_option_invocation( } "-q" => { let _ = args.optional(); + flags.quiet = true; } "-w" if force_window => { let _ = args.optional(); @@ -123,7 +124,7 @@ pub(super) fn parse_set_option_invocation( args.no_extra("set-option")?; let effective_target = target.clone().or(default_target.clone()); - let scope = resolve_set_option_scope( + let scope = match resolve_set_option_scope( &option, flags.global, flags.server, @@ -131,7 +132,16 @@ pub(super) fn parse_set_option_invocation( flags.pane, flags.append, effective_target.clone(), - )?; + ) { + Ok(scope) => scope, + // tmux's `set -q` suppresses errors about unknown/ambiguous options, which configs + // like oh-my-tmux rely on to silently skip options removed in newer tmux + // (e.g. `set -q -g status-utf8 on`). Drop the command instead of aborting the file. + Err(error) if flags.quiet && show_options_quiet_suppresses(&error) => { + return Ok(ParsedSetOptionCommand::NoOp); + } + Err(error) => return Err(error), + }; let Some(scope) = scope.into_scope() else { return Ok(ParsedSetOptionCommand::NoOp); }; @@ -184,6 +194,7 @@ struct SetOptionFlags { only_if_unset: bool, unset: bool, unset_pane_overrides: bool, + quiet: bool, } impl SetOptionFlags { @@ -198,6 +209,7 @@ impl SetOptionFlags { only_if_unset: false, unset: false, unset_pane_overrides: false, + quiet: false, } } @@ -215,7 +227,7 @@ impl SetOptionFlags { 's' => self.server = true, 'w' => self.window = true, 'p' => self.pane = true, - 'q' => {} + 'q' => self.quiet = true, 'a' => self.append = true, 'F' => self.format = true, 'o' => self.only_if_unset = true, diff --git a/crates/rmux-server/src/handler_scripting/source_files.rs b/crates/rmux-server/src/handler_scripting/source_files.rs index 1eae0753..f8d88ef1 100644 --- a/crates/rmux-server/src/handler_scripting/source_files.rs +++ b/crates/rmux-server/src/handler_scripting/source_files.rs @@ -398,8 +398,11 @@ fn open_strict_source_entry(entry: &Path) -> io::Result { } fn read_tmux_compat_source_entry(entry: &Path) -> io::Result { - let preopen_metadata = fs::symlink_metadata(entry)?; - validate_tmux_compat_preopen_metadata(&preopen_metadata)?; + // Follow symlinks (the popular oh-my-tmux setup symlinks ~/.config/tmux/tmux.conf), + // but stat the final target so we still skip FIFOs/dirs/devices. stat() never blocks + // on a FIFO, and the open below keeps O_NONBLOCK as a second line of defense. + let preopen_metadata = fs::metadata(entry)?; + validate_tmux_compat_regular_metadata(&preopen_metadata)?; let file = open_tmux_compat_regular_file(entry)?; let metadata = file.metadata()?; @@ -414,16 +417,6 @@ fn read_tmux_compat_source_entry(entry: &Path) -> io::Result { Ok(contents) } -fn validate_tmux_compat_preopen_metadata(metadata: &fs::Metadata) -> io::Result<()> { - if metadata.file_type().is_symlink() { - return Err(io::Error::new( - io::ErrorKind::InvalidInput, - "tmux fallback config is not a regular file", - )); - } - validate_tmux_compat_regular_metadata(metadata) -} - fn validate_tmux_compat_regular_metadata(metadata: &fs::Metadata) -> io::Result<()> { if !metadata.file_type().is_file() { return Err(io::Error::new( @@ -443,7 +436,7 @@ fn open_tmux_compat_regular_file(entry: &Path) -> io::Result { let fd = open( entry, - OFlags::RDONLY | OFlags::CLOEXEC | OFlags::NOFOLLOW | OFlags::NONBLOCK, + OFlags::RDONLY | OFlags::CLOEXEC | OFlags::NONBLOCK, Mode::empty(), ) .map_err(io::Error::from)?; @@ -782,6 +775,31 @@ mod tests { assert!(inputs.is_empty()); } + #[cfg(unix)] + #[test] + fn tmux_best_effort_source_follows_symlink_to_regular_file() { + // oh-my-tmux symlinks ~/.config/tmux/tmux.conf to its bundled config; the + // fallback must follow that symlink rather than silently skip it. + let target_path = temp_source_path("symlink-target-regular-tmux-fallback"); + let symlink_path = temp_source_path("symlink-regular-tmux-fallback"); + std::fs::write(&target_path, "set -g base-index 1\n").expect("write source target"); + std::os::unix::fs::symlink(&target_path, &symlink_path).expect("create source symlink"); + + let inputs = source_inputs_for_path( + &symlink_path.to_string_lossy(), + None, + false, + None, + SourceReadPolicy::BestEffort, + ) + .expect("best-effort tmux source should follow symlink to regular file"); + let _ = std::fs::remove_file(&symlink_path); + let _ = std::fs::remove_file(&target_path); + + assert_eq!(inputs.len(), 1); + assert_eq!(inputs[0].contents, "set -g base-index 1\n"); + } + #[test] fn loaded_source_file_tracks_errors_for_fallback_gating() { let mut loaded = LoadedSourceFile::default(); From d1ca2b945c067f7684734c424d81685a69de04d4 Mon Sep 17 00:00:00 2001 From: Teng Lin Date: Sat, 27 Jun 2026 09:16:13 -0400 Subject: [PATCH 2/2] fix: don't let a slow config run-shell freeze daemon startup Config `run-shell` commands run synchronously during startup config load, and the config-loading guard they hold gates client readiness. A command that blocks therefore makes `rmux` startup hang until the client's 20s deadline expires. oh-my-tmux triggers this: it shells out to the tmux binary against rmux's socket (TMUX_PROGRAM detection / _apply_configuration), which stalls ~20s. Release the readiness guard after a short budget (2s) while the config keeps executing in the background, so a slow command no longer blocks startup. Normal configs finish far inside the budget and are never delayed; only a pathologically slow config releases readiness early. Execution is not cancelled, so the commands still complete and nothing is orphaned. Add a regression test that readiness clears before a blocking run-shell finishes (with a short test-only budget). --- .../src/handler_scripting/source_runtime.rs | 95 +++++++++++++++++-- 1 file changed, 85 insertions(+), 10 deletions(-) diff --git a/crates/rmux-server/src/handler_scripting/source_runtime.rs b/crates/rmux-server/src/handler_scripting/source_runtime.rs index cf843d88..b791b232 100644 --- a/crates/rmux-server/src/handler_scripting/source_runtime.rs +++ b/crates/rmux-server/src/handler_scripting/source_runtime.rs @@ -1,4 +1,5 @@ use std::path::Path; +use std::time::Duration; use rmux_core::{ command_parser::{CommandParser, ParsedCommand, ParsedCommands, SOURCE_FILE_MAX_COMMAND_BYTES}, @@ -35,6 +36,21 @@ use crate::{ConfigFileSelection, ConfigLoadOptions}; const SOURCE_PARSE_RECOVERY_ERROR_LIMIT: usize = 256; +/// How long startup config execution may hold up client readiness before the +/// daemon reports itself ready anyway. A config's `run-shell` commands run +/// synchronously and can block (e.g. oh-my-tmux shells out to the `tmux` binary +/// against rmux's socket and stalls for ~20s); without this bound a single slow +/// command makes `rmux` startup appear to hang. Normal configs finish far inside +/// this window, so they are never delayed; only a pathologically slow config +/// releases readiness early and keeps executing in the background. +// ponytail: drop-guard-early flips config_loading_active() for the remaining +// (already-slow) config commands; move to a separate readiness signal if that +// ever matters for a real config. +#[cfg(not(test))] +const STARTUP_READINESS_BUDGET: Duration = Duration::from_secs(2); +#[cfg(test)] +const STARTUP_READINESS_BUDGET: Duration = Duration::from_millis(100); + impl RequestHandler { #[cfg(test)] pub(crate) async fn load_startup_config(&self, config_load: ConfigLoadOptions) { @@ -46,7 +62,7 @@ impl RequestHandler { pub(crate) async fn load_startup_config_with_guard( &self, config_load: ConfigLoadOptions, - _guard: ConfigLoadingGuard, + guard: ConfigLoadingGuard, ) { let (paths, tmux_fallback_paths) = match config_load.selection() { ConfigFileSelection::Disabled => return, @@ -102,14 +118,27 @@ impl RequestHandler { if let Some(error) = loaded.take_error() { errors.push(error); } - let execution = self - .execute_loaded_source_file( - std::process::id(), - loaded, - QueueExecutionContext::new(command.caller_cwd.clone()), - 1, - ) - .await; + // Executing the config runs its `run-shell` commands synchronously, which + // can block. Release the readiness guard after a short budget so clients + // stop waiting, but keep executing in the background so the commands still + // complete (and aren't cancelled/orphaned). See STARTUP_READINESS_BUDGET. + let exec = self.execute_loaded_source_file( + std::process::id(), + loaded, + QueueExecutionContext::new(command.caller_cwd.clone()), + 1, + ); + tokio::pin!(exec); + let mut guard = Some(guard); + let execution = tokio::select! { + biased; + execution = &mut exec => execution, + _ = tokio::time::sleep(STARTUP_READINESS_BUDGET) => { + drop(guard.take()); + exec.await + } + }; + drop(guard); if let Some(error) = execution.error { errors.push(error); } @@ -992,7 +1021,7 @@ fn config_error_lines(error: &RmuxError) -> Vec { mod tests { use std::fs; use std::path::{Path, PathBuf}; - use std::time::{SystemTime, UNIX_EPOCH}; + use std::time::{Duration, SystemTime, UNIX_EPOCH}; use super::super::super::RequestHandler; use crate::test_env::EnvVarGuard; @@ -1019,6 +1048,52 @@ mod tests { ); } + #[tokio::test] + async fn startup_readiness_clears_before_a_blocking_run_shell_finishes() { + let _lock = crate::test_env::lock_async().await; + let root = unique_temp_root("slow-run-shell-readiness"); + let config_path = root.join("slow.conf"); + // A run-shell that blocks far longer than STARTUP_READINESS_BUDGET, mimicking + // an oh-my-tmux config that shells out and stalls. + write_test_config(config_path.clone(), "run-shell 'sleep 3'\n"); + let handler = RequestHandler::new(); + let config = DaemonConfig::new(root.join("rmux.sock")).with_config_files( + vec![config_path], + false, + Some(root.clone()), + ); + + let guard = handler.start_config_loading(); + assert!(handler.config_loading_active()); + + let load_handler = handler.clone(); + let load_config = config.config_load().clone(); + let task = tokio::spawn(async move { + load_handler + .load_startup_config_with_guard(load_config, guard) + .await; + }); + + let cleared = async { + while handler.config_loading_active() { + tokio::time::sleep(Duration::from_millis(10)).await; + } + }; + tokio::time::timeout(Duration::from_secs(1), cleared) + .await + .expect("readiness should clear within the budget, not wait out the run-shell"); + + // The budget released readiness while execution continued — the load task is + // still running the slow command, proving this exercised the budget path. + assert!( + !task.is_finished(), + "the blocking run-shell should still be executing after readiness cleared" + ); + + task.abort(); + let _ = fs::remove_dir_all(root); + } + #[tokio::test] async fn tmux_fallback_is_not_used_after_rmux_config_load_error() { let _lock = crate::test_env::lock_async().await;