biryani 0.0.14 → 0.0.16

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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 96b9dc1294ce1840c02e6cf1700b1ebae51409b586dffd25b7df814acdd65034
4
- data.tar.gz: 66d417773c04e090101661650748415566e9f4ca0f91ff95720b2cb7f8668f7b
3
+ metadata.gz: 1a92a6898bad6aab7de417a5eff15af74a99a41d33e380fd93447f8b15d77ba5
4
+ data.tar.gz: 473061a7d89df0b3956bc396a29102f76f6205b7e3795a452244f30f1f84da3e
5
5
  SHA512:
6
- metadata.gz: '079d3ab7c12394a9fd340757c5f076cbe937bd1f1a1e60cd24e98f7535526e320189a35f6baa2a5dab3c621b467fec52566e2cfec43a9b994d9f4e5c9dd818cf'
7
- data.tar.gz: 7eed1c453cef71438ee5e625bd7b954c38c8cb4aa417db79c00894bf00e4f1f500a759a16d8c6ea58d3c90f273b5bcbbbefbe3a2de1059367b087095921aaec8
6
+ metadata.gz: c76936ffedc6058d29c946a526217f47840248b0e7a1042a2fee816a3488f79aa2a848621f3c443d5d58eed16df8c3f013906fc3f34730eb71a24da6b2022287
7
+ data.tar.gz: e9b25fdf0d6d1481d12faae276941d80d8910361e0916f22d617b059982c3a56cc7310579e63c0b0466797181b2976eb4213b9650b613be55717f7d05060c250
data/.ruby-version CHANGED
@@ -1 +1 @@
1
- 4.0.2
1
+ 4.1-dev
data/Gemfile CHANGED
@@ -1,9 +1,12 @@
1
1
  source 'https://rubygems.org'
2
2
 
3
3
  group :development do
4
+ gem 'ractor-sharing'
4
5
  gem 'rake', '13.3.1'
5
6
  gem 'rspec'
6
7
  gem 'rubocop', '1.82.1'
8
+ gem 'sqlite3'
9
+ gem 'webrick'
7
10
  end
8
11
 
9
12
  gemspec
@@ -4,7 +4,7 @@ RSpec.describe Server do
4
4
  before do
5
5
  @tcpserver = TCPServer.open(PORT)
6
6
 
7
- Ractor.new(@tcpserver) do |socket|
7
+ r = Ractor.new do
8
8
  server = Server.new(
9
9
  Ractor.shareable_proc do |_, res|
10
10
  res.status = 200
@@ -12,8 +12,9 @@ RSpec.describe Server do
12
12
  end
13
13
  )
14
14
 
15
- server.run(socket)
15
+ server.run(Ractor.receive)
16
16
  end
17
+ r.send(@tcpserver.dup, move: true)
17
18
  end
18
19
 
19
20
  let(:junit_report_file_path) do
