fix(daemon): reject dead structured sessions

This commit is contained in:
Miguel Palhas
2026-09-05 02:13:55 +01:00
parent c7c294996e
commit bbae09bc48
3 changed files with 58 additions and 10 deletions
+3 -3
View File
@@ -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 {
+20 -6
View File
@@ -103,6 +103,15 @@ pub struct SessionRecord {
acp_event_pump: Option<watch::Receiver<bool>>,
}
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 {
+35 -1
View File
@@ -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]