Skip to content

Commit 82f75c0

Browse files
committed
feat: add configurable command timeout with unlimited option support
1 parent 11a7c89 commit 82f75c0

8 files changed

Lines changed: 150 additions & 10 deletions

File tree

src/cli.rs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,13 @@ pub struct Cli {
9696
)]
9797
pub strict_host_key_checking: String,
9898

99+
#[arg(
100+
long,
101+
default_value = "300",
102+
help = "Command timeout in seconds (0 for unlimited)"
103+
)]
104+
pub timeout: u64,
105+
99106
#[arg(trailing_var_arg = true, help = "Command to execute on remote hosts")]
100107
pub command_args: Vec<String>,
101108
}

src/commands/download.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -165,6 +165,7 @@ pub async fn download_file(
165165
Some(params.strict_mode),
166166
params.use_agent,
167167
params.use_password,
168+
None, // Use default timeout for ls command
168169
)
169170
.await?;
170171

src/commands/exec.rs

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ pub struct ExecuteCommandParams<'a> {
3131
pub use_agent: bool,
3232
pub use_password: bool,
3333
pub output_dir: Option<&'a Path>,
34+
pub timeout: Option<u64>,
3435
}
3536

3637
pub async fn execute_command(params: ExecuteCommandParams<'_>) -> Result<()> {
@@ -47,7 +48,8 @@ pub async fn execute_command(params: ExecuteCommandParams<'_>) -> Result<()> {
4748
params.strict_mode,
4849
params.use_agent,
4950
params.use_password,
50-
);
51+
)
52+
.with_timeout(params.timeout);
5153

5254
let results = executor.execute(params.command).await?;
5355

src/config.rs

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ pub struct Defaults {
4040
pub port: Option<u16>,
4141
pub ssh_key: Option<String>,
4242
pub parallel: Option<usize>,
43+
pub timeout: Option<u64>,
4344
}
4445

4546
#[derive(Debug, Serialize, Deserialize, Default, Clone)]
@@ -132,6 +133,7 @@ pub struct ClusterDefaults {
132133
pub user: Option<String>,
133134
pub port: Option<u16>,
134135
pub ssh_key: Option<String>,
136+
pub timeout: Option<u64>,
135137
}
136138

137139
#[derive(Debug, Serialize, Deserialize, Clone)]
@@ -362,6 +364,18 @@ impl Config {
362364
self.defaults.ssh_key.clone()
363365
}
364366

367+
pub fn get_timeout(&self, cluster_name: Option<&str>) -> Option<u64> {
368+
if let Some(cluster_name) = cluster_name {
369+
if let Some(cluster) = self.get_cluster(cluster_name) {
370+
if let Some(timeout) = cluster.defaults.timeout {
371+
return Some(timeout);
372+
}
373+
}
374+
}
375+
376+
self.defaults.timeout
377+
}
378+
365379
/// Get interactive configuration for a cluster (with fallback to global)
366380
pub fn get_interactive_config(&self, cluster_name: Option<&str>) -> InteractiveConfig {
367381
let mut config = self.interactive.clone();

src/executor.rs

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ pub struct ParallelExecutor {
3131
strict_mode: StrictHostKeyChecking,
3232
use_agent: bool,
3333
use_password: bool,
34+
timeout: Option<u64>,
3435
}
3536

3637
impl ParallelExecutor {
@@ -56,6 +57,7 @@ impl ParallelExecutor {
5657
strict_mode,
5758
use_agent: false,
5859
use_password: false,
60+
timeout: None,
5961
}
6062
}
6163

@@ -73,6 +75,7 @@ impl ParallelExecutor {
7375
strict_mode,
7476
use_agent,
7577
use_password: false,
78+
timeout: None,
7679
}
7780
}
7881

@@ -91,9 +94,15 @@ impl ParallelExecutor {
9194
strict_mode,
9295
use_agent,
9396
use_password,
97+
timeout: None,
9498
}
9599
}
96100

