├── LICENSE.txt ├── README.md ├── lib └── tcp_timeout.rb └── tcp_timeout.gemspec /LICENSE.txt: -------------------------------------------------------------------------------- 1 | Copyright (c) 2013 Lann Martin 2 | 3 | MIT License 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining 6 | a copy of this software and associated documentation files (the 7 | "Software"), to deal in the Software without restriction, including 8 | without limitation the rights to use, copy, modify, merge, publish, 9 | distribute, sublicense, and/or sell copies of the Software, and to 10 | permit persons to whom the Software is furnished to do so, subject to 11 | the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be 14 | included in all copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 17 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 18 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 19 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 20 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 21 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 22 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 23 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # TCPTimeout 2 | 3 | A wrapper around Ruby Sockets providing timeouts for connect, write, and read 4 | operations using `Socket#*_nonblock` methods and `IO.select` instead of 5 | `Timeout.timeout`. 6 | 7 | ## Usage 8 | 9 | `gem install tcp_timeout` 10 | 11 | Pass one or more of `:connect_timeout`, `:write_timeout`, and `:read_timeout` 12 | as options to TCPTimeout::TCPSocket.new. If a timeout is omitted or nil, that 13 | operation will behave as a normal Socket would. On timeout, a 14 | `TCPTimeout::SocketTimeout` (subclass of `SocketError`) will be raised. 15 | 16 | When calling `#read` with a byte length it is possible for it to read some data 17 | before timing out. If you need to avoid losing this data you can pass a buffer 18 | string which will receive the data even after a timeout. 19 | 20 | Other options: 21 | 22 | - `:family` - set the address family for the connection, e.g. `:INET` or `:INET6` 23 | - `:local_host` and `:local_port` - the host and port to bind to 24 | 25 | TCPTimeout::TCPSocket supports only a subset of IO methods, including: 26 | 27 | ```close closed? read read_nonblock readbyte readpartial write write_nonblock``` 28 | 29 | **Example:** 30 | 31 | ```ruby 32 | begin 33 | sock = TCPTimeout::TCPSocket.new(host, port, connect_timeout: 10, write_timeout: 9) 34 | sock.write('data') 35 | sock.close 36 | rescue TCPTimeout::SocketTimeout 37 | puts "Operation timed out!" 38 | end 39 | ``` 40 | -------------------------------------------------------------------------------- /lib/tcp_timeout.rb: -------------------------------------------------------------------------------- 1 | require 'socket' 2 | 3 | module TCPTimeout 4 | VERSION = "0.1.1" 5 | 6 | DELEGATED_METHODS = %w[ 7 | close closed? 8 | getsockopt setsockopt 9 | local_address remote_address 10 | read_nonblock write_nonblock 11 | fileno 12 | ] 13 | 14 | class SocketTimeout < SocketError; end 15 | 16 | class TCPSocket 17 | DELEGATED_METHODS.each do |method| 18 | class_eval(<<-EVAL, __FILE__, __LINE__) 19 | def #{method}(*args) 20 | @socket.__send__(:#{method}, *args) 21 | end 22 | EVAL 23 | end 24 | 25 | def initialize(host, port, opts = {}) 26 | @connect_timeout = opts[:connect_timeout] 27 | @write_timeout = opts[:write_timeout] 28 | @read_timeout = opts[:read_timeout] 29 | 30 | family = opts[:family] || Socket::AF_INET 31 | address = Socket.getaddrinfo(host, nil, family).first[3] 32 | @sockaddr = Socket.pack_sockaddr_in(port, address) 33 | 34 | @socket = Socket.new(family, Socket::SOCK_STREAM, 0) 35 | @socket.setsockopt(Socket::IPPROTO_TCP, Socket::TCP_NODELAY, 1) 36 | 37 | local_host = opts[:local_host] 38 | local_port = opts[:local_port] 39 | if local_host || local_port 40 | local_host ||= '' 41 | local_address = Socket.getaddrinfo(local_host, nil, family).first[3] 42 | local_sockaddr = Socket.pack_sockaddr_in(local_port, local_address) 43 | @socket.bind(local_sockaddr) 44 | end 45 | 46 | connect 47 | end 48 | 49 | def connect 50 | return @socket.connect(@sockaddr) unless @connect_timeout 51 | 52 | begin 53 | @socket.connect_nonblock(@sockaddr) 54 | rescue Errno::EINPROGRESS 55 | select_timeout(:connect, @connect_timeout) 56 | # If there was a failure this will raise an Error 57 | begin 58 | @socket.connect_nonblock(@sockaddr) 59 | rescue Errno::EISCONN 60 | # Successfully connected 61 | end 62 | end 63 | end 64 | 65 | def write(data, timeout = nil) 66 | timeout ||= @write_timeout 67 | return @socket.write(data) unless timeout 68 | 69 | length = data.bytesize 70 | 71 | total_count = 0 72 | loop do 73 | begin 74 | count = @socket.write_nonblock(data) 75 | rescue Errno::EWOULDBLOCK 76 | timeout = select_timeout(:write, timeout) 77 | retry 78 | end 79 | 80 | total_count += count 81 | return total_count if total_count >= length 82 | data = data.byteslice(count..-1) 83 | end 84 | end 85 | 86 | def read(length = nil, *args) 87 | raise ArgumentError, 'too many arguments' if args.length > 2 88 | 89 | timeout = (args.length > 1) ? args.pop : @read_timeout 90 | return @socket.read(length, *args) unless length && length > 0 && timeout 91 | 92 | buffer = args.first || ''.force_encoding(Encoding::ASCII_8BIT) 93 | 94 | begin 95 | # Drain internal buffers 96 | @socket.read_nonblock(length, buffer) 97 | return buffer if buffer.bytesize >= length 98 | rescue Errno::EWOULDBLOCK 99 | # Internal buffers were empty 100 | buffer.clear 101 | rescue EOFError 102 | return nil 103 | end 104 | 105 | @chunk ||= ''.force_encoding(Encoding::ASCII_8BIT) 106 | 107 | loop do 108 | timeout = select_timeout(:read, timeout) 109 | 110 | begin 111 | @socket.read_nonblock(length, @chunk) 112 | rescue Errno::EWOULDBLOCK 113 | retry 114 | rescue EOFError 115 | return buffer.empty? ? nil : buffer 116 | end 117 | buffer << @chunk 118 | 119 | if length 120 | length -= @chunk.bytesize 121 | return buffer if length <= 0 122 | end 123 | end 124 | end 125 | 126 | def readpartial(length, *args) 127 | raise ArgumentError, 'too many arguments' if args.length > 2 128 | 129 | timeout = (args.length > 1) ? args.pop : @read_timeout 130 | return @socket.readpartial(length, *args) unless length > 0 && timeout 131 | 132 | begin 133 | @socket.read_nonblock(length, *args) 134 | rescue Errno::EWOULDBLOCK 135 | timeout = select_timeout(:read, timeout) 136 | retry 137 | end 138 | end 139 | 140 | def readbyte 141 | readpartial(1).ord 142 | end 143 | 144 | private 145 | 146 | def select_timeout(type, timeout) 147 | if timeout >= 0 148 | if type == :read 149 | read_array = [@socket] 150 | else 151 | write_array = [@socket] 152 | end 153 | 154 | start = Time.now 155 | if IO.select(read_array, write_array, [@socket], timeout) 156 | waited = Time.now - start 157 | return timeout - waited 158 | end 159 | end 160 | raise SocketTimeout, "#{type} timeout" 161 | end 162 | end 163 | end 164 | -------------------------------------------------------------------------------- /tcp_timeout.gemspec: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | lib = File.expand_path('../lib', __FILE__) 3 | $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib) 4 | require 'tcp_timeout' 5 | 6 | Gem::Specification.new do |spec| 7 | spec.name = "tcp_timeout" 8 | spec.version = TCPTimeout::VERSION 9 | spec.authors = ["Lann Martin"] 10 | spec.email = ["tcptimeoutgem@lannbox.com"] 11 | spec.summary = "TCPSocket proxy with select-based timeouts" 12 | spec.description = "Wraps Socket, providing timeouts for connect, write, and read without Timeout.timeout." 13 | spec.homepage = "https://github.com/lann/tcp-timeout-ruby" 14 | spec.license = "MIT" 15 | 16 | spec.files = `git ls-files`.split($/) 17 | spec.require_paths = ["lib"] 18 | end 19 | --------------------------------------------------------------------------------