From df2967f5e0469ef98e8fcb9a5cc76720d5f5271f Mon Sep 17 00:00:00 2001 From: Yizhen Zhang Date: Mon, 24 Aug 2026 22:11:47 +0300 Subject: [PATCH] [counterpoll] Fix bash tab-completion by not requiring CONFIG_DB at import register_dynamic_commands() runs at import time and calls ConfigDBConnector().connect(). The bash-completion generator imports every CLI module in a redis-less build container to emit the completion scripts; the counterpoll import raises there, so no /etc/bash_completion.d/counterpoll file is generated and "counterpoll " does not complete on the switch, while config/show complete fine. Catch the RuntimeError swsscommon raises when CONFIG_DB is unreachable, log a warning, and skip the DPU-only dynamic commands instead of failing the import; unrelated errors still propagate. Completion is dynamic, so on a DPU with CONFIG_DB up the eni/ha_set commands are still registered and still complete. Add regression tests for both the swallowed RuntimeError and the propagated unexpected error. Signed-off-by: Yizhen Zhang --- counterpoll/main.py | 18 +++++++++++++++--- tests/counterpoll_test.py | 22 ++++++++++++++++++++++ 2 files changed, 37 insertions(+), 3 deletions(-) diff --git a/counterpoll/main.py b/counterpoll/main.py index c3be705f0..443024cf3 100755 --- a/counterpoll/main.py +++ b/counterpoll/main.py @@ -1,11 +1,13 @@ import click import os -from sonic_py_common import device_info, multi_asic +from sonic_py_common import device_info, multi_asic, logger from tabulate import tabulate from flow_counter_util.route import exit_if_route_flow_counter_not_support from swsscommon.swsscommon import ConfigDBConnector, SonicDBConfig from swsscommon.swsscommon import CFG_FLEX_COUNTER_TABLE_NAME as CFG_FLEX_COUNTER_TABLE +log = logger.Logger("counterpoll") + BUFFER_POOL_WATERMARK = "BUFFER_POOL_WATERMARK" PORT_BUFFER_DROP = "PORT_BUFFER_DROP" PORT_PHY_ATTR = "PORT_PHY_ATTR" @@ -870,8 +872,18 @@ def register_dynamic_commands(cmds): """ Dynamically register commands based on condition callback. """ - db = ConfigDBConnector() - db.connect() + try: + db = ConfigDBConnector() + db.connect() + except RuntimeError as e: + # Runs at import time. swsscommon raises RuntimeError when CONFIG_DB is + # unreachable (e.g. build-time completion generation in a redis-less + # container). Skip DPU-only dynamic commands rather than fail the import, + # else the completion file is never generated. + log.log_warning( + "CONFIG_DB not available at import, skipping DPU dynamic commands " + "(eni/ha_set): {}".format(e)) + return for cmd, cb in cmds: if cb(db): cli.add_command(cmd) diff --git a/tests/counterpoll_test.py b/tests/counterpoll_test.py index 3bb636789..7d145b4f3 100644 --- a/tests/counterpoll_test.py +++ b/tests/counterpoll_test.py @@ -543,6 +543,28 @@ def test_tunnel_interval(self): table = db.cfgdb.get_table("FLEX_COUNTER_TABLE") assert test_interval == table["TUNNEL"]["POLL_INTERVAL"] + def test_register_dynamic_commands_survives_db_connect_failure(self): + # Importing counterpoll.main must not require a live CONFIG_DB. The + # build-time bash-completion generator imports every CLI module in a + # redis-less container; if the connect failure (RuntimeError) + # propagates from register_dynamic_commands (which runs at import), the + # generator drops the counterpoll completion file and "counterpoll " + # stops working. + mock_conn = mock.Mock() + mock_conn.connect.side_effect = RuntimeError("Unable to connect to redis") + with mock.patch.object(counterpoll, "ConfigDBConnector", return_value=mock_conn): + # Must not raise even though connect() fails. + counterpoll.register_dynamic_commands(counterpoll.dynamic_commands) + + def test_register_dynamic_commands_propagates_unexpected_error(self): + # Only swsscommon connection failures (RuntimeError) are tolerated; + # unrelated errors must still surface rather than be silently swallowed. + mock_conn = mock.Mock() + mock_conn.connect.side_effect = ValueError("unexpected") + with mock.patch.object(counterpoll, "ConfigDBConnector", return_value=mock_conn): + with pytest.raises(ValueError): + counterpoll.register_dynamic_commands(counterpoll.dynamic_commands) + @classmethod def teardown_class(cls): print("TEARDOWN")