use std::path::Path;
use std::sync::Arc;
use a3s_box_core::error::{BoxError, Result};
use tokio::sync::Mutex;
type PtyFrameReader = a3s_transport::FrameReader<tokio::io::ReadHalf<tokio::net::UnixStream>>;
type PtyFrameWriter = a3s_transport::FrameWriter<tokio::io::WriteHalf<tokio::net::UnixStream>>;
#[derive(Debug)]
pub struct PtyClient {
reader: PtyFrameReader,
writer: PtyFrameWriter,
}
impl PtyClient {
pub async fn connect(socket_path: &Path) -> Result<Self> {
let stream = tokio::net::UnixStream::connect(socket_path)
.await
.map_err(|e| {
BoxError::ExecError(format!(
"Failed to connect to PTY server at {}: {}",
socket_path.display(),
e,
))
})?;
let (r, w) = tokio::io::split(stream);
Ok(Self {
reader: a3s_transport::FrameReader::new(r),
writer: a3s_transport::FrameWriter::new(w),
})
}
pub async fn send_request(&mut self, req: &a3s_box_core::pty::PtyRequest) -> Result<()> {
let payload = serde_json::to_vec(req)
.map_err(|e| BoxError::ExecError(format!("Failed to serialize PtyRequest: {}", e)))?;
self.write_raw_frame(a3s_box_core::pty::FRAME_PTY_REQUEST, &payload)
.await
}
pub async fn start_stream(
mut self,
req: &a3s_box_core::pty::PtyRequest,
) -> Result<StreamingPty> {
self.send_request(req).await?;
Ok(StreamingPty {
reader: self.reader,
writer: Arc::new(Mutex::new(self.writer)),
started: std::time::Instant::now(),
stdout_bytes: 0,
done: false,
})
}
pub async fn send_data(&mut self, data: &[u8]) -> Result<()> {
self.write_raw_frame(a3s_box_core::pty::FRAME_PTY_DATA, data)
.await
}
pub async fn send_resize(&mut self, cols: u16, rows: u16) -> Result<()> {
let resize = a3s_box_core::pty::PtyResize { cols, rows };
let payload = serde_json::to_vec(&resize)
.map_err(|e| BoxError::ExecError(format!("Failed to serialize PtyResize: {}", e)))?;
self.write_raw_frame(a3s_box_core::pty::FRAME_PTY_RESIZE, &payload)
.await
}
pub async fn read_frame(&mut self) -> Result<Option<(u8, Vec<u8>)>> {
match self.reader.read_frame().await {
Ok(Some(frame)) => Ok(Some((frame.frame_type as u8, frame.payload))),
Ok(None) => Ok(None),
Err(e) => Err(BoxError::ExecError(format!("PTY frame read failed: {}", e))),
}
}
pub fn into_split(
self,
) -> (
a3s_transport::FrameReader<tokio::io::ReadHalf<tokio::net::UnixStream>>,
a3s_transport::FrameWriter<tokio::io::WriteHalf<tokio::net::UnixStream>>,
) {
(self.reader, self.writer)
}
async fn write_raw_frame(&mut self, frame_type: u8, payload: &[u8]) -> Result<()> {
let ft = a3s_transport::FrameType::try_from(frame_type)
.unwrap_or(a3s_transport::FrameType::Data);
let frame = a3s_transport::Frame {
frame_type: ft,
payload: payload.to_vec(),
};
self.writer
.write_frame(&frame)
.await
.map_err(|e| BoxError::ExecError(format!("PTY frame write failed: {}", e)))
}
}
#[derive(Clone, Debug)]
pub struct StreamingPtyInput {
writer: Arc<Mutex<PtyFrameWriter>>,
}
impl StreamingPtyInput {
pub async fn write_stdin(&self, data: &[u8]) -> Result<()> {
let frame = a3s_transport::Frame {
frame_type: a3s_transport::FrameType::Control,
payload: data.to_vec(),
};
self.writer
.lock()
.await
.write_frame(&frame)
.await
.map_err(|e| BoxError::ExecError(format!("PTY stdin write failed: {}", e)))
}
pub async fn resize(&self, cols: u16, rows: u16) -> Result<()> {
let resize = a3s_box_core::pty::PtyResize { cols, rows };
let payload = serde_json::to_vec(&resize)
.map_err(|e| BoxError::ExecError(format!("Failed to serialize PtyResize: {}", e)))?;
let frame = a3s_transport::Frame {
frame_type: a3s_transport::FrameType::Heartbeat,
payload,
};
self.writer
.lock()
.await
.write_frame(&frame)
.await
.map_err(|e| BoxError::ExecError(format!("PTY resize write failed: {}", e)))
}
pub async fn close(&self) -> Result<()> {
self.writer
.lock()
.await
.write_frame(&a3s_transport::Frame::close())
.await
.map_err(|e| BoxError::ExecError(format!("PTY close write failed: {}", e)))
}
}
pub struct StreamingPty {
reader: PtyFrameReader,
writer: Arc<Mutex<PtyFrameWriter>>,
started: std::time::Instant,
stdout_bytes: u64,
done: bool,
}
impl StreamingPty {
pub fn input(&self) -> StreamingPtyInput {
StreamingPtyInput {
writer: self.writer.clone(),
}
}
pub async fn next_event(&mut self) -> Result<Option<a3s_box_core::exec::ExecEvent>> {
use a3s_box_core::exec::{ExecChunk, ExecEvent, ExecExit, StreamType};
if self.done {
return Ok(None);
}
let Some(frame) = self
.reader
.read_frame()
.await
.map_err(|e| BoxError::ExecError(format!("PTY frame read failed: {}", e)))?
else {
self.done = true;
return Ok(None);
};
match frame.frame_type as u8 {
a3s_box_core::pty::FRAME_PTY_DATA => {
self.stdout_bytes += frame.payload.len() as u64;
Ok(Some(ExecEvent::Chunk(ExecChunk {
stream: StreamType::Stdout,
data: frame.payload,
})))
}
a3s_box_core::pty::FRAME_PTY_EXIT => {
let exit: a3s_box_core::pty::PtyExit = serde_json::from_slice(&frame.payload)
.map_err(|e| BoxError::ExecError(format!("Failed to parse PTY exit: {}", e)))?;
self.done = true;
Ok(Some(ExecEvent::Exit(ExecExit {
exit_code: exit.exit_code,
oom_killed: false,
})))
}
a3s_box_core::pty::FRAME_PTY_ERROR => {
let msg = String::from_utf8_lossy(&frame.payload);
self.done = true;
Err(BoxError::ExecError(format!("PTY session error: {}", msg)))
}
other => Err(BoxError::ExecError(format!(
"Unexpected PTY frame type in stream: 0x{other:02x}"
))),
}
}
pub async fn cancel(&mut self) -> Result<()> {
self.input().close().await
}
pub fn is_done(&self) -> bool {
self.done
}
pub fn metrics(&self) -> a3s_box_core::exec::ExecMetrics {
a3s_box_core::exec::ExecMetrics {
duration_ms: self.started.elapsed().as_millis() as u64,
peak_memory_bytes: None,
stdout_bytes: self.stdout_bytes,
stderr_bytes: 0,
}
}
}
impl std::fmt::Debug for StreamingPty {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("StreamingPty")
.field("done", &self.done)
.field("stdout_bytes", &self.stdout_bytes)
.finish()
}
}
#[cfg(test)]
mod tests {
use super::*;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::UnixListener;
fn bind_test_listener(path: &Path) -> Option<UnixListener> {
match UnixListener::bind(path) {
Ok(listener) => Some(listener),
Err(e) if e.kind() == std::io::ErrorKind::PermissionDenied => {
eprintln!(
"skipping Unix socket test; sandbox denied bind at {}: {}",
path.display(),
e
);
None
}
Err(e) => panic!("failed to bind test socket {}: {}", path.display(), e),
}
}
fn test_pty_request() -> a3s_box_core::pty::PtyRequest {
a3s_box_core::pty::PtyRequest {
cmd: vec!["/bin/sh".to_string()],
env: vec!["TERM=xterm".to_string()],
working_dir: Some("/workspace".to_string()),
rootfs: Some("/run/a3s/rootfs".to_string()),
user: Some("1000:1000".to_string()),
cols: 100,
rows: 30,
}
}
#[tokio::test]
async fn test_pty_client_connect_nonexistent() {
let result = PtyClient::connect(Path::new("/tmp/nonexistent-pty-test.sock")).await;
assert!(result.is_err());
}
#[tokio::test]
async fn test_pty_frame_roundtrip() {
let tmp = tempfile::TempDir::new().unwrap();
let sock_path = tmp.path().join("pty.sock");
let Some(listener) = bind_test_listener(&sock_path) else {
return;
};
let sock_path_clone = sock_path.clone();
let server = tokio::spawn(async move {
let (mut stream, _) = listener.accept().await.unwrap();
let mut header = [0u8; 5];
stream.read_exact(&mut header).await.unwrap();
let frame_type = header[0];
let len = u32::from_be_bytes([header[1], header[2], header[3], header[4]]) as usize;
let mut payload = vec![0u8; len];
if len > 0 {
stream.read_exact(&mut payload).await.unwrap();
}
stream.write_all(&header).await.unwrap();
stream.write_all(&payload).await.unwrap();
(frame_type, payload)
});
let mut client = PtyClient::connect(&sock_path_clone).await.unwrap();
client.send_data(b"hello world").await.unwrap();
let frame = client.read_frame().await.unwrap().unwrap();
assert_eq!(frame.0, a3s_box_core::pty::FRAME_PTY_DATA);
assert_eq!(&frame.1[..], b"hello world");
let (server_type, server_payload) = server.await.unwrap();
assert_eq!(server_type, a3s_box_core::pty::FRAME_PTY_DATA);
assert_eq!(&server_payload[..], b"hello world");
}
#[tokio::test]
async fn test_pty_send_request_serializes_payload() {
let tmp = tempfile::TempDir::new().unwrap();
let sock_path = tmp.path().join("pty_request.sock");
let Some(listener) = bind_test_listener(&sock_path) else {
return;
};
let server = tokio::spawn(async move {
let (stream, _) = listener.accept().await.unwrap();
let (r, _w) = tokio::io::split(stream);
let mut reader = a3s_transport::FrameReader::new(r);
reader.read_frame().await.unwrap().unwrap()
});
let mut client = PtyClient::connect(&sock_path).await.unwrap();
let req = test_pty_request();
client.send_request(&req).await.unwrap();
let frame = server.await.unwrap();
assert_eq!(frame.frame_type as u8, a3s_box_core::pty::FRAME_PTY_REQUEST);
let parsed: a3s_box_core::pty::PtyRequest = serde_json::from_slice(&frame.payload).unwrap();
assert_eq!(parsed.cmd, req.cmd);
assert_eq!(parsed.env, req.env);
assert_eq!(parsed.working_dir, req.working_dir);
assert_eq!(parsed.rootfs, req.rootfs);
assert_eq!(parsed.user, req.user);
assert_eq!(parsed.cols, 100);
assert_eq!(parsed.rows, 30);
}
#[tokio::test]
async fn test_pty_send_resize() {
let tmp = tempfile::TempDir::new().unwrap();
let sock_path = tmp.path().join("pty_resize.sock");
let Some(listener) = bind_test_listener(&sock_path) else {
return;
};
tokio::spawn(async move {
let (mut stream, _) = listener.accept().await.unwrap();
let mut header = [0u8; 5];
stream.read_exact(&mut header).await.unwrap();
let frame_type = header[0];
let len = u32::from_be_bytes([header[1], header[2], header[3], header[4]]) as usize;
let mut payload = vec![0u8; len];
stream.read_exact(&mut payload).await.unwrap();
assert_eq!(frame_type, a3s_box_core::pty::FRAME_PTY_RESIZE);
let resize: a3s_box_core::pty::PtyResize = serde_json::from_slice(&payload).unwrap();
assert_eq!(resize.cols, 120);
assert_eq!(resize.rows, 40);
});
let mut client = PtyClient::connect(&sock_path).await.unwrap();
client.send_resize(120, 40).await.unwrap();
}
#[tokio::test]
async fn test_pty_stream_input_writes_stdin_and_resize_frames() {
let tmp = tempfile::TempDir::new().unwrap();
let sock_path = tmp.path().join("pty_input.sock");
let Some(listener) = bind_test_listener(&sock_path) else {
return;
};
let server = tokio::spawn(async move {
let (stream, _) = listener.accept().await.unwrap();
let (r, _w) = tokio::io::split(stream);
let mut reader = a3s_transport::FrameReader::new(r);
let request = reader.read_frame().await.unwrap().unwrap();
assert_eq!(
request.frame_type as u8,
a3s_box_core::pty::FRAME_PTY_REQUEST
);
let stdin = reader.read_frame().await.unwrap().unwrap();
let resize = reader.read_frame().await.unwrap().unwrap();
(stdin, resize)
});
let client = PtyClient::connect(&sock_path).await.unwrap();
let stream = client.start_stream(&test_pty_request()).await.unwrap();
let input = stream.input();
input.write_stdin(b"echo hi\n").await.unwrap();
input.resize(132, 43).await.unwrap();
let (stdin, resize) = server.await.unwrap();
assert_eq!(stdin.frame_type, a3s_transport::FrameType::Control);
assert_eq!(stdin.payload, b"echo hi\n");
assert_eq!(resize.frame_type, a3s_transport::FrameType::Heartbeat);
let resize: a3s_box_core::pty::PtyResize = serde_json::from_slice(&resize.payload).unwrap();
assert_eq!(resize.cols, 132);
assert_eq!(resize.rows, 43);
}
#[tokio::test]
async fn test_pty_read_frame_eof() {
let tmp = tempfile::TempDir::new().unwrap();
let sock_path = tmp.path().join("pty_eof.sock");
let Some(listener) = bind_test_listener(&sock_path) else {
return;
};
tokio::spawn(async move {
let (stream, _) = listener.accept().await.unwrap();
drop(stream); });
let mut client = PtyClient::connect(&sock_path).await.unwrap();
let frame = client.read_frame().await.unwrap();
assert!(frame.is_none()); }
#[tokio::test]
async fn test_pty_stream_eof_marks_done_and_metrics_count_stdout() {
let tmp = tempfile::TempDir::new().unwrap();
let sock_path = tmp.path().join("pty_stream_eof.sock");
let Some(listener) = bind_test_listener(&sock_path) else {
return;
};
tokio::spawn(async move {
let (stream, _) = listener.accept().await.unwrap();
let (r, w) = tokio::io::split(stream);
let mut reader = a3s_transport::FrameReader::new(r);
let mut writer = a3s_transport::FrameWriter::new(w);
let request = reader.read_frame().await.unwrap().unwrap();
assert_eq!(
request.frame_type as u8,
a3s_box_core::pty::FRAME_PTY_REQUEST
);
writer
.write_frame(&a3s_transport::Frame {
frame_type: a3s_transport::FrameType::Control,
payload: b"abc".to_vec(),
})
.await
.unwrap();
});
let client = PtyClient::connect(&sock_path).await.unwrap();
let mut stream = client.start_stream(&test_pty_request()).await.unwrap();
assert!(!stream.is_done());
match stream.next_event().await.unwrap().unwrap() {
a3s_box_core::exec::ExecEvent::Chunk(chunk) => assert_eq!(chunk.data, b"abc"),
other => panic!("unexpected event: {other:?}"),
}
assert_eq!(stream.metrics().stdout_bytes, 3);
assert!(stream.next_event().await.unwrap().is_none());
assert!(stream.is_done());
assert!(stream.next_event().await.unwrap().is_none());
}
#[tokio::test]
async fn test_pty_client_start_stream_reads_data_and_exit() {
let tmp = tempfile::TempDir::new().unwrap();
let sock_path = tmp.path().join("pty_stream.sock");
let Some(listener) = bind_test_listener(&sock_path) else {
return;
};
tokio::spawn(async move {
let (stream, _) = listener.accept().await.unwrap();
let (r, w) = tokio::io::split(stream);
let mut reader = a3s_transport::FrameReader::new(r);
let mut writer = a3s_transport::FrameWriter::new(w);
let request = reader.read_frame().await.unwrap().unwrap();
assert_eq!(
request.frame_type as u8,
a3s_box_core::pty::FRAME_PTY_REQUEST
);
writer
.write_frame(&a3s_transport::Frame {
frame_type: a3s_transport::FrameType::Control,
payload: b"tty output".to_vec(),
})
.await
.unwrap();
let exit = a3s_box_core::pty::PtyExit { exit_code: 9 };
writer
.write_frame(&a3s_transport::Frame {
frame_type: a3s_transport::FrameType::Error,
payload: serde_json::to_vec(&exit).unwrap(),
})
.await
.unwrap();
});
let client = PtyClient::connect(&sock_path).await.unwrap();
let req = a3s_box_core::pty::PtyRequest {
cmd: vec!["/bin/sh".to_string()],
env: vec![],
working_dir: None,
rootfs: None,
user: None,
cols: 80,
rows: 24,
};
let mut stream = client.start_stream(&req).await.unwrap();
match stream.next_event().await.unwrap().unwrap() {
a3s_box_core::exec::ExecEvent::Chunk(chunk) => {
assert_eq!(chunk.stream, a3s_box_core::exec::StreamType::Stdout);
assert_eq!(chunk.data, b"tty output");
}
other => panic!("unexpected event: {other:?}"),
}
match stream.next_event().await.unwrap().unwrap() {
a3s_box_core::exec::ExecEvent::Exit(exit) => assert_eq!(exit.exit_code, 9),
other => panic!("unexpected event: {other:?}"),
}
}
#[tokio::test]
async fn test_pty_stream_error_frame_marks_done() {
let tmp = tempfile::TempDir::new().unwrap();
let sock_path = tmp.path().join("pty_stream_error.sock");
let Some(listener) = bind_test_listener(&sock_path) else {
return;
};
tokio::spawn(async move {
let (stream, _) = listener.accept().await.unwrap();
let (r, w) = tokio::io::split(stream);
let mut reader = a3s_transport::FrameReader::new(r);
let mut writer = a3s_transport::FrameWriter::new(w);
let request = reader.read_frame().await.unwrap().unwrap();
assert_eq!(
request.frame_type as u8,
a3s_box_core::pty::FRAME_PTY_REQUEST
);
writer
.write_frame(&a3s_transport::Frame {
frame_type: a3s_transport::FrameType::Close,
payload: b"terminal failed".to_vec(),
})
.await
.unwrap();
});
let client = PtyClient::connect(&sock_path).await.unwrap();
let mut stream = client.start_stream(&test_pty_request()).await.unwrap();
let err = stream.next_event().await.unwrap_err();
assert!(err.to_string().contains("terminal failed"));
assert!(stream.is_done());
assert!(stream.next_event().await.unwrap().is_none());
}
#[tokio::test]
async fn test_pty_stream_unexpected_frame_type_errors_without_marking_done() {
let tmp = tempfile::TempDir::new().unwrap();
let sock_path = tmp.path().join("pty_stream_unexpected.sock");
let Some(listener) = bind_test_listener(&sock_path) else {
return;
};
tokio::spawn(async move {
let (stream, _) = listener.accept().await.unwrap();
let (r, w) = tokio::io::split(stream);
let mut reader = a3s_transport::FrameReader::new(r);
let mut writer = a3s_transport::FrameWriter::new(w);
let request = reader.read_frame().await.unwrap().unwrap();
assert_eq!(
request.frame_type as u8,
a3s_box_core::pty::FRAME_PTY_REQUEST
);
writer
.write_frame(&a3s_transport::Frame {
frame_type: a3s_transport::FrameType::Data,
payload: b"not a PTY data frame".to_vec(),
})
.await
.unwrap();
});
let client = PtyClient::connect(&sock_path).await.unwrap();
let mut stream = client.start_stream(&test_pty_request()).await.unwrap();
let err = stream.next_event().await.unwrap_err();
assert!(err.to_string().contains("Unexpected PTY frame type"));
assert!(!stream.is_done());
}
#[tokio::test]
async fn test_pty_stream_cancel_writes_close_frame() {
let tmp = tempfile::TempDir::new().unwrap();
let sock_path = tmp.path().join("pty_cancel.sock");
let Some(listener) = bind_test_listener(&sock_path) else {
return;
};
tokio::spawn(async move {
let (stream, _) = listener.accept().await.unwrap();
let (r, _w) = tokio::io::split(stream);
let mut reader = a3s_transport::FrameReader::new(r);
let request = reader.read_frame().await.unwrap().unwrap();
assert_eq!(
request.frame_type as u8,
a3s_box_core::pty::FRAME_PTY_REQUEST
);
let close = reader.read_frame().await.unwrap().unwrap();
assert_eq!(close.frame_type, a3s_transport::FrameType::Close);
assert!(close.payload.is_empty());
});
let client = PtyClient::connect(&sock_path).await.unwrap();
let req = a3s_box_core::pty::PtyRequest {
cmd: vec!["sleep".to_string(), "60".to_string()],
env: vec![],
working_dir: None,
rootfs: None,
user: None,
cols: 80,
rows: 24,
};
let mut stream = client.start_stream(&req).await.unwrap();
stream.cancel().await.unwrap();
}
}