data/example/README.md ADDED
@@ -0,0 +1,30 @@
1
+ ## Usage
2
+
3
+ The examples run as follows:
4
+
5
+ ```bash
6
+ $ cd /path/to/biryani/example/
7
+
8
+ $ bundle exec ruby hello_world.rb
9
+
10
+ $ curl --http2-prior-knowledge http://localhost:8888
11
+ Hello, world!
12
+ ```
13
+
14
+ Web browsers do NOT support h2c. If you access the examples from a browser, you could use [nginx](https://nginx.org/en/docs/stream/ngx_stream_core_module.html) as a TLS termination proxy.
15
+
16
+ To terminate TLS, `nginx` requires PEM files of certificate and private key. For example, you could generate a locally-trusted certificate, `server.crt` and `server.key`, using [mkcert](https://github.com/FiloSottile/mkcert).
17
+
18
+ ```bash
19
+ $ mkcert -install
20
+
21
+ $ mkcert -cert-file server.crt -key-file server.key localhost 127.0.0.1
22
+ ```
23
+
24
+ Run `nginx` with [nginx.conf](nginx.conf) in this directory.
25
+
26
+ ```bash
27
+ $ nginx -p "$PWD" -c nginx.conf -g 'daemon off; pid /dev/null; error_log stderr;'
28
+ ```
29
+
30
+ Then open https://localhost:4433 in your browser.
data/example/echo.rb CHANGED
@@ -22,5 +22,5 @@ server = Biryani::Server.new(
22
22
  )
23
23
  server.run(socket)
24
24
 
25
- # $ bundle exec ruby example/echo.rb
25
+ # $ bundle exec ruby echo.rb
26
26
  # $ curl -v --http2-prior-knowledge http://localhost:8888 -X POST -d hi
@@ -18,5 +18,5 @@ server = Biryani::Server.new(
18
18
  )
19
19
  server.run(socket)
20
20
 
21
- # $ bundle exec ruby example/hello_world.rb
21
+ # $ bundle exec ruby hello_world.rb
22
22
  # $ curl -v --http2-prior-knowledge http://localhost:8888
@@ -0,0 +1,112 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ $LOAD_PATH << "#{__dir__}/../lib"
4
+
5
+ require 'json'
6
+ require 'socket'
7
+ require 'ractor/keylockhash'
8
+ require 'ractor/lockvar'
9
+ require 'biryani'
10
+
11
+ # `Ractor::LockVar` and `Ractor::KeyLockHash` (ko1/ractor-sharing) are shareable, so every stream Ractor uses NEXT_ID and STORE directly.
12
+ #
13
+ # +-- stream Ractor (POST /) --------------+ +-- NEXT_ID ---------------------+
14
+ # | NEXT_ID.increment --------------------+-------->| Ractor::LockVar |
15
+ # | STORE[id] = value --------------------+----+ +--------------------------------+
16
+ # +----------------------------------------+ |
17
+ # | +-- STORE -----------------------+
18
+ # +--->| Ractor::KeyLockHash |
19
+ # | | 1 => "hoge" |
20
+ # +-- stream Ractor (PUT /:id) ------------+ | | 2 => "piyo" |
21
+ # | STORE.update(id) { ... } -------------+----+ | |
22
+ # +----------------------------------------+ +--------------------------------+
23
+ NEXT_ID = Ractor::LockVar.new(0) # plays the role of AUTOINCREMENT in SQL
24
+ STORE = Ractor::KeyLockHash.new
25
+
26
+ module App
27
+ # @param req [Biryani::HTTP::Request]
28
+ # @param res [Biryani::HTTP::Response]
29
+ def self.call(req, res)
30
+ case req.method
31
+ when 'POST'
32
+ do_post(req.content, res)
33
+ when 'GET'
34
+ req.uri.path == '/' ? do_list(res) : do_get(item_id(req), res)
35
+ when 'PUT'
36
+ do_put(item_id(req), req.content, res)
37
+ when 'DELETE'
38
+ do_delete(item_id(req), res)
39
+ else
40
+ respond(res, 405, { error: 'HTTP Method not allowed' })
41
+ end
42
+ end
43
+
44
+ def self.do_post(value, res)
45
+ id = NEXT_ID.increment
46
+ STORE[id] = value
47
+ respond(res, 201, { status: 'created', id: id })
48
+ end
49
+
50
+ def self.do_list(res)
51
+ items = STORE.to_h.compact.sort.map { |id, value| { id: id, value: value } }
52
+ respond(res, 200, items)
53
+ end
54
+
55
+ def self.do_get(id, res)
56
+ value = STORE[id]
57
+ return respond(res, 404, { error: 'not found' }) if value.nil?
58
+
59
+ respond(res, 200, { id: id, value: value })
60
+ end
61
+
62
+ def self.do_put(id, value, res)
63
+ updated = false
64
+ STORE.update(id) do |old|
65
+ next if old.nil?
66
+
67
+ updated = true
68
+ value
69
+ end
70
+ return respond(res, 404, { error: 'not found' }) unless updated
71
+
72
+ respond(res, 200, { status: 'ok' })
73
+ end
74
+
75
+ def self.do_delete(id, res)
76
+ return respond(res, 404, { error: 'not found' }) if STORE.delete(id).nil?
77
+
78
+ respond(res, 200, { status: 'ok' })
79
+ end
80
+
81
+ # @param req [Biryani::HTTP::Request]
82
+ #
83
+ # @return [Integer]
84
+ def self.item_id(req)
85
+ req.uri.path.split('/').reject(&:empty?).first.to_i
86
+ end
87
+
88
+ def self.respond(res, status, body)
89
+ res.status = status
90
+ res.fields['content-type'] = 'application/json'
91
+ res.content = JSON.generate(body)
92
+ end
93
+ end
94
+
95
+ port = ARGV[0] || 8888
96
+ socket = TCPServer.new(port)
97
+
98
+ server = Biryani::Server.new(
99
+ # @param req [Biryani::HTTP::Request]
100
+ # @param res [Biryani::HTTP::Response]
101
+ Ractor.shareable_proc do |req, res|
102
+ App.call(req, res)
103
+ end
104
+ )
105
+ server.run(socket)
106
+
107
+ # $ bundle exec ruby kv_store.rb
108
+ # $ curl -v --http2-prior-knowledge http://localhost:8888 -X POST -d 'hoge'
109
+ # $ curl -v --http2-prior-knowledge http://localhost:8888/
110
+ # $ curl -v --http2-prior-knowledge http://localhost:8888/1 -X PUT -d 'piyo'
111
+ # $ curl -v --http2-prior-knowledge http://localhost:8888/1
112
+ # $ curl -v --http2-prior-knowledge http://localhost:8888/1 -X DELETE
@@ -0,0 +1,11 @@
1
+ events {}
2
+
3
+ stream {
4
+ server {
5
+ listen 127.0.0.1:4433 ssl;
6
+ ssl_certificate server.crt;
7
+ ssl_certificate_key server.key;
8
+ ssl_alpn h2;
9
+ proxy_pass 127.0.0.1:8888;
10
+ }
11
+ }
@@ -15,5 +15,5 @@ server = Biryani::Server.new(
15
15
  )
16
16
  server.run(socket)
17
17
 
18
- # $ bundle exec ruby example/raise_error.rb
18
+ # $ bundle exec ruby raise_error.rb
19
19
  # $ curl -v --http2-prior-knowledge http://localhost:8888
@@ -0,0 +1,171 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ $LOAD_PATH << "#{__dir__}/../lib"
4
+
5
+ require 'json'
6
+ require 'socket'
7
+ require 'sqlite3'
8
+ require 'biryani'
9
+
10
+ # `sqlite3` is not Ractor-safe, so the main Ractor owns the database, and handlers send queries to it through DB_PORT.
11
+ #
12
+ # +-- main Ractor ---------------------+ +-- stream Ractor (1 Ractor / stream) ------------+
13
+ # | | | |
14
+ # | +-- DB -----------------------+ | | +-- handler -------------------------------+ |
15
+ # | | SQLite3::Database | | | | App.call(req, res) | |
16
+ # | +-----------------------------+ | | | DB.execute(sql, *binds) | |
17
+ # | ^ | | | | | |
18
+ # | | sql | | | | sql | |
19
+ # | | | | | v | |
20
+ # | DB_PORT.receive <---------------+-----------+--+---- DB_PORT.send | |
21
+ # | reply.send ---------------------+-----------+--+---> reply.receive | |
22
+ # | | | +------------------------------------------+ |
23
+ # | r.send(socket, move: true) | | |
24
+ # +----+-------------------------------+ +-------------------------------------------------+
25
+ # | ^
26
+ # v |
27
+ # +-- server Ractor ----------------------+ +-- connection Ractor (1 Ractor / connection) --+--+
28
+ # | server.run(socket) | | Stream.new(tx, stream_id, proc) | |
29
+ # | r.send(io, move: true) ------------+------>| Ractor.new(tx, stream_id, proc) -----------+ |
30
+ # +---------------------------------------+ +--------------------------------------------------+
31
+ DB_PORT = Ractor::Port.new
32
+
33
+ module DB
34
+ class Error < StandardError; end
35
+
36
+ # @param sql [String]
37
+ # @param binds [Array<Object>]
38
+ #
39
+ # @raise [DB::Error] if the query fails on the main Ractor
40
+ #
41
+ # @return [Array<Hash>] rows
42
+ # @return [Integer] changes
43
+ def self.execute(sql, *binds)
44
+ reply = Ractor::Port.new
45
+ DB_PORT.send([sql, binds, reply])
46
+ case reply.receive
47
+ in [:ok, rows, changes]
48
+ [rows, changes]
49
+ in [:error, klass, message]
50
+ raise Error, "#{klass}: #{message}"
51
+ end
52
+ end
53
+
54
+ # @param path [String]
55
+ def self.serve(path)
56
+ db = SQLite3::Database.new(path)
57
+ db.results_as_hash = true
58
+ db.execute('CREATE TABLE IF NOT EXISTS users (id INTEGER PRIMARY KEY, name TEXT);')
59
+
60
+ loop do
61
+ sql, binds, reply = DB_PORT.receive
62
+ begin
63
+ rows = db.execute(sql, binds)
64
+ reply.send([:ok, rows, db.changes])
65
+ rescue StandardError => e
66
+ reply.send([:error, e.class.name, e.message])
67
+ end
68
+ end
69
+ end
70
+ end
71
+
72
+ module App
73
+ # @param req [Biryani::HTTP::Request]
74
+ # @param res [Biryani::HTTP::Response]
75
+ def self.call(req, res)
76
+ case req.method
77
+ when 'POST'
78
+ do_post(req, res)
79
+ when 'GET'
80
+ do_get(req, res)
81
+ when 'PUT'
82
+ do_put(req, res)
83
+ when 'DELETE'
84
+ do_delete(req, res)
85
+ else
86
+ respond(res, 405, { error: 'HTTP Method not allowed' })
87
+ end
88
+ end
89
+
90
+ def self.do_post(req, res)
91
+ name = parse_name(req.content)
92
+ return respond(res, 400, { error: 'not found `name`' }) if name.nil?
93
+
94
+ DB.execute('INSERT INTO users (name) VALUES (?);', name)
95
+ respond(res, 201, { status: 'created' })
96
+ end
97
+
98
+ def self.do_get(req, res)
99
+ if req.uri.path == '/'
100
+ users, = DB.execute('SELECT id, name FROM users ORDER BY id;')
101
+ return respond(res, 200, users)
102
+ end
103
+
104
+ users, = DB.execute('SELECT id, name FROM users WHERE id = ?;', user_id(req))
105
+ return respond(res, 404, { error: 'not found' }) if users.empty?
106
+
107
+ respond(res, 200, users.first)
108
+ end
109
+
110
+ def self.do_put(req, res)
111
+ name = parse_name(req.content)
112
+ return respond(res, 400, { error: 'not found `name`' }) if name.nil?
113
+
114
+ _, changes = DB.execute('UPDATE users SET name = ? WHERE id = ?;', name, user_id(req))
115
+ return respond(res, 404, { error: 'not found' }) if changes.zero?
116
+
117
+ respond(res, 200, { status: 'ok' })
118
+ end
119
+
120
+ def self.do_delete(req, res)
121
+ _, changes = DB.execute('DELETE FROM users WHERE id = ?;', user_id(req))
122
+ return respond(res, 404, { error: 'not found' }) if changes.zero?
123
+
124
+ respond(res, 200, { status: 'ok' })
125
+ end
126
+
127
+ # @param content [String]
128
+ #
129
+ # @return [String, nil]
130
+ def self.parse_name(content)
131
+ user = JSON.parse(content)
132
+ user['name']&.to_s if user.is_a?(Hash)
133
+ rescue JSON::ParserError
134
+ nil
135
+ end
136
+
137
+ # @param req [Biryani::HTTP::Request]
138
+ #
139
+ # @return [Integer]
140
+ def self.user_id(req)
141
+ req.uri.path.split('/').reject(&:empty?).first.to_i
142
+ end
143
+
144
+ def self.respond(res, status, body)
145
+ res.status = status
146
+ res.fields['content-type'] = 'application/json'
147
+ res.content = JSON.generate(body)
148
+ end
149
+ end
150
+
151
+ port = ARGV[0] || 8888
152
+ path = ARGV[1] || '/tmp/sqlite3.db'
153
+ socket = TCPServer.new(port)
154
+
155
+ server = Biryani::Server.new(
156
+ # @param req [Biryani::HTTP::Request]
157
+ # @param res [Biryani::HTTP::Response]
158
+ Ractor.shareable_proc do |req, res|
159
+ App.call(req, res)
160
+ end
161
+ )
162
+ r = Ractor.new(server) { |s| s.run(Ractor.receive) }
163
+ r.send(socket, move: true)
164
+ DB.serve(path)
165
+
166
+ # $ bundle exec ruby sqlite3.rb
167
+ # $ curl -v --http2-prior-knowledge http://localhost:8888 -X POST -H "Content-Type: application/json" -d '{"name":"Alice","age":18}'
168
+ # $ curl -v --http2-prior-knowledge http://localhost:8888/
169
+ # $ curl -v --http2-prior-knowledge http://localhost:8888/1 -X PUT -H "Content-Type: application/json" -d '{"name":"Bob","age":20}'
170
+ # $ curl -v --http2-prior-knowledge http://localhost:8888/
171
+ # $ curl -v --http2-prior-knowledge http://localhost:8888/1 -X DELETE
@@ -0,0 +1,47 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8" />
5
+ <title>svg tiles</title>
6
+ <style>
7
+ #tiles {
8
+ display: grid;
9
+ grid-template-columns: repeat(15, 32px);
10
+ }
11
+ #tiles img {
12
+ display: block;
13
+ width: 32px;
14
+ height: 32px;
15
+ }
16
+ </style>
17
+ </head>
18
+ <body>
19
+ <p>
20
+ <a href="?latency=0">0 ms</a> | <a href="?latency=100">100 ms</a> |
21
+ <a href="?latency=500">500 ms</a>
22
+ </p>
23
+ <p id="result">loading...</p>
24
+ <div id="tiles"></div>
25
+ <script>
26
+ const GRID = 15;
27
+ const latency =
28
+ new URLSearchParams(location.search).get("latency") || "0";
29
+ const tiles = document.getElementById("tiles");
30
+ for (let y = 0; y < GRID; y++) {
31
+ for (let x = 0; x < GRID; x++) {
32
+ const img = document.createElement("img");
33
+ img.src = `/tile/${x}/${y}.svg?latency=${latency}`;
34
+ tiles.appendChild(img);
35
+ }
36
+ }
37
+
38
+ window.addEventListener("load", () => {
39
+ const protocol =
40
+ performance.getEntriesByType("navigation")[0].nextHopProtocol;
41
+ const time = Math.round(performance.now());
42
+ document.getElementById("result").textContent =
43
+ `${protocol}, latency: ${latency} ms / tile, loaded in ${time} ms`;
44
+ });
45
+ </script>
46
+ </body>
47
+ </html>
data/example/tiles.rb ADDED
@@ -0,0 +1,85 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ $LOAD_PATH << "#{__dir__}/../lib"
4
+
5
+ require 'optparse'
6
+ require 'uri'
7
+
8
+ INDEX_HTML = File.read("#{__dir__}/tiles/index.html").freeze
9
+ TILE_SIZE = 32
10
+
11
+ module App
12
+ def self.call(path, query)
13
+ case path
14
+ when '/', '/index.html'
15
+ [200, 'text/html; charset=utf-8', INDEX_HTML]
16
+ when %r{^/tile/(\d+)/(\d+)\.svg$}
17
+ do_tile(Regexp.last_match(1).to_i, Regexp.last_match(2).to_i, latency(query))
18
+ else
19
+ [404, 'text/plain', 'not found']
20
+ end
21
+ end
22
+
23
+ def self.do_tile(x, y, latency)
24
+ sleep(latency / 1000.0)
25
+ hue = (x + y) * 12 % 360
26
+ svg = <<~SVG
27
+ <svg xmlns="http://www.w3.org/2000/svg" width="#{TILE_SIZE}" height="#{TILE_SIZE}">
28
+ <rect width="#{TILE_SIZE}" height="#{TILE_SIZE}" fill="hsl(#{hue}, 70%, 70%)"/>
29
+ </svg>
30
+ SVG
31
+ [200, 'image/svg+xml', svg]
32
+ end
33
+
34
+ def self.latency(query)
35
+ query = URI.decode_www_form(query || '').to_h
36
+ query['latency'].to_i.clamp(0, 500)
37
+ end
38
+ end
39
+
40
+ http1 = false
41
+ opt = OptionParser.new
42
+ opt.banner = 'Usage: tiles.rb [--http2 | --http1.1] [port]'
43
+ opt.on('--http2', 'serve over HTTP/2 with biryani (default)') { http1 = false }
44
+ opt.on('--http1.1', 'serve over HTTP/1.1 with WEBrick') { http1 = true }
45
+ opt.parse!
46
+ port = ARGV[0] || 8888
47
+
48
+ if http1
49
+ require 'webrick'
50
+
51
+ server = WEBrick::HTTPServer.new(Port: port)
52
+ server.mount_proc('/') do |req, res|
53
+ status, content_type, content = App.call(req.path, req.query_string)
54
+ res.status = status
55
+ res['content-type'] = content_type
56
+ res['cache-control'] = 'no-store'
57
+ res.body = content
58
+ end
59
+ trap('INT') { server.shutdown }
60
+ server.start
61
+ else
62
+ require 'socket'
63
+ require 'biryani'
64
+
65
+ socket = TCPServer.new(port)
66
+ server = Biryani::Server.new(
67
+ # @param req [Biryani::HTTP::Request]
68
+ # @param res [Biryani::HTTP::Response]
69
+ Ractor.shareable_proc do |req, res|
70
+ status, content_type, content = App.call(req.uri.path, req.uri.query)
71
+ res.status = status
72
+ res.fields['content-type'] = content_type
73
+ res.content = content
74
+ res.fields['cache-control'] = 'no-store'
75
+ end
76
+ )
77
+ server.run(socket)
78
+ end
79
+
80
+ # $ bundle exec ruby tiles.rb --http1.1 8889
81
+ # $ open http://localhost:8889/?latency=500
82
+ #
83
+ # $ bundle exec ruby tiles.rb --http2
84
+ # $ nginx -p "$PWD" -c nginx.conf -g 'daemon off; pid /dev/null; error_log stderr;'
85
+ # $ open https://localhost:4433/?latency=500
@@ -14,8 +14,10 @@ module Biryani
14
14
  CONNECTION_PREFACE_LENGTH = CONNECTION_PREFACE.length
