From 2ec8ff1d5e14222b116c9cd0d9ca2e16fb93e23e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rodrigo=20P=C3=A9rez?= Date: Thu, 11 Jun 2026 15:43:55 -0400 Subject: [PATCH] feat(subscription): store-native slice 6 and remove OPTIONS 26s loop MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Drop runtime router→store overwrites; refresh static TGs via RPTO, startup/reload apply_startup_bridges, and dmrd fallback only (V2-P2-016). --- CHANGELOG.md | 6 + .../development/behaviour-and-timers.md | 2 +- .../development/behaviour-and-timers.md | 2 +- .../application/bridge/bridge_table.py | 195 ++++++++---------- src/adn_server/application/bridge/timers.py | 15 +- .../application/subscription/store_sync.py | 6 +- .../infrastructure/bootstrap/peer_server.py | 2 +- tests/README.md | 2 +- tests/bridge/test_config_reload.py | 16 ++ tests/bridge/test_options_config_loop.py | 25 ++- tests/bridge/test_peer_options_override.py | 6 +- 11 files changed, 140 insertions(+), 137 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7fafdd7..61d868c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ All notable changes to **adn-server** are documented here. Versioning follows [S ### Added +- SIGHUP reload reapplies YAML static TGs via `apply_startup_bridges()` when SYSTEMS change. - Store-native timer loops (`rule_timer`, `stat_trimmer`, `bridge_debug`, `bridge_reset`) and in-band signalling. - Store-native bridge table (OPTIONS, static TG, reflectors, UA) and OBP source ensure. - `InbandTriggers` (ON/OFF/RESET) on `Subscription` with import/export round-trip. @@ -22,6 +23,11 @@ All notable changes to **adn-server** are documented here. Versioning follows [S - Dashboard peers without RPTO inherit `TS1_STATIC` / `TS2_STATIC` from the MASTER YAML. - Inject-only monitor remap: sole connected hotspot receives dynamic UA downlink when a bridge leg is ACTIVE. +### Removed + +- Periodic 26s `options_config_loop` (V2-P2-016): OPTIONS/static TGs covered by RPTO, startup/reload, and dmrd fallback. +- Runtime `replace_store_from_bridges` calls in bridge timers and OPTIONS paths (store-native slice 6). + ## [2.0.0-alpha.2] - 2026-06-11 Subscription runtime (phase 2b/2c) closed for production Chile. diff --git a/docs/en/server/development/behaviour-and-timers.md b/docs/en/server/development/behaviour-and-timers.md index 2e3775b..182d359 100644 --- a/docs/en/server/development/behaviour-and-timers.md +++ b/docs/en/server/development/behaviour-and-timers.md @@ -19,7 +19,7 @@ The following intervals are part of the current runtime behavior: | `rule_timer` | **52s** | Bridge timeout/on-off state progression. | | `stream_trimmer` | **5s** | Stream cleanup, timeout handling, end-of-call state trimming. | | `bridge_reset` | **6s** | Bridge reset flag cleanup and pending reset completion. | -| `options_config_loop` | **26s** | Refresh static TG / reflector options from peer OPTIONS payloads. | +| OPTIONS refresh | **event-driven** | Static TG / reflector from **RPTO**, **startup/reload** (`apply_startup_bridges`), **dmrd** no-source fallback. No periodic 26s loop (**D-28**). | | `statTrimmer` | **303s** | Trim stale STAT bridges and transient status entries. | If you change one of these intervals, document the operational impact for monitoring, loop behavior, and troubleshooting. diff --git a/docs/es/server/development/behaviour-and-timers.md b/docs/es/server/development/behaviour-and-timers.md index 03b5c69..b303041 100644 --- a/docs/es/server/development/behaviour-and-timers.md +++ b/docs/es/server/development/behaviour-and-timers.md @@ -19,7 +19,7 @@ Los siguientes intervalos forman parte del comportamiento actual en ejecución: | `rule_timer` | **52s** | Progresión de timeout y estado on/off de bridges. | | `stream_trimmer` | **5s** | Limpieza de streams, manejo de timeout y cierre de estado de llamada. | | `bridge_reset` | **6s** | Limpieza de flags de reset y cierre de resets pendientes. | -| `options_config_loop` | **26s** | Refresco de TG estáticas / reflector desde payload OPTIONS de peers. | +| OPTIONS refresh | **por evento** | TG estáticas / reflector vía **RPTO**, **startup/reload** (`apply_startup_bridges`), fallback **dmrd** sin source. Sin loop periódico de 26s (**D-28**). | | `statTrimmer` | **303s** | Limpieza de bridges STAT obsoletos y estados transitorios. | Si cambias uno de estos intervalos, documenta el impacto operativo en monitorización, comportamiento de bucles y troubleshooting. diff --git a/src/adn_server/application/bridge/bridge_table.py b/src/adn_server/application/bridge/bridge_table.py index fec9842..9939394 100644 --- a/src/adn_server/application/bridge/bridge_table.py +++ b/src/adn_server/application/bridge/bridge_table.py @@ -16,7 +16,7 @@ logger = logging.getLogger(__name__) class BridgeTableMixin: - """make_single_bridge, stat/static TG, options_config_loop.""" + """make_single_bridge, stat/static TG, OPTIONS refresh (RPTO / startup / dmrd).""" def make_single_bridge( self, @@ -32,9 +32,7 @@ class BridgeTableMixin: if _tgid_s in ("9990", "9991", "9992", "9993", "9994", "9995", "9996", "9997", "9998", "9999"): _tmout = 1.0 / 6.0 from ..subscription.bridge_table_ops import make_single_bridge_store - from ..subscription.store_sync import replace_store_from_bridges - replace_store_from_bridges(self._subscription_store, self._router.get_bridges()) make_single_bridge_store( self._subscription_store, tgid_int, @@ -131,9 +129,7 @@ class BridgeTableMixin: """Legacy make_stat_bridge: on-the-fly relay bridges for OBP traffic when GEN_STAT_BRIDGES is True.""" _tgid_s = str(int_id(_tgid)) from ..subscription.bridge_table_ops import make_stat_bridge_store - from ..subscription.store_sync import replace_store_from_bridges - replace_store_from_bridges(self._subscription_store, self._router.get_bridges()) make_stat_bridge_store( self._subscription_store, _tgid, @@ -145,9 +141,7 @@ class BridgeTableMixin: def deactivate_all_dynamic_bridges(self, system_name: str) -> None: """Legacy deactivate_all_dynamic_bridges: deactivate all non-STAT, non-reflector bridges for a system (TG 4000).""" from ..subscription.bridge_table_ops import deactivate_all_dynamic_bridges_store - from ..subscription.store_sync import replace_store_from_bridges - replace_store_from_bridges(self._subscription_store, self._router.get_bridges()) deactivate_all_dynamic_bridges_store(self._subscription_store, system_name) self._export_store_to_router() @@ -164,9 +158,6 @@ class BridgeTableMixin: def apply_startup_bridges(self) -> None: """Legacy startup: set default reflectors and static TGs for each MASTER system.""" - from ..subscription.store_sync import replace_store_from_bridges - - replace_store_from_bridges(self._subscription_store, self._router.get_bridges()) prohibited_tgs = (0, 1, 2, 3, 4, 5, 9, 9990, 9991, 9992, 9993, 9994, 9995, 9996, 9997, 9998, 9999) logger.debug("(ROUTER) Setting default reflectors") for system, sys_cfg in self._config.get("SYSTEMS", {}).items(): @@ -200,8 +191,78 @@ class BridgeTableMixin: if tg in prohibited_tgs: continue self.make_static_tg(tg, 2, tmout, system) + for system, sys_cfg in self._config.get("SYSTEMS", {}).items(): + if sys_cfg.get("MODE") != "MASTER": + continue + if not sys_cfg.get("ENABLED", True): + continue + self.options_config_for_system(system) self._sync_subscription_store() + def _first_connected_peer_options(self, system_name: str) -> bytes | str | None: + """First connected peer OPTIONS (legacy options_config peer scan without 26s loop).""" + protocols = self._get_protocols() if self._get_protocols else {} + proto = protocols.get(system_name) + peers = getattr(proto, "_peers", {}) if proto is not None else {} + if not isinstance(peers, dict): + return None + for peer in peers.values(): + if isinstance(peer, dict) and peer.get("CONNECTION") == "YES" and peer.get("OPTIONS"): + return peer["OPTIONS"] + return None + + def _options_key_allows(self, system_name: str, parsed: dict[str, Any]) -> bool: + """Legacy OPTIONS KEY gate (_opt_key on MASTER).""" + sys_cfg = self._config.get("SYSTEMS", {}).get(system_name, {}) + if sys_cfg.get("_opt_key"): + if "KEY" not in parsed: + logger.debug( + "(OPTIONS) %s, options key set but no key in options string, skipping", + system_name, + ) + return False + if sys_cfg["_opt_key"] != parsed.get("KEY"): + logger.debug( + "(OPTIONS) %s, options key set but key sent does not match, skipping", + system_name, + ) + return False + elif parsed.get("KEY"): + sys_cfg["_opt_key"] = parsed["KEY"] + logger.debug( + "(OPTIONS) %s, _opt_key not set but key sent. Setting to sent key", + system_name, + ) + else: + sys_cfg["_opt_key"] = False + return True + + def _maybe_update_reflector_from_options( + self, system_name: str, parsed: dict[str, Any] + ) -> None: + """Apply DEFAULT_REFLECTOR / DIAL changes from parsed OPTIONS (legacy options_config).""" + prohibited_tgs = (0, 1, 2, 3, 4, 5, 9, 9990, 9991, 9992, 9993, 9994, 9995, 9996, 9997, 9998, 9999) + sys_cfg = self._config.get("SYSTEMS", {}).get(system_name, {}) + raw_timer = parsed.get("DEFAULT_UA_TIMER", sys_cfg.get("DEFAULT_UA_TIMER", 10)) + try: + timer_int = int(raw_timer) + tmout = float(35791394 if timer_int == 0 else timer_int) + except (TypeError, ValueError): + tmout = float(sys_cfg.get("DEFAULT_UA_TIMER", 10)) + new_ref = int(parsed.get("DEFAULT_REFLECTOR", 0) or 0) + cur_ref = int(sys_cfg.get("DEFAULT_REFLECTOR", 0) or 0) + if new_ref == cur_ref: + return + if new_ref > 0: + logger.debug("(OPTIONS) %s default reflector changed, updating", system_name) + self.reset_all_reflector_system(tmout, system_name) + self.make_default_reflector(new_ref, tmout, system_name) + elif new_ref in prohibited_tgs and not bool(new_ref): + logger.debug("(OPTIONS) %s default reflector is prohibited, ignoring change", system_name) + else: + logger.debug("(OPTIONS) %s default reflector disabled, updating", system_name) + self.reset_all_reflector_system(tmout, system_name) + def _parse_options_string(self, opt_str: bytes | str) -> dict[str, Any] | None: """Parse hotspot OPTIONS / RPTO payload into a normalized options dict.""" try: @@ -401,24 +462,22 @@ class BridgeTableMixin: ``peer_options`` from RPTO overrides YAML (inject-only proxy: OPTIONS live on each peer). """ - if not getattr(self, "_options_store_batch", False): - from ..subscription.store_sync import replace_store_from_bridges - - replace_store_from_bridges(self._subscription_store, self._router.get_bridges()) prohibited_tgs = (0, 1, 2, 3, 4, 5, 9, 9990, 9991, 9992, 9993, 9994, 9995, 9996, 9997, 9998, 9999) systems_cfg = self._config.get("SYSTEMS", {}) sys_cfg = systems_cfg.get(system_name, {}) if sys_cfg.get("MODE") != "MASTER": return try: - if peer_options is not None: - parsed_peer = self._parse_options_string(peer_options) - if parsed_peer: - self._apply_master_runtime_options(system_name, parsed_peer) - elif "OPTIONS" in sys_cfg: - parsed_sys = self._parse_options_string(sys_cfg["OPTIONS"]) - if parsed_sys: - self._apply_master_runtime_options(system_name, parsed_sys) + runtime_source: bytes | str | None = peer_options + if runtime_source is None and "OPTIONS" in sys_cfg: + runtime_source = sys_cfg["OPTIONS"] + if runtime_source is None: + runtime_source = self._first_connected_peer_options(system_name) + if runtime_source is not None: + parsed_runtime = self._parse_options_string(runtime_source) + if parsed_runtime and self._options_key_allows(system_name, parsed_runtime): + self._apply_master_runtime_options(system_name, parsed_runtime) + self._maybe_update_reflector_from_options(system_name, parsed_runtime) source_opt: bytes | str | None = peer_options if source_opt is None and "OPTIONS" in sys_cfg: @@ -715,95 +774,3 @@ class BridgeTableMixin: if len(lines) > 1: logger.debug("\n".join(lines)) - def options_config_loop(self) -> None: - """Legacy options_config: parse OPTIONS from MASTER systems and update bridges (default reflector, static TGs).""" - batch_store = True - if batch_store: - from ..subscription.store_sync import replace_store_from_bridges - - replace_store_from_bridges(self._subscription_store, self._router.get_bridges()) - self._options_store_batch = True - prohibited_tgs = (0, 1, 2, 3, 4, 5, 9, 9990, 9991, 9992, 9993, 9994, 9995, 9996, 9997, 9998, 9999) - logger.debug("(OPTIONS) Running options parser") - systems_cfg = self._config.get("SYSTEMS", {}) - try: - for _system in list(systems_cfg.keys()): - try: - if systems_cfg.get(_system, {}).get("MODE") != "MASTER": - continue - if not systems_cfg.get(_system, {}).get("ENABLED", True): - continue - opt_str: bytes | str | None = systems_cfg.get(_system, {}).get("OPTIONS") - if opt_str is None: - protocols = self._get_protocols() if self._get_protocols else {} - proto = protocols.get(_system) - peers = getattr(proto, "_peers", {}) if proto is not None else {} - if isinstance(peers, dict): - for peer in peers.values(): - if isinstance(peer, dict) and peer.get("CONNECTION") == "YES" and peer.get("OPTIONS"): - opt_str = peer["OPTIONS"] - if opt_str is None: - continue - _options = self._parse_options_string(opt_str) - if not _options: - continue - logger.debug("(OPTIONS) Options found for %s", _system) - if "_opt_key" in systems_cfg[_system] and systems_cfg[_system].get("_opt_key"): - if "KEY" not in _options: - logger.debug("(OPTIONS) %s, options key set but no key in options string, skipping", _system) - continue - if systems_cfg[_system]["_opt_key"] != _options.get("KEY"): - logger.debug("(OPTIONS) %s, options key set but key sent does not match, skipping", _system) - continue - elif _options.get("KEY"): - systems_cfg[_system]["_opt_key"] = _options["KEY"] - logger.debug("(OPTIONS) %s, _opt_key not set but key sent. Setting to sent key", _system) - else: - systems_cfg[_system]["_opt_key"] = False - logger.debug("(OPTIONS) %s, _opt_key not set and no key sent. Set to false", _system) - self._apply_master_runtime_options(_system, _options) - _options.setdefault("TS1_STATIC", False) - _options.setdefault("TS2_STATIC", False) - _options.setdefault("DEFAULT_REFLECTOR", 0) - _options.setdefault("OVERRIDE_IDENT_TG", False) - _options.setdefault("DEFAULT_UA_TIMER", systems_cfg[_system].get("DEFAULT_UA_TIMER", 10)) - if "TS1_STATIC" not in _options or "TS2_STATIC" not in _options or "DEFAULT_REFLECTOR" not in _options or "DEFAULT_UA_TIMER" not in _options: - logger.debug("(OPTIONS) %s - Required field missing, ignoring", _system) - continue - if _options["TS1_STATIC"] == "": - _options["TS1_STATIC"] = False - if _options["TS2_STATIC"] == "": - _options["TS2_STATIC"] = False - if _options.get("TS1_STATIC") and re.search(r"[^\d,]", str(_options["TS1_STATIC"])): - logger.debug("(OPTIONS) %s - TS1_STATIC contains characters other than numbers and comma, ignoring", _system) - continue - if _options.get("TS2_STATIC") and re.search(r"[^\d,]", str(_options["TS2_STATIC"])): - logger.debug("(OPTIONS) %s - TS2_STATIC contains characters other than numbers and comma, ignoring", _system) - continue - for key in ("DEFAULT_REFLECTOR", "OVERRIDE_IDENT_TG", "DEFAULT_UA_TIMER"): - if isinstance(_options.get(key), str) and not str(_options[key]).isdigit(): - logger.debug("(OPTIONS) %s - %s is not an integer, ignoring", _system, key) - continue - if int(_options.get("DEFAULT_UA_TIMER", 0)) == 0: - _options["DEFAULT_UA_TIMER"] = 35791394 - _tmout = float(int(_options["DEFAULT_UA_TIMER"])) - new_ref = int(_options.get("DEFAULT_REFLECTOR", 0)) - cur_ref = int(systems_cfg[_system].get("DEFAULT_REFLECTOR", 0)) - if new_ref != cur_ref: - if new_ref > 0: - logger.debug("(OPTIONS) %s default reflector changed, updating", _system) - self.reset_all_reflector_system(_tmout, _system) - self.make_default_reflector(new_ref, _tmout, _system) - elif new_ref in prohibited_tgs and not bool(new_ref): - logger.debug("(OPTIONS) %s default reflector is prohibited, ignoring change", _system) - else: - logger.debug("(OPTIONS) %s default reflector disabled, updating", _system) - self.reset_all_reflector_system(_tmout, _system) - self.options_config_for_system(_system) - except Exception as e: - logger.exception("(OPTIONS) caught exception: %s", e) - finally: - if batch_store: - self._options_store_batch = False - self._sync_subscription_store() - diff --git a/src/adn_server/application/bridge/timers.py b/src/adn_server/application/bridge/timers.py index 63de9dc..81b95f7 100644 --- a/src/adn_server/application/bridge/timers.py +++ b/src/adn_server/application/bridge/timers.py @@ -20,8 +20,7 @@ class BridgeTimerMixin: def rule_timer_loop(self) -> None: """Run one iteration of rule_timer_loop (legacy 52s LoopingCall). Activate/deactivate by timeout.""" from ..subscription.rule_timer_ops import apply_rule_timer_store - from ..subscription.store_sync import replace_store_from_bridges - replace_store_from_bridges(self._subscription_store, self._router.get_bridges()) + apply_rule_timer_store( self._subscription_store, self._config.get("SYSTEMS", {}), @@ -33,8 +32,7 @@ class BridgeTimerMixin: """Legacy bridgeDebug (bridge_master.py 487-543): remove invalid bridges, fix >1 active dial per MASTER.""" logger.debug("(BRIDGEDEBUG) Running bridge debug") from ..subscription.bridge_debug_ops import apply_bridge_debug_store - from ..subscription.store_sync import replace_store_from_bridges - replace_store_from_bridges(self._subscription_store, self._router.get_bridges()) + apply_bridge_debug_store( self._subscription_store, self._config.get("SYSTEMS", {}), @@ -50,8 +48,7 @@ class BridgeTimerMixin: De-activation distinguishes SINGLE_MODE True/False (legacy ~3484-3548). """ from ..subscription.in_band_signalling_ops import apply_in_band_signalling_store - from ..subscription.store_sync import replace_store_from_bridges - replace_store_from_bridges(self._subscription_store, self._router.get_bridges()) + apply_in_band_signalling_store( self._subscription_store, system_name, @@ -319,8 +316,7 @@ class BridgeTimerMixin: deactivate_system_legs_store, restore_prohibited_static_legs_store, ) - from ..subscription.store_sync import replace_store_from_bridges - replace_store_from_bridges(self._subscription_store, self._router.get_bridges()) + now = time.time() for system_name in list(systems_cfg.keys()): sys_cfg = systems_cfg.get(system_name, {}) @@ -357,7 +353,6 @@ class BridgeTimerMixin: """Trim STAT-only bridges with no ON/OFF in use (legacy statTrimmer, 303s).""" logger.debug("(ROUTER) STAT trimmer loop started") from ..subscription.stat_trimmer_ops import apply_stat_trimmer_store - from ..subscription.store_sync import replace_store_from_bridges - replace_store_from_bridges(self._subscription_store, self._router.get_bridges()) + apply_stat_trimmer_store(self._subscription_store) self._export_store_to_router() diff --git a/src/adn_server/application/subscription/store_sync.py b/src/adn_server/application/subscription/store_sync.py index 2a268f7..240184b 100644 --- a/src/adn_server/application/subscription/store_sync.py +++ b/src/adn_server/application/subscription/store_sync.py @@ -1,4 +1,8 @@ -"""Keep ``SubscriptionStore`` aligned with legacy ``BRIDGES`` (read-only mirror; not routing authority yet).""" +"""Import legacy ``BRIDGES`` snapshots into ``SubscriptionStore`` (tests and harness only). + +Runtime paths mutate the store directly and publish via ``export_bridges``; they must not +call ``replace_store_from_bridges`` — that would overwrite store authority with the shim. +""" from __future__ import annotations diff --git a/src/adn_server/infrastructure/bootstrap/peer_server.py b/src/adn_server/infrastructure/bootstrap/peer_server.py index 7dea2ab..4c03b32 100644 --- a/src/adn_server/infrastructure/bootstrap/peer_server.py +++ b/src/adn_server/infrastructure/bootstrap/peer_server.py @@ -521,6 +521,7 @@ def run_peer_server( return not is_proxy_inject_only(config, system_name) def _on_config_systems_changed() -> None: + bridge_use_cases.apply_startup_bridges() reporting_use_cases.send_config(config.get("SYSTEMS", {})) reporting_use_cases.send_bridge(get_bridges()) user_passwords_loader.load(config) @@ -611,7 +612,6 @@ def run_peer_server( reactor.callInThread(ident_use_cases.run_ident) task.LoopingCall(ident_loop).start(3600).addErrback(_looping_errback, logger) - task.LoopingCall(bridge_use_cases.options_config_loop).start(26).addErrback(_looping_errback, logger) task.LoopingCall(bridge_use_cases.log_connected_systems_and_tgs).start(60).addErrback(_looping_errback, logger) task.LoopingCall(lambda: logger.debug("(ROUTER) KeepAlive reporting loop started")).start(60).addErrback(_looping_errback, logger) diff --git a/tests/README.md b/tests/README.md index d6cf499..658f3de 100644 --- a/tests/README.md +++ b/tests/README.md @@ -55,7 +55,7 @@ Mark new stack tests with `@pytest.mark.integration`. | `test_config_reload.py` | 1 | Merge system config on reload | | `test_crc_dedup.py` | 3 | HBP/OBP CRC dedup, seq=0 | | `test_echo_bridgereset.py` | 5 | Echo leg after BRIDGERESET / OPTIONS | -| `test_options_config_loop.py` | 2 | 26s OPTIONS static TG | +| `test_options_config_loop.py` | 3 | OPTIONS paths (RPTO/startup; no 26s loop) | | `test_private_voice.py` | 3 | Private call routing | | `test_startup_bridges.py` | 4 | Startup BRIDGES + voice E2E | | `test_static_tg_options.py` | 4 | Static TG from peer OPTIONS | diff --git a/tests/bridge/test_config_reload.py b/tests/bridge/test_config_reload.py index 570b622..41e7b7a 100644 --- a/tests/bridge/test_config_reload.py +++ b/tests/bridge/test_config_reload.py @@ -36,3 +36,19 @@ def test_merge_system_config_preserves_peers_and_static_tgs() -> None: assert merged["TS2_STATIC"] == old["TS2_STATIC"] assert merged["_options_static_apply_fp"] == old["_options_static_apply_fp"] assert merged["GROUP_HANGTIME"] == 3 + + +def test_merge_new_master_keeps_yaml_static_tg() -> None: + """New MASTER on reload keeps YAML static lists (apply_startup_bridges materializes them).""" + new = { + "MODE": "MASTER", + "ENABLED": True, + "IP": "127.0.0.1", + "PORT": 62032, + "TS2_STATIC": "52090", + "DEFAULT_UA_TIMER": 10, + } + + merged = merge_system_config({}, new) + + assert merged["TS2_STATIC"] == "52090" diff --git a/tests/bridge/test_options_config_loop.py b/tests/bridge/test_options_config_loop.py index bde6475..8b1cd55 100644 --- a/tests/bridge/test_options_config_loop.py +++ b/tests/bridge/test_options_config_loop.py @@ -1,32 +1,47 @@ -"""OPTIONS timer loop (options_config_loop, 26s refresh).""" +"""OPTIONS refresh paths (RPTO, startup, connected peer — no 26s loop, V2-P2-016).""" from __future__ import annotations from tests.harness.deterministic import DeterministicScenario -def test_options_config_loop_applies_static_tg_from_options() -> None: +def test_options_config_for_system_applies_static_tg_from_yaml_options() -> None: config = DeterministicScenario().config config["SYSTEMS"]["MASTER-A"]["OPTIONS"] = "TS2=52090;TIMER=10" config["SYSTEMS"]["MASTER-A"]["TS1_STATIC"] = "" config["SYSTEMS"]["MASTER-A"]["TS2_STATIC"] = "" scenario = DeterministicScenario(config=config) - scenario.bridge.options_config_loop() + scenario.bridge.options_config_for_system("MASTER-A") sys_cfg = scenario.config["SYSTEMS"]["MASTER-A"] assert sys_cfg["TS2_STATIC"] == "52090" assert "52090" in scenario.bridge.get_bridges() -def test_options_config_loop_skips_master_without_options() -> None: +def test_options_config_for_system_skips_master_without_options() -> None: config = DeterministicScenario().config config["SYSTEMS"]["MASTER-A"].pop("OPTIONS", None) config["SYSTEMS"]["MASTER-B"]["OPTIONS"] = "TS2=91;TIMER=10" config["SYSTEMS"]["MASTER-B"]["TS2_STATIC"] = "" scenario = DeterministicScenario(config=config) - scenario.bridge.options_config_loop() + scenario.bridge.options_config_for_system("MASTER-A") + scenario.bridge.options_config_for_system("MASTER-B") assert scenario.config["SYSTEMS"]["MASTER-A"].get("TS2_STATIC", "") == "" assert scenario.config["SYSTEMS"]["MASTER-B"]["TS2_STATIC"] == "91" + + +def test_apply_startup_runs_options_config_for_each_master() -> None: + """Startup/reload path: YAML OPTIONS without TS*_STATIC still materializes bridges.""" + config = DeterministicScenario().config + config["SYSTEMS"]["MASTER-A"]["OPTIONS"] = "TS2=52090;TIMER=10" + config["SYSTEMS"]["MASTER-A"]["TS1_STATIC"] = "" + config["SYSTEMS"]["MASTER-A"]["TS2_STATIC"] = "" + scenario = DeterministicScenario(config=config) + + scenario.bridge.apply_startup_bridges() + + assert scenario.config["SYSTEMS"]["MASTER-A"]["TS2_STATIC"] == "52090" + assert "52090" in scenario.bridge.get_bridges() diff --git a/tests/bridge/test_peer_options_override.py b/tests/bridge/test_peer_options_override.py index 485fb03..cb919f1 100644 --- a/tests/bridge/test_peer_options_override.py +++ b/tests/bridge/test_peer_options_override.py @@ -58,12 +58,12 @@ def test_rpto_single_and_timer_override_yaml() -> None: assert scenario.bridge.get_bridges()["52090"][0]["TIMEOUT"] == 300.0 -def test_options_loop_reads_connected_peer_without_yaml_options() -> None: +def test_options_config_reads_connected_peer_without_yaml_options() -> None: scenario = _proxy_system_scenario(single_mode_yaml=False) bridges = active_bridge(730444, (("MASTER-A", 2), ("MASTER-B", 2))) scenario.router.set_bridges(bridges) - scenario.bridge.options_config_loop() + scenario.bridge.options_config_for_system("MASTER-A") assert scenario.config["SYSTEMS"]["MASTER-A"]["SINGLE_MODE"] is True @@ -224,7 +224,7 @@ def test_peer_timer_does_not_override_other_peer_static_tg_timeout() -> None: def test_make_static_tg_preserves_single_mode_deactivation_on_options_refresh() -> None: - """OPTIONS loop (make_static_tg) must not re-activate TGs turned off by SINGLE VTERM.""" + """OPTIONS refresh (make_static_tg) must not re-activate TGs turned off by SINGLE VTERM.""" scenario = _proxy_system_scenario(single_mode_yaml=True) bridges = { "730444": [