633 lines
20 KiB
Rust
633 lines
20 KiB
Rust
//! A receiver SHOULD NOT send a response for a request it has already been told
|
|
//! to cancel. This drives a real stdio server with raw JSON-RPC: the tool blocks
|
|
//! until the request is cancelled, so its result is only produced *after* the
|
|
//! cancellation — the service loop must drop it rather than write it to the wire.
|
|
|
|
#[cfg(all(feature = "client", not(feature = "local")))]
|
|
use std::sync::Arc;
|
|
use std::{collections::BTreeSet, process::Stdio, time::Duration};
|
|
|
|
#[cfg(all(feature = "client", not(feature = "local")))]
|
|
use rmcp::{
|
|
ClientHandler, RoleClient,
|
|
model::{
|
|
CancelledNotification, CancelledNotificationParam, ClientJsonRpcMessage, ClientRequest,
|
|
ClientResult, ElicitRequest, ElicitRequestParams, ElicitResult, ElicitationAction,
|
|
ElicitationSchema, PingRequest, RequestId, ServerJsonRpcMessage, ServerNotification,
|
|
ServerRequest, ServerResult,
|
|
},
|
|
service::{PeerRequestOptions, QuitReason, serve_directly},
|
|
transport::{IntoTransport, Transport},
|
|
};
|
|
use rmcp::{
|
|
ErrorData as McpError, RoleServer, ServerHandler, ServiceExt,
|
|
model::{
|
|
CallToolRequestParams, CallToolResponse, CallToolResult, ContentBlock, ServerCapabilities,
|
|
ServerConfig,
|
|
},
|
|
service::RequestContext,
|
|
};
|
|
use serde_json::{Value, json};
|
|
use tokio::{
|
|
io::{AsyncBufReadExt, AsyncWrite, AsyncWriteExt, BufReader},
|
|
process::{Child, Command},
|
|
};
|
|
|
|
const HELPER_ENV: &str = "RMCP_CANCELLED_RESPONSE_HELPER";
|
|
const READ_TIMEOUT: Duration = Duration::from_secs(10);
|
|
const STRING_REQUEST_ID: &str = "tool-request-2";
|
|
|
|
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
|
async fn cancelled_request_receives_no_response() -> anyhow::Result<()> {
|
|
let mut child = spawn_helper();
|
|
let mut writer = child.stdin.take().expect("helper stdin");
|
|
let stdout = child.stdout.take().expect("helper stdout");
|
|
let mut reader = BufReader::new(stdout);
|
|
|
|
send_json(
|
|
&mut writer,
|
|
&json!({
|
|
"jsonrpc": "2.0",
|
|
"id": 1,
|
|
"method": "initialize",
|
|
"params": {
|
|
"protocolVersion": "2024-11-05",
|
|
"capabilities": {},
|
|
"clientInfo": { "name": "raw-test-client", "version": "0.0.0" }
|
|
}
|
|
}),
|
|
)
|
|
.await?;
|
|
collect_ids_until(&mut reader, "1", READ_TIMEOUT).await?;
|
|
send_json(
|
|
&mut writer,
|
|
&json!({ "jsonrpc": "2.0", "method": "notifications/initialized" }),
|
|
)
|
|
.await?;
|
|
|
|
// Start a request that blocks until cancelled, then cancel it. Its response is
|
|
// produced only after the cancellation arrives, so it must be suppressed.
|
|
send_json(
|
|
&mut writer,
|
|
&json!({
|
|
"jsonrpc": "2.0",
|
|
"id": STRING_REQUEST_ID,
|
|
"method": "tools/call",
|
|
"params": { "name": "wait-for-cancel", "arguments": {} }
|
|
}),
|
|
)
|
|
.await?;
|
|
send_json(
|
|
&mut writer,
|
|
&json!({
|
|
"jsonrpc": "2.0",
|
|
"method": "notifications/cancelled",
|
|
"params": { "requestId": "unrelated-request" }
|
|
}),
|
|
)
|
|
.await?;
|
|
send_json(
|
|
&mut writer,
|
|
&json!({
|
|
"jsonrpc": "2.0",
|
|
"method": "notifications/cancelled",
|
|
"params": { "requestId": STRING_REQUEST_ID }
|
|
}),
|
|
)
|
|
.await?;
|
|
// A ping proves the server is alive past the cancellation, so the absence of
|
|
// a cancelled response is genuine suppression rather than a dead connection.
|
|
send_json(
|
|
&mut writer,
|
|
&json!({ "jsonrpc": "2.0", "id": 3, "method": "ping" }),
|
|
)
|
|
.await?;
|
|
|
|
let seen = collect_ids_until(&mut reader, "3", READ_TIMEOUT).await?;
|
|
assert!(seen.contains("3"));
|
|
assert!(!seen.contains(STRING_REQUEST_ID));
|
|
|
|
send_json(
|
|
&mut writer,
|
|
&json!({
|
|
"jsonrpc": "2.0",
|
|
"id": 4,
|
|
"method": "tools/call",
|
|
"params": { "name": "complete-unless-cancelled", "arguments": {} }
|
|
}),
|
|
)
|
|
.await?;
|
|
send_json(
|
|
&mut writer,
|
|
&json!({
|
|
"jsonrpc": "2.0",
|
|
"method": "notifications/cancelled",
|
|
"params": { "requestId": "4" }
|
|
}),
|
|
)
|
|
.await?;
|
|
send_json(
|
|
&mut writer,
|
|
&json!({ "jsonrpc": "2.0", "id": 5, "method": "ping" }),
|
|
)
|
|
.await?;
|
|
|
|
let seen = collect_ids_until(&mut reader, "5", READ_TIMEOUT).await?;
|
|
assert!(seen.contains("4"), "string and numeric IDs must not alias");
|
|
assert!(seen.contains("5"));
|
|
|
|
drop(writer);
|
|
wait_for_child(&mut child).await;
|
|
Ok(())
|
|
}
|
|
|
|
struct WaitForCancelServer;
|
|
|
|
impl ServerHandler for WaitForCancelServer {
|
|
fn get_info(&self) -> ServerConfig {
|
|
ServerConfig::new(ServerCapabilities::builder().enable_tools().build())
|
|
}
|
|
|
|
async fn call_tool(
|
|
&self,
|
|
request: CallToolRequestParams,
|
|
context: RequestContext<RoleServer>,
|
|
) -> Result<CallToolResponse, McpError> {
|
|
if request.name == "complete-unless-cancelled" {
|
|
tokio::select! {
|
|
_ = context.ct.cancelled() => {}
|
|
_ = tokio::time::sleep(Duration::from_millis(100)) => {}
|
|
}
|
|
return Ok(CallToolResult::success(vec![ContentBlock::text("completed")]).into());
|
|
}
|
|
context.ct.cancelled().await;
|
|
Ok(CallToolResult::success(vec![ContentBlock::text("late response")]).into())
|
|
}
|
|
}
|
|
|
|
#[cfg(all(feature = "client", not(feature = "local")))]
|
|
#[derive(Clone)]
|
|
struct WaitForReverseCancelClient {
|
|
events: tokio::sync::mpsc::UnboundedSender<&'static str>,
|
|
finish: Option<Arc<tokio::sync::Notify>>,
|
|
return_error: bool,
|
|
}
|
|
|
|
#[cfg(all(feature = "client", not(feature = "local")))]
|
|
impl ClientHandler for WaitForReverseCancelClient {
|
|
async fn create_elicitation(
|
|
&self,
|
|
_request: ElicitRequestParams,
|
|
context: rmcp::service::RequestContext<RoleClient>,
|
|
) -> Result<ElicitResult, McpError> {
|
|
self.events.send("started").expect("test is listening");
|
|
context.ct.cancelled().await;
|
|
self.events.send("cancelled").expect("test is listening");
|
|
if let Some(finish) = &self.finish {
|
|
finish.notified().await;
|
|
}
|
|
if self.return_error {
|
|
return Err(McpError::internal_error("late handler error", None));
|
|
}
|
|
Ok(ElicitResult::new(ElicitationAction::Decline))
|
|
}
|
|
}
|
|
|
|
#[cfg(all(feature = "client", not(feature = "local")))]
|
|
#[rstest::rstest]
|
|
#[case::initialize("initialize")]
|
|
#[case::direct_legacy("direct-legacy")]
|
|
#[case::direct_unknown("direct-unknown")]
|
|
#[tokio::test]
|
|
async fn peer_cancels_reverse_request_without_cancelling_outbound_request(
|
|
#[case] startup: &str,
|
|
#[values(false, true)] equal_ids: bool,
|
|
) -> anyhow::Result<()> {
|
|
tokio::time::timeout(READ_TIMEOUT, reverse_cancellation(startup, equal_ids)).await??;
|
|
Ok(())
|
|
}
|
|
|
|
#[cfg(all(feature = "client", not(feature = "local")))]
|
|
async fn reverse_cancellation(startup: &str, equal_ids: bool) -> anyhow::Result<()> {
|
|
use rmcp::model::ProtocolVersion;
|
|
let (client_transport, server_transport) = tokio::io::duplex(4096);
|
|
let (events_tx, mut events_rx) = tokio::sync::mpsc::unbounded_channel();
|
|
let handler = WaitForReverseCancelClient {
|
|
events: events_tx,
|
|
finish: None,
|
|
return_error: false,
|
|
};
|
|
let mut server = IntoTransport::<RoleServer, _, _>::into_transport(server_transport);
|
|
let mut info = ServerConfig::default();
|
|
info.protocol_version = ProtocolVersion::V_2025_11_25;
|
|
let client = if startup == "initialize" {
|
|
let (client, handshake) = tokio::join!(handler.serve(client_transport), async {
|
|
let Some(ClientJsonRpcMessage::Request(request)) = server.receive().await else {
|
|
panic!("expected initialize request");
|
|
};
|
|
assert!(matches!(
|
|
request.request,
|
|
ClientRequest::InitializeRequest(_)
|
|
));
|
|
server
|
|
.send(ServerJsonRpcMessage::response(
|
|
ServerResult::InitializeResult(info.clone()),
|
|
request.id,
|
|
))
|
|
.await?;
|
|
assert!(matches!(
|
|
server.receive().await,
|
|
Some(ClientJsonRpcMessage::Notification(_))
|
|
));
|
|
anyhow::Ok(())
|
|
});
|
|
handshake?;
|
|
client?
|
|
} else {
|
|
let peer_info = (startup == "direct-legacy").then(|| info.into());
|
|
serve_directly::<RoleClient, _, _, _, _>(handler, client_transport, peer_info)
|
|
};
|
|
let mut outbound = client
|
|
.send_cancellable_request(
|
|
ClientRequest::PingRequest(PingRequest {
|
|
method: Default::default(),
|
|
extensions: Default::default(),
|
|
}),
|
|
PeerRequestOptions::no_options(),
|
|
)
|
|
.await?;
|
|
let Some(ClientJsonRpcMessage::Request(outbound_request)) = server.receive().await else {
|
|
panic!("expected outbound ping request");
|
|
};
|
|
let id = if equal_ids {
|
|
outbound_request.id.clone()
|
|
} else {
|
|
RequestId::String("elicitation-1".into())
|
|
};
|
|
let mut request = reverse_elicitation("elicitation-1");
|
|
if let ServerJsonRpcMessage::Request(request) = &mut request {
|
|
request.id = id.clone();
|
|
}
|
|
server.send(request).await?;
|
|
assert_eq!(events_rx.recv().await, Some("started"));
|
|
let unknown = if equal_ids {
|
|
RequestId::String(id.to_string().into())
|
|
} else {
|
|
outbound_request.id.clone()
|
|
};
|
|
for cancel_id in [unknown, RequestId::String("unknown".into())] {
|
|
server.send(cancel_notification(cancel_id)).await?;
|
|
}
|
|
exchange_ping(&mut server, "before-cancel").await?;
|
|
assert!(matches!(
|
|
events_rx.try_recv(),
|
|
Err(tokio::sync::mpsc::error::TryRecvError::Empty)
|
|
));
|
|
assert!(matches!(
|
|
outbound.rx.try_recv(),
|
|
Err(tokio::sync::oneshot::error::TryRecvError::Empty)
|
|
));
|
|
|
|
// A cancellation can arrive after its response was already delivered.
|
|
server
|
|
.send(cancel_notification(RequestId::String(
|
|
"before-cancel".into(),
|
|
)))
|
|
.await?;
|
|
server.send(cancel_notification(id)).await?;
|
|
assert_eq!(events_rx.recv().await, Some("cancelled"));
|
|
exchange_ping(&mut server, "after-cancel").await?;
|
|
assert!(matches!(
|
|
outbound.rx.try_recv(),
|
|
Err(tokio::sync::oneshot::error::TryRecvError::Empty)
|
|
));
|
|
server
|
|
.send(ServerJsonRpcMessage::response(
|
|
ServerResult::empty(()),
|
|
outbound_request.id,
|
|
))
|
|
.await?;
|
|
assert!(matches!(
|
|
outbound.await_response().await?,
|
|
ServerResult::EmptyResult(_)
|
|
));
|
|
|
|
client.cancel().await?;
|
|
assert!(
|
|
server.receive().await.is_none(),
|
|
"no late response after handler drain"
|
|
);
|
|
Ok(())
|
|
}
|
|
|
|
#[cfg(all(feature = "client", not(feature = "local")))]
|
|
fn cancel_notification(id: RequestId) -> ServerJsonRpcMessage {
|
|
ServerJsonRpcMessage::notification(
|
|
CancelledNotification::new(CancelledNotificationParam::new(
|
|
Some(id),
|
|
Some("request cancelled".to_owned()),
|
|
))
|
|
.into(),
|
|
)
|
|
}
|
|
|
|
#[cfg(all(feature = "client", not(feature = "local")))]
|
|
async fn exchange_ping(server: &mut impl Transport<RoleServer>, id: &str) -> anyhow::Result<()> {
|
|
server
|
|
.send(ServerJsonRpcMessage::request(
|
|
PingRequest {
|
|
method: Default::default(),
|
|
extensions: Default::default(),
|
|
}
|
|
.into(),
|
|
RequestId::String(id.into()),
|
|
))
|
|
.await?;
|
|
let Some(ClientJsonRpcMessage::Response(response)) = server.receive().await else {
|
|
panic!("expected ping response");
|
|
};
|
|
assert_eq!(response.id, RequestId::String(id.into()));
|
|
assert!(matches!(response.result, ClientResult::EmptyResult(_)));
|
|
Ok(())
|
|
}
|
|
|
|
#[cfg(all(feature = "client", not(feature = "local")))]
|
|
struct ChannelClientTransport {
|
|
incoming: tokio::sync::mpsc::UnboundedReceiver<ServerJsonRpcMessage>,
|
|
outgoing: tokio::sync::mpsc::UnboundedSender<ClientJsonRpcMessage>,
|
|
eof_observed: Arc<tokio::sync::Notify>,
|
|
}
|
|
|
|
#[cfg(all(feature = "client", not(feature = "local")))]
|
|
impl Transport<RoleClient> for ChannelClientTransport {
|
|
type Error = std::io::Error;
|
|
|
|
fn send(
|
|
&mut self,
|
|
item: ClientJsonRpcMessage,
|
|
) -> impl Future<Output = Result<(), Self::Error>> + Send + 'static {
|
|
let outgoing = self.outgoing.clone();
|
|
async move {
|
|
outgoing.send(item).map_err(|_| {
|
|
std::io::Error::new(std::io::ErrorKind::BrokenPipe, "test receiver closed")
|
|
})
|
|
}
|
|
}
|
|
|
|
async fn receive(&mut self) -> Option<ServerJsonRpcMessage> {
|
|
let message = self.incoming.recv().await;
|
|
if message.is_none() {
|
|
self.eof_observed.notify_one();
|
|
}
|
|
message
|
|
}
|
|
|
|
async fn close(&mut self) -> Result<(), Self::Error> {
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
#[cfg(all(feature = "client", not(feature = "local")))]
|
|
fn reverse_elicitation(id: &str) -> ServerJsonRpcMessage {
|
|
ServerJsonRpcMessage::request(
|
|
ServerRequest::ElicitRequest(ElicitRequest::new(
|
|
ElicitRequestParams::FormElicitationParams {
|
|
meta: None,
|
|
message: "Continue the operation?".to_owned(),
|
|
requested_schema: ElicitationSchema::builder()
|
|
.build()
|
|
.expect("empty elicitation schema is valid"),
|
|
},
|
|
)),
|
|
RequestId::String(id.into()),
|
|
)
|
|
}
|
|
|
|
#[cfg(all(feature = "client", not(feature = "local")))]
|
|
#[tokio::test]
|
|
async fn graceful_close_drains_uncancelled_reverse_response() -> anyhow::Result<()> {
|
|
const REVERSE_REQUEST_ID: &str = "elicitation-close-drain";
|
|
|
|
let (to_client, incoming) = tokio::sync::mpsc::unbounded_channel();
|
|
let (outgoing, mut from_client) = tokio::sync::mpsc::unbounded_channel();
|
|
let eof_observed = Arc::new(tokio::sync::Notify::new());
|
|
let finish = Arc::new(tokio::sync::Notify::new());
|
|
let (events_tx, mut events_rx) = tokio::sync::mpsc::unbounded_channel();
|
|
let mut client = serve_directly::<RoleClient, _, _, _, _>(
|
|
WaitForReverseCancelClient {
|
|
events: events_tx,
|
|
finish: Some(finish.clone()),
|
|
return_error: false,
|
|
},
|
|
ChannelClientTransport {
|
|
incoming,
|
|
outgoing,
|
|
eof_observed,
|
|
},
|
|
None,
|
|
);
|
|
|
|
to_client.send(reverse_elicitation(REVERSE_REQUEST_ID))?;
|
|
assert_eq!(events_rx.recv().await, Some("started"));
|
|
|
|
let (close_result, cancellation_event) = tokio::join!(client.close(), async {
|
|
let event = events_rx.recv().await;
|
|
finish.notify_one();
|
|
event
|
|
});
|
|
assert!(matches!(close_result?, QuitReason::Cancelled));
|
|
assert_eq!(cancellation_event, Some("cancelled"));
|
|
|
|
let Some(ClientJsonRpcMessage::Response(response)) =
|
|
tokio::time::timeout(READ_TIMEOUT, from_client.recv()).await?
|
|
else {
|
|
panic!("expected in-flight response during graceful close");
|
|
};
|
|
assert_eq!(response.id, RequestId::String(REVERSE_REQUEST_ID.into()));
|
|
Ok(())
|
|
}
|
|
|
|
#[cfg(all(feature = "client", not(feature = "local")))]
|
|
#[tokio::test]
|
|
async fn cancelled_reverse_request_stays_suppressed_during_eof_drain() -> anyhow::Result<()> {
|
|
for return_error in [false, true] {
|
|
tokio::time::timeout(
|
|
READ_TIMEOUT,
|
|
cancelled_reverse_response_at_eof(return_error),
|
|
)
|
|
.await??;
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
#[cfg(all(feature = "client", not(feature = "local")))]
|
|
async fn cancelled_reverse_response_at_eof(return_error: bool) -> anyhow::Result<()> {
|
|
const REVERSE_REQUEST_ID: &str = "elicitation-before-eof";
|
|
|
|
let (to_client, incoming) = tokio::sync::mpsc::unbounded_channel();
|
|
let (outgoing, mut from_client) = tokio::sync::mpsc::unbounded_channel();
|
|
let eof_observed = Arc::new(tokio::sync::Notify::new());
|
|
let finish = Arc::new(tokio::sync::Notify::new());
|
|
let (events_tx, mut events_rx) = tokio::sync::mpsc::unbounded_channel();
|
|
let client = serve_directly::<RoleClient, _, _, _, _>(
|
|
WaitForReverseCancelClient {
|
|
events: events_tx,
|
|
finish: Some(finish.clone()),
|
|
return_error,
|
|
},
|
|
ChannelClientTransport {
|
|
incoming,
|
|
outgoing,
|
|
eof_observed: eof_observed.clone(),
|
|
},
|
|
None,
|
|
);
|
|
|
|
to_client.send(reverse_elicitation(REVERSE_REQUEST_ID))?;
|
|
assert_eq!(
|
|
tokio::time::timeout(READ_TIMEOUT, events_rx.recv()).await?,
|
|
Some("started")
|
|
);
|
|
|
|
to_client.send(ServerJsonRpcMessage::notification(
|
|
ServerNotification::CancelledNotification(CancelledNotification::new(
|
|
CancelledNotificationParam::new(
|
|
Some(RequestId::String(REVERSE_REQUEST_ID.into())),
|
|
Some("user cancelled".to_owned()),
|
|
),
|
|
)),
|
|
))?;
|
|
assert_eq!(
|
|
tokio::time::timeout(READ_TIMEOUT, events_rx.recv()).await?,
|
|
Some("cancelled")
|
|
);
|
|
|
|
let eof = eof_observed.notified();
|
|
drop(to_client);
|
|
tokio::time::timeout(READ_TIMEOUT, eof).await?;
|
|
finish.notify_one();
|
|
|
|
assert!(
|
|
matches!(
|
|
tokio::time::timeout(READ_TIMEOUT, client.waiting()).await??,
|
|
QuitReason::Closed
|
|
),
|
|
"client must close after input EOF"
|
|
);
|
|
assert!(
|
|
from_client.recv().await.is_none(),
|
|
"cancelled reverse request must not be sent during EOF drain (return_error={return_error})"
|
|
);
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn cancelled_response_helper() -> anyhow::Result<()> {
|
|
if std::env::var(HELPER_ENV).as_deref() != Ok("1") {
|
|
return Ok(());
|
|
}
|
|
run_helper_server().await?;
|
|
Ok(())
|
|
}
|
|
|
|
#[cfg(feature = "local")]
|
|
async fn run_helper_server() -> anyhow::Result<()> {
|
|
tokio::task::LocalSet::new()
|
|
.run_until(serve_helper_stdio())
|
|
.await
|
|
}
|
|
|
|
#[cfg(not(feature = "local"))]
|
|
async fn run_helper_server() -> anyhow::Result<()> {
|
|
serve_helper_stdio().await
|
|
}
|
|
|
|
async fn serve_helper_stdio() -> anyhow::Result<()> {
|
|
let server = WaitForCancelServer.serve(rmcp::transport::stdio()).await?;
|
|
server.waiting().await?;
|
|
Ok(())
|
|
}
|
|
|
|
fn spawn_helper() -> Child {
|
|
let exe = std::env::current_exe().expect("current test exe");
|
|
Command::new(exe)
|
|
.arg("--exact")
|
|
.arg("cancelled_response_helper")
|
|
.arg("--quiet")
|
|
.arg("--nocapture")
|
|
.arg("--test-threads")
|
|
.arg("1")
|
|
.env(HELPER_ENV, "1")
|
|
.stdin(Stdio::piped())
|
|
.stdout(Stdio::piped())
|
|
.stderr(Stdio::null())
|
|
.kill_on_drop(true)
|
|
.spawn()
|
|
.expect("spawn helper")
|
|
}
|
|
|
|
async fn wait_for_child(child: &mut Child) {
|
|
let _ = tokio::time::timeout(Duration::from_secs(2), child.wait()).await;
|
|
if child.id().is_some() {
|
|
let _ = child.kill().await;
|
|
}
|
|
}
|
|
|
|
async fn send_json<W>(writer: &mut W, message: &Value) -> anyhow::Result<()>
|
|
where
|
|
W: AsyncWrite + Unpin,
|
|
{
|
|
let serialized = serde_json::to_string(message)?;
|
|
writer.write_all(serialized.as_bytes()).await?;
|
|
writer.write_all(b"\n").await?;
|
|
writer.flush().await?;
|
|
Ok(())
|
|
}
|
|
|
|
/// Read response lines, collecting every message id seen, until `stop_id` is seen
|
|
/// (then a short grace read to catch any straggler) or the timeout elapses.
|
|
async fn collect_ids_until<R>(
|
|
reader: &mut BufReader<R>,
|
|
stop_id: &str,
|
|
timeout: Duration,
|
|
) -> anyhow::Result<BTreeSet<String>>
|
|
where
|
|
R: tokio::io::AsyncRead + Unpin,
|
|
{
|
|
let mut seen = BTreeSet::new();
|
|
let mut deadline = tokio::time::Instant::now() + timeout;
|
|
loop {
|
|
let remaining = deadline.saturating_duration_since(tokio::time::Instant::now());
|
|
if remaining.is_zero() {
|
|
break;
|
|
}
|
|
let mut line = String::new();
|
|
let Ok(read_result) = tokio::time::timeout(remaining, reader.read_line(&mut line)).await
|
|
else {
|
|
break;
|
|
};
|
|
if read_result? == 0 {
|
|
break;
|
|
}
|
|
let trimmed = line.trim();
|
|
if trimmed.is_empty() {
|
|
continue;
|
|
}
|
|
let Ok(value) = serde_json::from_str::<Value>(trimmed) else {
|
|
continue;
|
|
};
|
|
let id = match value.get("id") {
|
|
Some(Value::String(id)) => Some(id.clone()),
|
|
Some(Value::Number(id)) => Some(id.to_string()),
|
|
_ => None,
|
|
};
|
|
if let Some(id) = id {
|
|
seen.insert(id.clone());
|
|
if id == stop_id {
|
|
// Give any late (incorrectly-sent) response a brief window to arrive.
|
|
deadline = tokio::time::Instant::now() + Duration::from_millis(300);
|
|
}
|
|
}
|
|
}
|
|
Ok(seen)
|
|
}
|