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
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions