tipi 0.41 → 0.46

Sign up to get free protection for your applications and to get access to all the features.
Files changed (69) hide show
  1. checksums.yaml +4 -4
  2. data/.github/FUNDING.yml +1 -0
  3. data/.github/workflows/test.yml +3 -1
  4. data/.gitignore +3 -1
  5. data/CHANGELOG.md +34 -0
  6. data/Gemfile +7 -1
  7. data/Gemfile.lock +53 -33
  8. data/README.md +184 -8
  9. data/Rakefile +1 -7
  10. data/benchmarks/bm_http1_parser.rb +85 -0
  11. data/bin/benchmark +37 -0
  12. data/bin/h1pd +6 -0
  13. data/bin/tipi +3 -21
  14. data/bm.png +0 -0
  15. data/df/agent.rb +1 -1
  16. data/df/sample_agent.rb +2 -2
  17. data/df/server.rb +3 -1
  18. data/df/server_utils.rb +48 -46
  19. data/examples/full_service.rb +13 -0
  20. data/examples/hello.rb +5 -0
  21. data/examples/hello.ru +3 -3
  22. data/examples/http1_parser.rb +10 -8
  23. data/examples/http_server.js +1 -1
  24. data/examples/http_server.rb +4 -1
  25. data/examples/http_server_graceful.rb +1 -1
  26. data/examples/https_server.rb +41 -15
  27. data/examples/rack_server_forked.rb +26 -0
  28. data/examples/rack_server_https_forked.rb +1 -1
  29. data/examples/servername_cb.rb +37 -0
  30. data/examples/websocket_demo.rb +1 -1
  31. data/lib/tipi/acme.rb +320 -0
  32. data/lib/tipi/cli.rb +93 -0
  33. data/lib/tipi/config_dsl.rb +13 -13
  34. data/lib/tipi/configuration.rb +2 -2
  35. data/lib/tipi/controller/bare_polyphony.rb +0 -0
  36. data/lib/tipi/controller/bare_stock.rb +10 -0
  37. data/lib/tipi/controller/extensions.rb +37 -0
  38. data/lib/tipi/controller/stock_http1_adapter.rb +15 -0
  39. data/lib/tipi/controller/web_polyphony.rb +353 -0
  40. data/lib/tipi/controller/web_stock.rb +635 -0
  41. data/lib/tipi/controller.rb +12 -0
  42. data/lib/tipi/digital_fabric/agent.rb +5 -5
  43. data/lib/tipi/digital_fabric/agent_proxy.rb +15 -8
  44. data/lib/tipi/digital_fabric/executive.rb +7 -3
  45. data/lib/tipi/digital_fabric/protocol.rb +3 -3
  46. data/lib/tipi/digital_fabric/request_adapter.rb +0 -4
  47. data/lib/tipi/digital_fabric/service.rb +17 -18
  48. data/lib/tipi/handler.rb +2 -2
  49. data/lib/tipi/http1_adapter.rb +85 -124
  50. data/lib/tipi/http2_adapter.rb +29 -16
  51. data/lib/tipi/http2_stream.rb +52 -57
  52. data/lib/tipi/rack_adapter.rb +2 -2
  53. data/lib/tipi/response_extensions.rb +1 -1
  54. data/lib/tipi/supervisor.rb +75 -0
  55. data/lib/tipi/version.rb +1 -1
  56. data/lib/tipi/websocket.rb +3 -3
  57. data/lib/tipi.rb +9 -7
  58. data/test/coverage.rb +2 -2
  59. data/test/helper.rb +60 -12
  60. data/test/test_http_server.rb +14 -41
  61. data/test/test_request.rb +2 -29
  62. data/tipi.gemspec +10 -10
  63. metadata +80 -54
  64. data/examples/automatic_certificate.rb +0 -193
  65. data/ext/tipi/extconf.rb +0 -12
  66. data/ext/tipi/http1_parser.c +0 -534
  67. data/ext/tipi/http1_parser.h +0 -18
  68. data/ext/tipi/tipi_ext.c +0 -5
  69. data/lib/tipi/http1_adapter_new.rb +0 -293
@@ -3,18 +3,30 @@
3
3
  require 'http/2'
4
4
  require_relative './http2_stream'
5
5
 
6
+ # patch to fix bug in HTTP2::Stream
7
+ class HTTP2::Stream
8
+ def end_stream?(frame)
9
+ case frame[:type]
10
+ when :data, :headers, :continuation
11
+ frame[:flags]&.include?(:end_stream)
12
+ else false
13
+ end
14
+ end
15
+ end
16
+
6
17
  module Tipi
7
18
  # HTTP2 server adapter
8
19
  class HTTP2Adapter
9
- def self.upgrade_each(socket, opts, headers, &block)
10
- adapter = new(socket, opts, headers)
20
+ def self.upgrade_each(socket, opts, headers, body, &block)
21
+ adapter = new(socket, opts, headers, body)
11
22
  adapter.each(&block)
12
23
  end
13
-
14
- def initialize(conn, opts, upgrade_headers = nil)
24
+
25
+ def initialize(conn, opts, upgrade_headers = nil, upgrade_body = nil)
15
26
  @conn = conn
16
27
  @opts = opts
17
28
  @upgrade_headers = upgrade_headers
29
+ @upgrade_body = upgrade_body
18
30
  @first = true
19
31
  @rx = (upgrade_headers && upgrade_headers[':rx']) || 0
20
32
  @tx = (upgrade_headers && upgrade_headers[':tx']) || 0
@@ -24,33 +36,34 @@ module Tipi
24
36
  @interface.on(:frame, &method(:send_frame))
25
37
  @streams = {}
26
38
  end
27
-
39
+
28
40
  def send_frame(data)
29
41
  if @transfer_count_request
30
42
  @transfer_count_request.tx_incr(data.bytesize)
31
43
  end
32
44
  @conn << data
45
+ rescue Polyphony::BaseException
46
+ raise
33
47
  rescue Exception => e
34
48
  @connection_fiber.transfer e
35
49
  end
36
-
50
+
37
51
  UPGRADE_MESSAGE = <<~HTTP.gsub("\n", "\r\n")
38
52
  HTTP/1.1 101 Switching Protocols
39
53
  Connection: Upgrade
40
54
  Upgrade: h2c
41
-
55
+
42
56
  HTTP
43
-
57
+
44
58
  def upgrade
45
59
  @conn << UPGRADE_MESSAGE
46
60
  @tx += UPGRADE_MESSAGE.bytesize
47
61
  settings = @upgrade_headers['http2-settings']
48
- Fiber.current.schedule(nil)
49
- @interface.upgrade(settings, @upgrade_headers, '')
62
+ @interface.upgrade(settings, @upgrade_headers, @upgrade_body || '')
50
63
  ensure
51
64
  @upgrade_headers = nil
52
65
  end
53
-
66
+
54
67
  # Iterates over incoming requests
55
68
  def each(&block)
56
69
  @interface.on(:stream) { |stream| start_stream(stream, &block) }
@@ -60,7 +73,7 @@ module Tipi
60
73
  @rx += data.bytesize
61
74
  @interface << data
62
75
  end
63
- rescue SystemCallError, IOError
76
+ rescue SystemCallError, IOError, HTTP2::Error::Error
64
77
  # ignore
65
78
  ensure
66
79
  finalize_client_loop
@@ -71,26 +84,26 @@ module Tipi
71
84
  @rx = 0
72
85
  count
73
86
  end
74
-
87
+
75
88
  def get_tx_count
76
89
  count = @tx
77
90
  @tx = 0
78
91
  count
79
92
  end
80
-
93
+
81
94
  def start_stream(stream, &block)
82
95
  stream = HTTP2StreamHandler.new(self, stream, @conn, @first, &block)
83
96
  @first = nil if @first
84
97
  @streams[stream] = true
85
98
  end
86
-
99
+
87
100
  def finalize_client_loop
88
101
  @interface = nil
89
102
  @streams.each_key(&:stop)
90
103
  @conn.shutdown if @conn.respond_to?(:shutdown) rescue nil
91
104
  @conn.close
92
105
  end
93
-
106
+
94
107
  def close
95
108
  @conn.shutdown if @conn.respond_to?(:shutdown) rescue nil
96
109
  @conn.close
@@ -6,17 +6,15 @@ require 'qeweney/request'
6
6
  module Tipi
7
7
  # Manages an HTTP 2 stream
8
8
  class HTTP2StreamHandler
9
- attr_accessor :__next__
10
9
  attr_reader :conn
11
-
10
+
12
11
  def initialize(adapter, stream, conn, first, &block)
13
12
  @adapter = adapter
14
13
  @stream = stream
15
14
  @conn = conn
16
15
  @first = first
17
16
  @connection_fiber = Fiber.current
18
- @stream_fiber = spin { |req| handle_request(req, &block) }
19
- Thread.current.fiber_unschedule(@stream_fiber)
17
+ @stream_fiber = spin { run(&block) }
20
18
 
21
19
  # Stream callbacks occur on the connection fiber (see HTTP2Adapter#each).
22
20
  # The request handler is run on a separate fiber for each stream, allowing
@@ -33,20 +31,20 @@ module Tipi
33
31
  stream.on(:data, &method(:on_data))
34
32
  stream.on(:half_close, &method(:on_half_close))
35
33
  end
36
-
37
- def handle_request(request, &block)
34
+
35
+ def run(&block)
36
+ request = receive
38
37
  error = nil
39
38
  block.(request)
40
39
  @connection_fiber.schedule
41
- rescue Polyphony::MoveOn
42
- # ignore
40
+ rescue Polyphony::BaseException
41
+ raise
43
42
  rescue Exception => e
44
43
  error = e
45
44
  ensure
46
- @done = true
47
45
  @connection_fiber.schedule error
48
46
  end
49
-
47
+
50
48
  def on_headers(headers)
51
49
  @request = Qeweney::Request.new(headers.to_h, self)
52
50
  @request.rx_incr(@adapter.get_rx_count)
@@ -55,31 +53,21 @@ module Tipi
55
53
  @request.headers[':first'] = true
56
54
  @first = false
57
55
  end
58
- @stream_fiber.schedule @request
56
+ @stream_fiber << @request
59
57
  end
60
58
 
61
59
  def on_data(data)
62
60
  data = data.to_s # chunks might be wrapped in a HTTP2::Buffer
63
- if @waiting_for_body_chunk
64
- @waiting_for_body_chunk = nil
65
- @stream_fiber.schedule data
66
- else
67
- @request.buffer_body_chunk(data)
68
- end
61
+
62
+ (@buffered_chunks ||= []) << data
63
+ @get_body_chunk_fiber&.schedule
69
64
  end
70
65
 
71
66
  def on_half_close
72
- if @waiting_for_body_chunk
73
- @waiting_for_body_chunk = nil
74
- @stream_fiber.schedule
75
- elsif @waiting_for_half_close
76
- @waiting_for_half_close = nil
77
- @stream_fiber.schedule
78
- else
79
- @request.complete!
80
- end
67
+ @get_body_chunk_fiber&.schedule
68
+ @complete = true
81
69
  end
82
-
70
+
83
71
  def protocol
84
72
  'h2'
85
73
  end
@@ -90,33 +78,40 @@ module Tipi
90
78
  ensure
91
79
  @adapter.unset_request_for_transfer_count(request)
92
80
  end
93
-
94
- def get_body_chunk(request)
95
- # called in the context of the stream fiber
96
- return nil if @request.complete?
97
-
98
- with_transfer_count(request) do
99
- @waiting_for_body_chunk = true
100
- # the chunk (or an exception) will be returned once the stream fiber is
101
- # resumed
81
+
82
+ def get_body_chunk(request, buffered_only = false)
83
+ @buffered_chunks ||= []
84
+ return @buffered_chunks.shift unless @buffered_chunks.empty?
85
+ return nil if @complete
86
+
87
+ begin
88
+ @get_body_chunk_fiber = Fiber.current
102
89
  suspend
90
+ ensure
91
+ @get_body_chunk_fiber = nil
103
92
  end
104
- ensure
105
- @waiting_for_body_chunk = nil
93
+ @buffered_chunks.shift
106
94
  end
107
-
108
- # Wait for request to finish
109
- def consume_request(request)
110
- return if @request.complete?
111
-
112
- with_transfer_count(request) do
113
- @waiting_for_half_close = true
114
- suspend
95
+
96
+ def get_body(request)
97
+ @buffered_chunks ||= []
98
+ return @buffered_chunks.join if @complete
99
+
100
+ while !@complete
101
+ begin
102
+ @get_body_chunk_fiber = Fiber.current
103
+ suspend
104
+ ensure
105
+ @get_body_chunk_fiber = nil
106
+ end
115
107
  end
