From bbae09bc484c67bdaaf9ba824403ced7ed8f872e Mon Sep 17 00:00:00 2001 From: Miguel Palhas Date: Sat, 5 Sep 2026 02:13:55 +0100 Subject: [PATCH] fix(daemon): reject dead structured sessions --- crates/maestro-daemon/src/local_api.rs | 6 ++-- crates/maestro-daemon/src/session.rs | 26 +++++++++++++---- crates/maestro-daemon/tests/local_api.rs | 36 +++++++++++++++++++++++- 3 files changed, 58 insertions(+), 10 deletions(-) diff --git a/crates/maestro-daemon/src/local_api.rs b/crates/maestro-daemon/src/local_api.rs index 030519d..da83481 100644 --- a/crates/maestro-daemon/src/local_api.rs +++ b/crates/maestro-daemon/src/local_api.rs @@ -556,9 +556,9 @@ async fn structured_ws( })? else { return Err(error_response( - StatusCode::NOT_FOUND, - "unknown_structured_session", - "no live structured session with this id", + StatusCode::CONFLICT, + "not_structured", + "session is not a live structured session", )); }; Ok(ws.on_upgrade(move |socket| async move { diff --git a/crates/maestro-daemon/src/session.rs b/crates/maestro-daemon/src/session.rs index 246a125..4ae618d 100644 --- a/crates/maestro-daemon/src/session.rs +++ b/crates/maestro-daemon/src/session.rs @@ -103,6 +103,15 @@ pub struct SessionRecord { acp_event_pump: Option>, } +impl SessionRecord { + fn is_live_structured(&self) -> bool { + self.session.view == SessionView::Structured + && self.session.status == SessionStatus::Running + && self.acp.is_some() + && self.acp_events.is_some() + } +} + #[derive(Debug)] struct AcpEventStream { log: EventLog, @@ -341,6 +350,9 @@ impl SessionManager { let Some(record) = sessions.get(&id) else { return Ok(None); }; + if !record.is_live_structured() { + return Ok(None); + } let Some(stream) = &record.acp_events else { return Ok(None); }; @@ -378,7 +390,7 @@ impl SessionManager { let stream = { let sessions = self.inner.sessions.lock().await; let record = sessions.get(&id).ok_or(SessionError::NotFound(id))?; - if record.session.view != SessionView::Structured { + if !record.is_live_structured() { return Ok(None); } record.acp_events.clone() @@ -441,9 +453,10 @@ impl SessionManager { > { let stream = { let sessions = self.inner.sessions.lock().await; - let Some(record) = sessions.get(&id) else { + let record = sessions.get(&id).ok_or(SessionError::NotFound(id))?; + if !record.is_live_structured() { return Ok(None); - }; + } let Some(stream) = &record.acp_events else { return Ok(None); }; @@ -488,9 +501,7 @@ impl SessionManager { let (acp, stream) = { let sessions = self.inner.sessions.lock().await; let record = sessions.get(&id).ok_or(SessionError::NotFound(id))?; - if record.session.view != SessionView::Structured - || record.session.status != SessionStatus::Running - { + if !record.is_live_structured() { return Ok(BeginAcpTurn::Unavailable); } (record.acp.clone(), record.acp_events.clone()) @@ -512,6 +523,9 @@ impl SessionManager { let (acp, stream) = { let sessions = self.inner.sessions.lock().await; let record = sessions.get(&id).ok_or(SessionError::NotFound(id))?; + if !record.is_live_structured() { + return Ok(false); + } (record.acp.clone(), record.acp_events.clone()) }; let (Some(acp), Some(stream)) = (acp, stream) else { diff --git a/crates/maestro-daemon/tests/local_api.rs b/crates/maestro-daemon/tests/local_api.rs index 8aea8d3..f7262b6 100644 --- a/crates/maestro-daemon/tests/local_api.rs +++ b/crates/maestro-daemon/tests/local_api.rs @@ -445,7 +445,7 @@ async fn structured_replay_pages_projected_events_in_cursor_order() { } #[tokio::test] -async fn structured_replay_rejects_unknown_and_terminal_sessions() { +async fn structured_replay_rejects_unknown_terminal_and_dead_sessions() { let h = spawn("naps-laptop").await; let client = reqwest::Client::new(); let unknown = client @@ -478,6 +478,40 @@ async fn structured_replay_rejects_unknown_and_terminal_sessions() { .await .unwrap(); assert_eq!(response.status(), reqwest::StatusCode::CONFLICT); + + let fake = FakeAcp::new(); + let dead = structured_session(&h, &fake).await; + h.manager.shutdown().await; + assert_eq!(h.manager.status(dead).await, Some(SessionStatus::Dead)); + let response = client + .get(url( + h.addr, + &format!("/api/sessions/{dead}/structured/replay"), + )) + .send() + .await + .unwrap(); + assert_eq!(response.status(), reqwest::StatusCode::CONFLICT); + let error: ApiError = response.json().await.unwrap(); + assert_eq!(error.error.code, "not_structured"); +} + +#[tokio::test] +async fn structured_ws_rejects_a_dead_session() { + let h = spawn("naps-laptop").await; + let fake = FakeAcp::new(); + let id = structured_session(&h, &fake).await; + h.manager.shutdown().await; + assert_eq!(h.manager.status(id).await, Some(SessionStatus::Dead)); + + let err = connect_async(ws_url(h.addr, &format!("/api/structured/{id}"))) + .await + .expect_err("dead structured session must not upgrade"); + let message = err.to_string(); + assert!( + message.contains("409") || message.to_lowercase().contains("conflict"), + "{message}" + ); } #[tokio::test]