Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 15 additions & 3 deletions crates/rmux-server/src/handler_scripting/config_parse.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -123,15 +124,24 @@ 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,
flags.window,
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);
};
Expand Down Expand Up @@ -184,6 +194,7 @@ struct SetOptionFlags {
only_if_unset: bool,
unset: bool,
unset_pane_overrides: bool,
quiet: bool,
}

impl SetOptionFlags {
Expand All @@ -198,6 +209,7 @@ impl SetOptionFlags {
only_if_unset: false,
unset: false,
unset_pane_overrides: false,
quiet: false,
}
}

Expand All @@ -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,
Expand Down
44 changes: 31 additions & 13 deletions crates/rmux-server/src/handler_scripting/source_files.rs
Original file line number Diff line number Diff line change
Expand Up @@ -398,8 +398,11 @@ fn open_strict_source_entry(entry: &Path) -> io::Result<File> {
}

fn read_tmux_compat_source_entry(entry: &Path) -> io::Result<String> {
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()?;
Expand All @@ -414,16 +417,6 @@ fn read_tmux_compat_source_entry(entry: &Path) -> io::Result<String> {
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(
Expand All @@ -443,7 +436,7 @@ fn open_tmux_compat_regular_file(entry: &Path) -> io::Result<File> {

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)?;
Expand Down Expand Up @@ -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();
Expand Down
95 changes: 85 additions & 10 deletions crates/rmux-server/src/handler_scripting/source_runtime.rs
Original file line number Diff line number Diff line change
@@ -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},
Expand Down Expand Up @@ -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) {
Expand All @@ -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,
Expand Down Expand Up @@ -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);
}
Expand Down Expand Up @@ -992,7 +1021,7 @@ fn config_error_lines(error: &RmuxError) -> Vec<String> {
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;
Expand All @@ -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;
Expand Down