diff --git a/changelog.d/20261013-maintenance-diagnostic-export.md b/changelog.d/20261013-maintenance-diagnostic-export.md new file mode 100644 index 0000000..635b5d7 --- /dev/null +++ b/changelog.d/20261013-maintenance-diagnostic-export.md @@ -0,0 +1,7 @@ +--- +area: manager +type: fixed +audience: admins +title: Diagnostics record blocked startup requests and maintenance owner information +--- +A start refused before the launcher runs is now logged with its reason. Exported diagnostics include the last recorded maintenance owner, while clearly distinguishing old metadata from a currently held operating-system lock. diff --git a/crates/coa-core/src/diag.rs b/crates/coa-core/src/diag.rs index a5ad4e8..c53614d 100644 --- a/crates/coa-core/src/diag.rs +++ b/crates/coa-core/src/diag.rs @@ -347,6 +347,15 @@ pub fn export_package(root: &Path, meta_dir: &Path, manager_log: &Path, meta: &I "wow_client_set": meta.client_path.is_some(), }); add("summary.json", serde_json::to_string_pretty(&summary)?.as_bytes())?; + let owner = fs::read_to_string(meta_dir.join("update-lock-owner.json")).ok() + .and_then(|text| serde_json::from_str::(&redact_json(&text)).ok()); + let maintenance = serde_json::json!({ + "lockFilePresent": meta_dir.join("update.lock").is_file(), + "lastRecordedOwner": owner, + "note": "The lock file and owner record may remain after completion. Their presence does not establish whether the OS lock is currently held." + }); + add("maintenance.json", serde_json::to_string_pretty(&maintenance)?.as_bytes())?; + // Logs. The newest update logs of the repack's own updater are added under their own names. let mut logs: Vec<(String, PathBuf)> = vec![ @@ -599,6 +608,8 @@ real error fs::write(root.join("Core/configs/worldserver.conf"), "LoginDatabaseInfo = \"127.0.0.1;3307;acore;SECRETPW;auth\"\nRate.XP.Kill = 1\n").unwrap(); let meta_dir = d.path().join("srv.manager"); fs::create_dir_all(&meta_dir).unwrap(); + fs::write(meta_dir.join("update.lock"), b"").unwrap(); + fs::write(meta_dir.join("update-lock-owner.json"), r#"{"operation":"checking updates","pid":1234,"started":"2026-10-13T01:00:00Z","token":"owner-secret"}"#).unwrap(); fs::create_dir_all(meta_dir.join("diagnostics")).unwrap(); fs::write(meta_dir.join("diagnostics/schema-validation-CoA-test.json"), r#"{"problems":[{"table":"creature_display_preset","column":"display_id","detail":"default mismatch"}],"password":"schema-secret"}"#).unwrap(); fs::write(meta_dir.join("diagnostics/database-recovery-test.json"), r#"{"objects":[{"database":"acore_characters","kind":"TRIGGER","name":"user_character_trigger"}],"password":"recovery-secret"}"#).unwrap(); @@ -620,6 +631,10 @@ real error assert!(!all.contains("schema-secret")); assert!(all.contains("user_character_trigger") && all.contains("acore_characters")); assert!(!all.contains("recovery-secret")); + assert!(all.contains("checking updates") && all.contains("1234")); + assert!(all.contains("lockFilePresent") && all.contains("does not establish")); + assert!(!all.contains("owner-secret")); + assert_eq!(fs::read(meta_dir.join("update.lock")).unwrap(), b""); assert!(!all.contains("hunter2") && !all.contains("SECRETPW") && !all.contains("upstream-secret")); } } diff --git a/crates/coa-core/src/driver.rs b/crates/coa-core/src/driver.rs index 954f4ef..e741f69 100644 --- a/crates/coa-core/src/driver.rs +++ b/crates/coa-core/src/driver.rs @@ -90,7 +90,12 @@ fn launcher(root: &Path) -> Result<(PathBuf, PathBuf)> { /// Run a launcher verb to completion (blocking; call from a worker thread). pub fn run(root: &Path, verb: Verb) -> Result { - run_inner(root, verb, false) + tracing::info!(?verb, root = %root.display(), "driver: launcher requested"); + let result = run_inner(root, verb, false); + if let Err(error) = &result { + tracing::warn!(?verb, root = %root.display(), %error, "driver: launcher request failed before completion"); + } + result } pub(crate) fn validate_update(root: &Path) -> Result { @@ -287,6 +292,31 @@ pub(crate) fn startup_failure(root: &Path, started: &DriverOutcome) -> String { mod tests { use super::*; + #[test] + fn a_blocked_start_is_logged_before_any_launcher_is_spawned() { + use std::io::Write; + use std::sync::{Arc, Mutex}; + struct Capture(Arc>>); + impl Write for Capture { + fn write(&mut self, bytes: &[u8]) -> std::io::Result { self.0.lock().unwrap().extend_from_slice(bytes); Ok(bytes.len()) } + fn flush(&mut self) -> std::io::Result<()> { Ok(()) } + } + let temp = tempfile::tempdir().unwrap(); + let root = temp.path().join("server"); + std::fs::create_dir(&root).unwrap(); + let metadata = crate::registry::metadata_dir_for(&root).unwrap(); + let _maintenance = crate::update::operation_lock(&metadata).unwrap(); + let buffer = Arc::new(Mutex::new(Vec::new())); + let writer = Arc::clone(&buffer); + let subscriber = tracing_subscriber::fmt().without_time().with_ansi(false).with_writer(move || Capture(Arc::clone(&writer))).finish(); + let error = tracing::subscriber::with_default(subscriber, || run(&root, Verb::StartAll)).unwrap_err(); + assert!(error.to_string().contains("in progress")); + let text = String::from_utf8(buffer.lock().unwrap().clone()).unwrap(); + assert!(text.contains("launcher requested") && text.contains("StartAll")); + assert!(text.contains("launcher request failed before completion") && text.contains("in progress")); + assert!(!text.contains("starting launcher verb")); + } + #[test] fn background_start_disables_cli_in_template_and_rendered_config() { let folder = tempfile::tempdir().unwrap();