feat: configurable exec timeout per request (--timeout flag, default 30s)
This commit is contained in:
parent
1c0af1693b
commit
537ed95a3c
5 changed files with 44 additions and 17 deletions
|
|
@ -392,8 +392,7 @@ async fn handle_message(
|
||||||
ClientMessage::Ack { request_id }
|
ClientMessage::Ack { request_id }
|
||||||
}
|
}
|
||||||
|
|
||||||
ServerMessage::ExecRequest { request_id, command } => {
|
ServerMessage::ExecRequest { request_id, command, timeout_ms } => {
|
||||||
// Truncate long commands for display
|
|
||||||
let cmd_display = if command.len() > 60 {
|
let cmd_display = if command.len() > 60 {
|
||||||
format!("{}…", &command[..60])
|
format!("{}…", &command[..60])
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -401,7 +400,7 @@ async fn handle_message(
|
||||||
};
|
};
|
||||||
log_cmd!("exec › {}", cmd_display);
|
log_cmd!("exec › {}", cmd_display);
|
||||||
let mut sh = shell.lock().await;
|
let mut sh = shell.lock().await;
|
||||||
match sh.run(&command).await {
|
match sh.run(&command, timeout_ms).await {
|
||||||
Ok((stdout, stderr, exit_code)) => {
|
Ok((stdout, stderr, exit_code)) => {
|
||||||
let out = stdout.trim().lines().next().unwrap_or("").to_string();
|
let out = stdout.trim().lines().next().unwrap_or("").to_string();
|
||||||
let out_display = if out.len() > 60 {
|
let out_display = if out.len() > 60 {
|
||||||
|
|
|
||||||
|
|
@ -4,28 +4,27 @@
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
use tokio::process::Command;
|
use tokio::process::Command;
|
||||||
|
|
||||||
const TIMEOUT_MS: u64 = 30_000;
|
const DEFAULT_TIMEOUT_MS: u64 = 30_000;
|
||||||
|
|
||||||
pub struct PersistentShell;
|
pub struct PersistentShell;
|
||||||
|
|
||||||
impl PersistentShell {
|
impl PersistentShell {
|
||||||
pub fn new() -> Self { Self }
|
pub fn new() -> Self { Self }
|
||||||
|
|
||||||
pub async fn run(&mut self, command: &str) -> Result<(String, String, i32), String> {
|
pub async fn run(&mut self, command: &str, timeout_ms: Option<u64>) -> Result<(String, String, i32), String> {
|
||||||
|
let timeout_ms = timeout_ms.unwrap_or(DEFAULT_TIMEOUT_MS);
|
||||||
|
let timeout = Duration::from_millis(timeout_ms);
|
||||||
#[cfg(windows)]
|
#[cfg(windows)]
|
||||||
{
|
{
|
||||||
// -NoProfile: skip $PROFILE (prevents user's `clear` from running)
|
|
||||||
// -NonInteractive: no prompts
|
|
||||||
// -Command: run the given command string
|
|
||||||
let mut cmd = Command::new("powershell.exe");
|
let mut cmd = Command::new("powershell.exe");
|
||||||
cmd.args(["-NoProfile", "-NonInteractive", "-Command", command]);
|
cmd.args(["-NoProfile", "-NonInteractive", "-Command", command]);
|
||||||
run_captured(cmd, Duration::from_millis(TIMEOUT_MS)).await
|
run_captured(cmd, timeout).await
|
||||||
}
|
}
|
||||||
#[cfg(not(windows))]
|
#[cfg(not(windows))]
|
||||||
{
|
{
|
||||||
let mut cmd = Command::new("sh");
|
let mut cmd = Command::new("sh");
|
||||||
cmd.args(["-c", command]);
|
cmd.args(["-c", command]);
|
||||||
run_captured(cmd, Duration::from_millis(TIMEOUT_MS)).await
|
run_captured(cmd, timeout).await
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -27,6 +27,8 @@ pub enum ServerMessage {
|
||||||
ExecRequest {
|
ExecRequest {
|
||||||
request_id: Uuid,
|
request_id: Uuid,
|
||||||
command: String,
|
command: String,
|
||||||
|
/// Timeout in milliseconds. None = use client default (30s)
|
||||||
|
timeout_ms: Option<u64>,
|
||||||
},
|
},
|
||||||
/// Simulate a mouse click
|
/// Simulate a mouse click
|
||||||
ClickRequest {
|
ClickRequest {
|
||||||
|
|
|
||||||
|
|
@ -60,6 +60,19 @@ async fn dispatch<F>(
|
||||||
op: &str,
|
op: &str,
|
||||||
make_msg: F,
|
make_msg: F,
|
||||||
) -> Result<ClientMessage, (StatusCode, Json<ErrorBody>)>
|
) -> Result<ClientMessage, (StatusCode, Json<ErrorBody>)>
|
||||||
|
where
|
||||||
|
F: FnOnce(Uuid) -> ServerMessage,
|
||||||
|
{
|
||||||
|
dispatch_with_timeout(state, session_id, op, make_msg, REQUEST_TIMEOUT).await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn dispatch_with_timeout<F>(
|
||||||
|
state: &AppState,
|
||||||
|
session_id: &str,
|
||||||
|
op: &str,
|
||||||
|
make_msg: F,
|
||||||
|
timeout: Duration,
|
||||||
|
) -> Result<ClientMessage, (StatusCode, Json<ErrorBody>)>
|
||||||
where
|
where
|
||||||
F: FnOnce(Uuid) -> ServerMessage,
|
F: FnOnce(Uuid) -> ServerMessage,
|
||||||
{
|
{
|
||||||
|
|
@ -86,7 +99,7 @@ where
|
||||||
send_error(session_id, op)
|
send_error(session_id, op)
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
match tokio::time::timeout(REQUEST_TIMEOUT, rx).await {
|
match tokio::time::timeout(timeout, rx).await {
|
||||||
Ok(Ok(response)) => Ok(response),
|
Ok(Ok(response)) => Ok(response),
|
||||||
Ok(Err(_)) => Err(send_error(session_id, op)),
|
Ok(Err(_)) => Err(send_error(session_id, op)),
|
||||||
Err(_) => Err(timeout_error(session_id, op)),
|
Err(_) => Err(timeout_error(session_id, op)),
|
||||||
|
|
@ -143,6 +156,9 @@ pub async fn request_screenshot(
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
pub struct ExecBody {
|
pub struct ExecBody {
|
||||||
pub command: String,
|
pub command: String,
|
||||||
|
/// Optional timeout in milliseconds (default: 30000). Use higher values for
|
||||||
|
/// long-running commands like downloads.
|
||||||
|
pub timeout_ms: Option<u64>,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn request_exec(
|
pub async fn request_exec(
|
||||||
|
|
@ -150,10 +166,16 @@ pub async fn request_exec(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
Json(body): Json<ExecBody>,
|
Json(body): Json<ExecBody>,
|
||||||
) -> impl IntoResponse {
|
) -> impl IntoResponse {
|
||||||
match dispatch(&state, &session_id, "exec", |rid| ServerMessage::ExecRequest {
|
// Server-side wait must be at least as long as the client timeout + buffer
|
||||||
|
let server_timeout = body.timeout_ms
|
||||||
|
.map(|ms| std::time::Duration::from_millis(ms + 5_000))
|
||||||
|
.unwrap_or(REQUEST_TIMEOUT);
|
||||||
|
|
||||||
|
match dispatch_with_timeout(&state, &session_id, "exec", |rid| ServerMessage::ExecRequest {
|
||||||
request_id: rid,
|
request_id: rid,
|
||||||
command: body.command.clone(),
|
command: body.command.clone(),
|
||||||
})
|
timeout_ms: body.timeout_ms,
|
||||||
|
}, server_timeout)
|
||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
Ok(ClientMessage::ExecResponse {
|
Ok(ClientMessage::ExecResponse {
|
||||||
|
|
|
||||||
|
|
@ -38,14 +38,14 @@ def _headers() -> dict:
|
||||||
return {"X-Api-Key": API_KEY, "Content-Type": "application/json"}
|
return {"X-Api-Key": API_KEY, "Content-Type": "application/json"}
|
||||||
|
|
||||||
|
|
||||||
def _req(method: str, path: str, **kwargs):
|
def _req(method: str, path: str, timeout: int = 30, **kwargs):
|
||||||
url = f"{BASE_URL}{path}"
|
url = f"{BASE_URL}{path}"
|
||||||
try:
|
try:
|
||||||
resp = requests.request(method, url, headers=_headers(), timeout=30, **kwargs)
|
resp = requests.request(method, url, headers=_headers(), timeout=timeout, **kwargs)
|
||||||
except requests.exceptions.ConnectionError as exc:
|
except requests.exceptions.ConnectionError as exc:
|
||||||
sys.exit(f"[helios-remote] CONNECTION ERROR: Cannot reach {url}\n → {exc}")
|
sys.exit(f"[helios-remote] CONNECTION ERROR: Cannot reach {url}\n → {exc}")
|
||||||
except requests.exceptions.Timeout:
|
except requests.exceptions.Timeout:
|
||||||
sys.exit(f"[helios-remote] TIMEOUT: {url} did not respond within 30 s")
|
sys.exit(f"[helios-remote] TIMEOUT: {url} did not respond within {timeout} s")
|
||||||
|
|
||||||
if not resp.ok:
|
if not resp.ok:
|
||||||
body = resp.text[:1000] if resp.text else "(empty body)"
|
body = resp.text[:1000] if resp.text else "(empty body)"
|
||||||
|
|
@ -141,7 +141,10 @@ def cmd_exec(args):
|
||||||
"""Run a shell command on the remote session."""
|
"""Run a shell command on the remote session."""
|
||||||
sid = resolve_session(args.session_id)
|
sid = resolve_session(args.session_id)
|
||||||
command = " ".join(args.parts) if isinstance(args.parts, list) else args.parts
|
command = " ".join(args.parts) if isinstance(args.parts, list) else args.parts
|
||||||
resp = _req("POST", f"/sessions/{sid}/exec", json={"command": command})
|
body = {"command": command}
|
||||||
|
if args.timeout:
|
||||||
|
body["timeout_ms"] = args.timeout * 1000 # seconds → ms
|
||||||
|
resp = _req("POST", f"/sessions/{sid}/exec", json=body, timeout=max(35, (args.timeout or 30) + 5))
|
||||||
data = resp.json()
|
data = resp.json()
|
||||||
|
|
||||||
stdout = data.get("stdout") or data.get("output") or ""
|
stdout = data.get("stdout") or data.get("output") or ""
|
||||||
|
|
@ -343,6 +346,8 @@ def build_parser() -> argparse.ArgumentParser:
|
||||||
ep.add_argument("session_id")
|
ep.add_argument("session_id")
|
||||||
ep.add_argument("parts", nargs=argparse.REMAINDER, metavar="command",
|
ep.add_argument("parts", nargs=argparse.REMAINDER, metavar="command",
|
||||||
help="Command (and arguments) to execute")
|
help="Command (and arguments) to execute")
|
||||||
|
ep.add_argument("--timeout", type=int, default=None, metavar="SECONDS",
|
||||||
|
help="Timeout in seconds (default: 30). Use higher for long downloads etc.")
|
||||||
|
|
||||||
cp = sub.add_parser("click", help="Send a mouse click")
|
cp = sub.add_parser("click", help="Send a mouse click")
|
||||||
cp.add_argument("session_id")
|
cp.add_argument("session_id")
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue