Skip to content

fix: preserve SSE resume cache and correct event replay - #1165

Open
freedom-winds wants to merge 3 commits into
modelcontextprotocol:mainfrom
freedom-winds:pr/09-rust-sdk-session-resume-and-cache-20260811200316-01
Open

freedom-winds wants to merge 3 commits into
modelcontextprotocol:mainfrom
freedom-winds:pr/09-rust-sdk-session-resume-and-cache-20260811200316-01

Conversation

@freedom-winds

Copy link
Copy Markdown

Summary

Fix two issues in the Streamable HTTP server's SSE resume behavior:

  • The event identified by Last-Event-ID was replayed again.
  • Request-scoped SSE caches were deleted immediately after the final response, preventing clients from recovering responses after a disconnect.

Changes

  • Adjust the SSE resume offset to replay only events after Last-Event-ID.
  • Reject event IDs older than the retained cache window.
  • Mark completed request channels as complete while retaining their cached events.
  • Allow completed_cache_ttl to evict completed request caches.
  • Add regression coverage for retaining completed request channel state.

@freedom-winds
freedom-winds requested a review from a team as a code owner August 11, 2026 12:03
@github-actions github-actions Bot added T-core Core library changes T-transport Transport layer changes labels Aug 11, 2026
Comment thread crates/rmcp/src/transport/streamable_http_server/session/local.rs
Comment thread crates/rmcp/src/transport/streamable_http_server/session/local.rs Outdated
@github-actions github-actions Bot added the T-test Testing related changes label Aug 12, 2026
let sync_index = index.saturating_sub(front_event_id.index);
// Last-Event-ID is the last event the client received. The resume is
// valid as long as the next event has not already fallen out of cache.
let next_index = index.saturating_add(1);

@DaleSeo DaleSeo Aug 18, 2026 •

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A standalone GET emits a synthetic priming event with ID 0, but CachedTx::new_common also assigns 0 to the first cached common event. How can a client that only received the priming event resume without sync(0) skipping that unseen notification?

Comment on lines +1421 to +1422
assert!(worker.tx_router[&http_request_id].completed_at.is_some());
assert!(worker.tx_router[&http_request_id].resources.is_empty());

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could this regression exercise resume and assert that the cached final response is delivered?

@github-actions

Copy link
Copy Markdown
Contributor

This pull request has had no activity for 60 days and is now marked as stale. It will be closed in 14 days if there is no further activity. Add the keep label to keep it open.

@github-actions github-actions Bot added the Stale label Oct 18, 2026
Comment on lines +1421 to +1422
assert!(worker.tx_router[&http_request_id].completed_at.is_some());
assert!(worker.tx_router[&http_request_id].resources.is_empty());

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The name suggests the cache survives a resume, but the assertions only check bookkeeping fields. So a regression that drops the cache would still pass. Could we resume from the priming event and assert that the final error response is replayed?

Comment on lines +749 to 753
match last_event_index {
Some(last_event_index) => self.common.sync(last_event_index).await?,
None if self.event_store.is_none() => self.common.replay_cached().await?,
None => {}
}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A fresh GET without a Last-Event-ID replays the entire cache, including events the previous primary already delivered. Is that redelivery intentional, given that Last-Event-ID is the spec's way to recover missed events?

Comment on lines +359 to +368
async fn replay_cached(&mut self) -> Result<(), SessionError> {
for message in &self.cache {
let send_result = self.tx.send(message.clone()).await;
if send_result.is_err() {
let event_id: EventId = message.event_id.as_deref().unwrap_or_default().parse()?;
return Err(SessionError::ChannelClosed(Some(event_id.index as u64)));
}
}
Ok(())
}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This duplicates the replay loop in sync, including its error mapping. Could we have both use a shared replay_from(skip) method?

Then sync could end with self.replay_from(sync_index).await, and the common-channel call site could use self.common.replay_from(0).

Comment on lines +340 to +344
let next_index = index.saturating_add(1);
if next_index < front_event_id.index {
return Err(SessionError::InvalidEventId);
}
let sync_index = next_index.saturating_sub(front_event_id.index);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

checked_sub handles the bounds check and subtraction in one step, so we no longer need saturating_sub.

Suggested change
let next_index = index.saturating_add(1);
if next_index < front_event_id.index {
return Err(SessionError::InvalidEventId);
}
let sync_index = next_index.saturating_sub(front_event_id.index);
let Some(sync_index) = index.saturating_add(1).checked_sub(front_event_id.index) else {
return Err(SessionError::InvalidEventId);
};

Comment on lines +1438 to +1446
assert!(matches!(
cached_tx.sync(3).await,
Err(SessionError::InvalidEventId)
));
cached_tx.sync(4).await.unwrap();

assert_eq!(rx.recv().await.unwrap().event_id.as_deref(), Some("5"));
assert_eq!(rx.recv().await.unwrap().event_id.as_deref(), Some("6"));
}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This test checks two behaviors, rejection and acceptance, and nothing directly covers the headline fix that the Last-Event-ID event itself is not replayed. Could the rejection case get its own test, plus one that resumes from 5 and gets only 6?

Suggested change
assert!(matches!(
cached_tx.sync(3).await,
Err(SessionError::InvalidEventId)
));
cached_tx.sync(4).await.unwrap();
assert_eq!(rx.recv().await.unwrap().event_id.as_deref(), Some("5"));
assert_eq!(rx.recv().await.unwrap().event_id.as_deref(), Some("6"));
}
cached_tx.sync(4).await.unwrap();
assert_eq!(rx.recv().await.unwrap().event_id.as_deref(), Some("5"));
assert_eq!(rx.recv().await.unwrap().event_id.as_deref(), Some("6"));
}
#[tokio::test]
async fn resume_skips_event_matching_last_event_id() {
let (tx, mut rx) = tokio::sync::mpsc::channel(4);
let mut cached_tx = CachedTx::new(tx, None, 0, "test-stream".into(), None);
cached_tx.cache.push_back(ServerSseMessage {
event_id: Some("5".into()),
..Default::default()
});
cached_tx.cache.push_back(ServerSseMessage {
event_id: Some("6".into()),
..Default::default()
});
cached_tx.sync(5).await.unwrap();
drop(cached_tx);
assert_eq!(rx.recv().await.unwrap().event_id.as_deref(), Some("6"));
assert!(rx.recv().await.is_none());
}
#[tokio::test]
async fn resume_rejects_event_older_than_cache_front() {
let (tx, _rx) = tokio::sync::mpsc::channel(4);
let mut cached_tx = CachedTx::new(tx, None, 0, "test-stream".into(), None);
cached_tx.cache.push_back(ServerSseMessage {
event_id: Some("5".into()),
..Default::default()
});
let result = cached_tx.sync(3).await;
assert!(matches!(result, Err(SessionError::InvalidEventId)));
}

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Stale T-core Core library changes T-test Testing related changes T-transport Transport layer changes

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants