Merge pull request #181 from Corfirean/fix/manager-092-issues

Manager 0.9.2: database startup, recovery and CoA settings
This commit is contained in:
Corfirean authored and GitHub committed 2026-10-28 23:20:11 +05:00
commit 65631450ff
22 files changed
+582 -83

No files matched your search

+2
View File
@@ -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
+3 -1
View File
@@ -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
Generated
+3 -3
View File
@@ -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",
+1 -1
View File
@@ -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"
@@ -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.
@@ -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.
@@ -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.
@@ -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.
+220 -48
View File
@@ -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<Vec<(std::path::PathBuf, Targets)>> {
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<Vec<Item>> {
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::<Vec<_>>(),
);
}
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<String, String>) -> Result<Vec<String>> {
let known: BTreeMap<String, String> =
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<u8>)> = Vec::new();
let mut confs: Vec<ConfFile> = 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<String, String>) -> 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::<f64>().is_ok() && !value.parse::<f64>().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<String, String>) -> 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<String> = 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<String, String>) -> 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) {
+8 -4
View File
@@ -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<String> {
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)
);
}
+52 -15
View File
@@ -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<Installed> {
// 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<Installed> {
})();
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"))
}
+50
View File
@@ -0,0 +1,50 @@
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":
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)
else:
launcher["main"]()
+2 -2
View File
@@ -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",
+1 -1
View File
@@ -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": {
+1 -1
View File
@@ -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",
+25 -5
View File
@@ -1,19 +1,39 @@
{
"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": "Одновременные проверки обновлений используют общий результат или ожидают своей очереди",
"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"
"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",
"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"
"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",
"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"
"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",
"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": "已有的不受支持角色记录不再阻止无关的服务器更新"
"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": "同时进行的更新检查会共用结果或依次执行",
"Recovery can stop an unfinished world startup after restoring an older launcher": "恢复旧版启动器后,恢复流程也能停止未完成启动的世界服务器"
}
},
"server": {}
+7 -1
View File
@@ -1,4 +1,7 @@
import { invoke } from "@tauri-apps/api/core";
import { updateCheckQueue } from "./updateCheckQueue";
const queuedUpdateCheck = updateCheckQueue<UpdatePreview>();
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<void>("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<UpdatePreview>("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<UpdatePreview>("check_update", { id, source: source ?? null, background: background ?? false }),
),
pendingUpdate: (id: string) => invoke<UpdateTxn | null>("pending_update", { id }),
applyUpdate: (id: string, resolutions: Record<string, "keep" | "replace">, source?: string) =>
invoke<{ txn: UpdateTxn; preexisting_character_count?: number }>("apply_update", { id, source: source ?? null, resolutions }),
+19
View File
@@ -0,0 +1,19 @@
export function updateCheckQueue<T>() {
const requests = new Map<string, Promise<T>>();
const servers = new Map<string, Promise<unknown>>();
return (server: string, request: string, run: () => Promise<T>): Promise<T> => {
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;
};
}
+1 -1
View File
@@ -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(
+4
View File
@@ -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);`,
};
+123
View File
@@ -0,0 +1,123 @@
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) / 'Scripts/manage.py'
script.parent.mkdir()
script.write_text(body, encoding='utf8')
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', '''
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()
+32
View File
@@ -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");
});