From 6ee24338d98dd7bb2d45f8b165505282c4ec8ba4 Mon Sep 17 00:00:00 2001 From: William Bergamin Date: Tue, 8 Sep 2026 12:08:05 -0400 Subject: [PATCH] fix(socket_mode): shut down current_session_runner in built-in close() The built-in SocketModeClient starts three IntervalRunner daemon threads in __init__ (current_session_runner, current_app_monitor, message_processor) but close() only shut down two of them, leaking current_session_runner on every closed instance. Long-lived processes that open and close many clients accumulate one idle thread per closed instance (issue #1873). The sync sibling websocket_client.close() already shuts its current_session_runner down, and the async backends cancel all their futures. The leak was built-in only. Adding the shutdown alone is not enough: current_session_runner runs run_until_completion, whose loop exits only when the connection state is terminated. disconnect() does not set that flag, and shutdown() joins with no timeout, so the join would hang on a connected client. close() now sets current_session_state.terminated = True before disconnecting (mirroring connect()'s handling of a retired session), letting the loop return so the join completes. This is the CI hang the earlier #1874 attempt ran into. Adds a regression test on the connected path (connect then close) asserting all three runners are reaped; it hangs under pytest-timeout if terminated is not set, so it guards both the leak and the deadlock. Refs #1873. Supersedes #1874. Co-Authored-By: Claude --- slack_sdk/socket_mode/builtin/client.py | 5 ++++- .../socket_mode/test_interactions_builtin.py | 22 +++++++++++++++++++ 2 files changed, 26 insertions(+), 1 deletion(-) diff --git a/slack_sdk/socket_mode/builtin/client.py b/slack_sdk/socket_mode/builtin/client.py index a5e902b5e..132de10db 100644 --- a/slack_sdk/socket_mode/builtin/client.py +++ b/slack_sdk/socket_mode/builtin/client.py @@ -221,10 +221,13 @@ def send_message(self, message: str) -> None: ) raise e - def close(self): + def close(self) -> None: self.closed = True self.auto_reconnect_enabled = False + self.current_session_state.terminated = True self.disconnect() + if self.current_session_runner.is_alive(): + self.current_session_runner.shutdown() if self.current_app_monitor.is_alive(): self.current_app_monitor.shutdown() if self.message_processor.is_alive(): diff --git a/tests/slack_sdk/socket_mode/test_interactions_builtin.py b/tests/slack_sdk/socket_mode/test_interactions_builtin.py index 3f53e4cd1..216453afe 100644 --- a/tests/slack_sdk/socket_mode/test_interactions_builtin.py +++ b/tests/slack_sdk/socket_mode/test_interactions_builtin.py @@ -145,3 +145,25 @@ def test_send_message_while_disconnection(self): client.send_message("foo") finally: client.close() + + def test_close_reaps_current_session_runner(self): + # Regression for #1873: close() must reap current_session_runner without hanging. + client = SocketModeClient( + app_token="xapp-A111-222-xyz", + web_client=self.web_client, + auto_reconnect_enabled=False, + trace_enabled=True, + ) + try: + client.wss_uri = "ws://0.0.0.0:3011/link" + client.connect() + self.assertTrue(client.is_connected()) + time.sleep(1) + + client.close() + + self.assertFalse(client.current_session_runner.is_alive()) + self.assertFalse(client.current_app_monitor.is_alive()) + self.assertFalse(client.message_processor.is_alive()) + finally: + client.close()