15
15
  DEFAULT_HEADER_TABLE_SIZE = 4_096
16
16
  INITIAL_CONNECTION_WINDOW_SIZE = 65_535
17
+ LINGER_TIMEOUT = 5
17
18
 
18
- private_constant :CONNECTION_PREFACE, :CONNECTION_PREFACE_LENGTH, :DEFAULT_HEADER_TABLE_SIZE, :INITIAL_CONNECTION_WINDOW_SIZE
19
+ private_constant :CONNECTION_PREFACE, :CONNECTION_PREFACE_LENGTH, :DEFAULT_HEADER_TABLE_SIZE, :INITIAL_CONNECTION_WINDOW_SIZE,
20
+ :LINGER_TIMEOUT
19
21
  Ractor.make_shareable(CONNECTION_PREFACE)
20
22
  Ractor.make_shareable(CONNECTION_PREFACE_LENGTH)
21
23
 
@@ -47,32 +49,33 @@ module Biryani
47
49
 
48
50
  recv_loop(io.clone)
49
51
  select_loop(io)
52
+ rescue *PEER_CLOSED_ERRORS
53
+ # peer has already closed the connection
50
54
  rescue StandardError => e
51
55
  puts e.backtrace
52
- self.class.do_send(io, Frame::Goaway.new(@streams_ctx.last_stream_id, ErrorCode::INTERNAL_ERROR, 'internal error'), true)
56
+ Biryani.ignore_peer_closed do
57
+ self.class.do_send(io, Frame::Goaway.new(@streams_ctx.last_stream_id, ErrorCode::INTERNAL_ERROR, 'internal error'), true)
58
+ end
53
59
  ensure
