Skip to content

Commit

Permalink
Feature | Shutdown on idle (#3209)
Browse files Browse the repository at this point in the history
* Feature | Shutdown on idle

* Add test asserting systemd sockets aren't closed

* Handle JRuby specific error post-shutdown

* Add test for between second request data, clean up test descriptions

* Rely on just the `IO.select` timeout

Co-authored-by: Jean Boussier <byroot@ruby-lang.org>

* Slight change to docs [ci skip]

* Include the first request in the timeout

---------

Co-authored-by: Jean Boussier <byroot@ruby-lang.org>
Co-authored-by: Nate Berkopec <nate.berkopec@gmail.com>
  • Loading branch information
3 people committed Sep 4, 2023
1 parent 2563396 commit 0b696a2
Show file tree
Hide file tree
Showing 6 changed files with 185 additions and 15 deletions.
4 changes: 4 additions & 0 deletions lib/puma/cli.rb
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,10 @@ def setup_options
$LOAD_PATH.unshift(*arg.split(':'))
end

o.on "--idle-timeout SECONDS", "Number of seconds until the next request before automatic shutdown" do |arg|
user_config.idle_timeout arg
end

o.on "-p", "--port PORT", "Define the TCP port to bind to",
"Use -b for more advanced options" do |arg|
user_config.bind "tcp://#{Configuration::DEFAULTS[:tcp_host]}:#{arg}"
Expand Down
4 changes: 3 additions & 1 deletion lib/puma/configuration.rb
Original file line number Diff line number Diff line change
Expand Up @@ -133,8 +133,10 @@ class Configuration
debug: false,
early_hints: nil,
environment: 'development'.freeze,
# Number of seconds to wait until we get the first data for the request
# Number of seconds to wait until we get the first data for the request.
first_data_timeout: 30,
# Number of seconds to wait until the next request before shutting down.
idle_timeout: nil,
io_selector_backend: :auto,
log_requests: false,
logger: STDOUT,
Expand Down
12 changes: 9 additions & 3 deletions lib/puma/dsl.rb
Original file line number Diff line number Diff line change
Expand Up @@ -315,16 +315,22 @@ def port(port, host=nil)
bind URI::Generic.build(scheme: 'tcp', host: host, port: Integer(port)).to_s
end

# Define how long the tcp socket stays open, if no data has been received.
# @see Puma::Server.new
def first_data_timeout(seconds)
@options[:first_data_timeout] = Integer(seconds)
end

# Define how long persistent connections can be idle before Puma closes them.
# @see Puma::Server.new
def persistent_timeout(seconds)
@options[:persistent_timeout] = Integer(seconds)
end

# Define how long the tcp socket stays open, if no data has been received.
# If a new request is not received within this number of seconds, begin shutting down.
# @see Puma::Server.new
def first_data_timeout(seconds)
@options[:first_data_timeout] = Integer(seconds)
def idle_timeout(seconds)
@options[:idle_timeout] = Integer(seconds)
end

# Work around leaky apps that leave garbage in Thread locals
Expand Down
27 changes: 16 additions & 11 deletions lib/puma/server.rb
Original file line number Diff line number Diff line change
Expand Up @@ -81,15 +81,16 @@ def initialize(app, events = nil, options = {})
UserFileDefaultOptions.new(options, Configuration::DEFAULTS)
end

@log_writer = @options.fetch :log_writer, LogWriter.stdio
@early_hints = @options[:early_hints]
@first_data_timeout = @options[:first_data_timeout]
@min_threads = @options[:min_threads]
@max_threads = @options[:max_threads]
@persistent_timeout = @options[:persistent_timeout]
@queue_requests = @options[:queue_requests]
@max_fast_inline = @options[:max_fast_inline]
@io_selector_backend = @options[:io_selector_backend]
@log_writer = @options.fetch :log_writer, LogWriter.stdio
@early_hints = @options[:early_hints]
@first_data_timeout = @options[:first_data_timeout]
@persistent_timeout = @options[:persistent_timeout]
@idle_timeout = @options[:idle_timeout]
@min_threads = @options[:min_threads]
@max_threads = @options[:max_threads]
@queue_requests = @options[:queue_requests]
@max_fast_inline = @options[:max_fast_inline]
@io_selector_backend = @options[:io_selector_backend]
@http_content_length_limit = @options[:http_content_length_limit]

# make this a hash, since we prefer `key?` over `include?`
Expand Down Expand Up @@ -325,8 +326,12 @@ def handle_servers

while @status == :run || (drain && shutting_down?)
begin
ios = IO.select sockets, nil, nil, (shutting_down? ? 0 : nil)
break unless ios
ios = IO.select sockets, nil, nil, (shutting_down? ? 0 : @idle_timeout)
unless ios
@status = :stop unless shutting_down?
break
end

ios.first.each do |sock|
if sock == check
break if handle_check
Expand Down
24 changes: 24 additions & 0 deletions test/test_integration_single.rb
Original file line number Diff line number Diff line change
Expand Up @@ -254,4 +254,28 @@ def test_puma_debug_loaded_exts

cli_pumactl 'stop'
end

def test_pre_existing_unix_after_idle_timeout
skip_unless :unix

File.open(@bind_path, mode: 'wb') { |f| f.puts 'pre existing' }

cli_server "-q test/rackup/hello.ru", unix: :unix, config: "idle_timeout 1"

sock = connection = connect(nil, unix: true)
read_body(connection)

sleep 1.15

assert sock.wait_readable(1), 'Unexpected timeout'
assert_raises Puma.jruby? ? IOError : Errno::ECONNREFUSED, "Connection refused" do
connection = connect(nil, unix: true)
end

assert File.exist?(@bind_path)
ensure
if UNIX_SKT_EXIST
File.unlink @bind_path if File.exist? @bind_path
end
end
end
129 changes: 129 additions & 0 deletions test/test_puma_server.rb
Original file line number Diff line number Diff line change
Expand Up @@ -629,6 +629,135 @@ def test_no_timeout_after_data_received_no_queue
test_no_timeout_after_data_received
end

def test_idle_timeout_before_first_request
server_run(idle_timeout: 1)

sleep 1.15

assert @server.shutting_down?

assert_raises Errno::ECONNREFUSED do
send_http "POST / HTTP/1.1\r\nHost: test.com\r\nContent-Type: text/plain\r\nContent-Length: 12\r\n\r\n"
end
end

def test_idle_timeout_before_first_request_data
server_run(idle_timeout: 1)

sock = send_http "POST / HTTP/1.1\r\nHost: test.com\r\nContent-Type: text/plain\r\nContent-Length: 12\r\n\r\n"

sleep 1.15

sock << "hello world!"

data = sock.gets

assert_equal "HTTP/1.1 200 OK\r\n", data
end

def test_idle_timeout_between_first_request_data
server_run(idle_timeout: 1)

sock = send_http "POST / HTTP/1.1\r\nHost: test.com\r\nContent-Type: text/plain\r\nContent-Length: 12\r\n\r\n"

sock << "hello"

sleep 1.15

sock << " world!"

data = sock.gets

assert_equal "HTTP/1.1 200 OK\r\n", data
end

def test_idle_timeout_after_first_request
server_run(idle_timeout: 1)

sock = send_http "POST / HTTP/1.1\r\nHost: test.com\r\nContent-Type: text/plain\r\nContent-Length: 12\r\n\r\n"

sock << "hello world!"

data = sock.gets

assert_equal "HTTP/1.1 200 OK\r\n", data

sleep 1.15

assert @server.shutting_down?

assert sock.wait_readable(1), 'Unexpected timeout'
assert_raises Errno::ECONNREFUSED do
send_http "POST / HTTP/1.1\r\nHost: test.com\r\nContent-Type: text/plain\r\nContent-Length: 12\r\n\r\n"
end
end

def test_idle_timeout_between_request_data
server_run(idle_timeout: 1)

sock = send_http "POST / HTTP/1.1\r\nHost: test.com\r\nContent-Type: text/plain\r\nContent-Length: 12\r\n\r\n"

sock << "hello world!"

data = sock.gets

assert_equal "HTTP/1.1 200 OK\r\n", data

sleep 0.5

sock = send_http "POST / HTTP/1.1\r\nHost: test.com\r\nContent-Type: text/plain\r\nContent-Length: 12\r\n\r\n"

sock << "hello"

sleep 1.15

sock << " world!"

data = sock.gets

assert_equal "HTTP/1.1 200 OK\r\n", data

sleep 1.15

assert @server.shutting_down?

assert sock.wait_readable(1), 'Unexpected timeout'
assert_raises Errno::ECONNREFUSED do
send_http "POST / HTTP/1.1\r\nHost: test.com\r\nContent-Type: text/plain\r\nContent-Length: 12\r\n\r\n"
end
end

def test_idle_timeout_between_requests
server_run(idle_timeout: 1)

sock = send_http "POST / HTTP/1.1\r\nHost: test.com\r\nContent-Type: text/plain\r\nContent-Length: 12\r\n\r\n"

sock << "hello world!"

data = sock.gets

assert_equal "HTTP/1.1 200 OK\r\n", data

sleep 0.5

sock = send_http "POST / HTTP/1.1\r\nHost: test.com\r\nContent-Type: text/plain\r\nContent-Length: 12\r\n\r\n"

sock << "hello world!"

data = sock.gets

assert_equal "HTTP/1.1 200 OK\r\n", data

sleep 1.15

assert @server.shutting_down?

assert sock.wait_readable(1), 'Unexpected timeout'
assert_raises Errno::ECONNREFUSED do
send_http "POST / HTTP/1.1\r\nHost: test.com\r\nContent-Type: text/plain\r\nContent-Length: 12\r\n\r\n"
end
end

def test_http_11_keep_alive_with_body
server_run { [200, {"Content-Type" => "plain/text"}, ["hello\n"]] }

Expand Down

0 comments on commit 0b696a2

Please sign in to comment.