Module: TcpUserTimeout

Defined in:
lib/tcp_user_timeout.rb,
lib/tcp_user_timeout/hook.rb,
lib/tcp_user_timeout/rack.rb,
lib/tcp_user_timeout/storage.rb,
lib/tcp_user_timeout/version.rb,
lib/tcp_user_timeout/active_job.rb

Overview

Kernel-enforced socket deadlines on Linux via TCP_USER_TIMEOUT.

TcpUserTimeout.with_timeout(30) do
  Net::HTTP.get(URI("https://upstream.example/slow"))
end

Sockets opened inside the block get TCP_USER_TIMEOUT applied. The Linux kernel forcibly closes the connection when transmitted data has been unacknowledged for the configured time, raising Errno::ETIMEDOUT / IO::TimeoutError from the next blocking syscall. Catches write-side wedges and network partitions.

Pre-existing sockets — DB pools, persistent HTTP pools created at boot — are never re-bound. Only sockets created inside the block inherit the deadline.

Platform support:

- Linux: enforced by the kernel.
- Other platforms (macOS, BSD, Windows): silent no-op. setsockopt
  raises Errno::ENOPROTOOPT (or similar) and is rescued. There is no
  direct equivalent of TCP_USER_TIMEOUT on macOS.

What this does NOT cover:

- Read-side wedges where the peer's userspace is wedged but its
  kernel is responsive. TCP_USER_TIMEOUT does not fire because the
  peer kernel auto-ACKs packets even when its application is stuck.
  For those wedges use application-level timeouts (Net::HTTP
  read_timeout, IO#timeout=, SDK-specific request timeouts).
- FFI / libcurl-based HTTP clients (curb, etc.) — bypass Ruby's
  socket layer entirely.
- DNS (getaddrinfo). Mitigate via resolv.conf.
- Connect phase. Use Net::HTTP#open_timeout / libpq connect_timeout.

Defined Under Namespace

Modules: ActiveJob, Hook, Rack, Storage Classes: Error, State

Constant Summary collapse

TCP_USER_TIMEOUT_OPT =

TCP_USER_TIMEOUT optname from <linux/tcp.h>. Hardcoded because Socket::TCP_USER_TIMEOUT is not exposed on every Ruby version.

18
STORAGE_KEY =
:tcp_user_timeout_state
VERSION =
"0.1.0"

Class Attribute Summary collapse

Class Method Summary collapse

Class Attribute Details

.global_default_secondsObject

Optional ceiling applied to every outbound TCP socket created when no with_timeout block is in effect. nil disables the global default.



58
59
60
# File 'lib/tcp_user_timeout.rb', line 58

def global_default_seconds
  @global_default_seconds
end

Class Method Details

.current_stateObject

Public for testing and instrumentation.



100
101
102
# File 'lib/tcp_user_timeout.rb', line 100

def current_state
  Storage[STORAGE_KEY] || global_default_state
end

.install!Object

Idempotent installation of Socket / TCPSocket prepend hooks.



87
88
89
90
91
92
93
# File 'lib/tcp_user_timeout.rb', line 87

def install!
  return if @installed

  ::Socket.singleton_class.prepend(Hook::SocketTcp)
  ::TCPSocket.singleton_class.prepend(Hook::TCPSocketSingleton)
  @installed = true
end

.installed?Boolean

Returns:

  • (Boolean)


95
96
97
# File 'lib/tcp_user_timeout.rb', line 95

def installed?
  @installed == true
end

.maybe_apply!(socket) ⇒ Object

Apply the currently-scoped state (or the global default) to a socket. Silently no-ops on platforms / sockets that don’t support the option.



77
78
79
80
81
82
83
84
# File 'lib/tcp_user_timeout.rb', line 77

def maybe_apply!(socket)
  state = current_state
  return unless state&.timeout_ms&.positive?

  socket.setsockopt(::Socket::IPPROTO_TCP, TCP_USER_TIMEOUT_OPT, state.timeout_ms)
rescue Errno::ENOPROTOOPT, Errno::EINVAL, Errno::ENOTSOCK, Errno::EBADF
  nil
end

.with_timeout(seconds) ⇒ Object

Bound sockets opened inside the block to ‘seconds`.



65
66
67
68
69
70
71
72
73
# File 'lib/tcp_user_timeout.rb', line 65

def with_timeout(seconds)
  install!
  state = State.new(seconds.to_f)
  prev = Storage[STORAGE_KEY]
  Storage[STORAGE_KEY] = state
  yield
ensure
  Storage[STORAGE_KEY] = prev
end