fast_send 1.1.2

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: 1df225aaf4d88c9a194879693f2469882fa1acc6
4
+ data.tar.gz: 8f5eaa3d4a6d4600f7ae01ed2bae972da10f1c3d
5
+ SHA512:
6
+ metadata.gz: 03e9b6a4439667c658de6cc7f9e377cadd50f7eed94dc4e085b2e5e8cd55f0944ac3794fdcaab1c68785408224fa6d8054ca1263cffa6d405c460c00ce65d7f2
7
+ data.tar.gz: ed25fcedadd280d9400415190b8c34aa1020f95d4caef80e9a5ead3ff3e3b662f6204ea5f18c9619f0b7b80daa963dadfcbba9b7e3bc59a7b1bcd8865e79e9fd
data/Gemfile ADDED
@@ -0,0 +1,9 @@
1
+ source 'https://rubygems.org'
2
+
3
+ group :development do
4
+ gem 'rake'
5
+ gem 'jeweler', '> 2'
6
+ gem 'rspec', '~> 3'
7
+ gem 'puma'
8
+ gem 'sendfile', platforms: :mri
9
+ end
data/LICENSE.txt ADDED
@@ -0,0 +1,20 @@
1
+ Copyright (c) 2016 WeTransfer
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,100 @@
1
+ # FastSend
2
+
3
+ Is a Rack middleware to send large, long-running Rack responses via _file buffers._
4
+ When you send a lot of data, you can saturate the Ruby GC because you have to pump
5
+ strings to the socket through the runtime. If you already have a file handle with
6
+ the contents that you want to send, you can let the operating system do the socket
7
+ writes at optimum speed, without loading your Ruby VM with all the string
8
+ cleanup. This helps to reduce GC pressure, CPU use and memory use.
9
+
10
+ ## Usage
11
+
12
+ FastSend is a Rack middleware. Insert it before your application.
13
+
14
+ use FastSend
15
+
16
+ In normal circumstances FastSend will just do nothing. You have to explicitly trigger it
17
+ by returning a special response body object. The convention is that this object must respond
18
+ to `each_file` instead of the standard Rack `each`. Note that you _must_ yield real Ruby `File`
19
+ objects or subclasses, because some fairly low-level operations will be done to them - so a duck-typed
20
+ "file-like" object is not good enough.
21
+
22
+ class BigResponse
23
+ def each_file
24
+ File.open('/large_file.bin', 'rb'){|fh| yield(fh) }
25
+ end
26
+ end
27
+
28
+ # and in your application
29
+ [200, {'Content-Length' => big_size}, BigResponse.new]
30
+
31
+ The response object must yield File objects from that method. It is possible to yield an unlimited
32
+ number of files, they will all be sent to the socket in succession. The `yield` will block
33
+ for as long as the file is not sent in full.
34
+
35
+ ## Bandwidth metering and callbacks
36
+
37
+ Because FastSend uses Rack hijacking, it takes the usual Rack handling out of the response writing.
38
+ So you can effectively do two things if you want to have wrapping actions performed at the start of
39
+ the download, or at the end of the download, or at an abort:
40
+
41
+ * Make use of the custom fast_send headers. They will be removed by the middleware
42
+ * Add the method calls to your `each_file` method, using `ensure` and `rescue`
43
+
44
+ For example, to receive a callback every time some bytes get sent to the client
45
+
46
+ bytes_sent_proc = ->(sent,written_so_far_entire_response) {
47
+ bandwith_metering.increment(sent)
48
+ }
49
+
50
+ [200, {'fast_send.bytes_sent' => bytes_sent_proc}, large_body]
51
+
52
+ There are also more callbacks you can use, read the class documentation for more information on them.
53
+ For example, you can subscribe to a callback when the client suddenly disconnects - you will get an idea
54
+ of how much data the client could read/buffer so far before the connection went down.
55
+
56
+ ## Implementation details
57
+
58
+ Fundamentally, FastSend takes your Ruby File handles, one by one (you can `yield` multiple times from `each_file`)
59
+ and uses the fastest way possible, as available in your Ruby runtime, to send the file to the Rack webserver socket.
60
+ The options it tries are:
61
+
62
+ * non-blocking `sendfile(2)` call - if you have the "sendfile" gem, only on MRI/Rubinius, only on Linux
63
+ * blocking `sendfile(2)` call - if you have the "sendfile" gem, only on MRI/Rubinius, also works on OSX
64
+ * Java's NIO transferTo() call - if you are on jRuby
65
+ * IO.copy_stream() for all other cases
66
+
67
+ For the "sendfile" gem to work you need to add it to your application and `require` it before FastSend
68
+ has to dispatch a request (you do not have to `require` these two in a particular order).
69
+
70
+ ## Webserver compatibility
71
+
72
+ Your webserver (Rack adapter) _must_ support partial Rack hijacking. We use FastSend on Puma pretty much
73
+ exclusively, and it works well. Note that WebBrick only supports partial hijacking using a self-pipe, which
74
+ is not compatible with the socket operations in FastSend. Just like we require _real_ File objects for the
75
+ input, we _require_ a real Rack socket (raw TCP) for the output. Sorry 'bout that.
76
+
77
+ If those preconditions are not met, FastSend will revert to a standard Rack body that just reads your
78
+ yielded file into the Ruby runtime and yields it's parts to the caller. It does inflate memory and is
79
+ slow, but it helps sometimes
80
+
81
+ ## Without Rack hijacking support or when using rack-test
82
+
83
+ If you need to test FastSend as part of your application, your custom `each_file`-supporting Body object
84
+ will be wrapped with a `FastSend::NaiveEach` in your `rack-test` test case. This way the response will
85
+ be read into the Rack client buffer, and will use the standard string-pumping that is used for long Rack
86
+ responses. All the callbacks you define for FastSend will work.
87
+
88
+ ## Contributing to fast_send
89
+
90
+ * Check out the latest master to make sure the feature hasn't been implemented or the bug hasn't been fixed yet.
91
+ * Check out the issue tracker to make sure someone already hasn't requested it and/or contributed it.
92
+ * Fork the project.
93
+ * Start a feature/bugfix branch.
94
+ * Commit and push until you are happy with your contribution.
95
+ * Make sure to add tests for it. This is important so I don't break it in a future version unintentionally.
96
+ * 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.
97
+
98
+ ## Copyright
99
+
100
+ Copyright (c) 2016 WeTransfer. See LICENSE.txt for further details.
data/Rakefile ADDED
@@ -0,0 +1,38 @@
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/fast_send'
16
+ Jeweler::Tasks.new do |gem|
17
+ gem.version = FastSend::VERSION
18
+ gem.name = "fast_send"
19
+ gem.homepage = "https://github.com/WeTransfer/fast_send"
20
+ gem.license = "MIT"
21
+ gem.description = %Q{Send bursts of large files quickly via Rack}
22
+ gem.summary = %Q{and do so bypassing the Ruby VM}
23
+ gem.email = "me@julik.nl"
24
+ gem.authors = ["Julik Tarkhanov"]
25
+ # dependencies defined in Gemfile
26
+ end
27
+
28
+ Jeweler::RubygemsDotOrgTasks.new
29
+
30
+ require 'rspec/core'
31
+ require 'rspec/core/rake_task'
32
+ RSpec::Core::RakeTask.new(:spec) do |spec|
33
+ spec.rspec_opts = ["-c"]
34
+ spec.pattern = FileList['spec/**/*_spec.rb']
35
+ end
36
+
37
+ task :default => :spec
38
+
data/config.ru ADDED
@@ -0,0 +1,17 @@
1
+ require './lib/fast_send'
2
+
3
+ THE_F = '/Users/julik/Downloads/3gb.bin'
4
+ SEND_TIMES = 10
5
+
6
+ class TheBody
7
+ def each_file(&b)
8
+ SEND_TIMES.times { File.open(THE_F, 'rb', &b) }
9
+ end
10
+ end
11
+
12
+ app = ->(env) {
13
+ size = File.size(THE_F) * SEND_TIMES
14
+ [200, {'Content-Length' => size.to_s}, TheBody.new]
15
+ }
16
+
17
+ run FastSend.new(app)
data/fast_send.gemspec ADDED
@@ -0,0 +1,64 @@
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: fast_send 1.1.2 ruby lib
6
+
7
+ Gem::Specification.new do |s|
8
+ s.name = "fast_send"
9
+ s.version = "1.1.2"
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 = "2016-11-15"
15
+ s.description = "Send bursts of large files quickly via Rack"
16
+ s.email = "me@julik.nl"
17
+ s.extra_rdoc_files = [
18
+ "LICENSE.txt",
19
+ "README.md"
20
+ ]
21
+ s.files = [
22
+ "Gemfile",
23
+ "LICENSE.txt",
24
+ "README.md",
25
+ "Rakefile",
26
+ "config.ru",
27
+ "fast_send.gemspec",
28
+ "lib/fast_send.rb",
29
+ "lib/fast_send/null_logger.rb",
30
+ "lib/fast_send/socket_handler.rb",
31
+ "spec/fast_send_with_mocks_spec.rb",
32
+ "spec/fast_send_with_puma_spec.rb",
33
+ "spec/test_app.ru"
34
+ ]
35
+ s.homepage = "https://github.com/WeTransfer/fast_send"
36
+ s.licenses = ["MIT"]
37
+ s.rubygems_version = "2.4.5.1"
38
+ s.summary = "and do so bypassing the Ruby VM"
39
+
40
+ if s.respond_to? :specification_version then
41
+ s.specification_version = 4
42
+
43
+ if Gem::Version.new(Gem::VERSION) >= Gem::Version.new('1.2.0') then
44
+ s.add_development_dependency(%q<rake>, [">= 0"])
45
+ s.add_development_dependency(%q<jeweler>, ["> 2"])
46
+ s.add_development_dependency(%q<rspec>, ["~> 3"])
47
+ s.add_development_dependency(%q<puma>, [">= 0"])
48
+ s.add_development_dependency(%q<sendfile>, [">= 0"])
49
+ else
50
+ s.add_dependency(%q<rake>, [">= 0"])
51
+ s.add_dependency(%q<jeweler>, ["> 2"])
52
+ s.add_dependency(%q<rspec>, ["~> 3"])
53
+ s.add_dependency(%q<puma>, [">= 0"])
54
+ s.add_dependency(%q<sendfile>, [">= 0"])
55
+ end
56
+ else
57
+ s.add_dependency(%q<rake>, [">= 0"])
58
+ s.add_dependency(%q<jeweler>, ["> 2"])
59
+ s.add_dependency(%q<rspec>, ["~> 3"])
60
+ s.add_dependency(%q<puma>, [">= 0"])
61
+ s.add_dependency(%q<sendfile>, [">= 0"])
62
+ end
63
+ end
64
+
@@ -0,0 +1,8 @@
1
+ # Will be used as the logger if Rack passes us no logger at all
2
+ module FastSend::NullLogger
3
+ [:debug, :info, :warn, :fatal, :error].each do |m|
4
+ define_method(m) do
5
+ end
6
+ end
7
+ extend self
8
+ end
@@ -0,0 +1,211 @@
1
+ # Handles the TCP socket within the Rack hijack. Is used instead of a Proc object for better
2
+ # testability and better deallocation
3
+ class FastSend::SocketHandler < Struct.new(:stream, :logger, :started_proc, :aborted_proc, :error_proc,
4
+ :done_proc, :written_proc, :cleanup_proc)
5
+
6
+ # How many seconds we will wait before considering a client dead.
7
+ SOCKET_TIMEOUT = 60
8
+
9
+ # The time between select() calls when a socket is blocking on write
10
+ SELECT_TIMEOUT_ON_BLOCK = 5
11
+
12
+ # Is raised when it is not possible to send a chunk of data
13
+ # to the client using non-blocking sends for longer than the preset timeout
14
+ SlowLoris = Class.new(StandardError)
15
+
16
+ # Whether we are forced to use blocking IO for sendfile()
17
+ USE_BLOCKING_SENDFILE = !!(RUBY_PLATFORM =~ /darwin/)
18
+
19
+ # The amount of bytes we will try to fit in a single sendfile()/copy_stream() call
20
+ # We need to send it chunks because otherwise we have no way to have throughput
21
+ # stats that we need for load-balancing. Also, the sendfile() call is limited to the size
22
+ # of off_t, which is platform-specific. In general, it helps to stay small on this for
23
+ # more control.C
24
+ SENDFILE_CHUNK_SIZE = 2*1024*1024
25
+
26
+ def call(socket)
27
+ return if socket.closed?
28
+
29
+ writer_method_name = if socket.respond_to?(:sendfile)
30
+ :sendfile
31
+ elsif RUBY_PLATFORM == 'java'
32
+ :copy_nio
33
+ else
34
+ :copy_stream
35
+ end
36
+
37
+ logger.debug { "Will do file-to-socket using %s" % writer_method_name }
38
+
39
+ begin
40
+ logger.debug { "Starting the response" }
41
+
42
+ bytes_written = 0
43
+
44
+ started_proc.call(bytes_written)
45
+
46
+ stream.each_file do | file |
47
+ logger.debug { "Sending %s" % file.inspect }
48
+ # Run the sending method, depending on the implementation
49
+ send(writer_method_name, socket, file) do |n_bytes_sent|
50
+ bytes_written += n_bytes_sent
51
+ logger.debug { "Written %d bytes" % bytes_written }
52
+ written_proc.call(n_bytes_sent, bytes_written)
53
+ end
54
+ end
55
+
56
+ logger.info { "Response written in full - %d bytes" % bytes_written }
57
+ done_proc.call(bytes_written)
58
+ rescue *client_disconnect_exeptions => e
59
+ logger.warn { "Client closed connection: #{e.class}(#{e.message})" }
60
+ aborted_proc.call(e)
61
+ rescue Exception => e
62
+ logger.fatal { "Aborting response due to error: #{e.class}(#{e.message})" }
63
+ aborted_proc.call(e)
64
+ error_proc.call(e)
65
+ ensure
66
+ # With rack.hijack the consensus seems to be that the hijack
67
+ # proc is responsible for closing the socket. We also use no-keepalive
68
+ # so this should not pose any problems.
69
+ socket.close unless socket.closed?
70
+ logger.debug { "Performing cleanup" }
71
+ cleanup_proc.call(bytes_written)
72
+ end
73
+ end
74
+
75
+ # Returns an array of Exception classes we can rescue from (using a splat)
76
+ #
77
+ # @return [Array<Class>] the classes
78
+ def client_disconnect_exeptions
79
+ [SlowLoris] + ::FastSend::CLIENT_DISCONNECTS
80
+ end
81
+
82
+
83
+ # This is majorly useful - if the socket is not selectable after a certain
84
+ # timeout, it might be a slow loris or a connection that hung up on us. So if
85
+ # the return from select() is nil, we know that we still cannot write into
86
+ # the socket for some reason. Kill the request, it is dead, jim.
87
+ #
88
+ # Note that this will not work on OSX due to a sendfile() bug.
89
+ def fire_timeout_using_select(writable_socket)
90
+ at = Time.now
91
+ loop do
92
+ _, writeables, errored = IO.select(nil, [writable_socket], [writable_socket], SELECT_TIMEOUT_ON_BLOCK)
93
+ if writeables && writeables.include?(writable_socket)
94
+ return # We can proceed
95
+ end
96
+ if errored && errored.include?(writable_socket)
97
+ raise SlowLoris, "Receiving socket had an error, connection will be dropped"
98
+ end
99
+ if (Time.now - at) > SOCKET_TIMEOUT
100
+ raise SlowLoris, "Receiving socket timed out on sendfile(), probably a dead slow loris"
101
+ end
102
+ end
103
+ end
104
+
105
+ # Copies the file to the socket using sendfile().
106
+ # If we are not running on Darwin we are going to use a non-blocking version of
107
+ # sendfile(), and send the socket into a select() wait loop. If no data can be written
108
+ # after 3 minutes the request will be terminated.
109
+ # On Darwin a blocking sendfile() call will be used instead.
110
+ #
111
+ # @param socket[Socket] the socket to write to
112
+ # @param file[File] the IO you can read from
113
+ # @yields num_bytes_written[Fixnum] the number of bytes written on each `IO.copy_stream() call`
114
+ # @return [void]
115
+ def sendfile(socket, file)
116
+ chunk = SENDFILE_CHUNK_SIZE
117
+ remaining = file.size
118
+
119
+ loop do
120
+ break if remaining < 1
121
+
122
+ # Use exact offsets to avoid boobytraps
123
+ send_this_time = remaining < chunk ? remaining : chunk
124
+ read_at_offset = file.size - remaining
125
+
126
+ # We have to use blocking "sendfile" on Darwin because the non-blocking version
127
+ # is buggy
128
+ # (in an end-to-end test the number of bytes received varies).
129
+ written = if USE_BLOCKING_SENDFILE
130
+ socket.sendfile(file, read_at_offset, send_this_time)
131
+ else
132
+ socket.trysendfile(file, read_at_offset, send_this_time)
133
+ end
134
+
135
+ # Will be only triggered when using non-blocking "trysendfile", i.e. on Linux.
136
+ if written == :wait_writable
137
+ fire_timeout_using_select(socket) # Used to evict slow lorises
138
+ elsif written.nil? # Also only relevant for "trysendfile"
139
+ return # We are done, nil == EOF
140
+ else
141
+ remaining -= written
142
+ yield(written)
143
+ end
144
+ end
145
+ end
146
+
147
+ # Copies the file to the socket using `IO.copy_stream`.
148
+ # This allows the strings flowing from file to the socket to bypass
149
+ # the Ruby VM and be managed within the calls without allocations.
150
+ # This method gets used when Socket#sendfile is not available on the
151
+ # system we run on (for instance, on Jruby).
152
+ #
153
+ # @param socket[Socket] the socket to write to
154
+ # @param file[File] the IO you can read from
155
+ # @yields num_bytes_written[Fixnum] the number of bytes written on each `IO.copy_stream() call`
156
+ # @return [void]
157
+ def copy_stream(socket, file)
158
+ chunk = SENDFILE_CHUNK_SIZE
159
+ remaining = file.size
160
+
161
+ loop do
162
+ break if remaining < 1
163
+
164
+ # Use exact offsets to avoid boobytraps
165
+ send_this_time = remaining < chunk ? remaining : chunk
166
+ num_bytes_written = IO.copy_stream(file, socket, send_this_time)
167
+
168
+ if num_bytes_written.nonzero?
169
+ remaining -= num_bytes_written
170
+ yield(num_bytes_written)
171
+ end
172
+ end
173
+ end
174
+
175
+ # The closest you can get to sendfile with Java's NIO
176
+ # http://www.ibm.com/developerworks/library/j-zerocopy
177
+ #
178
+ # @param socket[Socket] the socket to write to
179
+ # @param file[File] the IO you can read from
180
+ # @yields num_bytes_written[Fixnum] the number of bytes written on each `IO.copy_stream() call`
181
+ # @return [void]
182
+ def copy_nio(socket, file)
183
+ chunk = SENDFILE_CHUNK_SIZE
184
+ remaining = file.size
185
+
186
+ # We need a Java stream for this, and we cannot really initialize
187
+ # it from a jRuby File in a convenient way. Since we need it briefly
188
+ # and we know that the file is on the filesystem at the given path,
189
+ # we can just open it using the Java means, and go from there
190
+ input_stream = java.io.FileInputStream.new(file.path)
191
+ input_channel = input_stream.getChannel
192
+ output_channel = socket.to_channel
193
+
194
+ loop do
195
+ break if remaining < 1
196
+
197
+ # Use exact offsets to avoid boobytraps
198
+ send_this_time = remaining < chunk ? remaining : chunk
199
+ read_at = file.size - remaining
200
+ num_bytes_written = input_channel.transferTo(read_at, send_this_time, output_channel)
201
+
202
+ if num_bytes_written.nonzero?
203
+ remaining -= num_bytes_written
204
+ yield(num_bytes_written)
205
+ end
206
+ end
207
+ ensure
208
+ input_channel.close
209
+ input_stream.close
210
+ end
211
+ end
data/lib/fast_send.rb ADDED
@@ -0,0 +1,158 @@
1
+ # A Rack middleware that sends the response using file buffers. If the response body
2
+ # returned by the upstream application supports "each_file", then the middleware will
3
+ # call this method, grab each yielded file in succession and use the fastest possible
4
+ # way to send it to the client (using a response wrapper or Rack hijacking). If
5
+ # sendfile support is available on the client socket, sendfile() will be used to stream
6
+ # the file via the OS.
7
+ #
8
+ # A sample response body object will look like this:
9
+ #
10
+ # class Files
11
+ # def each_file
12
+ # File.open('data1.bin','r') {|f| yield(f) }
13
+ # File.open('data2.bin','r') {|f| yield(f) }
14
+ # end
15
+ # end
16
+ #
17
+ # # and then in your Rack app
18
+ # return [200, {'Content-Type' => 'binary/octet-stream'}, Files.new]
19
+ #
20
+ # Note that the receiver of `each_file` is responsbble for closing and deallocating
21
+ # the file if necessary.
22
+ #
23
+ #
24
+ # You can also supply the following response headers that will be used as callbacks
25
+ # during the response send on the way out.
26
+ #
27
+ # `fast_send.started' => ->(zero_bytes) { } # When the response is started
28
+ # `fast_send.bytes_sent' => ->(sent_this_time, sent_total) { } # Called on each sent chunk
29
+ # `fast_send.complete' => ->(sent_total) { } # When response completes without exceptions
30
+ # `fast_send.aborted' => ->(exception) { } # When the response is not sent completely, both for exceptions and client closes
31
+ # `fast_send.error' => ->(exception) { } # the response is not sent completely due to an error in the application
32
+ # `fast_send.cleanup' => ->(sent_total) { } # Called at the end of the response, in an ensure block
33
+ class FastSend
34
+ require_relative 'fast_send/socket_handler'
35
+ require_relative 'fast_send/null_logger'
36
+
37
+ VERSION = '1.1.2'
38
+
39
+ # All exceptions that get raised when the client closes a connection before receiving the entire response
40
+ CLIENT_DISCONNECTS = [Errno::EPIPE, Errno::ECONNRESET, Errno::ENOTCONN, Errno::EPROTOTYPE]
41
+
42
+ if RUBY_PLATFORM =~ /java/
43
+ require 'java'
44
+ CLIENT_DISCONNECTS << Java::JavaIo::IOException
45
+ end
46
+
47
+
48
+ # Gets raised if a fast_send.something is mentioned in
49
+ # the response headers but is not supported as a callback
50
+ # (the dangers of hashmaps as datastructures is that you
51
+ # can sometimes mistype keys)
52
+ UnknownCallback = Class.new(StandardError)
53
+
54
+ # Gets used as a response body wrapper if the server does not support Rack hijacking.
55
+ # The wrapper will be automatically applied by FastSend and will also ensure that all the
56
+ # callbacks get executed.
57
+ class NaiveEach < Struct.new(:body_with_each_file, :started, :aborted, :error, :complete, :sent, :cleanup)
58
+ def each
59
+ written = 0
60
+ started.call(0)
61
+ body_with_each_file.each_file do | file |
62
+ while data = file.read(64 * 1024)
63
+ written += data.bytesize
64
+ yield(data)
65
+ sent.call(data.bytesize, written)
66
+ end
67
+ end
68
+ complete.call(written)
69
+ rescue *CLIENT_DISCONNECTS => e
70
+ aborted.call(e)
71
+ rescue Exception => e
72
+ aborted.call(e)
73
+ error.call(e)
74
+ raise e
75
+ ensure
76
+ cleanup.call(written)
77
+ end
78
+ end
79
+
80
+ NOOP = ->(*){}.freeze
81
+ C_Connection = 'Connection'.freeze
82
+ C_close = 'close'.freeze
83
+ C_rack_hijack = 'rack.hijack'.freeze
84
+ C_dispatch = 'X-Fast-Send-Dispatch'.freeze
85
+ C_hijack = 'hijack'.freeze
86
+ C_naive = 'each'.freeze
87
+ C_rack_logger = 'rack.logger'.freeze
88
+ C_SERVER_SOFTWARE = 'SERVER_SOFTWARE'.freeze
89
+
90
+ private_constant :NullLogger
91
+ private_constant :C_Connection, :C_close, :C_rack_hijack, :C_dispatch, :C_hijack, :C_naive, :NOOP,
92
+ :C_rack_logger, :C_SERVER_SOFTWARE
93
+
94
+ CALLBACK_HEADER_NAMES = %w(
95
+ fast_send.started
96
+ fast_send.aborted
97
+ fast_send.error
98
+ fast_send.complete
99
+ fast_send.bytes_sent
100
+ fast_send.cleanup
101
+ ).freeze
102
+
103
+ def initialize(with_rack_app)
104
+ @app = with_rack_app
105
+ end
106
+
107
+ def call(env)
108
+ s, h, b = @app.call(env)
109
+ return [s, h, b] unless b.respond_to?(:each_file)
110
+
111
+ @logger = env.fetch(C_rack_logger) { NullLogger }
112
+
113
+ server = env[C_SERVER_SOFTWARE]
114
+
115
+ if has_robust_hijack_support?(env)
116
+ @logger.debug { 'Server (%s) allows partial hijack, setting up Connection: close' % server }
117
+ h[C_Connection] = C_close
118
+ h[C_dispatch] = C_hijack
119
+ response_via_hijack(s, h, b)
120
+ else
121
+ @logger.warn {
122
+ msg = 'Server (%s) has no hijack support or hijacking is broken. Unwanted buffering possible.'
123
+ msg % server
124
+ }
125
+ h[C_dispatch] = C_naive
126
+ response_via_naive_each(s, h, b)
127
+ end
128
+ end
129
+
130
+ private
131
+
132
+ def has_robust_hijack_support?(env)
133
+ return false unless env['rack.hijack?']
134
+ return false if env['SERVER_SOFTWARE'] =~ /^WEBrick/ # WEBrick implements hijack using a pipe
135
+ true
136
+ end
137
+
138
+ def response_via_naive_each(s, h, b)
139
+ body = NaiveEach.new(b, *callbacks_from_headers(h))
140
+ [s, h, body]
141
+ end
142
+
143
+ def callbacks_from_headers(h)
144
+ headers_related = h.keys.grep(/^fast\_send\./i)
145
+ headers_related.each do | header_name |
146
+ unless CALLBACK_HEADER_NAMES.include?(header_name)
147
+ msg = "Unknown callback #{header_name.inspect} (supported: #{CALLBACK_HEADER_NAMES.join(', ')})"
148
+ raise UnknownCallback, msg
149
+ end
150
+ end
151
+ CALLBACK_HEADER_NAMES.map{|cb_name| h.delete(cb_name) || NOOP }
152
+ end
153
+
154
+ def response_via_hijack(status, headers, each_file_body)
155
+ headers[C_rack_hijack] = SocketHandler.new(each_file_body, @logger, *callbacks_from_headers(headers))
156
+ [status, headers, []]
157
+ end
158
+ end
@@ -0,0 +1,451 @@
1
+ require_relative '../lib/fast_send'
2
+ require 'logger'
3
+ describe 'FastSend when used with a mock Socket' do
4
+ let(:logger) {
5
+ Logger.new(nil).tap{|l| l.level = Logger::DEBUG }
6
+ }
7
+ let(:described_class) { FastSend }
8
+
9
+ class FakeSocket
10
+ def initialize(with_output)
11
+ @out = with_output
12
+ end
13
+
14
+ def closed?
15
+ !!@closed
16
+ end
17
+
18
+ def close
19
+ @closed = true
20
+ end
21
+
22
+ def write(data)
23
+ raise 'closed' if @closed
24
+ @out << data
25
+ data.bytesize
26
+ end
27
+ end
28
+
29
+ class FakeSocketWithSendfile < FakeSocket
30
+ def sendfile(file, read_at_offset, send_this_time)
31
+ raise 'closed' if @closed
32
+ file.pos = read_at_offset
33
+ @out << file.read(send_this_time)
34
+ send_this_time
35
+ end
36
+
37
+ def trysendfile(file, read_at_offset, send_this_time)
38
+ raise 'closed' if @closed
39
+ file.pos = read_at_offset
40
+ @out << file.read(send_this_time)
41
+ send_this_time
42
+ end
43
+
44
+ undef :write
45
+ end
46
+
47
+ class EachFileResponse
48
+ def each_file
49
+ f1 = Tempfile.new('x').tap{|f| 64.times{ f << Random.new.bytes(1024 * 1024)}; f.flush; f.rewind }
50
+ f2 = Tempfile.new('x').tap{|f| 54.times{ f << Random.new.bytes(1024 * 1024)}; f.flush; f.rewind }
51
+ yield f1
52
+ yield f2
53
+ end
54
+ end
55
+
56
+ class FailingResponse
57
+ def initialize(err_class = RuntimeError)
58
+ @err = err_class
59
+ end
60
+
61
+ def each_file
62
+ raise @err.new("This should not happen")
63
+ end
64
+ end
65
+
66
+ it 'returns the upstream response with a response that does not support each_file' do
67
+
68
+ app = ->(env) { [200, {}, ["Hello"]] }
69
+
70
+ handler = described_class.new(app)
71
+ res = handler.call({})
72
+ expect(res[0]).to eq(200)
73
+ expect(res[2]).to eq(["Hello"])
74
+ end
75
+
76
+ context 'within a server that has no hijack support (using NaiveEach)' do
77
+ it 'sets the X-Fast-Send-Dispatch header to "each"' do
78
+ source_size = (64 + 54) * 1024 * 1024
79
+ app = ->(env) { [200, {}, EachFileResponse.new] }
80
+ handler = described_class.new(app)
81
+
82
+ s, h, b = handler.call({})
83
+ expect(h['X-Fast-Send-Dispatch']).to eq('each')
84
+ end
85
+
86
+ it 'returns a naive each wrapper' do
87
+ source_size = (64 + 54) * 1024 * 1024
88
+ app = ->(env) { [200, {}, EachFileResponse.new] }
89
+ handler = described_class.new(app)
90
+
91
+ s, h, b = handler.call({})
92
+ expect(s).to eq(200)
93
+
94
+ tf = Tempfile.new('out')
95
+ b.each{|data| tf << data }
96
+ expect(tf.size).to eq((64 + 54) * 1024 * 1024)
97
+ end
98
+
99
+ it 'executes the aborted callback, the error callback and the cleanup callback if the response raises during reads' do
100
+ source_size = (64 + 54) * 1024 * 1024
101
+ aborted_cb = ->(e) { expect(e).to be_kind_of(StandardError) }
102
+ error_cb = ->(e) { expect(e).to be_kind_of(StandardError) }
103
+ cleanup_cb = ->(sent) { expect(sent).to be_zero }
104
+
105
+ app = ->(env) {
106
+ [200, {'fast_send.aborted' => aborted_cb, 'fast_send.error' => error_cb, 'fast_send.cleanup' => cleanup_cb},
107
+ FailingResponse.new]
108
+ }
109
+
110
+ handler = described_class.new(app)
111
+
112
+ s, h, b = handler.call({})
113
+ expect(s).to eq(200)
114
+ expect(h.keys.grep(/fast\_send/)).to be_empty
115
+
116
+ expect(error_cb).to receive(:call)
117
+ expect(aborted_cb).to receive(:call)
118
+ expect(cleanup_cb).to receive(:call)
119
+
120
+ tf = Tempfile.new('out')
121
+ expect {
122
+ b.each{|data| tf << data }
123
+ }.to raise_error(RuntimeError)
124
+ end
125
+
126
+ it 'executes the aborted callback and the cleanup callback, but not the error callback on an EPIPE' do
127
+ source_size = (64 + 54) * 1024 * 1024
128
+ aborted_cb = ->(e) { expect(e).to be_kind_of(StandardError) }
129
+ error_cb = ->(e) { raise "Should never be called" }
130
+ cleanup_cb = ->(sent) { expect(sent).to be_zero }
131
+
132
+ app = ->(env) { [200, {'fast_send.aborted' => aborted_cb, 'fast_send.error' => error_cb, 'fast_send.cleanup' => cleanup_cb},
133
+ FailingResponse.new(Errno::EPIPE)] }
134
+
135
+ handler = described_class.new(app)
136
+
137
+ s, h, b = handler.call({})
138
+ expect(s).to eq(200)
139
+ expect(h.keys.grep(/fast\_send/)).to be_empty
140
+
141
+ expect(error_cb).not_to receive(:call)
142
+ expect(aborted_cb).to receive(:call)
143
+ expect(cleanup_cb).to receive(:call)
144
+
145
+ tf = Tempfile.new('out')
146
+ b.each{|data| tf << data } # Raises an exception but it gets suppressed
147
+ end
148
+
149
+ it 'executes the bytes sent callback on each send of 64 kilobytes' do
150
+ source_size = (64 + 54) * 1024 * 1024
151
+
152
+ callbacks = []
153
+ sent_cb = ->(written, total_so_far) { callbacks << [written, total_so_far] }
154
+
155
+ app = ->(env) { [200, {'fast_send.bytes_sent' => sent_cb},
156
+ EachFileResponse.new] }
157
+
158
+ handler = described_class.new(app)
159
+
160
+ s, h, b = handler.call({})
161
+
162
+ expect(s).to eq(200)
163
+ expect(h.keys.grep(/fast\_send/)).to be_empty
164
+
165
+ b.each{|data| }
166
+
167
+ expect(callbacks).not_to be_empty
168
+ expect(callbacks.length).to eq(1888)
169
+ end
170
+ end
171
+
172
+ it 'raises about an unknown callback in the response headers if it finds one' do
173
+ app = ->(env) { [200, {'fast_send.mistyped_header'=> Proc.new{} }, EachFileResponse.new] }
174
+ handler = described_class.new(app)
175
+
176
+ expect {
177
+ handler.call({'rack.hijack?' => true, 'rack.logger' => logger})
178
+ }.to raise_error(/Unknown callback \"fast_send\.mistyped\_header\"/)
179
+ end
180
+
181
+ it 'sets the X-Fast-Send-Dispatch header to "hijack"' do
182
+ source_size = (64 + 54) * 1024 * 1024
183
+ app = ->(env) { [200, {}, EachFileResponse.new] }
184
+
185
+ handler = described_class.new(app)
186
+
187
+ status, headers, body = handler.call({'rack.hijack?' => true, 'rack.logger' => logger})
188
+ expect(headers['X-Fast-Send-Dispatch']).to eq('hijack')
189
+ end
190
+
191
+ it 'sends the files to the socket using sendfile()' do
192
+ source_size = (64 + 54) * 1024 * 1024
193
+ app = ->(env) { [200, {}, EachFileResponse.new] }
194
+
195
+ handler = described_class.new(app)
196
+
197
+ status, headers, body = handler.call({'rack.hijack?' => true, 'rack.logger' => logger})
198
+ expect(status).to eq(200)
199
+ expect(body).to eq([])
200
+
201
+ output = Tempfile.new('response_body')
202
+
203
+ fake_socket = FakeSocketWithSendfile.new(output)
204
+ if described_class::SocketHandler::USE_BLOCKING_SENDFILE
205
+ expect(fake_socket).to receive(:sendfile).at_least(:once).and_call_original
206
+ else
207
+ expect(fake_socket).to receive(:trysendfile).at_least(:once).and_call_original
208
+ end
209
+
210
+ # The socket MUST get closed at the end of hijack
211
+ expect(fake_socket).to receive(:close).and_call_original
212
+
213
+ hijack = headers.fetch('rack.hijack')
214
+ hijack.call(fake_socket)
215
+
216
+ expect(output.size).to eq(source_size)
217
+ end
218
+
219
+ it 'sends the files to the socket using write()' do
220
+ source_size = (64 + 54) * 1024 * 1024
221
+ app = ->(env) { [200, {}, EachFileResponse.new] }
222
+
223
+ handler = described_class.new(app)
224
+
225
+ status, headers, body = handler.call({'rack.hijack?' => true, 'rack.logger' => logger})
226
+ expect(status).to eq(200)
227
+ expect(body).to eq([])
228
+
229
+ output = Tempfile.new('response_body')
230
+
231
+ fake_socket = FakeSocket.new(output)
232
+ # The socket MUST get closed at the end of hijack
233
+ expect(fake_socket).to receive(:close).and_call_original
234
+
235
+ hijack = headers.fetch('rack.hijack')
236
+ hijack.call(fake_socket)
237
+
238
+ expect(output.size).to eq(source_size)
239
+ end
240
+
241
+
242
+ it 'can execute the hijack proc twice without resending the data' do
243
+ source_size = (64 + 54) * 1024 * 1024
244
+ app = ->(env) { [200, {}, EachFileResponse.new] }
245
+
246
+ handler = described_class.new(app)
247
+
248
+ status, headers, body = handler.call({'rack.hijack?' => true, 'rack.logger' => logger})
249
+ expect(status).to eq(200)
250
+ expect(body).to eq([])
251
+
252
+ output = Tempfile.new('response_body')
253
+
254
+ fake_socket = FakeSocket.new(output)
255
+ # The socket MUST get closed at the end of hijack
256
+ expect(fake_socket).to receive(:close).and_call_original
257
+
258
+ hijack = headers.fetch('rack.hijack')
259
+
260
+ hijack.call(fake_socket)
261
+ hijack.call(fake_socket)
262
+ end
263
+
264
+ it 'sets up the hijack proc and sends the file to the socket using write()' do
265
+ source_size = (64 + 54) * 1024 * 1024
266
+ app = ->(env) { [200, {}, EachFileResponse.new] }
267
+
268
+ handler = described_class.new(app)
269
+
270
+ status, headers, body = handler.call({'rack.hijack?' => true, 'rack.logger' => logger})
271
+ expect(status).to eq(200)
272
+ expect(body).to eq([])
273
+
274
+ output = Tempfile.new('response_body')
275
+
276
+ fake_socket = FakeSocketWithSendfile.new(output)
277
+ # The socket MUST get closed at the end of hijack
278
+ expect(fake_socket).to receive(:close).and_call_original
279
+
280
+ hijack = headers.fetch('rack.hijack')
281
+ hijack.call(fake_socket)
282
+
283
+ expect(output.size).to eq(source_size)
284
+ end
285
+
286
+ it 'calls all the supplied callback procs set in the headers' do
287
+ source_size = (64 + 54) * 1024 * 1024
288
+ callbacks = []
289
+
290
+ app = ->(env) {
291
+ [200, {
292
+ 'fast_send.started' => ->(b){ callbacks << [:started, b] },
293
+ 'fast_send.complete' => ->(b){ callbacks << [:complete, b] },
294
+ 'fast_send.bytes_sent' => ->(now, total) { callbacks << [:bytes_sent, now, total] },
295
+ 'fast_send.cleanup' => ->(b){ callbacks << [:cleanup, b] },
296
+ }, EachFileResponse.new]
297
+ }
298
+
299
+ handler = described_class.new(app)
300
+ status, headers, body = handler.call({'rack.hijack?' => true})
301
+
302
+ keys = headers.keys
303
+ expect(keys.grep(/fast\_send/)).to be_empty # The callback headers should be removed
304
+
305
+ expect(status).to eq(200)
306
+ expect(body).to eq([])
307
+
308
+ fake_socket = double('Socket')
309
+
310
+ allow(fake_socket).to receive(:respond_to?).with(:sendfile) { false }
311
+ allow(fake_socket).to receive(:respond_to?).with(:to_path) { false } # called by IO.copy_stream
312
+
313
+ expect(fake_socket).to receive(:closed?) { false }
314
+ allow(fake_socket).to receive(:write) {|data| data.bytesize }
315
+
316
+ expect(fake_socket).to receive(:closed?) { false }
317
+ expect(fake_socket).to receive(:close) # The socket MUST be closed at the end of hijack
318
+
319
+ hijack = headers.fetch('rack.hijack')
320
+ hijack.call(fake_socket)
321
+
322
+ expect(callbacks.length).to eq(62)
323
+
324
+ expect(callbacks[0]).to eq([:started, 0])
325
+ expect(callbacks[-2]).to eq([:complete, 123731968])
326
+ expect(callbacks[-1]).to eq([:cleanup, 123731968])
327
+
328
+ bytes_sent_cbs = callbacks[1..-3]
329
+ bytes_sent_cbs.each_with_index do | c, i |
330
+ expect(c[1]).to be_kind_of(Fixnum)
331
+ expect(c[2]).to be_kind_of(Fixnum)
332
+ end
333
+ end
334
+
335
+ it 'closes the socket even when the cleanup proc raises' do
336
+ source_size = (64 + 54) * 1024 * 1024
337
+ callbacks = []
338
+
339
+ app = ->(env) {
340
+ [200, {
341
+ 'fast_send.cleanup' => ->(b){ raise "Failed when executing the cleanup callbacks" },
342
+ }, EachFileResponse.new]
343
+ }
344
+
345
+ handler = described_class.new(app)
346
+ status, headers, body = handler.call({'rack.hijack?' => true})
347
+
348
+ keys = headers.keys
349
+ expect(keys.grep(/fast\_send/)).to be_empty # The callback headers should be removed
350
+
351
+ expect(status).to eq(200)
352
+ expect(body).to eq([])
353
+
354
+ fake_socket = double('Socket')
355
+
356
+ allow(fake_socket).to receive(:respond_to?).with(:sendfile) { false }
357
+ allow(fake_socket).to receive(:respond_to?).with(:to_path) { false } # called by IO.copy_stream
358
+
359
+ expect(fake_socket).to receive(:closed?) { false }
360
+ allow(fake_socket).to receive(:write) {|data| data.bytesize }
361
+
362
+ expect(fake_socket).to receive(:closed?) { false }
363
+ expect(fake_socket).to receive(:close) # The socket MUST be closed at the end of hijack
364
+
365
+ hijack = headers.fetch('rack.hijack')
366
+ expect {
367
+ hijack.call(fake_socket)
368
+ }.to raise_error(/Failed when executing the cleanup callback/)
369
+ end
370
+
371
+ it 'passes the exception to the fast_send.error proc' do
372
+ source_size = (64 + 54) * 1024 * 1024
373
+
374
+ error_proc = ->(e){ expect(e).to be_kind_of(RuntimeError) }
375
+ expect(error_proc).to receive(:call).and_call_original
376
+
377
+ app = ->(env) {
378
+ [200, {
379
+ 'Content-Length' => source_size.to_s,
380
+ 'fast_send.error' => error_proc,
381
+ }, FailingResponse.new]
382
+ }
383
+
384
+ handler = described_class.new(app)
385
+ status, headers, body = handler.call({'rack.hijack?' => true})
386
+ expect(status).to eq(200)
387
+ expect(body).to eq([])
388
+
389
+ fake_socket = FakeSocketWithSendfile.new(Tempfile.new('tt'))
390
+ # The socket MUST be closed at the end of hijack
391
+ expect(fake_socket).to receive(:close).and_call_original
392
+
393
+ hijack = headers.fetch('rack.hijack')
394
+ hijack.call(fake_socket)
395
+ end
396
+
397
+ it 'does not pass an EPIPE to the fast_send.error proc' do
398
+ source_size = (64 + 54) * 1024 * 1024
399
+
400
+ error_proc = ->(*){ throw :no }
401
+ expect(error_proc).not_to receive(:call)
402
+
403
+ app = ->(env) {
404
+ [200, {
405
+ 'Content-Length' => source_size.to_s,
406
+ 'fast_send.error' => error_proc,
407
+ }, FailingResponse.new(Errno::EPIPE)]
408
+ }
409
+
410
+ handler = described_class.new(app)
411
+ status, headers, body = handler.call({'rack.hijack?' => true})
412
+ expect(status).to eq(200)
413
+ expect(body).to eq([])
414
+
415
+ fake_socket = FakeSocketWithSendfile.new(Tempfile.new('tt'))
416
+ # The socket MUST be closed at the end of hijack
417
+ expect(fake_socket).to receive(:close).and_call_original
418
+
419
+ hijack = headers.fetch('rack.hijack')
420
+ hijack.call(fake_socket)
421
+ end
422
+
423
+ it 'passes the exception to the fast_send.aborted proc' do
424
+ source_size = (64 + 54) * 1024 * 1024
425
+
426
+ abort_proc = ->(e){ expect(e).to be_kind_of(RuntimeError) }
427
+ expect(abort_proc).to receive(:call).and_call_original
428
+
429
+ app = ->(env) {
430
+ [200, {
431
+ 'Content-Length' => source_size.to_s,
432
+ 'fast_send.aborted' => abort_proc,
433
+ }, FailingResponse.new]
434
+ }
435
+
436
+ handler = described_class.new(app)
437
+ status, headers, body = handler.call({'rack.hijack?' => true})
438
+ expect(status).to eq(200)
439
+ expect(body).to eq([])
440
+
441
+ fake_socket = FakeSocketWithSendfile.new(Tempfile.new('tt'))
442
+ # The socket MUST be closed at the end of hijack
443
+ expect(fake_socket).to receive(:close).and_call_original
444
+
445
+ hijack = headers.fetch('rack.hijack')
446
+ hijack.call(fake_socket)
447
+ end
448
+
449
+ it 'halts the response when the socket times out in IO.select'
450
+ it 'halts the response when the socket errors in IO.select'
451
+ end
@@ -0,0 +1,63 @@
1
+ require_relative '../lib/fast_send'
2
+ require 'net/http'
3
+
4
+ describe 'FastSend when used in combination with Puma' do
5
+ before :all do
6
+ # @server = Thread.new {
7
+ # ``
8
+ # }
9
+ command = 'bundle exec puma --port 9293 %s/test_app.ru' % __dir__
10
+ @server_pid = spawn(command)
11
+ end
12
+
13
+ it 'offers the file for download, sends the entire file' do
14
+ begin
15
+ require 'sendfile'
16
+ rescue LoadError # jruby et al
17
+ end
18
+
19
+ tries = 0
20
+ begin
21
+ headers = {}
22
+ uri = URI('http://127.0.0.1:9293')
23
+ conn = Net::HTTP.new(uri.host, uri.port)
24
+ conn.read_timeout = 1
25
+ conn.open_timeout = 1
26
+ conn.start do |http|
27
+ req = Net::HTTP::Get.new(uri.request_uri)
28
+ http.request(req) do |res|
29
+
30
+ dispatch = res.header['X-Fast-Send-Dispatch']
31
+ expect(dispatch).to eq('hijack')
32
+
33
+ downloaded_copy = Tempfile.new('cpy')
34
+ downloaded_copy.binmode
35
+
36
+ res.read_body {|chunk| downloaded_copy << chunk }
37
+ downloaded_copy.rewind
38
+
39
+ expect(downloaded_copy.size).to eq(res.header['Content-Length'].to_i)
40
+
41
+ File.open(res.header['X-Source-Path'], 'rb') do |source_file|
42
+ loop do
43
+ pos = source_file.pos
44
+ from_source = source_file.read(5)
45
+ downloaded = downloaded_copy.read(5)
46
+ break if from_source
47
+ expect(downloaded.unpack("C*")).to eq(from_source.unpack("C*"))
48
+ end
49
+ end
50
+ end
51
+ end
52
+ rescue Errno::ECONNREFUSED => e # Puma hasn't started yet
53
+ raise e if (tries += 1) > 100
54
+ sleep 0.5
55
+ retry
56
+ end
57
+ end
58
+
59
+ after :all do
60
+ Process.kill('TERM', @server_pid)
61
+ Process.wait(@server_pid)
62
+ end
63
+ end
data/spec/test_app.ru ADDED
@@ -0,0 +1,24 @@
1
+ require File.dirname(__FILE__) + '/../lib/fast_send'
2
+
3
+ require 'bundler'
4
+ Bundler.require(:development)
5
+
6
+ TF = Tempfile.new('xx')
7
+ TF.binmode
8
+ 64.times { TF << Random.new.bytes(1024*1024) }
9
+ TF.flush
10
+ TF.rewind
11
+
12
+ use FastSend
13
+
14
+ class Eacher
15
+ def each_file
16
+ yield(TF)
17
+ ensure
18
+ TF.rewind
19
+ end
20
+ end
21
+
22
+ run ->(env) {
23
+ [200, {'Content-Length' => TF.size.to_s, 'X-Source-Path' => TF.path}, Eacher.new]
24
+ }
metadata ADDED
@@ -0,0 +1,127 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: fast_send
3
+ version: !ruby/object:Gem::Version
4
+ version: 1.1.2
5
+ platform: ruby
6
+ authors:
7
+ - Julik Tarkhanov
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2016-11-15 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: rake
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - ">="
18
+ - !ruby/object:Gem::Version
19
+ version: '0'
20
+ type: :development
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - ">="
25
+ - !ruby/object:Gem::Version
26
+ version: '0'
27
+ - !ruby/object:Gem::Dependency
28
+ name: jeweler
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - ">"
32
+ - !ruby/object:Gem::Version
33
+ version: '2'
34
+ type: :development
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - ">"
39
+ - !ruby/object:Gem::Version
40
+ version: '2'
41
+ - !ruby/object:Gem::Dependency
42
+ name: rspec
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - "~>"
46
+ - !ruby/object:Gem::Version
47
+ version: '3'
48
+ type: :development
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - "~>"
53
+ - !ruby/object:Gem::Version
54
+ version: '3'
55
+ - !ruby/object:Gem::Dependency
56
+ name: puma
57
+ requirement: !ruby/object:Gem::Requirement
58
+ requirements:
59
+ - - ">="
60
+ - !ruby/object:Gem::Version
61
+ version: '0'
62
+ type: :development
63
+ prerelease: false
64
+ version_requirements: !ruby/object:Gem::Requirement
65
+ requirements:
66
+ - - ">="
67
+ - !ruby/object:Gem::Version
68
+ version: '0'
69
+ - !ruby/object:Gem::Dependency
70
+ name: sendfile
71
+ requirement: !ruby/object:Gem::Requirement
72
+ requirements:
73
+ - - ">="
74
+ - !ruby/object:Gem::Version
75
+ version: '0'
76
+ type: :development
77
+ prerelease: false
78
+ version_requirements: !ruby/object:Gem::Requirement
79
+ requirements:
80
+ - - ">="
81
+ - !ruby/object:Gem::Version
82
+ version: '0'
83
+ description: Send bursts of large files quickly via Rack
84
+ email: me@julik.nl
85
+ executables: []
86
+ extensions: []
87
+ extra_rdoc_files:
88
+ - LICENSE.txt
89
+ - README.md
90
+ files:
91
+ - Gemfile
92
+ - LICENSE.txt
93
+ - README.md
94
+ - Rakefile
95
+ - config.ru
96
+ - fast_send.gemspec
97
+ - lib/fast_send.rb
98
+ - lib/fast_send/null_logger.rb
99
+ - lib/fast_send/socket_handler.rb
100
+ - spec/fast_send_with_mocks_spec.rb
101
+ - spec/fast_send_with_puma_spec.rb
102
+ - spec/test_app.ru
103
+ homepage: https://github.com/WeTransfer/fast_send
104
+ licenses:
105
+ - MIT
106
+ metadata: {}
107
+ post_install_message:
108
+ rdoc_options: []
109
+ require_paths:
110
+ - lib
111
+ required_ruby_version: !ruby/object:Gem::Requirement
112
+ requirements:
113
+ - - ">="
114
+ - !ruby/object:Gem::Version
115
+ version: '0'
116
+ required_rubygems_version: !ruby/object:Gem::Requirement
117
+ requirements:
118
+ - - ">="
119
+ - !ruby/object:Gem::Version
120
+ version: '0'
121
+ requirements: []
122
+ rubyforge_project:
123
+ rubygems_version: 2.4.5.1
124
+ signing_key:
125
+ specification_version: 4
126
+ summary: and do so bypassing the Ruby VM
127
+ test_files: []