fast_send 1.1.2
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/Gemfile +9 -0
- data/LICENSE.txt +20 -0
- data/README.md +100 -0
- data/Rakefile +38 -0
- data/config.ru +17 -0
- data/fast_send.gemspec +64 -0
- data/lib/fast_send/null_logger.rb +8 -0
- data/lib/fast_send/socket_handler.rb +211 -0
- data/lib/fast_send.rb +158 -0
- data/spec/fast_send_with_mocks_spec.rb +451 -0
- data/spec/fast_send_with_puma_spec.rb +63 -0
- data/spec/test_app.ru +24 -0
- metadata +127 -0
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
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,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: []
|