diff --git a/src/adn_server/infrastructure/bootstrap/peer_server.py b/src/adn_server/infrastructure/bootstrap/peer_server.py index cb0610d..c6b221d 100644 --- a/src/adn_server/infrastructure/bootstrap/peer_server.py +++ b/src/adn_server/infrastructure/bootstrap/peer_server.py @@ -698,10 +698,11 @@ def run_peer_server( n = reopen_file_handlers() logger.info("(LOGGER) Reopened %s file log handler(s) after SIGUSR2", n) - def _create_hbp_protocol(system_name: str) -> Any: + def _create_hbp_protocol(system_name: str, system_config: dict[str, Any] | None = None) -> Any: + # Reload passes the config it is building: a system added there is not in the live one yet. return HBPProtocolFactory( system_name, - config, + config if system_config is None else system_config, report_sender, router=acl_router, dmrd_received=routing_use_cases.dmrd_received, diff --git a/src/adn_server/infrastructure/config_reload.py b/src/adn_server/infrastructure/config_reload.py index c8e0478..c0edc66 100644 --- a/src/adn_server/infrastructure/config_reload.py +++ b/src/adn_server/infrastructure/config_reload.py @@ -195,7 +195,7 @@ def reload_server_config( protocols: dict[str, Any], transports: dict[str, Any], *, - create_protocol: Callable[[str], Any], + create_protocol: Callable[[str, dict[str, Any]], Any], listen_udp: Callable[[str, BindSpec, Any], Any], stop_listener: Callable[[Any], None], on_systems_changed: Callable[[], None] | None = None, @@ -209,6 +209,11 @@ def reload_server_config( Preserves PEERS / STATS and protocol STATUS for systems that stay up with the same bind address. Returns a Deferred that fires on the reactor thread when listeners are rebound (waits for ``stopListening`` before re-bind). + + ``create_protocol(name, config)`` gets the config being reloaded: a new + system exists only there until the caller swaps it in, so a protocol built + from the live config would see no system block at all (no MODE: a MASTER + that answers nothing). """ log = log or logger try: @@ -270,7 +275,7 @@ def reload_server_config( for name in sorted(new_enabled - mapped_old_enabled): sys_cfg = copy.deepcopy(new_systems[name]) config.setdefault("SYSTEMS", {})[name] = sys_cfg - proto = create_protocol(name) + proto = create_protocol(name, config) protocols[name] = proto if should_bind_udp is None or should_bind_udp(name, sys_cfg): _schedule_start(name, sys_cfg, proto, bind_spec(sys_cfg)) @@ -294,7 +299,7 @@ def reload_server_config( inject_only = should_bind_udp is not None and not should_bind_udp(name, merged) was_inject_only = should_bind_udp is not None and not should_bind_udp(name, old_cfg) if proto is None: - proto = create_protocol(name) + proto = create_protocol(name, config) protocols[name] = proto if inject_only: transports.pop(name, None) diff --git a/tests/infrastructure/test_config_reload_udp.py b/tests/infrastructure/test_config_reload_udp.py index 8d1f1e8..04373ff 100644 --- a/tests/infrastructure/test_config_reload_udp.py +++ b/tests/infrastructure/test_config_reload_udp.py @@ -152,7 +152,7 @@ def test_reload_collapsed_generator_migrates_listener_without_rebind() -> None: _Loader(), protocols, transports, - create_protocol=lambda name: _FakeProto(name), + create_protocol=lambda name, _cfg: _FakeProto(name), listen_udp=_listen, stop_listener=lambda port: port.stopListening() if port else None, ).addCallback(_done) @@ -223,7 +223,7 @@ def test_reload_defers_rebind_until_stop_completes() -> None: _Loader(), protocols, transports, - create_protocol=lambda name: _FakeProto(name), + create_protocol=lambda name, _cfg: _FakeProto(name), listen_udp=_listen, stop_listener=lambda port: port.stopListening() if port else None, ) @@ -236,3 +236,61 @@ def test_reload_defers_rebind_until_stop_completes() -> None: stop_fired.callback(None) reactor.runUntilCurrent() assert listen_calls == [52556] + + +def test_a_master_added_on_reload_answers_its_peers() -> None: + """Regression (ADN 2131, 25-sep-2026): a MASTER added by SIGHUP logged "added system ... + listening" but answered nothing. Its protocol was built from the live config, where the + new system did not exist yet (it is swapped in after the reload): no MODE, no login.""" + from adn_server.application.runtime_context import ( + ConfigProxy, + RuntimeContext, + RuntimeContextHolder, + prepare_reload_config, + swap_runtime_config, + ) + from adn_server.infrastructure.acl_router import InMemoryAclRouter + from adn_server.infrastructure.config_loader import process_acls + from adn_server.infrastructure.twisted_adapters.udp_hbp import HBPProtocolFactory + from tests.support.hbp_repeat_stack import RecordingTransport + + holder = RuntimeContextHolder(RuntimeContext(config=_base_config())) + live = ConfigProxy(holder) # what the server hands its protocols + incoming = _base_config() + incoming["SYSTEMS"]["SMS-NEW"] = { + "MODE": "MASTER", "ENABLED": True, "IP": "", "PORT": 62999, "PASSPHRASE": "secret", + "MAX_PEERS": 1, "REPEAT": True, "USE_ACL": True, "REG_ACL": "PERMIT:ALL", "SUB_ACL": "PERMIT:ALL", + "TGID_TS1_ACL": "PERMIT:ALL", "TGID_TS2_ACL": "PERMIT:ALL", "ALLOW_UNREG_ID": True, + } + + class _Loader(YamlConfigLoader): + def load(self, _path: str) -> dict[str, Any]: + process_acls(incoming) # as the real loader does + return incoming + + def _create(name: str, system_config: dict[str, Any] | None = None) -> Any: + # Mirrors peer_server._create_hbp_protocol: live config unless reload passes its own. + return HBPProtocolFactory(name, live if system_config is None else system_config, router=InMemoryAclRouter()) + + new_config = prepare_reload_config(holder) + protocols = {name: _FakeProto(name) for name in _base_config()["SYSTEMS"]} + reload_server_config( + new_config, + "adn-server.yaml", + _Loader(), + protocols, + {name: _FakePort() for name in protocols}, + create_protocol=_create, + listen_udp=lambda _n, _b, _p: _FakePort(), + stop_listener=lambda port: port.stopListening() if port else None, + ) + from twisted.internet import reactor + + reactor.runUntilCurrent() + swap_runtime_config(holder, new_config) + + proto = protocols["SMS-NEW"] + assert proto._config.get("MODE") == "MASTER" + proto.transport = RecordingTransport() + proto.datagramReceived(b"RPTL" + (213003590).to_bytes(4, "big"), ("127.0.0.1", 50000)) + assert proto.transport.sent and proto.transport.sent[0][0].startswith(b"RPTACK")