Skip to content

SocketModeClient.close() leaks current_session_runner thread (built-in client) #1873

@animaartificialis

Description

@animaartificialis

Reproducible in:

$ pip freeze | grep slack
slack_sdk==3.41.0

$ python --version
Python 3.11.2

$ uname -srv
Linux 6.12.57+deb12-amd64 #1 SMP PREEMPT_DYNAMIC Debian 6.12.57-1~bpo12+1 (2025-11-17)

Also reproducible on main (commit at the time of filing) — the relevant lines in slack_sdk/socket_mode/builtin/client.py are unchanged from the v3.41.0 release.

The Slack SDK version

slack_sdk==3.41.0

Python runtime version

Python 3.11.2

OS info

Linux 6.12.57+deb12-amd64

Steps to reproduce:

The built-in SocketModeClient starts three IntervalRunner threads in __init__:

  • current_session_runner — interval 0.1 s (line ~126)
  • current_app_monitor — interval ping_interval (default 5 s) (line ~129)
  • message_processor — interval 0.001 s (line ~134)

close() shuts down current_app_monitor, message_processor, and message_workers, but does not call current_session_runner.shutdown() (lines ~224–232). So every SocketModeClient instance leaks one IntervalRunner thread running a 100 ms loop.

Minimal reproducer (no network, no real token needed):

import threading, time
from slack_sdk.socket_mode.builtin.client import SocketModeClient

c = SocketModeClient(app_token="xapp-fake-not-used")
c.close()
time.sleep(0.5)

print("current_session_runner.is_alive():", c.current_session_runner.is_alive())
print("current_app_monitor.is_alive():    ", c.current_app_monitor.is_alive())
print("message_processor.is_alive():      ", c.message_processor.is_alive())

# Repeat to show the linear leak
for _ in range(5):
    c2 = SocketModeClient(app_token="xapp-fake-not-used")
    c2.close()
time.sleep(0.5)
print("active_count after 5 more cycles:", threading.active_count())

Expected result:

After close(), all three runner threads exit and threading.active_count() returns to its baseline.

Actual result:

current_session_runner.is_alive(): True
current_app_monitor.is_alive():    False
message_processor.is_alive():      False
active_count after 5 more cycles: 7

Each init/close cycle leaks exactly one thread (the current_session_runner). In a long-running watcher that reconnects occasionally — e.g. on transient network blips or when the caller recreates the client in response to is_connected() == False — the leaked threads accumulate. Each one is a 100 ms loop, and combined with the still-running message_processor's 1 ms loop on the live instance, CPU usage climbs noticeably (in our case to 100 % of one core, with 26 threads — 19 in `clock_nanosleep` — after ~2 days of operation against a single workspace).

Proposed fix

One additional shutdown call in `close()`:

```python
def close(self):
self.closed = True
self.auto_reconnect_enabled = False
self.disconnect()
if self.current_session_runner.is_alive(): # <-- added
self.current_session_runner.shutdown() # <-- added
if self.current_app_monitor.is_alive():
self.current_app_monitor.shutdown()
if self.message_processor.is_alive():
self.message_processor.shutdown()
self.message_workers.shutdown()
```

Happy to send a PR if it would help.

Thanks for maintaining the SDK!

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions