From a9fb982ab05ad48e2b2ede0dd8eef66c8c629aaa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=94=D0=BC=D0=B8=D1=82=D1=80=D0=B8=D0=B9?= Date: Tue, 10 Nov 2026 09:38:59 +0500 Subject: [PATCH] Console shutdown request reads the reply before closing send_without_answer closed the socket right after writing "server shutdown". With the reply still unread, Windows resets the connection, and the world can drop the command: a world supervised by coa-launcher.exe ignored the shutdown and the stop timed out after 90 seconds. The reply is now read up to the next prompt, the end of the connection or 10 seconds. The E2E also checks which supervisor runs the world and that manage.py stops a world the Manager started. Co-Authored-By: Claude Opus 5.5 --- .../coa-core/examples/manager_launcher_e2e.rs | 11 +++++++++++ crates/coa-core/src/ra.rs | 18 ++++++++++++++++-- 2 files changed, 27 insertions(+), 2 deletions(-) diff --git a/crates/coa-core/examples/manager_launcher_e2e.rs b/crates/coa-core/examples/manager_launcher_e2e.rs index a9dc9f9..777e4c8 100644 --- a/crates/coa-core/examples/manager_launcher_e2e.rs +++ b/crates/coa-core/examples/manager_launcher_e2e.rs @@ -85,10 +85,21 @@ fn main() { assert_eq!(std::fs::read(&manage_py).unwrap(), original, "manage.py is not rewritten"); assert_eq!(realm_name(root), "AzerothCore", "no profile: the launcher's own realm name"); + let shipped = root.join("Scripts/coa-launcher.exe"); + let supervisor = state::running(root, "supervisor").expect("a supervisor"); + println!("[supervisor] {}", supervisor.exe); + let expected = if shipped.is_file() { "coa-launcher.exe" } else { "python.exe" }; + assert!(supervisor.exe.to_lowercase().ends_with(expected), "the world is supervised by {expected}"); + // 2 assert!(drive(root, Verb::StopAll).ok, "stop-all"); assert!(nothing_running(root), "1-2 left something running"); + // 2b: the repack's Python launcher stops a world the Manager started + assert!(drive(root, Verb::StartAll).ok); + assert!(manage(root, "stop-all").0, "manage.py stops what the Manager started"); + assert!(nothing_running(root), "2b left something running"); + // 3 assert!(manage(root, "start-all").0); assert!(drive(root, Verb::StopAll).ok, "driver stops what manage.py started"); diff --git a/crates/coa-core/src/ra.rs b/crates/coa-core/src/ra.rs index 5764488..cad0bcd 100644 --- a/crates/coa-core/src/ra.rs +++ b/crates/coa-core/src/ra.rs @@ -88,12 +88,26 @@ impl Ra { self.command(c) } - /// Send a command the server does not answer before it closes the console (`server shutdown`), and return at - /// once. + /// Send a command whose answer does not matter (`server shutdown`). The reply is still read (up to the next + /// prompt, the end of the connection, or a few seconds): closing a socket that holds unread data resets the + /// connection, and the server can then drop the command it has not processed yet. pub fn send_without_answer(mut self, cmd: &str) -> Result<()> { let c = crate::console::check_command(cmd)?; self.stream.write_all(format!("{c}\r\n").as_bytes())?; self.stream.flush()?; + self.stream.set_read_timeout(Some(Duration::from_secs(10)))?; + let mut seen = Vec::new(); + let mut buf = [0u8; 4096]; + while let Ok(n) = self.stream.read(&mut buf) { + if n == 0 { + break; + } + seen.extend_from_slice(&buf[..n]); + if seen.ends_with(b"AC>") || seen.len() > 1 << 20 { + break; + } + } + let _ = self.stream.shutdown(std::net::Shutdown::Both); Ok(()) }