Skip to content

Commit 6618e9a

Browse files
authored
Shut down help proxy when help comm drops (#1285)
Branched from #1284 While working on that PR I noticed that we never shut down the proxy server when the Help comm is tore down. If we start a new one, we'd pile up a new proxy. This is theoretical because Help is one of the comms that the frontend reuses on refresh/reload (unlike the UI comm). But it still seems nice to handle the shutdown path properly. Covered by a new test.
1 parent d23dbe2 commit 6618e9a

4 files changed

Lines changed: 139 additions & 15 deletions

File tree

crates/ark/src/help/r_help.rs

Lines changed: 12 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -42,11 +42,15 @@ pub struct HelpPorts {
4242
/// The R Help handler (together with the help proxy) provides the server side
4343
/// of Positron's Help panel.
4444
///
45-
/// A stateless unit struct. The server and proxy ports live on `Console` as
46-
/// `help_ports` so they can be reached via `Console::get()` by the
47-
/// `browseURL()` hook.
48-
#[derive(Debug)]
49-
pub struct RHelp;
45+
/// The server and proxy ports live on `Console` as `help_ports`, not here, so
46+
/// the `browseURL()` hook can reach them through `Console::get()`. What this
47+
/// handler does own is the proxy drop guard, which shuts the proxy down when
48+
/// the help comm closes.
49+
#[derive(Debug, Default)]
50+
pub struct RHelp {
51+
/// Drop guard to stop the help proxy server on teardown.
52+
proxy: Option<help_proxy::ProxyHandle>,
53+
}
5054

5155
impl RHelp {
5256
/// Public associated function so that callers can cheaply check if a url is
@@ -230,14 +234,15 @@ impl CommHandler for RHelp {
230234
};
231235
log::info!("R help server listening on port {r_port}");
232236

233-
let proxy_port = match help_proxy::start(r_port) {
234-
Ok(port) => port,
237+
let (proxy_port, proxy) = match help_proxy::start(r_port) {
238+
Ok(proxy) => proxy,
235239
Err(err) => {
236240
log::error!("Could not start R help proxy server: {err:?}");
237241
return;
238242
},
239243
};
240244

245+
self.proxy = Some(proxy);
241246
Console::get_mut().set_help_ports(r_port, proxy_port);
242247
}
243248

crates/ark/src/help_proxy.rs

Lines changed: 36 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -44,9 +44,21 @@ struct AppState {
4444
target_port: u16,
4545
}
4646

47+
/// Drop guard that ties the proxy server's lifetime to the help comm.
48+
///
49+
/// Dropping this stops the `actix_web` server and lets its thread and tokio
50+
/// runtime exit. We hold it in `RHelp` so the proxy lives exactly as long as
51+
/// the help comm. The field is never read. Dropping the `Sender` is the whole
52+
/// mechanism, because that's what the proxy thread waits on.
53+
#[derive(Debug)]
54+
pub struct ProxyHandle {
55+
_shutdown_tx: tokio::sync::oneshot::Sender<()>,
56+
}
57+
4758
// Starts the help proxy.
48-
pub fn start(target_port: u16) -> anyhow::Result<u16> {
59+
pub fn start(target_port: u16) -> anyhow::Result<(u16, ProxyHandle)> {
4960
let (port_tx, port_rx) = crossbeam::channel::bounded::<u16>(1);
61+
let (shutdown_tx, shutdown_rx) = tokio::sync::oneshot::channel::<()>();
5062

5163
spawn!("ark-help-proxy", move || -> anyhow::Result<()> {
5264
// Bind to port `0` to allow the OS to assign the port, avoiding any race conditions
@@ -85,9 +97,26 @@ pub fn start(target_port: u16) -> anyhow::Result<u16> {
8597

8698
// Execute the task within the runtime.
8799
rt.block_on(async {
88-
match server.run().await {
89-
Ok(value) => log::info!("Help proxy server exited with value: {:?}", value),
90-
Err(error) => log::error!("Help proxy server exited unexpectedly: {}", error),
100+
let server = server.run();
101+
let handle = server.handle();
102+
103+
// Dropping the help comm must tear down the whole proxy and its
104+
// runtime, not just stop the server. The trigger is `ProxyHandle`
105+
// (this oneshot's sender) dropping with `RHelp`, which resolves
106+
// `shutdown_rx`. The explicit `stop()` is required because dropping
107+
// the `ServerHandle` can't stop the server (the `Server` future
108+
// owns its own handle). `stop()` lets the `server.await` call
109+
// return, allowing the task and runtime to exit.
110+
tokio::spawn(async move {
111+
// We never send on the sender, so this only resolves (to `Err`)
112+
// when `ProxyHandle` drops. Either way it means "shut down".
113+
shutdown_rx.await.ok();
114+
handle.stop(false).await;
115+
});
116+
117+
match server.await {
118+
Ok(value) => log::info!("Help proxy server exited with value: {value:?}"),
119+
Err(error) => log::error!("Help proxy server exited unexpectedly: {error}"),
91120
}
92121
});
93122

@@ -96,7 +125,9 @@ pub fn start(target_port: u16) -> anyhow::Result<u16> {
96125

97126
// Wait for the returned port with an extensive timeout
98127
match port_rx.recv_timeout(Duration::from_secs(20)) {
99-
Ok(port) => Ok(port),
128+
Ok(port) => Ok((port, ProxyHandle {
129+
_shutdown_tx: shutdown_tx,
130+
})),
100131
Err(err) => Err(anyhow::anyhow!(
101132
"Help proxy server timed out while waiting for a port: {err:?}"
102133
)),

crates/ark/src/shell.rs

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -347,14 +347,15 @@ fn handle_comm_open_help(
347347
) -> amalthea::Result<(bool, Option<Receiver<()>>)> {
348348
// Register the handler on the R thread, like the UI comm. `RHelp::handle_open`
349349
// starts the R help server and proxy and records their ports on `Console`, all
350-
// on the R thread. The RPC handler itself is stateless.
350+
// on the R thread. The handler holds the proxy's drop guard, so the proxy is
351+
// torn down when the comm is removed.
351352
let (done_tx, done_rx) = bounded(0);
352353
kernel_request_tx
353354
.send(KernelRequest::CommOpen {
354355
comm_id: comm.comm_id.clone(),
355356
comm_name: comm.comm_name.clone(),
356357
outgoing_tx: comm.outgoing_tx.clone(),
357-
handler: Box::new(RHelp),
358+
handler: Box::new(RHelp::default()),
358359
done_tx,
359360
})
360361
.map_err(|err| amalthea::Error::SendError(err.to_string()))?;

crates/ark/tests/integration/help.rs

Lines changed: 88 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@
66
//
77

88
use core::panic;
9+
use std::net::TcpStream;
10+
use std::time::Duration;
911

1012
use amalthea::comm::comm_channel::CommMsg;
1113
use amalthea::comm::event::CommEvent;
@@ -68,7 +70,7 @@ impl TestRHelp {
6870
let (comm_event_tx, _) = bounded::<CommEvent>(10);
6971
let ctx = CommHandlerContext::new(outgoing_tx, comm_event_tx);
7072

71-
let mut handler = RHelp;
73+
let mut handler = RHelp::default();
7274
handler.handle_msg(msg, &ctx);
7375
});
7476

@@ -199,3 +201,88 @@ fn test_help_show_help_event() {
199201
frontend.recv_iopub_idle();
200202
frontend.recv_shell_execute_reply();
201203
}
204+
205+
/// Reopening the help comm (as happens on a frontend reload) must not leak the
206+
/// previous proxy server.
207+
///
208+
/// The proxy's lifetime is tied to the help comm via a drop guard held in
209+
/// `RHelp`. When the frontend opens a second `positron.help` comm, the kernel
210+
/// replaces the handler, the old `RHelp` drops, and its proxy stops. We observe
211+
/// this directly. Each proxy binds its own localhost port, so we connect to the
212+
/// old port and check it stops accepting connections. Before this was wired up,
213+
/// the old proxy stayed bound forever.
214+
#[test]
215+
fn test_help_proxy_torn_down_on_reopen() {
216+
let frontend = DummyArkFrontend::lock();
217+
218+
let comm_id_1 = open_help_comm(&frontend);
219+
let port_1 = show_help_and_get_proxy_port(&frontend, &comm_id_1);
220+
assert!(proxy_is_listening(port_1));
221+
222+
// Reopen the help comm, which replaces the handler and drops the old one.
223+
let comm_id_2 = open_help_comm(&frontend);
224+
let port_2 = show_help_and_get_proxy_port(&frontend, &comm_id_2);
225+
226+
// The new proxy binds before the old `RHelp` is dropped, so the OS hands out
227+
// a different port and we can tell the two apart.
228+
assert_ne!(port_1, port_2);
229+
assert!(proxy_is_listening(port_2));
230+
231+
// Teardown is asynchronous (a task inside the proxy's runtime calls
232+
// `stop()`), so poll until the old port refuses connections.
233+
wait_until_proxy_stops(port_1);
234+
}
235+
236+
fn open_help_comm(frontend: &DummyArkFrontend) -> String {
237+
let comm_id = uuid::Uuid::new_v4().to_string();
238+
frontend.send_shell(CommOpen {
239+
comm_id: comm_id.clone(),
240+
target_name: String::from("positron.help"),
241+
data: serde_json::json!({}),
242+
});
243+
frontend.recv_iopub_busy();
244+
frontend.recv_iopub_idle();
245+
comm_id
246+
}
247+
248+
/// Request a help topic and return the proxy port from the `show_help` URL.
249+
fn show_help_and_get_proxy_port(frontend: &DummyArkFrontend, comm_id: &str) -> u16 {
250+
frontend.send_execute_request("?plot", ExecuteRequestOptions::default());
251+
frontend.recv_iopub_busy();
252+
frontend.recv_iopub_execute_input();
253+
254+
let msg = frontend.recv_iopub_comm_msg();
255+
assert_eq!(msg.comm_id, comm_id);
256+
assert_eq!(
257+
msg.data.get("method").and_then(|v| v.as_str()),
258+
Some("show_help")
259+
);
260+
let content = msg.data["params"]["content"].as_str().unwrap();
261+
let port = proxy_port_from_url(content);
262+
263+
frontend.recv_iopub_idle();
264+
frontend.recv_shell_execute_reply();
265+
266+
port
267+
}
268+
269+
/// Pull the port out of a proxy URL like `http://127.0.0.1:<port>/...`.
270+
fn proxy_port_from_url(url: &str) -> u16 {
271+
let after_host = url.strip_prefix("http://127.0.0.1:").unwrap();
272+
let end = after_host.find('/').unwrap();
273+
after_host[..end].parse().unwrap()
274+
}
275+
276+
fn proxy_is_listening(port: u16) -> bool {
277+
TcpStream::connect(("127.0.0.1", port)).is_ok()
278+
}
279+
280+
fn wait_until_proxy_stops(port: u16) {
281+
for _ in 0..100 {
282+
if !proxy_is_listening(port) {
283+
return;
284+
}
285+
std::thread::sleep(Duration::from_millis(50));
286+
}
287+
panic!("Proxy on port {port} is still accepting connections after teardown");
288+
}

0 commit comments

Comments
 (0)