Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f765d415d6 | ||
|
|
d3e3010245 |
@@ -19,6 +19,12 @@ use tokio_tungstenite::tungstenite::Message;
|
|||||||
pub enum AttachError {
|
pub enum AttachError {
|
||||||
#[error("ws: {0}")]
|
#[error("ws: {0}")]
|
||||||
WebSocket(#[from] tokio_tungstenite::tungstenite::Error),
|
WebSocket(#[from] tokio_tungstenite::tungstenite::Error),
|
||||||
|
#[error("daemon returned {status} ({code}): {message}")]
|
||||||
|
Rejected {
|
||||||
|
status: String,
|
||||||
|
code: String,
|
||||||
|
message: String,
|
||||||
|
},
|
||||||
#[error("io: {0}")]
|
#[error("io: {0}")]
|
||||||
Io(#[from] io::Error),
|
Io(#[from] io::Error),
|
||||||
#[error("termios: {0}")]
|
#[error("termios: {0}")]
|
||||||
@@ -32,6 +38,21 @@ pub enum AttachError {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl AttachError {
|
impl AttachError {
|
||||||
|
fn from_websocket(error: tokio_tungstenite::tungstenite::Error) -> Self {
|
||||||
|
if let tokio_tungstenite::tungstenite::Error::Http(response) = &error {
|
||||||
|
if let Some(body) = response.body().as_deref() {
|
||||||
|
if let Ok(error_body) = serde_json::from_slice::<maestro_protocol::ApiError>(body) {
|
||||||
|
return Self::Rejected {
|
||||||
|
status: response.status().to_string(),
|
||||||
|
code: error_body.error.code,
|
||||||
|
message: error_body.error.message,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Self::WebSocket(error)
|
||||||
|
}
|
||||||
|
|
||||||
/// True when the failure was an inability to reach the WS endpoint at the
|
/// True when the failure was an inability to reach the WS endpoint at the
|
||||||
/// transport level (connection refused), as opposed to an HTTP-level
|
/// transport level (connection refused), as opposed to an HTTP-level
|
||||||
/// rejection from a daemon that did answer.
|
/// rejection from a daemon that did answer.
|
||||||
@@ -111,7 +132,9 @@ fn current_winsize() -> Option<(u16, u16)> {
|
|||||||
/// byte that exits cleanly without killing the session.
|
/// byte that exits cleanly without killing the session.
|
||||||
#[allow(clippy::result_large_err)]
|
#[allow(clippy::result_large_err)]
|
||||||
pub async fn run_attach_url(url: &str, detach_byte: u8) -> Result<(), AttachError> {
|
pub async fn run_attach_url(url: &str, detach_byte: u8) -> Result<(), AttachError> {
|
||||||
let (ws, _resp) = tokio_tungstenite::connect_async(url).await?;
|
let (ws, _resp) = tokio_tungstenite::connect_async(url)
|
||||||
|
.await
|
||||||
|
.map_err(AttachError::from_websocket)?;
|
||||||
let (mut sink, mut stream) = ws.split();
|
let (mut sink, mut stream) = ws.split();
|
||||||
|
|
||||||
let raw = RawTty::enable()?;
|
let raw = RawTty::enable()?;
|
||||||
@@ -233,4 +256,25 @@ mod tests {
|
|||||||
Err(AttachError::InvalidDetachKey(_))
|
Err(AttachError::InvalidDetachKey(_))
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn attach_surfaces_structured_handshake_errors() {
|
||||||
|
let body = serde_json::to_vec(&maestro_protocol::ApiError::new(
|
||||||
|
"not_terminal",
|
||||||
|
"session is not a terminal session",
|
||||||
|
))
|
||||||
|
.unwrap();
|
||||||
|
let response = tokio_tungstenite::tungstenite::http::Response::builder()
|
||||||
|
.status(409)
|
||||||
|
.body(Some(body))
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let error =
|
||||||
|
AttachError::from_websocket(tokio_tungstenite::tungstenite::Error::Http(response));
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
error.to_string(),
|
||||||
|
"daemon returned 409 Conflict (not_terminal): session is not a terminal session"
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ use std::io::Write;
|
|||||||
use maestro_protocol::display::abbreviate_dir;
|
use maestro_protocol::display::abbreviate_dir;
|
||||||
use maestro_protocol::{
|
use maestro_protocol::{
|
||||||
ActivityState, ClientError, CreateSessionRequest, DaemonClient, DaemonInfo, SendInputRequest,
|
ActivityState, ClientError, CreateSessionRequest, DaemonClient, DaemonInfo, SendInputRequest,
|
||||||
Session, SessionStatus, WorktreeSpec,
|
Session, SessionStatus, SessionView, WorktreeSpec,
|
||||||
};
|
};
|
||||||
use serde_json::Value;
|
use serde_json::Value;
|
||||||
|
|
||||||
@@ -179,8 +179,8 @@ fn print_session_table(out: &mut impl Write, sessions: &[Session], info: &Daemon
|
|||||||
}
|
}
|
||||||
writeln!(
|
writeln!(
|
||||||
out,
|
out,
|
||||||
" {:<36} {:<8} {:<9} {:<28} {:<40} {}",
|
" {:<36} {:<8} {:<10} {:<9} {:<28} {:<40} {}",
|
||||||
"ID", "STATUS", "ACTIVITY", "DIR", "COMMAND", "BLOCKED"
|
"ID", "STATUS", "VIEW", "ACTIVITY", "DIR", "COMMAND", "BLOCKED"
|
||||||
)
|
)
|
||||||
.ok();
|
.ok();
|
||||||
for s in sorted {
|
for s in sorted {
|
||||||
@@ -190,6 +190,10 @@ fn print_session_table(out: &mut impl Write, sessions: &[Session], info: &Daemon
|
|||||||
SessionStatus::Running => "running",
|
SessionStatus::Running => "running",
|
||||||
SessionStatus::Dead => "dead",
|
SessionStatus::Dead => "dead",
|
||||||
};
|
};
|
||||||
|
let view = match s.view {
|
||||||
|
SessionView::Terminal => "terminal",
|
||||||
|
SessionView::Structured => "structured",
|
||||||
|
};
|
||||||
let activity = match s.activity {
|
let activity = match s.activity {
|
||||||
ActivityState::Active => "active",
|
ActivityState::Active => "active",
|
||||||
ActivityState::Idle => "idle",
|
ActivityState::Idle => "idle",
|
||||||
@@ -207,8 +211,8 @@ fn print_session_table(out: &mut impl Write, sessions: &[Session], info: &Daemon
|
|||||||
let blocked = blocked_label(s);
|
let blocked = blocked_label(s);
|
||||||
writeln!(
|
writeln!(
|
||||||
out,
|
out,
|
||||||
" {:<36} {:<8} {:<9} {:<28} {:<40} {blocked}",
|
" {:<36} {:<8} {:<10} {:<9} {:<28} {:<40} {blocked}",
|
||||||
s.id, status, activity, dir, command
|
s.id, status, view, activity, dir, command
|
||||||
)
|
)
|
||||||
.ok();
|
.ok();
|
||||||
}
|
}
|
||||||
@@ -230,7 +234,7 @@ mod tests {
|
|||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
|
|
||||||
use chrono::{TimeZone, Utc};
|
use chrono::{TimeZone, Utc};
|
||||||
use maestro_protocol::{ActivityState, Session, SessionStatus};
|
use maestro_protocol::{ActivityState, Session, SessionStatus, SessionView};
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
use super::DaemonInfo;
|
use super::DaemonInfo;
|
||||||
@@ -286,6 +290,22 @@ mod tests {
|
|||||||
assert!(s.contains("idle"), "{s}");
|
assert!(s.contains("idle"), "{s}");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn table_exposes_terminal_and_structured_views() {
|
||||||
|
let terminal = session("/tmp", "bash", 1);
|
||||||
|
let mut structured = session("/tmp", "claude", 2);
|
||||||
|
structured.view = SessionView::Structured;
|
||||||
|
|
||||||
|
let mut buf = Vec::new();
|
||||||
|
super::print_session_table(&mut buf, &[terminal, structured], &info(""));
|
||||||
|
let out = String::from_utf8(buf).unwrap();
|
||||||
|
let lines: Vec<&str> = out.lines().collect();
|
||||||
|
let header = lines.iter().position(|line| line.contains("VIEW")).unwrap();
|
||||||
|
|
||||||
|
assert!(lines[header + 1].contains("terminal"), "{out}");
|
||||||
|
assert!(lines[header + 2].contains("structured"), "{out}");
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn live_cwd_overrides_spawn_dir_in_table() {
|
fn live_cwd_overrides_spawn_dir_in_table() {
|
||||||
let mut s = session("/home/naps/maestro", "bash", 1);
|
let mut s = session("/home/naps/maestro", "bash", 1);
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ use maestro_protocol::{
|
|||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
use crate::session::{QueueAcpPrompt, SessionManager};
|
use crate::session::{QueueAcpPrompt, SessionError, SessionManager};
|
||||||
use crate::sidecar::EventCursor;
|
use crate::sidecar::EventCursor;
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
@@ -179,6 +179,7 @@ fn session_error_status(code: &str) -> StatusCode {
|
|||||||
// The session outlived its pane: nothing to type into, and nothing
|
// The session outlived its pane: nothing to type into, and nothing
|
||||||
// the daemon can do about it.
|
// the daemon can do about it.
|
||||||
"not_running" => StatusCode::CONFLICT,
|
"not_running" => StatusCode::CONFLICT,
|
||||||
|
"not_terminal" => StatusCode::CONFLICT,
|
||||||
"shutting_down" => StatusCode::SERVICE_UNAVAILABLE,
|
"shutting_down" => StatusCode::SERVICE_UNAVAILABLE,
|
||||||
_ => StatusCode::BAD_GATEWAY,
|
_ => StatusCode::BAD_GATEWAY,
|
||||||
}
|
}
|
||||||
@@ -534,12 +535,23 @@ async fn attach_ws(
|
|||||||
State(s): State<LocalApiState>,
|
State(s): State<LocalApiState>,
|
||||||
ws: axum::extract::WebSocketUpgrade,
|
ws: axum::extract::WebSocketUpgrade,
|
||||||
) -> Result<Response, Response> {
|
) -> Result<Response, Response> {
|
||||||
let Some((replay, rx)) = s.manager.subscribe_pty_with_replay(id).await else {
|
let (replay, rx) = match s.manager.subscribe_pty_with_replay(id).await {
|
||||||
return Err(error_response(
|
Ok(subscription) => subscription,
|
||||||
StatusCode::NOT_FOUND,
|
Err(SessionError::NotFound(_)) => {
|
||||||
"unknown_session",
|
return Err(error_response(
|
||||||
"no such session on this daemon",
|
StatusCode::NOT_FOUND,
|
||||||
));
|
"unknown_session",
|
||||||
|
"no such session on this daemon",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Err(error) => {
|
||||||
|
let info = error.to_error_info();
|
||||||
|
return Err(error_response(
|
||||||
|
session_error_status(&info.code),
|
||||||
|
&info.code,
|
||||||
|
&info.message,
|
||||||
|
));
|
||||||
|
}
|
||||||
};
|
};
|
||||||
let manager = s.manager.clone();
|
let manager = s.manager.clone();
|
||||||
Ok(ws.on_upgrade(move |socket| async move {
|
Ok(ws.on_upgrade(move |socket| async move {
|
||||||
|
|||||||
@@ -169,6 +169,8 @@ pub enum SessionError {
|
|||||||
/// a stray keystroke on an attach socket, which is dropped.
|
/// a stray keystroke on an attach socket, which is dropped.
|
||||||
#[error("session is no longer running: {0}")]
|
#[error("session is no longer running: {0}")]
|
||||||
NotRunning(Uuid),
|
NotRunning(Uuid),
|
||||||
|
#[error("session is not a terminal session")]
|
||||||
|
NotTerminal(Uuid),
|
||||||
#[error("daemon is shutting down")]
|
#[error("daemon is shutting down")]
|
||||||
ShuttingDown,
|
ShuttingDown,
|
||||||
/// The kill was refused (#79): the session's worktree holds unsaved work.
|
/// The kill was refused (#79): the session's worktree holds unsaved work.
|
||||||
@@ -205,6 +207,7 @@ impl SessionError {
|
|||||||
SessionError::NotFound(_) => "not_found",
|
SessionError::NotFound(_) => "not_found",
|
||||||
SessionError::InvalidEnv(_) => "invalid_env",
|
SessionError::InvalidEnv(_) => "invalid_env",
|
||||||
SessionError::NotRunning(_) => "not_running",
|
SessionError::NotRunning(_) => "not_running",
|
||||||
|
SessionError::NotTerminal(_) => "not_terminal",
|
||||||
SessionError::ShuttingDown => "shutting_down",
|
SessionError::ShuttingDown => "shutting_down",
|
||||||
SessionError::KillGuarded(_) => "kill_guarded",
|
SessionError::KillGuarded(_) => "kill_guarded",
|
||||||
};
|
};
|
||||||
@@ -879,11 +882,17 @@ impl SessionManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Subscribe to a session's PTY broadcast. `None` if the session is
|
/// Subscribe to a terminal session's PTY broadcast.
|
||||||
/// unknown to this daemon.
|
pub async fn subscribe_pty(
|
||||||
pub async fn subscribe_pty(&self, id: Uuid) -> Option<broadcast::Receiver<Vec<u8>>> {
|
&self,
|
||||||
|
id: Uuid,
|
||||||
|
) -> Result<broadcast::Receiver<Vec<u8>>, SessionError> {
|
||||||
let sessions = self.inner.sessions.lock().await;
|
let sessions = self.inner.sessions.lock().await;
|
||||||
sessions.get(&id).map(|r| r.bridge.subscribe())
|
let record = sessions.get(&id).ok_or(SessionError::NotFound(id))?;
|
||||||
|
if record.session.view != SessionView::Terminal {
|
||||||
|
return Err(SessionError::NotTerminal(id));
|
||||||
|
}
|
||||||
|
Ok(record.bridge.subscribe())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Subscribe to a session's PTY broadcast and return the replay buffer
|
/// Subscribe to a session's PTY broadcast and return the replay buffer
|
||||||
@@ -897,11 +906,14 @@ impl SessionManager {
|
|||||||
pub async fn subscribe_pty_with_replay(
|
pub async fn subscribe_pty_with_replay(
|
||||||
&self,
|
&self,
|
||||||
id: Uuid,
|
id: Uuid,
|
||||||
) -> Option<(Vec<u8>, broadcast::Receiver<Vec<u8>>)> {
|
) -> Result<(Vec<u8>, broadcast::Receiver<Vec<u8>>), SessionError> {
|
||||||
let tmux = self.inner.tmux.clone();
|
let tmux = self.inner.tmux.clone();
|
||||||
let (result, updated) = {
|
let (result, updated) = {
|
||||||
let mut sessions = self.inner.sessions.lock().await;
|
let mut sessions = self.inner.sessions.lock().await;
|
||||||
let rec = sessions.get_mut(&id)?;
|
let rec = sessions.get_mut(&id).ok_or(SessionError::NotFound(id))?;
|
||||||
|
if rec.session.view != SessionView::Terminal {
|
||||||
|
return Err(SessionError::NotTerminal(id));
|
||||||
|
}
|
||||||
rec.session.last_focused_at = Some(Utc::now());
|
rec.session.last_focused_at = Some(Utc::now());
|
||||||
let updated = rec.session.clone();
|
let updated = rec.session.clone();
|
||||||
// For a live session, tmux's own screen beats the byte tail: the
|
// For a live session, tmux's own screen beats the byte tail: the
|
||||||
@@ -932,7 +944,7 @@ impl SessionManager {
|
|||||||
.inner
|
.inner
|
||||||
.events
|
.events
|
||||||
.send(CatalogEvent::SessionUpdated { session: updated });
|
.send(CatalogEvent::SessionUpdated { session: updated });
|
||||||
Some(result)
|
Ok(result)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn has(&self, id: Uuid) -> bool {
|
pub async fn has(&self, id: Uuid) -> bool {
|
||||||
@@ -945,6 +957,15 @@ impl SessionManager {
|
|||||||
sessions.get(&id).map(|r| r.session.status)
|
sessions.get(&id).map(|r| r.session.status)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn terminal_status(&self, id: Uuid) -> Result<SessionStatus, SessionError> {
|
||||||
|
let sessions = self.inner.sessions.lock().await;
|
||||||
|
let record = sessions.get(&id).ok_or(SessionError::NotFound(id))?;
|
||||||
|
if record.session.view != SessionView::Terminal {
|
||||||
|
return Err(SessionError::NotTerminal(id));
|
||||||
|
}
|
||||||
|
Ok(record.session.status)
|
||||||
|
}
|
||||||
|
|
||||||
/// Whether the natural-exit settle step (#82) has finished for this
|
/// Whether the natural-exit settle step (#82) has finished for this
|
||||||
/// session: `Some(true)` once applied or refused, `Some(false)` while
|
/// session: `Some(true)` once applied or refused, `Some(false)` while
|
||||||
/// still pending, `None` if the session is unknown. `Dead` is observable
|
/// still pending, `None` if the session is unknown. `Dead` is observable
|
||||||
@@ -1626,10 +1647,9 @@ impl SessionManager {
|
|||||||
/// its pane is gone, so this is a no-op rather than a tmux failure the
|
/// its pane is gone, so this is a no-op rather than a tmux failure the
|
||||||
/// client would have to interpret.
|
/// client would have to interpret.
|
||||||
pub async fn send_keys(&self, id: Uuid, bytes: &[u8]) -> Result<(), SessionError> {
|
pub async fn send_keys(&self, id: Uuid, bytes: &[u8]) -> Result<(), SessionError> {
|
||||||
match self.status(id).await {
|
match self.terminal_status(id).await? {
|
||||||
None => return Err(SessionError::NotFound(id)),
|
SessionStatus::Dead => return Ok(()),
|
||||||
Some(SessionStatus::Dead) => return Ok(()),
|
SessionStatus::Running => {}
|
||||||
Some(SessionStatus::Running) => {}
|
|
||||||
}
|
}
|
||||||
let tmux = self.inner.tmux.clone();
|
let tmux = self.inner.tmux.clone();
|
||||||
let bytes = bytes.to_vec();
|
let bytes = bytes.to_vec();
|
||||||
@@ -1645,10 +1665,9 @@ impl SessionManager {
|
|||||||
/// asked for text to land somewhere, and a silent success would let a
|
/// asked for text to land somewhere, and a silent success would let a
|
||||||
/// script believe an agent had been told something it never heard.
|
/// script believe an agent had been told something it never heard.
|
||||||
pub async fn send_input(&self, id: Uuid, text: &str, submit: bool) -> Result<(), SessionError> {
|
pub async fn send_input(&self, id: Uuid, text: &str, submit: bool) -> Result<(), SessionError> {
|
||||||
match self.status(id).await {
|
match self.terminal_status(id).await? {
|
||||||
None => return Err(SessionError::NotFound(id)),
|
SessionStatus::Dead => return Err(SessionError::NotRunning(id)),
|
||||||
Some(SessionStatus::Dead) => return Err(SessionError::NotRunning(id)),
|
SessionStatus::Running => {}
|
||||||
Some(SessionStatus::Running) => {}
|
|
||||||
}
|
}
|
||||||
let tmux = self.inner.tmux.clone();
|
let tmux = self.inner.tmux.clone();
|
||||||
let bytes = text.as_bytes().to_vec();
|
let bytes = text.as_bytes().to_vec();
|
||||||
@@ -1668,10 +1687,9 @@ impl SessionManager {
|
|||||||
/// Resize a session's window. No-op for a dead session, as with
|
/// Resize a session's window. No-op for a dead session, as with
|
||||||
/// [`Self::send_keys`].
|
/// [`Self::send_keys`].
|
||||||
pub async fn resize(&self, id: Uuid, cols: u16, rows: u16) -> Result<(), SessionError> {
|
pub async fn resize(&self, id: Uuid, cols: u16, rows: u16) -> Result<(), SessionError> {
|
||||||
match self.status(id).await {
|
match self.terminal_status(id).await? {
|
||||||
None => return Err(SessionError::NotFound(id)),
|
SessionStatus::Dead => return Ok(()),
|
||||||
Some(SessionStatus::Dead) => return Ok(()),
|
SessionStatus::Running => {}
|
||||||
Some(SessionStatus::Running) => {}
|
|
||||||
}
|
}
|
||||||
let tmux = self.inner.tmux.clone();
|
let tmux = self.inner.tmux.clone();
|
||||||
tmux_blocking(move || tmux.resize_window(id, cols, rows))
|
tmux_blocking(move || tmux.resize_window(id, cols, rows))
|
||||||
@@ -1703,6 +1721,7 @@ impl SessionManager {
|
|||||||
cols: u16,
|
cols: u16,
|
||||||
rows: u16,
|
rows: u16,
|
||||||
) -> Result<(), SessionError> {
|
) -> Result<(), SessionError> {
|
||||||
|
self.terminal_status(id).await?;
|
||||||
let target = self
|
let target = self
|
||||||
.inner
|
.inner
|
||||||
.viewports
|
.viewports
|
||||||
@@ -2807,13 +2826,15 @@ mod tests {
|
|||||||
async fn manager_with_structured_stream() -> (
|
async fn manager_with_structured_stream() -> (
|
||||||
tempfile::TempDir,
|
tempfile::TempDir,
|
||||||
SessionManager,
|
SessionManager,
|
||||||
|
Arc<FakePanes>,
|
||||||
uuid::Uuid,
|
uuid::Uuid,
|
||||||
Arc<tokio::sync::Mutex<AcpEventStream>>,
|
Arc<tokio::sync::Mutex<AcpEventStream>>,
|
||||||
uuid::Uuid,
|
uuid::Uuid,
|
||||||
) {
|
) {
|
||||||
let state = tempfile::tempdir().unwrap();
|
let state = tempfile::tempdir().unwrap();
|
||||||
|
let panes = Arc::new(FakePanes::new());
|
||||||
let manager = SessionManager::new(
|
let manager = SessionManager::new(
|
||||||
Arc::new(FakePanes::new()),
|
panes.clone(),
|
||||||
SidecarStore::new(state.path().join("sessions")),
|
SidecarStore::new(state.path().join("sessions")),
|
||||||
WorktreeManager::new(None),
|
WorktreeManager::new(None),
|
||||||
state.path().join("fifos"),
|
state.path().join("fifos"),
|
||||||
@@ -2848,12 +2869,12 @@ mod tests {
|
|||||||
record.focused_since = Some(std::time::Instant::now() - FOCUS_CLEAR_HOLD);
|
record.focused_since = Some(std::time::Instant::now() - FOCUS_CLEAR_HOLD);
|
||||||
record.acp_events = Some(events.clone());
|
record.acp_events = Some(events.clone());
|
||||||
}
|
}
|
||||||
(state, manager, structured.id, events, terminal.id)
|
(state, manager, panes, structured.id, events, terminal.id)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn blocked_structured_activity_read_does_not_block_the_catalog() {
|
async fn blocked_structured_activity_read_does_not_block_the_catalog() {
|
||||||
let (state, manager, structured_id, events, terminal_id) =
|
let (state, manager, _panes, structured_id, events, terminal_id) =
|
||||||
manager_with_structured_stream().await;
|
manager_with_structured_stream().await;
|
||||||
let held_stream = events.lock().await;
|
let held_stream = events.lock().await;
|
||||||
let activity_pass = run_structured_activity_pass(&manager.inner);
|
let activity_pass = run_structured_activity_pass(&manager.inner);
|
||||||
@@ -2898,9 +2919,55 @@ mod tests {
|
|||||||
assert_eq!(structured.activity, ActivityState::Idle);
|
assert_eq!(structured.activity, ActivityState::Idle);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn structured_sessions_reject_every_pty_operation_before_panes() {
|
||||||
|
let (_state, manager, panes, structured_id, _events, terminal_id) =
|
||||||
|
manager_with_structured_stream().await;
|
||||||
|
let before = panes.calls();
|
||||||
|
|
||||||
|
assert!(matches!(
|
||||||
|
manager.subscribe_pty(structured_id).await,
|
||||||
|
Err(super::SessionError::NotTerminal(id)) if id == structured_id
|
||||||
|
));
|
||||||
|
assert!(matches!(
|
||||||
|
manager.subscribe_pty_with_replay(structured_id).await,
|
||||||
|
Err(super::SessionError::NotTerminal(id)) if id == structured_id
|
||||||
|
));
|
||||||
|
assert!(matches!(
|
||||||
|
manager.send_keys(structured_id, b"ignored").await,
|
||||||
|
Err(super::SessionError::NotTerminal(id)) if id == structured_id
|
||||||
|
));
|
||||||
|
assert!(matches!(
|
||||||
|
manager.send_input(structured_id, "ignored", false).await,
|
||||||
|
Err(super::SessionError::NotTerminal(id)) if id == structured_id
|
||||||
|
));
|
||||||
|
assert!(matches!(
|
||||||
|
manager.resize(structured_id, 80, 24).await,
|
||||||
|
Err(super::SessionError::NotTerminal(id)) if id == structured_id
|
||||||
|
));
|
||||||
|
assert!(matches!(
|
||||||
|
manager
|
||||||
|
.set_viewport(structured_id, manager.attach_viewport(), 80, 24)
|
||||||
|
.await,
|
||||||
|
Err(super::SessionError::NotTerminal(id)) if id == structured_id
|
||||||
|
));
|
||||||
|
|
||||||
|
let after = panes.calls();
|
||||||
|
assert_eq!(after, before, "structured PTY operations touched panes");
|
||||||
|
|
||||||
|
manager
|
||||||
|
.send_input(terminal_id, "works", false)
|
||||||
|
.await
|
||||||
|
.expect("terminal input remains available");
|
||||||
|
manager
|
||||||
|
.resize(terminal_id, 100, 30)
|
||||||
|
.await
|
||||||
|
.expect("terminal resize remains available");
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn delayed_activity_from_a_replaced_stream_is_discarded() {
|
async fn delayed_activity_from_a_replaced_stream_is_discarded() {
|
||||||
let (_state, manager, structured_id, old_events, _terminal_id) =
|
let (_state, manager, _panes, structured_id, old_events, _terminal_id) =
|
||||||
manager_with_structured_stream().await;
|
manager_with_structured_stream().await;
|
||||||
let replacement = Arc::new(tokio::sync::Mutex::new(stream(
|
let replacement = Arc::new(tokio::sync::Mutex::new(stream(
|
||||||
manager
|
manager
|
||||||
|
|||||||
@@ -1311,6 +1311,39 @@ async fn attach_ws_streams_pty_bytes() {
|
|||||||
assert!(saw_hello, "PTY did not echo through the attach WS");
|
assert!(saw_hello, "PTY did not echo through the attach WS");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn pty_routes_reject_structured_sessions_as_not_terminal() {
|
||||||
|
let h = spawn("naps-laptop").await;
|
||||||
|
let fake = FakeAcp::new();
|
||||||
|
let id = structured_session(&h, &fake).await;
|
||||||
|
|
||||||
|
let response = reqwest::Client::new()
|
||||||
|
.post(url(h.addr, &format!("/api/sessions/{id}/input")))
|
||||||
|
.json(&maestro_protocol::SendInputRequest {
|
||||||
|
text: "ignored".into(),
|
||||||
|
submit: false,
|
||||||
|
})
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(response.status(), reqwest::StatusCode::CONFLICT);
|
||||||
|
let error: ApiError = response.json().await.unwrap();
|
||||||
|
assert_eq!(error.error.code, "not_terminal");
|
||||||
|
assert_eq!(error.error.message, "session is not a terminal session");
|
||||||
|
|
||||||
|
let error = connect_async(ws_url(h.addr, &format!("/api/attach/{id}")))
|
||||||
|
.await
|
||||||
|
.expect_err("structured session must not upgrade to a PTY socket");
|
||||||
|
let tokio_tungstenite::tungstenite::Error::Http(response) = error else {
|
||||||
|
panic!("expected an HTTP handshake rejection, got {error}");
|
||||||
|
};
|
||||||
|
assert_eq!(response.status(), reqwest::StatusCode::CONFLICT);
|
||||||
|
let body = response.body().as_deref().expect("ApiError response body");
|
||||||
|
let error: ApiError = serde_json::from_slice(body).unwrap();
|
||||||
|
assert_eq!(error.error.code, "not_terminal");
|
||||||
|
assert_eq!(error.error.message, "session is not a terminal session");
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn attach_ws_for_unknown_session_returns_404() {
|
async fn attach_ws_for_unknown_session_returns_404() {
|
||||||
let h = spawn("naps-laptop").await;
|
let h = spawn("naps-laptop").await;
|
||||||
|
|||||||
Reference in New Issue
Block a user