54
- io.close_write
60
+ Biryani.ignore_peer_closed { io.close_write }
55
61
  end
56
62
 
57
63
  # @param io [IO]
58
64
  def recv_loop(io)
59
- Ractor.new(io, @port) do |io_, port_|
60
- loop do
61
- obj = Frame.read(io_)
62
- if obj.nil?
63
- port_.send(:eof, move: true)
64
- break
65
- end
66
-
67
- port_.send([:frame, obj], move: true)
68
- end
65
+ r = Ractor.new(@port) do |port_|
66
+ io_ = Ractor.receive
67
+ Connection.recv_frames(io_, port_)
68
+ ensure
69
+ Biryani.ignore_peer_closed { io_&.close }
69
70
  end
71
+ r.send(io, move: true)
70
72
  end
71
73
 
72
74
  # @param io [IO]
73
75
  def select_loop(io)
74
76
  loop do
75
- break if @recv_done && @streams_ctx.empty?
77
+ # After EOF, no frames arrive from the peer, so only half_closed_remote streams can still progress: by the handler's response.
78
+ break if closed? || (@recv_done && @streams_ctx.none_half_closed_remote?)
76
79
 
77
80
  case @port.receive
78
81
  in :eof
@@ -82,8 +85,6 @@ module Biryani
82
85
  in [:response, res, stream_id]
83
86
  handle_response(io, res, stream_id)
84
87
  end
85
-
86
- break if closed?
87
88
  end
88
89
  end
89
90
 
@@ -148,7 +149,8 @@ module Biryani
148
149
  return [] if obj.nil?
149
150
 
150
151
  settings_ack = obj
151
- [settings_ack]
152
+ max_frame_size = @peer_settings[SettingsID::SETTINGS_MAX_FRAME_SIZE]
153
+ [settings_ack] + @data_buffer.take!(@send_window, @streams_ctx, max_frame_size)
152
154
  when FrameType::PING
153
155
  obj = self.class.handle_ping(frame)
154
156
  return [] if obj.nil?
@@ -185,7 +187,7 @@ module Biryani
185
187
  return [ConnectionError.new(ErrorCode::PROTOCOL_ERROR, "invalid frame type #{format('0x%02x', typ)} for stream identifier #{format('0x%02x', stream_id)}")] \
186
188
  if [FrameType::SETTINGS, FrameType::PING, FrameType::GOAWAY].include?(typ)
187
189
 
