Skip to content

IO

Sometimes the slow part is not “my Ruby loop”—it is one stubborn read or write. This strategy times the syscall-shaped work, not the whole block by magic.

Mental model: you set a kitchen timer on the one dish on the stove, not on the entire dinner party.

Quick example

TIMEx::Strategies::IO.read(socket, 4096, deadline: 2.0)
TIMEx::Strategies::IO.write(socket, buffer, deadline: 2.0)
sock = TIMEx::Strategies::IO.connect("example.com", 443, deadline: 2.0)
# `connect` already sets SO_RCVTIMEO/SO_SNDTIMEO from the deadline.
# Pass `apply_timeouts: false` to opt out, or call this helper yourself
# to refresh the kernel timeouts after a long pause:
TIMEx::Strategies::IO.apply_socket_timeouts(sock, deadline: 2.0)

Under the hood: IO.select plus read_nonblock / write_nonblock. When time is up you get TIMEx::Expired with strategy: :io and the original deadline_ms baked into the error—handy for logs.

At a glance

Topic Plain English
CPU-heavy Ruby Not this tool’s job—use Cooperative and check!.
Blocking IO Yes: the operation can bail with a clean timeout story.
Mutexes and shared state Safer than yanking threads: you get errno-style flow, not surprise raise.
Runs everywhere Plain MRI—no fork, no Ractors.
How tight the timeout is Roughly microsecond-scale scheduling around the poll loop.
Nesting Compose deadlines with min when you stack limits.

When not to use this exact helper

If you live on Async / Falcon, the fiber scheduler already cooperates with read / write / IO.select. Let the scheduler handle wait time and use Cooperative (or a future companion gem like timex-async) so you are not fighting the runtime twice.

Real-world: outbound webhook with one shared budget

A webhook fan-out has to connect, send, and read an ack under one deadline. Splitting the budget by syscall keeps a slow TLS handshake from eating all of read time, and connect automatically applies SO_RCVTIMEO / SO_SNDTIMEO so the kernel honors the same number—no half-open socket trick keeps us blocked past the budget:

def deliver_webhook(endpoint, payload, deadline:)
  uri = URI(endpoint)
  TIMEx.deadline(deadline) do |d|
    sock = TIMEx::Strategies::IO.connect(uri.host, uri.port, deadline: d)
    TIMEx::Strategies::IO.write(sock, http_request(uri, payload), deadline: d)
    TIMEx::Strategies::IO.read(sock, 4096, deadline: d)
  ensure
    sock&.close
  end
end

If the receiver is flaky, you get TIMEx::Expired with strategy: :io and the original budget in the log line—much friendlier than Net::HTTP’s opaque Net::OpenTimeout/ReadTimeout split.