feat: hand off ptys during updates
This commit is contained in:
parent
a1543f3e24
commit
d4a5e02e84
|
|
@ -0,0 +1,221 @@
|
|||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
HERDR_BIN="${HERDR_BIN:-$ROOT/target/debug/herdr}"
|
||||
BASE="${BASE:-$(mktemp -d /tmp/herdr-handoff-smoke.XXXXXX)}"
|
||||
CONFIG_HOME="$BASE/config"
|
||||
RUNTIME_DIR="$BASE/runtime"
|
||||
STATE_DIR="$BASE/state"
|
||||
|
||||
if [[ "$CONFIG_HOME" == "$HOME/.config" || "$CONFIG_HOME" == "$HOME/.config/"* ]]; then
|
||||
echo "refusing to run smoke test against $CONFIG_HOME" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
sessions=("default" "work" "api")
|
||||
ports=()
|
||||
server_pids=()
|
||||
|
||||
cleanup() {
|
||||
set +e
|
||||
for session in "${sessions[@]}"; do
|
||||
run_herdr "$session" server stop >/dev/null 2>&1 || true
|
||||
done
|
||||
for pid in "${server_pids[@]}"; do
|
||||
kill "$pid" >/dev/null 2>&1 || true
|
||||
done
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
run_herdr() {
|
||||
local session="$1"
|
||||
shift
|
||||
local socket
|
||||
socket="$(api_socket "$session")"
|
||||
assert_smoke_socket "$socket"
|
||||
mkdir -p "$(dirname "$socket")" "$RUNTIME_DIR" "$STATE_DIR"
|
||||
env -u HERDR_SOCKET_PATH \
|
||||
-u HERDR_CLIENT_SOCKET_PATH \
|
||||
-u HERDR_SESSION \
|
||||
XDG_CONFIG_HOME="$CONFIG_HOME" \
|
||||
XDG_RUNTIME_DIR="$RUNTIME_DIR" \
|
||||
XDG_STATE_HOME="$STATE_DIR" \
|
||||
"$HERDR_BIN" --session "$session" "$@"
|
||||
}
|
||||
|
||||
session_dir() {
|
||||
local session="$1"
|
||||
if [[ "$session" == "default" ]]; then
|
||||
printf '%s/herdr-dev' "$CONFIG_HOME"
|
||||
else
|
||||
printf '%s/herdr-dev/sessions/%s' "$CONFIG_HOME" "$session"
|
||||
fi
|
||||
}
|
||||
|
||||
api_socket() {
|
||||
printf '%s/herdr.sock' "$(session_dir "$1")"
|
||||
}
|
||||
|
||||
client_socket() {
|
||||
printf '%s/herdr-client.sock' "$(session_dir "$1")"
|
||||
}
|
||||
|
||||
assert_smoke_socket() {
|
||||
local socket="$1"
|
||||
case "$socket" in
|
||||
"$CONFIG_HOME"/herdr-dev/herdr.sock | "$CONFIG_HOME"/herdr-dev/sessions/*/herdr.sock)
|
||||
;;
|
||||
*)
|
||||
echo "refusing to use non-smoke socket: $socket" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
wait_for_socket() {
|
||||
local socket="$1"
|
||||
for _ in {1..200}; do
|
||||
[[ -S "$socket" ]] && return 0
|
||||
sleep 0.05
|
||||
done
|
||||
echo "socket did not appear: $socket" >&2
|
||||
return 1
|
||||
}
|
||||
|
||||
wait_for_http() {
|
||||
local port="$1"
|
||||
local expected="$2"
|
||||
for _ in {1..200}; do
|
||||
if curl -fsS "http://127.0.0.1:$port/" | grep -q "$expected"; then
|
||||
return 0
|
||||
fi
|
||||
sleep 0.05
|
||||
done
|
||||
echo "http server on port $port did not return $expected" >&2
|
||||
return 1
|
||||
}
|
||||
|
||||
json_request() {
|
||||
local socket="$1"
|
||||
local body="$2"
|
||||
python3 - "$socket" "$body" <<'PY'
|
||||
import socket
|
||||
import sys
|
||||
|
||||
path, body = sys.argv[1], sys.argv[2]
|
||||
client = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
||||
client.connect(path)
|
||||
client.sendall(body.encode() + b"\n")
|
||||
response = b""
|
||||
while not response.endswith(b"\n"):
|
||||
chunk = client.recv(65536)
|
||||
if not chunk:
|
||||
break
|
||||
response += chunk
|
||||
print(response.decode().strip())
|
||||
PY
|
||||
}
|
||||
|
||||
pane_id_for_session() {
|
||||
local session="$1"
|
||||
local socket
|
||||
socket="$(api_socket "$session")"
|
||||
json_request "$socket" '{"id":"smoke:workspace:create","method":"workspace.create","params":{"cwd":"/tmp","focus":true}}' \
|
||||
| python3 -c 'import json,sys; print(json.load(sys.stdin)["result"]["root_pane"]["pane_id"])'
|
||||
}
|
||||
|
||||
send_text() {
|
||||
local session="$1"
|
||||
local pane="$2"
|
||||
local text="$3"
|
||||
local socket
|
||||
socket="$(api_socket "$session")"
|
||||
python3 - "$socket" "$pane" "$text" <<'PY'
|
||||
import json
|
||||
import socket
|
||||
import sys
|
||||
|
||||
path, pane, text = sys.argv[1], sys.argv[2], sys.argv[3]
|
||||
request = {
|
||||
"id": "smoke:pane:send",
|
||||
"method": "pane.send_input",
|
||||
"params": {"pane_id": pane, "text": text, "keys": ["Enter"]},
|
||||
}
|
||||
client = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
||||
client.connect(path)
|
||||
client.sendall(json.dumps(request).encode() + b"\n")
|
||||
response = b""
|
||||
while not response.endswith(b"\n"):
|
||||
chunk = client.recv(65536)
|
||||
if not chunk:
|
||||
break
|
||||
response += chunk
|
||||
if b'"error"' in response:
|
||||
raise SystemExit(response.decode())
|
||||
PY
|
||||
}
|
||||
|
||||
unused_port() {
|
||||
python3 - <<'PY'
|
||||
import socket
|
||||
sock = socket.socket()
|
||||
sock.bind(("127.0.0.1", 0))
|
||||
print(sock.getsockname()[1])
|
||||
sock.close()
|
||||
PY
|
||||
}
|
||||
|
||||
smoke_http_count() {
|
||||
local count=0
|
||||
local port matches
|
||||
for port in "${ports[@]}"; do
|
||||
matches="$(pgrep -fc "python3 -m http.server $port --bind 127.0.0.1" || true)"
|
||||
if [[ -n "$matches" ]]; then
|
||||
count=$((count + matches))
|
||||
fi
|
||||
done
|
||||
printf '%s\n' "$count"
|
||||
}
|
||||
|
||||
echo "using herdr: $HERDR_BIN"
|
||||
echo "smoke base: $BASE"
|
||||
|
||||
cargo build --locked --manifest-path "$ROOT/Cargo.toml" >/dev/null
|
||||
mkdir -p "$CONFIG_HOME/herdr-dev" "$RUNTIME_DIR" "$STATE_DIR"
|
||||
printf 'onboarding = false\n' > "$CONFIG_HOME/herdr-dev/config.toml"
|
||||
|
||||
for session in "${sessions[@]}"; do
|
||||
echo "starting smoke session $session at $(api_socket "$session")"
|
||||
run_herdr "$session" server >/dev/null 2>&1 &
|
||||
server_pids+=("$!")
|
||||
wait_for_socket "$(api_socket "$session")"
|
||||
done
|
||||
|
||||
for session in "${sessions[@]}"; do
|
||||
port="$(unused_port)"
|
||||
ports+=("$port")
|
||||
web="$BASE/web-$session"
|
||||
mkdir -p "$web"
|
||||
printf 'hello-from-%s\n' "$session" > "$web/index.html"
|
||||
pane="$(pane_id_for_session "$session")"
|
||||
send_text "$session" "$pane" "cd '$web' && python3 -m http.server $port --bind 127.0.0.1"
|
||||
wait_for_http "$port" "hello-from-$session"
|
||||
done
|
||||
|
||||
before_count="$(smoke_http_count)"
|
||||
echo "smoke python http.server process count before handoff: $before_count"
|
||||
|
||||
for session in "${sessions[@]}"; do
|
||||
socket="$(api_socket "$session")"
|
||||
json_request "$socket" '{"id":"smoke:handoff","method":"server.live_handoff","params":{}}' >/dev/null
|
||||
wait_for_socket "$socket"
|
||||
done
|
||||
|
||||
for i in "${!sessions[@]}"; do
|
||||
wait_for_http "${ports[$i]}" "hello-from-${sessions[$i]}"
|
||||
done
|
||||
|
||||
after_count="$(smoke_http_count)"
|
||||
echo "smoke python http.server process count after handoff: $after_count"
|
||||
echo "multi-session live handoff smoke passed"
|
||||
|
|
@ -111,9 +111,14 @@ impl ApiClient {
|
|||
method: Method::Ping(PingParams::default()),
|
||||
})?;
|
||||
match response.result {
|
||||
ResponseResult::Pong { version, protocol } => Ok(crate::api::RuntimeStatus {
|
||||
ResponseResult::Pong {
|
||||
version,
|
||||
protocol,
|
||||
capabilities,
|
||||
} => Ok(crate::api::RuntimeStatus {
|
||||
version: Some(version),
|
||||
protocol: Some(protocol),
|
||||
capabilities,
|
||||
}),
|
||||
result => Err(ApiClientError::UnexpectedResult(format!("{result:?}"))),
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ mod subscriptions;
|
|||
mod wait;
|
||||
|
||||
pub use event_hub::EventHub;
|
||||
pub use server::start_server;
|
||||
pub use server::{start_server, start_server_with_capabilities, ServerHandle};
|
||||
pub use status::{read_runtime_status_at, RuntimeStatus};
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
|
|
|||
|
|
@ -14,6 +14,8 @@ pub enum Method {
|
|||
Ping(PingParams),
|
||||
#[serde(rename = "server.stop")]
|
||||
ServerStop(EmptyParams),
|
||||
#[serde(rename = "server.live_handoff")]
|
||||
ServerLiveHandoff(ServerLiveHandoffParams),
|
||||
#[serde(rename = "server.reload_config")]
|
||||
ServerReloadConfig(EmptyParams),
|
||||
#[serde(rename = "workspace.create")]
|
||||
|
|
@ -307,6 +309,16 @@ pub struct PaneSendInputParams {
|
|||
pub keys: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct ServerLiveHandoffParams {
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub import_exe: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub expected_protocol: Option<u32>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub expected_version: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct PaneReadParams {
|
||||
pub pane_id: String,
|
||||
|
|
@ -581,12 +593,19 @@ pub struct ErrorBody {
|
|||
pub message: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct ServerCapabilities {
|
||||
pub live_handoff: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(tag = "type", rename_all = "snake_case")]
|
||||
pub enum ResponseResult {
|
||||
Pong {
|
||||
version: String,
|
||||
protocol: u32,
|
||||
#[serde(default)]
|
||||
capabilities: Option<ServerCapabilities>,
|
||||
},
|
||||
WorkspaceInfo {
|
||||
workspace: WorkspaceInfo,
|
||||
|
|
@ -1225,6 +1244,7 @@ mod tests {
|
|||
result: ResponseResult::Pong {
|
||||
version: "0.1.2".into(),
|
||||
protocol: 6,
|
||||
capabilities: Some(ServerCapabilities { live_handoff: true }),
|
||||
},
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -1,29 +1,34 @@
|
|||
use std::fs;
|
||||
use std::io::{BufRead, BufReader, Read, Write};
|
||||
use std::io::{self, Read, Write};
|
||||
use std::os::unix::net::{UnixListener, UnixStream};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use tracing::{debug, error, info, warn};
|
||||
|
||||
#[cfg(test)]
|
||||
use std::fs;
|
||||
|
||||
use crate::api::schema::{
|
||||
ErrorBody, ErrorResponse, Method, Request, ResponseResult, SuccessResponse,
|
||||
ErrorBody, ErrorResponse, Method, Request, ResponseResult, ServerCapabilities, SuccessResponse,
|
||||
};
|
||||
use crate::api::subscriptions::ActiveSubscription;
|
||||
use crate::api::wait::wait_for_output;
|
||||
use crate::api::{request_changes_ui, socket_path, ApiRequestMessage, ApiRequestSender, EventHub};
|
||||
use crate::ipc::{remove_socket_file_if_owned, socket_file_identity, SocketFileIdentity};
|
||||
|
||||
const SOCKET_PERMISSION_MODE: u32 = 0o600;
|
||||
pub(super) const CONNECTION_POLL_INTERVAL: Duration = Duration::from_millis(100);
|
||||
pub(super) const APP_RESPONSE_TIMEOUT: Duration = Duration::from_secs(5);
|
||||
const INITIAL_REQUEST_TIMEOUT: Duration = Duration::from_secs(5);
|
||||
const STREAM_WRITE_TIMEOUT: Duration = Duration::from_secs(5);
|
||||
const MAX_INITIAL_REQUEST_BYTES: usize = 1024 * 1024;
|
||||
|
||||
pub struct ServerHandle {
|
||||
_thread: std::thread::JoinHandle<()>,
|
||||
path: PathBuf,
|
||||
identity: SocketFileIdentity,
|
||||
running: Arc<AtomicBool>,
|
||||
}
|
||||
|
||||
|
|
@ -31,7 +36,7 @@ impl Drop for ServerHandle {
|
|||
fn drop(&mut self) {
|
||||
self.running.store(false, Ordering::Relaxed);
|
||||
|
||||
if let Err(err) = fs::remove_file(&self.path) {
|
||||
if let Err(err) = self.remove_socket_file_if_owned() {
|
||||
if err.kind() != std::io::ErrorKind::NotFound {
|
||||
warn!(path = %self.path.display(), err = %err, "failed to remove api socket on shutdown");
|
||||
}
|
||||
|
|
@ -39,15 +44,34 @@ impl Drop for ServerHandle {
|
|||
}
|
||||
}
|
||||
|
||||
impl ServerHandle {
|
||||
pub(crate) fn remove_socket_file_if_owned(&self) -> std::io::Result<()> {
|
||||
remove_socket_file_if_owned(&self.path, self.identity)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn start_server(
|
||||
api_tx: ApiRequestSender,
|
||||
event_hub: EventHub,
|
||||
) -> std::io::Result<ServerHandle> {
|
||||
start_server_with_capabilities(
|
||||
api_tx,
|
||||
event_hub,
|
||||
Some(ServerCapabilities { live_handoff: true }),
|
||||
)
|
||||
}
|
||||
|
||||
pub fn start_server_with_capabilities(
|
||||
api_tx: ApiRequestSender,
|
||||
event_hub: EventHub,
|
||||
capabilities: Option<ServerCapabilities>,
|
||||
) -> std::io::Result<ServerHandle> {
|
||||
let path = socket_path();
|
||||
prepare_socket_path(&path)?;
|
||||
|
||||
let listener = UnixListener::bind(&path)?;
|
||||
restrict_socket_permissions(&path)?;
|
||||
let identity = socket_file_identity(&path)?;
|
||||
info!(path = %path.display(), "api server listening");
|
||||
|
||||
let running = Arc::new(AtomicBool::new(true));
|
||||
|
|
@ -58,11 +82,16 @@ pub fn start_server(
|
|||
Ok(stream) => {
|
||||
let api_tx = api_tx.clone();
|
||||
let event_hub = event_hub.clone();
|
||||
let capabilities = capabilities.clone();
|
||||
let connection_running = Arc::clone(&listener_running);
|
||||
std::thread::spawn(move || {
|
||||
if let Err(err) =
|
||||
handle_connection(stream, &api_tx, &event_hub, &connection_running)
|
||||
{
|
||||
if let Err(err) = handle_connection(
|
||||
stream,
|
||||
&api_tx,
|
||||
&event_hub,
|
||||
&connection_running,
|
||||
capabilities,
|
||||
) {
|
||||
warn!(err = %err, "api connection failed");
|
||||
}
|
||||
});
|
||||
|
|
@ -79,6 +108,7 @@ pub fn start_server(
|
|||
Ok(ServerHandle {
|
||||
_thread: thread,
|
||||
path,
|
||||
identity,
|
||||
running,
|
||||
})
|
||||
}
|
||||
|
|
@ -101,20 +131,15 @@ fn handle_connection(
|
|||
api_tx: &ApiRequestSender,
|
||||
event_hub: &EventHub,
|
||||
running: &Arc<AtomicBool>,
|
||||
capabilities: Option<ServerCapabilities>,
|
||||
) -> std::io::Result<()> {
|
||||
stream.set_read_timeout(Some(INITIAL_REQUEST_TIMEOUT))?;
|
||||
stream.set_write_timeout(Some(STREAM_WRITE_TIMEOUT))?;
|
||||
|
||||
let mut line = String::new();
|
||||
{
|
||||
let mut reader = BufReader::new(&stream);
|
||||
let read = reader.read_line(&mut line)?;
|
||||
if read == 0 {
|
||||
return Ok(());
|
||||
}
|
||||
if let Err(err) = stream.set_write_timeout(Some(STREAM_WRITE_TIMEOUT)) {
|
||||
debug!(err = %err, "api connection write timeout unavailable");
|
||||
}
|
||||
|
||||
stream.set_read_timeout(None)?;
|
||||
let Some(line) = read_initial_request_line(&mut stream)? else {
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
let line = line.trim();
|
||||
if line.is_empty() {
|
||||
|
|
@ -199,6 +224,7 @@ fn handle_connection(
|
|||
method: method_body,
|
||||
},
|
||||
api_tx,
|
||||
capabilities,
|
||||
);
|
||||
let result = write_text_line_allow_disconnect(&mut stream, &response);
|
||||
match &result {
|
||||
|
|
@ -217,13 +243,18 @@ fn handle_connection(
|
|||
}
|
||||
}
|
||||
|
||||
fn handle_request(request: Request, api_tx: &ApiRequestSender) -> String {
|
||||
fn handle_request(
|
||||
request: Request,
|
||||
api_tx: &ApiRequestSender,
|
||||
capabilities: Option<ServerCapabilities>,
|
||||
) -> String {
|
||||
match request.method {
|
||||
Method::Ping(_) => serde_json::to_string(&SuccessResponse {
|
||||
id: request.id,
|
||||
result: ResponseResult::Pong {
|
||||
version: env!("CARGO_PKG_VERSION").into(),
|
||||
protocol: crate::protocol::PROTOCOL_VERSION,
|
||||
capabilities,
|
||||
},
|
||||
})
|
||||
.unwrap_or_else(|_| {
|
||||
|
|
@ -238,6 +269,7 @@ fn api_method_name(method: &Method) -> &'static str {
|
|||
match method {
|
||||
Method::Ping(_) => "ping",
|
||||
Method::ServerStop(_) => "server.stop",
|
||||
Method::ServerLiveHandoff(_) => "server.live_handoff",
|
||||
Method::ServerReloadConfig(_) => "server.reload_config",
|
||||
Method::WorkspaceCreate(_) => "workspace.create",
|
||||
Method::WorkspaceList(_) => "workspace.list",
|
||||
|
|
@ -298,6 +330,52 @@ fn api_response_outcome(response: &str) -> &'static str {
|
|||
}
|
||||
}
|
||||
|
||||
fn read_initial_request_line(stream: &mut UnixStream) -> std::io::Result<Option<String>> {
|
||||
stream.set_nonblocking(true)?;
|
||||
let deadline = Instant::now() + INITIAL_REQUEST_TIMEOUT;
|
||||
let mut bytes = Vec::new();
|
||||
let mut byte = [0u8; 1];
|
||||
|
||||
loop {
|
||||
match stream.read(&mut byte) {
|
||||
Ok(0) => {
|
||||
stream.set_nonblocking(false)?;
|
||||
return Ok(None);
|
||||
}
|
||||
Ok(_) => {
|
||||
bytes.push(byte[0]);
|
||||
if byte[0] == b'\n' {
|
||||
stream.set_nonblocking(false)?;
|
||||
return String::from_utf8(bytes)
|
||||
.map(Some)
|
||||
.map_err(|err| io::Error::new(io::ErrorKind::InvalidData, err));
|
||||
}
|
||||
if bytes.len() > MAX_INITIAL_REQUEST_BYTES {
|
||||
stream.set_nonblocking(false)?;
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::InvalidData,
|
||||
"api request line is too large",
|
||||
));
|
||||
}
|
||||
}
|
||||
Err(err) if err.kind() == io::ErrorKind::WouldBlock => {
|
||||
if Instant::now() >= deadline {
|
||||
stream.set_nonblocking(false)?;
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::TimedOut,
|
||||
"timed out reading api request",
|
||||
));
|
||||
}
|
||||
std::thread::sleep(CONNECTION_POLL_INTERVAL);
|
||||
}
|
||||
Err(err) => {
|
||||
stream.set_nonblocking(false)?;
|
||||
return Err(err);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn stream_subscriptions(
|
||||
mut stream: UnixStream,
|
||||
request_id: String,
|
||||
|
|
@ -496,6 +574,7 @@ fn error_response_json(id: String, code: &str, message: String) -> String {
|
|||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::io::{BufRead, BufReader};
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
use std::sync::{Mutex, OnceLock};
|
||||
use tokio::sync::mpsc;
|
||||
|
|
@ -610,6 +689,7 @@ mod tests {
|
|||
method: Method::Ping(crate::api::schema::PingParams::default()),
|
||||
},
|
||||
&tx,
|
||||
Some(ServerCapabilities { live_handoff: true }),
|
||||
);
|
||||
|
||||
let parsed: SuccessResponse = serde_json::from_str(&response).unwrap();
|
||||
|
|
@ -626,7 +706,7 @@ mod tests {
|
|||
};
|
||||
|
||||
let request_for_thread = request.clone();
|
||||
let thread = std::thread::spawn(move || handle_request(request_for_thread, &tx));
|
||||
let thread = std::thread::spawn(move || handle_request(request_for_thread, &tx, None));
|
||||
|
||||
let msg = rx.blocking_recv().unwrap();
|
||||
assert_eq!(msg.request.id, "req_2");
|
||||
|
|
@ -692,7 +772,7 @@ mod tests {
|
|||
let event_hub = EventHub::default();
|
||||
let (done_tx, done_rx) = std::sync::mpsc::channel();
|
||||
let server_thread = std::thread::spawn(move || {
|
||||
let result = handle_connection(server, &api_tx, &event_hub, &server_running);
|
||||
let result = handle_connection(server, &api_tx, &event_hub, &server_running, None);
|
||||
done_tx.send(result).unwrap();
|
||||
});
|
||||
|
||||
|
|
@ -724,7 +804,7 @@ mod tests {
|
|||
let event_hub = EventHub::default();
|
||||
let (done_tx, done_rx) = std::sync::mpsc::channel();
|
||||
let server_thread = std::thread::spawn(move || {
|
||||
let result = handle_connection(server, &api_tx, &event_hub, &server_running);
|
||||
let result = handle_connection(server, &api_tx, &event_hub, &server_running, None);
|
||||
done_tx.send(result).unwrap();
|
||||
});
|
||||
|
||||
|
|
@ -756,7 +836,7 @@ mod tests {
|
|||
let event_hub = EventHub::default();
|
||||
let (done_tx, done_rx) = std::sync::mpsc::channel();
|
||||
let server_thread = std::thread::spawn(move || {
|
||||
let result = handle_connection(server, &api_tx, &event_hub, &server_running);
|
||||
let result = handle_connection(server, &api_tx, &event_hub, &server_running, None);
|
||||
done_tx.send(result).unwrap();
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ use crate::api::schema::{Method, Request, ResponseResult};
|
|||
pub struct RuntimeStatus {
|
||||
pub version: Option<String>,
|
||||
pub protocol: Option<u32>,
|
||||
pub capabilities: Option<crate::api::schema::ServerCapabilities>,
|
||||
}
|
||||
|
||||
pub fn read_runtime_status_at(
|
||||
|
|
@ -43,9 +44,14 @@ pub fn read_runtime_status_at(
|
|||
Err(err) => return Err(io::Error::other(err)),
|
||||
};
|
||||
match response.result {
|
||||
ResponseResult::Pong { version, protocol } => Ok(Some(RuntimeStatus {
|
||||
ResponseResult::Pong {
|
||||
version,
|
||||
protocol,
|
||||
capabilities,
|
||||
} => Ok(Some(RuntimeStatus {
|
||||
version: Some(version),
|
||||
protocol: Some(protocol),
|
||||
capabilities,
|
||||
})),
|
||||
result => Err(io::Error::other(format!(
|
||||
"server status request returned unexpected result: {result:?}"
|
||||
|
|
|
|||
|
|
@ -330,7 +330,9 @@ impl App {
|
|||
&mut self,
|
||||
request: crate::api::schema::Request,
|
||||
) -> String {
|
||||
use crate::api::schema::{Method, ResponseResult, SuccessResponse};
|
||||
use crate::api::schema::{
|
||||
ErrorBody, ErrorResponse, Method, ResponseResult, SuccessResponse,
|
||||
};
|
||||
|
||||
let response = match request.method {
|
||||
Method::ServerStop(_) => {
|
||||
|
|
@ -340,6 +342,16 @@ impl App {
|
|||
result: ResponseResult::Ok {},
|
||||
}
|
||||
}
|
||||
Method::ServerLiveHandoff(_) => {
|
||||
let response = ErrorResponse {
|
||||
id: request.id,
|
||||
error: ErrorBody {
|
||||
code: "unsupported_in_app_mode".into(),
|
||||
message: "live handoff is only supported by the headless server".into(),
|
||||
},
|
||||
};
|
||||
return serde_json::to_string(&response).unwrap_or_else(|_| "{}".to_string());
|
||||
}
|
||||
Method::ServerReloadConfig(_) => {
|
||||
let report = self.reload_config();
|
||||
SuccessResponse {
|
||||
|
|
|
|||
|
|
@ -555,6 +555,70 @@ impl App {
|
|||
}
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
pub fn new_from_handoff(
|
||||
config: &Config,
|
||||
config_diagnostic: Option<String>,
|
||||
api_rx: tokio::sync::mpsc::UnboundedReceiver<crate::api::ApiRequestMessage>,
|
||||
event_hub: crate::api::EventHub,
|
||||
snapshot: &crate::persist::SessionSnapshot,
|
||||
imports: &mut std::collections::HashMap<u32, crate::persist::ImportedPaneRuntime>,
|
||||
) -> io::Result<Self> {
|
||||
let mut app = Self::new(config, true, config_diagnostic, api_rx, event_hub);
|
||||
let (workspaces, terminals, runtimes) = crate::persist::restore_handoff(
|
||||
snapshot,
|
||||
config.advanced.scrollback_limit_bytes,
|
||||
&config.terminal.default_shell,
|
||||
imports,
|
||||
app.event_tx.clone(),
|
||||
app.render_notify.clone(),
|
||||
app.render_dirty.clone(),
|
||||
)?;
|
||||
|
||||
app.no_session = false;
|
||||
app.state.detach_exits = false;
|
||||
app.state.workspaces = workspaces;
|
||||
app.state.terminals = terminals;
|
||||
app.terminal_runtimes = runtimes.into();
|
||||
app.state.active = snapshot
|
||||
.active
|
||||
.filter(|&idx| idx < app.state.workspaces.len());
|
||||
app.state.selected = snapshot
|
||||
.selected
|
||||
.min(app.state.workspaces.len().saturating_sub(1));
|
||||
app.state.agent_panel_scope = snapshot.agent_panel_scope;
|
||||
if let Some(width) = snapshot.sidebar_width {
|
||||
app.state.sidebar_width = width;
|
||||
app.state.sidebar_width_source = state::SidebarWidthSource::Persisted;
|
||||
}
|
||||
if let Some(split) = snapshot.sidebar_section_split {
|
||||
app.state.sidebar_section_split = split;
|
||||
}
|
||||
app.state.collapsed_space_keys = snapshot.collapsed_space_keys.clone();
|
||||
app.state.mode = if app.state.active.is_some() {
|
||||
state::Mode::Terminal
|
||||
} else {
|
||||
state::Mode::Navigate
|
||||
};
|
||||
app.last_focus = app.state.active.and_then(|idx| {
|
||||
app.state
|
||||
.workspaces
|
||||
.get(idx)
|
||||
.and_then(|ws| ws.focused_pane_id().map(|pane_id| (idx, pane_id)))
|
||||
});
|
||||
Ok(app)
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
pub fn unpause_handoff_readers(&self) {
|
||||
self.terminal_runtimes.set_handoff_readers_paused(false);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
pub fn assume_handoff_ownership(&mut self) {
|
||||
self.terminal_runtimes.assume_handoff_ownership();
|
||||
}
|
||||
|
||||
fn request_full_redraw(&mut self) {
|
||||
self.full_redraw_pending = true;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
use crate::api::schema::{EmptyParams, Method, Request};
|
||||
use crate::api::schema::{EmptyParams, Method, Request, ServerLiveHandoffParams};
|
||||
|
||||
pub(super) fn run_server_command(args: &[String]) -> std::io::Result<Option<i32>> {
|
||||
let Some(subcommand) = args.first().map(|arg| arg.as_str()) else {
|
||||
|
|
@ -7,6 +7,8 @@ pub(super) fn run_server_command(args: &[String]) -> std::io::Result<Option<i32>
|
|||
|
||||
match subcommand {
|
||||
"stop" => server_stop(&args[1..]).map(Some),
|
||||
"live-handoff" => server_live_handoff(&args[1..]).map(Some),
|
||||
"--handoff-import" => Ok(None),
|
||||
"reload-config" => server_reload_config(&args[1..]).map(Some),
|
||||
"help" | "--help" | "-h" => {
|
||||
print_server_help();
|
||||
|
|
@ -40,9 +42,92 @@ fn server_reload_config(args: &[String]) -> std::io::Result<i32> {
|
|||
})?)
|
||||
}
|
||||
|
||||
fn server_live_handoff(args: &[String]) -> std::io::Result<i32> {
|
||||
let Some(params) = parse_live_handoff_params(args) else {
|
||||
eprintln!(
|
||||
"usage: herdr server live-handoff [--import-exe <path>] [--expected-protocol <n>] [--expected-version <version>]"
|
||||
);
|
||||
return Ok(2);
|
||||
};
|
||||
|
||||
let response = super::send_request(&Request {
|
||||
id: "cli:server:live-handoff".into(),
|
||||
method: Method::ServerLiveHandoff(params),
|
||||
})?;
|
||||
if response.get("error").is_some() {
|
||||
let rendered = serde_json::to_string(&response).unwrap_or_else(|err| {
|
||||
format!(
|
||||
"{{\"error\":{{\"code\":\"render_failed\",\"message\":\"failed to render error response: {err}\"}}}}"
|
||||
)
|
||||
});
|
||||
eprintln!("{rendered}");
|
||||
return Ok(1);
|
||||
}
|
||||
|
||||
eprintln!(
|
||||
"live handoff complete; server log: {}",
|
||||
crate::session::data_dir()
|
||||
.join("herdr-server.log")
|
||||
.display()
|
||||
);
|
||||
Ok(0)
|
||||
}
|
||||
|
||||
fn parse_live_handoff_params(args: &[String]) -> Option<ServerLiveHandoffParams> {
|
||||
let mut params = ServerLiveHandoffParams::default();
|
||||
let mut idx = 0;
|
||||
while idx < args.len() {
|
||||
let arg = &args[idx];
|
||||
let (flag, value) = if let Some((flag, value)) = arg.split_once('=') {
|
||||
(flag, Some(value.to_string()))
|
||||
} else {
|
||||
let value = args.get(idx + 1).cloned();
|
||||
idx += 1;
|
||||
(arg.as_str(), value)
|
||||
};
|
||||
let value = value?;
|
||||
match flag {
|
||||
"--import-exe" => params.import_exe = Some(value),
|
||||
"--expected-protocol" => {
|
||||
params.expected_protocol = Some(value.parse().ok()?);
|
||||
}
|
||||
"--expected-version" => params.expected_version = Some(value),
|
||||
_ => return None,
|
||||
}
|
||||
idx += 1;
|
||||
}
|
||||
Some(params)
|
||||
}
|
||||
|
||||
fn print_server_help() {
|
||||
eprintln!("herdr server commands:");
|
||||
eprintln!(" herdr server run as headless server");
|
||||
eprintln!(" herdr server stop stop the running server via the API socket");
|
||||
eprintln!(" herdr server live-handoff hand off live panes to a new local server");
|
||||
eprintln!(" herdr server reload-config reload config.toml in the running server");
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn live_handoff_params_parse_remote_update_fields() {
|
||||
let args = vec![
|
||||
"--import-exe".to_string(),
|
||||
"/home/me/.local/bin/herdr".to_string(),
|
||||
"--expected-protocol=9".to_string(),
|
||||
"--expected-version".to_string(),
|
||||
"0.6.2".to_string(),
|
||||
];
|
||||
|
||||
let params = parse_live_handoff_params(&args).expect("params");
|
||||
|
||||
assert_eq!(
|
||||
params.import_exe.as_deref(),
|
||||
Some("/home/me/.local/bin/herdr")
|
||||
);
|
||||
assert_eq!(params.expected_protocol, Some(9));
|
||||
assert_eq!(params.expected_version.as_deref(), Some("0.6.2"));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -74,6 +74,7 @@ enum ServerRuntimeStatus {
|
|||
Running {
|
||||
version: Option<String>,
|
||||
protocol: Option<u32>,
|
||||
capabilities: Option<crate::api::schema::ServerCapabilities>,
|
||||
},
|
||||
NotRunning,
|
||||
}
|
||||
|
|
@ -127,7 +128,9 @@ fn print_client_status(json: bool) -> std::io::Result<()> {
|
|||
|
||||
fn print_server_status_body(server: &ServerRuntimeStatus, indent: &str) {
|
||||
match server {
|
||||
ServerRuntimeStatus::Running { version, protocol } => {
|
||||
ServerRuntimeStatus::Running {
|
||||
version, protocol, ..
|
||||
} => {
|
||||
println!("{indent}status: running");
|
||||
println!("{indent}version: {}", option_label(version.as_deref()));
|
||||
println!("{indent}protocol: {}", protocol_label(*protocol));
|
||||
|
|
@ -146,6 +149,7 @@ fn read_server_runtime_status() -> std::io::Result<ServerRuntimeStatus> {
|
|||
Ok(status) => Ok(ServerRuntimeStatus::Running {
|
||||
version: status.version,
|
||||
protocol: status.protocol,
|
||||
capabilities: status.capabilities,
|
||||
}),
|
||||
Err(ApiClientError::Io(err)) if server_not_running_error(&err) => {
|
||||
Ok(ServerRuntimeStatus::NotRunning)
|
||||
|
|
@ -218,12 +222,18 @@ struct ServerStatusJson {
|
|||
running: bool,
|
||||
version: Option<String>,
|
||||
protocol: Option<u32>,
|
||||
capabilities: Option<ServerCapabilitiesJson>,
|
||||
compatible: Option<bool>,
|
||||
socket: String,
|
||||
session: Option<String>,
|
||||
restart_needed: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct ServerCapabilitiesJson {
|
||||
live_handoff: bool,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct UpdateStatusJson {
|
||||
restart_needed: Option<bool>,
|
||||
|
|
@ -240,11 +250,20 @@ fn client_status_json() -> ClientStatusJson {
|
|||
|
||||
fn server_status_json(server: &ServerRuntimeStatus) -> ServerStatusJson {
|
||||
match server {
|
||||
ServerRuntimeStatus::Running { version, protocol } => ServerStatusJson {
|
||||
ServerRuntimeStatus::Running {
|
||||
version,
|
||||
protocol,
|
||||
capabilities,
|
||||
} => ServerStatusJson {
|
||||
status: "running",
|
||||
running: true,
|
||||
version: version.clone(),
|
||||
protocol: *protocol,
|
||||
capabilities: capabilities
|
||||
.as_ref()
|
||||
.map(|capabilities| ServerCapabilitiesJson {
|
||||
live_handoff: capabilities.live_handoff,
|
||||
}),
|
||||
compatible: protocol.map(|value| value == crate::protocol::PROTOCOL_VERSION),
|
||||
socket: api::socket_path().display().to_string(),
|
||||
session: crate::session::active_name(),
|
||||
|
|
@ -255,6 +274,7 @@ fn server_status_json(server: &ServerRuntimeStatus) -> ServerStatusJson {
|
|||
running: false,
|
||||
version: None,
|
||||
protocol: None,
|
||||
capabilities: None,
|
||||
compatible: None,
|
||||
socket: api::socket_path().display().to_string(),
|
||||
session: crate::session::active_name(),
|
||||
|
|
|
|||
36
src/ipc.rs
36
src/ipc.rs
|
|
@ -1,9 +1,16 @@
|
|||
use std::fs;
|
||||
use std::io;
|
||||
use std::os::unix::fs::MetadataExt;
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
use std::os::unix::net::UnixStream;
|
||||
use std::path::Path;
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub(crate) struct SocketFileIdentity {
|
||||
dev: u64,
|
||||
ino: u64,
|
||||
}
|
||||
|
||||
pub(crate) fn prepare_socket_path(
|
||||
path: &Path,
|
||||
busy_message: impl FnOnce(&Path) -> String,
|
||||
|
|
@ -39,6 +46,35 @@ pub(crate) fn prepare_socket_path(
|
|||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn socket_file_identity(path: &Path) -> io::Result<SocketFileIdentity> {
|
||||
let metadata = fs::metadata(path)?;
|
||||
Ok(SocketFileIdentity {
|
||||
dev: metadata.dev(),
|
||||
ino: metadata.ino(),
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn remove_socket_file_if_owned(
|
||||
path: &Path,
|
||||
identity: SocketFileIdentity,
|
||||
) -> io::Result<()> {
|
||||
let current = match socket_file_identity(path) {
|
||||
Ok(current) => current,
|
||||
Err(err) if err.kind() == io::ErrorKind::NotFound => return Ok(()),
|
||||
Err(err) => return Err(err),
|
||||
};
|
||||
|
||||
if current != identity {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
match fs::remove_file(path) {
|
||||
Ok(()) => Ok(()),
|
||||
Err(err) if err.kind() == io::ErrorKind::NotFound => Ok(()),
|
||||
Err(err) => Err(err),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn restrict_socket_permissions(path: &Path, mode: u32) -> io::Result<()> {
|
||||
let mut permissions = fs::metadata(path)?.permissions();
|
||||
permissions.set_mode(mode);
|
||||
|
|
|
|||
|
|
@ -524,7 +524,7 @@ fn main() -> io::Result<()> {
|
|||
|
||||
let (api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel();
|
||||
let event_hub = api::EventHub::default();
|
||||
let _api_server = match api::start_server(api_tx, event_hub.clone()) {
|
||||
let _api_server = match api::start_server_with_capabilities(api_tx, event_hub.clone(), None) {
|
||||
Ok(server) => server,
|
||||
Err(err) if err.kind() == io::ErrorKind::AddrInUse => {
|
||||
eprintln!("error: herdr is already running");
|
||||
|
|
|
|||
766
src/pane.rs
766
src/pane.rs
|
|
@ -1,12 +1,12 @@
|
|||
use std::cell::Cell;
|
||||
use std::io::{BufWriter, Write};
|
||||
use std::io::{Read, Write};
|
||||
use std::sync::{
|
||||
atomic::{AtomicBool, AtomicU16, AtomicU32, Ordering},
|
||||
Arc, Mutex,
|
||||
};
|
||||
|
||||
use bytes::Bytes;
|
||||
use portable_pty::{native_pty_system, CommandBuilder, PtySize};
|
||||
use portable_pty::{native_pty_system, CommandBuilder, MasterPty, PtySize};
|
||||
use ratatui::{layout::Rect, Frame};
|
||||
use tokio::sync::{mpsc, watch, Notify};
|
||||
use tracing::{debug, error, info, warn};
|
||||
|
|
@ -168,6 +168,104 @@ fn should_publish_detection_update(
|
|||
|| (next.visible_idle && previous.visible_idle)
|
||||
}
|
||||
|
||||
fn spawn_basic_detection_task(
|
||||
pane_id: PaneId,
|
||||
child_pid: Arc<AtomicU32>,
|
||||
terminal: Arc<PaneTerminal>,
|
||||
state_events: mpsc::Sender<AppEvent>,
|
||||
) -> (
|
||||
tokio::task::AbortHandle,
|
||||
Arc<Notify>,
|
||||
Arc<Mutex<Option<PendingAgentRelease>>>,
|
||||
) {
|
||||
let detect_reset_notify = Arc::new(Notify::new());
|
||||
let detect_reset = detect_reset_notify.clone();
|
||||
let pending_release = Arc::new(Mutex::new(None));
|
||||
let pending_release_for_task = pending_release.clone();
|
||||
|
||||
let handle = tokio::spawn(async move {
|
||||
let mut agent_presence = AgentDetectionPresence::from_agent(None);
|
||||
let mut state = AgentState::Unknown;
|
||||
let mut last_visible_blocker = false;
|
||||
let mut last_visible_idle = false;
|
||||
let mut last_visible_working = false;
|
||||
|
||||
loop {
|
||||
tokio::select! {
|
||||
_ = tokio::time::sleep(std::time::Duration::from_millis(300)) => {}
|
||||
_ = detect_reset.notified() => {
|
||||
agent_presence = AgentDetectionPresence::from_agent(None);
|
||||
state = AgentState::Unknown;
|
||||
last_visible_blocker = false;
|
||||
last_visible_idle = false;
|
||||
last_visible_working = false;
|
||||
}
|
||||
}
|
||||
|
||||
let now = std::time::Instant::now();
|
||||
let pid = child_pid.load(Ordering::Acquire);
|
||||
let mut agent_changed = false;
|
||||
let mut agent = agent_presence.current_agent();
|
||||
|
||||
if pid > 0 {
|
||||
let new_agent = crate::detect::foreground_job(pid).and_then(|job| {
|
||||
crate::detect::identify_agent_in_job(&job).map(|(agent, _)| agent)
|
||||
});
|
||||
let previous_agent = agent_presence.current_agent();
|
||||
if agent_presence.observe_process_probe(new_agent) {
|
||||
agent = agent_presence.current_agent();
|
||||
agent_changed = previous_agent != agent;
|
||||
}
|
||||
}
|
||||
|
||||
let content = terminal.detection_text();
|
||||
let detection = crate::detect::detect_agent(agent, &content);
|
||||
let new_state = detection.state;
|
||||
let visible_blocker = detection.visible_blocker && new_state == AgentState::Blocked;
|
||||
let visible_idle = detection.visible_idle && new_state == AgentState::Idle;
|
||||
let visible_working = detection.visible_working && new_state == AgentState::Working;
|
||||
|
||||
if should_publish_detection_update(
|
||||
DetectionPublishState {
|
||||
state,
|
||||
visible_blocker: last_visible_blocker,
|
||||
visible_idle: last_visible_idle,
|
||||
visible_working: last_visible_working,
|
||||
},
|
||||
DetectionPublishState {
|
||||
state: new_state,
|
||||
visible_blocker,
|
||||
visible_idle,
|
||||
visible_working,
|
||||
},
|
||||
agent_changed,
|
||||
false,
|
||||
) {
|
||||
state = new_state;
|
||||
last_visible_blocker = visible_blocker;
|
||||
last_visible_idle = visible_idle;
|
||||
last_visible_working = visible_working;
|
||||
publish_state_changed_event(
|
||||
state_events.clone(),
|
||||
pane_id,
|
||||
agent,
|
||||
new_state,
|
||||
visible_blocker,
|
||||
visible_idle,
|
||||
visible_working,
|
||||
false,
|
||||
now,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
let _ = active_pending_release(&pending_release_for_task, now);
|
||||
}
|
||||
});
|
||||
|
||||
(handle.abort_handle(), detect_reset_notify, pending_release)
|
||||
}
|
||||
|
||||
impl AgentDetectionPresence {
|
||||
fn from_agent(current_agent: Option<Agent>) -> Self {
|
||||
Self {
|
||||
|
|
@ -230,13 +328,34 @@ pub struct PaneRuntime {
|
|||
resize_tx: watch::Sender<(u16, u16, u32, u32)>,
|
||||
current_size: Cell<(u16, u16, u32, u32)>,
|
||||
child_pid: Arc<AtomicU32>,
|
||||
pty_master: Option<Box<dyn MasterPty + Send>>,
|
||||
raw_master_fd: Option<std::os::fd::RawFd>,
|
||||
force_resize_fd: Option<std::os::fd::RawFd>,
|
||||
io_stop: Arc<AtomicBool>,
|
||||
reader_paused: Arc<AtomicBool>,
|
||||
reader_pause_ack: Arc<AtomicBool>,
|
||||
reader_stopped_rx: Option<std::sync::mpsc::Receiver<()>>,
|
||||
kitty_keyboard_flags: Arc<AtomicU16>,
|
||||
detect_reset_notify: Arc<Notify>,
|
||||
pending_release: Arc<Mutex<Option<PendingAgentRelease>>>,
|
||||
preserve_processes_on_drop: bool,
|
||||
// Task handles for deterministic shutdown
|
||||
detect_handle: tokio::task::AbortHandle,
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[derive(Debug)]
|
||||
pub struct PaneRuntimeImport {
|
||||
pub pane_id: PaneId,
|
||||
pub master_fd: std::os::fd::RawFd,
|
||||
pub child_pid: u32,
|
||||
pub rows: u16,
|
||||
pub cols: u16,
|
||||
pub cell_width_px: u32,
|
||||
pub cell_height_px: u32,
|
||||
pub initial_history_ansi: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum WheelRouting {
|
||||
HostScroll,
|
||||
|
|
@ -250,7 +369,16 @@ impl Drop for PaneRuntime {
|
|||
// Reader/writer/resize tasks shut down naturally via channel close
|
||||
// and PTY EOF when the rest of PaneRuntime is dropped.
|
||||
self.detect_handle.abort();
|
||||
shutdown_pane_processes(self.pane_id, self.child_pid.load(Ordering::Acquire));
|
||||
self.io_stop.store(true, Ordering::Release);
|
||||
if !self.preserve_processes_on_drop {
|
||||
shutdown_pane_processes(self.pane_id, self.child_pid.load(Ordering::Acquire));
|
||||
}
|
||||
if let Some(fd) = self.raw_master_fd.take() {
|
||||
let _ = unsafe { libc::close(fd) };
|
||||
}
|
||||
if let Some(fd) = self.force_resize_fd.take() {
|
||||
let _ = unsafe { libc::close(fd) };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -316,6 +444,22 @@ fn shutdown_pane_processes(pane_id: PaneId, child_pid: u32) {
|
|||
);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn truncate_handoff_history(history: String, max_bytes: usize) -> String {
|
||||
if history.len() <= max_bytes {
|
||||
return history;
|
||||
}
|
||||
let mut start = history.len().saturating_sub(max_bytes);
|
||||
while !history.is_char_boundary(start) {
|
||||
start += 1;
|
||||
}
|
||||
let Some(newline_offset) = history[start..].find('\n') else {
|
||||
return String::new();
|
||||
};
|
||||
start += newline_offset + 1;
|
||||
history[start..].to_owned()
|
||||
}
|
||||
|
||||
fn pane_shell(configured_shell: &str) -> String {
|
||||
pane_shell_from(configured_shell, std::env::var("SHELL").ok())
|
||||
}
|
||||
|
|
@ -368,12 +512,243 @@ fn restore_command_builder(agent: &str, fallback_shell: &str, argv: &[String]) -
|
|||
cmd
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn duplicate_fd(fd: std::os::fd::RawFd) -> std::io::Result<std::os::fd::RawFd> {
|
||||
let duplicated = unsafe { libc::dup(fd) };
|
||||
if duplicated < 0 {
|
||||
return Err(std::io::Error::last_os_error());
|
||||
}
|
||||
Ok(duplicated)
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn set_cloexec(fd: std::os::fd::RawFd) -> std::io::Result<()> {
|
||||
let flags = unsafe { libc::fcntl(fd, libc::F_GETFD) };
|
||||
if flags < 0 {
|
||||
return Err(std::io::Error::last_os_error());
|
||||
}
|
||||
if unsafe { libc::fcntl(fd, libc::F_SETFD, flags | libc::FD_CLOEXEC) } < 0 {
|
||||
return Err(std::io::Error::last_os_error());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn set_nonblocking(fd: std::os::fd::RawFd) -> std::io::Result<()> {
|
||||
let flags = unsafe { libc::fcntl(fd, libc::F_GETFL) };
|
||||
if flags < 0 {
|
||||
return Err(std::io::Error::last_os_error());
|
||||
}
|
||||
if unsafe { libc::fcntl(fd, libc::F_SETFL, flags | libc::O_NONBLOCK) } < 0 {
|
||||
return Err(std::io::Error::last_os_error());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn duplicate_cloexec_fd(fd: std::os::fd::RawFd) -> std::io::Result<std::os::fd::RawFd> {
|
||||
let duplicated = duplicate_fd(fd)?;
|
||||
if let Err(err) = set_cloexec(duplicated) {
|
||||
let _ = unsafe { libc::close(duplicated) };
|
||||
return Err(err);
|
||||
}
|
||||
Ok(duplicated)
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn file_from_duplicated_fd(fd: std::os::fd::RawFd) -> std::io::Result<std::fs::File> {
|
||||
use std::os::fd::FromRawFd;
|
||||
|
||||
let duplicated = duplicate_cloexec_fd(fd)?;
|
||||
Ok(unsafe { std::fs::File::from_raw_fd(duplicated) })
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn poll_read_ready(fd: std::os::fd::RawFd, timeout_ms: i32) -> std::io::Result<bool> {
|
||||
let mut poll_fd = libc::pollfd {
|
||||
fd,
|
||||
events: libc::POLLIN,
|
||||
revents: 0,
|
||||
};
|
||||
loop {
|
||||
let result = unsafe { libc::poll(&mut poll_fd, 1, timeout_ms) };
|
||||
if result < 0 {
|
||||
let err = std::io::Error::last_os_error();
|
||||
if err.kind() == std::io::ErrorKind::Interrupted {
|
||||
continue;
|
||||
}
|
||||
return Err(err);
|
||||
}
|
||||
return Ok(result > 0 && (poll_fd.revents & (libc::POLLIN | libc::POLLHUP)) != 0);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn poll_write_ready(fd: std::os::fd::RawFd, timeout_ms: i32) -> std::io::Result<bool> {
|
||||
let mut poll_fd = libc::pollfd {
|
||||
fd,
|
||||
events: libc::POLLOUT,
|
||||
revents: 0,
|
||||
};
|
||||
loop {
|
||||
let result = unsafe { libc::poll(&mut poll_fd, 1, timeout_ms) };
|
||||
if result < 0 {
|
||||
let err = std::io::Error::last_os_error();
|
||||
if err.kind() == std::io::ErrorKind::Interrupted {
|
||||
continue;
|
||||
}
|
||||
return Err(err);
|
||||
}
|
||||
return Ok(result > 0 && (poll_fd.revents & (libc::POLLOUT | libc::POLLHUP)) != 0);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn write_all_nonblocking(
|
||||
writer: &mut std::fs::File,
|
||||
fd: std::os::fd::RawFd,
|
||||
mut bytes: &[u8],
|
||||
io_stop: &AtomicBool,
|
||||
) -> std::io::Result<()> {
|
||||
while !bytes.is_empty() {
|
||||
if io_stop.load(Ordering::Acquire) {
|
||||
return Ok(());
|
||||
}
|
||||
match writer.write(bytes) {
|
||||
Ok(0) => {
|
||||
return Err(std::io::Error::new(
|
||||
std::io::ErrorKind::WriteZero,
|
||||
"pty write returned zero bytes",
|
||||
));
|
||||
}
|
||||
Ok(written) => bytes = &bytes[written..],
|
||||
Err(err) if err.kind() == std::io::ErrorKind::WouldBlock => {
|
||||
let _ = poll_write_ready(fd, 50)?;
|
||||
}
|
||||
Err(err) if err.kind() == std::io::ErrorKind::Interrupted => {}
|
||||
Err(err) => return Err(err),
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn resize_pty_fd(
|
||||
fd: std::os::fd::RawFd,
|
||||
rows: u16,
|
||||
cols: u16,
|
||||
cell_width_px: u32,
|
||||
cell_height_px: u32,
|
||||
) -> std::io::Result<()> {
|
||||
let size = libc::winsize {
|
||||
ws_row: rows,
|
||||
ws_col: cols,
|
||||
ws_xpixel: (cols as u32)
|
||||
.saturating_mul(cell_width_px)
|
||||
.min(u16::MAX as u32) as u16,
|
||||
ws_ypixel: (rows as u32)
|
||||
.saturating_mul(cell_height_px)
|
||||
.min(u16::MAX as u32) as u16,
|
||||
};
|
||||
if unsafe { libc::ioctl(fd, libc::TIOCSWINSZ, &size) } < 0 {
|
||||
return Err(std::io::Error::last_os_error());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
impl PaneRuntime {
|
||||
#[cfg(unix)]
|
||||
fn master_fd(&self) -> Option<std::os::fd::RawFd> {
|
||||
self.raw_master_fd.or_else(|| {
|
||||
self.pty_master
|
||||
.as_ref()
|
||||
.and_then(|master| master.as_raw_fd())
|
||||
})
|
||||
}
|
||||
|
||||
pub fn shutdown(self) {
|
||||
self.detect_handle.abort();
|
||||
shutdown_pane_processes(self.pane_id, self.child_pid.load(Ordering::Acquire));
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
pub fn duplicate_handoff_fd(&self) -> std::io::Result<std::os::fd::RawFd> {
|
||||
let master_fd = self
|
||||
.master_fd()
|
||||
.ok_or_else(|| std::io::Error::other("runtime has no PTY master fd"))?;
|
||||
duplicate_cloexec_fd(master_fd)
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
pub fn preserve_for_handoff(mut self) {
|
||||
self.io_stop.store(true, Ordering::Release);
|
||||
if let Some(reader_stopped_rx) = self.reader_stopped_rx.take() {
|
||||
let _ = reader_stopped_rx.recv_timeout(std::time::Duration::from_millis(500));
|
||||
}
|
||||
self.detect_handle.abort();
|
||||
self.preserve_processes_on_drop = true;
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
pub fn assume_handoff_ownership(&mut self) {
|
||||
self.preserve_processes_on_drop = false;
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
pub fn set_handoff_reader_paused(&self, paused: bool) {
|
||||
self.reader_paused.store(paused, Ordering::Release);
|
||||
if !paused {
|
||||
self.reader_pause_ack.store(false, Ordering::Release);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
pub fn pause_handoff_reader(&self, timeout: std::time::Duration) -> std::io::Result<()> {
|
||||
self.reader_pause_ack.store(false, Ordering::Release);
|
||||
self.reader_paused.store(true, Ordering::Release);
|
||||
let deadline = std::time::Instant::now() + timeout;
|
||||
while std::time::Instant::now() < deadline {
|
||||
if self.reader_pause_ack.load(Ordering::Acquire) || self.io_stop.load(Ordering::Acquire)
|
||||
{
|
||||
return Ok(());
|
||||
}
|
||||
std::thread::sleep(std::time::Duration::from_millis(5));
|
||||
}
|
||||
Err(std::io::Error::new(
|
||||
std::io::ErrorKind::TimedOut,
|
||||
"timed out waiting for pane reader to pause for handoff",
|
||||
))
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
pub fn handoff_pane(&self, pane_id: u32) -> crate::server::handoff::HandoffPane {
|
||||
let child_pid = self.child_pid.load(Ordering::Acquire);
|
||||
let (rows, cols, cell_width_px, cell_height_px) = self.current_size.get();
|
||||
crate::server::handoff::HandoffPane {
|
||||
pane_id,
|
||||
child_pid,
|
||||
rows,
|
||||
cols,
|
||||
cell_width_px,
|
||||
cell_height_px,
|
||||
initial_history_ansi: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
pub fn handoff_history_ansi(&self) -> Option<String> {
|
||||
if self
|
||||
.terminal
|
||||
.input_state()
|
||||
.is_some_and(|input_state| input_state.alternate_screen)
|
||||
{
|
||||
return None;
|
||||
}
|
||||
self.snapshot_history().map(|history| {
|
||||
truncate_handoff_history(history, crate::server::handoff::MAX_REPLAY_BYTES_PER_PANE)
|
||||
})
|
||||
}
|
||||
|
||||
pub fn apply_host_terminal_theme(&self, theme: crate::terminal_theme::TerminalTheme) {
|
||||
self.terminal.apply_host_terminal_theme(theme);
|
||||
}
|
||||
|
|
@ -565,6 +940,213 @@ impl PaneRuntime {
|
|||
)
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
pub fn from_handoff_fd(
|
||||
import: PaneRuntimeImport,
|
||||
scrollback_limit_bytes: usize,
|
||||
host_terminal_theme: crate::terminal_theme::TerminalTheme,
|
||||
events: mpsc::Sender<AppEvent>,
|
||||
render_notify: Arc<Notify>,
|
||||
render_dirty: Arc<AtomicBool>,
|
||||
) -> std::io::Result<Self> {
|
||||
let PaneRuntimeImport {
|
||||
pane_id,
|
||||
master_fd,
|
||||
child_pid,
|
||||
rows,
|
||||
cols,
|
||||
cell_width_px,
|
||||
cell_height_px,
|
||||
initial_history_ansi,
|
||||
} = import;
|
||||
use std::os::fd::{AsRawFd, FromRawFd, IntoRawFd};
|
||||
|
||||
let master_fd = unsafe { std::os::fd::OwnedFd::from_raw_fd(master_fd) };
|
||||
set_cloexec(master_fd.as_raw_fd())?;
|
||||
set_nonblocking(master_fd.as_raw_fd())?;
|
||||
let reader = file_from_duplicated_fd(master_fd.as_raw_fd())?;
|
||||
let writer = file_from_duplicated_fd(master_fd.as_raw_fd())?;
|
||||
let force_resize_fd = duplicate_cloexec_fd(master_fd.as_raw_fd())?;
|
||||
let resize_fd = unsafe {
|
||||
std::os::fd::OwnedFd::from_raw_fd(duplicate_cloexec_fd(master_fd.as_raw_fd())?)
|
||||
};
|
||||
let io_stop = Arc::new(AtomicBool::new(false));
|
||||
let reader_paused = Arc::new(AtomicBool::new(true));
|
||||
let reader_pause_ack = Arc::new(AtomicBool::new(false));
|
||||
|
||||
let (input_tx, mut input_rx) = mpsc::channel::<Bytes>(32);
|
||||
let mut terminal = crate::ghostty::Terminal::new(cols, rows, scrollback_limit_bytes)
|
||||
.map_err(|e| std::io::Error::other(e.to_string()))?;
|
||||
if crate::kitty_graphics::is_enabled() {
|
||||
terminal
|
||||
.enable_kitty_graphics()
|
||||
.map_err(|e| std::io::Error::other(e.to_string()))?;
|
||||
}
|
||||
let pane_terminal = GhosttyPaneTerminal::new(terminal, input_tx.clone())?;
|
||||
pane_terminal.apply_host_terminal_theme(host_terminal_theme);
|
||||
if let Some(ansi) = initial_history_ansi.as_deref() {
|
||||
pane_terminal.seed_history_ansi(ansi);
|
||||
}
|
||||
let terminal = Arc::new(PaneTerminal::new(pane_terminal));
|
||||
let child_pid = Arc::new(AtomicU32::new(child_pid));
|
||||
let kitty_keyboard_flags = Arc::new(AtomicU16::new(0));
|
||||
let (reader_stopped_tx, reader_stopped_rx) = std::sync::mpsc::channel();
|
||||
|
||||
{
|
||||
use std::os::fd::AsRawFd;
|
||||
|
||||
let mut reader = reader;
|
||||
let reader_fd = reader.as_raw_fd();
|
||||
let terminal = terminal.clone();
|
||||
let response_writer = input_tx.clone();
|
||||
let render_notify = render_notify.clone();
|
||||
let render_dirty = render_dirty.clone();
|
||||
let child_pid = child_pid.clone();
|
||||
let events = events.clone();
|
||||
let io_stop = io_stop.clone();
|
||||
let reader_paused = reader_paused.clone();
|
||||
let reader_pause_ack = reader_pause_ack.clone();
|
||||
let rt = tokio::runtime::Handle::current();
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let mut buf = [0u8; 8192];
|
||||
loop {
|
||||
if io_stop.load(Ordering::Acquire) {
|
||||
break;
|
||||
}
|
||||
if reader_paused.load(Ordering::Acquire) {
|
||||
reader_pause_ack.store(true, Ordering::Release);
|
||||
std::thread::sleep(std::time::Duration::from_millis(5));
|
||||
continue;
|
||||
}
|
||||
reader_pause_ack.store(false, Ordering::Release);
|
||||
match poll_read_ready(reader_fd, 50) {
|
||||
Ok(true) => {}
|
||||
Ok(false) => continue,
|
||||
Err(e) => {
|
||||
debug!(pane = pane_id.raw(), err = %e, "handoff pty reader poll failed");
|
||||
break;
|
||||
}
|
||||
}
|
||||
match reader.read(&mut buf) {
|
||||
Ok(0) => break,
|
||||
Err(e) if e.kind() == std::io::ErrorKind::WouldBlock => continue,
|
||||
Err(e) => {
|
||||
debug!(pane = pane_id.raw(), err = %e, "handoff pty reader closed");
|
||||
break;
|
||||
}
|
||||
Ok(n) => {
|
||||
let shell_pid = child_pid.load(Ordering::Acquire);
|
||||
let result = terminal.process_pty_bytes(
|
||||
pane_id,
|
||||
shell_pid,
|
||||
&buf[..n],
|
||||
&response_writer,
|
||||
);
|
||||
if result.request_render && !render_dirty.swap(true, Ordering::AcqRel) {
|
||||
render_notify.notify_one();
|
||||
}
|
||||
if let Some(delay) = result.render_delay {
|
||||
let render_notify = render_notify.clone();
|
||||
let render_dirty = render_dirty.clone();
|
||||
rt.spawn(async move {
|
||||
tokio::time::sleep(delay).await;
|
||||
if !render_dirty.swap(true, Ordering::AcqRel) {
|
||||
render_notify.notify_one();
|
||||
}
|
||||
});
|
||||
}
|
||||
for content in result.clipboard_writes {
|
||||
let _ =
|
||||
rt.block_on(events.send(AppEvent::ClipboardWrite { content }));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let _ = reader_stopped_tx.send(());
|
||||
let _ = rt.block_on(events.send(AppEvent::PaneDied { pane_id }));
|
||||
debug!(pane = pane_id.raw(), "handoff reader task exiting");
|
||||
});
|
||||
}
|
||||
|
||||
{
|
||||
use std::os::fd::AsRawFd;
|
||||
|
||||
let mut writer = writer;
|
||||
let writer_fd = writer.as_raw_fd();
|
||||
let io_stop = io_stop.clone();
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let rt = tokio::runtime::Handle::current();
|
||||
while let Some(bytes) = rt.block_on(input_rx.recv()) {
|
||||
if io_stop.load(Ordering::Acquire) {
|
||||
break;
|
||||
}
|
||||
if let Err(e) = write_all_nonblocking(&mut writer, writer_fd, &bytes, &io_stop)
|
||||
{
|
||||
warn!(pane = pane_id.raw(), err = %e, "handoff pty write failed");
|
||||
break;
|
||||
}
|
||||
if let Err(e) = writer.flush() {
|
||||
warn!(pane = pane_id.raw(), err = %e, "handoff pty flush failed");
|
||||
break;
|
||||
}
|
||||
}
|
||||
debug!(pane = pane_id.raw(), "handoff writer task exiting");
|
||||
});
|
||||
}
|
||||
|
||||
let (resize_tx, mut resize_rx) =
|
||||
watch::channel::<(u16, u16, u32, u32)>((rows, cols, cell_width_px, cell_height_px));
|
||||
{
|
||||
let io_stop = io_stop.clone();
|
||||
let resize_fd = resize_fd.into_raw_fd();
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let rt = tokio::runtime::Handle::current();
|
||||
let mut last_size = (rows, cols, cell_width_px, cell_height_px);
|
||||
while rt.block_on(resize_rx.changed()).is_ok() {
|
||||
if io_stop.load(Ordering::Acquire) {
|
||||
break;
|
||||
}
|
||||
let (rows, cols, cell_width_px, cell_height_px) =
|
||||
*resize_rx.borrow_and_update();
|
||||
if (rows, cols, cell_width_px, cell_height_px) == last_size {
|
||||
continue;
|
||||
}
|
||||
last_size = (rows, cols, cell_width_px, cell_height_px);
|
||||
if let Err(e) =
|
||||
resize_pty_fd(resize_fd, rows, cols, cell_width_px, cell_height_px)
|
||||
{
|
||||
warn!(pane = pane_id.raw(), err = %e, rows, cols, "handoff pty resize failed");
|
||||
}
|
||||
}
|
||||
let _ = unsafe { libc::close(resize_fd) };
|
||||
});
|
||||
}
|
||||
|
||||
let (detect_handle, detect_reset_notify, pending_release) =
|
||||
spawn_basic_detection_task(pane_id, child_pid.clone(), terminal.clone(), events);
|
||||
|
||||
Ok(Self {
|
||||
pane_id,
|
||||
terminal,
|
||||
sender: input_tx,
|
||||
resize_tx,
|
||||
current_size: Cell::new((rows, cols, cell_width_px, cell_height_px)),
|
||||
child_pid,
|
||||
pty_master: None,
|
||||
raw_master_fd: Some(master_fd.into_raw_fd()),
|
||||
force_resize_fd: Some(force_resize_fd),
|
||||
io_stop,
|
||||
reader_paused,
|
||||
reader_pause_ack,
|
||||
reader_stopped_rx: Some(reader_stopped_rx),
|
||||
kitty_keyboard_flags,
|
||||
detect_reset_notify,
|
||||
pending_release,
|
||||
preserve_processes_on_drop: true,
|
||||
detect_handle,
|
||||
})
|
||||
}
|
||||
|
||||
fn spawn_command_builder(
|
||||
pane_id: PaneId,
|
||||
rows: u16,
|
||||
|
|
@ -608,14 +1190,19 @@ impl PaneRuntime {
|
|||
let terminal = Arc::new(PaneTerminal::new(pane_terminal));
|
||||
let kitty_keyboard_flags = Arc::new(AtomicU16::new(0));
|
||||
|
||||
let reader = pair
|
||||
let master_fd = pair
|
||||
.master
|
||||
.try_clone_reader()
|
||||
.map_err(|e| std::io::Error::other(e.to_string()))?;
|
||||
let writer = pair
|
||||
.master
|
||||
.take_writer()
|
||||
.map_err(|e| std::io::Error::other(e.to_string()))?;
|
||||
.as_raw_fd()
|
||||
.ok_or_else(|| std::io::Error::other("pty master fd is unavailable"))?;
|
||||
set_nonblocking(master_fd)?;
|
||||
let reader = file_from_duplicated_fd(master_fd)?;
|
||||
let writer = file_from_duplicated_fd(master_fd)?;
|
||||
let force_resize_fd = duplicate_cloexec_fd(master_fd)?;
|
||||
let resize_fd = duplicate_cloexec_fd(master_fd)?;
|
||||
let io_stop = Arc::new(AtomicBool::new(false));
|
||||
let reader_paused = Arc::new(AtomicBool::new(false));
|
||||
let reader_pause_ack = Arc::new(AtomicBool::new(false));
|
||||
let (reader_stopped_tx, reader_stopped_rx) = std::sync::mpsc::channel();
|
||||
|
||||
// --- Child watcher task ---
|
||||
let child_pid = Arc::new(AtomicU32::new(0));
|
||||
|
|
@ -652,19 +1239,43 @@ impl PaneRuntime {
|
|||
|
||||
// --- Reader task: PTY → terminal backend + screen snapshot + terminal query responses ---
|
||||
{
|
||||
use std::os::fd::AsRawFd;
|
||||
|
||||
let mut reader = reader;
|
||||
let reader_fd = reader.as_raw_fd();
|
||||
let terminal = terminal.clone();
|
||||
let response_writer = input_tx.clone();
|
||||
let render_notify = render_notify.clone();
|
||||
let render_dirty = render_dirty.clone();
|
||||
let child_pid = child_pid.clone();
|
||||
let events = events.clone();
|
||||
let io_stop = io_stop.clone();
|
||||
let reader_paused = reader_paused.clone();
|
||||
let reader_pause_ack = reader_pause_ack.clone();
|
||||
let rt = tokio::runtime::Handle::current();
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let mut buf = [0u8; 8192];
|
||||
loop {
|
||||
if io_stop.load(Ordering::Acquire) {
|
||||
break;
|
||||
}
|
||||
if reader_paused.load(Ordering::Acquire) {
|
||||
reader_pause_ack.store(true, Ordering::Release);
|
||||
std::thread::sleep(std::time::Duration::from_millis(5));
|
||||
continue;
|
||||
}
|
||||
reader_pause_ack.store(false, Ordering::Release);
|
||||
match poll_read_ready(reader_fd, 50) {
|
||||
Ok(true) => {}
|
||||
Ok(false) => continue,
|
||||
Err(e) => {
|
||||
debug!(pane = pane_id.raw(), err = %e, "pty reader poll failed");
|
||||
break;
|
||||
}
|
||||
}
|
||||
match reader.read(&mut buf) {
|
||||
Ok(0) => break,
|
||||
Err(e) if e.kind() == std::io::ErrorKind::WouldBlock => continue,
|
||||
Err(e) => {
|
||||
debug!(pane = pane_id.raw(), err = %e, "pty reader closed");
|
||||
break;
|
||||
|
|
@ -704,6 +1315,7 @@ impl PaneRuntime {
|
|||
}
|
||||
}
|
||||
}
|
||||
let _ = reader_stopped_tx.send(());
|
||||
debug!(pane = pane_id.raw(), "reader task exiting");
|
||||
});
|
||||
}
|
||||
|
|
@ -969,11 +1581,19 @@ impl PaneRuntime {
|
|||
|
||||
// --- Writer task: channel → PTY ---
|
||||
{
|
||||
let mut writer = BufWriter::new(writer);
|
||||
use std::os::fd::AsRawFd;
|
||||
|
||||
let mut writer = writer;
|
||||
let writer_fd = writer.as_raw_fd();
|
||||
let io_stop = io_stop.clone();
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let rt = tokio::runtime::Handle::current();
|
||||
while let Some(bytes) = rt.block_on(input_rx.recv()) {
|
||||
if let Err(e) = writer.write_all(&bytes) {
|
||||
if io_stop.load(Ordering::Acquire) {
|
||||
break;
|
||||
}
|
||||
if let Err(e) = write_all_nonblocking(&mut writer, writer_fd, &bytes, &io_stop)
|
||||
{
|
||||
warn!(pane = pane_id.raw(), err = %e, "pty write failed");
|
||||
break;
|
||||
}
|
||||
|
|
@ -989,30 +1609,27 @@ impl PaneRuntime {
|
|||
// --- Resize task ---
|
||||
let (resize_tx, mut resize_rx) = watch::channel::<(u16, u16, u32, u32)>((rows, cols, 0, 0));
|
||||
{
|
||||
let master = pair.master;
|
||||
let io_stop = io_stop.clone();
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let rt = tokio::runtime::Handle::current();
|
||||
let mut last_size = (rows, cols, 0, 0);
|
||||
while rt.block_on(resize_rx.changed()).is_ok() {
|
||||
if io_stop.load(Ordering::Acquire) {
|
||||
break;
|
||||
}
|
||||
let (rows, cols, cell_width_px, cell_height_px) =
|
||||
*resize_rx.borrow_and_update();
|
||||
if (rows, cols, cell_width_px, cell_height_px) == last_size {
|
||||
continue;
|
||||
}
|
||||
last_size = (rows, cols, cell_width_px, cell_height_px);
|
||||
if let Err(e) = master.resize(PtySize {
|
||||
rows,
|
||||
cols,
|
||||
pixel_width: (cols as u32)
|
||||
.saturating_mul(cell_width_px)
|
||||
.min(u16::MAX as u32) as u16,
|
||||
pixel_height: (rows as u32)
|
||||
.saturating_mul(cell_height_px)
|
||||
.min(u16::MAX as u32) as u16,
|
||||
}) {
|
||||
if let Err(e) =
|
||||
resize_pty_fd(resize_fd, rows, cols, cell_width_px, cell_height_px)
|
||||
{
|
||||
warn!(pane = pane_id.raw(), err = %e, rows, cols, "pty resize failed");
|
||||
}
|
||||
}
|
||||
let _ = unsafe { libc::close(resize_fd) };
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -1023,9 +1640,17 @@ impl PaneRuntime {
|
|||
resize_tx,
|
||||
current_size: Cell::new((rows, cols, 0, 0)),
|
||||
child_pid,
|
||||
pty_master: Some(pair.master),
|
||||
raw_master_fd: None,
|
||||
force_resize_fd: Some(force_resize_fd),
|
||||
io_stop,
|
||||
reader_paused,
|
||||
reader_pause_ack,
|
||||
reader_stopped_rx: Some(reader_stopped_rx),
|
||||
kitty_keyboard_flags,
|
||||
detect_reset_notify,
|
||||
pending_release,
|
||||
preserve_processes_on_drop: false,
|
||||
detect_handle,
|
||||
})
|
||||
}
|
||||
|
|
@ -1060,6 +1685,36 @@ impl PaneRuntime {
|
|||
let _ = self.resize_tx.send(size);
|
||||
}
|
||||
|
||||
pub fn nudge_child_redraw_after_handoff(&self) {
|
||||
let Some(fd) = self.force_resize_fd else {
|
||||
return;
|
||||
};
|
||||
let (rows, cols, cell_width_px, cell_height_px) = self.current_size.get();
|
||||
let nudge = if rows > 2 {
|
||||
(rows - 1, cols, cell_width_px, cell_height_px)
|
||||
} else {
|
||||
(
|
||||
rows,
|
||||
cols.saturating_sub(1).max(4),
|
||||
cell_width_px,
|
||||
cell_height_px,
|
||||
)
|
||||
};
|
||||
if nudge == (rows, cols, cell_width_px, cell_height_px) {
|
||||
return;
|
||||
}
|
||||
|
||||
let Ok(fd) = duplicate_cloexec_fd(fd) else {
|
||||
return;
|
||||
};
|
||||
std::thread::spawn(move || {
|
||||
let _ = resize_pty_fd(fd, nudge.0, nudge.1, nudge.2, nudge.3);
|
||||
std::thread::sleep(std::time::Duration::from_millis(30));
|
||||
let _ = resize_pty_fd(fd, rows, cols, cell_width_px, cell_height_px);
|
||||
let _ = unsafe { libc::close(fd) };
|
||||
});
|
||||
}
|
||||
|
||||
/// Scroll up by N lines (into scrollback history).
|
||||
pub fn scroll_up(&self, lines: usize) {
|
||||
self.terminal.scroll_up(lines);
|
||||
|
|
@ -1322,9 +1977,17 @@ impl PaneRuntime {
|
|||
resize_tx,
|
||||
current_size: Cell::new((rows, cols, 0, 0)),
|
||||
child_pid: Arc::new(AtomicU32::new(0)),
|
||||
pty_master: None,
|
||||
raw_master_fd: None,
|
||||
force_resize_fd: None,
|
||||
io_stop: Arc::new(AtomicBool::new(false)),
|
||||
reader_paused: Arc::new(AtomicBool::new(false)),
|
||||
reader_pause_ack: Arc::new(AtomicBool::new(false)),
|
||||
reader_stopped_rx: None,
|
||||
kitty_keyboard_flags: Arc::new(AtomicU16::new(0)),
|
||||
detect_reset_notify: Arc::new(Notify::new()),
|
||||
pending_release: Arc::new(Mutex::new(None)),
|
||||
preserve_processes_on_drop: true,
|
||||
detect_handle: tokio::spawn(async {}).abort_handle(),
|
||||
},
|
||||
rx,
|
||||
|
|
@ -1410,6 +2073,47 @@ mod tests {
|
|||
assert_eq!(output, "vt100\n24bit\n");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn handoff_history_ansi_captures_primary_screen() {
|
||||
let runtime =
|
||||
PaneRuntime::test_with_scrollback_bytes(40, 5, 4096, b"handoff-primary-history\r\n");
|
||||
|
||||
let history = runtime.handoff_history_ansi().unwrap();
|
||||
|
||||
assert!(history.contains("handoff-primary-history"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn handoff_history_ansi_skips_alternate_screen() {
|
||||
let runtime = PaneRuntime::test_with_scrollback_bytes(
|
||||
40,
|
||||
5,
|
||||
4096,
|
||||
b"primary\r\n\x1b[?1049halt-screen",
|
||||
);
|
||||
|
||||
assert!(runtime.handoff_history_ansi().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn truncate_handoff_history_keeps_recent_utf8_boundary() {
|
||||
let history = format!("old\n{}\nrecent\n", "é".repeat(8));
|
||||
|
||||
let truncated = truncate_handoff_history(history, 20);
|
||||
|
||||
assert_eq!(truncated, "recent\n");
|
||||
assert!(truncated.is_char_boundary(0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn truncate_handoff_history_drops_partial_long_line() {
|
||||
let history = format!("old\n{}", "x".repeat(64));
|
||||
|
||||
let truncated = truncate_handoff_history(history, 12);
|
||||
|
||||
assert!(truncated.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn restore_wrapper_falls_back_after_early_resume_failure() {
|
||||
let argv = vec!["/bin/sh".into(), "-c".into(), "exit 7".into()];
|
||||
|
|
@ -1510,9 +2214,17 @@ mod tests {
|
|||
resize_tx,
|
||||
current_size: Cell::new((80, 24, 0, 0)),
|
||||
child_pid: Arc::new(AtomicU32::new(0)),
|
||||
pty_master: None,
|
||||
raw_master_fd: None,
|
||||
force_resize_fd: None,
|
||||
io_stop: Arc::new(AtomicBool::new(false)),
|
||||
reader_paused: Arc::new(AtomicBool::new(false)),
|
||||
reader_pause_ack: Arc::new(AtomicBool::new(false)),
|
||||
reader_stopped_rx: None,
|
||||
kitty_keyboard_flags: Arc::new(AtomicU16::new(0)),
|
||||
detect_reset_notify: Arc::new(Notify::new()),
|
||||
pending_release: Arc::new(Mutex::new(None)),
|
||||
preserve_processes_on_drop: true,
|
||||
detect_handle: tokio::spawn(async {}).abort_handle(),
|
||||
};
|
||||
|
||||
|
|
@ -1534,9 +2246,17 @@ mod tests {
|
|||
resize_tx,
|
||||
current_size: Cell::new((80, 24, 0, 0)),
|
||||
child_pid: Arc::new(AtomicU32::new(0)),
|
||||
pty_master: None,
|
||||
raw_master_fd: None,
|
||||
force_resize_fd: None,
|
||||
io_stop: Arc::new(AtomicBool::new(false)),
|
||||
reader_paused: Arc::new(AtomicBool::new(false)),
|
||||
reader_pause_ack: Arc::new(AtomicBool::new(false)),
|
||||
reader_stopped_rx: None,
|
||||
kitty_keyboard_flags: Arc::new(AtomicU16::new(0)),
|
||||
detect_reset_notify: Arc::new(Notify::new()),
|
||||
pending_release: Arc::new(Mutex::new(None)),
|
||||
preserve_processes_on_drop: true,
|
||||
detect_handle: tokio::spawn(async {}).abort_handle(),
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -9,6 +9,8 @@ mod snapshot;
|
|||
|
||||
pub use self::io::{clear, clear_history, load, load_history, save};
|
||||
pub use self::restore::restore;
|
||||
#[cfg(unix)]
|
||||
pub use self::restore::{restore_handoff, ImportedPaneRuntime};
|
||||
pub use self::snapshot::{
|
||||
capture, capture_history, DirectionSnapshot, LayoutSnapshot, SessionHistorySnapshot,
|
||||
SessionSnapshot, TabSnapshot, WorkspaceSnapshot,
|
||||
|
|
|
|||
|
|
@ -22,6 +22,17 @@ use super::{
|
|||
WorkspaceSnapshot,
|
||||
};
|
||||
|
||||
#[cfg(unix)]
|
||||
pub struct ImportedPaneRuntime {
|
||||
pub master_fd: std::os::fd::RawFd,
|
||||
pub child_pid: u32,
|
||||
pub rows: u16,
|
||||
pub cols: u16,
|
||||
pub cell_width_px: u32,
|
||||
pub cell_height_px: u32,
|
||||
pub initial_history_ansi: Option<String>,
|
||||
}
|
||||
|
||||
struct AgentRestoreState<'a> {
|
||||
enabled: bool,
|
||||
resumed_sessions: &'a mut HashSet<String>,
|
||||
|
|
@ -34,6 +45,32 @@ struct PaneRestoreStartup<'a> {
|
|||
reserved_agent_session: Option<String>,
|
||||
}
|
||||
|
||||
struct RestoreRuntimeContext<'a> {
|
||||
scrollback_limit_bytes: usize,
|
||||
default_shell: &'a str,
|
||||
resume_agents_on_restore: bool,
|
||||
events: mpsc::Sender<AppEvent>,
|
||||
render_notify: Arc<Notify>,
|
||||
render_dirty: Arc<AtomicBool>,
|
||||
}
|
||||
|
||||
type RestoredSession = (
|
||||
Vec<Workspace>,
|
||||
HashMap<TerminalId, TerminalState>,
|
||||
HashMap<TerminalId, TerminalRuntime>,
|
||||
);
|
||||
type RestoredWorkspace = (
|
||||
Workspace,
|
||||
Vec<TerminalState>,
|
||||
HashMap<TerminalId, TerminalRuntime>,
|
||||
);
|
||||
type RestoredTab = (
|
||||
crate::workspace::Tab,
|
||||
Vec<TerminalState>,
|
||||
HashMap<TerminalId, TerminalRuntime>,
|
||||
);
|
||||
type RestoreFailures<T> = (T, usize);
|
||||
|
||||
/// Restore workspaces from a snapshot. Each pane gets a fresh shell in its saved cwd.
|
||||
pub fn restore(
|
||||
snapshot: &SessionSnapshot,
|
||||
|
|
@ -46,29 +83,156 @@ pub fn restore(
|
|||
events: mpsc::Sender<AppEvent>,
|
||||
render_notify: Arc<Notify>,
|
||||
render_dirty: Arc<AtomicBool>,
|
||||
) -> (
|
||||
Vec<Workspace>,
|
||||
HashMap<TerminalId, TerminalState>,
|
||||
HashMap<TerminalId, TerminalRuntime>,
|
||||
) {
|
||||
) -> RestoredSession {
|
||||
let mut imported_panes = HashMap::new();
|
||||
restore_with_imports(
|
||||
snapshot,
|
||||
history,
|
||||
rows,
|
||||
cols,
|
||||
scrollback_limit_bytes,
|
||||
default_shell,
|
||||
resume_agents_on_restore,
|
||||
&mut imported_panes,
|
||||
events,
|
||||
render_notify,
|
||||
render_dirty,
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
pub fn restore_handoff(
|
||||
snapshot: &SessionSnapshot,
|
||||
scrollback_limit_bytes: usize,
|
||||
default_shell: &str,
|
||||
imports: &mut HashMap<u32, ImportedPaneRuntime>,
|
||||
events: mpsc::Sender<AppEvent>,
|
||||
render_notify: Arc<Notify>,
|
||||
render_dirty: Arc<AtomicBool>,
|
||||
) -> std::io::Result<RestoredSession> {
|
||||
restore_with_imports_strict(
|
||||
snapshot,
|
||||
None,
|
||||
24,
|
||||
80,
|
||||
scrollback_limit_bytes,
|
||||
default_shell,
|
||||
false,
|
||||
imports,
|
||||
events,
|
||||
render_notify,
|
||||
render_dirty,
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn restore_with_imports_strict(
|
||||
snapshot: &SessionSnapshot,
|
||||
history: Option<&SessionHistorySnapshot>,
|
||||
rows: u16,
|
||||
cols: u16,
|
||||
scrollback_limit_bytes: usize,
|
||||
default_shell: &str,
|
||||
resume_agents_on_restore: bool,
|
||||
imported_panes: &mut HashMap<u32, ImportedPaneRuntime>,
|
||||
events: mpsc::Sender<AppEvent>,
|
||||
render_notify: Arc<Notify>,
|
||||
render_dirty: Arc<AtomicBool>,
|
||||
) -> std::io::Result<RestoredSession> {
|
||||
let (restored, failed_imports) = restore_with_imports_and_failures(
|
||||
snapshot,
|
||||
history,
|
||||
rows,
|
||||
cols,
|
||||
scrollback_limit_bytes,
|
||||
default_shell,
|
||||
resume_agents_on_restore,
|
||||
imported_panes,
|
||||
events,
|
||||
render_notify,
|
||||
render_dirty,
|
||||
);
|
||||
if failed_imports > 0 {
|
||||
return Err(std::io::Error::other(format!(
|
||||
"handoff failed to restore {failed_imports} imported pane runtime(s)"
|
||||
)));
|
||||
}
|
||||
if !imported_panes.is_empty() {
|
||||
return Err(std::io::Error::other(format!(
|
||||
"handoff import did not consume {} pane runtime(s)",
|
||||
imported_panes.len()
|
||||
)));
|
||||
}
|
||||
Ok(restored)
|
||||
}
|
||||
|
||||
fn restore_with_imports(
|
||||
snapshot: &SessionSnapshot,
|
||||
history: Option<&SessionHistorySnapshot>,
|
||||
rows: u16,
|
||||
cols: u16,
|
||||
scrollback_limit_bytes: usize,
|
||||
default_shell: &str,
|
||||
resume_agents_on_restore: bool,
|
||||
imported_panes: &mut HashMap<u32, ImportedPaneRuntime>,
|
||||
events: mpsc::Sender<AppEvent>,
|
||||
render_notify: Arc<Notify>,
|
||||
render_dirty: Arc<AtomicBool>,
|
||||
) -> RestoredSession {
|
||||
restore_with_imports_and_failures(
|
||||
snapshot,
|
||||
history,
|
||||
rows,
|
||||
cols,
|
||||
scrollback_limit_bytes,
|
||||
default_shell,
|
||||
resume_agents_on_restore,
|
||||
imported_panes,
|
||||
events,
|
||||
render_notify,
|
||||
render_dirty,
|
||||
)
|
||||
.0
|
||||
}
|
||||
|
||||
fn restore_with_imports_and_failures(
|
||||
snapshot: &SessionSnapshot,
|
||||
history: Option<&SessionHistorySnapshot>,
|
||||
rows: u16,
|
||||
cols: u16,
|
||||
scrollback_limit_bytes: usize,
|
||||
default_shell: &str,
|
||||
resume_agents_on_restore: bool,
|
||||
imported_panes: &mut HashMap<u32, ImportedPaneRuntime>,
|
||||
events: mpsc::Sender<AppEvent>,
|
||||
render_notify: Arc<Notify>,
|
||||
render_dirty: Arc<AtomicBool>,
|
||||
) -> RestoreFailures<RestoredSession> {
|
||||
let mut workspaces = Vec::new();
|
||||
let mut terminals = HashMap::new();
|
||||
let mut terminal_runtimes = HashMap::new();
|
||||
let mut resumed_agent_sessions = HashSet::new();
|
||||
let mut failed_imports = 0;
|
||||
for (idx, ws_snap) in snapshot.workspaces.iter().enumerate() {
|
||||
if let Some((workspace, restored_terminals, restored_runtimes)) = restore_workspace(
|
||||
let runtime_context = RestoreRuntimeContext {
|
||||
scrollback_limit_bytes,
|
||||
default_shell,
|
||||
resume_agents_on_restore,
|
||||
events: events.clone(),
|
||||
render_notify: render_notify.clone(),
|
||||
render_dirty: render_dirty.clone(),
|
||||
};
|
||||
let (restored, workspace_failed_imports) = restore_workspace(
|
||||
ws_snap,
|
||||
history.and_then(|history| history.workspaces.get(idx)),
|
||||
rows,
|
||||
cols,
|
||||
scrollback_limit_bytes,
|
||||
default_shell,
|
||||
resume_agents_on_restore,
|
||||
&runtime_context,
|
||||
&mut resumed_agent_sessions,
|
||||
events.clone(),
|
||||
render_notify.clone(),
|
||||
render_dirty.clone(),
|
||||
) {
|
||||
imported_panes,
|
||||
);
|
||||
failed_imports += workspace_failed_imports;
|
||||
if let Some((workspace, restored_terminals, restored_runtimes)) = restored {
|
||||
for terminal in restored_terminals {
|
||||
terminals.insert(terminal.id.clone(), terminal);
|
||||
}
|
||||
|
|
@ -76,7 +240,7 @@ pub fn restore(
|
|||
workspaces.push(workspace);
|
||||
}
|
||||
}
|
||||
(workspaces, terminals, terminal_runtimes)
|
||||
((workspaces, terminals, terminal_runtimes), failed_imports)
|
||||
}
|
||||
|
||||
fn restore_workspace(
|
||||
|
|
@ -84,42 +248,32 @@ fn restore_workspace(
|
|||
history: Option<&WorkspaceHistorySnapshot>,
|
||||
rows: u16,
|
||||
cols: u16,
|
||||
scrollback_limit_bytes: usize,
|
||||
default_shell: &str,
|
||||
resume_agents_on_restore: bool,
|
||||
runtime_context: &RestoreRuntimeContext<'_>,
|
||||
resumed_agent_sessions: &mut HashSet<String>,
|
||||
events: mpsc::Sender<AppEvent>,
|
||||
render_notify: Arc<Notify>,
|
||||
render_dirty: Arc<AtomicBool>,
|
||||
) -> Option<(
|
||||
Workspace,
|
||||
Vec<TerminalState>,
|
||||
HashMap<TerminalId, TerminalRuntime>,
|
||||
)> {
|
||||
imported_panes: &mut HashMap<u32, ImportedPaneRuntime>,
|
||||
) -> RestoreFailures<Option<RestoredWorkspace>> {
|
||||
let mut tabs = Vec::new();
|
||||
let mut terminals = Vec::new();
|
||||
let mut terminal_runtimes = HashMap::new();
|
||||
let mut public_pane_numbers = HashMap::new();
|
||||
let mut next_public_pane_number = 1;
|
||||
let mut agent_restore = AgentRestoreState {
|
||||
enabled: resume_agents_on_restore,
|
||||
resumed_sessions: resumed_agent_sessions,
|
||||
};
|
||||
let mut failed_imports = 0;
|
||||
|
||||
for (idx, tab_snap) in snap.tabs.iter().enumerate() {
|
||||
let (tab, restored_terminals, restored_runtimes) = restore_tab(
|
||||
let (restored_tab, tab_failed_imports) = restore_tab(
|
||||
tab_snap,
|
||||
history.and_then(|history| history.tabs.get(idx)),
|
||||
idx + 1,
|
||||
rows,
|
||||
cols,
|
||||
scrollback_limit_bytes,
|
||||
default_shell,
|
||||
&mut agent_restore,
|
||||
events.clone(),
|
||||
render_notify.clone(),
|
||||
render_dirty.clone(),
|
||||
)?;
|
||||
runtime_context,
|
||||
resumed_agent_sessions,
|
||||
imported_panes,
|
||||
);
|
||||
failed_imports += tab_failed_imports;
|
||||
let Some((tab, restored_terminals, restored_runtimes)) = restored_tab else {
|
||||
continue;
|
||||
};
|
||||
for pane_id in tab.layout.pane_ids() {
|
||||
public_pane_numbers.insert(pane_id, next_public_pane_number);
|
||||
next_public_pane_number += 1;
|
||||
|
|
@ -130,13 +284,13 @@ fn restore_workspace(
|
|||
}
|
||||
|
||||
if tabs.is_empty() {
|
||||
return None;
|
||||
return (None, failed_imports);
|
||||
}
|
||||
|
||||
let worktree_space = restored_worktree_space_membership(snap.worktree_space.clone());
|
||||
|
||||
Some((
|
||||
Workspace {
|
||||
(
|
||||
Some(Workspace {
|
||||
id: snap
|
||||
.id
|
||||
.clone()
|
||||
|
|
@ -153,10 +307,10 @@ fn restore_workspace(
|
|||
tabs,
|
||||
#[cfg(test)]
|
||||
test_runtimes: HashMap::new(),
|
||||
},
|
||||
terminals,
|
||||
terminal_runtimes,
|
||||
))
|
||||
})
|
||||
.map(|workspace| (workspace, terminals, terminal_runtimes)),
|
||||
failed_imports,
|
||||
)
|
||||
}
|
||||
|
||||
fn restored_worktree_space_membership(
|
||||
|
|
@ -175,17 +329,10 @@ fn restore_tab(
|
|||
number: usize,
|
||||
rows: u16,
|
||||
cols: u16,
|
||||
scrollback_limit_bytes: usize,
|
||||
default_shell: &str,
|
||||
agent_restore: &mut AgentRestoreState<'_>,
|
||||
events: mpsc::Sender<AppEvent>,
|
||||
render_notify: Arc<Notify>,
|
||||
render_dirty: Arc<AtomicBool>,
|
||||
) -> Option<(
|
||||
crate::workspace::Tab,
|
||||
Vec<TerminalState>,
|
||||
HashMap<TerminalId, TerminalRuntime>,
|
||||
)> {
|
||||
runtime_context: &RestoreRuntimeContext<'_>,
|
||||
resumed_agent_sessions: &mut HashSet<String>,
|
||||
imported_panes: &mut HashMap<u32, ImportedPaneRuntime>,
|
||||
) -> RestoreFailures<Option<RestoredTab>> {
|
||||
let (node, id_map) = restore_node_remapped(&snap.layout);
|
||||
let reverse_id_map: HashMap<PaneId, u32> = id_map
|
||||
.iter()
|
||||
|
|
@ -196,6 +343,7 @@ fn restore_tab(
|
|||
let mut panes = HashMap::new();
|
||||
let mut terminals = Vec::new();
|
||||
let mut terminal_runtimes = HashMap::new();
|
||||
let mut failed_imports = 0;
|
||||
for id in &pane_ids {
|
||||
let old_id = reverse_id_map.get(id);
|
||||
let saved_pane = old_id.and_then(|old_id| snap.panes.get(old_id));
|
||||
|
|
@ -225,13 +373,40 @@ fn restore_tab(
|
|||
let saved_agent_session = saved_pane.and_then(|p| p.agent_session.as_ref());
|
||||
let saved_history =
|
||||
old_id.and_then(|old_id| history.and_then(|history| history.panes.get(old_id)));
|
||||
let startup = pane_restore_startup(saved_agent_session, saved_history, agent_restore);
|
||||
let startup = {
|
||||
let mut agent_restore = AgentRestoreState {
|
||||
enabled: runtime_context.resume_agents_on_restore,
|
||||
resumed_sessions: resumed_agent_sessions,
|
||||
};
|
||||
pane_restore_startup(saved_agent_session, saved_history, &mut agent_restore)
|
||||
};
|
||||
let initial_restore_agent = startup
|
||||
.restore_plan
|
||||
.as_ref()
|
||||
.and_then(|plan| crate::detect::parse_agent_label(&plan.agent));
|
||||
|
||||
let runtime_result = if let Some(plan) = startup.restore_plan {
|
||||
let old_pane_id = reverse_id_map.get(id).copied();
|
||||
let imported_runtime = old_pane_id.and_then(|old_id| imported_panes.remove(&old_id));
|
||||
let was_imported = imported_runtime.is_some();
|
||||
let runtime_result = if let Some(imported) = imported_runtime {
|
||||
TerminalRuntime::from_handoff_fd(
|
||||
crate::pane::PaneRuntimeImport {
|
||||
pane_id: *id,
|
||||
master_fd: imported.master_fd,
|
||||
child_pid: imported.child_pid,
|
||||
rows: imported.rows,
|
||||
cols: imported.cols,
|
||||
cell_width_px: imported.cell_width_px,
|
||||
cell_height_px: imported.cell_height_px,
|
||||
initial_history_ansi: imported.initial_history_ansi,
|
||||
},
|
||||
runtime_context.scrollback_limit_bytes,
|
||||
crate::terminal_theme::TerminalTheme::default(),
|
||||
runtime_context.events.clone(),
|
||||
runtime_context.render_notify.clone(),
|
||||
runtime_context.render_dirty.clone(),
|
||||
)
|
||||
} else if let Some(plan) = startup.restore_plan {
|
||||
let launch = crate::agent_resume::AgentResumeLaunch {
|
||||
plan: &plan,
|
||||
initial_history_ansi: startup.initial_history_ansi,
|
||||
|
|
@ -242,12 +417,12 @@ fn restore_tab(
|
|||
cols,
|
||||
cwd.clone(),
|
||||
launch,
|
||||
scrollback_limit_bytes,
|
||||
runtime_context.scrollback_limit_bytes,
|
||||
crate::terminal_theme::TerminalTheme::default(),
|
||||
default_shell,
|
||||
events.clone(),
|
||||
render_notify.clone(),
|
||||
render_dirty.clone(),
|
||||
runtime_context.default_shell,
|
||||
runtime_context.events.clone(),
|
||||
runtime_context.render_notify.clone(),
|
||||
runtime_context.render_dirty.clone(),
|
||||
)
|
||||
} else {
|
||||
TerminalRuntime::spawn_with_initial_history(
|
||||
|
|
@ -255,13 +430,13 @@ fn restore_tab(
|
|||
rows,
|
||||
cols,
|
||||
cwd.clone(),
|
||||
scrollback_limit_bytes,
|
||||
runtime_context.scrollback_limit_bytes,
|
||||
crate::terminal_theme::TerminalTheme::default(),
|
||||
default_shell,
|
||||
runtime_context.default_shell,
|
||||
startup.initial_history_ansi,
|
||||
events.clone(),
|
||||
render_notify.clone(),
|
||||
render_dirty.clone(),
|
||||
runtime_context.events.clone(),
|
||||
runtime_context.render_notify.clone(),
|
||||
runtime_context.render_dirty.clone(),
|
||||
)
|
||||
};
|
||||
|
||||
|
|
@ -298,7 +473,16 @@ fn restore_tab(
|
|||
}
|
||||
Err(e) => {
|
||||
if let Some(key) = startup.reserved_agent_session.as_deref() {
|
||||
agent_restore.resumed_sessions.remove(key);
|
||||
resumed_agent_sessions.remove(key);
|
||||
}
|
||||
if was_imported {
|
||||
failed_imports += 1;
|
||||
error!(
|
||||
tab = ?snap.custom_name,
|
||||
pane_id = id.raw(),
|
||||
err = %e,
|
||||
"failed to restore imported pane"
|
||||
);
|
||||
}
|
||||
error!(
|
||||
tab = ?snap.custom_name,
|
||||
|
|
@ -315,7 +499,7 @@ fn restore_tab(
|
|||
tab = ?snap.custom_name,
|
||||
"no panes could be restored for tab, dropping it"
|
||||
);
|
||||
return None;
|
||||
return (None, failed_imports);
|
||||
}
|
||||
|
||||
let surviving: HashSet<PaneId> = panes.keys().copied().collect();
|
||||
|
|
@ -324,30 +508,38 @@ fn restore_tab(
|
|||
tab = ?snap.custom_name,
|
||||
"restored tab lost all panes after pruning missing layout nodes"
|
||||
);
|
||||
return None;
|
||||
return (None, failed_imports);
|
||||
};
|
||||
let pane_ids = collect_pane_ids(&node);
|
||||
let focus = resolve_restored_pane(snap.focused, &id_map, &surviving, &pane_ids)?;
|
||||
let root_pane = resolve_restored_pane(snap.root_pane, &id_map, &surviving, &pane_ids)?;
|
||||
let Some(focus) = resolve_restored_pane(snap.focused, &id_map, &surviving, &pane_ids) else {
|
||||
return (None, failed_imports);
|
||||
};
|
||||
let Some(root_pane) = resolve_restored_pane(snap.root_pane, &id_map, &surviving, &pane_ids)
|
||||
else {
|
||||
return (None, failed_imports);
|
||||
};
|
||||
let layout = TileLayout::from_saved(node, focus);
|
||||
|
||||
Some((
|
||||
crate::workspace::Tab {
|
||||
custom_name: snap.custom_name.clone(),
|
||||
number,
|
||||
root_pane,
|
||||
layout,
|
||||
panes,
|
||||
#[cfg(test)]
|
||||
runtimes: HashMap::new(),
|
||||
zoomed: snap.zoomed,
|
||||
events,
|
||||
render_notify,
|
||||
render_dirty,
|
||||
},
|
||||
terminals,
|
||||
terminal_runtimes,
|
||||
))
|
||||
(
|
||||
Some((
|
||||
crate::workspace::Tab {
|
||||
custom_name: snap.custom_name.clone(),
|
||||
number,
|
||||
root_pane,
|
||||
layout,
|
||||
panes,
|
||||
#[cfg(test)]
|
||||
runtimes: HashMap::new(),
|
||||
zoomed: snap.zoomed,
|
||||
events: runtime_context.events.clone(),
|
||||
render_notify: runtime_context.render_notify.clone(),
|
||||
render_dirty: runtime_context.render_dirty.clone(),
|
||||
},
|
||||
terminals,
|
||||
terminal_runtimes,
|
||||
)),
|
||||
failed_imports,
|
||||
)
|
||||
}
|
||||
|
||||
fn pane_restore_startup<'a>(
|
||||
|
|
|
|||
244
src/remote.rs
244
src/remote.rs
|
|
@ -322,11 +322,15 @@ fn prepare_remote_herdr(target: &str) -> io::Result<PreparedRemoteHerdr> {
|
|||
let platform = detect_remote_platform(target)?;
|
||||
let remote_herdr = RemoteHerdr::for_platform(platform);
|
||||
let override_binary = remote_binary_override_path()?;
|
||||
let path_remote_herdr = remote_binary_on_path_any(target, &remote_herdr)?;
|
||||
|
||||
if override_binary.is_none() {
|
||||
if let Some(path_remote_herdr) = remote_binary_on_path(target, &remote_herdr)? {
|
||||
if let Some(path_remote_herdr) = path_remote_herdr
|
||||
.as_ref()
|
||||
.filter(|candidate| remote_binary_matches(target, candidate).unwrap_or(false))
|
||||
{
|
||||
return Ok(PreparedRemoteHerdr {
|
||||
remote_herdr: path_remote_herdr,
|
||||
remote_herdr: path_remote_herdr.clone(),
|
||||
installed_or_replaced: false,
|
||||
});
|
||||
}
|
||||
|
|
@ -338,6 +342,13 @@ fn prepare_remote_herdr(target: &str) -> io::Result<PreparedRemoteHerdr> {
|
|||
}
|
||||
}
|
||||
|
||||
if let Some(status_probe_herdr) = path_remote_herdr.as_ref().or_else(|| {
|
||||
remote_binary_exists(target, &remote_herdr)
|
||||
.ok()
|
||||
.and_then(|exists| exists.then_some(&remote_herdr))
|
||||
}) {
|
||||
confirm_remote_install_with_running_server(target, status_probe_herdr)?;
|
||||
}
|
||||
confirm_remote_install(
|
||||
target,
|
||||
&remote_herdr,
|
||||
|
|
@ -381,28 +392,27 @@ fn detect_remote_platform(target: &str) -> io::Result<RemotePlatform> {
|
|||
})
|
||||
}
|
||||
|
||||
fn remote_binary_on_path(
|
||||
fn remote_binary_on_path_any(
|
||||
target: &str,
|
||||
remote_herdr: &RemoteHerdr,
|
||||
) -> io::Result<Option<RemoteHerdr>> {
|
||||
let output = ssh_output(target, remote_path_probe_command())?;
|
||||
let output = ssh_output(target, remote_path_probe_any_command())?;
|
||||
if !output.status.success() {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
Ok(remote_herdr_from_path_probe(remote_herdr, &stdout))
|
||||
Ok(remote_herdr_from_path_probe_any(remote_herdr, &stdout))
|
||||
}
|
||||
|
||||
fn remote_path_probe_command() -> &'static str {
|
||||
fn remote_path_probe_any_command() -> &'static str {
|
||||
r#"path=$(command -v herdr) || exit 1
|
||||
test -n "$path" || exit 1
|
||||
version=$("$path" --version) || exit 1
|
||||
status=$("$path" status client --json) || exit 1
|
||||
printf '%s\n%s\n%s\n' "$path" "$version" "$status"
|
||||
printf '%s\n' "$path"
|
||||
"#
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn remote_herdr_from_path_probe(remote_herdr: &RemoteHerdr, stdout: &str) -> Option<RemoteHerdr> {
|
||||
let mut lines = stdout.lines();
|
||||
let path = lines.next()?;
|
||||
|
|
@ -419,6 +429,18 @@ fn remote_herdr_from_path_probe(remote_herdr: &RemoteHerdr, stdout: &str) -> Opt
|
|||
Some(remote_herdr.clone().with_shell_path(shell_quote(path)))
|
||||
}
|
||||
|
||||
fn remote_herdr_from_path_probe_any(
|
||||
remote_herdr: &RemoteHerdr,
|
||||
stdout: &str,
|
||||
) -> Option<RemoteHerdr> {
|
||||
let mut lines = stdout.lines();
|
||||
let path = lines.next()?;
|
||||
if !path.starts_with('/') {
|
||||
return None;
|
||||
}
|
||||
Some(remote_herdr.clone().with_shell_path(shell_quote(path)))
|
||||
}
|
||||
|
||||
fn remote_binary_matches(target: &str, remote_herdr: &RemoteHerdr) -> io::Result<bool> {
|
||||
let command = format!(
|
||||
"test -x {0} && {0} --version && {0} status client --json",
|
||||
|
|
@ -439,6 +461,11 @@ fn remote_binary_matches(target: &str, remote_herdr: &RemoteHerdr) -> io::Result
|
|||
.unwrap_or(false))
|
||||
}
|
||||
|
||||
fn remote_binary_exists(target: &str, remote_herdr: &RemoteHerdr) -> io::Result<bool> {
|
||||
let command = format!("test -x {}", remote_herdr.shell_path);
|
||||
Ok(ssh_output(target, &command)?.status.success())
|
||||
}
|
||||
|
||||
fn remote_binary_override_path() -> io::Result<Option<PathBuf>> {
|
||||
let Some(value) = std::env::var_os(REMOTE_BINARY_ENV_VAR) else {
|
||||
return Ok(None);
|
||||
|
|
@ -509,6 +536,7 @@ enum RemoteServerStatus {
|
|||
Running {
|
||||
version: Option<String>,
|
||||
protocol: Option<u32>,
|
||||
live_handoff: bool,
|
||||
},
|
||||
NotRunning,
|
||||
}
|
||||
|
|
@ -526,7 +554,12 @@ fn ensure_remote_server_ready(
|
|||
remote_binary_changed: bool,
|
||||
) -> io::Result<()> {
|
||||
let status = remote_server_status(target, remote_herdr)?;
|
||||
let RemoteServerStatus::Running { version, protocol } = status else {
|
||||
let RemoteServerStatus::Running {
|
||||
version,
|
||||
protocol,
|
||||
live_handoff,
|
||||
} = status
|
||||
else {
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
|
|
@ -536,6 +569,17 @@ fn ensure_remote_server_ready(
|
|||
return Ok(());
|
||||
};
|
||||
|
||||
if live_handoff && confirm_remote_server_handoff(target, version.as_deref(), protocol, reason)?
|
||||
{
|
||||
match live_handoff_remote_server(target, remote_herdr) {
|
||||
Ok(()) => return Ok(()),
|
||||
Err(err) => {
|
||||
eprintln!("remote live handoff failed: {err}");
|
||||
eprintln!("falling back to remote server restart.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if confirm_remote_server_stop(target, version.as_deref(), protocol, reason)? {
|
||||
stop_remote_server(target, remote_herdr)?;
|
||||
}
|
||||
|
|
@ -559,6 +603,85 @@ fn remote_server_restart_reason(
|
|||
None
|
||||
}
|
||||
|
||||
fn confirm_remote_install_with_running_server(
|
||||
target: &str,
|
||||
remote_herdr: &RemoteHerdr,
|
||||
) -> io::Result<()> {
|
||||
let status = match remote_server_status(target, remote_herdr) {
|
||||
Ok(status) => status,
|
||||
Err(err) => {
|
||||
if !io::stdin().is_terminal() {
|
||||
return Err(io::Error::other(format!(
|
||||
"could not inspect the running remote herdr server on {target} before installing: {err}; run from an interactive terminal to approve updating the remote binary"
|
||||
)));
|
||||
}
|
||||
eprintln!(
|
||||
"could not inspect the running remote herdr server on {target} before installing: {err}"
|
||||
);
|
||||
eprint!("continue installing the remote herdr binary? [Y/n] ");
|
||||
io::stderr().flush()?;
|
||||
|
||||
let mut answer = String::new();
|
||||
io::stdin().read_line(&mut answer)?;
|
||||
let answer = answer.trim().to_ascii_lowercase();
|
||||
if answer == "n" || answer == "no" {
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::Interrupted,
|
||||
"remote herdr install cancelled",
|
||||
));
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
};
|
||||
let RemoteServerStatus::Running {
|
||||
version,
|
||||
protocol,
|
||||
live_handoff,
|
||||
} = status
|
||||
else {
|
||||
return Ok(());
|
||||
};
|
||||
if live_handoff {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
if !io::stdin().is_terminal() {
|
||||
return Err(io::Error::other(format!(
|
||||
"remote herdr server on {target} is running v{} protocol {}, but it does not advertise live handoff; run from an interactive terminal to approve updating the remote binary",
|
||||
version_label(version.as_deref()),
|
||||
protocol_label(protocol)
|
||||
)));
|
||||
}
|
||||
|
||||
eprintln!("remote herdr server on {target} is currently running:");
|
||||
eprintln!(
|
||||
" server: v{} protocol {}",
|
||||
version_label(version.as_deref()),
|
||||
protocol_label(protocol)
|
||||
);
|
||||
eprintln!(
|
||||
"this server does not advertise live handoff, so this attach cannot preserve its running panes during the update."
|
||||
);
|
||||
eprintln!(
|
||||
"future remote updates can preserve panes after the remote server has run a handoff-capable version once."
|
||||
);
|
||||
eprintln!();
|
||||
eprint!("continue installing the remote herdr binary? [Y/n] ");
|
||||
io::stderr().flush()?;
|
||||
|
||||
let mut answer = String::new();
|
||||
io::stdin().read_line(&mut answer)?;
|
||||
let answer = answer.trim().to_ascii_lowercase();
|
||||
if answer == "n" || answer == "no" {
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::Interrupted,
|
||||
"remote herdr install cancelled",
|
||||
));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn remote_server_status(
|
||||
target: &str,
|
||||
remote_herdr: &RemoteHerdr,
|
||||
|
|
@ -583,6 +706,12 @@ struct RemoteServerStatusJson {
|
|||
running: bool,
|
||||
version: Option<String>,
|
||||
protocol: Option<u32>,
|
||||
capabilities: Option<RemoteServerCapabilitiesJson>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct RemoteServerCapabilitiesJson {
|
||||
live_handoff: bool,
|
||||
}
|
||||
|
||||
fn parse_client_status_json(status: &str) -> Option<RemoteClientStatusJson> {
|
||||
|
|
@ -602,6 +731,9 @@ fn parse_remote_server_status_json(status: &str) -> io::Result<RemoteServerStatu
|
|||
Ok(RemoteServerStatus::Running {
|
||||
version: parsed.version,
|
||||
protocol: parsed.protocol,
|
||||
live_handoff: parsed
|
||||
.capabilities
|
||||
.is_some_and(|capabilities| capabilities.live_handoff),
|
||||
})
|
||||
}
|
||||
|
||||
|
|
@ -677,6 +809,80 @@ fn confirm_remote_server_stop(
|
|||
Ok(true)
|
||||
}
|
||||
|
||||
fn confirm_remote_server_handoff(
|
||||
target: &str,
|
||||
version: Option<&str>,
|
||||
protocol: Option<u32>,
|
||||
reason: RemoteServerRestartReason,
|
||||
) -> io::Result<bool> {
|
||||
if !io::stdin().is_terminal() {
|
||||
if reason == RemoteServerRestartReason::ProtocolMismatch {
|
||||
return Err(io::Error::other(format!(
|
||||
"remote herdr server on {target} is running with protocol {}, but this client needs protocol {CURRENT_PROTOCOL}; run from an interactive terminal to approve live handoff or stopping it",
|
||||
protocol_label(protocol)
|
||||
)));
|
||||
}
|
||||
|
||||
eprintln!(
|
||||
"remote herdr server on {target} is still running v{}; it will use v{CURRENT_VERSION} after it restarts.",
|
||||
version_label(version)
|
||||
);
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
eprintln!("remote herdr server on {target} is currently running:");
|
||||
eprintln!(
|
||||
" server: v{} protocol {}",
|
||||
version_label(version),
|
||||
protocol_label(protocol)
|
||||
);
|
||||
eprintln!(" prepared binary: v{CURRENT_VERSION} protocol {CURRENT_PROTOCOL}");
|
||||
eprintln!();
|
||||
|
||||
match reason {
|
||||
RemoteServerRestartReason::ProtocolMismatch => {
|
||||
eprintln!(
|
||||
"the remote server protocol does not match this client. herdr will try to hand off live pane processes to the prepared remote server before the old server exits."
|
||||
);
|
||||
}
|
||||
RemoteServerRestartReason::BinaryUpdated => {
|
||||
eprintln!(
|
||||
"the remote herdr binary was installed or replaced. herdr will try to hand off live pane processes to the prepared remote server."
|
||||
);
|
||||
}
|
||||
RemoteServerRestartReason::VersionMismatch => {
|
||||
eprintln!(
|
||||
"the remote server is still running a different herdr version. herdr will try to hand off live pane processes to the prepared remote server."
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
eprint!("live-handoff remote panes to the prepared server? [Y/n] ");
|
||||
io::stderr().flush()?;
|
||||
|
||||
let mut answer = String::new();
|
||||
io::stdin().read_line(&mut answer)?;
|
||||
let answer = answer.trim().to_ascii_lowercase();
|
||||
Ok(answer != "n" && answer != "no")
|
||||
}
|
||||
|
||||
fn live_handoff_remote_server(target: &str, remote_herdr: &RemoteHerdr) -> io::Result<()> {
|
||||
let command = format!(
|
||||
"{} server live-handoff --import-exe {} --expected-protocol {CURRENT_PROTOCOL} --expected-version {CURRENT_VERSION}",
|
||||
remote_herdr.shell_path,
|
||||
remote_herdr.shell_path
|
||||
);
|
||||
let output = ssh_output(target, &command)?;
|
||||
if !output.status.success() {
|
||||
return Err(command_failed("remote server live handoff failed", &output));
|
||||
}
|
||||
|
||||
eprintln!(
|
||||
"handed off the remote herdr server on {target}; reconnecting to the prepared server."
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn stop_remote_server(target: &str, remote_herdr: &RemoteHerdr) -> io::Result<()> {
|
||||
let command = format!("{} server stop", remote_herdr.shell_path);
|
||||
let output = ssh_output(target, &command)?;
|
||||
|
|
@ -1495,6 +1701,21 @@ mod tests {
|
|||
|
||||
#[test]
|
||||
fn parse_remote_server_status_json_reads_running_server() {
|
||||
assert_eq!(
|
||||
parse_remote_server_status_json(
|
||||
r#"{"status":"running","running":true,"version":"0.6.0","protocol":8,"capabilities":{"live_handoff":true}}"#
|
||||
)
|
||||
.unwrap(),
|
||||
RemoteServerStatus::Running {
|
||||
version: Some("0.6.0".into()),
|
||||
protocol: Some(8),
|
||||
live_handoff: true
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_remote_server_status_json_treats_missing_capability_as_no_handoff() {
|
||||
assert_eq!(
|
||||
parse_remote_server_status_json(
|
||||
r#"{"status":"running","running":true,"version":"0.6.0","protocol":8}"#
|
||||
|
|
@ -1502,7 +1723,8 @@ mod tests {
|
|||
.unwrap(),
|
||||
RemoteServerStatus::Running {
|
||||
version: Some("0.6.0".into()),
|
||||
protocol: Some(8)
|
||||
protocol: Some(8),
|
||||
live_handoff: false
|
||||
}
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -48,3 +48,22 @@ pub(crate) fn accept_pending_client_connections(
|
|||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Drains pending thin-client connections without starting handshakes.
|
||||
///
|
||||
/// During live handoff the old server must not let clients sit in the Unix
|
||||
/// listener backlog waiting for a welcome frame that will never be sent.
|
||||
pub(crate) fn reject_pending_client_connections(listener: &UnixListener) -> io::Result<()> {
|
||||
loop {
|
||||
match listener.accept() {
|
||||
Ok((_stream, _addr)) => {}
|
||||
Err(ref err) if err.kind() == io::ErrorKind::WouldBlock => break,
|
||||
Err(err) => {
|
||||
error!(err = %err, "client listener reject failed");
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,446 @@
|
|||
#[cfg(unix)]
|
||||
use std::io::{self, Read, Write};
|
||||
#[cfg(unix)]
|
||||
use std::os::fd::{AsRawFd, RawFd};
|
||||
#[cfg(unix)]
|
||||
use std::os::unix::net::{UnixListener, UnixStream};
|
||||
#[cfg(unix)]
|
||||
use std::os::unix::process::CommandExt;
|
||||
#[cfg(unix)]
|
||||
use std::path::{Path, PathBuf};
|
||||
#[cfg(unix)]
|
||||
use std::process::Command;
|
||||
#[cfg(unix)]
|
||||
use std::time::Duration;
|
||||
|
||||
#[cfg(unix)]
|
||||
use serde::{Deserialize, Serialize};
|
||||
#[cfg(unix)]
|
||||
use tracing::{info, warn};
|
||||
|
||||
#[cfg(unix)]
|
||||
const HANDOFF_VERSION: u32 = 1;
|
||||
#[cfg(unix)]
|
||||
const READY_TIMEOUT: Duration = Duration::from_secs(30);
|
||||
#[cfg(unix)]
|
||||
pub(crate) const MAX_FDS_PER_HANDOFF: usize = 64;
|
||||
#[cfg(unix)]
|
||||
pub(crate) const MAX_REPLAY_BYTES_PER_PANE: usize = 8 * 1024;
|
||||
#[cfg(unix)]
|
||||
pub(crate) const COMMIT_TIMEOUT: Duration = READY_TIMEOUT;
|
||||
|
||||
#[cfg(unix)]
|
||||
#[derive(Serialize, Deserialize)]
|
||||
pub(crate) struct HandoffManifest {
|
||||
pub version: u32,
|
||||
pub source_version: String,
|
||||
pub source_protocol: u32,
|
||||
pub expected_version: Option<String>,
|
||||
pub expected_protocol: Option<u32>,
|
||||
pub snapshot: crate::persist::SessionSnapshot,
|
||||
pub panes: Vec<HandoffPane>,
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub(crate) struct HandoffPane {
|
||||
pub pane_id: u32,
|
||||
pub child_pid: u32,
|
||||
pub rows: u16,
|
||||
pub cols: u16,
|
||||
pub cell_width_px: u32,
|
||||
pub cell_height_px: u32,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub initial_history_ansi: Option<String>,
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
pub(crate) struct ReceivedHandoff {
|
||||
pub manifest: HandoffManifest,
|
||||
pub fds: Vec<RawFd>,
|
||||
pub stream: UnixStream,
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
pub(crate) fn handoff_socket_path() -> PathBuf {
|
||||
crate::session::data_dir().join(format!("herdr-handoff-{}.sock", std::process::id()))
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
pub(crate) fn spawn_handoff_import(
|
||||
import_exe: Option<&Path>,
|
||||
socket_path: &Path,
|
||||
token: &str,
|
||||
) -> io::Result<u32> {
|
||||
let fallback_exe;
|
||||
let exe = if let Some(import_exe) = import_exe {
|
||||
import_exe
|
||||
} else {
|
||||
fallback_exe = std::env::current_exe().map_err(|err| {
|
||||
io::Error::new(
|
||||
err.kind(),
|
||||
format!("failed to determine herdr executable path: {err}"),
|
||||
)
|
||||
})?;
|
||||
&fallback_exe
|
||||
};
|
||||
let mut command = Command::new(exe);
|
||||
command
|
||||
.arg("server")
|
||||
.arg("--handoff-import")
|
||||
.arg(socket_path)
|
||||
.arg(token)
|
||||
.process_group(0)
|
||||
.stdin(std::process::Stdio::null())
|
||||
.stdout(std::process::Stdio::null())
|
||||
.stderr(std::process::Stdio::null());
|
||||
let child = command.spawn().map_err(|err| {
|
||||
io::Error::new(
|
||||
err.kind(),
|
||||
format!(
|
||||
"failed to spawn handoff import server at {}: {err}",
|
||||
exe.display()
|
||||
),
|
||||
)
|
||||
})?;
|
||||
Ok(child.id())
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
pub(crate) fn bind_listener(socket_path: &Path) -> io::Result<UnixListener> {
|
||||
let _ = std::fs::remove_file(socket_path);
|
||||
let listener = UnixListener::bind(socket_path)?;
|
||||
listener.set_nonblocking(true)?;
|
||||
restrict_socket_permissions(socket_path)?;
|
||||
Ok(listener)
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
pub(crate) fn accept_and_validate_on(
|
||||
listener: UnixListener,
|
||||
socket_path: &Path,
|
||||
token: &str,
|
||||
manifest: &HandoffManifest,
|
||||
) -> io::Result<UnixStream> {
|
||||
let (mut stream, _) = accept_with_timeout(&listener, READY_TIMEOUT)?;
|
||||
stream.set_nonblocking(false)?;
|
||||
stream.set_read_timeout(Some(READY_TIMEOUT))?;
|
||||
stream.set_write_timeout(Some(READY_TIMEOUT))?;
|
||||
let token_line = read_line_unbuffered(&mut stream)?;
|
||||
if token_line.trim_end() != token {
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::PermissionDenied,
|
||||
"handoff import token mismatch",
|
||||
));
|
||||
}
|
||||
|
||||
serde_json::to_writer(&mut stream, manifest).map_err(io::Error::other)?;
|
||||
stream.write_all(b"\n")?;
|
||||
stream.flush()?;
|
||||
|
||||
stream.set_read_timeout(Some(READY_TIMEOUT))?;
|
||||
let validated = read_line_unbuffered(&mut stream)?;
|
||||
if validated.trim_end() != "validated" {
|
||||
return Err(io::Error::other("handoff import did not validate manifest"));
|
||||
}
|
||||
let _ = std::fs::remove_file(socket_path);
|
||||
Ok(stream)
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
pub(crate) fn send_fds_and_wait_restored(stream: &mut UnixStream, fds: &[RawFd]) -> io::Result<()> {
|
||||
if fds.len() > MAX_FDS_PER_HANDOFF {
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::InvalidInput,
|
||||
format!("handoff supports at most {MAX_FDS_PER_HANDOFF} pane file descriptors at once"),
|
||||
));
|
||||
}
|
||||
send_fds(stream, fds)?;
|
||||
|
||||
stream.set_read_timeout(Some(READY_TIMEOUT))?;
|
||||
let restored = read_line_unbuffered(&mut *stream)?;
|
||||
if restored.trim_end() != "restored" {
|
||||
return Err(io::Error::other(
|
||||
"handoff import did not report restored runtimes",
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
pub(crate) fn wait_ready(stream: &mut UnixStream) -> io::Result<()> {
|
||||
stream.set_read_timeout(Some(READY_TIMEOUT))?;
|
||||
let ready = read_line_unbuffered(&mut *stream)?;
|
||||
if ready.trim_end() != "ready" {
|
||||
return Err(io::Error::other("handoff import did not report ready"));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
pub(crate) fn report_committed(stream: &mut UnixStream) -> io::Result<()> {
|
||||
stream.write_all(b"committed\n")?;
|
||||
stream.flush()
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
pub(crate) fn wait_owned_ack(stream: &mut UnixStream) {
|
||||
if let Err(err) = stream.set_read_timeout(Some(READY_TIMEOUT)) {
|
||||
warn!(err = %err, "failed to set handoff ownership ack timeout");
|
||||
return;
|
||||
}
|
||||
match read_line_unbuffered(&mut *stream) {
|
||||
Ok(owned) if owned.trim_end() == "owned" => {}
|
||||
Ok(other) => {
|
||||
warn!(
|
||||
response = %other.trim_end(),
|
||||
"handoff import sent unexpected ownership ack after commit"
|
||||
);
|
||||
}
|
||||
Err(err) => {
|
||||
warn!(err = %err, "handoff import ownership ack was not received after commit");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
pub(crate) fn receive(socket_path: &Path, token: &str) -> io::Result<ReceivedHandoff> {
|
||||
let mut stream = UnixStream::connect(socket_path)?;
|
||||
stream.write_all(token.as_bytes())?;
|
||||
stream.write_all(b"\n")?;
|
||||
stream.flush()?;
|
||||
|
||||
let manifest_line = read_line_unbuffered(&mut stream)?;
|
||||
let manifest: HandoffManifest =
|
||||
serde_json::from_str(&manifest_line).map_err(io::Error::other)?;
|
||||
if manifest.version != HANDOFF_VERSION {
|
||||
return Err(io::Error::other(format!(
|
||||
"unsupported handoff version {}",
|
||||
manifest.version
|
||||
)));
|
||||
}
|
||||
if manifest
|
||||
.expected_protocol
|
||||
.is_some_and(|protocol| protocol != crate::protocol::PROTOCOL_VERSION)
|
||||
{
|
||||
return Err(io::Error::other(format!(
|
||||
"handoff expected protocol {}, but this server speaks protocol {}",
|
||||
manifest.expected_protocol.unwrap_or_default(),
|
||||
crate::protocol::PROTOCOL_VERSION
|
||||
)));
|
||||
}
|
||||
if manifest
|
||||
.expected_version
|
||||
.as_deref()
|
||||
.is_some_and(|version| version != env!("CARGO_PKG_VERSION"))
|
||||
{
|
||||
return Err(io::Error::other(format!(
|
||||
"handoff expected herdr v{}, but this server is v{}",
|
||||
manifest.expected_version.as_deref().unwrap_or("unknown"),
|
||||
env!("CARGO_PKG_VERSION")
|
||||
)));
|
||||
}
|
||||
stream.write_all(b"validated\n")?;
|
||||
stream.flush()?;
|
||||
let fds = recv_fds(&stream, manifest.panes.len())?;
|
||||
Ok(ReceivedHandoff {
|
||||
manifest,
|
||||
fds,
|
||||
stream,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
pub(crate) fn report_restored(stream: &mut UnixStream) -> io::Result<()> {
|
||||
stream.write_all(b"restored\n")?;
|
||||
stream.flush()
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
pub(crate) fn report_ready(stream: &mut UnixStream) -> io::Result<()> {
|
||||
stream.write_all(b"ready\n")?;
|
||||
stream.flush()
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
pub(crate) fn wait_committed(stream: &mut UnixStream) -> io::Result<()> {
|
||||
stream.set_read_timeout(Some(READY_TIMEOUT))?;
|
||||
let committed = read_line_unbuffered(&mut *stream)?;
|
||||
if committed.trim_end() != "committed" {
|
||||
return Err(io::Error::other("handoff source did not commit"));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
pub(crate) fn report_owned(stream: &mut UnixStream) -> io::Result<()> {
|
||||
stream.write_all(b"owned\n")?;
|
||||
stream.flush()
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
pub(crate) fn manifest_for(
|
||||
snapshot: crate::persist::SessionSnapshot,
|
||||
panes: Vec<HandoffPane>,
|
||||
expected_protocol: Option<u32>,
|
||||
expected_version: Option<String>,
|
||||
) -> HandoffManifest {
|
||||
HandoffManifest {
|
||||
version: HANDOFF_VERSION,
|
||||
source_version: env!("CARGO_PKG_VERSION").to_string(),
|
||||
source_protocol: crate::protocol::PROTOCOL_VERSION,
|
||||
expected_version,
|
||||
expected_protocol,
|
||||
snapshot,
|
||||
panes,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn restrict_socket_permissions(path: &Path) -> io::Result<()> {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
|
||||
std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600))
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn accept_with_timeout(
|
||||
listener: &UnixListener,
|
||||
timeout: Duration,
|
||||
) -> io::Result<(UnixStream, std::os::unix::net::SocketAddr)> {
|
||||
let deadline = std::time::Instant::now() + timeout;
|
||||
loop {
|
||||
match listener.accept() {
|
||||
Ok(accepted) => return Ok(accepted),
|
||||
Err(err) if err.kind() == io::ErrorKind::WouldBlock => {
|
||||
if std::time::Instant::now() >= deadline {
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::TimedOut,
|
||||
"timed out waiting for handoff import connection",
|
||||
));
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(25));
|
||||
}
|
||||
Err(err) if err.kind() == io::ErrorKind::Interrupted => {}
|
||||
Err(err) => return Err(err),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn read_line_unbuffered(stream: &mut UnixStream) -> io::Result<String> {
|
||||
let mut bytes = Vec::new();
|
||||
let mut byte = [0u8; 1];
|
||||
loop {
|
||||
let read = stream.read(&mut byte)?;
|
||||
if read == 0 {
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::UnexpectedEof,
|
||||
"handoff stream closed while reading line",
|
||||
));
|
||||
}
|
||||
bytes.push(byte[0]);
|
||||
if byte[0] == b'\n' {
|
||||
return String::from_utf8(bytes)
|
||||
.map_err(|err| io::Error::new(io::ErrorKind::InvalidData, err));
|
||||
}
|
||||
if bytes.len() > 16 * 1024 * 1024 {
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::InvalidData,
|
||||
"handoff line exceeded maximum size",
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn send_fds(stream: &UnixStream, fds: &[RawFd]) -> io::Result<()> {
|
||||
if fds.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
let byte = [b'F'];
|
||||
let iov = [libc::iovec {
|
||||
iov_base: byte.as_ptr() as *mut libc::c_void,
|
||||
iov_len: byte.len(),
|
||||
}];
|
||||
let fd_bytes = std::mem::size_of_val(fds);
|
||||
let mut control = vec![0u8; unsafe { libc::CMSG_SPACE(fd_bytes as u32) as usize }];
|
||||
let mut msg: libc::msghdr = unsafe { std::mem::zeroed() };
|
||||
msg.msg_iov = iov.as_ptr() as *mut libc::iovec;
|
||||
msg.msg_iovlen = iov.len() as _;
|
||||
msg.msg_control = control.as_mut_ptr() as *mut libc::c_void;
|
||||
msg.msg_controllen = control.len() as _;
|
||||
|
||||
unsafe {
|
||||
let cmsg = libc::CMSG_FIRSTHDR(&msg);
|
||||
if cmsg.is_null() {
|
||||
return Err(io::Error::other("failed to allocate fd control message"));
|
||||
}
|
||||
(*cmsg).cmsg_level = libc::SOL_SOCKET;
|
||||
(*cmsg).cmsg_type = libc::SCM_RIGHTS;
|
||||
(*cmsg).cmsg_len = libc::CMSG_LEN(fd_bytes as u32) as _;
|
||||
std::ptr::copy_nonoverlapping(fds.as_ptr() as *const u8, libc::CMSG_DATA(cmsg), fd_bytes);
|
||||
if libc::sendmsg(stream.as_raw_fd(), &msg, 0) < 0 {
|
||||
return Err(io::Error::last_os_error());
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn recv_fds(stream: &UnixStream, expected: usize) -> io::Result<Vec<RawFd>> {
|
||||
if expected == 0 {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
let mut byte = [0u8; 1];
|
||||
let mut iov = [libc::iovec {
|
||||
iov_base: byte.as_mut_ptr() as *mut libc::c_void,
|
||||
iov_len: byte.len(),
|
||||
}];
|
||||
let fd_bytes = expected * std::mem::size_of::<RawFd>();
|
||||
let mut control = vec![0u8; unsafe { libc::CMSG_SPACE(fd_bytes as u32) as usize }];
|
||||
let mut msg: libc::msghdr = unsafe { std::mem::zeroed() };
|
||||
msg.msg_iov = iov.as_mut_ptr();
|
||||
msg.msg_iovlen = iov.len() as _;
|
||||
msg.msg_control = control.as_mut_ptr() as *mut libc::c_void;
|
||||
msg.msg_controllen = control.len() as _;
|
||||
|
||||
let read = unsafe { libc::recvmsg(stream.as_raw_fd(), &mut msg, 0) };
|
||||
if read < 0 {
|
||||
return Err(io::Error::last_os_error());
|
||||
}
|
||||
if msg.msg_flags & libc::MSG_CTRUNC != 0 {
|
||||
return Err(io::Error::other("handoff fd control message was truncated"));
|
||||
}
|
||||
|
||||
let mut out = Vec::new();
|
||||
unsafe {
|
||||
let cmsg = libc::CMSG_FIRSTHDR(&msg);
|
||||
if cmsg.is_null()
|
||||
|| (*cmsg).cmsg_level != libc::SOL_SOCKET
|
||||
|| (*cmsg).cmsg_type != libc::SCM_RIGHTS
|
||||
{
|
||||
return Err(io::Error::other("handoff fd message missing SCM_RIGHTS"));
|
||||
}
|
||||
let data_len = ((*cmsg).cmsg_len as usize).saturating_sub(libc::CMSG_LEN(0) as usize);
|
||||
let count = data_len / std::mem::size_of::<RawFd>();
|
||||
let data = libc::CMSG_DATA(cmsg) as *const RawFd;
|
||||
for idx in 0..count {
|
||||
out.push(*data.add(idx));
|
||||
}
|
||||
}
|
||||
if out.len() != expected {
|
||||
for fd in out {
|
||||
let _ = unsafe { libc::close(fd) };
|
||||
}
|
||||
return Err(io::Error::other(format!(
|
||||
"expected {expected} handoff fds, received fewer"
|
||||
)));
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
pub(crate) fn log_import_result(panes: usize) {
|
||||
info!(panes, "handoff import ready");
|
||||
}
|
||||
|
|
@ -15,7 +15,6 @@
|
|||
//! and pane spawn failure during restore
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::fs;
|
||||
use std::io;
|
||||
use std::os::unix::net::UnixListener;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
|
@ -35,11 +34,14 @@ use crate::api;
|
|||
use crate::app;
|
||||
use crate::config;
|
||||
use crate::events::AppEvent;
|
||||
use crate::ipc::{remove_socket_file_if_owned, socket_file_identity, SocketFileIdentity};
|
||||
use crate::protocol::{
|
||||
self, AttachScrollDirection, AttachScrollSource, FrameData, ServerMessage, MAX_FRAME_SIZE,
|
||||
MAX_GRAPHICS_FRAME_SIZE,
|
||||
};
|
||||
use crate::server::client_accept::accept_pending_client_connections;
|
||||
use crate::server::client_accept::{
|
||||
accept_pending_client_connections, reject_pending_client_connections,
|
||||
};
|
||||
use crate::server::client_transport::ServerEvent;
|
||||
use crate::server::clients::{
|
||||
events_include_interaction, latest_app_client, render_targets, terminal_attach_client_ids,
|
||||
|
|
@ -58,6 +60,8 @@ use crate::server::terminal_attach::paste_payload_for_runtime;
|
|||
use crate::protocol::RenderEncoding;
|
||||
#[cfg(test)]
|
||||
use crate::server::client_transport::ClientWriter;
|
||||
#[cfg(test)]
|
||||
use std::fs;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Loop event enum for the headless server event loop
|
||||
|
|
@ -100,8 +104,11 @@ const CLIENT_ACCEPT_POLL_INTERVAL: Duration = Duration::from_millis(250);
|
|||
/// The headless server — runs the herdr event loop without a real terminal.
|
||||
pub struct HeadlessServer {
|
||||
app: app::App,
|
||||
api_tx: Option<api::ApiRequestSender>,
|
||||
api_server: Option<api::ServerHandle>,
|
||||
client_listener: UnixListener,
|
||||
client_socket_path: PathBuf,
|
||||
client_socket_identity: SocketFileIdentity,
|
||||
clients: HashMap<u64, ClientConnection>,
|
||||
next_client_id: u64,
|
||||
/// The client currently driving the shared pane runtime size, theme, and input keybindings.
|
||||
|
|
@ -121,6 +128,10 @@ pub struct HeadlessServer {
|
|||
effective_size: (u16, u16),
|
||||
/// Flag set when shutdown is initiated.
|
||||
shutting_down: bool,
|
||||
/// Flag set while exporting live PTYs to a replacement server.
|
||||
handoff_in_progress: bool,
|
||||
/// Imported panes get one app-safe resize nudge after the first client attaches.
|
||||
pending_handoff_repaint_nudge: bool,
|
||||
/// Flag set by Ctrl+C or `server stop` signal.
|
||||
should_quit: Arc<AtomicBool>,
|
||||
/// Channel for receiving server events from client connection threads.
|
||||
|
|
@ -209,12 +220,18 @@ impl HeadlessServer {
|
|||
/// 1. Prepares the client socket path (cleans up stale sockets)
|
||||
/// 2. Binds the client socket listener
|
||||
/// 3. Returns the server ready to run
|
||||
pub fn new(app: app::App, config_diagnostics: &[String]) -> io::Result<Self> {
|
||||
pub fn new(
|
||||
app: app::App,
|
||||
config_diagnostics: &[String],
|
||||
api_tx: Option<api::ApiRequestSender>,
|
||||
api_server: Option<api::ServerHandle>,
|
||||
) -> io::Result<Self> {
|
||||
let client_path = client_socket_path();
|
||||
prepare_socket_path(&client_path)?;
|
||||
|
||||
let listener = UnixListener::bind(&client_path)?;
|
||||
restrict_socket_permissions(&client_path)?;
|
||||
let client_socket_identity = socket_file_identity(&client_path)?;
|
||||
info!(path = %client_path.display(), "client protocol socket listening");
|
||||
|
||||
// Set non-blocking on the listener so we can poll it from the event loop.
|
||||
|
|
@ -230,8 +247,11 @@ impl HeadlessServer {
|
|||
|
||||
Ok(Self {
|
||||
app,
|
||||
api_tx,
|
||||
api_server,
|
||||
client_listener: listener,
|
||||
client_socket_path: client_path,
|
||||
client_socket_identity,
|
||||
clients: HashMap::new(),
|
||||
next_client_id: 1,
|
||||
foreground_client_id: None,
|
||||
|
|
@ -242,6 +262,8 @@ impl HeadlessServer {
|
|||
next_activity_stamp: 1,
|
||||
effective_size: (MIN_COLS, MIN_ROWS),
|
||||
shutting_down: false,
|
||||
handoff_in_progress: false,
|
||||
pending_handoff_repaint_nudge: false,
|
||||
should_quit,
|
||||
server_event_rx,
|
||||
server_event_tx,
|
||||
|
|
@ -539,6 +561,217 @@ impl HeadlessServer {
|
|||
}
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn perform_live_handoff(
|
||||
&mut self,
|
||||
params: crate::api::schema::ServerLiveHandoffParams,
|
||||
) -> io::Result<()> {
|
||||
info!("starting live handoff");
|
||||
let import_exe = params.import_exe.as_deref().map(std::path::PathBuf::from);
|
||||
let socket_path = crate::server::handoff::handoff_socket_path();
|
||||
let token = format!(
|
||||
"{}-{}",
|
||||
std::process::id(),
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_nanos()
|
||||
);
|
||||
let listener = match crate::server::handoff::bind_listener(&socket_path) {
|
||||
Ok(listener) => listener,
|
||||
Err(err) => {
|
||||
self.handoff_in_progress = false;
|
||||
return Err(err);
|
||||
}
|
||||
};
|
||||
|
||||
let mut pane_by_terminal = HashMap::new();
|
||||
for ws in &self.app.state.workspaces {
|
||||
for tab in &ws.tabs {
|
||||
for (pane_id, pane) in &tab.panes {
|
||||
pane_by_terminal.insert(pane.attached_terminal_id.clone(), pane_id.raw());
|
||||
}
|
||||
}
|
||||
}
|
||||
if pane_by_terminal.len() > crate::server::handoff::MAX_FDS_PER_HANDOFF {
|
||||
let _ = std::fs::remove_file(&socket_path);
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::InvalidInput,
|
||||
format!(
|
||||
"live handoff supports at most {} panes in one update; close panes or restart herdr normally",
|
||||
crate::server::handoff::MAX_FDS_PER_HANDOFF
|
||||
),
|
||||
));
|
||||
}
|
||||
|
||||
self.handoff_in_progress = true;
|
||||
self.disconnect_all_clients_for_handoff();
|
||||
let _ = reject_pending_client_connections(&self.client_listener);
|
||||
|
||||
let mut paused_terminal_ids = Vec::new();
|
||||
for terminal_id in pane_by_terminal.keys() {
|
||||
if let Some(runtime) = self.app.terminal_runtimes.get(terminal_id) {
|
||||
if let Err(err) = runtime.pause_handoff_reader(Duration::from_secs(2)) {
|
||||
self.rollback_handoff_before_commit(&socket_path, &paused_terminal_ids);
|
||||
return Err(err);
|
||||
}
|
||||
paused_terminal_ids.push(terminal_id.clone());
|
||||
}
|
||||
}
|
||||
|
||||
let snapshot = crate::persist::capture(
|
||||
&self.app.state.workspaces,
|
||||
&self.app.state.terminals,
|
||||
&self.app.terminal_runtimes,
|
||||
self.app.state.active,
|
||||
self.app.state.selected,
|
||||
self.app.state.agent_panel_scope,
|
||||
self.app.state.sidebar_width,
|
||||
self.app.state.sidebar_section_split,
|
||||
self.app.state.collapsed_space_keys.clone(),
|
||||
);
|
||||
|
||||
let mut panes = Vec::new();
|
||||
for (terminal_id, runtime) in self.app.terminal_runtimes.iter() {
|
||||
let Some(pane_id) = pane_by_terminal.get(terminal_id).copied() else {
|
||||
continue;
|
||||
};
|
||||
let mut handoff_pane = runtime.handoff_pane(pane_id);
|
||||
let has_agent_session = self
|
||||
.app
|
||||
.state
|
||||
.terminals
|
||||
.get(terminal_id)
|
||||
.is_some_and(|terminal| terminal.persisted_agent_session.is_some());
|
||||
if !has_agent_session {
|
||||
handoff_pane.initial_history_ansi = runtime.handoff_history_ansi();
|
||||
}
|
||||
panes.push(handoff_pane);
|
||||
}
|
||||
|
||||
let manifest = crate::server::handoff::manifest_for(
|
||||
snapshot,
|
||||
panes,
|
||||
params.expected_protocol,
|
||||
params.expected_version,
|
||||
);
|
||||
let child_pid = match crate::server::handoff::spawn_handoff_import(
|
||||
import_exe.as_deref(),
|
||||
&socket_path,
|
||||
&token,
|
||||
) {
|
||||
Ok(child_pid) => child_pid,
|
||||
Err(err) => {
|
||||
self.rollback_handoff_before_commit(&socket_path, &paused_terminal_ids);
|
||||
return Err(err);
|
||||
}
|
||||
};
|
||||
info!(pid = child_pid, socket = %socket_path.display(), "spawned handoff import server");
|
||||
|
||||
let mut fds = Vec::new();
|
||||
let duplicate_result = (|| {
|
||||
for (terminal_id, runtime) in self.app.terminal_runtimes.iter() {
|
||||
if !pane_by_terminal.contains_key(terminal_id) {
|
||||
continue;
|
||||
}
|
||||
fds.push(runtime.duplicate_handoff_fd()?);
|
||||
}
|
||||
Ok::<(), io::Error>(())
|
||||
})();
|
||||
if let Err(err) = duplicate_result {
|
||||
for fd in fds {
|
||||
let _ = unsafe { libc::close(fd) };
|
||||
}
|
||||
self.rollback_handoff_before_commit(&socket_path, &paused_terminal_ids);
|
||||
return Err(err);
|
||||
}
|
||||
|
||||
let mut stream = match crate::server::handoff::accept_and_validate_on(
|
||||
listener,
|
||||
&socket_path,
|
||||
&token,
|
||||
&manifest,
|
||||
) {
|
||||
Ok(stream) => stream,
|
||||
Err(err) => {
|
||||
for fd in fds {
|
||||
let _ = unsafe { libc::close(fd) };
|
||||
}
|
||||
self.rollback_handoff_before_commit(&socket_path, &paused_terminal_ids);
|
||||
return Err(err);
|
||||
}
|
||||
};
|
||||
|
||||
let send_result = crate::server::handoff::send_fds_and_wait_restored(&mut stream, &fds);
|
||||
for fd in fds {
|
||||
let _ = unsafe { libc::close(fd) };
|
||||
}
|
||||
if let Err(err) = send_result {
|
||||
self.rollback_handoff_before_commit(&socket_path, &paused_terminal_ids);
|
||||
return Err(err);
|
||||
}
|
||||
|
||||
if let Some(api_server) = &self.api_server {
|
||||
let _ = api_server.remove_socket_file_if_owned();
|
||||
} else {
|
||||
let _ = std::fs::remove_file(crate::api::socket_path());
|
||||
}
|
||||
let _ = remove_socket_file_if_owned(&self.client_socket_path, self.client_socket_identity);
|
||||
if let Err(err) = crate::server::handoff::wait_ready(&mut stream) {
|
||||
match self.wait_then_restore_public_sockets_after_failed_handoff() {
|
||||
Ok(()) => {
|
||||
self.rollback_handoff_before_commit(&socket_path, &paused_terminal_ids);
|
||||
}
|
||||
Err(restore_err) => {
|
||||
self.rollback_handoff_before_commit(&socket_path, &paused_terminal_ids);
|
||||
return Err(io::Error::other(format!(
|
||||
"handoff replacement server did not become ready: {err}; old server could not restore public sockets: {restore_err}"
|
||||
)));
|
||||
}
|
||||
}
|
||||
return Err(io::Error::other(format!(
|
||||
"handoff replacement server did not become ready: {err}"
|
||||
)));
|
||||
}
|
||||
if let Err(err) = crate::server::handoff::report_committed(&mut stream) {
|
||||
match self.wait_then_restore_public_sockets_after_failed_handoff() {
|
||||
Ok(()) => {
|
||||
self.rollback_handoff_before_commit(&socket_path, &paused_terminal_ids);
|
||||
}
|
||||
Err(restore_err) => {
|
||||
self.rollback_handoff_before_commit(&socket_path, &paused_terminal_ids);
|
||||
return Err(io::Error::other(format!(
|
||||
"handoff replacement server was ready, but commit failed: {err}; old server could not restore public sockets: {restore_err}"
|
||||
)));
|
||||
}
|
||||
}
|
||||
return Err(err);
|
||||
}
|
||||
|
||||
for (terminal_id, runtime) in self.app.terminal_runtimes.drain_for_handoff() {
|
||||
if !pane_by_terminal.contains_key(&terminal_id) {
|
||||
continue;
|
||||
}
|
||||
debug!(terminal = %terminal_id, "preserving pane runtime for handoff");
|
||||
runtime.preserve_for_handoff();
|
||||
}
|
||||
crate::server::handoff::wait_owned_ack(&mut stream);
|
||||
|
||||
self.shutting_down = true;
|
||||
self.app.state.should_quit = true;
|
||||
self.app.no_session = true;
|
||||
info!("live handoff completed; old server exiting");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(not(unix))]
|
||||
fn perform_live_handoff(
|
||||
&mut self,
|
||||
_params: crate::api::schema::ServerLiveHandoffParams,
|
||||
) -> io::Result<()> {
|
||||
Err(io::Error::other("live handoff is only supported on Unix"))
|
||||
}
|
||||
|
||||
fn sync_visible_server_config_diagnostic(&mut self, uses_local_keybindings: bool) {
|
||||
let visible = if uses_local_keybindings {
|
||||
&self.server_config_diagnostic_without_keybindings
|
||||
|
|
@ -552,6 +785,64 @@ impl HeadlessServer {
|
|||
}
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn restore_public_sockets_after_failed_handoff(&mut self) -> io::Result<()> {
|
||||
let api_tx = self
|
||||
.api_tx
|
||||
.clone()
|
||||
.ok_or_else(|| io::Error::other("cannot restore api socket without api sender"))?;
|
||||
let api_server = api::start_server(api_tx, self.app.event_hub.clone())?;
|
||||
|
||||
let client_path = client_socket_path();
|
||||
prepare_socket_path(&client_path)?;
|
||||
let listener = UnixListener::bind(&client_path)?;
|
||||
restrict_socket_permissions(&client_path)?;
|
||||
let client_socket_identity = socket_file_identity(&client_path)?;
|
||||
listener.set_nonblocking(true)?;
|
||||
|
||||
self.api_server = Some(api_server);
|
||||
self.client_listener = listener;
|
||||
self.client_socket_path = client_path;
|
||||
self.client_socket_identity = client_socket_identity;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn wait_then_restore_public_sockets_after_failed_handoff(&mut self) -> io::Result<()> {
|
||||
let timeout = crate::server::handoff::COMMIT_TIMEOUT + Duration::from_secs(2);
|
||||
wait_for_old_public_sockets_to_close(timeout)?;
|
||||
self.restore_public_sockets_after_failed_handoff()
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn rollback_handoff_before_commit(
|
||||
&mut self,
|
||||
socket_path: &Path,
|
||||
paused_terminal_ids: &[crate::terminal::TerminalId],
|
||||
) {
|
||||
for terminal_id in paused_terminal_ids {
|
||||
if let Some(runtime) = self.app.terminal_runtimes.get(terminal_id) {
|
||||
runtime.set_handoff_reader_paused(false);
|
||||
}
|
||||
}
|
||||
self.handoff_in_progress = false;
|
||||
let _ = std::fs::remove_file(socket_path);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn nudge_handoff_panes_on_first_client_attach(&mut self) {
|
||||
if !self.pending_handoff_repaint_nudge {
|
||||
return;
|
||||
}
|
||||
self.pending_handoff_repaint_nudge = false;
|
||||
self.app
|
||||
.terminal_runtimes
|
||||
.nudge_child_redraw_after_handoff();
|
||||
}
|
||||
|
||||
#[cfg(not(unix))]
|
||||
fn nudge_handoff_panes_on_first_client_attach(&mut self) {}
|
||||
|
||||
fn reload_server_config(&mut self, notify_success: bool) -> crate::config::ConfigReloadReport {
|
||||
let server_keybindings = self.server_keybindings.clone();
|
||||
apply_keybindings(&mut self.app, &server_keybindings);
|
||||
|
|
@ -706,6 +997,9 @@ impl HeadlessServer {
|
|||
|
||||
/// Accepts pending client connections from the non-blocking listener.
|
||||
fn accept_client_connections(&mut self) -> io::Result<()> {
|
||||
if self.handoff_in_progress {
|
||||
return reject_pending_client_connections(&self.client_listener);
|
||||
}
|
||||
accept_pending_client_connections(
|
||||
&self.client_listener,
|
||||
&mut self.next_client_id,
|
||||
|
|
@ -1236,6 +1530,28 @@ impl HeadlessServer {
|
|||
}
|
||||
}
|
||||
|
||||
fn disconnect_all_clients_for_handoff(&mut self) {
|
||||
let client_ids = self.clients.keys().copied().collect::<Vec<_>>();
|
||||
for client_id in client_ids {
|
||||
self.send_client_graphics_cleanup(client_id);
|
||||
self.send_to_client(
|
||||
client_id,
|
||||
ServerMessage::ServerShutdown {
|
||||
reason: Some(
|
||||
"live update in progress; reconnect after handoff completes".to_owned(),
|
||||
),
|
||||
},
|
||||
);
|
||||
if let Some(client) = self.clients.get_mut(&client_id) {
|
||||
client.writer = None;
|
||||
}
|
||||
let _ = self.remove_client(client_id);
|
||||
}
|
||||
self.foreground_client_id = None;
|
||||
self.sync_foreground_client_state();
|
||||
self.resize_shared_runtime_to_effective_size();
|
||||
}
|
||||
|
||||
fn attach_terminal_client(
|
||||
&mut self,
|
||||
client_id: u64,
|
||||
|
|
@ -1310,6 +1626,10 @@ impl HeadlessServer {
|
|||
|
||||
/// Handles a server event. Returns true if the event requires a re-render.
|
||||
fn handle_server_event(&mut self, ev: ServerEvent) -> bool {
|
||||
if self.handoff_in_progress && Self::ignore_client_event_during_handoff(&ev) {
|
||||
return false;
|
||||
}
|
||||
|
||||
match ev {
|
||||
ServerEvent::ClientConnected {
|
||||
client_id,
|
||||
|
|
@ -1321,6 +1641,19 @@ impl HeadlessServer {
|
|||
writer,
|
||||
render_encoding,
|
||||
} => {
|
||||
if self.handoff_in_progress {
|
||||
if let Ok(message) =
|
||||
Self::frame_server_message(&ServerMessage::ServerShutdown {
|
||||
reason: Some(
|
||||
"live update in progress; reconnect after handoff completes"
|
||||
.to_owned(),
|
||||
),
|
||||
})
|
||||
{
|
||||
let _ = writer.control.send(message);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
info!(
|
||||
client_id,
|
||||
cols,
|
||||
|
|
@ -1351,6 +1684,7 @@ impl HeadlessServer {
|
|||
self.foreground_client_id = Some(client_id);
|
||||
self.sync_foreground_client_state();
|
||||
self.resize_shared_runtime_to_effective_size();
|
||||
self.nudge_handoff_panes_on_first_client_attach();
|
||||
true
|
||||
}
|
||||
ServerEvent::ClientAttachTerminal {
|
||||
|
|
@ -1370,6 +1704,14 @@ impl HeadlessServer {
|
|||
client_id, source, direction, lines, column, row, modifiers,
|
||||
),
|
||||
ServerEvent::ClientInput { client_id, data } => {
|
||||
if self.handoff_in_progress {
|
||||
debug!(
|
||||
client_id,
|
||||
len = data.len(),
|
||||
"ignored client input during handoff"
|
||||
);
|
||||
return false;
|
||||
}
|
||||
debug!(client_id, len = data.len(), "client input received");
|
||||
if let Some(ClientConnection {
|
||||
mode: ClientConnectionMode::TerminalAttach { terminal_id },
|
||||
|
|
@ -1548,6 +1890,16 @@ impl HeadlessServer {
|
|||
}
|
||||
}
|
||||
|
||||
fn ignore_client_event_during_handoff(ev: &ServerEvent) -> bool {
|
||||
!matches!(
|
||||
ev,
|
||||
ServerEvent::ClientConnected { .. }
|
||||
| ServerEvent::ClientDisconnected { .. }
|
||||
| ServerEvent::ClientWriterDrained { .. }
|
||||
| ServerEvent::QuitSignal
|
||||
)
|
||||
}
|
||||
|
||||
/// Drains API requests with shutdown awareness.
|
||||
///
|
||||
/// During shutdown, remaining requests get a `server_unavailable` error.
|
||||
|
|
@ -1583,6 +1935,25 @@ impl HeadlessServer {
|
|||
return false;
|
||||
}
|
||||
|
||||
if let api::schema::Method::ServerLiveHandoff(params) = &msg.request.method {
|
||||
let response = match self.perform_live_handoff(params.clone()) {
|
||||
Ok(()) => serde_json::to_string(&api::schema::SuccessResponse {
|
||||
id: msg.request.id,
|
||||
result: api::schema::ResponseResult::Ok {},
|
||||
}),
|
||||
Err(err) => serde_json::to_string(&api::schema::ErrorResponse {
|
||||
id: msg.request.id,
|
||||
error: api::schema::ErrorBody {
|
||||
code: "handoff_failed".into(),
|
||||
message: err.to_string(),
|
||||
},
|
||||
}),
|
||||
}
|
||||
.unwrap_or_else(|_| "{}".to_string());
|
||||
let _ = msg.respond_to.send(response);
|
||||
return true;
|
||||
}
|
||||
|
||||
let changed = api::request_changes_ui(&msg.request);
|
||||
let changed = self.drain_all_internal_events_with_forwarding() || changed;
|
||||
|
||||
|
|
@ -2153,7 +2524,9 @@ impl HeadlessServer {
|
|||
|
||||
/// Removes socket files created by the server.
|
||||
fn cleanup_sockets(&self) -> io::Result<()> {
|
||||
if let Err(err) = fs::remove_file(&self.client_socket_path) {
|
||||
if let Err(err) =
|
||||
remove_socket_file_if_owned(&self.client_socket_path, self.client_socket_identity)
|
||||
{
|
||||
if err.kind() != io::ErrorKind::NotFound {
|
||||
warn!(
|
||||
path = %self.client_socket_path.display(),
|
||||
|
|
@ -2224,12 +2597,24 @@ fn is_keybinding_config_diagnostic(diagnostic: &str) -> bool {
|
|||
pub fn run_server() -> io::Result<()> {
|
||||
init_logging();
|
||||
|
||||
let args: Vec<String> = std::env::args().collect();
|
||||
if args.get(2).map(String::as_str) == Some("--handoff-import") {
|
||||
let socket_path = args
|
||||
.get(3)
|
||||
.map(PathBuf::from)
|
||||
.ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, "missing handoff socket"))?;
|
||||
let token = args
|
||||
.get(4)
|
||||
.ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, "missing handoff token"))?;
|
||||
return run_handoff_import_server(&socket_path, token);
|
||||
}
|
||||
|
||||
let loaded_config = config::Config::load();
|
||||
let (api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel();
|
||||
let event_hub = api::EventHub::default();
|
||||
|
||||
// Start the JSON API socket server.
|
||||
let _api_server = match api::start_server(api_tx, event_hub.clone()) {
|
||||
let _api_server = match api::start_server(api_tx.clone(), event_hub.clone()) {
|
||||
Ok(server) => server,
|
||||
Err(err) if err.kind() == io::ErrorKind::AddrInUse => {
|
||||
eprintln!("error: herdr server is already running");
|
||||
|
|
@ -2263,7 +2648,12 @@ pub fn run_server() -> io::Result<()> {
|
|||
app.local_terminal_notifications = false;
|
||||
|
||||
// Create the headless server.
|
||||
let mut server = match HeadlessServer::new(app, &loaded_config.diagnostics) {
|
||||
let mut server = match HeadlessServer::new(
|
||||
app,
|
||||
&loaded_config.diagnostics,
|
||||
Some(api_tx.clone()),
|
||||
Some(_api_server),
|
||||
) {
|
||||
Ok(server) => server,
|
||||
Err(err) if err.kind() == io::ErrorKind::AddrInUse => {
|
||||
eprintln!("error: herdr server is already running");
|
||||
|
|
@ -2288,6 +2678,106 @@ pub fn run_server() -> io::Result<()> {
|
|||
result
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn run_handoff_import_server(socket_path: &Path, token: &str) -> io::Result<()> {
|
||||
let loaded_config = config::Config::load();
|
||||
let mut received = crate::server::handoff::receive(socket_path, token)?;
|
||||
crate::server::handoff::log_import_result(received.manifest.panes.len());
|
||||
|
||||
let (api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel();
|
||||
let event_hub = api::EventHub::default();
|
||||
|
||||
let mut imports = HashMap::new();
|
||||
for (pane, fd) in received.manifest.panes.iter().zip(received.fds) {
|
||||
imports.insert(
|
||||
pane.pane_id,
|
||||
crate::persist::ImportedPaneRuntime {
|
||||
master_fd: fd,
|
||||
child_pid: pane.child_pid,
|
||||
rows: pane.rows,
|
||||
cols: pane.cols,
|
||||
cell_width_px: pane.cell_width_px,
|
||||
cell_height_px: pane.cell_height_px,
|
||||
initial_history_ansi: pane.initial_history_ansi.clone(),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
let rt = tokio::runtime::Builder::new_multi_thread()
|
||||
.enable_all()
|
||||
.build()
|
||||
.map_err(io::Error::other)?;
|
||||
|
||||
let result = rt.block_on(async {
|
||||
let mut app = app::App::new_from_handoff(
|
||||
&loaded_config.config,
|
||||
config::config_diagnostic_summary(&loaded_config.diagnostics),
|
||||
api_rx,
|
||||
event_hub.clone(),
|
||||
&received.manifest.snapshot,
|
||||
&mut imports,
|
||||
)?;
|
||||
app.state.local_sound_playback = false;
|
||||
app.local_terminal_notifications = false;
|
||||
crate::server::handoff::report_restored(&mut received.stream)?;
|
||||
if std::env::var("HERDR_TEST_HANDOFF_IMPORT_FAIL").as_deref() == Ok("after_restored") {
|
||||
return Err(io::Error::other(
|
||||
"test handoff import failure after restored",
|
||||
));
|
||||
}
|
||||
wait_for_old_public_sockets_to_close(Duration::from_secs(5))?;
|
||||
|
||||
let api_server = api::start_server(api_tx.clone(), event_hub.clone())?;
|
||||
let mut server = HeadlessServer::new(
|
||||
app,
|
||||
&loaded_config.diagnostics,
|
||||
Some(api_tx.clone()),
|
||||
Some(api_server),
|
||||
)?;
|
||||
crate::server::handoff::report_ready(&mut received.stream)?;
|
||||
crate::server::handoff::wait_committed(&mut received.stream)?;
|
||||
server.app.assume_handoff_ownership();
|
||||
server.app.unpause_handoff_readers();
|
||||
server.pending_handoff_repaint_nudge = true;
|
||||
if let Err(err) = crate::server::handoff::report_owned(&mut received.stream) {
|
||||
warn!(err = %err, "failed to report handoff ownership; continuing as owner");
|
||||
}
|
||||
info!("handoff import server started");
|
||||
print_ready_message(&api::socket_path(), &client_socket_path());
|
||||
server.run().await
|
||||
});
|
||||
|
||||
rt.shutdown_timeout(Duration::from_millis(100));
|
||||
crate::logging::shutdown("server");
|
||||
result
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn wait_for_old_public_sockets_to_close(timeout: Duration) -> io::Result<()> {
|
||||
use std::os::unix::net::UnixStream;
|
||||
|
||||
let deadline = Instant::now() + timeout;
|
||||
let api_socket = api::socket_path();
|
||||
let client_socket = client_socket_path();
|
||||
while Instant::now() < deadline {
|
||||
let api_open = api_socket.exists() && UnixStream::connect(&api_socket).is_ok();
|
||||
let client_open = client_socket.exists() && UnixStream::connect(&client_socket).is_ok();
|
||||
if !api_open && !client_open {
|
||||
return Ok(());
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(50));
|
||||
}
|
||||
Err(io::Error::new(
|
||||
io::ErrorKind::TimedOut,
|
||||
"old server sockets did not close before handoff import bind",
|
||||
))
|
||||
}
|
||||
|
||||
#[cfg(not(unix))]
|
||||
fn run_handoff_import_server(_socket_path: &Path, _token: &str) -> io::Result<()> {
|
||||
Err(io::Error::other("live handoff is only supported on Unix"))
|
||||
}
|
||||
|
||||
fn print_ready_message(api_socket: &Path, client_socket: &Path) {
|
||||
eprintln!("herdr server running; you can use any herdr CLI command in another terminal.");
|
||||
eprintln!("api socket: {}", api_socket.display());
|
||||
|
|
@ -2336,6 +2826,8 @@ mod tests {
|
|||
let socket_path = dir.join("client.sock");
|
||||
let _ = fs::remove_file(&socket_path);
|
||||
let listener = UnixListener::bind(&socket_path).expect("bind test listener");
|
||||
let client_socket_identity =
|
||||
socket_file_identity(&socket_path).expect("test listener socket identity");
|
||||
listener
|
||||
.set_nonblocking(true)
|
||||
.expect("set listener nonblocking");
|
||||
|
|
@ -2344,8 +2836,11 @@ mod tests {
|
|||
|
||||
HeadlessServer {
|
||||
app,
|
||||
api_tx: None,
|
||||
api_server: None,
|
||||
client_listener: listener,
|
||||
client_socket_path: socket_path,
|
||||
client_socket_identity,
|
||||
clients: HashMap::new(),
|
||||
next_client_id: 1,
|
||||
foreground_client_id: None,
|
||||
|
|
@ -2356,6 +2851,8 @@ mod tests {
|
|||
next_activity_stamp: 1,
|
||||
effective_size: (MIN_COLS, MIN_ROWS),
|
||||
shutting_down: false,
|
||||
handoff_in_progress: false,
|
||||
pending_handoff_repaint_nudge: false,
|
||||
should_quit: Arc::new(AtomicBool::new(false)),
|
||||
server_event_rx,
|
||||
server_event_tx,
|
||||
|
|
|
|||
|
|
@ -3,6 +3,8 @@ pub(crate) mod client_accept;
|
|||
pub(crate) mod client_transport;
|
||||
pub(crate) mod clients;
|
||||
pub(crate) mod clipboard_image;
|
||||
#[cfg(unix)]
|
||||
pub(crate) mod handoff;
|
||||
pub mod headless;
|
||||
pub(crate) mod keybindings;
|
||||
pub(crate) mod notifications;
|
||||
|
|
|
|||
|
|
@ -100,7 +100,11 @@ pub fn local_attach_command() -> String {
|
|||
}
|
||||
|
||||
pub fn local_stop_command() -> String {
|
||||
match active_name() {
|
||||
stop_command_for(active_name().as_deref())
|
||||
}
|
||||
|
||||
pub fn stop_command_for(name: Option<&str>) -> String {
|
||||
match name {
|
||||
Some(name) => format!("herdr session stop {name}"),
|
||||
None => "herdr server stop".to_string(),
|
||||
}
|
||||
|
|
|
|||
|
|
@ -19,6 +19,61 @@ impl TerminalRuntime {
|
|||
self.0.shutdown();
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
pub fn duplicate_handoff_fd(&self) -> std::io::Result<std::os::fd::RawFd> {
|
||||
self.0.duplicate_handoff_fd()
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
pub fn preserve_for_handoff(self) {
|
||||
self.0.preserve_for_handoff()
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
pub fn assume_handoff_ownership(&mut self) {
|
||||
self.0.assume_handoff_ownership();
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
pub fn set_handoff_reader_paused(&self, paused: bool) {
|
||||
self.0.set_handoff_reader_paused(paused);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
pub fn pause_handoff_reader(&self, timeout: std::time::Duration) -> std::io::Result<()> {
|
||||
self.0.pause_handoff_reader(timeout)
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
pub fn handoff_pane(&self, pane_id: u32) -> crate::server::handoff::HandoffPane {
|
||||
self.0.handoff_pane(pane_id)
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
pub fn handoff_history_ansi(&self) -> Option<String> {
|
||||
self.0.handoff_history_ansi()
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
pub fn from_handoff_fd(
|
||||
import: crate::pane::PaneRuntimeImport,
|
||||
scrollback_limit_bytes: usize,
|
||||
host_terminal_theme: crate::terminal_theme::TerminalTheme,
|
||||
events: mpsc::Sender<AppEvent>,
|
||||
render_notify: Arc<Notify>,
|
||||
render_dirty: Arc<AtomicBool>,
|
||||
) -> std::io::Result<Self> {
|
||||
crate::pane::PaneRuntime::from_handoff_fd(
|
||||
import,
|
||||
scrollback_limit_bytes,
|
||||
host_terminal_theme,
|
||||
events,
|
||||
render_notify,
|
||||
render_dirty,
|
||||
)
|
||||
.map(Self)
|
||||
}
|
||||
|
||||
pub fn spawn(
|
||||
pane_id: PaneId,
|
||||
rows: u16,
|
||||
|
|
@ -172,6 +227,10 @@ impl TerminalRuntime {
|
|||
self.0.resize(rows, cols, cell_width_px, cell_height_px);
|
||||
}
|
||||
|
||||
pub fn nudge_child_redraw_after_handoff(&self) {
|
||||
self.0.nudge_child_redraw_after_handoff();
|
||||
}
|
||||
|
||||
pub fn scroll_up(&self, lines: usize) {
|
||||
self.0.scroll_up(lines);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -37,10 +37,43 @@ impl TerminalRuntimeRegistry {
|
|||
self.runtimes.values()
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
pub(crate) fn iter(&self) -> impl Iterator<Item = (&TerminalId, &TerminalRuntime)> {
|
||||
self.runtimes.iter()
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
pub(crate) fn set_handoff_readers_paused(&self, paused: bool) {
|
||||
for runtime in self.runtimes.values() {
|
||||
runtime.set_handoff_reader_paused(paused);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
pub(crate) fn assume_handoff_ownership(&mut self) {
|
||||
for runtime in self.runtimes.values_mut() {
|
||||
runtime.assume_handoff_ownership();
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn len(&self) -> usize {
|
||||
self.runtimes.len()
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
pub(crate) fn nudge_child_redraw_after_handoff(&self) {
|
||||
for runtime in self.runtimes.values() {
|
||||
runtime.nudge_child_redraw_after_handoff();
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
pub(crate) fn drain_for_handoff(
|
||||
&mut self,
|
||||
) -> impl Iterator<Item = (TerminalId, TerminalRuntime)> + '_ {
|
||||
self.runtimes.drain()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn drain(&mut self) -> impl Iterator<Item = (TerminalId, TerminalRuntime)> + '_ {
|
||||
self.runtimes.drain()
|
||||
|
|
|
|||
1207
src/update.rs
1207
src/update.rs
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,817 @@
|
|||
mod support;
|
||||
|
||||
use std::fs;
|
||||
use std::io::{BufRead, BufReader, Read, Write};
|
||||
use std::net::{TcpListener, TcpStream};
|
||||
use std::os::unix::net::UnixStream;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
use std::sync::{Mutex, MutexGuard, OnceLock};
|
||||
use std::thread;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use portable_pty::{native_pty_system, Child, CommandBuilder, MasterPty, PtySize};
|
||||
use support::{
|
||||
cleanup_test_base, client_handshake, register_runtime_dir, register_spawned_herdr_pid,
|
||||
unregister_spawned_herdr_pid, wait_for_disconnect, wait_for_socket,
|
||||
};
|
||||
|
||||
struct SpawnedHerdr {
|
||||
_master: Box<dyn MasterPty + Send>,
|
||||
child: Box<dyn Child + Send + Sync>,
|
||||
}
|
||||
|
||||
impl Drop for SpawnedHerdr {
|
||||
fn drop(&mut self) {
|
||||
let pid = self.child.process_id();
|
||||
let _ = self.child.kill();
|
||||
unregister_spawned_herdr_pid(pid);
|
||||
}
|
||||
}
|
||||
|
||||
fn test_lock() -> MutexGuard<'static, ()> {
|
||||
static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
|
||||
LOCK.get_or_init(|| Mutex::new(()))
|
||||
.lock()
|
||||
.unwrap_or_else(|poisoned| poisoned.into_inner())
|
||||
}
|
||||
|
||||
fn unique_test_dir() -> PathBuf {
|
||||
static COUNTER: AtomicUsize = AtomicUsize::new(0);
|
||||
let n = COUNTER.fetch_add(1, Ordering::Relaxed);
|
||||
PathBuf::from(format!("/tmp/hlh-{}-{n}", std::process::id()))
|
||||
}
|
||||
|
||||
fn spawn_server(config_home: &Path, runtime_dir: &Path, api_socket: &Path) -> SpawnedHerdr {
|
||||
spawn_server_with_env(config_home, runtime_dir, api_socket, &[])
|
||||
}
|
||||
|
||||
fn spawn_server_with_env(
|
||||
config_home: &Path,
|
||||
runtime_dir: &Path,
|
||||
api_socket: &Path,
|
||||
extra_env: &[(&str, &str)],
|
||||
) -> SpawnedHerdr {
|
||||
fs::create_dir_all(config_home.join("herdr")).unwrap();
|
||||
fs::create_dir_all(runtime_dir).unwrap();
|
||||
fs::write(
|
||||
config_home.join("herdr/config.toml"),
|
||||
"onboarding = false\n",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let pair = native_pty_system()
|
||||
.openpty(PtySize {
|
||||
rows: 24,
|
||||
cols: 80,
|
||||
pixel_width: 0,
|
||||
pixel_height: 0,
|
||||
})
|
||||
.unwrap();
|
||||
let mut cmd = CommandBuilder::new(env!("CARGO_BIN_EXE_herdr"));
|
||||
cmd.arg("server");
|
||||
cmd.env("XDG_CONFIG_HOME", config_home);
|
||||
cmd.env("XDG_RUNTIME_DIR", runtime_dir);
|
||||
cmd.env("HERDR_SOCKET_PATH", api_socket);
|
||||
cmd.env(
|
||||
"HERDR_CLIENT_SOCKET_PATH",
|
||||
runtime_dir.join("herdr-client.sock"),
|
||||
);
|
||||
cmd.env("SHELL", "/bin/sh");
|
||||
for (key, value) in extra_env {
|
||||
cmd.env(key, value);
|
||||
}
|
||||
|
||||
let child = pair.slave.spawn_command(cmd).unwrap();
|
||||
register_spawned_herdr_pid(child.process_id());
|
||||
SpawnedHerdr {
|
||||
_master: pair.master,
|
||||
child,
|
||||
}
|
||||
}
|
||||
|
||||
fn spawn_named_session_server(
|
||||
config_home: &Path,
|
||||
runtime_dir: &Path,
|
||||
session_name: &str,
|
||||
) -> SpawnedHerdr {
|
||||
fs::create_dir_all(config_home.join("herdr-dev")).unwrap();
|
||||
fs::create_dir_all(runtime_dir).unwrap();
|
||||
fs::write(
|
||||
config_home.join("herdr-dev/config.toml"),
|
||||
"onboarding = false\n",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let pair = native_pty_system()
|
||||
.openpty(PtySize {
|
||||
rows: 24,
|
||||
cols: 80,
|
||||
pixel_width: 0,
|
||||
pixel_height: 0,
|
||||
})
|
||||
.unwrap();
|
||||
let mut cmd = CommandBuilder::new(env!("CARGO_BIN_EXE_herdr"));
|
||||
cmd.arg("server");
|
||||
cmd.env("XDG_CONFIG_HOME", config_home);
|
||||
cmd.env("XDG_RUNTIME_DIR", runtime_dir);
|
||||
cmd.env("HERDR_SESSION", session_name);
|
||||
cmd.env_remove("HERDR_SOCKET_PATH");
|
||||
cmd.env_remove("HERDR_CLIENT_SOCKET_PATH");
|
||||
cmd.env("SHELL", "/bin/sh");
|
||||
|
||||
let child = pair.slave.spawn_command(cmd).unwrap();
|
||||
register_spawned_herdr_pid(child.process_id());
|
||||
SpawnedHerdr {
|
||||
_master: pair.master,
|
||||
child,
|
||||
}
|
||||
}
|
||||
|
||||
fn spawn_default_session_server(config_home: &Path, runtime_dir: &Path) -> SpawnedHerdr {
|
||||
fs::create_dir_all(config_home.join("herdr-dev")).unwrap();
|
||||
fs::create_dir_all(runtime_dir).unwrap();
|
||||
fs::write(
|
||||
config_home.join("herdr-dev/config.toml"),
|
||||
"onboarding = false\n",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let pair = native_pty_system()
|
||||
.openpty(PtySize {
|
||||
rows: 24,
|
||||
cols: 80,
|
||||
pixel_width: 0,
|
||||
pixel_height: 0,
|
||||
})
|
||||
.unwrap();
|
||||
let mut cmd = CommandBuilder::new(env!("CARGO_BIN_EXE_herdr"));
|
||||
cmd.arg("server");
|
||||
cmd.env("XDG_CONFIG_HOME", config_home);
|
||||
cmd.env("XDG_RUNTIME_DIR", runtime_dir);
|
||||
cmd.env_remove("HERDR_SESSION");
|
||||
cmd.env_remove("HERDR_SOCKET_PATH");
|
||||
cmd.env_remove("HERDR_CLIENT_SOCKET_PATH");
|
||||
cmd.env("SHELL", "/bin/sh");
|
||||
|
||||
let child = pair.slave.spawn_command(cmd).unwrap();
|
||||
register_spawned_herdr_pid(child.process_id());
|
||||
SpawnedHerdr {
|
||||
_master: pair.master,
|
||||
child,
|
||||
}
|
||||
}
|
||||
|
||||
fn request(socket_path: &Path, request: serde_json::Value) -> serde_json::Value {
|
||||
let mut stream = UnixStream::connect(socket_path).unwrap();
|
||||
stream.write_all(request.to_string().as_bytes()).unwrap();
|
||||
stream.write_all(b"\n").unwrap();
|
||||
stream.flush().unwrap();
|
||||
let mut line = String::new();
|
||||
BufReader::new(stream).read_line(&mut line).unwrap();
|
||||
serde_json::from_str(&line).unwrap()
|
||||
}
|
||||
|
||||
fn assert_ok(response: serde_json::Value) {
|
||||
assert!(
|
||||
response.get("result").is_some(),
|
||||
"api request failed: {response}"
|
||||
);
|
||||
}
|
||||
|
||||
fn wait_for_api(socket_path: &Path, timeout: Duration) {
|
||||
let deadline = Instant::now() + timeout;
|
||||
while Instant::now() < deadline {
|
||||
if UnixStream::connect(socket_path).is_ok() {
|
||||
let response = request(
|
||||
socket_path,
|
||||
serde_json::json!({"id":"test:ping","method":"ping","params":{}}),
|
||||
);
|
||||
if response.get("result").is_some() {
|
||||
return;
|
||||
}
|
||||
}
|
||||
thread::sleep(Duration::from_millis(25));
|
||||
}
|
||||
panic!("api did not become ready at {}", socket_path.display());
|
||||
}
|
||||
|
||||
fn wait_for_output(socket_path: &Path, pane_id: &str, needle: &str) {
|
||||
let deadline = Instant::now() + Duration::from_secs(5);
|
||||
let mut last_text = String::new();
|
||||
let mut last_response = serde_json::Value::Null;
|
||||
while Instant::now() < deadline {
|
||||
let response = request(
|
||||
socket_path,
|
||||
serde_json::json!({
|
||||
"id": "test:pane:read",
|
||||
"method": "pane.read",
|
||||
"params": {
|
||||
"pane_id": pane_id,
|
||||
"source": "visible",
|
||||
"lines": 20,
|
||||
"format": "text",
|
||||
"strip_ansi": true
|
||||
}
|
||||
}),
|
||||
);
|
||||
last_response = response.clone();
|
||||
let text = response["result"]["read"]["text"]
|
||||
.as_str()
|
||||
.unwrap_or_default();
|
||||
last_text = text.to_string();
|
||||
if text.contains(needle) {
|
||||
return;
|
||||
}
|
||||
thread::sleep(Duration::from_millis(50));
|
||||
}
|
||||
panic!(
|
||||
"pane output did not contain {needle:?}; last text was {last_text:?}; last response was {last_response}"
|
||||
);
|
||||
}
|
||||
|
||||
fn wait_for_file_contains(path: &Path, needle: &str, timeout: Duration) -> String {
|
||||
let deadline = Instant::now() + timeout;
|
||||
let mut last_text = String::new();
|
||||
while Instant::now() < deadline {
|
||||
if let Ok(text) = fs::read_to_string(path) {
|
||||
last_text = text;
|
||||
if last_text.contains(needle) {
|
||||
return last_text;
|
||||
}
|
||||
}
|
||||
thread::sleep(Duration::from_millis(50));
|
||||
}
|
||||
panic!(
|
||||
"{} did not contain {needle:?}; last text was {last_text:?}",
|
||||
path.display()
|
||||
);
|
||||
}
|
||||
|
||||
fn unused_local_port() -> u16 {
|
||||
TcpListener::bind("127.0.0.1:0")
|
||||
.unwrap()
|
||||
.local_addr()
|
||||
.unwrap()
|
||||
.port()
|
||||
}
|
||||
|
||||
fn wait_for_http_contains(port: u16, needle: &str, timeout: Duration) -> String {
|
||||
let deadline = Instant::now() + timeout;
|
||||
let mut last_response = String::new();
|
||||
while Instant::now() < deadline {
|
||||
if let Ok(mut stream) = TcpStream::connect(("127.0.0.1", port)) {
|
||||
let _ =
|
||||
stream.write_all(b"GET / HTTP/1.1\r\nHost: 127.0.0.1\r\nConnection: close\r\n\r\n");
|
||||
let mut response = String::new();
|
||||
let _ = stream.read_to_string(&mut response);
|
||||
last_response = response;
|
||||
if last_response.contains(needle) {
|
||||
return last_response;
|
||||
}
|
||||
}
|
||||
thread::sleep(Duration::from_millis(50));
|
||||
}
|
||||
panic!(
|
||||
"http server on port {port} did not return {needle:?}; last response was {last_response:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn live_handoff_preserves_named_session_socket_paths() {
|
||||
let _lock = test_lock();
|
||||
let base = unique_test_dir();
|
||||
let config_home = base.join("config");
|
||||
let runtime_dir = base.join("runtime");
|
||||
let session_dir = config_home.join("herdr-dev/sessions/work");
|
||||
let api_socket = session_dir.join("herdr.sock");
|
||||
let client_socket = session_dir.join("herdr-client.sock");
|
||||
|
||||
let spawned = spawn_named_session_server(&config_home, &runtime_dir, "work");
|
||||
wait_for_socket(&api_socket, Duration::from_secs(10));
|
||||
register_runtime_dir(&runtime_dir);
|
||||
|
||||
assert_ok(request(
|
||||
&api_socket,
|
||||
serde_json::json!({"id":"test:handoff","method":"server.live_handoff","params":{}}),
|
||||
));
|
||||
drop(spawned);
|
||||
wait_for_api(&api_socket, Duration::from_secs(10));
|
||||
wait_for_socket(&client_socket, Duration::from_secs(5));
|
||||
assert!(
|
||||
!config_home.join("herdr-dev/herdr.sock").exists(),
|
||||
"named handoff unexpectedly bound the default session API socket"
|
||||
);
|
||||
|
||||
let _ = request(
|
||||
&api_socket,
|
||||
serde_json::json!({"id":"test:stop","method":"server.stop","params":{}}),
|
||||
);
|
||||
cleanup_test_base(&base);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn live_handoff_preserves_pane_process_io() {
|
||||
let _lock = test_lock();
|
||||
let base = unique_test_dir();
|
||||
let config_home = base.join("config");
|
||||
let runtime_dir = base.join("runtime");
|
||||
let api_socket = runtime_dir.join("herdr.sock");
|
||||
let client_socket = runtime_dir.join("herdr-client.sock");
|
||||
let marker = base.join("child.pid");
|
||||
let second_marker = base.join("second-child.pid");
|
||||
let hup_marker = base.join("hup");
|
||||
let second_hup_marker = base.join("second-hup");
|
||||
let received_marker = base.join("received");
|
||||
let second_received_marker = base.join("second-received");
|
||||
|
||||
let spawned = spawn_server(&config_home, &runtime_dir, &api_socket);
|
||||
wait_for_socket(&api_socket, Duration::from_secs(10));
|
||||
register_runtime_dir(&runtime_dir);
|
||||
|
||||
let created = request(
|
||||
&api_socket,
|
||||
serde_json::json!({
|
||||
"id": "test:workspace:create",
|
||||
"method": "workspace.create",
|
||||
"params": {"cwd": "/tmp", "focus": true}
|
||||
}),
|
||||
);
|
||||
let pane_id = created["result"]["root_pane"]["pane_id"]
|
||||
.as_str()
|
||||
.unwrap()
|
||||
.to_string();
|
||||
let split = request(
|
||||
&api_socket,
|
||||
serde_json::json!({
|
||||
"id": "test:pane:split",
|
||||
"method": "pane.split",
|
||||
"params": {
|
||||
"target_pane_id": pane_id,
|
||||
"direction": "right",
|
||||
"focus": false
|
||||
}
|
||||
}),
|
||||
);
|
||||
assert_ok(split.clone());
|
||||
let second_pane_id = split["result"]["pane"]["pane_id"]
|
||||
.as_str()
|
||||
.unwrap()
|
||||
.to_string();
|
||||
|
||||
let command = format!(
|
||||
"sh -c 'echo READY $$ > {}; trap \"echo HUP >> {}\" HUP; while read line; do echo got:$line; echo got:$line >> {}; done'",
|
||||
marker.display(),
|
||||
hup_marker.display(),
|
||||
received_marker.display()
|
||||
);
|
||||
let second_command = format!(
|
||||
"sh -c 'echo SECOND_READY $$ > {}; trap \"echo HUP >> {}\" HUP; while read line; do echo second:$line; echo second:$line >> {}; done'",
|
||||
second_marker.display(),
|
||||
second_hup_marker.display(),
|
||||
second_received_marker.display()
|
||||
);
|
||||
assert_ok(request(
|
||||
&api_socket,
|
||||
serde_json::json!({
|
||||
"id": "test:pane:run",
|
||||
"method": "pane.send_input",
|
||||
"params": {"pane_id": pane_id, "text": command, "keys": ["Enter"]}
|
||||
}),
|
||||
));
|
||||
assert_ok(request(
|
||||
&api_socket,
|
||||
serde_json::json!({
|
||||
"id": "test:second-pane:run",
|
||||
"method": "pane.send_input",
|
||||
"params": {"pane_id": second_pane_id, "text": second_command, "keys": ["Enter"]}
|
||||
}),
|
||||
));
|
||||
support::wait_for_file(&marker, Duration::from_secs(5));
|
||||
support::wait_for_file(&second_marker, Duration::from_secs(5));
|
||||
let pid_text = fs::read_to_string(&marker).unwrap();
|
||||
let child_pid: u32 = pid_text.split_whitespace().last().unwrap().parse().unwrap();
|
||||
let second_pid_text = fs::read_to_string(&second_marker).unwrap();
|
||||
let second_child_pid: u32 = second_pid_text
|
||||
.split_whitespace()
|
||||
.last()
|
||||
.unwrap()
|
||||
.parse()
|
||||
.unwrap();
|
||||
assert_eq!(unsafe { libc::kill(child_pid as libc::pid_t, 0) }, 0);
|
||||
assert_eq!(unsafe { libc::kill(second_child_pid as libc::pid_t, 0) }, 0);
|
||||
|
||||
let protocol = request(
|
||||
&api_socket,
|
||||
serde_json::json!({"id":"test:protocol","method":"ping","params":{}}),
|
||||
)["result"]["protocol"]
|
||||
.as_u64()
|
||||
.unwrap() as u32;
|
||||
let mut client_stream = UnixStream::connect(&client_socket).unwrap();
|
||||
let (server_protocol, error) = client_handshake(&mut client_stream, protocol, 80, 24).unwrap();
|
||||
assert_eq!(server_protocol, protocol);
|
||||
assert!(error.is_none(), "client handshake failed: {error:?}");
|
||||
|
||||
assert_ok(request(
|
||||
&api_socket,
|
||||
serde_json::json!({
|
||||
"id": "test:pane:before-log",
|
||||
"method": "pane.send_input",
|
||||
"params": {"pane_id": pane_id, "text": "before_replay", "keys": ["Enter"]}
|
||||
}),
|
||||
));
|
||||
wait_for_output(&api_socket, &pane_id, "got:before_replay");
|
||||
|
||||
assert_ok(request(
|
||||
&api_socket,
|
||||
serde_json::json!({"id":"test:handoff","method":"server.live_handoff","params":{}}),
|
||||
));
|
||||
drop(spawned);
|
||||
assert!(
|
||||
wait_for_disconnect(&mut client_stream, Duration::from_secs(5)).unwrap(),
|
||||
"connected clients should disconnect during live handoff"
|
||||
);
|
||||
thread::sleep(Duration::from_millis(300));
|
||||
wait_for_api(&api_socket, Duration::from_secs(10));
|
||||
wait_for_socket(&client_socket, Duration::from_secs(5));
|
||||
assert_eq!(unsafe { libc::kill(child_pid as libc::pid_t, 0) }, 0);
|
||||
assert_eq!(unsafe { libc::kill(second_child_pid as libc::pid_t, 0) }, 0);
|
||||
assert!(
|
||||
!hup_marker.exists(),
|
||||
"pane process received HUP during handoff"
|
||||
);
|
||||
assert!(
|
||||
!second_hup_marker.exists(),
|
||||
"second pane process received HUP during handoff"
|
||||
);
|
||||
wait_for_output(&api_socket, &pane_id, "got:before_replay");
|
||||
|
||||
assert_ok(request(
|
||||
&api_socket,
|
||||
serde_json::json!({
|
||||
"id": "test:pane:send",
|
||||
"method": "pane.send_input",
|
||||
"params": {"pane_id": pane_id, "text": "after-handoff", "keys": ["Enter"]}
|
||||
}),
|
||||
));
|
||||
wait_for_file_contains(
|
||||
&received_marker,
|
||||
"got:after-handoff",
|
||||
Duration::from_secs(5),
|
||||
);
|
||||
wait_for_output(&api_socket, &pane_id, "got:after-handoff");
|
||||
assert_ok(request(
|
||||
&api_socket,
|
||||
serde_json::json!({
|
||||
"id": "test:second-pane:send",
|
||||
"method": "pane.send_input",
|
||||
"params": {"pane_id": second_pane_id, "text": "after-handoff-second", "keys": ["Enter"]}
|
||||
}),
|
||||
));
|
||||
wait_for_file_contains(
|
||||
&second_received_marker,
|
||||
"second:after-handoff-second",
|
||||
Duration::from_secs(5),
|
||||
);
|
||||
wait_for_output(&api_socket, &second_pane_id, "second:after-handoff-sec");
|
||||
|
||||
let _ = request(
|
||||
&api_socket,
|
||||
serde_json::json!({"id":"test:stop","method":"server.stop","params":{}}),
|
||||
);
|
||||
let _ = client_socket;
|
||||
cleanup_test_base(&base);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn live_handoff_preserves_python_http_server() {
|
||||
let _lock = test_lock();
|
||||
let base = unique_test_dir();
|
||||
let config_home = base.join("config");
|
||||
let runtime_dir = base.join("runtime");
|
||||
let api_socket = runtime_dir.join("herdr.sock");
|
||||
let client_socket = runtime_dir.join("herdr-client.sock");
|
||||
let web_root = base.join("web");
|
||||
fs::create_dir_all(&web_root).unwrap();
|
||||
fs::write(
|
||||
web_root.join("index.html"),
|
||||
"hello-from-python-before-and-after",
|
||||
)
|
||||
.unwrap();
|
||||
let port = unused_local_port();
|
||||
|
||||
let spawned = spawn_server(&config_home, &runtime_dir, &api_socket);
|
||||
wait_for_socket(&api_socket, Duration::from_secs(10));
|
||||
register_runtime_dir(&runtime_dir);
|
||||
|
||||
let created = request(
|
||||
&api_socket,
|
||||
serde_json::json!({
|
||||
"id": "test:workspace:create",
|
||||
"method": "workspace.create",
|
||||
"params": {"cwd": web_root, "focus": true}
|
||||
}),
|
||||
);
|
||||
let pane_id = created["result"]["root_pane"]["pane_id"]
|
||||
.as_str()
|
||||
.unwrap()
|
||||
.to_string();
|
||||
|
||||
assert_ok(request(
|
||||
&api_socket,
|
||||
serde_json::json!({
|
||||
"id": "test:pane:run-python",
|
||||
"method": "pane.send_input",
|
||||
"params": {
|
||||
"pane_id": pane_id,
|
||||
"text": format!("python3 -m http.server {port} --bind 127.0.0.1"),
|
||||
"keys": ["Enter"]
|
||||
}
|
||||
}),
|
||||
));
|
||||
wait_for_http_contains(
|
||||
port,
|
||||
"hello-from-python-before-and-after",
|
||||
Duration::from_secs(10),
|
||||
);
|
||||
|
||||
assert_ok(request(
|
||||
&api_socket,
|
||||
serde_json::json!({"id":"test:handoff","method":"server.live_handoff","params":{}}),
|
||||
));
|
||||
drop(spawned);
|
||||
wait_for_api(&api_socket, Duration::from_secs(10));
|
||||
wait_for_http_contains(
|
||||
port,
|
||||
"hello-from-python-before-and-after",
|
||||
Duration::from_secs(10),
|
||||
);
|
||||
|
||||
let _ = request(
|
||||
&api_socket,
|
||||
serde_json::json!({"id":"test:stop","method":"server.stop","params":{}}),
|
||||
);
|
||||
let _ = client_socket;
|
||||
cleanup_test_base(&base);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn live_handoff_preserves_http_servers_across_multiple_sessions() {
|
||||
let _lock = test_lock();
|
||||
let base = unique_test_dir();
|
||||
let config_home = base.join("config");
|
||||
let runtime_dir = base.join("runtime");
|
||||
let sessions = [
|
||||
(None, config_home.join("herdr-dev/herdr.sock")),
|
||||
(
|
||||
Some("work"),
|
||||
config_home.join("herdr-dev/sessions/work/herdr.sock"),
|
||||
),
|
||||
];
|
||||
let mut spawned = Vec::new();
|
||||
let mut ports = Vec::new();
|
||||
|
||||
for (session_name, api_socket) in &sessions {
|
||||
let web_root = base.join(format!("web-{}", session_name.unwrap_or("default")));
|
||||
fs::create_dir_all(&web_root).unwrap();
|
||||
fs::write(
|
||||
web_root.join("index.html"),
|
||||
format!("hello-from-{}", session_name.unwrap_or("default")),
|
||||
)
|
||||
.unwrap();
|
||||
let port = unused_local_port();
|
||||
let server = if let Some(session_name) = session_name {
|
||||
spawn_named_session_server(&config_home, &runtime_dir, session_name)
|
||||
} else {
|
||||
spawn_default_session_server(&config_home, &runtime_dir)
|
||||
};
|
||||
wait_for_socket(api_socket, Duration::from_secs(10));
|
||||
let created = request(
|
||||
api_socket,
|
||||
serde_json::json!({
|
||||
"id": "test:workspace:create",
|
||||
"method": "workspace.create",
|
||||
"params": {"cwd": web_root, "focus": true}
|
||||
}),
|
||||
);
|
||||
let pane_id = created["result"]["root_pane"]["pane_id"]
|
||||
.as_str()
|
||||
.unwrap()
|
||||
.to_string();
|
||||
assert_ok(request(
|
||||
api_socket,
|
||||
serde_json::json!({
|
||||
"id": "test:pane:run-python",
|
||||
"method": "pane.send_input",
|
||||
"params": {
|
||||
"pane_id": pane_id,
|
||||
"text": format!("python3 -m http.server {port} --bind 127.0.0.1"),
|
||||
"keys": ["Enter"]
|
||||
}
|
||||
}),
|
||||
));
|
||||
wait_for_http_contains(
|
||||
port,
|
||||
&format!("hello-from-{}", session_name.unwrap_or("default")),
|
||||
Duration::from_secs(10),
|
||||
);
|
||||
spawned.push(server);
|
||||
ports.push((port, session_name.unwrap_or("default").to_string()));
|
||||
}
|
||||
register_runtime_dir(&runtime_dir);
|
||||
|
||||
for (_session_name, api_socket) in &sessions {
|
||||
assert_ok(request(
|
||||
api_socket,
|
||||
serde_json::json!({"id":"test:handoff","method":"server.live_handoff","params":{}}),
|
||||
));
|
||||
}
|
||||
drop(spawned);
|
||||
|
||||
for (_session_name, api_socket) in &sessions {
|
||||
wait_for_api(api_socket, Duration::from_secs(10));
|
||||
}
|
||||
for (port, label) in ports {
|
||||
wait_for_http_contains(
|
||||
port,
|
||||
&format!("hello-from-{label}"),
|
||||
Duration::from_secs(10),
|
||||
);
|
||||
}
|
||||
|
||||
for (_session_name, api_socket) in &sessions {
|
||||
let _ = request(
|
||||
api_socket,
|
||||
serde_json::json!({"id":"test:stop","method":"server.stop","params":{}}),
|
||||
);
|
||||
}
|
||||
cleanup_test_base(&base);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn live_handoff_bad_expected_protocol_rolls_back_old_server() {
|
||||
let _lock = test_lock();
|
||||
let base = unique_test_dir();
|
||||
let config_home = base.join("config");
|
||||
let runtime_dir = base.join("runtime");
|
||||
let api_socket = runtime_dir.join("herdr.sock");
|
||||
let marker = base.join("child.pid");
|
||||
let received_marker = base.join("received");
|
||||
|
||||
let spawned = spawn_server(&config_home, &runtime_dir, &api_socket);
|
||||
wait_for_socket(&api_socket, Duration::from_secs(10));
|
||||
register_runtime_dir(&runtime_dir);
|
||||
|
||||
let created = request(
|
||||
&api_socket,
|
||||
serde_json::json!({
|
||||
"id": "test:workspace:create",
|
||||
"method": "workspace.create",
|
||||
"params": {"cwd": "/tmp", "focus": true}
|
||||
}),
|
||||
);
|
||||
let pane_id = created["result"]["root_pane"]["pane_id"]
|
||||
.as_str()
|
||||
.unwrap()
|
||||
.to_string();
|
||||
let command = format!(
|
||||
"sh -c 'echo READY $$ > {}; while read line; do echo got:$line; echo got:$line >> {}; done'",
|
||||
marker.display(),
|
||||
received_marker.display()
|
||||
);
|
||||
assert_ok(request(
|
||||
&api_socket,
|
||||
serde_json::json!({
|
||||
"id": "test:pane:run",
|
||||
"method": "pane.send_input",
|
||||
"params": {"pane_id": pane_id, "text": command, "keys": ["Enter"]}
|
||||
}),
|
||||
));
|
||||
support::wait_for_file(&marker, Duration::from_secs(5));
|
||||
let pid_text = fs::read_to_string(&marker).unwrap();
|
||||
let child_pid: u32 = pid_text.split_whitespace().last().unwrap().parse().unwrap();
|
||||
|
||||
let failed = request(
|
||||
&api_socket,
|
||||
serde_json::json!({
|
||||
"id": "test:bad-handoff",
|
||||
"method": "server.live_handoff",
|
||||
"params": {"expected_protocol": 999999}
|
||||
}),
|
||||
);
|
||||
assert!(
|
||||
failed.get("error").is_some(),
|
||||
"bad protocol handoff should fail: {failed}"
|
||||
);
|
||||
wait_for_api(&api_socket, Duration::from_secs(5));
|
||||
assert_eq!(unsafe { libc::kill(child_pid as libc::pid_t, 0) }, 0);
|
||||
|
||||
assert_ok(request(
|
||||
&api_socket,
|
||||
serde_json::json!({
|
||||
"id": "test:pane:send-after-failed-handoff",
|
||||
"method": "pane.send_input",
|
||||
"params": {"pane_id": pane_id, "text": "after-failed-handoff", "keys": ["Enter"]}
|
||||
}),
|
||||
));
|
||||
wait_for_file_contains(
|
||||
&received_marker,
|
||||
"got:after-failed-handoff",
|
||||
Duration::from_secs(5),
|
||||
);
|
||||
wait_for_output(&api_socket, &pane_id, "got:after-failed-handoff");
|
||||
|
||||
let _ = request(
|
||||
&api_socket,
|
||||
serde_json::json!({"id":"test:stop","method":"server.stop","params":{}}),
|
||||
);
|
||||
drop(spawned);
|
||||
cleanup_test_base(&base);
|
||||
}
|
||||
|
||||
fn live_handoff_import_failure_rolls_back_old_server_at(failure_point: &str) {
|
||||
let _lock = test_lock();
|
||||
let base = unique_test_dir();
|
||||
let config_home = base.join("config");
|
||||
let runtime_dir = base.join("runtime");
|
||||
let api_socket = runtime_dir.join("herdr.sock");
|
||||
let client_socket = runtime_dir.join("herdr-client.sock");
|
||||
let marker = base.join("child.pid");
|
||||
let received_marker = base.join("received");
|
||||
|
||||
let spawned = spawn_server_with_env(
|
||||
&config_home,
|
||||
&runtime_dir,
|
||||
&api_socket,
|
||||
&[("HERDR_TEST_HANDOFF_IMPORT_FAIL", failure_point)],
|
||||
);
|
||||
wait_for_socket(&api_socket, Duration::from_secs(10));
|
||||
register_runtime_dir(&runtime_dir);
|
||||
|
||||
let created = request(
|
||||
&api_socket,
|
||||
serde_json::json!({
|
||||
"id": "test:workspace:create",
|
||||
"method": "workspace.create",
|
||||
"params": {"cwd": "/tmp", "focus": true}
|
||||
}),
|
||||
);
|
||||
let pane_id = created["result"]["root_pane"]["pane_id"]
|
||||
.as_str()
|
||||
.unwrap()
|
||||
.to_string();
|
||||
let command = format!(
|
||||
"sh -c 'echo READY $$ > {}; while read line; do echo got:$line; echo got:$line >> {}; done'",
|
||||
marker.display(),
|
||||
received_marker.display()
|
||||
);
|
||||
assert_ok(request(
|
||||
&api_socket,
|
||||
serde_json::json!({
|
||||
"id": "test:pane:run",
|
||||
"method": "pane.send_input",
|
||||
"params": {"pane_id": pane_id, "text": command, "keys": ["Enter"]}
|
||||
}),
|
||||
));
|
||||
support::wait_for_file(&marker, Duration::from_secs(5));
|
||||
let pid_text = fs::read_to_string(&marker).unwrap();
|
||||
let child_pid: u32 = pid_text.split_whitespace().last().unwrap().parse().unwrap();
|
||||
|
||||
let failed = request(
|
||||
&api_socket,
|
||||
serde_json::json!({"id":"test:handoff-fail","method":"server.live_handoff","params":{}}),
|
||||
);
|
||||
assert!(
|
||||
failed.get("error").is_some(),
|
||||
"{failure_point} handoff should fail: {failed}"
|
||||
);
|
||||
wait_for_api(&api_socket, Duration::from_secs(10));
|
||||
wait_for_socket(&client_socket, Duration::from_secs(5));
|
||||
assert_eq!(unsafe { libc::kill(child_pid as libc::pid_t, 0) }, 0);
|
||||
|
||||
assert_ok(request(
|
||||
&api_socket,
|
||||
serde_json::json!({
|
||||
"id": "test:pane:send-after-import-failure",
|
||||
"method": "pane.send_input",
|
||||
"params": {"pane_id": pane_id, "text": failure_point, "keys": ["Enter"]}
|
||||
}),
|
||||
));
|
||||
wait_for_file_contains(
|
||||
&received_marker,
|
||||
&format!("got:{failure_point}"),
|
||||
Duration::from_secs(5),
|
||||
);
|
||||
|
||||
let _ = request(
|
||||
&api_socket,
|
||||
serde_json::json!({"id":"test:stop","method":"server.stop","params":{}}),
|
||||
);
|
||||
drop(spawned);
|
||||
cleanup_test_base(&base);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn live_handoff_after_restored_failure_rolls_back_old_server() {
|
||||
live_handoff_import_failure_rolls_back_old_server_at("after_restored");
|
||||
}
|
||||
|
|
@ -328,16 +328,30 @@ pub fn wait_for_message_variant(
|
|||
}
|
||||
|
||||
pub fn wait_for_disconnect(stream: &mut UnixStream, timeout: Duration) -> Result<bool, String> {
|
||||
stream
|
||||
.set_read_timeout(Some(Duration::from_millis(200)))
|
||||
.map_err(|e| e.to_string())?;
|
||||
stream.set_nonblocking(true).map_err(|e| e.to_string())?;
|
||||
let deadline = Instant::now() + timeout;
|
||||
while Instant::now() < deadline {
|
||||
if read_server_message(stream).is_err() {
|
||||
return Ok(true);
|
||||
let mut idle_since = None;
|
||||
let result = loop {
|
||||
match read_server_message(stream) {
|
||||
Ok(_) => idle_since = None,
|
||||
Err(err)
|
||||
if err.to_ascii_lowercase().contains("would block")
|
||||
|| err.contains("Resource temporarily unavailable") =>
|
||||
{
|
||||
let idle_started = *idle_since.get_or_insert_with(Instant::now);
|
||||
if idle_started.elapsed() >= Duration::from_millis(200) {
|
||||
break Ok(true);
|
||||
}
|
||||
}
|
||||
Err(_) => break Ok(true),
|
||||
}
|
||||
}
|
||||
Ok(false)
|
||||
if Instant::now() >= deadline {
|
||||
break Ok(false);
|
||||
}
|
||||
thread::sleep(Duration::from_millis(25));
|
||||
};
|
||||
let _ = stream.set_nonblocking(false);
|
||||
result
|
||||
}
|
||||
|
||||
pub fn cleanup_registered_herdr_pids() {
|
||||
|
|
|
|||
Loading…
Reference in New Issue