Class: Stipa::Connection

Inherits:
Object
  • Object
show all
Defined in:
lib/stipa/connection.rb

Overview

Manages the HTTP/1.1 keep-alive request/response loop for a single socket.

A Connection is created by the Server for every accepted TCP socket and runs inside a worker thread from the ThreadPool. It owns the socket for the lifetime of the keep-alive session and closes it on exit.

Keep-alive protocol:

HTTP/1.1: persistent by default. We close when:
  - client sends "Connection: close"
  - we've served max_requests on this connection
  - a read/write timeout fires (slow client or idle connection)
  - a parse error occurs (send 400, close — client is misbehaving)
  - an unhandled exception occurs (send 500, close)
HTTP/1.0: close by default unless client sends "Connection: keep-alive"

Timeout strategy (using IO.select, not SO_RCVTIMEO):

- header_read_timeout (10s): time to receive the full header block.
  Applied to the FIRST request on a new connection. Defeats slow-loris.
- keepalive_timeout (5s): idle time allowed BETWEEN requests on a
  persistent connection. Much shorter than header_read_timeout.
- body_read_timeout (30s): time to read the request body after headers.
- write_timeout (10s): time to flush the full response to the client.

Why IO.select instead of SO_RCVTIMEO?

SO_RCVTIMEO raises Errno::EAGAIN or EWOULDBLOCK inconsistently across
Ruby versions, especially in combination with Ruby's IO buffering. IO.select
is pure Ruby scheduler: it releases the GVL while waiting, allows other
threads to run, and works identically on Linux, macOS, and JRuby.

Constant Summary collapse

CRLF2 =
"\r\n\r\n".freeze
DEFAULTS =

Default timeouts and limits — all overridable via server config hash

{
  header_read_timeout: 10,
  body_read_timeout:   30,
  write_timeout:       10,
  keepalive_timeout:   5,
  max_requests:        100,
  max_header_size:     Request::MAX_HEADER_SIZE,
}.freeze

Instance Method Summary collapse

Constructor Details

#initialize(socket, app:, logger:, config: {}) ⇒ Connection

Returns a new instance of Connection.



45
46
47
48
49
50
51
52
53
54
55
56
57
58
# File 'lib/stipa/connection.rb', line 45

def initialize(socket, app:, logger:, config: {})
  @socket          = socket
  @app             = app      # compiled middleware+router callable
  @logger          = logger
  cfg              = DEFAULTS.merge(config)
  @header_timeout  = cfg[:header_read_timeout]
  @body_timeout    = cfg[:body_read_timeout]
  @write_timeout   = cfg[:write_timeout]
  @ka_timeout      = cfg[:keepalive_timeout]
  @max_requests    = cfg[:max_requests]
  @max_header_size = cfg[:max_header_size]
  @requests_served = 0
  @peer            = @socket.remote_address.inspect_sockaddr rescue 'unknown'
end

Instance Method Details

#runObject

Drive the request/response loop until the connection should be closed.



61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
# File 'lib/stipa/connection.rb', line 61

def run
  loop do
    # First request: use the full header timeout.
    # Subsequent requests on the same connection: use the shorter
    # keepalive idle timeout so we don't hold worker threads too long.
    idle_timeout = @requests_served.zero? ? @header_timeout : @ka_timeout

    result = read_headers(idle_timeout)
    break if result.nil?   # clean EOF, timeout, or parse error

    raw_headers, leftover = result

    req = Request.parse(
      raw_headers,
      socket:        @socket,
      peer:          @peer,
      body_timeout:  @body_timeout,
      socket_buffer: leftover,
    )

    res        = Response.new
    keep_going = dispatch(req, res)

    write_response(res, req)
    @requests_served += 1

    break unless keep_going && persistent?(req, res)
  end
rescue => e
  # Unexpected error outside the request cycle (e.g., SSL error)
  @logger.error("connection error peer=#{@peer}: #{e.class}: #{e.message}")
ensure
  # Always close the socket, even if an exception escapes
  @socket.close rescue nil
end