From ce83f04d9d21805c0099d946a601200ad5a88b37 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, 28 Oct 2026 22:17:26 +0500 Subject: [PATCH 1/2] Prepare Manager 0.9.2 with database startup and configuration fixes --- .github/workflows/check.yml | 2 + .github/workflows/release.yml | 4 +- Cargo.lock | 6 +- Cargo.toml | 2 +- changelog.d/20261028-coa-config-search.md | 7 + changelog.d/20261028-mysql-install-startup.md | 7 + changelog.d/20261028-update-check-queue.md | 7 + crates/coa-core/src/allsettings.rs | 268 ++++++++++++++---- crates/coa-core/src/driver.rs | 12 +- crates/coa-core/src/install.rs | 67 ++++- crates/coa-core/src/launcher_bridge.py | 22 ++ package-lock.json | 4 +- package.json | 2 +- src-tauri/tauri.conf.json | 2 +- src/data/latest-note-translations.json | 25 +- src/lib/api.ts | 8 +- src/lib/updateCheckQueue.ts | 19 ++ src/screens/SettingsPage.tsx | 2 +- tools/overview-update-error.test.mjs | 4 + tools/test_launcher_bridge.py | 51 ++++ tools/update-check-queue.test.mjs | 32 +++ 21 files changed, 470 insertions(+), 83 deletions(-) create mode 100644 changelog.d/20261028-coa-config-search.md create mode 100644 changelog.d/20261028-mysql-install-startup.md create mode 100644 changelog.d/20261028-update-check-queue.md create mode 100644 crates/coa-core/src/launcher_bridge.py create mode 100644 src/lib/updateCheckQueue.ts create mode 100644 tools/test_launcher_bridge.py create mode 100644 tools/update-check-queue.test.mjs diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml index 0f0270c..20d4693 100644 --- a/.github/workflows/check.yml +++ b/.github/workflows/check.yml @@ -25,4 +25,6 @@ jobs: - run: npm ci - run: node tools/check-i18n.mjs - run: npm run build + - run: node --experimental-strip-types --test tools/*.test.mjs + - run: python -B -m unittest discover -s tools -p test_launcher_bridge.py - run: cargo test --workspace --locked diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index a9881fc..62e5c33 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -70,7 +70,9 @@ jobs: node-version: 22 - run: npm ci - name: Tests - run: cargo test --workspace -- --test-threads=1 + run: cargo test --workspace --locked -- --test-threads=1 + - run: node --experimental-strip-types --test tools/*.test.mjs + - run: python -B -m unittest discover -s tools -p test_launcher_bridge.py - name: Build and sign (publish only when requested) id: tauri uses: tauri-apps/tauri-action@v0 diff --git a/Cargo.lock b/Cargo.lock index 8e17467..58152cc 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -586,7 +586,7 @@ dependencies = [ [[package]] name = "coa-core" -version = "0.9.1" +version = "0.9.2" dependencies = [ "base64 0.22.1", "chrono", @@ -634,7 +634,7 @@ dependencies = [ [[package]] name = "coa-release" -version = "0.9.1" +version = "0.9.2" dependencies = [ "chrono", "coa-core", @@ -643,7 +643,7 @@ dependencies = [ [[package]] name = "coa-server-manager" -version = "0.9.1" +version = "0.9.2" dependencies = [ "coa-control-proto", "coa-core", diff --git a/Cargo.toml b/Cargo.toml index cfdf328..6c954be 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,7 +4,7 @@ members = ["crates/coa-core", "crates/coa-release", "crates/coa-registry-proto", exclude = ["services/coa-registry", "services/coa-registry-e2e", "services/coa-coordinator", "services/coa-relay"] [workspace.package] -version = "0.9.1" +version = "0.9.2" edition = "2021" license = "AGPL-3.0-only" diff --git a/changelog.d/20261028-coa-config-search.md b/changelog.d/20261028-coa-config-search.md new file mode 100644 index 0000000..6ebd436 --- /dev/null +++ b/changelog.d/20261028-coa-config-search.md @@ -0,0 +1,7 @@ +--- +area: manager +type: added +audience: admins +title: Find and edit CoA options in the existing server settings +--- +Search includes the separate CoA configuration, including legacy mount flight. Changes go to the correct file; realm profile controls stay managed by the Manager. diff --git a/changelog.d/20261028-mysql-install-startup.md b/changelog.d/20261028-mysql-install-startup.md new file mode 100644 index 0000000..c866c8a --- /dev/null +++ b/changelog.d/20261028-mysql-install-startup.md @@ -0,0 +1,7 @@ +--- +area: manager +type: fixed +audience: admins +title: New server installation waits for slower database startup +--- +Allow up to five minutes for MySQL to become ready. Failed installations stop their temporary database before cleaning up its files. diff --git a/changelog.d/20261028-update-check-queue.md b/changelog.d/20261028-update-check-queue.md new file mode 100644 index 0000000..992ccf6 --- /dev/null +++ b/changelog.d/20261028-update-check-queue.md @@ -0,0 +1,7 @@ +--- +area: manager +type: fixed +audience: admins +title: Simultaneous update checks share results or wait their turn +--- +Repeated checks no longer compete with another foreground or background check in the same Manager window. Maintenance and recovery locks remain enforced. diff --git a/crates/coa-core/src/allsettings.rs b/crates/coa-core/src/allsettings.rs index f9ab932..865d9b4 100644 --- a/crates/coa-core/src/allsettings.rs +++ b/crates/coa-core/src/allsettings.rs @@ -1,5 +1,5 @@ //! Every setting of the world server's configuration, for people who want more than the curated list: the documented -//! settings come from `worldserver.conf.dist` (the file that ships with the server and explains each one), the current +//! settings come from `worldserver.conf.dist` and `modules/coa.conf.dist`, the current //! values from the configuration the next start will use. Anything that holds a password, a port, an address, a folder or //! the database connection is left out; the Manager owns those. @@ -10,7 +10,7 @@ use std::path::Path; use serde::Serialize; use crate::config::parser::ConfFile; -use crate::config::{take_snapshot, targets, Scope}; +use crate::config::{take_snapshot, targets, Scope, Targets}; use crate::error::{Error, Result}; use crate::fsx; @@ -39,6 +39,14 @@ fn hidden(key: &str) -> bool { || k.starts_with("logindatabase.") || k.starts_with("worlddatabase.") || k.starts_with("characterdatabase.") + || [ + "coa.classmodel", + "coa.realmtype", + "coa.gamemodemask", + "coa.mapclass10towarrior", + "coa.clientbooleanconfigs", + ] + .contains(&k.as_str()) || [ "bindip", "realmid", @@ -121,52 +129,85 @@ fn dist_path(root: &Path) -> std::path::PathBuf { root.join("Core/configs/worldserver.conf.dist") } +fn sources(root: &Path) -> Result> { + let mut result = vec![(dist_path(root), targets(root, Scope::Server)?)]; + let dist = root.join("Core/configs/modules/coa.conf.dist"); + if dist.is_file() { + let active = root.join("Core/configs/modules/coa.conf"); + result.push(( + dist, + Targets { + read: active.clone(), + writes: vec![active], + generated: None, + }, + )); + } + Ok(result) +} + pub fn list(root: &Path) -> Result> { - let dist = fs::read_to_string(dist_path(root)).map_err(|_| { - Error::Invalid( - "The documented settings file (worldserver.conf.dist) was not found on this server." - .into(), - ) - })?; - let t = targets(root, Scope::Server)?; - let active = ConfFile::parse_bytes(&fs::read(&t.read)?)?; - Ok(parse_dist(&dist) - .into_iter() - .filter(|(k, _, _)| !hidden(k)) - .map(|(key, default, doc)| { - let value = active - .get(&key) - .map(|v| v.trim().to_string()) - .unwrap_or_else(|| default.clone()); - let changed = value != default; - Item { - key, - value, - default, - doc, - changed, - } - }) - .collect()) + let mut items = Vec::new(); + for (dist_path, t) in sources(root)? { + let dist = fs::read_to_string(&dist_path)?; + let active = ConfFile::parse_bytes(&if t.read.exists() { + fs::read(&t.read)? + } else { + dist.as_bytes().to_vec() + })?; + items.extend( + parse_dist(&dist) + .into_iter() + .filter(|(k, _, _)| !hidden(k)) + .map(|(key, default, doc)| { + let value = active + .get(&key) + .map(|v| v.trim().to_string()) + .unwrap_or_else(|| default.clone()); + let changed = value != default; + Item { + key, + value, + default, + doc, + changed, + } + }) + .collect::>(), + ); + } + Ok(items) } /// Change settings that exist in the documented file. A value is one line of at most 500 characters. Returns the keys /// that actually changed; the previous files are snapshotted first (and can be restored from Settings). pub fn save(root: &Path, meta: &Path, changes: &BTreeMap) -> Result> { - let known: BTreeMap = - parse_dist(&fs::read_to_string(dist_path(root)).map_err(|_| { - Error::Invalid("worldserver.conf.dist was not found on this server.".into()) - })?) - .into_iter() - .map(|(k, d, _)| (k, d)) - .collect(); - let t = targets(root, Scope::Server)?; + let _lock = crate::update::operation_lock(meta)?; + crate::update::ensure_recovered(meta)?; + let sources = sources(root)?; + let mut known = BTreeMap::new(); let mut originals: Vec<(std::path::PathBuf, Vec)> = Vec::new(); let mut confs: Vec = Vec::new(); - for p in &t.writes { - let bytes = fs::read(p)?; - confs.push(ConfFile::parse_bytes(&bytes)?); - originals.push((p.clone(), bytes)); + let mut owners = Vec::new(); + for (index, (dist, t)) in sources.iter().enumerate() { + let defaults = fs::read(dist)?; + for (key, default, _) in parse_dist(&String::from_utf8_lossy(&defaults)) { + if known.insert(key.clone(), (default, index)).is_some() { + return Err(Error::Invalid(format!( + "{key} is documented in more than one configuration file." + ))); + } + } + for p in &t.writes { + let bytes = if p.exists() { + fs::read(p)? + } else { + defaults.clone() + }; + confs.push(ConfFile::parse_bytes(&bytes)?); + originals.push((p.clone(), bytes)); + owners.push(index); + } } let mut changed = Vec::new(); for (key, value) in changes { @@ -181,10 +222,22 @@ pub fn save(root: &Path, meta: &Path, changes: &BTreeMap) -> Res "The value of {key} must be one line of text." ))); } - let same = confs.iter().all(|c| match c.get(key) { - Some(v) => v.trim() == value, - None => known[key] == value, - }); + let (default, owner) = &known[key]; + if default.parse::().is_ok() && !value.parse::().is_ok_and(f64::is_finite) + || key == "CoA.LegacyMountFlight" && !matches!(value, "0" | "1") + { + return Err(Error::Invalid(format!( + "The value of {key} must be a number matching its documented default." + ))); + } + let same = confs + .iter() + .zip(&owners) + .filter(|(_, index)| *index == owner) + .all(|(c, _)| match c.get(key) { + Some(v) => v.trim() == value, + None => default == value, + }); if !same { changed.push((key.clone(), value.to_string())); } @@ -192,20 +245,31 @@ pub fn save(root: &Path, meta: &Path, changes: &BTreeMap) -> Res if changed.is_empty() { return Ok(Vec::new()); } - for c in &mut confs { + for (c, owner) in confs.iter_mut().zip(&owners) { for (k, v) in &changed { - c.set(k, v, &["Added by CoA Server Manager"]); + if known[k].1 == *owner { + c.set(k, v, &["Added by CoA Server Manager"]); + } } } let texts: Vec = confs.iter().map(ConfFile::to_text).collect(); - for text in &texts { + for (text, owner) in texts.iter().zip(&owners) { let reread = ConfFile::parse(text); for (k, v) in &changed { - if reread.get(k).map(str::trim) != Some(v.as_str()) { + if known[k].1 == *owner && reread.get(k).map(str::trim) != Some(v.as_str()) { return Err(Error::Invalid(format!("internal check failed for {k}"))); } } } + let affected: std::collections::BTreeSet<_> = + changed.iter().map(|(key, _)| known[key].1).collect(); + let (originals, texts): (Vec<_>, Vec<_>) = originals + .into_iter() + .zip(texts) + .zip(owners) + .filter(|(_, owner)| affected.contains(owner)) + .map(|(pair, _)| pair) + .unzip(); take_snapshot( meta, Scope::Server, @@ -232,6 +296,114 @@ pub fn save(root: &Path, meta: &Path, changes: &BTreeMap) -> Res mod tests { use super::*; + #[test] + fn unrelated_saves_do_not_create_coa_files_and_existing_realm_values_are_preserved() { + let d = tempfile::tempdir().unwrap(); + let (root, meta) = server(d.path()); + let module = root.join("Core/configs/modules"); + fs::create_dir_all(&module).unwrap(); + fs::write( + module.join("coa.conf.dist"), + "CoA.LegacyMountFlight = 0\nCoA.ClassModel = \"coa\"\n", + ) + .unwrap(); + save( + &root, + &meta, + &BTreeMap::from([("Rate.Drop.Item.Rare".into(), "1.5".into())]), + ) + .unwrap(); + assert!(!module.join("coa.conf").exists()); + fs::write( + module.join("coa.conf"), + "# Reborn realm\nCoA.ClassModel = \"wcr\"\nCustom.Preserved = 42\n", + ) + .unwrap(); + assert!(save( + &root, + &meta, + &BTreeMap::from([("CoA.ClassModel".into(), "\"coa\"".into())]) + ) + .is_err()); + save( + &root, + &meta, + &BTreeMap::from([("CoA.LegacyMountFlight".into(), "1".into())]), + ) + .unwrap(); + let actual = fs::read_to_string(module.join("coa.conf")).unwrap(); + assert!( + actual.contains("CoA.ClassModel = \"wcr\"") && actual.contains("Custom.Preserved = 42") + ); + assert!(actual.contains("# Reborn realm")); + } + + #[test] + fn coa_settings_are_found_saved_to_the_module_and_snapshotted_without_profile_overrides() { + let d = tempfile::tempdir().unwrap(); + let (root, meta) = server(d.path()); + let module = root.join("Core/configs/modules"); + fs::create_dir_all(&module).unwrap(); + let defaults = "[worldserver]\n# Mount flight rules\nCoA.LegacyMountFlight = 0\nCoA.ClassModel = \"coa\"\nCoA.RealmType = \"live\"\n"; + fs::write(module.join("coa.conf.dist"), defaults).unwrap(); + let original_world = fs::read(root.join("Core/configs/worldserver.conf")).unwrap(); + let items = list(&root).unwrap(); + assert!(items + .iter() + .any(|i| i.key == "CoA.LegacyMountFlight" && i.value == "0")); + assert!(!items + .iter() + .any(|i| i.key == "CoA.ClassModel" || i.key == "CoA.RealmType")); + for value in ["2", "invalid", "1\nInjected = 1"] { + assert!(save( + &root, + &meta, + &BTreeMap::from([("CoA.LegacyMountFlight".into(), value.into())]) + ) + .is_err()); + } + assert!(!module.join("coa.conf").exists()); + save( + &root, + &meta, + &BTreeMap::from([ + ("CoA.LegacyMountFlight".into(), "1".into()), + ("Rate.Drop.Item.Rare".into(), "1.5".into()), + ]), + ) + .unwrap(); + assert!(fs::read_to_string(module.join("coa.conf")) + .unwrap() + .contains("CoA.LegacyMountFlight = 1")); + let world = fs::read_to_string(root.join("Core/configs/worldserver.conf")).unwrap(); + assert!(!world.contains("CoA.LegacyMountFlight")); + assert!(world.contains("Rate.Drop.Item.Rare = 1.5")); + assert!(!String::from_utf8(original_world) + .unwrap() + .contains("CoA.LegacyMountFlight")); + assert_eq!( + list(&root) + .unwrap() + .iter() + .find(|i| i.key == "CoA.LegacyMountFlight") + .unwrap() + .value, + "1" + ); + assert!(!crate::config::list_snapshots(&meta).is_empty()); + let saved = crate::config::list_snapshots(&meta).remove(0); + crate::config::restore_snapshot(&meta, &saved.id).unwrap(); + assert_eq!( + list(&root) + .unwrap() + .iter() + .find(|i| i.key == "CoA.LegacyMountFlight") + .unwrap() + .value, + "0" + ); + } + const DIST: &str = "[worldserver]\r\n\r\n###################\r\n# SECTION\r\n###################\r\n\r\n#\r\n# Rate.Drop.Item.Epic\r\n# Rate.Drop.Item.Rare\r\n# Description: Drop rates by quality.\r\n# Default: 1\r\n#\r\n\r\nRate.Drop.Item.Rare = 1\r\nRate.Drop.Item.Epic = 1\r\n\r\n#\r\n# LoginDatabaseInfo\r\n# Description: secret\r\n#\r\n\r\nLoginDatabaseInfo = \"127.0.0.1;3306;a;b;c\"\r\n\r\n#\r\n# Instance.ResetTimeHour\r\n# Description: Hour of the reset.\r\n# Important: Use 0-23.\r\n# Default: 4\r\n#\r\n\r\nInstance.ResetTimeHour = 4\r\n"; fn server(d: &Path) -> (std::path::PathBuf, std::path::PathBuf) { diff --git a/crates/coa-core/src/driver.rs b/crates/coa-core/src/driver.rs index 94aee93..6eff427 100644 --- a/crates/coa-core/src/driver.rs +++ b/crates/coa-core/src/driver.rs @@ -34,8 +34,8 @@ impl Verb { fn timeout(self, root: &Path) -> Duration { match self { Verb::StartAll | Verb::StartWorld if squid_enabled(root) => Duration::from_secs(2700), - Verb::StartAll | Verb::StartWorld => Duration::from_secs(420), - Verb::StartMysql => Duration::from_secs(120), + Verb::StartAll | Verb::StartWorld => Duration::from_secs(720), + Verb::StartMysql => Duration::from_secs(360), Verb::StopAll => Duration::from_secs(240), } } @@ -393,7 +393,7 @@ fn recover_mysql_launcher_state(root: &Path, prefer_listener: bool) -> Result<() Ok(()) } -const LAUNCH_SCRIPT: &str = "import runpy,sys\nfrom pathlib import Path\nscript=sys.argv[1]\nsys.path.insert(0,str(Path(script).resolve().parent))\nsys.argv=sys.argv[1:]\nif sys.argv[1]=='stop-all':\n launcher=runpy.run_path(script,run_name='coa_manager_shutdown')\n config=launcher['settings']()\n with launcher['control_lock']():\n launcher['stop_all'](config)\nelse:\n runpy.run_path(script,run_name='__main__')"; +const LAUNCH_SCRIPT: &str = include_str!("launcher_bridge.py"); pub(crate) fn patch_launcher_imports(text: &str) -> Result { let line = "sys.path.insert(0, str(Path(__file__).resolve().parent))"; @@ -624,7 +624,11 @@ mod tests { std::fs::write(modules.join("playerbots.conf"), "AiPlayerbot.Enabled = 0\n").unwrap(); assert_eq!( Verb::StartWorld.timeout(folder.path()), - Duration::from_secs(420) + Duration::from_secs(720) + ); + assert_eq!( + Verb::StartMysql.timeout(folder.path()), + Duration::from_secs(360) ); } diff --git a/crates/coa-core/src/install.rs b/crates/coa-core/src/install.rs index 4b43da8..f027005 100644 --- a/crates/coa-core/src/install.rs +++ b/crates/coa-core/src/install.rs @@ -239,14 +239,14 @@ fn bootstrap_database_inner(root: &Path) -> Result<()> { } fs::copy(&boot, &target)?; // launcher needs database.json to render its files at first start - let started = driver::run(root, Verb::StartMysql)?; - if !started.ok { - return Err(Error::Invalid(format!( - "The database could not be started. {}", - driver::startup_failure(root, &started) - ))); - } let result = (|| -> Result<()> { + let started = driver::run(root, Verb::StartMysql)?; + if !started.ok { + return Err(Error::Invalid(format!( + "The database could not be started. {}", + driver::startup_failure(root, &started) + ))); + } let db = Db::from_repack(root, Account::Admin)?; let (root_pw, app_pw) = (random_hex(48), random_hex(48)); let accounts = db.query( @@ -362,6 +362,7 @@ pub fn install_base(p: &Params, report: &dyn Fn(Step)) -> Result { // 3. Extract into our own staging folder. if staging_root.exists() { if staging_root.join(MARKER).is_file() { + stop_installation(&staging_root)?; fs::remove_dir_all(&staging_root)?; // leftover of an earlier failed attempt that we created } else { return Err(Error::Invalid(format!("{} is left over from an earlier installation that did not finish, and the Manager cannot tell whether it may be deleted. Delete that folder yourself, then try again.", staging_root.display()))); @@ -426,24 +427,60 @@ pub fn install_base(p: &Params, report: &dyn Fn(Step)) -> Result { })(); if result.is_err() { - if staging_root.join(MARKER).exists() { - // Unfinished work of ours; the destination was never touched. - let _ = driver::run(&staging_root, Verb::StopAll); - let _ = fs::remove_dir_all(&staging_root); + let unfinished = if staging_root.join(MARKER).is_file() { + Some(&staging_root) } else if dest.join(MARKER).is_file() { - // Failed after the folder was moved into place. The destination was empty or absent before (preflight), - // so what carries our marker is only our unfinished install. - let _ = driver::run(&dest, Verb::StopAll); - let _ = fs::remove_dir_all(&dest); + Some(&dest) + } else { + None + }; + if let Some(unfinished) = unfinished { + if let Err(cleanup) = stop_installation(unfinished) { + return Err(Error::Invalid(format!( + "{} {cleanup}", + result.as_ref().unwrap_err() + ))); + } + let _ = fs::remove_dir_all(unfinished); } } result } +fn stop_installation(root: &Path) -> Result<()> { + if !root.join("Scripts/manage.py").is_file() { + return Ok(()); + } + let stopped = driver::run(root, Verb::StopAll)?; + if !stopped.ok { + return Err(Error::Invalid(format!( + "The unfinished installation could not be stopped; its files were kept. {}", + driver::startup_failure(root, &stopped) + ))); + } + Ok(()) +} + #[cfg(test)] mod tests { use super::*; + #[test] + fn an_unstoppable_partial_installation_keeps_its_files_for_recovery() { + let folder = tempfile::tempdir().unwrap(); + let root = folder.path(); + fs::create_dir_all(root.join("Scripts")).unwrap(); + fs::write(root.join("Scripts/manage.py"), "# incomplete launcher").unwrap(); + fs::write(root.join(MARKER), "").unwrap(); + fs::write(root.join("database-preserved"), "unchanged").unwrap(); + assert!(stop_installation(root).is_err()); + assert_eq!( + fs::read_to_string(root.join("database-preserved")).unwrap(), + "unchanged" + ); + assert!(root.join(MARKER).is_file()); + } + fn reg(d: &Path) -> Registry { Registry::at(d.join("reg/installs.json")) } diff --git a/crates/coa-core/src/launcher_bridge.py b/crates/coa-core/src/launcher_bridge.py new file mode 100644 index 0000000..76ff2aa --- /dev/null +++ b/crates/coa-core/src/launcher_bridge.py @@ -0,0 +1,22 @@ +import runpy +import sys +from pathlib import Path + +script = sys.argv[1] +sys.path.insert(0, str(Path(script).resolve().parent)) +sys.argv = sys.argv[1:] +launcher = runpy.run_path(script, run_name="coa_manager_launcher") +original_wait = launcher["wait_ready"] + + +def wait_ready(name, port, seconds=180): + return original_wait(name, port, max(seconds, 300) if name == "mysql" else seconds) + + +launcher["start_mysql"].__globals__["wait_ready"] = wait_ready +if sys.argv[1] == "stop-all": + config = launcher["settings"]() + with launcher["control_lock"](): + launcher["stop_all"](config) +else: + launcher["main"]() diff --git a/package-lock.json b/package-lock.json index a2411a6..883c792 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "coa-server-manager", - "version": "0.9.1", + "version": "0.9.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "coa-server-manager", - "version": "0.9.1", + "version": "0.9.2", "dependencies": { "@tauri-apps/api": "^2.12.0", "@tauri-apps/plugin-dialog": "^2.8.0", diff --git a/package.json b/package.json index 9cfc15b..aff2341 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "coa-server-manager", "private": true, - "version": "0.9.1", + "version": "0.9.2", "license": "AGPL-3.0-only", "type": "module", "scripts": { diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 1f0cb7c..787576f 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "CoA Server Manager", - "version": "0.9.1", + "version": "0.9.2", "identifier": "com.coa.servermanager", "build": { "beforeDevCommand": "npm run dev", diff --git a/src/data/latest-note-translations.json b/src/data/latest-note-translations.json index ba66fed..2beabac 100644 --- a/src/data/latest-note-translations.json +++ b/src/data/latest-note-translations.json @@ -1,19 +1,34 @@ { "manager": { "ru": { - "Existing unsupported character records no longer block unrelated server updates": "Старые неподдерживаемые записи персонажей больше не блокируют несвязанные обновления сервера" + "Existing unsupported character records no longer block unrelated server updates": "Старые неподдерживаемые записи персонажей больше не блокируют несвязанные обновления сервера", + "New server installation waits for slower database startup": "Установка нового сервера дольше ожидает запуска базы на медленных дисках", + "Find and edit CoA options in the existing server settings": "Параметры CoA доступны для поиска и редактирования в настройках сервера", + "Simultaneous update checks share results or wait their turn": "Одновременные проверки обновлений используют общий результат или ожидают своей очереди" }, "de": { - "Existing unsupported character records no longer block unrelated server updates": "Bestehende nicht unterstützte Charakterdatensätze blockieren keine unabhängigen Serverupdates mehr" + "Existing unsupported character records no longer block unrelated server updates": "Bestehende nicht unterstützte Charakterdatensätze blockieren keine unabhängigen Serverupdates mehr", + "New server installation waits for slower database startup": "Die Neuinstallation wartet länger auf einen langsamen Datenbankstart", + "Find and edit CoA options in the existing server settings": "CoA-Optionen in den bestehenden Servereinstellungen suchen und bearbeiten", + "Simultaneous update checks share results or wait their turn": "Gleichzeitige Updateprüfungen teilen Ergebnisse oder warten aufeinander" }, "fr": { - "Existing unsupported character records no longer block unrelated server updates": "Les anciennes fiches de personnage non prises en charge ne bloquent plus les mises à jour sans rapport" + "Existing unsupported character records no longer block unrelated server updates": "Les anciennes fiches de personnage non prises en charge ne bloquent plus les mises à jour sans rapport", + "New server installation waits for slower database startup": "L’installation attend le démarrage de la base sur les disques plus lents", + "Find and edit CoA options in the existing server settings": "Recherchez et modifiez les options CoA dans les paramètres du serveur", + "Simultaneous update checks share results or wait their turn": "Les vérifications simultanées partagent leurs résultats ou attendent leur tour" }, "es": { - "Existing unsupported character records no longer block unrelated server updates": "Los registros antiguos de personajes no compatibles ya no bloquean actualizaciones del servidor no relacionadas" + "Existing unsupported character records no longer block unrelated server updates": "Los registros antiguos de personajes no compatibles ya no bloquean actualizaciones del servidor no relacionadas", + "New server installation waits for slower database startup": "La instalación espera más tiempo al inicio de la base de datos en discos lentos", + "Find and edit CoA options in the existing server settings": "Busca y edita opciones de CoA en la configuración actual del servidor", + "Simultaneous update checks share results or wait their turn": "Las comprobaciones simultáneas comparten resultados o esperan su turno" }, "zh": { - "Existing unsupported character records no longer block unrelated server updates": "已有的不受支持角色记录不再阻止无关的服务器更新" + "Existing unsupported character records no longer block unrelated server updates": "已有的不受支持角色记录不再阻止无关的服务器更新", + "New server installation waits for slower database startup": "安装新服务器时会等待较慢的数据库启动", + "Find and edit CoA options in the existing server settings": "在现有服务器设置中查找和编辑 CoA 选项", + "Simultaneous update checks share results or wait their turn": "同时进行的更新检查会共用结果或依次执行" } }, "server": {} diff --git a/src/lib/api.ts b/src/lib/api.ts index e0f4d41..538b410 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -1,4 +1,7 @@ import { invoke } from "@tauri-apps/api/core"; +import { updateCheckQueue } from "./updateCheckQueue"; + +const queuedUpdateCheck = updateCheckQueue(); export const REMOTE_CLIENT_ID = "@remote-client"; export interface RemoteConnection { host: string; client_path: string | null } @@ -539,7 +542,10 @@ export const api = { createAccount: (id: string, username: string, password: string, administrator: boolean) => invoke("create_account", { id, username, password, administrator }), /** `background`: the repeating check, which must not start the database of a stopped server. */ - checkUpdate: (id: string, source?: string, background?: boolean) => invoke("check_update", { id, source: source ?? null, background: background ?? false }), + checkUpdate: (id: string, source?: string, background?: boolean) => queuedUpdateCheck( + id, JSON.stringify([source ?? null, background ?? false]), + () => invoke("check_update", { id, source: source ?? null, background: background ?? false }), + ), pendingUpdate: (id: string) => invoke("pending_update", { id }), applyUpdate: (id: string, resolutions: Record, source?: string) => invoke<{ txn: UpdateTxn; preexisting_character_count?: number }>("apply_update", { id, source: source ?? null, resolutions }), diff --git a/src/lib/updateCheckQueue.ts b/src/lib/updateCheckQueue.ts new file mode 100644 index 0000000..87cd67f --- /dev/null +++ b/src/lib/updateCheckQueue.ts @@ -0,0 +1,19 @@ +export function updateCheckQueue() { + const requests = new Map>(); + const servers = new Map>(); + return (server: string, request: string, run: () => Promise): Promise => { + const key = JSON.stringify([server, request]); + const existing = requests.get(key); + if (existing) return existing; + const previous = servers.get(server) ?? Promise.resolve(); + const result = previous.catch(() => undefined).then(run); + requests.set(key, result); + servers.set(server, result); + const clear = () => { + if (requests.get(key) === result) requests.delete(key); + if (servers.get(server) === result) servers.delete(server); + }; + void result.then(clear, clear); + return result; + }; +} diff --git a/src/screens/SettingsPage.tsx b/src/screens/SettingsPage.tsx index 76f6bc2..0c363ed 100644 --- a/src/screens/SettingsPage.tsx +++ b/src/screens/SettingsPage.tsx @@ -284,8 +284,8 @@ export function SettingsPage(props: { const q = query.trim().toLowerCase(); const searching = q !== ""; const hit = (...parts: string[]) => parts.some((x) => x.toLowerCase().includes(q)); - const curatedKeys = new Set(view.settings.map((s) => s.key)); const collections = collectionsOf(cols); + const curatedKeys = new Set([...view.settings.map((s) => s.key), ...collections.map((c) => c.item.key)]); const curatedHits = searching ? view.settings.filter((s) => hit(s.key, sx.title(s), sx.description(s))) : []; const collectionHits = searching ? collections.filter((c) => hit(c.item.key, t(c.label), t(c.text))) : []; const rawHits = (raw ?? []).filter( diff --git a/tools/overview-update-error.test.mjs b/tools/overview-update-error.test.mjs index 0028fa8..3907219 100644 --- a/tools/overview-update-error.test.mjs +++ b/tools/overview-update-error.test.mjs @@ -15,6 +15,10 @@ const stubs = { "@/lib/clientJob": `export const dismissClientJob=()=>{}; export const startClientUpdate=()=>{}; export const stopClientJob=()=>{}; export const syncClient=()=>{}; export const useClientJob=()=>({phase:'idle'});`, "@/lib/clientUpdate": `export const checkClient=()=>{}; export const useClientStatus=()=>({status:null});`, "@/lib/autoRestartPrefs": `export const useAutoRestart=()=>[false,()=>{}];`, + "@/screens/PublishCard": `export const ListingChip=()=>null;`, + "@/screens/PublishDialog": `export const PublishRealmDialog=()=>null;`, + "@/lib/registry": `export const useRegistryStatus=()=>({status:null});`, + "@/lib/publishRealms": `export const usePublishRealms=()=>[];`, "@/components/ui/button": `import {createElement} from 'react'; export const Button=({children,disabled,onClick,className})=>createElement('button',{disabled,onClick,className},children);`, "@/components/ui/card": `import {createElement} from 'react'; export const Card=({children,...props})=>createElement('div',props,children);`, }; diff --git a/tools/test_launcher_bridge.py b/tools/test_launcher_bridge.py new file mode 100644 index 0000000..de79737 --- /dev/null +++ b/tools/test_launcher_bridge.py @@ -0,0 +1,51 @@ +import contextlib +import pathlib +import runpy +import sys +import tempfile +import unittest +from unittest.mock import patch + +BRIDGE = pathlib.Path(__file__).resolve().parents[1] / 'crates/coa-core/src/launcher_bridge.py' + + +class LauncherBridgeTests(unittest.TestCase): + def launch(self, action, body): + with tempfile.TemporaryDirectory() as directory: + script = pathlib.Path(directory) / 'manage.py' + script.write_text(body, encoding='utf8') + with patch.object(sys, 'argv', ['bridge', str(script), action]): + return runpy.run_path(str(BRIDGE)) + + def test_slow_mysql_gets_time_to_finish_but_other_waits_are_unchanged(self): + result = self.launch('start-mysql', ''' +calls = [] +def wait_ready(name, port, seconds=180): + calls.append((name, seconds)) + if name == 'mysql' and seconds < 162: + raise RuntimeError('mysql is still starting') +def start_mysql(): + wait_ready('mysql', 12345, 60) +def main(): + start_mysql() + wait_ready('world', 23456, 180) + wait_ready('mysql', 12345, 900) +''') + self.assertEqual(result['launcher']['calls'], [('mysql', 300), ('world', 180), ('mysql', 900)]) + + def test_stop_dispatch_does_not_prepare_or_start_anything(self): + result = self.launch('stop-all', ''' +from contextlib import nullcontext +calls = [] +def wait_ready(*args): raise AssertionError('must not wait') +def start_mysql(): raise AssertionError('must not start') +def main(): raise AssertionError('must not prepare') +def settings(): return {'mysqlPort': 12345} +def control_lock(): return nullcontext() +def stop_all(config): calls.append(config['mysqlPort']) +''') + self.assertEqual(result['launcher']['calls'], [12345]) + + +if __name__ == '__main__': + unittest.main() diff --git a/tools/update-check-queue.test.mjs b/tools/update-check-queue.test.mjs new file mode 100644 index 0000000..fd6fd67 --- /dev/null +++ b/tools/update-check-queue.test.mjs @@ -0,0 +1,32 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { updateCheckQueue } from "../src/lib/updateCheckQueue.ts"; + +test("duplicate checks share one result and foreground waits for background on the same server", async () => { + const check = updateCheckQueue(); + let release; + let calls = 0; + const background = check("server", "background", () => { calls++; return new Promise(resolve => { release = resolve; }); }); + assert.equal(check("server", "background", () => { throw Error("duplicate"); }), background); + const foreground = check("server", "foreground", async () => { calls++; return "full preview"; }); + await Promise.resolve(); + await Promise.resolve(); + assert.equal(calls, 1); + release("background preview"); + assert.equal(await background, "background preview"); + assert.equal(await foreground, "full preview"); + assert.equal(calls, 2); +}); + +test("one server's failure releases its queue and does not block other servers", async () => { + const check = updateCheckQueue(); + let fail; + const first = check("one", "a", () => new Promise((_, reject) => { fail = reject; })); + const failed = assert.rejects(first, /offline/); + const next = check("one", "b", async () => "retry"); + assert.equal(await check("two", "a", async () => "independent"), "independent"); + fail(Error("offline")); + await failed; + assert.equal(await next, "retry"); + assert.equal(await check("one", "a", async () => "recovered"), "recovered"); +}); From 2d9c6a65bbb1e296dc919e4c9e5785ed9d4a070b 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, 28 Oct 2026 22:57:00 +0500 Subject: [PATCH 2/2] Recover safely when rollback restores a legacy world launcher --- changelog.d/20261028-legacy-world-shutdown.md | 7 ++ crates/coa-core/src/launcher_bridge.py | 28 +++++++ src/data/latest-note-translations.json | 15 ++-- tools/test_launcher_bridge.py | 80 ++++++++++++++++++- 4 files changed, 121 insertions(+), 9 deletions(-) create mode 100644 changelog.d/20261028-legacy-world-shutdown.md diff --git a/changelog.d/20261028-legacy-world-shutdown.md b/changelog.d/20261028-legacy-world-shutdown.md new file mode 100644 index 0000000..24a4ab2 --- /dev/null +++ b/changelog.d/20261028-legacy-world-shutdown.md @@ -0,0 +1,7 @@ +--- +area: manager +type: fixed +audience: admins +title: Recovery can stop an unfinished world startup after restoring an older launcher +--- +If the world console is unavailable, stop only the verified recorded world process. Keep MySQL running if ownership or shutdown cannot be confirmed. diff --git a/crates/coa-core/src/launcher_bridge.py b/crates/coa-core/src/launcher_bridge.py index 76ff2aa..e5bdadf 100644 --- a/crates/coa-core/src/launcher_bridge.py +++ b/crates/coa-core/src/launcher_bridge.py @@ -15,6 +15,34 @@ def wait_ready(name, port, seconds=180): launcher["start_mysql"].__globals__["wait_ready"] = wait_ready if sys.argv[1] == "stop-all": + shutdown = launcher["stop_all"].__globals__ + if "ra" in shutdown: + original_ra = shutdown["ra"] + + class UnsafeShutdown(Exception): + pass + + def shutdown_command(command, config): + try: + return original_ra(command, config) + except (OSError, RuntimeError): + if not command.startswith("server shutdown "): + raise + world = shutdown["process"]("world") + if not world: + return "Worldserver has already stopped." + expected = Path(script).resolve().parents[1] / "Core" / "worldserver.exe" + if Path(world.get("exe", "")) != expected or not shutdown["same_process"](world): + raise UnsafeShutdown("World process ownership could not be verified; the database was left running.") + try: + shutdown["terminate"](world) + except (OSError, RuntimeError) as error: + raise UnsafeShutdown("Worldserver could not be stopped safely; its database was left running.") from error + if not shutdown["wait_stopped"](world, 90): + raise UnsafeShutdown("Worldserver is still stopping; its database was left running.") + return "Stopped this server's recorded world process without its unavailable console." + + shutdown["ra"] = shutdown_command config = launcher["settings"]() with launcher["control_lock"](): launcher["stop_all"](config) diff --git a/src/data/latest-note-translations.json b/src/data/latest-note-translations.json index 2beabac..e2b2394 100644 --- a/src/data/latest-note-translations.json +++ b/src/data/latest-note-translations.json @@ -4,31 +4,36 @@ "Existing unsupported character records no longer block unrelated server updates": "Старые неподдерживаемые записи персонажей больше не блокируют несвязанные обновления сервера", "New server installation waits for slower database startup": "Установка нового сервера дольше ожидает запуска базы на медленных дисках", "Find and edit CoA options in the existing server settings": "Параметры CoA доступны для поиска и редактирования в настройках сервера", - "Simultaneous update checks share results or wait their turn": "Одновременные проверки обновлений используют общий результат или ожидают своей очереди" + "Simultaneous update checks share results or wait their turn": "Одновременные проверки обновлений используют общий результат или ожидают своей очереди", + "Recovery can stop an unfinished world startup after restoring an older launcher": "Восстановление может остановить зависший запуск мира после возврата старого launcher" }, "de": { "Existing unsupported character records no longer block unrelated server updates": "Bestehende nicht unterstützte Charakterdatensätze blockieren keine unabhängigen Serverupdates mehr", "New server installation waits for slower database startup": "Die Neuinstallation wartet länger auf einen langsamen Datenbankstart", "Find and edit CoA options in the existing server settings": "CoA-Optionen in den bestehenden Servereinstellungen suchen und bearbeiten", - "Simultaneous update checks share results or wait their turn": "Gleichzeitige Updateprüfungen teilen Ergebnisse oder warten aufeinander" + "Simultaneous update checks share results or wait their turn": "Gleichzeitige Updateprüfungen teilen Ergebnisse oder warten aufeinander", + "Recovery can stop an unfinished world startup after restoring an older launcher": "Die Wiederherstellung stoppt einen unvollständigen Weltstart auch mit altem Launcher" }, "fr": { "Existing unsupported character records no longer block unrelated server updates": "Les anciennes fiches de personnage non prises en charge ne bloquent plus les mises à jour sans rapport", "New server installation waits for slower database startup": "L’installation attend le démarrage de la base sur les disques plus lents", "Find and edit CoA options in the existing server settings": "Recherchez et modifiez les options CoA dans les paramètres du serveur", - "Simultaneous update checks share results or wait their turn": "Les vérifications simultanées partagent leurs résultats ou attendent leur tour" + "Simultaneous update checks share results or wait their turn": "Les vérifications simultanées partagent leurs résultats ou attendent leur tour", + "Recovery can stop an unfinished world startup after restoring an older launcher": "La restauration peut arrêter un démarrage du monde inachevé avec un ancien lanceur" }, "es": { "Existing unsupported character records no longer block unrelated server updates": "Los registros antiguos de personajes no compatibles ya no bloquean actualizaciones del servidor no relacionadas", "New server installation waits for slower database startup": "La instalación espera más tiempo al inicio de la base de datos en discos lentos", "Find and edit CoA options in the existing server settings": "Busca y edita opciones de CoA en la configuración actual del servidor", - "Simultaneous update checks share results or wait their turn": "Las comprobaciones simultáneas comparten resultados o esperan su turno" + "Simultaneous update checks share results or wait their turn": "Las comprobaciones simultáneas comparten resultados o esperan su turno", + "Recovery can stop an unfinished world startup after restoring an older launcher": "La recuperación puede detener el inicio del mundo tras restaurar un lanzador antiguo" }, "zh": { "Existing unsupported character records no longer block unrelated server updates": "已有的不受支持角色记录不再阻止无关的服务器更新", "New server installation waits for slower database startup": "安装新服务器时会等待较慢的数据库启动", "Find and edit CoA options in the existing server settings": "在现有服务器设置中查找和编辑 CoA 选项", - "Simultaneous update checks share results or wait their turn": "同时进行的更新检查会共用结果或依次执行" + "Simultaneous update checks share results or wait their turn": "同时进行的更新检查会共用结果或依次执行", + "Recovery can stop an unfinished world startup after restoring an older launcher": "恢复旧版启动器后,恢复流程也能停止未完成启动的世界服务器" } }, "server": {} diff --git a/tools/test_launcher_bridge.py b/tools/test_launcher_bridge.py index de79737..e48b1be 100644 --- a/tools/test_launcher_bridge.py +++ b/tools/test_launcher_bridge.py @@ -1,4 +1,3 @@ -import contextlib import pathlib import runpy import sys @@ -12,10 +11,83 @@ BRIDGE = pathlib.Path(__file__).resolve().parents[1] / 'crates/coa-core/src/laun class LauncherBridgeTests(unittest.TestCase): def launch(self, action, body): with tempfile.TemporaryDirectory() as directory: - script = pathlib.Path(directory) / 'manage.py' + script = pathlib.Path(directory) / 'Scripts/manage.py' + script.parent.mkdir() script.write_text(body, encoding='utf8') - with patch.object(sys, 'argv', ['bridge', str(script), action]): - return runpy.run_path(str(BRIDGE)) + original = runpy.run_path + def capture(*args, **kwargs): + self.launcher = original(*args, **kwargs) + return self.launcher + with patch.object(sys, 'argv', ['bridge', str(script), action]), patch.object(runpy, 'run_path', side_effect=capture): + return original(str(BRIDGE)) + + def shutdown_fixture(self, *, console_error='RuntimeError', stopped=True, foreign=False, same=True, modern=False, terminate_error=False): + return f''' +from pathlib import Path +from contextlib import nullcontext +calls = [] +world = {{'pid': 123, 'created': 456, 'exe': str(Path(__file__).resolve().parents[1] / {'"foreign.exe"' if foreign else '"Core/worldserver.exe"'})}} +def wait_ready(*args): pass +def start_mysql(): raise AssertionError('must not start') +def main(): raise AssertionError('must not prepare') +def settings(): return {{}} +def control_lock(): return nullcontext() +def process(name): return world if name == 'world' else None +def same_process(record): return {same!r} +def ra(command, config): + calls.append('console') + {'raise '+console_error+'("Start this repack worldserver first")' if console_error else 'return "Graceful shutdown"'} +def terminate(record): + assert record is world + calls.append('terminate') + {'raise RuntimeError("identity changed")' if terminate_error else 'pass'} +def wait_stopped(record, seconds): + assert record is world + calls.append('wait') + return {stopped!r} +def stop_all(config): + record = process('world') + if record: + if {modern!r}: + try: ra('server shutdown 1s 0', config) + except (OSError, RuntimeError): terminate(record) + else: + ra('server shutdown 1s 0', config) + if not wait_stopped(record, 60): raise RuntimeError('world still alive') + calls.append('mysql-shutdown') +''' + + def test_unavailable_console_in_restored_legacy_launcher_stops_owned_world_before_mysql(self): + for error in ['RuntimeError', 'OSError']: + with self.subTest(error=error): + result = self.launch('stop-all', self.shutdown_fixture(console_error=error)) + self.assertEqual(result['launcher']['calls'], ['console', 'terminate', 'wait', 'wait', 'mysql-shutdown']) + + def test_healthy_console_keeps_graceful_shutdown(self): + result = self.launch('stop-all', self.shutdown_fixture(console_error=None)) + self.assertEqual(result['launcher']['calls'], ['console', 'wait', 'mysql-shutdown']) + + def test_foreign_or_reused_process_is_never_terminated_even_by_a_modern_launcher_catch(self): + for modern in [False, True]: + for identity in [{'foreign': True}, {'same': False}]: + with self.subTest(modern=modern, **identity): + with self.assertRaisesRegex(Exception, 'ownership could not be verified'): + self.launch('stop-all', self.shutdown_fixture(modern=modern, **identity)) + self.assertEqual(self.launcher['calls'], ['console']) + + def test_world_that_stays_alive_blocks_database_shutdown(self): + for modern in [False, True]: + with self.subTest(modern=modern): + with self.assertRaisesRegex(Exception, 'database was left running'): + self.launch('stop-all', self.shutdown_fixture(stopped=False, modern=modern)) + self.assertEqual(self.launcher['calls'], ['console', 'terminate', 'wait']) + + def test_termination_identity_failure_leaves_mysql_untouched(self): + for modern in [False, True]: + with self.subTest(modern=modern): + with self.assertRaisesRegex(Exception, 'could not be stopped safely'): + self.launch('stop-all', self.shutdown_fixture(terminate_error=True, modern=modern)) + self.assertEqual(self.launcher['calls'], ['console', 'terminate']) def test_slow_mysql_gets_time_to_finish_but_other_waits_are_unchanged(self): result = self.launch('start-mysql', '''