tipi 0.41 → 0.46

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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