Skip to content

Commit c228bcf

Browse files
committed
fix(acp): detect transport close in incoming_protocol_actor
The incoming actor's merged stream never ended when the transport closed because the merge kept waiting on internal channels. Chain a sentinel item after the transport stream so EOF is detected explicitly, then return an error to abort the try_join! and let run_until cancel the foreground task.
1 parent d18c691 commit c228bcf

3 files changed

Lines changed: 193 additions & 4 deletions

File tree

src/agent-client-protocol/src/jsonrpc/incoming_actor.rs

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ use std::collections::HashMap;
44
use futures::StreamExt as _;
55
use futures::channel::mpsc;
66
use futures::channel::oneshot;
7+
use futures::stream;
78
use futures_concurrency::stream::StreamExt as _;
89
use rustc_hash::FxHashMap;
910
use uuid::Uuid;
@@ -58,8 +59,11 @@ pub(super) async fn incoming_protocol_actor<Counterpart: Role>(
5859
mut handler: impl HandleDispatchFrom<Counterpart>,
5960
protocol_compat: ProtocolCompat,
6061
) -> Result<(), crate::Error> {
61-
let mut my_rx = transport_rx
62-
.map(IncomingProtocolMsg::Transport)
62+
let transport_with_eof = futures::StreamExt::chain(
63+
transport_rx.map(IncomingProtocolMsg::Transport),
64+
stream::iter([IncomingProtocolMsg::TransportClosed]),
65+
);
66+
let mut my_rx = transport_with_eof
6367
.merge(dynamic_handler_rx.map(IncomingProtocolMsg::DynamicHandler))
6468
.merge(reply_rx.map(IncomingProtocolMsg::Reply));
6569

@@ -76,6 +80,11 @@ pub(super) async fn incoming_protocol_actor<Counterpart: Role>(
7680
while let Some(message_result) = my_rx.next().await {
7781
tracing::trace!(message = ?message_result, actor = "incoming_protocol_actor");
7882
match message_result {
83+
IncomingProtocolMsg::TransportClosed => {
84+
tracing::debug!("Transport closed (EOF), shutting down incoming actor");
85+
return Err(crate::Error::internal_error().data("transport closed".to_string()));
86+
}
87+
7988
IncomingProtocolMsg::Reply(message) => match message {
8089
ReplyMessage::Subscribe {
8190
id,
@@ -253,6 +262,7 @@ pub(super) async fn incoming_protocol_actor<Counterpart: Role>(
253262
#[derive(Debug)]
254263
enum IncomingProtocolMsg<Counterpart: Role> {
255264
Transport(Result<RawJsonRpcMessage, crate::Error>),
265+
TransportClosed,
256266
DynamicHandler(DynamicHandlerMessage<Counterpart>),
257267
Reply(ReplyMessage),
258268
}

src/agent-client-protocol/src/util.rs

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -105,8 +105,9 @@ pub async fn both<E>(
105105
/// Run `background` until `foreground` completes.
106106
///
107107
/// Returns the result of `foreground`. If `background` errors before
108-
/// `foreground` completes, the error is propagated. If `background`
109-
/// completes with `Ok(())`, we continue waiting for `foreground`.
108+
/// `foreground` completes, the error is propagated and `foreground` is
109+
/// cancelled. If `background` completes with `Ok(())`, we continue
110+
/// waiting for `foreground`.
110111
pub async fn run_until<T, E>(
111112
background: impl Future<Output = Result<(), E>>,
112113
foreground: impl Future<Output = Result<T, E>>,
Lines changed: 178 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,178 @@
1+
//! Tests for transport close (EOF) detection.
2+
//!
3+
//! Verifies that `connect_with` and `connect_to` return when the remote
4+
//! end of the transport closes, rather than hanging forever.
5+
6+
use agent_client_protocol::{ByteStreams, Dispatch, Handled, role::UntypedRole};
7+
use futures::{AsyncRead, AsyncWrite};
8+
use std::time::Duration;
9+
use tokio::sync::mpsc;
10+
use tokio_util::compat::{TokioAsyncReadCompatExt, TokioAsyncWriteCompatExt};
11+
12+
fn setup_test_streams() -> (
13+
impl AsyncRead,
14+
impl AsyncWrite,
15+
impl AsyncRead,
16+
impl AsyncWrite,
17+
) {
18+
let (client_writer, server_reader) = tokio::io::duplex(1024);
19+
let (server_writer, client_reader) = tokio::io::duplex(1024);
20+
21+
(
22+
server_reader.compat(),
23+
server_writer.compat_write(),
24+
client_reader.compat(),
25+
client_writer.compat_write(),
26+
)
27+
}
28+
29+
/// When the remote side's `connect_with` returns (dropping the transport),
30+
/// the local `connect_with` with a blocking main_fn should also return
31+
/// rather than hanging forever.
32+
#[tokio::test(flavor = "current_thread")]
33+
async fn connect_with_returns_on_remote_transport_close() {
34+
use tokio::task::LocalSet;
35+
36+
let local = LocalSet::new();
37+
38+
local
39+
.run_until(async {
40+
let (server_reader, server_writer, client_reader, client_writer) = setup_test_streams();
41+
42+
let server_transport = ByteStreams::new(server_writer, server_reader);
43+
let client_transport = ByteStreams::new(client_writer, client_reader);
44+
45+
// Server accepts and immediately returns (closing transport).
46+
tokio::task::spawn_local(async move {
47+
UntypedRole
48+
.builder()
49+
.name("server")
50+
.connect_with(server_transport, async move |_cx| Ok(()))
51+
.await
52+
.ok();
53+
});
54+
55+
// Client blocks on an mpsc channel (simulating a proxy pattern).
56+
let (_tx, mut rx) = mpsc::unbounded_channel::<Dispatch>();
57+
let result = tokio::time::timeout(
58+
Duration::from_secs(2),
59+
UntypedRole.builder().name("client").connect_with(
60+
client_transport,
61+
async move |_cx| {
62+
// Block until channel closes — but transport EOF should
63+
// cancel this future before that happens.
64+
while rx.recv().await.is_some() {}
65+
Ok(())
66+
},
67+
),
68+
)
69+
.await;
70+
71+
assert!(
72+
result.is_ok(),
73+
"connect_with should return on transport close, not time out"
74+
);
75+
// The connection closed before main_fn finished, so it returns Err.
76+
assert!(result.unwrap().is_err());
77+
})
78+
.await;
79+
}
80+
81+
/// `connect_to` (server mode) should return when the remote end disconnects.
82+
#[tokio::test(flavor = "current_thread")]
83+
async fn connect_to_returns_on_remote_transport_close() {
84+
use tokio::task::LocalSet;
85+
86+
let local = LocalSet::new();
87+
88+
local
89+
.run_until(async {
90+
let (server_reader, server_writer, client_reader, client_writer) = setup_test_streams();
91+
92+
let server_transport = ByteStreams::new(server_writer, server_reader);
93+
let client_transport = ByteStreams::new(client_writer, client_reader);
94+
95+
// Client connects and immediately returns.
96+
tokio::task::spawn_local(async move {
97+
UntypedRole
98+
.builder()
99+
.name("client")
100+
.connect_with(client_transport, async move |_cx| Ok(()))
101+
.await
102+
.ok();
103+
});
104+
105+
// Server uses connect_to (pending foreground) — should detect EOF.
106+
let result = tokio::time::timeout(
107+
Duration::from_secs(2),
108+
UntypedRole
109+
.builder()
110+
.name("server")
111+
.connect_to(server_transport),
112+
)
113+
.await;
114+
115+
assert!(
116+
result.is_ok(),
117+
"connect_to should return on transport close, not time out"
118+
);
119+
})
120+
.await;
121+
}
122+
123+
/// When using `on_receive_dispatch` with a forwarding channel pattern,
124+
/// transport close should still be detected and the connection should exit.
125+
#[tokio::test(flavor = "current_thread")]
126+
async fn connect_with_on_receive_dispatch_returns_on_transport_close() {
127+
use tokio::task::LocalSet;
128+
129+
let local = LocalSet::new();
130+
131+
local
132+
.run_until(async {
133+
let (server_reader, server_writer, client_reader, client_writer) = setup_test_streams();
134+
135+
let server_transport = ByteStreams::new(server_writer, server_reader);
136+
let client_transport = ByteStreams::new(client_writer, client_reader);
137+
138+
// Server immediately exits.
139+
tokio::task::spawn_local(async move {
140+
UntypedRole
141+
.builder()
142+
.name("server")
143+
.connect_with(server_transport, async move |_cx| Ok(()))
144+
.await
145+
.ok();
146+
});
147+
148+
// Client with an on_receive_dispatch handler and a blocking main_fn.
149+
let (_outgoing_tx, mut outgoing_rx) = mpsc::unbounded_channel::<Dispatch>();
150+
let result = tokio::time::timeout(
151+
Duration::from_secs(2),
152+
UntypedRole
153+
.builder()
154+
.name("client")
155+
.on_receive_dispatch(
156+
async move |_dispatch: Dispatch,
157+
_cx: agent_client_protocol::ConnectionTo<UntypedRole>| {
158+
Ok(Handled::Yes)
159+
},
160+
agent_client_protocol::on_receive_dispatch!(),
161+
)
162+
.connect_with(client_transport, async move |cx| {
163+
while let Some(dispatch) = outgoing_rx.recv().await {
164+
cx.send_proxied_message(dispatch)?;
165+
}
166+
Ok(())
167+
}),
168+
)
169+
.await;
170+
171+
assert!(
172+
result.is_ok(),
173+
"connect_with with handler should return on transport close, not time out"
174+
);
175+
assert!(result.unwrap().is_err());
176+
})
177+
.await;
178+
}

0 commit comments

Comments
 (0)