Merge PR #1991: Control port: don't clone the connection's socket (Windows: launched programs kept it open)

apps/lightcraft/src/control_server.rs: serve() keeps #2027's idle read timeout and #1991's single-stream serve_lines (no try_clone); #1991's Windows test passes IDLE_TIMEOUT
This commit is contained in:
aiongg
2026-10-22 02:37:18 +00:00
+37 -2
View File
@@ -77,13 +77,17 @@ const NO_FRAMES_AFTER: Duration = Duration::from_secs(5);
pub const HIDDEN_WINDOW_ERROR: &str = "the app drew no frame for 5 s, so the request was not run: its window is probably not visible \
(on Wayland a window on a hidden workspace or minimized is not redrawn, which pauses the app). Bring the window on screen and send it again";
/// Reads and writes go through the one `stream` (`&TcpStream` is `Read` and `Write`): no
/// `try_clone`. On Windows a cloned socket handle is inheritable, so every program the app starts
/// while a client is connected (a link in the browser, Show in Explorer, an external editor) kept a
/// copy of the connection: closing it then reached the client only when that program exited, as a
/// reset instead of a clean close.
fn serve(stream: TcpStream, tx: &Sender<ControlRequest>, ctx: &egui::Context, idle_timeout: Duration) {
if let Err(e) = stream.set_read_timeout(Some(idle_timeout)) {
log::warn!("control connection closed: could not set idle read timeout: {e}");
return;
}
let Ok(read) = stream.try_clone() else { return };
serve_lines(read, stream, |method, params| {
serve_lines(&stream, &stream, |method, params| {
let (req, rrx) = ControlRequest::new(method, params);
let state = Arc::clone(&req.state);
tx.send(req).ok()?;
@@ -444,6 +448,37 @@ mod tests {
assert!(read_bounded_line(&mut &[b'a'; 11][..], &mut buf, 10).is_err());
}
/// A program the app starts while a client is connected doesn't keep the connection open:
/// when the server closes it, the client sees that at once, not when the program exits.
#[cfg(windows)]
#[test]
fn a_program_started_meanwhile_does_not_hold_the_connection_open() {
let listener = TcpListener::bind(("127.0.0.1", 0)).unwrap();
let addr = listener.local_addr().unwrap();
let (tx, _rx) = channel::<ControlRequest>();
let server = std::thread::spawn(move || {
let (s, _) = listener.accept().unwrap();
serve(s, &tx, &egui::Context::default(), IDLE_TIMEOUT);
});
let mut c = TcpStream::connect(addr).unwrap();
c.set_read_timeout(Some(Duration::from_secs(20))).unwrap();
// the connection is being served: start a program that lives about 4 s
std::thread::sleep(Duration::from_millis(200));
let mut child = std::process::Command::new("cmd").args(["/c", "ping -n 5 127.0.0.1 >nul"]).spawn().unwrap();
std::thread::sleep(Duration::from_millis(200));
// junk makes the server answer once and close
c.write_all(b"hello\n").unwrap();
let t0 = std::time::Instant::now();
let mut text = String::new();
let read = c.read_to_string(&mut text);
let waited = t0.elapsed();
server.join().unwrap();
let _ = child.wait();
assert!(read.is_ok(), "the close must be clean, not a reset: {read:?}");
assert!(text.contains("closing the connection"), "{text}");
assert!(waited < Duration::from_secs(2), "the client saw the close only after {waited:?}, when the program exited");
}
#[test]
fn tcp_connection_is_closed_after_junk() {
// End to end over a real loopback socket (the transport the app uses).