mirror of
https://github.com/Corfirean/coa-server-manager.git
synced 2026-11-11 03:51:01 +00:00
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:
1 parent
e1b8267022
commit
bbf06793ba
6 files changed
+306
No files matched your search
@@ -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());
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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.",
|
||||
|
||||
@@ -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), "");
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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)"
|
||||
Reference in new issue
Block a user