116
- ensure
117
- @waiting_for_half_close = nil
108
+ @buffered_chunks.join
109
+ end
110
+
111
+ def complete?(request)
112
+ @complete
118
113
  end
119
-
114
+
120
115
  # response API
121
116
  def respond(request, chunk, headers)
122
117
  headers[':status'] ||= Qeweney::Status::OK
@@ -153,10 +148,10 @@ module Tipi
153
148
  end
154
149
  end
155
150
  end
156
-
151
+
157
152
  def send_headers(request, headers, empty_response: false)
158
153
  return if @headers_sent
159
-
154
+
160
155
  headers[':status'] ||= (empty_response ? Qeweney::Status::NO_CONTENT : Qeweney::Status::OK).to_s
161
156
  with_transfer_count(request) do
162
157
  @stream.headers(transform_headers(headers), end_stream: false)
@@ -165,10 +160,10 @@ module Tipi
165
160
  rescue HTTP2::Error::StreamClosed
166
161
  # ignore
167
162
  end
168
-
163
+
169
164
  def send_chunk(request, chunk, done: false)
170
165
  send_headers({}, false) unless @headers_sent
171
-
166
+
172
167
  if chunk
173
168
  with_transfer_count(request) do
174
169
  @stream.data(chunk, end_stream: done)
@@ -179,7 +174,7 @@ module Tipi
179
174
  rescue HTTP2::Error::StreamClosed
180
175
  # ignore
181
176
  end
182
-
177
+
183
178
  def finish(request)
184
179
  if @headers_sent
185
180
  @stream.close
@@ -192,10 +187,10 @@ module Tipi
192
187
  rescue HTTP2::Error::StreamClosed
193
188
  # ignore
194
189
  end
195
-
190
+
196
191
  def stop
197
- return if @done
198
-
192
+ return if @complete
193
+
199
194
  @stream.close
200
195
  @stream_fiber.schedule(Polyphony::MoveOn.new)
201
196
  end
@@ -3,12 +3,12 @@
3
3
  require 'rack'
4
4
 
5
5
  module Tipi
6
- module RackAdapter
6
+ module RackAdapter
7
7
  class << self
8
8
  def run(app)
9
9
  ->(req) { respond(req, app.(env(req))) }
10
10
  end
11
-
11
+
12
12
  def load(path)
13
13
  src = IO.read(path)
14
14
  instance_eval(src, path, 1)
@@ -9,7 +9,7 @@ module Tipi
9
9
  def serve_io(io, opts)
10
10
  if !opts[:stat] || opts[:stat].size >= SPLICE_CHUNKS_SIZE_THRESHOLD
11
11
  @adapter.respond_from_io(self, io, opts[:headers], opts[:chunk_size] || 2**14)
12
- else
12
+ else
13
13
  respond(io.read, opts[:headers] || {})
14
14
  end
15
15
  end
@@ -0,0 +1,75 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'polyphony'
4
+ require 'json'
5
+
6
+ module Tipi
7
+ module Supervisor
8
+ class << self
9
+ def run(opts)
10
+ puts "Start supervisor pid: #{Process.pid}"
11
+ @opts = opts
12
+ @controller_watcher = start_controller_watcher
13
+ supervise_loop
14
+ end
15
+
16
+ def start_controller_watcher
17
+ spin do
18
+ cmd = controller_cmd
19
+ puts "Starting controller..."
20
+ pid = Kernel.spawn(*cmd)
21
+ @controller_pid = pid
22
+ puts "Controller pid: #{pid}"
23
+ _pid, status = Polyphony.backend_waitpid(pid)
24
+ puts "Controller has terminated with status: #{status.inspect}"
25
+ terminated = true
26
+ ensure
27
+ if pid && !terminated
28
+ puts "Terminate controller #{pid.inspect}"
29
+ Polyphony::Process.kill_process(pid)
30
+ end
31
+ Fiber.current.parent << pid
32
+ end
33
+ end
34
+
35
+ def controller_cmd
36
+ [
37
+ 'ruby',
38
+ File.join(__dir__, 'controller.rb'),
39
+ @opts.to_json
40
+ ]
41
+ end
42
+
43
+ def supervise_loop
44
+ this_fiber = Fiber.current
45
+ trap('SIGUSR2') { this_fiber << :replace_controller }
46
+ loop do
47
+ case (msg = receive)
48
+ when :replace_controller
49
+ replace_controller
50
+ when Integer
51
+ pid = msg
52
+ if pid == @controller_pid
53
+ puts 'Detected dead controller. Restarting...'
54
+ exit!
55
+ @controller_watcher.restart
56
+ end
57
+ else
58
+ raise "Invalid message received: #{msg.inspect}"
59
+ end
60
+ end
61
+ end
62
+
63
+ def replace_controller
64
+ puts "Replacing controller"
65
+ old_watcher = @controller_watcher
66
+ @controller_watcher = start_controller_watcher
67
+
68
+ # TODO: we'll want to get some kind of signal from the new controller once it's ready
69
+ sleep 1
70
+
71
+ old_watcher.terminate(true)
72
+ end
73
+ end
74
+ end
75
+ end
data/lib/tipi/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Tipi
4
- VERSION = '0.41'
4
+ VERSION = '0.46'
5
5
  end
