fix(slack): bound reconnect-path and socket-write network calls with timeouts#1334
fix(slack): bound reconnect-path and socket-write network calls with timeouts#1334howie wants to merge 6 commits into
Conversation
get_socket_mode_url and connect_async in the Socket Mode reconnect loop had no timeout, unlike the post-connect idle watchdog or SlackAdapter::new's Web API client. A stalled TLS handshake or apps.connections.open POST during reconnect (e.g. a peer silently dropping connections without TLS close_notify) parked the reconnect task in .await forever: no error, no log, process alive but permanently dark on Slack until manually restarted. Wrap both calls in tokio::time::timeout(30s) so a stall surfaces as a logged retry instead of an indefinite silent hang, and reuse SlackAdapter's existing bounded reqwest::Client for get_socket_mode_url instead of constructing a fresh unbounded one per call (folds in openabdev#386). Closes openabdev#1279 Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FhkGYryKMKn2hmJME1NQMp
- Split RECONNECT_TIMEOUT_SECS (connect_async, sole bound) from RECONNECT_HTTP_TIMEOUT_SECS=35 (get_socket_mode_url), set strictly above the adapter client's own 30s timeout so the reqwest error (with cause) fires first instead of racing an identically-timed outer timeout. - Correct the get_socket_mode_url doc comment, which had misattributed the reconnect-path bound to the client's timeout instead of the explicit tokio::time::timeout wrapper at the call site. - Extract wait_backoff_or_shutdown() to deduplicate the select!+backoff block that was copy-pasted at three retry points in run_slack_adapter. - Add a regression test (connect_async_timeout_fires_on_stalled_handshake) using #[tokio::test(start_paused = true)] against a local TcpListener that never completes the WS handshake, asserting the timeout fires instead of hanging -- this exercises the exact openabdev#1279 failure mode. Addresses consensus findings from Claude + Codex mob review on PR openabdev#1281. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FhkGYryKMKn2hmJME1NQMp
…ff_or_shutdown Round-2 mob re-review found the new regression test duplicated the tokio::time::timeout(...) expression instead of calling production code, and the new wait_backoff_or_shutdown() helper had no direct test coverage. - Extract connect_with_reconnect_timeout() wrapping connect_async in the reconnect timeout; run_slack_adapter and the regression test now call the same function, so the test pins the actual production code path. - Add wait_backoff_or_shutdown_returns_next_backoff_when_no_shutdown and wait_backoff_or_shutdown_returns_none_on_shutdown, covering both branches of the shared retry helper under #[tokio::test(start_paused)]. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FhkGYryKMKn2hmJME1NQMp
… test PR openabdev#1281 fixed the two unbounded .await points in the reconnect path (get_socket_mode_url and connect_async) but was auto-closed by the stale-contributor bot before its final round of review feedback could be addressed. This continues that work: - Bound the four unbounded write.send(...) calls in the live socket loop (envelope ack, pong, keepalive ping, shutdown close) with the same tokio::time::timeout idiom, via a new send_with_timeout() helper. A stalled outbound send on a half-open socket previously parked the loop inside that select! branch forever -- unreachable from ping_interval.tick(), so even the IDLE_TIMEOUT_SECS watchdog couldn't fire. Same bug class as openabdev#1279, on the write side. - Extract get_socket_mode_url_with_timeout() (mirroring connect_with_reconnect_timeout()) and add a regression test that exercises it against a stalled local HTTP listener under start_paused, closing the previously untested HTTP-timeout leg. - Add ConnectResult/SocketModeUrlResult type aliases for the nested Result types, per review nit. Closes openabdev#1279 Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011G3ZC96t3zDXTTviRLDZJF
This comment has been minimized.
This comment has been minimized.
There was a problem hiding this comment.
🟡 Non-blocking suggestions
1 · Ack/pong send sites swallow non-timeout send errors (both reviewers)
send_with_timeout returns Result<Result<(), tungstenite::Error>, Elapsed>. The ack (slack.rs:884) and pong (slack.rs:1220) sites check only .is_err() on the outer result, so a completed-but-erroring send (a real tungstenite::Error, e.g. broken pipe) is silently ignored and the loop proceeds as if it succeeded. The keepalive-ping site (slack.rs:1253) does it right — it 3-way matches Ok(Err(e)) / Err(_) / Ok(Ok(())). Not a regression (matches the old let _ = write.send()), but since this PR is specifically about tightening these sends, consider aligning ack/pong to the ping site's match pattern (or a one-line "best-effort; read/ping side catches a dead socket" comment).
2 · Undocumented behaviour change on ack timeout (infrabot)
On an ack-send timeout (slack.rs:882-897) the code now breaks before event routing/dispatch — everything from slack.rs:899 on is skipped. Previously the ack result was ignored and the event was routed regardless. This is likely the safer behaviour (Slack redelivers an un-acked envelope on reconnect, avoiding a double-process), but it's an intentional change that isn't called out in the PR description — worth a line so it isn't later mistaken for an oversight.
🟢 Info / follow-ups (out of scope for this PR)
- Write-side timeout is untested (both) —
send_with_timeout(4 sites) is the PR's primary new behaviour; the 2 new regression tests cover only the reconnect legs. Low risk (structurally symmetric to the testedconnect_with_reconnect_timeout); deterministically forcing TCP write-backpressure is hard. A stalled-sink test would close the gap. Fine to merge as-is. - Pre-existing WSS-URL logging (infra-Patrick) — the unchanged
info!(url = %ws_url)line logs the full Socket Mode URL, which embeds a short-lived auth ticket. Out of scope here; follow-up only. - Rollout watch (infrabot) — first production exercise of these bounds after the 11h outage; worth a glance at reconnect-frequency metrics post-deploy, though 30/35/10s are generous vs.
PING_INTERVAL_SECS=30/IDLE_TIMEOUT_SECS=75.
Correctness questions — confirmed clean
- 35s > 30s ordering / race? No race.
SlackAdapter::newsets.timeout(30s)covering the whole request cycle incl.resp.json().await; same tokio clock with a 5s margin → the inner reqwest error fires first. 35s is correctly scoped as a backstop for the fallback path where.build()fails to an unboundedClient::new(). The new HTTP test targets exactly that bare-client scenario. breakon write timeout / in-flight state? Safe — falls through to the same reconnect/backoff path as the pre-existing ping-failure break. Only real change is the ack-timeout-skips-routing behaviour noted above.- 10s shutdown-close truncating handshake? No —
Message::Close(None)is a local buffer write+flush; tungstenite doesn't wait for the peer's close-ack here, before or after. Worst-case shutdown latency goes unbounded → 10s, strictly better.
Also verified: all 4 previously-unbounded write sites now route through send_with_timeout (no unbounded write remains in the live loop); Cargo.toml's new [dev-dependencies] tokio test-util is necessary (main dep uses full, which excludes test-util); wait_backoff_or_shutdown is a faithful, behaviour-preserving extraction across all 3 call sites.
The ack (envelope) and pong send sites checked only the outer `.is_err()` on send_with_timeout's `Result<Result<(), tungstenite::Error>, Elapsed>`, so a completed-but-erroring send (a real tungstenite::Error such as a broken pipe) was silently treated as success and the loop proceeded. Align both sites to the keepalive-ping site's 3-arm match: - Ok(Err(e)) -> log the send error and reconnect - Err(_) -> log the timeout and reconnect - Ok(Ok(())) -> proceed Also clarify with a comment that an ack that cannot be written forces a reconnect before dispatch, relying on Slack redelivering unacked envelopes. Addresses the non-blocking review suggestion on openabdev#1334. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01A9qJD9nYio1iRyuC5DKE2w
|
Note LGTM ✅ — Solid, well-tested fix for a real production outage. Addresses all prior review feedback from #1281. What This PR DoesBounds all previously-unbounded network calls in the Slack Socket Mode reconnect loop and live socket write path with explicit timeouts, preventing stalled connections from permanently parking the adapter task (the root cause of an 11+ hour production outage on openab-viola, #1279). How It Works
Findings
What's Good (🟢)
Baseline Check
5️⃣ Three Reasons We Might Not Need This PR
|
|
Note LGTM ✅ — Well-structured timeout + write-bounding fix for a real 11-hour production outage, with proper regression tests. What This PR DoesFixes #1279: the Slack Socket Mode reconnect path ( How It Works
Findings
Baseline Check
What's Good (🟢)
5️⃣ Three Reasons We Might Not Need This PR
|
Discord Discussion URL: https://discord.com/channels/1491295327620169908/1491969620754567270/1524555735294410812
What problem does this solve?
Continuation of #1281, which was auto-closed by the stale-contributor bot for inactivity before its final round of review feedback could be addressed. All prior review rounds (Claude + Codex mob review,
@dogzzdogzz's consolidated Claude+Codex review) had converged to LGTM on the core fix; this PR rebases those commits onto currentmainand additionally addresses the one unresolved finding from the last review round.Original problem (from #1279):
get_socket_mode_urlandtokio_tungstenite::connect_asyncin the Slack Socket Mode reconnect loop (crates/openab-core/src/slack.rs) had no timeout, unlike the post-connect idle watchdog (IDLE_TIMEOUT_SECS) andSlackAdapter::new's Web API client. A stalled network path during reconnect parked the reconnect task in.awaitforever — no error, no log, process alive but the bot permanently dark on Slack until manually restarted. This caused an 11+ hour production outage on openab-viola.Closes #1279
What's carried over from #1281 (unchanged, already LGTM'd)
get_socket_mode_url(...)intokio::time::timeout(35s)(RECONNECT_HTTP_TIMEOUT_SECS), set strictly above the adapter client's own 30s timeout so the diagnosable inner error fires first.connect_asyncintokio::time::timeout(30s)(RECONNECT_TIMEOUT_SECS) viaconnect_with_reconnect_timeout().SlackAdapter's boundedreqwest::Clientinget_socket_mode_urlinstead of constructing an unbounded one per call (folds in Slack: get_socket_mode_url creates new reqwest::Client per reconnect #386).wait_backoff_or_shutdown()to deduplicate the backoff+shutdownselect!block that was copy-pasted at three retry points.connect_async_timeout_fires_on_stalled_handshake(start_paused = trueagainst a localTcpListenerthat never completes the handshake).New in this PR (addresses #1281's last review round)
@dogzzdogzz's consolidated review on #1281 flagged that the reconnect handshake was bounded, but the live socket loop still had unbounded writes — the same bug class as #1279, just on the write side:slack.rs: envelope ack send, pong send, keepalive ping send, shutdown close send were all unboundedwrite.send(...).await. If any of these stalls on the same flaky/half-open socket that motivated bug(slack): get_socket_mode_url / connect_async in the reconnect loop have no timeout, unlike the post-connect idle watchdog #1279, the task parks inside thatselect!branch — and since it can never return toping_interval.tick(),IDLE_TIMEOUT_SECScan't fire either.Fix: extracted
send_with_timeout()(bounded by a newWRITE_TIMEOUT_SECS = 10) and used it at all four call sites; each nowbreaks to force a reconnect on timeout, matching the existing keepalive-ping error-handling pattern.Intentional behaviour change — ack timeout now skips routing: on an envelope ack-send timeout (or send error) the loop
breaks before the event is routed/dispatched, whereas previously the ack result was ignored and the event was routed regardless. This is deliberate and safer: Slack redelivers an un-acked envelope on the next connection, so skipping dispatch avoids double-processing an envelope Slack still considers pending. Called out explicitly so it isn't later mistaken for an oversight.Also addressed from the same review round:
get_socket_mode_url_with_timeout), using a stalled local listener withreqwest::Client::builder().resolve(...)to redirect theslack.comhost, mirroring the existing WS handshake test.ConnectResult/SocketModeUrlResulttype aliases for the nestedResultreturn types (readability nit).Ok(Err(e))/Err(_)/Ok(Ok(()))) so a completed-but-erroring send (a realtungstenite::Error, e.g. broken pipe) forces a reconnect instead of being silently swallowed by an outer-.is_err()-only check.Validation
cargo build -p openab-corepassescargo test -p openab-core --lib slack— 66/66 tests pass (including the 2 new regression tests)cargo clippy -p openab-core --all-targets— no new warnings (4 pre-existing warnings incron.rs/discord.rsare unrelated)cargo fmt -p openab-core -- --check— no new formatting diffs introduced by this change (pre-existing unformatted regions elsewhere in the file are untouched)Supersedes
Closes/supersedes #1281 (auto-closed for inactivity, all review rounds there had converged to LGTM on the core fix).