Skip to content

Commit

Permalink
Add an option idle_timeout
Browse files Browse the repository at this point in the history
The main problem with current implementation of HTTP server is it's
cooperative scheduling. To control connections we need invent something
that will periodically checks open connections and close idles with
exceeded timeout. In proposed implementation we close idle connections
with exceeded idle_timeout in synchronous manner - before start
processing a new connection. In comparison to other solutions (like
running checking function in a separate fiber) closing connections
synchronously is much easier to implement and don't make module complex.

Fixes #137
  • Loading branch information
ligurio committed Mar 23, 2022
1 parent 56a7e58 commit a3794d5
Show file tree
Hide file tree
Showing 4 changed files with 113 additions and 0 deletions.
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,9 @@ httpd = require('http.server').new(host, port[, { options } ])
* `keepalive_disable` - disables keep-alive connections with misbehaving
clients. Parameter accept a map that contains a user agents with non-nil
value. By default map is empty.
* `idle_timeout` - maximum amount of time an idle (keep-alive) connection will
remain idle before closing. When the idle timeout is exceeded, HTTP server
closes the keepalive connection. Default value: 0 seconds (disabled).

Example:

Expand Down
58 changes: 58 additions & 0 deletions http/server.lua
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

local lib = require('http.lib')

local fiber = require('fiber')
local fio = require('fio')
local require = require
local package = package
Expand All @@ -15,6 +16,10 @@ local errno = require 'errno'

local DETACHED = 101

local function idle_timeout_enabled(httpd_obj)
return httpd_obj.options.idle_timeout > 0
end

local function errorf(fmt, ...)
error(string.format(fmt, ...))
end
Expand Down Expand Up @@ -753,7 +758,43 @@ local function parse_request(req)
return p
end

local function add_connection(self, fiber_obj)
local fiber_id = fiber_obj:id()
self.connections[fiber_id] = {
obj = fiber_obj,
last_time_active = fiber.clock(),
}

return true
end

local function close_connection(self, fiber_obj, fiber_id, logger)
-- Name of fiber for http connection contains a hostname and port of http client.
local fiber_name = fiber_obj:name()
self.connections[fiber_id] = nil
logger("Close connection with %s, idle_timeout was exceeded\n", fiber_name)
fiber_obj:cancel()

return true
end

local function check_connections(self)
local logger = get_request_logger(self.options)

-- FIXME: complexity is O(N) and it is bad
for fiber_id, opts in pairs(self.connections) do
if fiber.clock() - opts.last_time_active >= self.options.idle_timeout then
self:close_connection(opts.obj, fiber_id, logger)
end
end
end

local function process_client(self, s, peer)
if idle_timeout_enabled(self) then
self:check_connections()
self:add_connection(fiber.self())
end

while true do
local hdrs = ''

Expand Down Expand Up @@ -979,6 +1020,9 @@ local function httpd_stop(self)
self.tcp_server:close()
self.tcp_server = nil
end

self.connections = {}

return self
end

Expand Down Expand Up @@ -1306,6 +1350,10 @@ local exports = {
if type(options.keepalive_disable) ~= 'table' then
error('Option keepalive_disable must be a table.')
end
if options.idle_timeout ~= nil and
type(options.idle_timeout) ~= 'number' then
error('Option idle_timeout must be a number.')
end

local default = {
max_header_size = 4096,
Expand All @@ -1320,6 +1368,7 @@ local exports = {
log_errors = true,
display_errors = false,
keepalive_disable = {},
idle_timeout = 0, -- no timeout, option is disabled
}

local self = {
Expand All @@ -1344,6 +1393,10 @@ local exports = {
hook = set_hook,
url_for = url_for_httpd,

add_connection = add_connection,
close_connection = close_connection,
check_connections = check_connections,

-- Exposed to make it replaceable by a user.
tcp_server_f = socket.tcp_server,

Expand All @@ -1353,6 +1406,11 @@ local exports = {
ctx = {},
static = {},
},

-- A map with fiber id's to table with fiber object and time when
-- it was active last time. It is used for tracking idle http
-- connections.
connections = {},
}

return self
Expand Down
9 changes: 9 additions & 0 deletions test/helpers.lua
Original file line number Diff line number Diff line change
Expand Up @@ -94,4 +94,13 @@ helpers.teardown = function(httpd)
end)
end

helpers.tablelength = function(t)
local count = 0
for _ in pairs(t) do
count = count + 1
end

return count
end

return helpers
43 changes: 43 additions & 0 deletions test/integration/http_server_options_test.lua
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,20 @@ g.before_test('test_keepalive_disallowed', function()
g.httpd:start()
end)

g.before_test('test_idle_timeout_default', function()
g.httpd = helpers.cfgserv()
g.httpd:start()
end)

local IDLE_TIMEOUT = 3

g.before_test('test_idle_timeout', function()
g.httpd = helpers.cfgserv({
idle_timeout = IDLE_TIMEOUT,
})
g.httpd:start()
end)

g.after_each(function()
helpers.teardown(g.httpd)
end)
Expand Down Expand Up @@ -73,3 +87,32 @@ g.test_keepalive_change_in_runtime = function(g)
["Mozilla/4.0"] = true
})
end

g.test_idle_timeout_default = function(g)
local httpd = g.httpd
t.assert_equals(httpd.options.idle_timeout, 0)
t.assert_equals(#httpd.connections, 0)
end

g.test_idle_timeout = function(g)
local httpd = g.httpd
t.assert_equals(httpd.options.idle_timeout, IDLE_TIMEOUT)
t.assert_equals(helpers.tablelength(httpd.connections), 0) -- No open connections.

-- Open a connection to the started httpd with enabled keepalive header.
-- Otherwise connection will be closed right after processing.
local response = http_client.request('GET', helpers.base_uri .. '/test', nil, {
headers = {
connection = 'keep-alive'
},
})
t.assert_equals(response.status, 200)
t.assert_equals(response.headers.connection, 'keep-alive')
t.assert_equals(helpers.tablelength(httpd.connections), 1) -- A single connection is open.

-- Run method that closes connections that opened without activity more
-- than httpd.options.idle_timeout.
require('fiber').sleep(IDLE_TIMEOUT)
httpd:check_connections()
t.assert_equals(helpers.tablelength(httpd.connections), 0)
end

0 comments on commit a3794d5

Please sign in to comment.