Skip to content

Skip Timeout.timeout wrap when TCPSocket native connect_timeout is available#840

Merged
sferik merged 2 commits into
httprb:mainfrom
Linuus:skip-timeout-wrap-when-native
May 26, 2026
Merged

Skip Timeout.timeout wrap when TCPSocket native connect_timeout is available#840
sferik merged 2 commits into
httprb:mainfrom
Linuus:skip-timeout-wrap-when-native

Conversation

@Linuus
Copy link
Copy Markdown
Contributor

@Linuus Linuus commented May 25, 2026

We are always wrapping in Timeout.timeout when a connect_timeout is set, even when native socket connect_timeout is available. The native timeout already enforces the limit and raises IO::TimeoutError, so the wrap is redundant.

Using Timeout.timeout is problematic: under certain exit paths, the Timeout::Request is not removed from the Timeout watcher's internal request queue, retaining the target Thread for the process lifetime. In long-running workers (Sidekiq, Puma in some configurations) this surfaces as slow memory growth.

Looks like this was meant to be avoided when native timeout support was added - the comment above the method even says "Falls back to Timeout.timeout on older Rubies or with custom socket classes" - but the code applies the wrap unconditionally. This PR makes the behavior match the comment.

I think this PR basically resolves #542

Linuus added 2 commits May 25, 2026 12:16
…ailable

When a connect_timeout is provided on Ruby 3.4+ with TCPSocket, the
socket's native connect_timeout already enforces the timeout and raises
IO::TimeoutError on expiration. The outer Timeout.timeout wrap is
therefore redundant when native is in use, and creates a
Timeout::Request
that can leak under abnormal exit paths, pinning the calling Thread for
the process lifetime. In long-running workers (Sidekiq, etc.) this
results in slow memory growth from accumulated Threads.

This matches the existing method comment's intent ("Falls back to
Timeout.timeout on older Rubies or with custom socket classes") which
was previously not what the code actually did — the wrap was applied
unconditionally whenever a connect_timeout was set.
@sferik sferik force-pushed the main branch 3 times, most recently from 110e972 to 1a07d7a Compare May 26, 2026 18:51
@sferik sferik merged commit 79ecf7e into httprb:main May 26, 2026
8 checks passed
@sferik
Copy link
Copy Markdown
Contributor

sferik commented May 26, 2026

Looks good. Thanks!

sferik added a commit that referenced this pull request May 26, 2026
Three small polish items after merging the native-connect_timeout fix:

1. Pin the fallback shape. Existing tests for `open_socket` cover the
   native path (`native_timeout?` stubbed true) and the no-timeout path,
   but nothing verified that `Timeout.timeout(connect_timeout,
   ConnectTimeoutError)` actually wraps the call when native is
   unsupported. Added a test that stubs `Timeout.timeout` and asserts it
   was invoked with the right arguments — so the Ruby 3.2/3.3 and
   custom-socket-class fallback can't regress silently on Ruby 3.4+ CI.

2. Tighten a regex. The global-timeout test's stub raises
   `IO::TimeoutError`, which the rescue in `open_socket` always remaps
   to `ConnectTimeoutError, "Connect timed out after N seconds"` on
   both native and fallback paths. The `|execution/` alternative in
   the regex was therefore unreachable.

3. CHANGELOG entry under `## [Unreleased]` describing the behavior
   change, with a reference link to #542.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Remove Timeout.timeout

2 participants