test: fix intermittent CI hang in the unit test matrix - #1921
Conversation
The "Unit tests (3.x)" matrix job intermittently hung on "Run tests" until its 15-minute timeout on a random Python version, passing on retry. Root cause was in the test infrastructure, not the SDK: - No per-test timeout, so a hung test gave a blind 15-min job timeout with no indication of which test stuck. - The shared mock web API server used a single-threaded HTTPServer on a non-daemon thread with unbounded join()/wait(). With HTTP/1.1 keep-alive it could leak a thread holding port 8888, hanging the next test's setUp. - Two socket-mode interaction tests leaked servers/clients: test_interactions_websockets omitted stop_socket_mode_server in tearDown and shared port 3001 with the aiohttp test; test_interactions_builtin had its per-iteration client.close() commented out, leaking thread pools. Fixes (test/CI only, no shipped SDK change): - Add pytest-timeout with a thread-method timeout so a hang fails fast with a full thread dump instead of stalling the job. - Make the mock web API server threads daemon + ThreadingHTTPServer, and bound join()/server_started.wait() so a failed bind fails loudly. Apply the same to the live duplicate in tests/rtm and drop the dead MockServerThread in tests/slack_sdk/web/mock_web_api_handler.py. - Fix the two leaking socket-mode tests: add the missing teardown, move off the shared port, and re-enable per-iteration client.close(). Co-Authored-By: Claude <svc-devxp-claude@slack-corp.com>
Follow-up to the CI-hang fix, addressing review findings: - Fix a shutdown race: stop() deleted self.server.queue before server.shutdown(), so under ThreadingHTTPServer an in-flight handler thread could hit AttributeError. Reorder to shutdown() -> join() -> del, after which no handler threads remain. - Unify stop()/stop_unsafe() into a single stop(). The split only existed because asyncio.Queue lacks .mutex; with the safe ordering it is unnecessary. Make the queue optional and drop redundant str(). - Warn when the server thread outlives join(timeout=5) instead of silently abandoning it, so a re-emerging leak is visible. - Dedupe tests/rtm/mock_web_api_server.py to reuse the canonical MockServerThread instead of a near-identical copy. - Wrap the async websockets tearDown in try/finally so the socket-mode server is always stopped even if mock-server cleanup raises. Co-Authored-By: Claude <svc-devxp-claude@slack-corp.com>
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## main #1921 +/- ##
==========================================
- Coverage 84.17% 84.14% -0.03%
==========================================
Files 118 118
Lines 13423 13423
==========================================
- Hits 11299 11295 -4
- Misses 2124 2128 +4 ☔ View full report in Codecov by Harness. |
| self._handle() | ||
|
|
||
|
|
||
| class MockServerThread(threading.Thread): |
There was a problem hiding this comment.
Keeps implementation DRY
| test.thread.start() | ||
| test.server_started.wait() | ||
| if not test.server_started.wait(timeout=5): | ||
| raise RuntimeError( |
There was a problem hiding this comment.
Fails with noise
| self._handle() | ||
|
|
||
|
|
||
| class MockServerThread(threading.Thread): |
There was a problem hiding this comment.
Keeps implementations DRY
| try: | ||
| cleanup_mock_web_api_server_async(self) | ||
| finally: | ||
| stop_socket_mode_server(self) |
There was a problem hiding this comment.
We were not always stopping the socket mode server I think this was leading to test hanging sometimes
| self.server = HTTPServer(("localhost", self.port), self.handler) | ||
| self.server.queue = self.queue | ||
| self.test.server_url = f"http://localhost:{str(self.port)}" | ||
| self.server = ThreadingHTTPServer(("localhost", self.port), self.handler) |
There was a problem hiding this comment.
The ThreadingHTTPServer is only available in python 3.7+, this is why we were using HTTPServer
hello-ashleyintech
left a comment
There was a problem hiding this comment.
Great work, changes make sense and LGTM! 🙏 thank you
Summary
Fixes an intermittent hang in the Unit tests CI matrix that occasionally stalled the job until the workflow-level timeout.
Root cause: the tests' mock web API server ran on a single-threaded
HTTPServerwith a non-daemon thread, and teardown diddel self.server.queuebeforeserver.shutdown(). An in-flight request handler (whose first action isqueue.put_nowait(...)) could race the teardown, and a non-daemon thread that never joined could wedge the whole run.Changes (test infra only):
ThreadingHTTPServerand run it on adaemon=Truethread so a stuck worker can never block interpreter exit.shutdown()→join(timeout=5)→del queue, closing the delete-before-shutdown race (server_close()in the run loop'sfinallyjoins workers, so no handler touches the queue after the join).stop()/stop_unsafe()into a singlestop()that works for both the syncqueue.Queueand asyncasyncio.Queuepaths (the mutex is no longer needed), and make the queue optional so RTM can reuse the class.server_started.wait(timeout=5)now raises a clearRuntimeError(e.g. "port already in use") instead of blocking forever.MockServerThreadcopies intests/rtm/andtests/slack_sdk/web/; both now import the canonical implementation.tearDownintry/finallyso the socket-mode server is always stopped even if mock-server cleanup raises.client.close()intest_interactions_builtin.py(was commented out).pytest-timeout(timeout=180,timeout_method="thread") so a future hang fails fast with a thread dump instead of stalling CI. Thethreadmethod is required here becausesignalcannot interrupt hangs on non-main threads (asyncio/socket-mode).Testing
Reviewers can verify with:
Verified locally: full suite 988 passed, 5 skipped (4m17s) with the per-test timeout armed (no hang),
black --checkclean, and the async socket-mode test stable across 5 consecutive runs.Category
/docs(Documents)/tutorial(PythOnBoardingBot tutorial)tests/integration_tests(Automated tests for this library)Requirements
python3 -m venv .venv && source .venv/bin/activate && ./scripts/run_validation.shafter making the changes.