From ff18e0db150d24605d4194517f6ff0b54f2c8697 Mon Sep 17 00:00:00 2001 From: Pavan Kumar Date: Fri, 27 Mar 2026 14:50:29 +0530 Subject: [PATCH 1/3] Fix worker crash when no ingress lists are configured (CON-510) Guard against calling blpop with an empty list, which raises a ResponseError and crashes the worker. Now logs a warning and retries after 15s, keeping the worker alive until a valid config is available. Co-Authored-By: Claude Sonnet 4.6 --- server/main.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/server/main.py b/server/main.py index 0ec4815..56f5387 100644 --- a/server/main.py +++ b/server/main.py @@ -642,7 +642,12 @@ def worker_loop(worker_id: int) -> None: ingress_chain_map = get_ingress_chain_map() all_ingress_lists = list(ingress_chain_map.keys()) logger.debug("[%s] Monitoring ingress lists: %s", worker_name, all_ingress_lists) - + + if not all_ingress_lists: + logger.warning("[%s] No ingress lists configured, retrying in 15s", worker_name) + time.sleep(15) + continue + logger.debug("[%s] Waiting for vCon on ingress lists (timeout: 15s)", worker_name) popped_item = r.blpop(all_ingress_lists, timeout=15) if not popped_item: From 034f503fcc52111b8c0b7c9f7e9bea9a33e1b146 Mon Sep 17 00:00:00 2001 From: Pavan Kumar Date: Fri, 3 Apr 2026 13:11:53 +0530 Subject: [PATCH 2/3] Fix crash when config.yml is null/empty (CON-510) yaml.safe_load returns None for all-comment or empty files, causing AttributeError when get_config() callers call .get() on the result. Return {} instead so the worker falls through to the existing 'no ingress lists' retry path. Co-Authored-By: Claude Sonnet 4.6 --- server/config.py | 2 +- server/tests/test_config_null.py | 94 ++++++++++++++++++++++++++++++++ 2 files changed, 95 insertions(+), 1 deletion(-) create mode 100644 server/tests/test_config_null.py diff --git a/server/config.py b/server/config.py index 7643aba..8362c45 100644 --- a/server/config.py +++ b/server/config.py @@ -8,7 +8,7 @@ def get_config() -> dict: """This is to keep logic of accessing config in one place""" global _config with open(settings.CONSERVER_CONFIG_FILE) as file: - _config = yaml.safe_load(file) + _config = yaml.safe_load(file) or {} return _config diff --git a/server/tests/test_config_null.py b/server/tests/test_config_null.py new file mode 100644 index 0000000..7b3b7ff --- /dev/null +++ b/server/tests/test_config_null.py @@ -0,0 +1,94 @@ +"""Tests for get_config() handling of null/empty config files (CON-510).""" + +import io +import pytest +from unittest.mock import patch, mock_open + + +class TestGetConfigNullHandling: + """Test that get_config() returns {} when yaml.safe_load returns None.""" + + def test_get_config_returns_empty_dict_when_yaml_is_null(self): + """All-comment YAML makes yaml.safe_load return None; get_config must return {}.""" + import importlib + import config + importlib.reload(config) + from config import get_config + + # yaml.safe_load returns None for a file with only comments + null_yaml = "# just a comment\n" + with patch("builtins.open", mock_open(read_data=null_yaml)): + result = get_config() + + assert result == {}, f"Expected {{}} but got {result!r}" + + def test_get_config_returns_empty_dict_when_file_is_empty(self): + """Completely empty file also makes yaml.safe_load return None.""" + import importlib + import config + importlib.reload(config) + from config import get_config + + with patch("builtins.open", mock_open(read_data="")): + result = get_config() + + assert result == {}, f"Expected {{}} but got {result!r}" + + def test_get_config_returns_dict_when_valid(self): + """Normal config file should be returned as-is.""" + import importlib + import config + importlib.reload(config) + from config import get_config + + valid_yaml = "chains:\n my_chain:\n ingress_lists:\n - my_queue\n" + with patch("builtins.open", mock_open(read_data=valid_yaml)): + result = get_config() + + assert result == {"chains": {"my_chain": {"ingress_lists": ["my_queue"]}}} + + +class TestGetIngressChainMapNullConfig: + """Test that get_ingress_chain_map() does not crash when config is null.""" + + def test_get_ingress_chain_map_logic_with_null_config(self): + """Simulate get_ingress_chain_map() logic when config is {} (null yaml result). + + The real function does: + chains = config.get("chains", {}) + for chain_name, chain_config in chains.items(): ... + This must not crash when config is {}. + """ + import importlib + import config + importlib.reload(config) + from config import get_config + + null_yaml = "# only comments\n" + with patch("builtins.open", mock_open(read_data=null_yaml)): + cfg = get_config() + + # Replicate get_ingress_chain_map logic + chains = cfg.get("chains", {}) + ingress_details = {} + for chain_name, chain_config in chains.items(): + for ingress_list in chain_config.get("ingress_lists", []): + ingress_details[ingress_list] = {"name": chain_name, **chain_config} + + assert ingress_details == {} + + def test_worker_loop_does_not_crash_on_null_config(self): + """Simulate the worker_loop config reload path with a null config.""" + import importlib + import config + importlib.reload(config) + from config import get_config + + # Verify that calling .get() on the result of get_config() doesn't raise + null_yaml = "# nothing here\n" + with patch("builtins.open", mock_open(read_data=null_yaml)): + cfg = get_config() + + # This is exactly what get_ingress_chain_map() does — must not raise + chains = cfg.get("chains", {}) + assert chains == {} From 12216fcd773d879e4d7d18fe027cec6a079c3df6 Mon Sep 17 00:00:00 2001 From: Pavan Kumar Date: Fri, 3 Apr 2026 13:33:02 +0530 Subject: [PATCH 3/3] fix(tests): remove importlib.reload(config) to restore mock isolation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit importlib.reload(config) replaced the Configuration class object, breaking @patch("config.Configuration.get_ingress_auth") in test_external_ingress.py — api.py held the old class reference so mocks had no effect. Move get_config import to module level instead. Co-Authored-By: Claude Sonnet 4.6 --- server/tests/test_config_null.py | 28 +--------------------------- 1 file changed, 1 insertion(+), 27 deletions(-) diff --git a/server/tests/test_config_null.py b/server/tests/test_config_null.py index 7b3b7ff..10bdb98 100644 --- a/server/tests/test_config_null.py +++ b/server/tests/test_config_null.py @@ -1,8 +1,7 @@ """Tests for get_config() handling of null/empty config files (CON-510).""" -import io -import pytest from unittest.mock import patch, mock_open +from config import get_config class TestGetConfigNullHandling: @@ -10,11 +9,6 @@ class TestGetConfigNullHandling: def test_get_config_returns_empty_dict_when_yaml_is_null(self): """All-comment YAML makes yaml.safe_load return None; get_config must return {}.""" - import importlib - import config - importlib.reload(config) - from config import get_config - # yaml.safe_load returns None for a file with only comments null_yaml = "# just a comment\n" with patch("builtins.open", mock_open(read_data=null_yaml)): @@ -24,11 +18,6 @@ def test_get_config_returns_empty_dict_when_yaml_is_null(self): def test_get_config_returns_empty_dict_when_file_is_empty(self): """Completely empty file also makes yaml.safe_load return None.""" - import importlib - import config - importlib.reload(config) - from config import get_config - with patch("builtins.open", mock_open(read_data="")): result = get_config() @@ -36,11 +25,6 @@ def test_get_config_returns_empty_dict_when_file_is_empty(self): def test_get_config_returns_dict_when_valid(self): """Normal config file should be returned as-is.""" - import importlib - import config - importlib.reload(config) - from config import get_config - valid_yaml = "chains:\n my_chain:\n ingress_lists:\n - my_queue\n" with patch("builtins.open", mock_open(read_data=valid_yaml)): result = get_config() @@ -59,11 +43,6 @@ def test_get_ingress_chain_map_logic_with_null_config(self): for chain_name, chain_config in chains.items(): ... This must not crash when config is {}. """ - import importlib - import config - importlib.reload(config) - from config import get_config - null_yaml = "# only comments\n" with patch("builtins.open", mock_open(read_data=null_yaml)): cfg = get_config() @@ -79,11 +58,6 @@ def test_get_ingress_chain_map_logic_with_null_config(self): def test_worker_loop_does_not_crash_on_null_config(self): """Simulate the worker_loop config reload path with a null config.""" - import importlib - import config - importlib.reload(config) - from config import get_config - # Verify that calling .get() on the result of get_config() doesn't raise null_yaml = "# nothing here\n" with patch("builtins.open", mock_open(read_data=null_yaml)):