188
- max_streams = @peer_settings[SettingsID::SETTINGS_MAX_CONCURRENT_STREAMS]
190
+ max_streams = @settings[SettingsID::SETTINGS_MAX_CONCURRENT_STREAMS]
189
191
  send_initial_window_size = @peer_settings[SettingsID::SETTINGS_INITIAL_WINDOW_SIZE]
190
192
  recv_initial_window_size = @settings[SettingsID::SETTINGS_INITIAL_WINDOW_SIZE]
191
193
  obj = self.class.transition_stream_state_recv(frame, @streams_ctx, @data_buffer, stream_id, max_streams, send_initial_window_size, recv_initial_window_size)
@@ -235,6 +237,14 @@ module Biryani
235
237
  # @param res [HTTP::Response]
236
238
  # @param stream_id [Integer]
237
239
  def handle_response(io, res, stream_id)
240
+ ctx = @streams_ctx[stream_id]
241
+ # The RST_STREAM frame fully terminates the referenced stream and
242
+ # causes it to enter the "closed" state. After receiving a RST_STREAM
243
+ # on a stream, the receiver MUST NOT send additional frames for that
244
+ # stream, except for PRIORITY.
245
+ # https://datatracker.ietf.org/doc/html/rfc9113#section-6.4-5
246
+ return if ctx.nil? || ctx.closed?
247
+
238
248
  fragment, data = self.class.http_response(res, @encoder)
239
249
  max_frame_size = @peer_settings[SettingsID::SETTINGS_MAX_FRAME_SIZE]
240
250
  self.class.send_headers(io, stream_id, fragment, data.empty?, max_frame_size, @streams_ctx)
@@ -359,14 +369,44 @@ module Biryani
359
369
  ConnectionError.new(ErrorCode::PROTOCOL_ERROR, 'invalid connection preface') if s != CONNECTION_PREFACE
360
370
  end
361
371
 
372
+ # @param io [IO]
373
+ # @param port [Ractor::Port]
374
+ def self.recv_frames(io, port)
375
+ loop do
376
+ obj = Frame.read(io)
377
+ break if obj.nil?
378
+
379
+ port.send([:frame, obj], move: true)
380
+ rescue Ractor::ClosedError
381
+ # the connection has finished before the peer closes
382
+ return linger(io)
383
+ end
384
+
385
+ port.send(:eof, move: true)
386
+ rescue Ractor::ClosedError
387
+ # the connection has finished after the peer closes
388
+ end
389
+
390
+ # Lingering close: discard incoming data until EOF so that close does not send RST.
391
+ #
392
+ # @param io [IO]
393
+ def self.linger(io)
394
+ Timeout.timeout(LINGER_TIMEOUT) do
395
+ loop { io.readpartial(16_384) }
396
+ end
397
+ rescue *PEER_CLOSED_ERRORS, Timeout::Error
398
+ # EOF, timeout, connection reset, etc.
399
+ end
400
+
362
401
  # @param stream_id [Integer]
363
402
  # @param data [String]
364
403
  # @param recv_window [Window]
365
404
  # @param streams_ctx [StreamsContext]
366
405
  # @param decoder [Decoder]
367
406
  #
368
- # @return [Array<WindowUpdate>, ConnectionError]
407
+ # @return [Array<WindowUpdate>, ConnectionError, StreamError]
369
408
  # rubocop: disable Metrics/AbcSize
409
+ # rubocop: disable Metrics/CyclomaticComplexity
370
410
  def self.handle_data(stream_id, data, recv_window, streams_ctx, decoder)
371
411
  ctx = streams_ctx[stream_id]
372
412
  return ConnectionError.new(ErrorCode::FLOW_CONTROL_ERROR, 'DATA Frame length exceeds flow-control window size') \
@@ -374,7 +414,8 @@ module Biryani
374
414
 
375
415
  ctx.content << data
376
416
  if ctx.half_closed_remote?
377
- obj = http_request(ctx.fragment, ctx.content, decoder)
417
+ obj = http_request(stream_id, ctx.fragment, ctx.content, decoder)
418
+ ctx << nil if Biryani.err?(obj) # terminate the Stream Ractor waiting for a request
378
419
  return obj if Biryani.err?(obj)
379
420
 
380
421
  ctx << obj
@@ -386,16 +427,18 @@ module Biryani
386
427
  window_updates
387
428
  end
388
429
  # rubocop: enable Metrics/AbcSize
430
+ # rubocop: enable Metrics/CyclomaticComplexity
389
431
 
390
432
  # @param headers [Headers]
391
433
  # @param ctx [StreamContext]
392
434
  # @param decoder [Decoder]
393
435
  #
394
- # @return [nil, ConnectionError]
436
+ # @return [nil, ConnectionError, StreamError]
395
437
  def self.handle_headers(headers, ctx, decoder)
396
438
  ctx.fragment << headers.fragment
397
439
  if ctx.half_closed_remote?
398
- obj = http_request(ctx.fragment, ctx.content, decoder)
440
+ obj = http_request(headers.stream_id, ctx.fragment, ctx.content, decoder)
441
+ ctx << nil if Biryani.err?(obj) # terminate the Stream Ractor waiting for a request
399
442
  return obj if Biryani.err?(obj)
400
443
 
401
444
  ctx << obj
@@ -463,21 +506,23 @@ module Biryani
463
506
  nil
464
507
  end
465
508
 
509
+ # @param stream_id [Integer]
466
510
  # @param fragment [String]
467
511
  # @param content [String]
468
512
  # @param decoder [Decoder]
469
513
  #
470
- # @return [HTTP::Request, ConnectionError]
471
- def self.http_request(fragment, content, decoder)
472
- obj = decoder.decode(fragment)
473
- return obj if Biryani.err?(obj)
474
-
475
- fields = obj
514
+ # @return [HTTP::Request, ConnectionError, StreamError]
515
+ def self.http_request(stream_id, fragment, content, decoder)
516
+ fields = decoder.decode(fragment)
476
517
  builder = HTTP::RequestBuilder.new
477
- err = builder.fields(fields)
478
- return err unless err.nil?
479
-
518
+ builder.fields(fields)
480
519
  builder.build(content)
520
+ rescue HPACK::Error::Error => e
521
+ # https://datatracker.ietf.org/doc/html/rfc9113#section-4.3-3
522
+ ConnectionError.new(ErrorCode::COMPRESSION_ERROR, e.message)
523
+ rescue HTTP::Error::MalformedRequestError => e
524
+ # https://datatracker.ietf.org/doc/html/rfc9113#section-8.1.1-3
525
+ StreamError.new(ErrorCode::PROTOCOL_ERROR, stream_id, e.message)
481
526
  end
482
527
 
483
528
  # @param res [HTTP::Response]
