From bbf06793ba3e3fcf20fe1d0d74be3f8278cf0642 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: Wed, 30 Sep 2026 07:04:59 +0500 Subject: [PATCH] Phase 2b: launcher delegate driver, log-based failure diagnosis, fixture builder Verified on a disposable fixture: start reports world_db_incompatible for a pristine DB with a newer binary; stop is graceful; live install untouched. Co-Authored-By: Claude Sonnet 5.5 --- crates/coa-core/examples/drive.rs | 16 ++++ crates/coa-core/src/driver.rs | 152 ++++++++++++++++++++++++++++++ crates/coa-core/src/error.rs | 12 +++ crates/coa-core/src/health.rs | 85 +++++++++++++++++ crates/coa-core/src/lib.rs | 2 + tools/make-fixture.ps1 | 39 ++++++++ 6 files changed, 306 insertions(+) create mode 100644 crates/coa-core/examples/drive.rs create mode 100644 crates/coa-core/src/driver.rs create mode 100644 crates/coa-core/src/health.rs create mode 100644 tools/make-fixture.ps1 diff --git a/crates/coa-core/examples/drive.rs b/crates/coa-core/examples/drive.rs new file mode 100644 index 0000000..71c77d5 --- /dev/null +++ b/crates/coa-core/examples/drive.rs @@ -0,0 +1,16 @@ +use std::path::Path; + +fn main() { + let mut args = std::env::args().skip(1); + let root = args.next().expect("usage: drive "); + let verb = match args.next().as_deref() { + Some("start") => coa_core::driver::Verb::StartAll, + Some("stop") => coa_core::driver::Verb::StopAll, + _ => panic!("verb must be start or stop"), + }; + let outcome = coa_core::driver::run(Path::new(&root), verb).expect("driver failed"); + println!("{}", serde_json::to_string_pretty(&outcome).unwrap()); + let report = coa_core::layout::scan(Path::new(&root)).unwrap(); + let observed = coa_core::process::observe(Path::new(&root), &report.ports); + println!("{}", serde_json::to_string_pretty(&observed).unwrap()); +} diff --git a/crates/coa-core/src/driver.rs b/crates/coa-core/src/driver.rs new file mode 100644 index 0000000..1d49e72 --- /dev/null +++ b/crates/coa-core/src/driver.rs @@ -0,0 +1,152 @@ +//! Start/stop of a repack-shaped installation by delegating to the repack's own supervisor +//! (`Runtime\python\python.exe Scripts\manage.py `), which already implements config rendering, the +//! bug-report relay, RA graceful shutdown and readiness. Observation stays native (see `process`). + +use std::io::Read; +use std::path::{Path, PathBuf}; +use std::process::{Command, Stdio}; +use std::time::{Duration, Instant}; + +use serde::Serialize; + +use crate::error::{Error, ErrorCode, Result}; +use crate::fsx; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "kebab-case")] +pub enum Verb { + StartAll, + StopAll, +} + +impl Verb { + fn arg(self) -> &'static str { + match self { + Verb::StartAll => "start-all", + Verb::StopAll => "stop-all", + } + } + + fn timeout(self) -> Duration { + match self { + Verb::StartAll => Duration::from_secs(420), + Verb::StopAll => Duration::from_secs(240), + } + } +} + +#[derive(Debug, Clone, Serialize)] +pub struct DriverOutcome { + pub ok: bool, + pub exit_code: Option, + /// Human-facing classification of a failure. + pub code: Option, + pub output: String, +} + +/// Map the launcher's own messages onto stable error codes. Unknown text stays `Unknown` (details remain visible). +pub fn translate(output: &str) -> ErrorCode { + let o = output.to_lowercase(); + if o.contains("is already used") { + ErrorCode::PortInUse + } else if o.contains("another start/stop action is in progress") || o.contains("shutdown is still finishing") { + ErrorCode::OperationInProgress + } else if o.contains("packaged database is missing") || o.contains("extract the complete repack") { + ErrorCode::ServerFilesIncomplete + } else if o.contains("stopped during startup") || o.contains("is still starting") || o.contains("world startup failed") { + ErrorCode::StartupFailed + } else if o.contains("invalid port") || o.contains("unresolved placeholder") { + ErrorCode::InvalidConfigValue + } else { + ErrorCode::Unknown + } +} + +fn launcher(root: &Path) -> Result<(PathBuf, PathBuf)> { + let python = root.join("Runtime/python/python.exe"); + let script = root.join("Scripts/manage.py"); + if !python.is_file() || !script.is_file() { + return Err(Error::Invalid( + "This server has no CoA Repack launcher; start/stop is not available for it yet.".into(), + )); + } + Ok((python, script)) +} + +/// Run a launcher verb to completion (blocking; call from a worker thread). +pub fn run(root: &Path, verb: Verb) -> Result { + let root = fsx::canonicalize_lenient(root)?; + let (python, script) = launcher(&root)?; + let mut cmd = Command::new(&python); + cmd.arg("-B").arg(&script).arg(verb.arg()).current_dir(&root).stdin(Stdio::null()).stdout(Stdio::piped()).stderr(Stdio::piped()); + #[cfg(windows)] + { + use std::os::windows::process::CommandExt; + cmd.creation_flags(0x0800_0000); // CREATE_NO_WINDOW + } + tracing::info!(?verb, root = %root.display(), "driver: starting launcher verb"); + let mut child = cmd.spawn()?; + let mut stdout = child.stdout.take().expect("piped"); + let mut stderr = child.stderr.take().expect("piped"); + let out_t = std::thread::spawn(move || { + let mut s = String::new(); + let _ = stdout.read_to_string(&mut s); + s + }); + let err_t = std::thread::spawn(move || { + let mut s = String::new(); + let _ = stderr.read_to_string(&mut s); + s + }); + let deadline = Instant::now() + verb.timeout(); + let status = loop { + if let Some(st) = child.try_wait()? { + break Some(st); + } + if Instant::now() > deadline { + // Never kill the servers themselves: only abandon the launcher process we spawned. + let _ = child.kill(); + break None; + } + std::thread::sleep(Duration::from_millis(200)); + }; + let mut output = out_t.join().unwrap_or_default(); + output.push_str(&err_t.join().unwrap_or_default()); + let output = output.trim().to_string(); + let (ok, exit_code) = match status { + Some(st) => (st.success(), st.code()), + None => (false, None), + }; + let code = if ok { + None + } else { + // Log evidence beats the launcher's generic "stopped during startup" message. + let base = if status.is_none() { ErrorCode::StartupFailed } else { translate(&output) }; + match base { + ErrorCode::StartupFailed | ErrorCode::Unknown => Some(crate::health::diagnose_installation(&root).unwrap_or(base)), + other => Some(other), + } + }; + tracing::info!(?verb, ok, ?code, "driver: launcher verb finished"); + Ok(DriverOutcome { ok, exit_code, code, output }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn translates_known_launcher_messages() { + assert_eq!(translate("RuntimeError: Port 8085 is already used. Stop the other server"), ErrorCode::PortInUse); + assert_eq!(translate("Another start/stop action is in progress."), ErrorCode::OperationInProgress); + assert_eq!(translate("The packaged database is missing. Extract the complete repack."), ErrorCode::ServerFilesIncomplete); + assert_eq!(translate("world stopped during startup. Check its log."), ErrorCode::StartupFailed); + assert_eq!(translate("Exit code -1073741819"), ErrorCode::Unknown); + } + + #[test] + fn refuses_folder_without_launcher() { + let dir = tempfile::tempdir().unwrap(); + assert!(run(dir.path(), Verb::StartAll).is_err()); + } +} diff --git a/crates/coa-core/src/error.rs b/crates/coa-core/src/error.rs index b671e20..9a64b02 100644 --- a/crates/coa-core/src/error.rs +++ b/crates/coa-core/src/error.rs @@ -33,6 +33,8 @@ pub enum ErrorCode { WorldserverAlreadyRunning, InvalidConfigValue, DiskFull, + OperationInProgress, + StartupFailed, HashMismatch, PathRejected, Unknown, @@ -95,6 +97,16 @@ impl ErrorCode { "Free up some space or choose another drive, then try again.", &[ChooseAnotherFolder, Retry], ), + ErrorCode::OperationInProgress => ( + "Another action is still in progress", + "The server is busy starting or stopping. Wait a moment and try again.", + &[Retry], + ), + ErrorCode::StartupFailed => ( + "Server could not start", + "A service stopped while starting. Check the details for the cause.", + &[FixAutomatically, ShowDetails], + ), ErrorCode::HashMismatch => ( "A downloaded file is damaged", "The file did not match its checksum and was not used.", diff --git a/crates/coa-core/src/health.rs b/crates/coa-core/src/health.rs new file mode 100644 index 0000000..65f608c --- /dev/null +++ b/crates/coa-core/src/health.rs @@ -0,0 +1,85 @@ +//! Turn worldserver/authserver log output into a stable cause, so users never see raw exit codes. + +use std::fs::File; +use std::io::{Read, Seek, SeekFrom}; +use std::path::Path; + +use crate::error::ErrorCode; + +/// Best-effort cause of a failed startup from log text. Later lines win over earlier ones. +pub fn diagnose(text: &str) -> Option { + let mut found = None; + for line in text.lines() { + let l = line.to_lowercase(); + let hit = if l.contains("database structure is not up to date") + || (l.contains("table '") && l.contains("doesn't exist") && l.contains("[1146]")) + || l.contains("could not prepare statements") + { + Some(ErrorCode::WorldDbIncompatible) + } else if l.contains("can't connect to mysql") + || l.contains("can't connect to server on") + || l.contains("[2003]") + || l.contains("[2002]") + || l.contains("connection refused") + { + Some(ErrorCode::DatabaseNotRunning) + } else if l.contains("address already in use") || l.contains("could not bind") || l.contains("only one usage of each socket") { + Some(ErrorCode::PortInUse) + } else if l.contains("config::loadfile") && l.contains("failed open file") && l.contains("worldserver.conf") { + Some(ErrorCode::ServerFilesIncomplete) + } else { + None + }; + if hit.is_some() { + found = hit; + } + } + found +} + +pub fn tail(path: &Path, max_bytes: u64) -> String { + let Ok(mut f) = File::open(path) else { return String::new() }; + let Ok(len) = f.metadata().map(|m| m.len()) else { return String::new() }; + let _ = f.seek(SeekFrom::Start(len.saturating_sub(max_bytes))); + let mut buf = Vec::new(); + let _ = f.take(max_bytes).read_to_end(&mut buf); + String::from_utf8_lossy(&buf).into_owned() +} + +/// Diagnose from the repack's own service logs (only the recent tail of each). +pub fn diagnose_installation(root: &Path) -> Option { + ["Core/Logs/world-console.log", "Core/Logs/auth-console.log", "mysql/logs/mysql-error.log"] + .iter() + .find_map(|rel| diagnose(&tail(&root.join(rel), 64 * 1024))) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn recognises_missing_migrations() { + let log = "ERROR [sql.sql] [1146] Table 'acore_world.coa_boss' doesn't exist\nFATAL [sql.sql] Your database structure is not up to date."; + assert_eq!(diagnose(log), Some(ErrorCode::WorldDbIncompatible)); + } + + #[test] + fn recognises_db_down_and_port_conflicts() { + assert_eq!(diagnose("ERROR Can't connect to MySQL server on '127.0.0.1:3307'"), Some(ErrorCode::DatabaseNotRunning)); + assert_eq!(diagnose("Could not bind to port 8085"), Some(ErrorCode::PortInUse)); + assert_eq!(diagnose("INFO all good"), None); + } + + #[test] + fn tail_reads_only_the_end_of_huge_files() { + let dir = tempfile::tempdir().unwrap(); + let p = dir.path().join("big.log"); + let mut s = "x".repeat(200_000); + s.push_str("\nFATAL Your database structure is not up to date\n"); + std::fs::write(&p, s).unwrap(); + let t = tail(&p, 1000); + assert!(t.len() <= 1000); + assert_eq!(diagnose(&t), Some(ErrorCode::WorldDbIncompatible)); + assert_eq!(tail(&dir.path().join("missing"), 10), ""); + } +} diff --git a/crates/coa-core/src/lib.rs b/crates/coa-core/src/lib.rs index a834e1a..5527909 100644 --- a/crates/coa-core/src/lib.rs +++ b/crates/coa-core/src/lib.rs @@ -1,5 +1,7 @@ +pub mod driver; pub mod error; pub mod fsx; +pub mod health; pub mod layout; pub mod logging; pub mod process; diff --git a/tools/make-fixture.ps1 b/tools/make-fixture.ps1 new file mode 100644 index 0000000..1b40366 --- /dev/null +++ b/tools/make-fixture.ps1 @@ -0,0 +1,39 @@ +# Builds a disposable copy of a CoA Repack on different ports for start/stop testing. +# NEVER writes to the source repack. Data\ is a junction to the source (read-only use by the server). +param( + [string]$Source = 'C:\games\CoA-Repack', + [string]$Target = 'C:\games\coa-fixture', + [int]$PortBase = 13000 +) +$ErrorActionPreference = 'Stop' +if ((Resolve-Path $Source).Path -eq $Target) { throw 'source and target are the same' } +if (Test-Path $Target) { throw "$Target already exists; delete it first (fixtures are disposable)" } +New-Item -ItemType Directory $Target | Out-Null + +function Copy-Tree($rel, [string[]]$xd = @(), [string[]]$xf = @()) { + $args = @("$Source\$rel", "$Target\$rel", '/E', '/R:1', '/W:1', '/NFL', '/NDL', '/NJH', '/NJS', '/NP') + if ($xd) { $args += '/XD'; $args += $xd } + if ($xf) { $args += '/XF'; $args += $xf } + & robocopy @args | Out-Null + if ($LASTEXITCODE -ge 8) { throw "robocopy failed for $rel ($LASTEXITCODE)" } +} + +Copy-Tree 'Core' -xd @('Logs', 'Crashes') -xf @('*.pre-*', '*.bak*', '*.orig', '*.pdb', '*.log', '*.prev_*') +Copy-Tree 'Settings' +Copy-Tree 'Scripts' +Copy-Tree 'Runtime' +Copy-Tree 'BugReport' -xd @('Logs', 'reports') +foreach ($d in 'bin', 'lib', 'share') { Copy-Tree "mysql\$d" } +Copy-Item "$Source\RELEASE.json", "$Source\MANIFEST.json" $Target + +# Pristine packaged database (never the live data directory). +& 'C:\Program Files\7-Zip\7z.exe' x "$Source\mysql\data.7z" "-o$Target\mysql" -y | Out-Null +if (-not (Test-Path "$Target\mysql\data\acore_characters")) { throw 'database extraction failed' } + +New-Item -ItemType Junction -Path "$Target\Data" -Target "$Source\Data" | Out-Null + +$ports = @{ mysqlPort = $PortBase + 307; authPort = $PortBase + 724; worldPort = $PortBase + 85; raPort = $PortBase + 443 } +$cfg = Get-Content "$Target\Settings\repack.json" -Raw | ConvertFrom-Json +foreach ($k in $ports.Keys) { $cfg.$k = $ports[$k] } +$cfg | ConvertTo-Json | Set-Content "$Target\Settings\repack.json" -Encoding utf8 +"fixture ready at $Target ports: $($ports | ConvertTo-Json -Compress)"