@@ -20,12 +20,12 @@ module Tipi
20
20
  @version = headers['sec-websocket-version'].to_i
21
21
  @reader = ::WebSocket::Frame::Incoming::Server.new(version: @version)
22
22
  end
23
-
23
+
24
24
  def recv
25
25
  if (msg = @reader.next)
26
26
  return msg.to_s
27
27
  end
28
-
28
+
29
29
  @conn.recv_loop do |data|
30
30
  @reader << data
31
31
  if (msg = @reader.next)
@@ -48,7 +48,7 @@ module Tipi
48
48
  end
49
49
  end
50
50
  end
51
-
51
+
52
52
  OutgoingFrame = ::WebSocket::Frame::Outgoing::Server
53
53
 
54
54
  def send(data)
data/lib/tipi.rb CHANGED
@@ -1,11 +1,13 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'polyphony'
4
+
4
5
  require_relative './tipi/http1_adapter'
5
- # require_relative './tipi/http1_adapter_new'
6
6
  require_relative './tipi/http2_adapter'
7
7
  require_relative './tipi/configuration'
8
8
  require_relative './tipi/response_extensions'
9
+ require_relative './tipi/acme'
10
+
9
11
  require 'qeweney/request'
10
12
 
11
13
  class Qeweney::Request
@@ -15,7 +17,7 @@ end
15
17
  module Tipi
16
18
  ALPN_PROTOCOLS = %w[h2 http/1.1].freeze
17
19
  H2_PROTOCOL = 'h2'
18
-
20
+
19
21
  class << self
20
22
  def serve(host, port, opts = {}, &handler)
21
23
  opts[:alpn_protocols] = ALPN_PROTOCOLS
@@ -24,7 +26,7 @@ module Tipi
24
26
  ensure
25
27
  server&.close
26
28
  end
27
-
29
+
28
30
  def listen(host, port, opts = {})
29
31
  opts[:alpn_protocols] = ALPN_PROTOCOLS
30
32
  Polyphony::Net.tcp_listen(host, port, opts).tap do |socket|
@@ -33,7 +35,7 @@ module Tipi
33
35
  end
34
36
  end
35
37
  end
36
-
38
+
37
39
  def accept_loop(server, opts, &handler)
38
40
  server.accept_loop do |client|
39
41
  spin { client_loop(client, opts, &handler) }
@@ -41,7 +43,7 @@ module Tipi
41
43
  # disregard
42
44
  end
43
45
  end
44
-
46
+
45
47
  def client_loop(client, opts, &handler)
46
48
  client.no_delay if client.respond_to?(:no_delay)
47
49
  adapter = protocol_adapter(client, opts)
@@ -49,11 +51,11 @@ module Tipi
49
51
  ensure
50
52
  client.close rescue nil
51
53
  end
52
-
54
+
53
55
  def protocol_adapter(socket, opts)
54
56
  use_http2 = socket.respond_to?(:alpn_protocol) &&
55
57
  socket.alpn_protocol == H2_PROTOCOL
56
- klass = use_http2 ? HTTP2Adapter : HTTP1Adapter#New
58
+ klass = use_http2 ? HTTP2Adapter : HTTP1Adapter
57
59
  klass.new(socket, opts)
58
60
  end
59
61
 
data/test/coverage.rb CHANGED
@@ -26,10 +26,10 @@ module Coverage
26
26
  @result = {}