data/lib/biryani/frame.rb CHANGED
@@ -157,10 +157,13 @@ module Biryani
157
157
 
158
158
  payload_length, f_type, flags, stream_id = read_header(s)
159
159
  payload = io.read(payload_length)
160
- return ConnectionError.new(ErrorCode::PROTOCOL_ERROR, 'invalid frame') if payload.bytesize != payload_length
160
+ return ConnectionError.new(ErrorCode::PROTOCOL_ERROR, 'invalid frame') if payload.nil? || payload.bytesize != payload_length
161
161
  return Frame::Unknown.new(f_type, flags, stream_id, payload) unless FRAME_MAP.key?(f_type)
162
162
 
163
163
  FRAME_MAP[f_type].read(payload, flags, stream_id)
164
+ rescue *PEER_CLOSED_ERRORS
165
+ # the peer has closed the connection (e.g. ECONNRESET): treat it as EOF
166
+ nil
164
167
  rescue StandardError
165
168
  ConnectionError.new(ErrorCode::FRAME_SIZE_ERROR, 'invalid frame')
166
169
  end
@@ -8,6 +8,8 @@ module Biryani
8
8
 
9
9
  # @param s [String]
10
10
  #
11
+ # @raise [HPACK::Error::Error]
12
+ #
11
13
  # @return [Array]
12
14
  def decode(s)
13
15
  Fields.decode(s.force_encoding(Encoding::ASCII_8BIT), @dynamic_table)
@@ -51,7 +51,7 @@ module Biryani
51
51
  #
52
52
  # @return [Array] [name, value]
53
53
  def entry_at(index)
54
- raise Error::HPACKDecodeError if index.zero? || index > STATIC_TABLE_SIZE + count_entries
54
+ raise Error::HPACKDecodeError, "invalid index #{index}" if index.zero? || index > STATIC_TABLE_SIZE + count_entries
55
55
 
56
56
  if index <= STATIC_TABLE_SIZE
57
57
  STATIC_TABLE[index - 1]
@@ -215,10 +215,11 @@ module Biryani
215
215
  # @return [nil]
216
216
  # @return [Integer]
217
217
  def self.decode_dynamic_table_size_update(io, cursor, dynamic_table)
218
- raise Error::HPACKDecodeError unless cursor.zero? || (io.get_value(:U8, 0) & 0b00100000).positive? && Integer.decode(io, 5, 0)[1] == cursor
218
+ raise Error::HPACKDecodeError, 'misplaced dynamic table size update' \
219
+ unless cursor.zero? || (io.get_value(:U8, 0) & 0b00100000).positive? && Integer.decode(io, 5, 0)[1] == cursor
219
220
 
220
221
  max_size, c = Integer.decode(io, 5, cursor)
221
- raise Error::HPACKDecodeError if max_size > dynamic_table.limit
222
+ raise Error::HPACKDecodeError, 'dynamic table size update exceeds the limit' if max_size > dynamic_table.limit
222
223
 
223
224
  dynamic_table.chomp!(max_size)
224
225
  [nil, c]
@@ -550,7 +550,7 @@ module Biryani
550
550
  (length * 8).times do |i|
551
551
  bits_len += 1
552
552
  bits += 1 if (bytes[i / 8] & (1 << 7 - (i % 8))).positive?
553
- raise Error::HuffmanDecodeError if bits == EOS
553
+ raise Error::HuffmanDecodeError, 'EOS in huffman-encoded string' if bits == EOS
554
554
 
555
555
  if (chr = DECODE_TABLE[[bits, bits_len]])
556
556
  res << chr
@@ -560,7 +560,7 @@ module Biryani
560
560
  bits <<= 1
561
561
  end
562
562
  end
563
- raise Error::HuffmanDecodeError if bits_len.positive? && bits != (2 << bits_len) - 2 || bits_len >= 8
563
+ raise Error::HuffmanDecodeError, 'invalid huffman padding' if bits_len.positive? && bits != (2 << bits_len) - 2 || bits_len >= 8
564
564
 
565
565
  res
566
566
  end
@@ -1,6 +1,10 @@
1
1
  module Biryani
2
2
  module HPACK
3
3
  module Integer
4
+ # https://datatracker.ietf.org/doc/html/rfc7541#section-7.4
5
+ MAX_DECODED_INTEGER = (1 << 32) - 1
6
+ private_constant :MAX_DECODED_INTEGER
7
+
4
8
  # if I < 2^N - 1, encode I on N bits
5
9
  # else
6
10
  # encode (2^N - 1) on N bits
@@ -44,10 +48,14 @@ module Biryani
44
48
  # @param cursor [Integer]
45
49
  # @param n [Integer]
46
50
  #
51
+ # @raise [HPACKDecodeError]
52
+ #
47
53
  # @return [Integer]
48
54
  # @return [Integer]
49
55
  def self.decode(io, n, cursor)
50
56
  limit = (1 << n) - 1
57
+ raise Error::HPACKDecodeError, 'integer exceeds field block' if cursor >= io.size
58
+
51
59
  h = io.get_value(:U8, cursor)
52
60
  return [h & limit, cursor + 1] if (h & limit) != limit
53
61
 
@@ -55,8 +63,11 @@ module Biryani
55
63
  c = cursor + 1
56
64
  i = 0
57
65
  loop do
66
+ raise Error::HPACKDecodeError, 'integer exceeds field block' if c + i >= io.size
67
+
58
68
  byte = io.get_value(:U8, c + i)
59
69
  res += (byte & 127) << (i * 7)
70
+ raise Error::HPACKDecodeError, 'integer overflow' if res > MAX_DECODED_INTEGER
60
71
 
61
72
  break if (byte & 128).zero?
62
73
 
@@ -26,11 +26,18 @@ module Biryani
26
26
  # @param io [IO::Buffer]
27
27
  # @param cursor [Integer]
28
28
  #
29
+ # @raise [HPACKDecodeError]
30
+ # @raise [HuffmanDecodeError]
31
+ #
29
32
  # @return [String]
30
33
  # @return [Integer]
31
34
  def self.decode(io, cursor)
35
+ raise Error::HPACKDecodeError, 'string exceeds field block' if cursor >= io.size
36
+
32
37
  h = (io.get_value(:U8, cursor) & 0b10000000).positive?
33
38
  len, c = Integer.decode(io, 7, cursor)
39
+ raise Error::HPACKDecodeError, 'string exceeds field block' if c + len > io.size
40
+
34
41
  return [Huffman.decode(io, c, len), c + len] if h
35
42
 
36
43
  [io.get_string(c, len), c + len]
@@ -4,7 +4,9 @@ module Biryani
4
4
  # Generic error, common for all classes under Biryani::HTTP::Error module.
5
5
  class Error < StandardError; end
6
6
 
