From bd3ed0b04f826b20cce83f9b77fc13c0eefd3902 Mon Sep 17 00:00:00 2001 From: Eric Wong Date: Wed, 18 Aug 2010 23:59:21 -0700 Subject: add Rainbows::ThreadTimeout middleware This allows for per-dispatch timeouts similar to (but not exactly) the way Mongrel (1.1.x) implemented them with threads. --- lib/rainbows.rb | 1 + lib/rainbows/thread_timeout.rb | 94 +++++++++++++++++++++++++++++++++++++ t/t9100-thread-timeout.sh | 36 ++++++++++++++ t/t9100.ru | 9 ++++ t/t9101-thread-timeout-threshold.sh | 62 ++++++++++++++++++++++++ t/t9101.ru | 9 ++++ 6 files changed, 211 insertions(+) create mode 100644 lib/rainbows/thread_timeout.rb create mode 100755 t/t9100-thread-timeout.sh create mode 100644 t/t9100.ru create mode 100755 t/t9101-thread-timeout-threshold.sh create mode 100644 t/t9101.ru diff --git a/lib/rainbows.rb b/lib/rainbows.rb index 2faf3c8..52e7519 100644 --- a/lib/rainbows.rb +++ b/lib/rainbows.rb @@ -137,4 +137,5 @@ module Rainbows autoload :ByteSlice, 'rainbows/byte_slice' autoload :StreamFile, 'rainbows/stream_file' autoload :HttpResponse, 'rainbows/http_response' # deprecated + autoload :ThreadTimeout, 'rainbows/thread_timeout' end diff --git a/lib/rainbows/thread_timeout.rb b/lib/rainbows/thread_timeout.rb new file mode 100644 index 0000000..53c675b --- /dev/null +++ b/lib/rainbows/thread_timeout.rb @@ -0,0 +1,94 @@ +# -*- encoding: binary -*- +require 'thread' + +# Soft timeout middleware for thread-based concurrency models in \Rainbows! +# This timeout only includes application dispatch, and will not take into +# account the (rare) response bodies that are dynamically generated while +# they are being written out to the client. +# +# In your rackup config file (config.ru), the following line will +# cause execution to timeout in 1.5 seconds. +# +# use Rainbows::ThreadTimeout, :timeout => 1.5 +# run MyApplication.new +# +# You may also specify a threshold, so the timeout does not take +# effect until there are enough active clients. It does not make +# sense to set a +:threshold+ higher or equal to the +# +worker_connections+ \Rainbows! configuration parameter. +# You may specify a negative threshold to be an absolute +# value relative to the +worker_connections+ parameter, thus +# if you specify a threshold of -1, and have 100 worker_connections, +# ThreadTimeout will only activate when there are 99 active requests. +# +# use Rainbows::ThreadTimeout, :timeout => 1.5, :threshold => -1 +# run MyApplication.new +# +# This middleware only affects elements below it in the stack, so +# it can be configured to ignore certain endpoints or middlewares. +# +# Timed-out requests will cause this middleware to return with a +# "408 Request Timeout" response. + +class Rainbows::ThreadTimeout < Struct.new(:app, :timeout, + :threshold, :watchdog, + :active, :lock) + + # :stopdoc: + class ExecutionExpired < ::Exception + end + + def initialize(app, opts) + timeout = opts[:timeout] + Numeric === timeout or + raise TypeError, "timeout=#{timeout.inspect} is not numeric" + + if threshold = opts[:threshold] + Integer === threshold or + raise TypeError, "threshold=#{threshold.inspect} is not an integer" + threshold == 0 and + raise ArgumentError, "threshold=0 does not make sense" + threshold < 0 and + threshold += Rainbows::G.server.worker_connections + end + super(app, timeout, threshold, nil, {}, Mutex.new) + end + + def call(env) + lock.synchronize do + start_watchdog unless watchdog + active[Thread.current] = Time.now + timeout + end + begin + app.call(env) + ensure + lock.synchronize { active.delete(Thread.current) } + end + rescue ExecutionExpired + [ 408, { 'Content-Type' => 'text/plain', 'Content-Length' => '0' }, [] ] + end + + def start_watchdog + self.watchdog = Thread.new do + begin + if next_wake = lock.synchronize { active.values }.min + next_wake -= Time.now + sleep(next_wake) if next_wake > 0 + else + sleep(timeout) + end + + # "active.size" is atomic in MRI 1.8 and 1.9 + next if threshold && active.size < threshold + + now = Time.now + lock.synchronize do + active.delete_if do |thread, time| + time >= now and thread.raise(ExecutionExpired).nil? + end + end + end while true + end + end + # :startdoc: +end diff --git a/t/t9100-thread-timeout.sh b/t/t9100-thread-timeout.sh new file mode 100755 index 0000000..ab46e5a --- /dev/null +++ b/t/t9100-thread-timeout.sh @@ -0,0 +1,36 @@ +#!/bin/sh +. ./test-lib.sh +case $model in +ThreadSpawn|ThreadPool|RevThreadSpawn|RevThreadPool) ;; +*) t_info "$0 is only compatible with Thread*"; exit 0 ;; +esac + +t_plan 5 "ThreadTimeout Rack middleware test for $model" + +t_begin "configure and start" && { + rtmpfiles curl_err + rainbows_setup + rainbows -D t9100.ru -c $unicorn_config + rainbows_wait_start +} + +t_begin "normal request should not timeout" && { + test x"HI" = x"$(curl -sSf http://$listen/ 2>> $curl_err)" +} + +t_begin "sleepy request times out with 408" && { + rm -f $ok + curl -sSf http://$listen/2 2>> $curl_err || > $ok + test -e $ok + grep 408 $curl_err +} + +t_begin "kill server" && { + kill $rainbows_pid +} + +t_begin "no errors in Rainbows! stderr" && { + check_stderr +} + +t_done diff --git a/t/t9100.ru b/t/t9100.ru new file mode 100644 index 0000000..ed9e1ee --- /dev/null +++ b/t/t9100.ru @@ -0,0 +1,9 @@ +use Rack::ContentLength +use Rack::ContentType, 'text/plain' +use Rainbows::ThreadTimeout, :timeout => 1 +run lambda { |env| + if env["PATH_INFO"] =~ %r{/([\d\.]+)\z} + Rainbows.sleep($1.to_f) + end + [ 200, [], [ "HI\n" ] ] +} diff --git a/t/t9101-thread-timeout-threshold.sh b/t/t9101-thread-timeout-threshold.sh new file mode 100755 index 0000000..1979dba --- /dev/null +++ b/t/t9101-thread-timeout-threshold.sh @@ -0,0 +1,62 @@ +#!/bin/sh +. ./test-lib.sh +case $model in +ThreadSpawn|ThreadPool|RevThreadSpawn|RevThreadPool) ;; +*) t_info "$0 is only compatible with Thread*"; exit 0 ;; +esac + +t_plan 6 "ThreadTimeout Rack middleware test for $model" + +t_begin "configure and start" && { + rtmpfiles curl_err curl_out + rainbows_setup $model 10 + rainbows -D t9101.ru -c $unicorn_config + rainbows_wait_start +} + +t_begin "normal request should not timeout" && { + test x"HI" = x"$(curl -sSf http://$listen/ 2>> $curl_err)" +} + +t_begin "8 sleepy requests do not time out" && { + > $curl_err + for i in 1 2 3 4 5 6 7 8 + do + curl --no-buffer -sSf http://$listen/3 \ + 2>> $curl_err >> $curl_out & + done + wait + test 8 -eq "$(wc -l < $curl_out)" + test xHI = x"$(sort < $curl_out | uniq)" +} + +t_begin "9 sleepy requests do time out" && { + > $curl_err + > $curl_out + for i in 1 2 3 4 5 6 7 8 9 + do + rtmpfiles curl_err_$i + curl -sSf --no-buffer \ + http://$listen/3 2>> ${curl_err}_${i} >> $curl_out & + done + wait + if test -s $curl_out + then + dbgcat curl_out + die "$curl_out should be empty" + fi + for i in 1 2 3 4 5 6 7 8 9 + do + grep 408 ${curl_err}_${i} + done +} + +t_begin "kill server" && { + kill $rainbows_pid +} + +t_begin "no errors in Rainbows! stderr" && { + check_stderr +} + +t_done diff --git a/t/t9101.ru b/t/t9101.ru new file mode 100644 index 0000000..ee20085 --- /dev/null +++ b/t/t9101.ru @@ -0,0 +1,9 @@ +use Rack::ContentLength +use Rack::ContentType, 'text/plain' +use Rainbows::ThreadTimeout, :timeout => 1, :threshold => -1 +run lambda { |env| + if env["PATH_INFO"] =~ %r{/([\d\.]+)\z} + Rainbows.sleep($1.to_f) + end + [ 200, [], [ "HI\n" ] ] +} -- cgit v1.2.3-24-ge0c7