101+
pub fn with_timeout(mut self, timeout: Option<u64>) -> Self {
102+
self.timeout = timeout;
103+
self
104+
}
105+
97106
pub async fn execute(&self, command: &str) -> Result<Vec<ExecutionResult>> {
98107
let semaphore = Arc::new(Semaphore::new(self.max_parallel));
99108
let multi_progress = MultiProgress::new();
@@ -113,6 +122,7 @@ impl ParallelExecutor {
113122
let strict_mode = self.strict_mode;
114123
let use_agent = self.use_agent;
115124
let use_password = self.use_password;
125+
let timeout = self.timeout;
116126
let semaphore = Arc::clone(&semaphore);
117127
let pb = multi_progress.add(ProgressBar::new_spinner());
118128
pb.set_style(style.clone());
@@ -137,6 +147,7 @@ impl ParallelExecutor {
137147
strict_mode,
138148
use_agent,
139149
use_password,
150+
timeout,
140151
)
141152
.await;
142153

@@ -479,6 +490,7 @@ async fn execute_on_node(
479490
strict_mode: StrictHostKeyChecking,
480491
use_agent: bool,
481492
use_password: bool,
493+
timeout: Option<u64>,
482494
) -> Result<CommandResult> {
483495
let mut client = SshClient::new(node.host.clone(), node.port, node.username.clone());
484496

@@ -491,6 +503,7 @@ async fn execute_on_node(
491503
Some(strict_mode),
492504
use_agent,
493505
use_password,
506+
timeout,
494507
)
495508
.await
496509
}

src/main.rs

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -212,6 +212,13 @@ async fn main() -> Result<()> {
212212
}
213213
_ => {
214214
// Execute command (default or Exec subcommand)
215+
// Determine timeout: CLI argument takes precedence over config
216+
let timeout = if cli.timeout > 0 {
217+
Some(cli.timeout)
218+
} else {
219+
config.get_timeout(cli.cluster.as_deref())
220+
};
221+
215222
let params = ExecuteCommandParams {
216223
nodes,
217224
command: &command,
@@ -222,6 +229,7 @@ async fn main() -> Result<()> {
222229
use_agent: cli.use_agent,
223230
use_password: cli.password,
224231
output_dir: cli.output_dir.as_deref(),
232+
timeout,
225233
};
226234
execute_command(params).await
227235
}

src/ssh/client.rs

Lines changed: 33 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@ impl SshClient {
4040
key_path: Option<&Path>,
4141
use_agent: bool,
4242
) -> Result<CommandResult> {
43-
self.connect_and_execute_with_host_check(command, key_path, None, use_agent, false)
43+
self.connect_and_execute_with_host_check(command, key_path, None, use_agent, false, None)
4444
.await
4545
}
4646

@@ -51,6 +51,7 @@ impl SshClient {
5151
strict_mode: Option<StrictHostKeyChecking>,
5252
use_agent: bool,
5353
use_password: bool,
54+
timeout_seconds: Option<u64>,
5455
) -> Result<CommandResult> {
5556
let addr = (self.host.as_str(), self.port);
5657
tracing::debug!("Connecting to {}:{}", self.host, self.port);
@@ -79,14 +80,37 @@ impl SshClient {
7980
tracing::debug!("Executing command: {}", command);
8081

8182
// Execute command with timeout
82-
let command_timeout = Duration::from_secs(300); // 5 minutes default
83-
let result = tokio::time::timeout(
84-
command_timeout,
85-
client.execute(command)
86-
)
87-
.await
88-
.with_context(|| format!("Command execution timeout: The command '{}' did not complete within 5 minutes on {}:{}", command, self.host, self.port))?
89-
.with_context(|| format!("Failed to execute command '{}' on {}:{}. The SSH connection was successful but the command could not be executed.", command, self.host, self.port))?;
83+
let result = if let Some(timeout_secs) = timeout_seconds {
84+
if timeout_secs == 0 {
85+
// No timeout (unlimited)
86+
tracing::debug!("Executing command with no timeout (unlimited)");
87+
client.execute(command)
88+
.await
89+
.with_context(|| format!("Failed to execute command '{}' on {}:{}. The SSH connection was successful but the command could not be executed.", command, self.host, self.port))?
90+
} else {
91+
// With timeout
92+
let command_timeout = Duration::from_secs(timeout_secs);
93+
tracing::debug!("Executing command with timeout of {} seconds", timeout_secs);
94+
tokio::time::timeout(
95+
command_timeout,
96+
client.execute(command)
97+
)
98+
.await
99+
.with_context(|| format!("Command execution timeout: The command '{}' did not complete within {} seconds on {}:{}", command, timeout_secs, self.host, self.port))?
100+
.with_context(|| format!("Failed to execute command '{}' on {}:{}. The SSH connection was successful but the command could not be executed.", command, self.host, self.port))?
101+
}
102+
} else {
103+
// Default timeout of 300 seconds if not specified
104+
let command_timeout = Duration::from_secs(300);
105+
tracing::debug!("Executing command with default timeout of 300 seconds");
106+
tokio::time::timeout(
107+
command_timeout,
108+
client.execute(command)
109+
)
110+
.await
111+
.with_context(|| format!("Command execution timeout: The command '{}' did not complete within 5 minutes on {}:{}", command, self.host, self.port))?
112+
.with_context(|| format!("Failed to execute command '{}' on {}:{}. The SSH connection was successful but the command could not be executed.", command, self.host, self.port))?
113+
};
90114

91115
tracing::debug!(
92116
"Command execution completed with status: {}",

tests/timeout_test.rs

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
use bssh::config::Config;
2+
3+
#[tokio::test]
4+
async fn test_config_timeout_parsing() {
5+
let yaml = r#"
6+
defaults:
7+
timeout: 120
8+
9+
clusters:
10+
production:
11+
nodes:
12+
- host1.example.com
13+
timeout: 60
14+
15+
staging:
16+
nodes:
17+
- host2.example.com
18+
"#;
19+
20+
let config: Config = serde_yaml::from_str(yaml).unwrap();
21+
22+
// Test default timeout
23+
assert_eq!(config.defaults.timeout, Some(120));
24+
25+
// Test cluster-specific timeout
26+
assert_eq!(config.get_timeout(Some("production")), Some(60));
27+
28+
// Test cluster without timeout (falls back to default)
29+
assert_eq!(config.get_timeout(Some("staging")), Some(120));
30+
31+
// Test unknown cluster (falls back to default)
32+
assert_eq!(config.get_timeout(Some("unknown")), Some(120));
33+
34+
// Test no cluster specified (uses default)
35+
assert_eq!(config.get_timeout(None), Some(120));
36+
}
37+
38+
#[tokio::test]
39+
async fn test_config_no_timeout() {
40+
let yaml = r#"
41+
clusters:
42+
production:
43+
nodes:
44+
- host1.example.com
45+
"#;
46+
47+
let config: Config = serde_yaml::from_str(yaml).unwrap();
48+
49+
// Test no timeout configured anywhere
50+
assert_eq!(config.defaults.timeout, None);
51+
assert_eq!(config.get_timeout(Some("production")), None);
52+
}
53+
54+
#[tokio::test]
55+
async fn test_config_zero_timeout() {
56+
let yaml = r#"
57+
defaults:
58+
timeout: 0
59+
60+
clusters:
61+
production:
62+
nodes:
63+
- host1.example.com
64+
"#;
65+
66+
let config: Config = serde_yaml::from_str(yaml).unwrap();
67+
68+
// Test timeout 0 (unlimited)
69+
assert_eq!(config.defaults.timeout, Some(0));
70+
assert_eq!(config.get_timeout(Some("production")), Some(0));
71+
}

0 commit comments

Comments
 (0)