7
- class InvalidHTTPResponseError < Error; end
7
+ class InvalidResponseError < Error; end
8
+
9
+ class MalformedRequestError < Error; end
8
10
  end
9
11
  end
10
12
  end
@@ -25,6 +25,8 @@ module Biryani
25
25
  class RequestBuilder
26
26
  PSEUDO_HEADER_FIELDS = [':authority', ':method', ':path', ':scheme'].freeze
27
27
  Ractor.make_shareable(PSEUDO_HEADER_FIELDS)
28
+ REQUIRED_PSEUDO_HEADER_FIELDS = [':method', ':path', ':scheme'].freeze
29
+ Ractor.make_shareable(REQUIRED_PSEUDO_HEADER_FIELDS)
28
30
 
29
31
  def initialize
30
32
  @h = {}
@@ -33,23 +35,21 @@ module Biryani
33
35
  # @param name [String]
34
36
  # @param value [String]
35
37
  #
36
- # @return [nil, ConnectioError]
38
+ # @raise [Error::MalformedRequestError]
37
39
  # rubocop: disable Metrics/AbcSize
38
40
  # rubocop: disable Metrics/CyclomaticComplexity
39
41
  # rubocop: disable Metrics/PerceivedComplexity
40
42
  def field(name, value)
41
- return ConnectionError.new(ErrorCode::PROTOCOL_ERROR, 'field name has uppercase letter') if name.downcase != name
42
- return ConnectionError.new(ErrorCode::PROTOCOL_ERROR, 'unknown pseudo-header field name') if name[0] == ':' && !PSEUDO_HEADER_FIELDS.include?(name)
43
- return ConnectionError.new(ErrorCode::PROTOCOL_ERROR, 'appear pseudo-header fields after regular fields') if name[0] == ':' && @h.any? { |name_, _| name_[0] != ':' }
44
- return ConnectionError.new(ErrorCode::PROTOCOL_ERROR, 'duplicated pseudo-header fields') if PSEUDO_HEADER_FIELDS.include?(name) && @h.key?(name)
45
- return ConnectionError.new(ErrorCode::PROTOCOL_ERROR, "invalid `#{name}` field") if PSEUDO_HEADER_FIELDS.include?(name) && value.empty?
46
- return ConnectionError.new(ErrorCode::PROTOCOL_ERROR, 'connection-specific field is forbidden') if name == 'connection'
47
- return ConnectionError.new(ErrorCode::PROTOCOL_ERROR, '`TE` field has a value other than `trailers`') if name == 'te' && value != 'trailers'
43
+ raise Error::MalformedRequestError, 'field name has uppercase letter' if name.downcase != name
44
+ raise Error::MalformedRequestError, 'unknown pseudo-header field name' if name[0] == ':' && !PSEUDO_HEADER_FIELDS.include?(name)
45
+ raise Error::MalformedRequestError, 'appear pseudo-header fields after regular fields' if name[0] == ':' && @h.any? { |name_, _| name_[0] != ':' }
46
+ raise Error::MalformedRequestError, 'duplicated pseudo-header fields' if PSEUDO_HEADER_FIELDS.include?(name) && @h.key?(name)
47
+ raise Error::MalformedRequestError, "invalid `#{name}` field" if PSEUDO_HEADER_FIELDS.include?(name) && value.empty?
48
+ raise Error::MalformedRequestError, 'connection-specific field is forbidden' if name == 'connection'
49
+ raise Error::MalformedRequestError, '`TE` field has a value other than `trailers`' if name == 'te' && value != 'trailers'
48
50
 
49
51
  @h[name] = [] unless @h.key?(name)
50
52
  @h[name] << value
51
-
52
- nil
53
53
  end
54
54
  # rubocop: enable Metrics/AbcSize
55
55
  # rubocop: enable Metrics/CyclomaticComplexity
@@ -57,19 +57,18 @@ module Biryani
57
57
 
58
58
  # @param arr [Array]
59
59
  #
60
- # @return [nil, ConnectioError]
60
+ # @raise [Error::MalformedRequestError]
61
61
  def fields(arr)
62
62
  arr.each do |name, value|
63
- err = field(name, value)
64
- return err unless err.nil?
63
+ field(name, value)
65
64
  end
66
-
67
- nil
68
65
  end
69
66
 
70
67
  # @param s [String]
71
68
  #
72
69
  # @return [Request]
70
+ #
71
+ # @raise [Error::MalformedRequestError]
73
72
  def build(s)
74
73
  # `Ractor.send(req, move: true)` moves entries in HPACK::DynamicTable; therefore, a `dup` call is required.
75
74
  h = @h.transform_values { |x| x.map(&:dup) }
@@ -79,19 +78,48 @@ module Biryani
79
78
  # @param h [Hash<String, Array<String>>]
80
79
  # @param s [String]
81
80
  #
82
- # @return [Request, ConnectionError]
81
+ # @return [Request]
82
+ #
83
+ # @raise [Error::MalformedRequestError]
83
84
  def self.build(h, s)
84
- return ConnectionError.new(ErrorCode::PROTOCOL_ERROR, 'missing pseudo-header fields') unless PSEUDO_HEADER_FIELDS.all? { |x| h.key?(x) }
85
- return ConnectionError.new(ErrorCode::PROTOCOL_ERROR, 'invalid content-length') if h.key?('content-length') && !s.empty? && s.length != h['content-length'][0].to_i
85
+ # https://datatracker.ietf.org/doc/html/rfc9113#section-8.3.1-3
86
+ raise Error::MalformedRequestError, 'missing pseudo-header fields' unless REQUIRED_PSEUDO_HEADER_FIELDS.all? { |x| h.key?(x) }
87
+ raise Error::MalformedRequestError, 'invalid content-length' if h.key?('content-length') && !s.empty? && s.length != h['content-length'][0].to_i
86
88
 
89
+ authority = authority(h)
87
90
  scheme = h[':scheme'][0]
88
- domain = h[':authority'][0]
89
91
  path = h[':path'][0]
90
- uri = URI("#{scheme}://#{domain}#{path}")
92
+ uri = URI("#{scheme}://#{authority}#{path}")
91
93
  method = h[':method'][0].upcase
92
94
  h['cookie'] = [h['cookie'].join('; ')] if h.key?('cookie')
93
95
  Request.new(method, uri, h.except(*PSEUDO_HEADER_FIELDS), s)
94
96
  end
