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 <noreply@anthropic.com>
This commit is contained in:
ДмитрийandClaude Sonnet 5.5 committed 2026-09-30 07:04:59 +05:00
1 parent e1b8267022
commit bbf06793ba
6 files changed
+306

No files matched your search

+16
View File
@@ -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 <server folder> <start|stop>");
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());
}
+152
View File
@@ -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 <verb>`), 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<i32>,
/// Human-facing classification of a failure.
pub code: Option<ErrorCode>,
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<DriverOutcome> {
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());
}
}
+12
View File
@@ -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.",
+85
View File
@@ -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<ErrorCode> {
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<ErrorCode> {
["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), "");
}
}
+2
View File
@@ -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;
+39
View File
@@ -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)"