mirror of
https://github.com/samsonjs/vibetunnel.git
synced 2026-04-27 15:17:38 +00:00
Add resize feature to Rust server
This commit is contained in:
parent
720c81c704
commit
978205da76
5 changed files with 377 additions and 20 deletions
|
|
@ -79,6 +79,12 @@ struct InputRequest {
|
||||||
text: String,
|
text: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct ResizeRequest {
|
||||||
|
cols: u16,
|
||||||
|
rows: u16,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
struct MkdirRequest {
|
struct MkdirRequest {
|
||||||
path: String,
|
path: String,
|
||||||
|
|
@ -349,6 +355,11 @@ pub fn start_server(
|
||||||
{
|
{
|
||||||
handle_session_input(&control_path, path, &req)
|
handle_session_input(&control_path, path, &req)
|
||||||
}
|
}
|
||||||
|
(&Method::POST, path)
|
||||||
|
if path.starts_with("/api/sessions/") && path.ends_with("/resize") =>
|
||||||
|
{
|
||||||
|
handle_session_resize(&control_path, path, &req)
|
||||||
|
}
|
||||||
(&Method::DELETE, path)
|
(&Method::DELETE, path)
|
||||||
if path.starts_with("/api/sessions/") && path.ends_with("/cleanup") =>
|
if path.starts_with("/api/sessions/") && path.ends_with("/cleanup") =>
|
||||||
{
|
{
|
||||||
|
|
@ -588,11 +599,13 @@ fn handle_create_session(
|
||||||
let session_info_path = session_path.join("session.json");
|
let session_info_path = session_path.join("session.json");
|
||||||
let stream_out_path = session_path.join("stream-out");
|
let stream_out_path = session_path.join("stream-out");
|
||||||
let stdin_path = session_path.join("stdin");
|
let stdin_path = session_path.join("stdin");
|
||||||
|
let control_path = session_path.join("control");
|
||||||
let notification_stream_path = session_path.join("notification-stream");
|
let notification_stream_path = session_path.join("notification-stream");
|
||||||
|
|
||||||
if let Err(e) = tty_spawn
|
if let Err(e) = tty_spawn
|
||||||
.stdout_path(&stream_out_path, true)
|
.stdout_path(&stream_out_path, true)
|
||||||
.and_then(|spawn| spawn.stdin_path(&stdin_path))
|
.and_then(|spawn| spawn.stdin_path(&stdin_path))
|
||||||
|
.and_then(|spawn| spawn.control_path(&control_path))
|
||||||
{
|
{
|
||||||
eprintln!("Failed to set up TTY paths for session {session_id_clone}: {e}");
|
eprintln!("Failed to set up TTY paths for session {session_id_clone}: {e}");
|
||||||
return;
|
return;
|
||||||
|
|
@ -971,6 +984,103 @@ fn handle_session_input(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn handle_session_resize(
|
||||||
|
control_path: &Path,
|
||||||
|
path: &str,
|
||||||
|
req: &crate::http_server::HttpRequest,
|
||||||
|
) -> Response<String> {
|
||||||
|
if let Some(session_id) = extract_session_id(path) {
|
||||||
|
let body_bytes = req.body();
|
||||||
|
let body = String::from_utf8_lossy(body_bytes);
|
||||||
|
|
||||||
|
if let Ok(resize_req) = serde_json::from_str::<ResizeRequest>(&body) {
|
||||||
|
// Validate dimensions
|
||||||
|
if resize_req.cols == 0 || resize_req.rows == 0 {
|
||||||
|
let error = ApiResponse {
|
||||||
|
success: None,
|
||||||
|
message: None,
|
||||||
|
error: Some("Invalid dimensions: cols and rows must be greater than 0".to_string()),
|
||||||
|
session_id: None,
|
||||||
|
};
|
||||||
|
return json_response(StatusCode::BAD_REQUEST, &error);
|
||||||
|
}
|
||||||
|
|
||||||
|
// First validate session exists and is running
|
||||||
|
match sessions::list_sessions(control_path) {
|
||||||
|
Ok(sessions) => {
|
||||||
|
if let Some(session_entry) = sessions.get(&session_id) {
|
||||||
|
// Check if session is running
|
||||||
|
if session_entry.session_info.status != "running" {
|
||||||
|
let error = ApiResponse {
|
||||||
|
success: None,
|
||||||
|
message: None,
|
||||||
|
error: Some("Session is not running".to_string()),
|
||||||
|
session_id: None,
|
||||||
|
};
|
||||||
|
return json_response(StatusCode::BAD_REQUEST, &error);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Perform the resize
|
||||||
|
match sessions::resize_session(control_path, &session_id, resize_req.cols, resize_req.rows) {
|
||||||
|
Ok(()) => {
|
||||||
|
let response = ApiResponse {
|
||||||
|
success: Some(true),
|
||||||
|
message: Some(format!("Session resized to {}x{}", resize_req.cols, resize_req.rows)),
|
||||||
|
error: None,
|
||||||
|
session_id: None,
|
||||||
|
};
|
||||||
|
json_response(StatusCode::OK, &response)
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
let error = ApiResponse {
|
||||||
|
success: None,
|
||||||
|
message: None,
|
||||||
|
error: Some(format!("Failed to resize session: {e}")),
|
||||||
|
session_id: None,
|
||||||
|
};
|
||||||
|
json_response(StatusCode::INTERNAL_SERVER_ERROR, &error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
let error = ApiResponse {
|
||||||
|
success: None,
|
||||||
|
message: None,
|
||||||
|
error: Some("Session not found".to_string()),
|
||||||
|
session_id: None,
|
||||||
|
};
|
||||||
|
json_response(StatusCode::NOT_FOUND, &error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
let error = ApiResponse {
|
||||||
|
success: None,
|
||||||
|
message: None,
|
||||||
|
error: Some(format!("Failed to list sessions: {e}")),
|
||||||
|
session_id: None,
|
||||||
|
};
|
||||||
|
json_response(StatusCode::INTERNAL_SERVER_ERROR, &error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
let error = ApiResponse {
|
||||||
|
success: None,
|
||||||
|
message: None,
|
||||||
|
error: Some("Invalid request body. Expected JSON with 'cols' and 'rows' fields".to_string()),
|
||||||
|
session_id: None,
|
||||||
|
};
|
||||||
|
json_response(StatusCode::BAD_REQUEST, &error)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
let error = ApiResponse {
|
||||||
|
success: None,
|
||||||
|
message: None,
|
||||||
|
error: Some("Invalid session ID".to_string()),
|
||||||
|
session_id: None,
|
||||||
|
};
|
||||||
|
json_response(StatusCode::BAD_REQUEST, &error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn handle_session_kill(control_path: &Path, path: &str) -> Response<String> {
|
fn handle_session_kill(control_path: &Path, path: &str) -> Response<String> {
|
||||||
let session_id = if let Some(id) = extract_session_id(path) {
|
let session_id = if let Some(id) = extract_session_id(path) {
|
||||||
id
|
id
|
||||||
|
|
@ -1976,6 +2086,20 @@ mod tests {
|
||||||
assert_eq!(request.text, "arrow_up");
|
assert_eq!(request.text, "arrow_up");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_resize_request_deserialization() {
|
||||||
|
let json = r#"{"cols":120,"rows":40}"#;
|
||||||
|
let request: ResizeRequest = serde_json::from_str(json).unwrap();
|
||||||
|
assert_eq!(request.cols, 120);
|
||||||
|
assert_eq!(request.rows, 40);
|
||||||
|
|
||||||
|
// Test with zero values (should be rejected by handler)
|
||||||
|
let json = r#"{"cols":0,"rows":0}"#;
|
||||||
|
let request: ResizeRequest = serde_json::from_str(json).unwrap();
|
||||||
|
assert_eq!(request.cols, 0);
|
||||||
|
assert_eq!(request.rows, 0);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_mkdir_request_deserialization() {
|
fn test_mkdir_request_deserialization() {
|
||||||
let json = r#"{"path":"/tmp/test"}"#;
|
let json = r#"{"path":"/tmp/test"}"#;
|
||||||
|
|
@ -2100,6 +2224,8 @@ mod tests {
|
||||||
started_at: Some(jiff::Timestamp::now()),
|
started_at: Some(jiff::Timestamp::now()),
|
||||||
term: "xterm".to_string(),
|
term: "xterm".to_string(),
|
||||||
spawn_type: "pty".to_string(),
|
spawn_type: "pty".to_string(),
|
||||||
|
cols: None,
|
||||||
|
rows: None,
|
||||||
};
|
};
|
||||||
|
|
||||||
fs::write(
|
fs::write(
|
||||||
|
|
@ -2140,6 +2266,8 @@ mod tests {
|
||||||
started_at: None,
|
started_at: None,
|
||||||
term: "xterm".to_string(),
|
term: "xterm".to_string(),
|
||||||
spawn_type: "pty".to_string(),
|
spawn_type: "pty".to_string(),
|
||||||
|
cols: None,
|
||||||
|
rows: None,
|
||||||
};
|
};
|
||||||
|
|
||||||
fs::write(
|
fs::write(
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,7 @@ fn main() -> Result<(), anyhow::Error> {
|
||||||
let mut session_id = std::env::var("TTY_SESSION_ID").ok();
|
let mut session_id = std::env::var("TTY_SESSION_ID").ok();
|
||||||
let mut send_key = None::<String>;
|
let mut send_key = None::<String>;
|
||||||
let mut send_text = None::<String>;
|
let mut send_text = None::<String>;
|
||||||
|
let mut resize = None::<String>;
|
||||||
let mut signal = None::<i32>;
|
let mut signal = None::<i32>;
|
||||||
let mut stop = false;
|
let mut stop = false;
|
||||||
let mut kill = false;
|
let mut kill = false;
|
||||||
|
|
@ -64,6 +65,9 @@ fn main() -> Result<(), anyhow::Error> {
|
||||||
p if p.is_long("send-text") => {
|
p if p.is_long("send-text") => {
|
||||||
send_text = Some(parser.value()?);
|
send_text = Some(parser.value()?);
|
||||||
}
|
}
|
||||||
|
p if p.is_long("resize") => {
|
||||||
|
resize = Some(parser.value()?);
|
||||||
|
}
|
||||||
p if p.is_long("signal") => {
|
p if p.is_long("signal") => {
|
||||||
let signal_str: String = parser.value()?;
|
let signal_str: String = parser.value()?;
|
||||||
signal = Some(
|
signal = Some(
|
||||||
|
|
@ -112,6 +116,7 @@ fn main() -> Result<(), anyhow::Error> {
|
||||||
println!(" --send-key <key> Send key input to session");
|
println!(" --send-key <key> Send key input to session");
|
||||||
println!(" Keys: arrow_up, arrow_down, arrow_left, arrow_right, escape, enter, ctrl_enter, shift_enter");
|
println!(" Keys: arrow_up, arrow_down, arrow_left, arrow_right, escape, enter, ctrl_enter, shift_enter");
|
||||||
println!(" --send-text <text> Send text input to session");
|
println!(" --send-text <text> Send text input to session");
|
||||||
|
println!(" --resize <cols>x<rows> Resize terminal (e.g., --resize 120x40)");
|
||||||
println!(" --signal <number> Send signal number to session PID");
|
println!(" --signal <number> Send signal number to session PID");
|
||||||
println!(
|
println!(
|
||||||
" --stop Send SIGTERM to session (equivalent to --signal 15)"
|
" --stop Send SIGTERM to session (equivalent to --signal 15)"
|
||||||
|
|
@ -162,6 +167,28 @@ fn main() -> Result<(), anyhow::Error> {
|
||||||
return Err(anyhow!("--send-text requires --session <session_id>"));
|
return Err(anyhow!("--send-text requires --session <session_id>"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Handle resize command
|
||||||
|
if let Some(resize_spec) = resize {
|
||||||
|
if let Some(sid) = &session_id {
|
||||||
|
// Parse resize spec like "120x40"
|
||||||
|
let parts: Vec<&str> = resize_spec.split('x').collect();
|
||||||
|
if parts.len() != 2 {
|
||||||
|
return Err(anyhow!("Invalid resize format. Use <cols>x<rows> (e.g., 120x40)"));
|
||||||
|
}
|
||||||
|
let cols: u16 = parts[0].parse()
|
||||||
|
.map_err(|_| anyhow!("Invalid column value: {}", parts[0]))?;
|
||||||
|
let rows: u16 = parts[1].parse()
|
||||||
|
.map_err(|_| anyhow!("Invalid row value: {}", parts[1]))?;
|
||||||
|
|
||||||
|
if cols == 0 || rows == 0 {
|
||||||
|
return Err(anyhow!("Column and row values must be greater than 0"));
|
||||||
|
}
|
||||||
|
|
||||||
|
return sessions::resize_session(&control_path, sid, cols, rows);
|
||||||
|
}
|
||||||
|
return Err(anyhow!("--resize requires --session <session_id>"));
|
||||||
|
}
|
||||||
|
|
||||||
// Handle signal command
|
// Handle signal command
|
||||||
if let Some(sig) = signal {
|
if let Some(sig) = signal {
|
||||||
if let Some(sid) = &session_id {
|
if let Some(sid) = &session_id {
|
||||||
|
|
|
||||||
|
|
@ -27,6 +27,10 @@ pub struct SessionInfo {
|
||||||
pub term: String,
|
pub term: String,
|
||||||
#[serde(default = "get_default_spawn_type")]
|
#[serde(default = "get_default_spawn_type")]
|
||||||
pub spawn_type: String,
|
pub spawn_type: String,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none", default)]
|
||||||
|
pub cols: Option<u16>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none", default)]
|
||||||
|
pub rows: Option<u16>,
|
||||||
}
|
}
|
||||||
|
|
||||||
fn get_default_term() -> String {
|
fn get_default_term() -> String {
|
||||||
|
|
@ -760,6 +764,8 @@ mod tests {
|
||||||
started_at: Some(Timestamp::now()),
|
started_at: Some(Timestamp::now()),
|
||||||
term: "xterm-256color".to_string(),
|
term: "xterm-256color".to_string(),
|
||||||
spawn_type: "pty".to_string(),
|
spawn_type: "pty".to_string(),
|
||||||
|
cols: None,
|
||||||
|
rows: None,
|
||||||
};
|
};
|
||||||
|
|
||||||
let json = serde_json::to_string(&session).unwrap();
|
let json = serde_json::to_string(&session).unwrap();
|
||||||
|
|
@ -1119,6 +1125,8 @@ mod tests {
|
||||||
started_at: None,
|
started_at: None,
|
||||||
term: "xterm".to_string(),
|
term: "xterm".to_string(),
|
||||||
spawn_type: "pty".to_string(),
|
spawn_type: "pty".to_string(),
|
||||||
|
cols: None,
|
||||||
|
rows: None,
|
||||||
},
|
},
|
||||||
stream_out: "/tmp/stream.out".to_string(),
|
stream_out: "/tmp/stream.out".to_string(),
|
||||||
stdin: "/tmp/stdin".to_string(),
|
stdin: "/tmp/stdin".to_string(),
|
||||||
|
|
|
||||||
|
|
@ -289,6 +289,71 @@ pub fn reap_zombies() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn resize_session(
|
||||||
|
control_path: &Path,
|
||||||
|
session_id: &str,
|
||||||
|
cols: u16,
|
||||||
|
rows: u16,
|
||||||
|
) -> Result<(), anyhow::Error> {
|
||||||
|
let session_path = control_path.join(session_id);
|
||||||
|
let session_json_path = session_path.join("session.json");
|
||||||
|
let control_fifo_path = session_path.join("control");
|
||||||
|
|
||||||
|
if !session_json_path.exists() {
|
||||||
|
return Err(anyhow!("Session {} not found", session_id));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read session info
|
||||||
|
let content = fs::read_to_string(&session_json_path)?;
|
||||||
|
let mut session_info: serde_json::Value = serde_json::from_str(&content)?;
|
||||||
|
|
||||||
|
// Update dimensions in session.json
|
||||||
|
session_info["cols"] = serde_json::json!(cols);
|
||||||
|
session_info["rows"] = serde_json::json!(rows);
|
||||||
|
|
||||||
|
// Write updated session info
|
||||||
|
let updated_content = serde_json::to_string_pretty(&session_info)?;
|
||||||
|
fs::write(&session_json_path, updated_content)?;
|
||||||
|
|
||||||
|
// Create control message
|
||||||
|
let control_msg = serde_json::json!({
|
||||||
|
"cmd": "resize",
|
||||||
|
"cols": cols,
|
||||||
|
"rows": rows
|
||||||
|
});
|
||||||
|
let control_msg_str = serde_json::to_string(&control_msg)?;
|
||||||
|
|
||||||
|
// Try to send resize command via control FIFO if it exists
|
||||||
|
if control_fifo_path.exists() {
|
||||||
|
// Write to control FIFO with timeout
|
||||||
|
write_to_pipe_with_timeout(
|
||||||
|
&control_fifo_path,
|
||||||
|
format!("{}\n", control_msg_str).as_bytes(),
|
||||||
|
Duration::from_secs(2),
|
||||||
|
)?;
|
||||||
|
} else {
|
||||||
|
// If no control FIFO, try sending SIGWINCH to the process
|
||||||
|
if let Some(pid) = session_info.get("pid").and_then(|p| p.as_u64()) {
|
||||||
|
if is_pid_alive(pid as u32) {
|
||||||
|
let result = unsafe { libc::kill(pid as i32, libc::SIGWINCH) };
|
||||||
|
if result != 0 {
|
||||||
|
return Err(anyhow!("Failed to send SIGWINCH to PID {}", pid));
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
return Err(anyhow!(
|
||||||
|
"Session {} process (PID: {}) is not running",
|
||||||
|
session_id,
|
||||||
|
pid
|
||||||
|
));
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
return Err(anyhow!("Session {} has no PID recorded", session_id));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
pub fn send_signal_to_session(
|
pub fn send_signal_to_session(
|
||||||
control_path: &Path,
|
control_path: &Path,
|
||||||
session_id: &str,
|
session_id: &str,
|
||||||
|
|
@ -485,6 +550,8 @@ mod tests {
|
||||||
started_at: None,
|
started_at: None,
|
||||||
term: "xterm".to_string(),
|
term: "xterm".to_string(),
|
||||||
spawn_type: "pty".to_string(),
|
spawn_type: "pty".to_string(),
|
||||||
|
cols: None,
|
||||||
|
rows: None,
|
||||||
};
|
};
|
||||||
|
|
||||||
let session2_info = SessionInfo {
|
let session2_info = SessionInfo {
|
||||||
|
|
@ -497,6 +564,8 @@ mod tests {
|
||||||
started_at: None,
|
started_at: None,
|
||||||
term: "xterm-256color".to_string(),
|
term: "xterm-256color".to_string(),
|
||||||
spawn_type: "socket".to_string(),
|
spawn_type: "socket".to_string(),
|
||||||
|
cols: None,
|
||||||
|
rows: None,
|
||||||
};
|
};
|
||||||
|
|
||||||
create_test_session(control_path, "session1", &session1_info).unwrap();
|
create_test_session(control_path, "session1", &session1_info).unwrap();
|
||||||
|
|
@ -538,6 +607,8 @@ mod tests {
|
||||||
started_at: None,
|
started_at: None,
|
||||||
term: "xterm".to_string(),
|
term: "xterm".to_string(),
|
||||||
spawn_type: "pty".to_string(),
|
spawn_type: "pty".to_string(),
|
||||||
|
cols: None,
|
||||||
|
rows: None,
|
||||||
};
|
};
|
||||||
create_test_session(control_path, "valid-session", &session_info).unwrap();
|
create_test_session(control_path, "valid-session", &session_info).unwrap();
|
||||||
|
|
||||||
|
|
@ -599,6 +670,8 @@ mod tests {
|
||||||
started_at: None,
|
started_at: None,
|
||||||
term: "xterm".to_string(),
|
term: "xterm".to_string(),
|
||||||
spawn_type: "pty".to_string(),
|
spawn_type: "pty".to_string(),
|
||||||
|
cols: None,
|
||||||
|
rows: None,
|
||||||
};
|
};
|
||||||
create_test_session(control_path, "current-session", &session_info).unwrap();
|
create_test_session(control_path, "current-session", &session_info).unwrap();
|
||||||
|
|
||||||
|
|
@ -713,6 +786,8 @@ mod tests {
|
||||||
started_at: None,
|
started_at: None,
|
||||||
term: "xterm".to_string(),
|
term: "xterm".to_string(),
|
||||||
spawn_type: "pty".to_string(),
|
spawn_type: "pty".to_string(),
|
||||||
|
cols: None,
|
||||||
|
rows: None,
|
||||||
};
|
};
|
||||||
create_test_session(control_path, "test-session", &session_info).unwrap();
|
create_test_session(control_path, "test-session", &session_info).unwrap();
|
||||||
|
|
||||||
|
|
@ -753,6 +828,8 @@ mod tests {
|
||||||
started_at: None,
|
started_at: None,
|
||||||
term: "xterm".to_string(),
|
term: "xterm".to_string(),
|
||||||
spawn_type: "pty".to_string(),
|
spawn_type: "pty".to_string(),
|
||||||
|
cols: None,
|
||||||
|
rows: None,
|
||||||
};
|
};
|
||||||
create_test_session(control_path, "test-session", &session_info).unwrap();
|
create_test_session(control_path, "test-session", &session_info).unwrap();
|
||||||
|
|
||||||
|
|
@ -788,6 +865,8 @@ mod tests {
|
||||||
started_at: None,
|
started_at: None,
|
||||||
term: "xterm".to_string(),
|
term: "xterm".to_string(),
|
||||||
spawn_type: "pty".to_string(),
|
spawn_type: "pty".to_string(),
|
||||||
|
cols: None,
|
||||||
|
rows: None,
|
||||||
};
|
};
|
||||||
create_test_session(control_path, "running-session", &session_info).unwrap();
|
create_test_session(control_path, "running-session", &session_info).unwrap();
|
||||||
|
|
||||||
|
|
@ -816,6 +895,8 @@ mod tests {
|
||||||
started_at: None,
|
started_at: None,
|
||||||
term: "xterm".to_string(),
|
term: "xterm".to_string(),
|
||||||
spawn_type: "pty".to_string(),
|
spawn_type: "pty".to_string(),
|
||||||
|
cols: None,
|
||||||
|
rows: None,
|
||||||
};
|
};
|
||||||
|
|
||||||
let running_session = SessionInfo {
|
let running_session = SessionInfo {
|
||||||
|
|
@ -828,6 +909,8 @@ mod tests {
|
||||||
started_at: None,
|
started_at: None,
|
||||||
term: "xterm".to_string(),
|
term: "xterm".to_string(),
|
||||||
spawn_type: "pty".to_string(),
|
spawn_type: "pty".to_string(),
|
||||||
|
cols: None,
|
||||||
|
rows: None,
|
||||||
};
|
};
|
||||||
|
|
||||||
let no_pid_session = SessionInfo {
|
let no_pid_session = SessionInfo {
|
||||||
|
|
@ -840,6 +923,8 @@ mod tests {
|
||||||
started_at: None,
|
started_at: None,
|
||||||
term: "xterm".to_string(),
|
term: "xterm".to_string(),
|
||||||
spawn_type: "pty".to_string(),
|
spawn_type: "pty".to_string(),
|
||||||
|
cols: None,
|
||||||
|
rows: None,
|
||||||
};
|
};
|
||||||
|
|
||||||
create_test_session(control_path, "dead-session", &dead_session).unwrap();
|
create_test_session(control_path, "dead-session", &dead_session).unwrap();
|
||||||
|
|
@ -902,4 +987,36 @@ mod tests {
|
||||||
// Just ensure the function doesn't panic
|
// Just ensure the function doesn't panic
|
||||||
reap_zombies();
|
reap_zombies();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_resize_session() {
|
||||||
|
let temp_dir = TempDir::new().unwrap();
|
||||||
|
let control_path = temp_dir.path();
|
||||||
|
|
||||||
|
// Create a test session with cols/rows
|
||||||
|
let mut session_info = SessionInfo::default();
|
||||||
|
session_info.status = "running".to_string();
|
||||||
|
session_info.pid = Some(std::process::id());
|
||||||
|
session_info.cols = Some(80);
|
||||||
|
session_info.rows = Some(24);
|
||||||
|
|
||||||
|
create_test_session(control_path, "test-session", &session_info).unwrap();
|
||||||
|
|
||||||
|
// Create control FIFO
|
||||||
|
let control_fifo_path = control_path.join("test-session").join("control");
|
||||||
|
unsafe {
|
||||||
|
let path_cstr = std::ffi::CString::new(control_fifo_path.to_str().unwrap()).unwrap();
|
||||||
|
libc::mkfifo(path_cstr.as_ptr(), 0o666);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Note: Actually testing resize would require a real PTY and process
|
||||||
|
// This test just verifies the session.json update logic
|
||||||
|
|
||||||
|
// Read back session.json to verify initial dimensions
|
||||||
|
let session_json_path = control_path.join("test-session").join("session.json");
|
||||||
|
let content = std::fs::read_to_string(&session_json_path).unwrap();
|
||||||
|
let session_data: serde_json::Value = serde_json::from_str(&content).unwrap();
|
||||||
|
assert_eq!(session_data.get("cols").and_then(|v| v.as_u64()), Some(80));
|
||||||
|
assert_eq!(session_data.get("rows").and_then(|v| v.as_u64()), Some(24));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -156,6 +156,7 @@ impl TtySpawn {
|
||||||
command,
|
command,
|
||||||
stdin_file: None,
|
stdin_file: None,
|
||||||
stdout_file: None,
|
stdout_file: None,
|
||||||
|
control_file: None,
|
||||||
notification_writer: None,
|
notification_writer: None,
|
||||||
session_json_path: None,
|
session_json_path: None,
|
||||||
session_name: None,
|
session_name: None,
|
||||||
|
|
@ -181,6 +182,19 @@ impl TtySpawn {
|
||||||
Ok(self)
|
Ok(self)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Sets a path as control file for resize and other control commands.
|
||||||
|
pub fn control_path<P: AsRef<Path>>(&mut self, path: P) -> Result<&mut Self, Error> {
|
||||||
|
let path = path.as_ref();
|
||||||
|
mkfifo_atomic(path)?;
|
||||||
|
let file = File::options()
|
||||||
|
.read(true)
|
||||||
|
.write(true)
|
||||||
|
.custom_flags(O_NONBLOCK)
|
||||||
|
.open(path)?;
|
||||||
|
self.options_mut().control_file = Some(file);
|
||||||
|
Ok(self)
|
||||||
|
}
|
||||||
|
|
||||||
/// Sets a path as output file for stdout.
|
/// Sets a path as output file for stdout.
|
||||||
///
|
///
|
||||||
/// If the `truncate` flag is set to `true` the file will be truncated
|
/// If the `truncate` flag is set to `true` the file will be truncated
|
||||||
|
|
@ -251,6 +265,7 @@ struct SpawnOptions {
|
||||||
command: Vec<OsString>,
|
command: Vec<OsString>,
|
||||||
stdin_file: Option<File>,
|
stdin_file: Option<File>,
|
||||||
stdout_file: Option<File>,
|
stdout_file: Option<File>,
|
||||||
|
control_file: Option<File>,
|
||||||
notification_writer: Option<NotificationWriter>,
|
notification_writer: Option<NotificationWriter>,
|
||||||
session_json_path: Option<PathBuf>,
|
session_json_path: Option<PathBuf>,
|
||||||
session_name: Option<String>,
|
session_name: Option<String>,
|
||||||
|
|
@ -265,6 +280,8 @@ pub fn create_session_info(
|
||||||
name: String,
|
name: String,
|
||||||
cwd: String,
|
cwd: String,
|
||||||
term: String,
|
term: String,
|
||||||
|
cols: Option<u16>,
|
||||||
|
rows: Option<u16>,
|
||||||
) -> Result<(), Error> {
|
) -> Result<(), Error> {
|
||||||
let session_info = SessionInfo {
|
let session_info = SessionInfo {
|
||||||
cmdline,
|
cmdline,
|
||||||
|
|
@ -276,6 +293,8 @@ pub fn create_session_info(
|
||||||
started_at: Some(Timestamp::now()),
|
started_at: Some(Timestamp::now()),
|
||||||
term,
|
term,
|
||||||
spawn_type: "socket".to_string(),
|
spawn_type: "socket".to_string(),
|
||||||
|
cols,
|
||||||
|
rows,
|
||||||
};
|
};
|
||||||
|
|
||||||
let session_info_str = serde_json::to_string(&session_info)?;
|
let session_info_str = serde_json::to_string(&session_info)?;
|
||||||
|
|
@ -325,6 +344,27 @@ fn update_session_status(
|
||||||
/// optional `out` log file. Additionally it can retrieve instructions from
|
/// optional `out` log file. Additionally it can retrieve instructions from
|
||||||
/// the given control socket.
|
/// the given control socket.
|
||||||
fn spawn(mut opts: SpawnOptions) -> Result<i32, Error> {
|
fn spawn(mut opts: SpawnOptions) -> Result<i32, Error> {
|
||||||
|
// if we can't retrieve the terminal atts we're not directly connected
|
||||||
|
// to a pty in which case we won't do any of the terminal related
|
||||||
|
// operations. In detached mode, we don't connect to the current terminal.
|
||||||
|
let term_attrs = if opts.detached {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
tcgetattr(io::stdin()).ok()
|
||||||
|
};
|
||||||
|
let winsize = if opts.detached {
|
||||||
|
Some(Winsize {
|
||||||
|
ws_row: 24,
|
||||||
|
ws_col: 80,
|
||||||
|
ws_xpixel: 0,
|
||||||
|
ws_ypixel: 0,
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
term_attrs
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|_| get_winsize(io::stdin().as_fd()))
|
||||||
|
};
|
||||||
|
|
||||||
// Create session info at the beginning if we have a session JSON path
|
// Create session info at the beginning if we have a session JSON path
|
||||||
if let Some(ref session_json_path) = opts.session_json_path {
|
if let Some(ref session_json_path) = opts.session_json_path {
|
||||||
// Get executable name for session name
|
// Get executable name for session name
|
||||||
|
|
@ -355,6 +395,8 @@ fn spawn(mut opts: SpawnOptions) -> Result<i32, Error> {
|
||||||
session_name.clone(),
|
session_name.clone(),
|
||||||
current_dir.clone(),
|
current_dir.clone(),
|
||||||
opts.term.clone(),
|
opts.term.clone(),
|
||||||
|
winsize.as_ref().map(|w| w.ws_col),
|
||||||
|
winsize.as_ref().map(|w| w.ws_row),
|
||||||
)?;
|
)?;
|
||||||
|
|
||||||
// Send session started notification
|
// Send session started notification
|
||||||
|
|
@ -371,26 +413,6 @@ fn spawn(mut opts: SpawnOptions) -> Result<i32, Error> {
|
||||||
let _ = notification_writer.write_notification(notification);
|
let _ = notification_writer.write_notification(notification);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// if we can't retrieve the terminal atts we're not directly connected
|
|
||||||
// to a pty in which case we won't do any of the terminal related
|
|
||||||
// operations. In detached mode, we don't connect to the current terminal.
|
|
||||||
let term_attrs = if opts.detached {
|
|
||||||
None
|
|
||||||
} else {
|
|
||||||
tcgetattr(io::stdin()).ok()
|
|
||||||
};
|
|
||||||
let winsize = if opts.detached {
|
|
||||||
Some(Winsize {
|
|
||||||
ws_row: 24,
|
|
||||||
ws_col: 80,
|
|
||||||
ws_xpixel: 0,
|
|
||||||
ws_ypixel: 0,
|
|
||||||
})
|
|
||||||
} else {
|
|
||||||
term_attrs
|
|
||||||
.as_ref()
|
|
||||||
.and_then(|_| get_winsize(io::stdin().as_fd()))
|
|
||||||
};
|
|
||||||
|
|
||||||
// Create the outer pty for stdout
|
// Create the outer pty for stdout
|
||||||
let pty = openpty(&winsize, &term_attrs)?;
|
let pty = openpty(&winsize, &term_attrs)?;
|
||||||
|
|
@ -434,6 +456,7 @@ fn spawn(mut opts: SpawnOptions) -> Result<i32, Error> {
|
||||||
let session_json_path = opts.session_json_path.clone();
|
let session_json_path = opts.session_json_path.clone();
|
||||||
let notification_writer = opts.notification_writer;
|
let notification_writer = opts.notification_writer;
|
||||||
let stdin_file = opts.stdin_file;
|
let stdin_file = opts.stdin_file;
|
||||||
|
let control_file = opts.control_file;
|
||||||
|
|
||||||
// Create StreamWriter for detached session if we have an output file
|
// Create StreamWriter for detached session if we have an output file
|
||||||
let stream_writer = if let Some(stdout_file) = opts.stdout_file.take() {
|
let stream_writer = if let Some(stdout_file) = opts.stdout_file.take() {
|
||||||
|
|
@ -464,6 +487,7 @@ fn spawn(mut opts: SpawnOptions) -> Result<i32, Error> {
|
||||||
notification_writer,
|
notification_writer,
|
||||||
stream_writer,
|
stream_writer,
|
||||||
stdin_file,
|
stdin_file,
|
||||||
|
control_file,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -845,6 +869,7 @@ fn monitor_detached_session(
|
||||||
mut notification_writer: Option<NotificationWriter>,
|
mut notification_writer: Option<NotificationWriter>,
|
||||||
mut stream_writer: Option<StreamWriter>,
|
mut stream_writer: Option<StreamWriter>,
|
||||||
stdin_file: Option<File>,
|
stdin_file: Option<File>,
|
||||||
|
control_file: Option<File>,
|
||||||
) -> Result<(), Error> {
|
) -> Result<(), Error> {
|
||||||
let mut buf = [0; 4096];
|
let mut buf = [0; 4096];
|
||||||
let mut done = false;
|
let mut done = false;
|
||||||
|
|
@ -858,6 +883,10 @@ fn monitor_detached_session(
|
||||||
read_fds.insert(f.as_fd());
|
read_fds.insert(f.as_fd());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if let Some(ref f) = control_file {
|
||||||
|
read_fds.insert(f.as_fd());
|
||||||
|
}
|
||||||
|
|
||||||
match select(None, Some(&mut read_fds), None, None, Some(&mut timeout)) {
|
match select(None, Some(&mut read_fds), None, None, Some(&mut timeout)) {
|
||||||
Ok(0) => {
|
Ok(0) => {
|
||||||
// Timeout occurred - just continue
|
// Timeout occurred - just continue
|
||||||
|
|
@ -868,6 +897,54 @@ fn monitor_detached_session(
|
||||||
Err(err) => return Err(err.into()),
|
Err(err) => return Err(err.into()),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if let Some(ref f) = control_file {
|
||||||
|
if read_fds.contains(f.as_fd()) {
|
||||||
|
match read(f, &mut buf) {
|
||||||
|
Ok(0) | Err(Errno::EAGAIN | Errno::EINTR) => {}
|
||||||
|
Err(err) => return Err(err.into()),
|
||||||
|
Ok(n) => {
|
||||||
|
// Parse control command
|
||||||
|
if let Ok(cmd_str) = std::str::from_utf8(&buf[..n]) {
|
||||||
|
for line in cmd_str.lines() {
|
||||||
|
if let Ok(cmd) = serde_json::from_str::<serde_json::Value>(line) {
|
||||||
|
if let Some(cmd_type) = cmd.get("cmd").and_then(|v| v.as_str()) {
|
||||||
|
if cmd_type == "resize" {
|
||||||
|
if let (Some(cols), Some(rows)) = (
|
||||||
|
cmd.get("cols").and_then(|v| v.as_u64()),
|
||||||
|
cmd.get("rows").and_then(|v| v.as_u64()),
|
||||||
|
) {
|
||||||
|
let winsize = Winsize {
|
||||||
|
ws_row: rows as u16,
|
||||||
|
ws_col: cols as u16,
|
||||||
|
ws_xpixel: 0,
|
||||||
|
ws_ypixel: 0,
|
||||||
|
};
|
||||||
|
if let Err(e) = set_winsize(master.as_fd(), winsize) {
|
||||||
|
eprintln!("Failed to resize terminal: {}", e);
|
||||||
|
} else {
|
||||||
|
// Log resize event
|
||||||
|
if let Some(writer) = &mut stream_writer {
|
||||||
|
let time = writer.elapsed_time();
|
||||||
|
let data = format!("{}x{}", cols, rows);
|
||||||
|
let event = AsciinemaEvent {
|
||||||
|
time,
|
||||||
|
event_type: AsciinemaEventType::Resize,
|
||||||
|
data,
|
||||||
|
};
|
||||||
|
let _ = writer.write_event(event);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if let Some(ref f) = stdin_file {
|
if let Some(ref f) = stdin_file {
|
||||||
if read_fds.contains(f.as_fd()) {
|
if read_fds.contains(f.as_fd()) {
|
||||||
match read(f, &mut buf) {
|
match read(f, &mut buf) {
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue