long_body 0.0.1

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA1:
3
+ metadata.gz: 919ec90f6d67ef12a71c59222abc33cfb7c48256
4
+ data.tar.gz: 3ba12c21e137a5ecf7351c36a2c03dfdac80b536
5
+ SHA512:
6
+ metadata.gz: 9947da48ea520ee2a2f5d7d73e103f0264853ce10ae868624d835f61097a78979d6e7c5be1385dfe0a51554e4da22920944c98c6990fcd4f75417b193e37fefc
7
+ data.tar.gz: 6fcb2f4830d5cc4be8ad2fce1c9c5f899a65423e30d37e3e24b1cfc87d8052bda8e9c24c507d411e7a6a4bb247478dd4a34fc58e6bc6fdf40239d24891edf669
data/.document ADDED
@@ -0,0 +1,5 @@
1
+ lib/**/*.rb
2
+ bin/*
3
+ -
4
+ features/**/*.feature
5
+ LICENSE.txt
data/.rspec ADDED
@@ -0,0 +1 @@
1
+ --color
data/Gemfile ADDED
@@ -0,0 +1,17 @@
1
+ source "http://rubygems.org"
2
+ gem 'rack', '~> 1'
3
+
4
+ # Add dependencies to develop your gem here.
5
+ # Include everything needed to run rake, tests, features, etc.
6
+ group :development do
7
+ gem 'thin', '~> 1.6'
8
+ gem 'puma', '~> 2', '>= 2.13.4'
9
+ gem "rspec", "~> 3.2", '< 3.3' # 3.3 does not work nicely with TextMate ATM
10
+ gem "rdoc", "~> 3.12"
11
+ gem "bundler", "~> 1.0"
12
+ gem "jeweler", "~> 2.0.1"
13
+ gem "simplecov", ">= 0"
14
+ gem 'retriable'
15
+ gem 'passenger', '~> 5'
16
+ gem 'rainbows'
17
+ end
data/LICENSE.txt ADDED
@@ -0,0 +1,20 @@
1
+ Copyright (c) 2015 Julik Tarkhanov
2
+
3
+ Permission is hereby granted, free of charge, to any person obtaining
4
+ a copy of this software and associated documentation files (the
5
+ "Software"), to deal in the Software without restriction, including
6
+ without limitation the rights to use, copy, modify, merge, publish,
7
+ distribute, sublicense, and/or sell copies of the Software, and to
8
+ permit persons to whom the Software is furnished to do so, subject to
9
+ the following conditions:
10
+
11
+ The above copyright notice and this permission notice shall be
12
+ included in all copies or substantial portions of the Software.
13
+
14
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
15
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
16
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
17
+ NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
18
+ LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
19
+ OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
20
+ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,75 @@
1
+ # long_body
2
+
3
+ Universal Rack middleware for immediate (after-headers) streaming of long Rack response bodies.
4
+ Normally most Rack handler webservers will buffer your entire response, or buffer your response
5
+ without telling you for some time before sending it.
6
+
7
+ This module provides a universal wrapper that will use the features of a specific webserver
8
+ to send your response with as little buffering as possible (direct to socket). For decent
9
+ servers that allow `rack.hijack` it will use that, for Thin it will use deferrables.
10
+
11
+ Note that within Thin sleeping in a long body might block the EM loop.
12
+
13
+ ## Usage examples
14
+
15
+ Server-sent events combined with `Transfer-Encoding: chunked` (can be used for chat applications and so forth):
16
+
17
+ class EventSource
18
+ def each
19
+ 20.times do | event_num |
20
+ yield "event: ping\n"
21
+ yield "data: ping_number_#{event_num}"
22
+ yield "\n\n"
23
+ sleep 3
24
+ end
25
+ end
26
+ end
27
+
28
+ # config.ru
29
+ use LongBody
30
+ use Rack::Chunked
31
+ run ->(env) {
32
+ h = {'Content-Type' => 'text/event-stream'}
33
+ [200, h, EventSource.new]
34
+ }
35
+
36
+ Streaming a large file, without buffering:
37
+
38
+ # config.ru
39
+ use LongBody
40
+ run ->(env) {
41
+ s = File.size("/tmp/large_file.bin")
42
+ h = {'Content-Length' => s}
43
+ [200, h, File.open(s, 'rb')]
44
+ }
45
+
46
+ ## Compatibility
47
+
48
+ This gem is tested on Ruby 2.2, and should run acceptably well on Ruby 2.+. If you are using Thin you have to
49
+ use Ruby 2.+ because of the fiber stack size limitation. If you are using Puma, Rainbows or other threaded
50
+ server running this gem on 1.9.2 should be possible as well.
51
+
52
+ <table>
53
+ <tr><th>Webserver</th><th>Version tested</th><th>Compatibility</th></tr>
54
+ <tr><td>Puma</td><td>2.13.4</td><td>Yes (use versions >= 2.13.4 due to a bug)</td></tr>
55
+ <tr><td>Passenger</td><td>5.0.15</td><td>Yes</td></tr>
56
+ <tr><td>Thin</td><td>1.6.3</td><td>Yes</td></tr>
57
+ <tr><td>Rainbows</td><td>4.6.2</td><td>Yes</td></tr>
58
+ <tr><td>WEBrick</td><td>stdlib 2.2.1</td><td>No (uses IO.pipe for hijack)</td></tr>
59
+ </table>
60
+
61
+ ## Contributing to long_body
62
+
63
+ * Check out the latest master to make sure the feature hasn't been implemented or the bug hasn't been fixed yet.
64
+ * Check out the issue tracker to make sure someone already hasn't requested it and/or contributed it.
65
+ * Fork the project.
66
+ * Start a feature/bugfix branch.
67
+ * Commit and push until you are happy with your contribution.
68
+ * Make sure to add tests for it. This is important so I don't break it in a future version unintentionally.
69
+ * Please try not to mess with the Rakefile, version, or history. If you want to have your own version, or is otherwise necessary, that is fine, but please isolate to its own commit so I can cherry-pick around it.
70
+
71
+ ## Copyright
72
+
73
+ Copyright (c) 2015 Julik Tarkhanov. See LICENSE.txt for
74
+ further details.
75
+
data/Rakefile ADDED
@@ -0,0 +1,47 @@
1
+ # encoding: utf-8
2
+
3
+ require 'rubygems'
4
+ require 'bundler'
5
+ begin
6
+ Bundler.setup(:default, :development)
7
+ rescue Bundler::BundlerError => e
8
+ $stderr.puts e.message
9
+ $stderr.puts "Run `bundle install` to install missing gems"
10
+ exit e.status_code
11
+ end
12
+ require 'rake'
13
+
14
+ require 'jeweler'
15
+ require_relative 'lib/long_body/version'
16
+
17
+ Jeweler::Tasks.new do |gem|
18
+ # gem is a Gem::Specification... see http://guides.rubygems.org/specification-reference/ for more options
19
+ gem.name = "long_body"
20
+ gem.homepage = "http://github.com/julik/long_body"
21
+ gem.license = "MIT"
22
+ gem.summary = %Q{Direct-to-socket streaming of Rack response bodies}
23
+ gem.description = %Q{Direct-to-socket streaming of Rack response bodies}
24
+ gem.version = LongBody::VERSION
25
+ gem.email = "me@julik.nl"
26
+ gem.authors = ["Julik Tarkhanov"]
27
+ # dependencies defined in Gemfile
28
+ end
29
+ Jeweler::RubygemsDotOrgTasks.new
30
+
31
+ require 'rspec/core'
32
+ require 'rspec/core/rake_task'
33
+ RSpec::Core::RakeTask.new(:spec) do |spec|
34
+ spec.pattern = FileList['spec/**/*_spec.rb']
35
+ end
36
+
37
+ task :default => :spec
38
+
39
+ require 'rdoc/task'
40
+ Rake::RDocTask.new do |rdoc|
41
+ version = File.exist?('VERSION') ? File.read('VERSION') : ""
42
+
43
+ rdoc.rdoc_dir = 'rdoc'
44
+ rdoc.title = "long_body #{version}"
45
+ rdoc.rdoc_files.include('README*')
46
+ rdoc.rdoc_files.include('lib/**/*.rb')
47
+ end
@@ -0,0 +1,32 @@
1
+ # Lifted from https://github.com/macournoyer/thin_async/blob/master/lib/thin/async.rb
2
+ # and originally written by James Tucker <raggi@rubyforge.org>
3
+ class LongBody::DeferrableBody
4
+ include EM::Deferrable
5
+
6
+ def initialize
7
+ @queue = []
8
+ end
9
+
10
+ def call(body)
11
+ @queue << body
12
+ schedule_dequeue
13
+ end
14
+
15
+ def each(&blk)
16
+ @body_callback = blk
17
+ schedule_dequeue
18
+ end
19
+
20
+ private
21
+
22
+ def schedule_dequeue
23
+ return unless @body_callback
24
+ EM.next_tick do
25
+ next unless body = @queue.shift
26
+ body.each do |chunk|
27
+ @body_callback.call(chunk)
28
+ end
29
+ schedule_dequeue unless @queue.empty?
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,54 @@
1
+ # The problem with fast each() bodies is that the Ruby process will
2
+ # enter a busy wait if the write socket for the webserver is saturated.
3
+ #
4
+ # We can bypass that by releasing the CPU using a select(), but for that
5
+ # we have to use "rack.hijack" in combination with a nonblocking write.
6
+ #
7
+ # For more on this:
8
+ # http://apidock.com/ruby/IO/write_nonblock
9
+ # http://old.blog.phusion.nl/2013/01/23/the-new-rack-socket-hijacking-api/
10
+ #
11
+ # By using this class as a middleware you will put a select() wait
12
+ # spinlock on the output socket of the webserver.
13
+ #
14
+ # The middleware will only trigger for 200 and 206 responses, and only
15
+ # if the Rack handler it is running on supports rack hijacking.
16
+ module LongBody::HijackHandler
17
+ extend self
18
+ HIJACK_HEADER = 'rack.hijack'.freeze
19
+
20
+ def perform(env, s, h, b)
21
+ # Replace the output with our socket moderating technology 2.0
22
+ h[HIJACK_HEADER] = create_socket_writer_lambda_with_body(b)
23
+ [s, h, []] # Recommended response body for partial hijack is an empty Array
24
+ end
25
+
26
+ def create_socket_writer_lambda_with_body(rack_response_body)
27
+ lambda do |socket|
28
+ begin
29
+ rack_response_body.each do | chunk |
30
+ begin
31
+ num_bytes_written = socket.write_nonblock(chunk)
32
+ # If we could write only partially, make sure we do a retry on the next
33
+ # iteration with the remaining part
34
+ if num_bytes_written < chunk.bytesize
35
+ chunk = chunk[num_bytes_written..-1]
36
+ raise Errno::EINTR
37
+ end
38
+ rescue IO::WaitWritable, Errno::EINTR # The output socket is saturated.
39
+ # If we are running within a threaded server,
40
+ # let another thread preempt here. We are waiting for IO
41
+ # and some other thread might have things to do here.
42
+ IO.select(nil, [socket]) # ...then wait on the socket to be writable again
43
+ retry # and off we go...
44
+ rescue Errno::EPIPE, Errno::EPROTOTYPE # Happens when the client aborts the connection
45
+ return
46
+ end
47
+ end
48
+ ensure
49
+ rack_response_body.close if rack_response_body.respond_to?(:close)
50
+ socket.close if socket.respond_to?(:closed?) && socket.respond_to?(:close) && !socket.closed?
51
+ end
52
+ end
53
+ end
54
+ end
@@ -0,0 +1,14 @@
1
+ # Rack::Lint bails on async.callback responses. "Fix" it to bypass
2
+ # if running within Thin and when async.callback is available.
3
+ module Rack
4
+ class Lint
5
+ THIN_RE = /^thin/.freeze
6
+ def call(env=nil)
7
+ if env && env['async.callback'] && env['SERVER_SOFTWARE'].to_s =~ THIN_RE
8
+ @app.call(env)
9
+ else
10
+ dup._call(env)
11
+ end
12
+ end
13
+ end
14
+ end
@@ -0,0 +1,114 @@
1
+ # Turns an iterable Rack response body that responds to each() into
2
+ # something that Thin can use within EventMachine. Uses internal Thin
3
+ # interfaces so not applicable for other servers.
4
+ module LongBody::ThinHandler
5
+ extend self
6
+
7
+ AsyncResponse = [-1, {}, []].freeze
8
+
9
+ # A wrapper that allows us to access an object that yields from each()
10
+ # as if it were an Enumerator-ish object.
11
+ #
12
+ # arr = %w( a b )
13
+ # w = FiberWrapper.new(arr)
14
+ # w.take #=> 'a'
15
+ # w.take #=> 'b'
16
+ # w.take #=> nil # Ended
17
+ class FiberWrapper
18
+ def initialize(eachable)
19
+ @fiber = Fiber.new do
20
+ eachable.each{|chunk| Fiber.yield(chunk.to_s) }
21
+ eachable.close if eachable.respond_to?(:close)
22
+ nil
23
+ end
24
+ end
25
+
26
+ def take
27
+ @fiber.resume
28
+ rescue FiberError
29
+ nil
30
+ end
31
+ end
32
+
33
+ # Controls the scheduling of the trickle-feed using EM.next_tick.
34
+ class ResponseSender
35
+ attr_reader :deferrable_body
36
+ def initialize(eachable_body)
37
+ require_relative 'deferrable_body'
38
+ require_relative 'lint_bypass'
39
+ @eachable_body = eachable_body
40
+ @enumerator = FiberWrapper.new(eachable_body)
41
+ @deferrable_body = LongBody::DeferrableBody.new
42
+ end
43
+
44
+ def abort!
45
+ @eachable_body.abort!
46
+ @deferrable_body.fail
47
+ end
48
+
49
+ def send_next_chunk
50
+ next_chunk = begin
51
+ @enumerator.take
52
+ rescue StandardError => e
53
+ abort!
54
+ end
55
+
56
+ if next_chunk
57
+ @deferrable_body.call([next_chunk]) # Has to be given in an Array
58
+ EM.next_tick { send_next_chunk }
59
+ else
60
+ @deferrable_body.succeed
61
+ end
62
+ end
63
+ end
64
+
65
+ # We need a way to raise from each() when the connection
66
+ # is closed prematurely.
67
+ class BodyWrapperWithExplicitClose
68
+ def abort!
69
+ @aborted = true; close
70
+ end
71
+
72
+ def close
73
+ @rack_body.close if @rack_body.respond_to?(:close)
74
+ end
75
+
76
+ def initialize(rack_body)
77
+ @rack_body = rack_body
78
+ end
79
+
80
+ def each
81
+ @rack_body.each do | bytes |
82
+ # Break the body out of the loop if the response is aborted (client disconnect)
83
+ raise "Disconnect or connection close" if @aborted
84
+ yield(bytes)
85
+ end
86
+ end
87
+ end
88
+
89
+ C_async_close = 'async.close'.freeze
90
+ C_async_callback = 'async.callback'.freeze
91
+
92
+ def perform(env, s, h, b)
93
+ # Wrap in a handler that will raise from within each()
94
+ # if async.close is triggered while the response is dripping by
95
+ b = BodyWrapperWithExplicitClose.new(b)
96
+
97
+ # Wrap in a handler that manages the DeferrableBody
98
+ sender = ResponseSender.new(b)
99
+
100
+ # Abort the body sending on both of those.
101
+ env[C_async_close].callback { sender.abort! }
102
+ env[C_async_close].errback { sender.abort! }
103
+ env[C_async_callback].call([s, h, sender.deferrable_body])
104
+ sender.send_next_chunk
105
+
106
+ AsyncResponse # Let Thin know we are using async.*
107
+ end
108
+
109
+ private
110
+
111
+ def running_with_thin?(env)
112
+ defined?(Thin) && env[C_async_callback] && env[C_async_callback].respond_to?(:call)
113
+ end
114
+ end
@@ -0,0 +1,3 @@
1
+ class LongBody
2
+ VERSION = '0.0.1'
3
+ end
data/lib/long_body.rb ADDED
@@ -0,0 +1,73 @@
1
+ # The base middleware class to use in your Rack application, Sinatra, Rails etc.
2
+ #
3
+ # use LongBody
4
+ #
5
+ # Note that if you want to use Rack::Chunked (and you most likely do) you have to insert it
6
+ # below LongBody:
7
+ #
8
+ # use LongBody
9
+ # use Rack::Chunked
10
+ class LongBody
11
+
12
+ require_relative 'long_body/version'
13
+ require_relative 'long_body/thin_handler'
14
+ require_relative 'long_body/hijack_handler'
15
+
16
+ class MisconfiguredBody < StandardError
17
+ def message
18
+ "Either Transfer-Encoding: chunked or Content-Length: <digits> must be set. " +
19
+ "If uncertain, insert Rack::ContentLength into your middleware chain to set Content-Length " +
20
+ "for all responses that do not pre-specify it, and Rack::Chunked to apply chunked encoding " +
21
+ "to all responses of unknown length"
22
+ end
23
+ end
24
+
25
+ STREAMABLE_CODES = [200, 206]
26
+
27
+ def status_might_have_streamable_body?(status_code)
28
+ STREAMABLE_CODES.include?(status_code.to_i)
29
+ end
30
+
31
+ def ensure_chunked_or_content_length!(header_hash)
32
+ unless header_hash['Transfer-Encoding'] == 'chunked' || header_hash['Content-Length']
33
+ raise MisconfiguredBody
34
+ end
35
+ end
36
+
37
+ def initialize(app)
38
+ @app = app
39
+ end
40
+
41
+ C_rack_logger = 'rack.logger'.freeze
42
+ HIJACK_SUPPORTED = 'rack.hijack?'.freeze
43
+ ASYNC_SUPPORT = 'async.callback'.freeze
44
+
45
+ def call(env)
46
+ # Call the upstream first
47
+ s, h, b = @app.call(env)
48
+
49
+ # If the response has nothing to do with the streaming response, just
50
+ # let it go through as it is not big enough to bother. Also if there is no hijack
51
+ # support there is no sense to bother at all.
52
+ return [s, h, b] unless status_might_have_streamable_body?(s)
53
+
54
+ # If the body is nil or is not each-able, return the original response - there probably is some other
55
+ # async trickery going on downstream from us, and we should not intercept this response.
56
+ return [s, h, b] if (b.nil? || !b.respond_to?(:each) || (b.respond_to?(:empty?) && b.empty?))
57
+
58
+ # Ensure either Content-Length or chunking is in place
59
+ ensure_chunked_or_content_length!(h)
60
+
61
+ # TODO: ensure not already hijacked
62
+
63
+ if env[ASYNC_SUPPORT]
64
+ env[C_rack_logger].info("Streaming via async.callback (Thin)") if env[C_rack_logger].respond_to?(:info)
65
+ ThinHandler.perform(env, s, h, b)
66
+ elsif env[HIJACK_SUPPORTED]
67
+ env[C_rack_logger].info("Streaming via hijack and IO.select") if env[C_rack_logger].respond_to?(:info)
68
+ HijackHandler.perform(env, s, h, b)
69
+ else
70
+ [s, h, b] # No recourse
71
+ end
72
+ end
73
+ end
data/long_body.gemspec ADDED
@@ -0,0 +1,89 @@
1
+ # Generated by jeweler
2
+ # DO NOT EDIT THIS FILE DIRECTLY
3
+ # Instead, edit Jeweler::Tasks in Rakefile, and run 'rake gemspec'
4
+ # -*- encoding: utf-8 -*-
5
+ # stub: long_body 0.0.1 ruby lib
6
+
7
+ Gem::Specification.new do |s|
8
+ s.name = "long_body"
9
+ s.version = "0.0.1"
10
+
11
+ s.required_rubygems_version = Gem::Requirement.new(">= 0") if s.respond_to? :required_rubygems_version=
12
+ s.require_paths = ["lib"]
13
+ s.authors = ["Julik Tarkhanov"]
14
+ s.date = "2015-08-29"
15
+ s.description = "Direct-to-socket streaming of Rack response bodies"
16
+ s.email = "me@julik.nl"
17
+ s.extra_rdoc_files = [
18
+ "LICENSE.txt",
19
+ "README.md"
20
+ ]
21
+ s.files = [
22
+ ".document",
23
+ ".rspec",
24
+ "Gemfile",
25
+ "LICENSE.txt",
26
+ "README.md",
27
+ "Rakefile",
28
+ "lib/long_body.rb",
29
+ "lib/long_body/deferrable_body.rb",
30
+ "lib/long_body/hijack_handler.rb",
31
+ "lib/long_body/lint_bypass.rb",
32
+ "lib/long_body/thin_handler.rb",
33
+ "lib/long_body/version.rb",
34
+ "long_body.gemspec",
35
+ "spec/long_body_spec.rb",
36
+ "spec/shared_webserver_examples.rb",
37
+ "spec/spec_helper.rb",
38
+ "spec/streaming_app.ru",
39
+ "spec/support/server_runners.rb",
40
+ "spec/test_download.rb"
41
+ ]
42
+ s.homepage = "http://github.com/julik/long_body"
43
+ s.licenses = ["MIT"]
44
+ s.rubygems_version = "2.2.2"
45
+ s.summary = "Direct-to-socket streaming of Rack response bodies"
46
+
47
+ if s.respond_to? :specification_version then
48
+ s.specification_version = 4
49
+
50
+ if Gem::Version.new(Gem::VERSION) >= Gem::Version.new('1.2.0') then
51
+ s.add_runtime_dependency(%q<rack>, ["~> 1"])
52
+ s.add_development_dependency(%q<thin>, ["~> 1.6"])
53
+ s.add_development_dependency(%q<puma>, [">= 2.13.4", "~> 2"])
54
+ s.add_development_dependency(%q<rspec>, ["< 3.3", "~> 3.2"])
55
+ s.add_development_dependency(%q<rdoc>, ["~> 3.12"])
56
+ s.add_development_dependency(%q<bundler>, ["~> 1.0"])
57
+ s.add_development_dependency(%q<jeweler>, ["~> 2.0.1"])
58
+ s.add_development_dependency(%q<simplecov>, [">= 0"])
59
+ s.add_development_dependency(%q<retriable>, [">= 0"])
60
+ s.add_development_dependency(%q<passenger>, ["~> 5"])
61
+ s.add_development_dependency(%q<rainbows>, [">= 0"])
62
+ else
63
+ s.add_dependency(%q<rack>, ["~> 1"])
64
+ s.add_dependency(%q<thin>, ["~> 1.6"])
65
+ s.add_dependency(%q<puma>, [">= 2.13.4", "~> 2"])
66
+ s.add_dependency(%q<rspec>, ["< 3.3", "~> 3.2"])
67
+ s.add_dependency(%q<rdoc>, ["~> 3.12"])
68
+ s.add_dependency(%q<bundler>, ["~> 1.0"])
69
+ s.add_dependency(%q<jeweler>, ["~> 2.0.1"])
70
+ s.add_dependency(%q<simplecov>, [">= 0"])
71
+ s.add_dependency(%q<retriable>, [">= 0"])
72
+ s.add_dependency(%q<passenger>, ["~> 5"])
73
+ s.add_dependency(%q<rainbows>, [">= 0"])
74
+ end
75
+ else
76
+ s.add_dependency(%q<rack>, ["~> 1"])
77
+ s.add_dependency(%q<thin>, ["~> 1.6"])
78
+ s.add_dependency(%q<puma>, [">= 2.13.4", "~> 2"])
79
+ s.add_dependency(%q<rspec>, ["< 3.3", "~> 3.2"])
80
+ s.add_dependency(%q<rdoc>, ["~> 3.12"])
81
+ s.add_dependency(%q<bundler>, ["~> 1.0"])
82
+ s.add_dependency(%q<jeweler>, ["~> 2.0.1"])
83
+ s.add_dependency(%q<simplecov>, [">= 0"])
84
+ s.add_dependency(%q<retriable>, [">= 0"])
85
+ s.add_dependency(%q<passenger>, ["~> 5"])
86
+ s.add_dependency(%q<rainbows>, [">= 0"])
87
+ end
88
+ end
89
+
@@ -0,0 +1,12 @@
1
+ require File.expand_path(File.dirname(__FILE__) + '/spec_helper')
2
+ require_relative 'test_download'
3
+ require 'retriable'
4
+ require_relative 'shared_webserver_examples'
5
+
6
+ describe "LongBody" do
7
+ SERVERS.each do | server_engine |
8
+ context "on #{server_engine.name}" do
9
+ it_behaves_like "compliant"
10
+ end
11
+ end
12
+ end
@@ -0,0 +1,61 @@
1
+ RSpec.shared_examples "compliant" do
2
+ it "when the response is sent in full works with predefined Content-Length" do | example |
3
+ parts = TestDownload.perform("http://0.0.0.0:9393/with-content-length")
4
+ timings = parts.map(&:time_difference)
5
+
6
+ # $postrun.puts ""
7
+ # $postrun.puts "#{example.full_description} part receive timings: #{timings.inspect}"
8
+ # $postrun.puts ""
9
+
10
+ expect(File).to exist('/tmp/streamer_close.mark')
11
+
12
+ # Ensure the time recieved of each part is within the tolerances, and certainly
13
+ # at least 1 second after the previous
14
+ (1..(parts.length-1)).each do | part_i|
15
+ this_part = parts[part_i]
16
+ previous_part = parts[part_i -1]
17
+ received_after_previous = this_part.time_difference - previous_part.time_difference
18
+
19
+ # Ensure there was some time before this chunk arrived. This is the most important test.
20
+ expect(received_after_previous).to be_within(1).of(0.3)
21
+ end
22
+ end
23
+
24
+ it 'when the response is sent in full works with chunked encoding' do | example |
25
+ parts = TestDownload.perform("http://0.0.0.0:9393/chunked")
26
+ timings = parts.map(&:time_difference)
27
+
28
+ # $postrun.puts example.full_description
29
+ # $postrun.puts "Part receive timings: #{timings.inspect}"
30
+ # $postrun.puts ""
31
+
32
+ expect(File).to exist('/tmp/streamer_close.mark')
33
+
34
+ (1..(parts.length-1)).each do | part_i|
35
+ this_part = parts[part_i]
36
+ previous_part = parts[part_i -1]
37
+ received_after_previous = this_part.time_difference - previous_part.time_difference
38
+
39
+ # Ensure there was some time before this chunk arrived. This is the most important test.
40
+ expect(received_after_previous).to be_within(1).of(0.3)
41
+ end
42
+ end
43
+
44
+ it 'when the HTTP client process is killed midflight, does not read more chunks from the body object' do
45
+ # This test checks whether the server makes the iterable body complete if the client closes the connection
46
+ # prematurely. If you have the callbacks set up wrong on Thin, for instance, it will read the response
47
+ # completely and potentially buffer it in memory, filling up your RAM. We need to ensure that the server
48
+ # uses it's internal mechanics to stop reading the body once the client is dropped.
49
+ pid = fork do
50
+ TestDownload.perform("http://0.0.0.0:9393/with-content-length")
51
+ end
52
+ sleep(1)
53
+ Process.kill("KILL", pid)
54
+
55
+ written_parts_list = File.read("/tmp/streamer_messages.log").split("\n")
56
+ expect(written_parts_list.length).to be < 6
57
+
58
+ expect(File).to exist('/tmp/streamer_close.mark')
59
+ end
60
+ end
61
+
@@ -0,0 +1,31 @@
1
+ $LOAD_PATH.unshift(File.join(File.dirname(__FILE__), '..', 'lib'))
2
+ $LOAD_PATH.unshift(File.dirname(__FILE__))
3
+
4
+ require 'rspec'
5
+ require 'long_body'
6
+
7
+ # Requires supporting files with custom matchers and macros, etc,
8
+ # in ./support/ and its subdirectories.
9
+ Dir["#{File.dirname(__FILE__)}/support/**/*.rb"].each {|f| require f}
10
+
11
+ RSpec.configure do |config|
12
+ config.order = 'random'
13
+ config.before :suite do
14
+ $postrun = StringIO.new
15
+ $postrun << "\n\n"
16
+
17
+ SERVERS.each(&:start!)
18
+
19
+ sleep 0.5 until SERVERS.all?(&:running?)
20
+ end
21
+
22
+ config.before :each do
23
+ FileUtils.rm('/tmp/streamer_messages.log') if File.exist?('/tmp/streamer_messages.log')
24
+ FileUtils.rm('/tmp/streamer_close.log') if File.exist?('/tmp/streamer_close.log')
25
+ end
26
+
27
+ config.after :suite do
28
+ SERVERS.each(&:stop!)
29
+ puts $postrun.string
30
+ end
31
+ end
@@ -0,0 +1,50 @@
1
+ require File.expand_path(File.dirname(__FILE__) + '/../lib/long_body')
2
+
3
+ require 'fileutils'
4
+ # The test app
5
+ class Streamer
6
+
7
+ class TestBody
8
+ def each
9
+ File.open("/tmp/streamer_messages.log", "w") do |f|
10
+ 25.times do |i|
11
+ sleep 0.3
12
+ yield "Message number #{i}"
13
+ f.puts(i)
14
+ f.flush # Make sure it is on disk
15
+ end
16
+ end
17
+ end
18
+
19
+ def close
20
+ FileUtils.touch('/tmp/streamer_close.mark')
21
+ end
22
+ end
23
+
24
+ def self.call(env)
25
+ # The absence of Content-Length will trigger Rack::Chunking into work automatically.
26
+ [200, {'Content-Type' => 'text/plain'}, TestBody.new]
27
+ end
28
+ end
29
+
30
+ class StreamerWithLength < Streamer
31
+ def self.call(env)
32
+ s, h, b = super
33
+ [s, h.merge('Content-Length' => '415'), b]
34
+ end
35
+ end
36
+
37
+ map '/chunked' do
38
+ use LongBody
39
+ use Rack::Chunked
40
+ run Streamer
41
+ end
42
+
43
+ map '/with-content-length' do
44
+ use LongBody
45
+ run StreamerWithLength
46
+ end
47
+
48
+ map '/alive' do
49
+ run ->(env) { [200, {}, ['Yes']]}
50
+ end
@@ -0,0 +1,61 @@
1
+ TEST_RACK_APP = File.join(File.dirname(File.expand_path(__FILE__)), '..', 'streaming_app.ru')
2
+
3
+ class RunningServer < Struct.new(:name, :command, :port)
4
+ def command
5
+ super % [port, TEST_RACK_APP]
6
+ end
7
+
8
+ def start!
9
+ # Boot Puma in a forked process
10
+ full_path = File.join(File.dirname(File.expand_path(__FILE__)), 'streaming_app.ru')
11
+ @pid = fork do
12
+ puts "Spinning up with #{command.inspect}"
13
+ # Do not pollute the RSpec output with the Puma logs, save the stuff
14
+ # to the logfiles instead
15
+ $stdout.reopen(File.open('%s_output.log' % name, 'a'))
16
+ $stderr.reopen(File.open('%s_output.log' % name, 'a'))
17
+
18
+ # Since we have to do with timing tolerances, having the output drip in ASAP is useful
19
+ $stdout.sync = true
20
+ $stderr.sync = true
21
+ exec(command)
22
+ end
23
+
24
+ Thread.new do
25
+ # Wait for Puma to be online, poll the alive URL until it stops responding
26
+ loop do
27
+ sleep 0.5
28
+ begin
29
+ this_server_url = "http://0.0.0.0:%d/alive" % port
30
+ TestDownload.perform(this_server_url)
31
+ puts "#{name} is alive!"
32
+ @running = true
33
+ break
34
+ rescue Errno::ECONNREFUSED
35
+ end
36
+ end
37
+ end
38
+
39
+ trap("TERM") { stop! }
40
+ end
41
+
42
+ def running?
43
+ !!@running
44
+ end
45
+
46
+ def stop!
47
+ return unless @pid
48
+
49
+ # Tell the webserver to quit, twice (we do not care if there are running responses)
50
+ %W( TERM TERM KILL ).each {|sig| Process.kill(sig, @pid); sleep 0.5 }
51
+ @pid = nil
52
+ end
53
+ end
54
+
55
+ SERVERS = [
56
+ RunningServer.new(:puma, "bundle exec puma --port %d %s", 9393),
57
+ RunningServer.new(:thin, "bundle exec thin --port %d --rackup %s start", 9394),
58
+ RunningServer.new(:rainbows, "bundle exec rainbows --port %d %s", 9395),
59
+ RunningServer.new(:passenger, "bundle exec passenger start --port %d --rackup %s", 9396),
60
+ ]
61
+
@@ -0,0 +1,42 @@
1
+ module TestDownload
2
+ extend self
3
+ Part = Struct.new(:time_difference, :payload)
4
+ def perform(uri)
5
+ response_chunks = []
6
+ uri = URI(uri.to_s)
7
+ conn = Net::HTTP.new(uri.host, uri.port)
8
+ conn.read_timeout = 120 # Might take LONG
9
+ conn.start do |http|
10
+ req = Net::HTTP::Get.new(uri.request_uri)
11
+ before_first = Time.now
12
+ http.request(req) do |res|
13
+ res.read_body do |chunk|
14
+ diff = (Time.now - before_first).to_f
15
+ response_chunks << Part.new(diff, chunk)
16
+ end
17
+ end
18
+ end
19
+ response_chunks
20
+ end
21
+
22
+ def perform_and_abort_after_3_chunks(uri)
23
+ response_chunks = []
24
+ catch :abort do
25
+ uri = URI(uri.to_s)
26
+ conn = Net::HTTP.new(uri.host, uri.port)
27
+ conn.read_timeout = 120 # Might take LONG
28
+ conn.start do |http|
29
+ req = Net::HTTP::Get.new(uri.request_uri)
30
+ before_first = Time.now.to_i
31
+ http.request(req) do |res|
32
+ res.read_body do |chunk|
33
+ diff = Time.now.to_i - before_first
34
+ response_chunks << Part.new(diff, chunk)
35
+ throw :abort if response_chunks.length == 3
36
+ end
37
+ end
38
+ end
39
+ end
40
+ response_chunks
41
+ end
42
+ end
metadata ADDED
@@ -0,0 +1,230 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: long_body
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.0.1
5
+ platform: ruby
6
+ authors:
7
+ - Julik Tarkhanov
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2015-08-29 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: rack
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - "~>"
18
+ - !ruby/object:Gem::Version
19
+ version: '1'
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - "~>"
25
+ - !ruby/object:Gem::Version
26
+ version: '1'
27
+ - !ruby/object:Gem::Dependency
28
+ name: thin
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - "~>"
32
+ - !ruby/object:Gem::Version
33
+ version: '1.6'
34
+ type: :development
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - "~>"
39
+ - !ruby/object:Gem::Version
40
+ version: '1.6'
41
+ - !ruby/object:Gem::Dependency
42
+ name: puma
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - ">="
46
+ - !ruby/object:Gem::Version
47
+ version: 2.13.4
48
+ - - "~>"
49
+ - !ruby/object:Gem::Version
50
+ version: '2'
51
+ type: :development
52
+ prerelease: false
53
+ version_requirements: !ruby/object:Gem::Requirement
54
+ requirements:
55
+ - - ">="
56
+ - !ruby/object:Gem::Version
57
+ version: 2.13.4
58
+ - - "~>"
59
+ - !ruby/object:Gem::Version
60
+ version: '2'
61
+ - !ruby/object:Gem::Dependency
62
+ name: rspec
63
+ requirement: !ruby/object:Gem::Requirement
64
+ requirements:
65
+ - - "<"
66
+ - !ruby/object:Gem::Version
67
+ version: '3.3'
68
+ - - "~>"
69
+ - !ruby/object:Gem::Version
70
+ version: '3.2'
71
+ type: :development
72
+ prerelease: false
73
+ version_requirements: !ruby/object:Gem::Requirement
74
+ requirements:
75
+ - - "<"
76
+ - !ruby/object:Gem::Version
77
+ version: '3.3'
78
+ - - "~>"
79
+ - !ruby/object:Gem::Version
80
+ version: '3.2'
81
+ - !ruby/object:Gem::Dependency
82
+ name: rdoc
83
+ requirement: !ruby/object:Gem::Requirement
84
+ requirements:
85
+ - - "~>"
86
+ - !ruby/object:Gem::Version
87
+ version: '3.12'
88
+ type: :development
89
+ prerelease: false
90
+ version_requirements: !ruby/object:Gem::Requirement
91
+ requirements:
92
+ - - "~>"
93
+ - !ruby/object:Gem::Version
94
+ version: '3.12'
95
+ - !ruby/object:Gem::Dependency
96
+ name: bundler
97
+ requirement: !ruby/object:Gem::Requirement
98
+ requirements:
99
+ - - "~>"
100
+ - !ruby/object:Gem::Version
101
+ version: '1.0'
102
+ type: :development
103
+ prerelease: false
104
+ version_requirements: !ruby/object:Gem::Requirement
105
+ requirements:
106
+ - - "~>"
107
+ - !ruby/object:Gem::Version
108
+ version: '1.0'
109
+ - !ruby/object:Gem::Dependency
110
+ name: jeweler
111
+ requirement: !ruby/object:Gem::Requirement
112
+ requirements:
113
+ - - "~>"
114
+ - !ruby/object:Gem::Version
115
+ version: 2.0.1
116
+ type: :development
117
+ prerelease: false
118
+ version_requirements: !ruby/object:Gem::Requirement
119
+ requirements:
120
+ - - "~>"
121
+ - !ruby/object:Gem::Version
122
+ version: 2.0.1
123
+ - !ruby/object:Gem::Dependency
124
+ name: simplecov
125
+ requirement: !ruby/object:Gem::Requirement
126
+ requirements:
127
+ - - ">="
128
+ - !ruby/object:Gem::Version
129
+ version: '0'
130
+ type: :development
131
+ prerelease: false
132
+ version_requirements: !ruby/object:Gem::Requirement
133
+ requirements:
134
+ - - ">="
135
+ - !ruby/object:Gem::Version
136
+ version: '0'
137
+ - !ruby/object:Gem::Dependency
138
+ name: retriable
139
+ requirement: !ruby/object:Gem::Requirement
140
+ requirements:
141
+ - - ">="
142
+ - !ruby/object:Gem::Version
143
+ version: '0'
144
+ type: :development
145
+ prerelease: false
146
+ version_requirements: !ruby/object:Gem::Requirement
147
+ requirements:
148
+ - - ">="
149
+ - !ruby/object:Gem::Version
150
+ version: '0'
151
+ - !ruby/object:Gem::Dependency
152
+ name: passenger
153
+ requirement: !ruby/object:Gem::Requirement
154
+ requirements:
155
+ - - "~>"
156
+ - !ruby/object:Gem::Version
157
+ version: '5'
158
+ type: :development
159
+ prerelease: false
160
+ version_requirements: !ruby/object:Gem::Requirement
161
+ requirements:
162
+ - - "~>"
163
+ - !ruby/object:Gem::Version
164
+ version: '5'
165
+ - !ruby/object:Gem::Dependency
166
+ name: rainbows
167
+ requirement: !ruby/object:Gem::Requirement
168
+ requirements:
169
+ - - ">="
170
+ - !ruby/object:Gem::Version
171
+ version: '0'
172
+ type: :development
173
+ prerelease: false
174
+ version_requirements: !ruby/object:Gem::Requirement
175
+ requirements:
176
+ - - ">="
177
+ - !ruby/object:Gem::Version
178
+ version: '0'
179
+ description: Direct-to-socket streaming of Rack response bodies
180
+ email: me@julik.nl
181
+ executables: []
182
+ extensions: []
183
+ extra_rdoc_files:
184
+ - LICENSE.txt
185
+ - README.md
186
+ files:
187
+ - ".document"
188
+ - ".rspec"
189
+ - Gemfile
190
+ - LICENSE.txt
191
+ - README.md
192
+ - Rakefile
193
+ - lib/long_body.rb
194
+ - lib/long_body/deferrable_body.rb
195
+ - lib/long_body/hijack_handler.rb
196
+ - lib/long_body/lint_bypass.rb
197
+ - lib/long_body/thin_handler.rb
198
+ - lib/long_body/version.rb
199
+ - long_body.gemspec
200
+ - spec/long_body_spec.rb
201
+ - spec/shared_webserver_examples.rb
202
+ - spec/spec_helper.rb
203
+ - spec/streaming_app.ru
204
+ - spec/support/server_runners.rb
205
+ - spec/test_download.rb
206
+ homepage: http://github.com/julik/long_body
207
+ licenses:
208
+ - MIT
209
+ metadata: {}
210
+ post_install_message:
211
+ rdoc_options: []
212
+ require_paths:
213
+ - lib
214
+ required_ruby_version: !ruby/object:Gem::Requirement
215
+ requirements:
216
+ - - ">="
217
+ - !ruby/object:Gem::Version
218
+ version: '0'
219
+ required_rubygems_version: !ruby/object:Gem::Requirement
220
+ requirements:
221
+ - - ">="
222
+ - !ruby/object:Gem::Version
223
+ version: '0'
224
+ requirements: []
225
+ rubyforge_project:
226
+ rubygems_version: 2.2.2
227
+ signing_key:
228
+ specification_version: 4
229
+ summary: Direct-to-socket streaming of Rack response bodies
230
+ test_files: []