From 7bb0ea94723b27123fe9e0ce17637ce03250928b 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: Thu, 29 Oct 2026 08:33:11 +0500 Subject: [PATCH 1/2] Wait for slow CoA worlds before update rollback --- changelog.d/20261029-slow-world-startup.md | 7 +++ crates/coa-core/src/driver.rs | 40 ++++++----------- crates/coa-core/src/launcher_bridge.py | 13 +++++- tools/test_launcher_bridge.py | 50 +++++++++++++++++++++- 4 files changed, 82 insertions(+), 28 deletions(-) create mode 100644 changelog.d/20261029-slow-world-startup.md diff --git a/changelog.d/20261029-slow-world-startup.md b/changelog.d/20261029-slow-world-startup.md new file mode 100644 index 0000000..8502b54 --- /dev/null +++ b/changelog.d/20261029-slow-world-startup.md @@ -0,0 +1,7 @@ +--- +area: manager +type: fixed +audience: admins +title: Slow server startup no longer triggers an early update rollback +--- +World startup now allows up to 25 minutes with either bot system and waits for both game and console ports within one deadline. Servers that stop during startup still fail immediately. diff --git a/crates/coa-core/src/driver.rs b/crates/coa-core/src/driver.rs index 6eff427..7e0eabd 100644 --- a/crates/coa-core/src/driver.rs +++ b/crates/coa-core/src/driver.rs @@ -31,36 +31,17 @@ impl Verb { } } - fn timeout(self, root: &Path) -> Duration { + 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(720), + // Includes database startup, optional bot provisioning and the shared 25-minute + // world/console wait in launcher_bridge.py, even for older installed scripts. + Verb::StartAll | Verb::StartWorld => Duration::from_secs(3600), Verb::StartMysql => Duration::from_secs(360), Verb::StopAll => Duration::from_secs(240), } } } -fn squid_enabled(root: &Path) -> bool { - let active = root.join("Core/configs/modules/playerbots.conf"); - let path = if active.exists() { - active - } else { - root.join("Core/configs/modules/playerbots.conf.dist") - }; - std::fs::read(path) - .ok() - .and_then(|bytes| crate::config::parser::ConfFile::parse_bytes(&bytes).ok()) - .is_some_and(|config| { - config.get("AiPlayerbot.Enabled").is_none_or(|value| { - !matches!( - value.trim().trim_matches('"').to_ascii_lowercase().as_str(), - "0" | "false" | "no" | "off" - ) - }) - }) -} - #[derive(Debug, Clone, Serialize)] pub struct DriverOutcome { pub ok: bool, @@ -230,6 +211,13 @@ fn run_inner(root: &Path, verb: Verb, validating_update: bool) -> Result (st.success(), st.code()), None => (false, None), @@ -604,7 +592,7 @@ mod tests { } #[test] - fn large_squid_provisioning_has_time_to_finish_without_extending_shutdown() { + fn slow_world_startup_has_time_with_either_bot_system_without_extending_shutdown() { let folder = tempfile::tempdir().unwrap(); let modules = folder.path().join("Core/configs/modules"); std::fs::create_dir_all(&modules).unwrap(); @@ -615,7 +603,7 @@ mod tests { .unwrap(); assert_eq!( Verb::StartAll.timeout(folder.path()), - Duration::from_secs(2700) + Duration::from_secs(3600) ); assert_eq!( Verb::StopAll.timeout(folder.path()), @@ -624,7 +612,7 @@ mod tests { std::fs::write(modules.join("playerbots.conf"), "AiPlayerbot.Enabled = 0\n").unwrap(); assert_eq!( Verb::StartWorld.timeout(folder.path()), - Duration::from_secs(720) + Duration::from_secs(3600) ); assert_eq!( Verb::StartMysql.timeout(folder.path()), diff --git a/crates/coa-core/src/launcher_bridge.py b/crates/coa-core/src/launcher_bridge.py index e5bdadf..e31d47c 100644 --- a/crates/coa-core/src/launcher_bridge.py +++ b/crates/coa-core/src/launcher_bridge.py @@ -1,5 +1,6 @@ import runpy import sys +import time from pathlib import Path script = sys.argv[1] @@ -7,10 +8,20 @@ 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"] +world_deadline = None def wait_ready(name, port, seconds=180): - return original_wait(name, port, max(seconds, 300) if name == "mysql" else seconds) + global world_deadline + if name == "mysql": + seconds = max(seconds, 300) + elif name == "world": + # Both game and console listeners belong to one startup, including on old repacks. + # Ordinary CoA worlds can load slowly too; this must not depend on SQUID. + if world_deadline is None: + world_deadline = time.monotonic() + max(seconds, 1500) + seconds = max(0, world_deadline - time.monotonic()) + return original_wait(name, port, seconds) launcher["start_mysql"].__globals__["wait_ready"] = wait_ready diff --git a/tools/test_launcher_bridge.py b/tools/test_launcher_bridge.py index e48b1be..5706900 100644 --- a/tools/test_launcher_bridge.py +++ b/tools/test_launcher_bridge.py @@ -3,6 +3,7 @@ import runpy import sys import tempfile import unittest +import time from unittest.mock import patch BRIDGE = pathlib.Path(__file__).resolve().parents[1] / 'crates/coa-core/src/launcher_bridge.py' @@ -103,7 +104,54 @@ def main(): wait_ready('world', 23456, 180) wait_ready('mysql', 12345, 900) ''') - self.assertEqual(result['launcher']['calls'], [('mysql', 300), ('world', 180), ('mysql', 900)]) + calls = result['launcher']['calls'] + self.assertEqual(calls[0], ('mysql', 300)) + self.assertEqual(calls[2], ('mysql', 900)) + self.assertEqual(calls[1][0], 'world') + self.assertAlmostEqual(calls[1][1], 1500, delta=1) + + def world_start(self, *, game_at=420, console_at=480, dies_at=None, requested=180): + clock = [0.0] + def sleep(seconds): clock[0] += seconds + body = f''' +import time +calls = [] +dies_at = {dies_at!r} +def wait_ready(name, port, seconds=180): + until = time.monotonic() + seconds + while time.monotonic() < until: + if dies_at is not None and time.monotonic() >= dies_at: + raise RuntimeError('world stopped during startup') + if time.monotonic() >= ({game_at!r} if port == 1 else {console_at!r}): + calls.append(port) + return + time.sleep(0.5) + raise RuntimeError('world is still starting') +def start_mysql(): pass +def main(): + wait_ready('world', 1, {requested}) + wait_ready('world', 2, {requested}) +''' + with patch('time.monotonic', side_effect=lambda: clock[0]), patch('time.sleep', side_effect=sleep): + result = self.launch('start-all', body) + return clock[0], result['launcher']['calls'] + + def test_legacy_coa_world_waits_for_both_ports_after_three_minutes(self): + elapsed, calls = self.world_start() + self.assertEqual((elapsed, calls), (480, [1, 2])) + + def test_console_wait_shares_the_world_deadline(self): + with self.assertRaisesRegex(RuntimeError, 'still starting'): + self.world_start(game_at=1490, console_at=1510) + self.assertEqual(self.launcher['calls'], [1]) + + def test_world_exit_is_reported_without_waiting_out_the_extended_budget(self): + with self.assertRaisesRegex(RuntimeError, 'stopped during startup'): + self.world_start(dies_at=12) + self.assertEqual(self.launcher['calls'], []) + + def test_existing_longer_world_timeout_is_preserved(self): + self.assertEqual(self.world_start(game_at=1600, console_at=1700, requested=1800), (1700, [1, 2])) def test_stop_dispatch_does_not_prepare_or_start_anything(self): result = self.launch('stop-all', ''' From e7b76137d1b22a7596c2b1fa05ca9d12403b99d3 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: Thu, 29 Oct 2026 10:07:33 +0500 Subject: [PATCH 2/2] Prepare Manager 0.9.3 and verify platform-specific realm restriction --- Cargo.lock | 6 +++--- Cargo.toml | 2 +- crates/coa-core/src/multiworld.rs | 7 ++++++- package-lock.json | 4 ++-- package.json | 2 +- src-tauri/tauri.conf.json | 2 +- src/data/latest-note-translations.json | 15 ++++++++++----- 7 files changed, 24 insertions(+), 14 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 58152cc..70d9a02 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -586,7 +586,7 @@ dependencies = [ [[package]] name = "coa-core" -version = "0.9.2" +version = "0.9.3" dependencies = [ "base64 0.22.1", "chrono", @@ -634,7 +634,7 @@ dependencies = [ [[package]] name = "coa-release" -version = "0.9.2" +version = "0.9.3" dependencies = [ "chrono", "coa-core", @@ -643,7 +643,7 @@ dependencies = [ [[package]] name = "coa-server-manager" -version = "0.9.2" +version = "0.9.3" dependencies = [ "coa-control-proto", "coa-core", diff --git a/Cargo.toml b/Cargo.toml index 6c954be..22bd0b9 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.2" +version = "0.9.3" edition = "2021" license = "AGPL-3.0-only" diff --git a/crates/coa-core/src/multiworld.rs b/crates/coa-core/src/multiworld.rs index 3c96fa2..5634a36 100644 --- a/crates/coa-core/src/multiworld.rs +++ b/crates/coa-core/src/multiworld.rs @@ -497,7 +497,12 @@ mod tests { ) .unwrap(); let err = set_reborn_enabled(t.path(), true).unwrap_err().to_string(); - assert!(err.contains("Select Conquest of Azeroth first"), "{err}"); + let expected = if cfg!(windows) { + "Select Conquest of Azeroth first" + } else { + "Simultaneous realms currently require Windows" + }; + assert!(err.contains(expected), "{err}"); assert!(!realms::state(t.path()).unwrap().with_reborn); } diff --git a/package-lock.json b/package-lock.json index 883c792..5d2e2e6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "coa-server-manager", - "version": "0.9.2", + "version": "0.9.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "coa-server-manager", - "version": "0.9.2", + "version": "0.9.3", "dependencies": { "@tauri-apps/api": "^2.12.0", "@tauri-apps/plugin-dialog": "^2.8.0", diff --git a/package.json b/package.json index aff2341..3d37c3c 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "coa-server-manager", "private": true, - "version": "0.9.2", + "version": "0.9.3", "license": "AGPL-3.0-only", "type": "module", "scripts": { diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 787576f..493ce6c 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.2", + "version": "0.9.3", "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 e2b2394..28b548c 100644 --- a/src/data/latest-note-translations.json +++ b/src/data/latest-note-translations.json @@ -5,35 +5,40 @@ "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" + "Recovery can stop an unfinished world startup after restoring an older launcher": "Восстановление может остановить зависший запуск мира после возврата старого launcher", + "Slow server startup no longer triggers an early update rollback": "Медленный запуск сервера больше не вызывает преждевременный откат обновления" }, "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", - "Recovery can stop an unfinished world startup after restoring an older launcher": "Die Wiederherstellung stoppt einen unvollständigen Weltstart auch mit altem Launcher" + "Recovery can stop an unfinished world startup after restoring an older launcher": "Die Wiederherstellung stoppt einen unvollständigen Weltstart auch mit altem Launcher", + "Slow server startup no longer triggers an early update rollback": "Ein langsamer Serverstart löst keinen vorzeitigen Update-Rollback mehr aus" }, "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", - "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" + "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", + "Slow server startup no longer triggers an early update rollback": "Un démarrage lent du serveur ne déclenche plus un retour arrière prématuré" }, "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", - "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" + "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", + "Slow server startup no longer triggers an early update rollback": "El inicio lento del servidor ya no provoca una reversión prematura de la actualización" }, "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": "同时进行的更新检查会共用结果或依次执行", - "Recovery can stop an unfinished world startup after restoring an older launcher": "恢复旧版启动器后,恢复流程也能停止未完成启动的世界服务器" + "Recovery can stop an unfinished world startup after restoring an older launcher": "恢复旧版启动器后,恢复流程也能停止未完成启动的世界服务器", + "Slow server startup no longer triggers an early update rollback": "服务器启动缓慢不再导致更新过早回滚" } }, "server": {}