97
+
98
+ # @param h [Hash<String, Array<String>>]
99
+ #
100
+ # @return [String]
101
+ #
102
+ # @raise [Error::MalformedRequestError]
103
+ # rubocop: disable Metrics/CyclomaticComplexity
104
+ # rubocop: disable Metrics/PerceivedComplexity
105
+ def self.authority(h)
106
+ host = h['host']
107
+ # https://datatracker.ietf.org/doc/html/rfc9110#section-5.3-3
108
+ raise Error::MalformedRequestError, 'duplicated host fields' if !host.nil? && host.length > 1
109
+
110
+ authority = h[':authority']&.first
111
+ # https://datatracker.ietf.org/doc/html/rfc9113#section-8.3.1-2.3.3
112
+ raise Error::MalformedRequestError, 'mismatched :authority and host fields' if !authority.nil? && !host.nil? && authority.downcase != host[0].downcase
113
+
114
+ s = authority || host&.first
115
+ # https://datatracker.ietf.org/doc/html/rfc9110#section-4.2.1-4
116
+ # https://datatracker.ietf.org/doc/html/rfc9110#section-4.2.2-4
117
+ raise Error::MalformedRequestError, 'missing :authority and host fields' if s.nil? || s.empty?
118
+
119
+ s
120
+ end
121
+ # rubocop: enable Metrics/CyclomaticComplexity
122
+ # rubocop: enable Metrics/PerceivedComplexity
95
123
  end
96
124
  end
97
125
  end
@@ -15,14 +15,14 @@ module Biryani
15
15
  @content = content
16
16
  end
17
17
 
18
- # @raise [InvalidHTTPResponseError]
18
+ # @raise [InvalidResponseError]
19
19
  # rubocop: disable Metrics/CyclomaticComplexity
20
20
  def validate
21
21
  # https://datatracker.ietf.org/doc/html/rfc9113#section-8.2.1
22
- raise Error::InvalidHTTPResponseError, 'invalid HTTP status' if @status < 100 || @status >= 600
23
- raise Error::InvalidHTTPResponseError, 'HTTP field name contains invalid characters' if (@fields.keys.join.downcase.bytes.uniq & FORBIDDEN_KEY_CHARS).any?
24
- raise Error::InvalidHTTPResponseError, 'HTTP field value contains NUL, LF or CR' if (@fields.values.join.bytes.uniq & FORBIDDEN_VALUE_CHARS).any?
25
- raise Error::InvalidHTTPResponseError, 'HTTP field value starts/ends with SP or HTAB' if @fields.values.filter { |s| s.start_with?("\t", ' ') || s.end_with?("\t", ' ') }.any?
22
+ raise Error::InvalidResponseError, 'invalid HTTP status' if @status < 100 || @status >= 600
23
+ raise Error::InvalidResponseError, 'HTTP field name contains invalid characters' if (@fields.keys.join.downcase.bytes.uniq & FORBIDDEN_KEY_CHARS).any?
24
+ raise Error::InvalidResponseError, 'HTTP field value contains NUL, LF or CR' if (@fields.values.join.bytes.uniq & FORBIDDEN_VALUE_CHARS).any?
25
+ raise Error::InvalidResponseError, 'HTTP field value starts/ends with SP or HTAB' if @fields.values.filter { |s| s.start_with?("\t", ' ') || s.end_with?("\t", ' ') }.any?
26
26
  end
27
27
  # rubocop: enable Metrics/CyclomaticComplexity
28
28
 
@@ -8,11 +8,15 @@ module Biryani
8
8
  # @param socket [Socket]
9
9
  def run(socket)
10
10
  loop do
11
- Ractor.new(socket.accept, @proc) do |io, proc|
11
+ io = socket.accept
12
+ r = Ractor.new(@proc) do |proc|
13
+ io_ = Ractor.receive
12
14
  conn = Connection.new(proc)
13
- conn.serve(io)
14
- io.close
15
+ conn.serve(io_)
16
+ ensure
17
+ Biryani.ignore_peer_closed { io_&.close }
15
18
  end
19
+ r.send(io, move: true)
16
20
  end
17
21
  end
18
22
  end
@@ -18,7 +18,11 @@ module Biryani
18
18
  res = HTTP::Response.internal_server_error
19
19
  end
20
20
 
21
- tx.send([:response, res, stream_id], move: true)
21
+ begin
22
+ tx.send([:response, res, stream_id], move: true)
23
+ rescue Ractor::ClosedError
24
+ # the Connection has already finished, so discard the response
25
+ end
22
26
  end
23
27
  end
24
28
  end
@@ -50,6 +50,11 @@ module Biryani
50
50
  @h.values.filter(&:active?).length
51
51
  end
52
52
 
53
+ # @return [Boolean]
54
+ def none_half_closed_remote?
55
+ @h.each_value.none?(&:half_closed_remote?)
56
+ end
57
+
53
58
  # @return [Integer, nil]
54
59
  def receiving_continuation_stream_id
55
60
  @h.find { |_, ctx| ctx.receiving_continuation? }&.first
data/lib/biryani/utils.rb CHANGED
@@ -20,4 +20,13 @@ module Biryani
20
20
  obj
21
21
  end
22
22
  end
23
+
24
+ # Errors raised when the peer has already closed the connection (EOF, EPIPE, ECONNRESET, etc.)
25
+ PEER_CLOSED_ERRORS = Ractor.make_shareable([IOError, SystemCallError])
26
+
27
+ def self.ignore_peer_closed
28
+ yield
29
+ rescue *PEER_CLOSED_ERRORS
30
+ nil
31
+ end
23
32
  end
@@ -1,3 +1,3 @@
1
1
  module Biryani
2
- VERSION = '0.0.14'.freeze
2
+ VERSION = '0.0.16'.freeze
3
3
  end
data/lib/biryani.rb CHANGED
@@ -1,3 +1,4 @@
1
+ require 'timeout'
1
2
  require 'uri'
2
3
 
3
4
  require_relative 'biryani/connection_error'
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: biryani
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.0.14
4
+ version: 0.0.16
5
5
  platform: ruby
6
6
  authors:
7
7
  - thekuwayama
@@ -39,9 +39,15 @@ files:
39
39
  - biryani.gemspec
40
40
  - conformance/server_spec.rb
41
41
  - conformance/spec_helper.rb
42
+ - example/README.md
42
43
  - example/echo.rb
43
44
  - example/hello_world.rb
45
+ - example/kv_store.rb
46
+ - example/nginx.conf
44
47
  - example/raise_error.rb
48
+ - example/sqlite3.rb
49
+ - example/tiles.rb
50
+ - example/tiles/index.html
45
51
  - lib/biryani.rb
46
52
  - lib/biryani/connection.rb
47
53
  - lib/biryani/connection_error.rb
@@ -99,7 +105,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
99
105
  - !ruby/object:Gem::Version
100
106
  version: '0'
101
107
  requirements: []
102
- rubygems_version: 4.0.6
108
+ rubygems_version: 4.0.20
103
109
  specification_version: 4
104
110
  summary: An HTTP/2 server implemented using Ruby Ractor
105
111
  test_files: []