27
27
  trace = TracePoint.new(:line) do |tp|
28
28
  next if tp.path =~ /\(/
29
-
29
+
30
30
  absolute = File.expand_path(tp.path)
31
31
  next unless LIB_FILES.include?(absolute)# =~ /^#{LIB_DIR}/
32
-
32
+
33
33
  @result[absolute] ||= relevant_lines_for_filename(absolute)
34
34
  @result[absolute][tp.lineno - 1] = 1
35
35
  end
data/test/helper.rb CHANGED
@@ -8,32 +8,29 @@ require_relative './eg'
8
8
  require_relative './coverage' if ENV['COVERAGE']
9
9
 
10
10
  require 'minitest/autorun'
11
- require 'minitest/reporters'
12
11
 
13
12
  require 'polyphony'
14
13
 
15
14
  ::Exception.__disable_sanitized_backtrace__ = true
16
15
 
17
- Minitest::Reporters.use! [
18
- Minitest::Reporters::SpecReporter.new
19
- ]
20
-
21
16
  class MiniTest::Test
22
17
  def setup
23
- # puts "* setup #{self.name}"
24
- if Fiber.current.children.size > 0
25
- puts "Children left: #{Fiber.current.children.inspect}"
26
- exit!
27
- end
18
+ # trace "* setup #{self.name}"
28
19
  Fiber.current.setup_main_fiber
29
20
  Fiber.current.instance_variable_set(:@auto_watcher, nil)
21
+ Thread.current.backend.finalize
30
22
  Thread.current.backend = Polyphony::Backend.new
31
23
  sleep 0
32
24
  end
33
25
 
34
26
  def teardown
35
- # puts "* teardown #{self.name.inspect} Fiber.current: #{Fiber.current.inspect}"
27
+ # trace "* teardown #{self.name}"
36
28
  Fiber.current.shutdown_all_children
29
+ if Fiber.current.children.size > 0
30
+ puts "Children left after #{self.name}: #{Fiber.current.children.inspect}"
31
+ exit!
32
+ end
33
+ Fiber.current.instance_variable_set(:@auto_watcher, nil)
37
34
  rescue => e
38
35
  puts e
39
36
  puts e.backtrace.join("\n")
@@ -47,4 +44,55 @@ module Kernel
47
44
  rescue Exception => e
48
45
  e
49
46
  end
50
- end
47
+
48
+ def trace(*args)
49
+ STDOUT.orig_write(format_trace(args))
50
+ end
51
+
52
+ def format_trace(args)
53
+ if args.first.is_a?(String)
54
+ if args.size > 1
55
+ format("%s: %p\n", args.shift, args)
56
+ else
57
+ format("%s\n", args.first)
58
+ end
59
+ else
60
+ format("%p\n", args.size == 1 ? args.first : args)
61
+ end
62
+ end
63
+ end
64
+
65
+ class IO
66
+ # Creates two mockup sockets for simulating server-client communication
67
+ def self.server_client_mockup
68
+ server_in, client_out = IO.pipe
69
+ client_in, server_out = IO.pipe
70
+
71
+ server_connection = mockup_connection(server_in, server_out, client_out)
72
+ client_connection = mockup_connection(client_in, client_out, server_out)
73
+
74
+ [server_connection, client_connection]
75
+ end
76
+
77
+ def self.mockup_connection(input, output, output2)
78
+ eg(
79
+ __parser_read_method__: ->() { :readpartial },
80
+ read: ->(*args) { input.read(*args) },
81
+ read_loop: ->(*args, &block) { input.read_loop(*args, &block) },
82
+ recv_loop: ->(*args, &block) { input.read_loop(*args, &block) },
83
+ readpartial: ->(*args) { input.readpartial(*args) },
84
+ recv: ->(*args) { input.readpartial(*args) },
85
+ '<<': ->(*args) { output.write(*args) },
86
+ write: ->(*args) { output.write(*args) },
87
+ close: -> { output.close },
88
+ eof?: -> { output2.closed? }
89
+ )
90
+ end
91
+ end
92
+
93
+ module Minitest::Assertions
94
+ def assert_in_range exp_range, act
95
+ msg = message(msg) { "Expected #{mu_pp(act)} to be in range #{mu_pp(exp_range)}" }
96
+ assert exp_range.include?(act), msg
97
+ end
98
+ end