biryani 0.0.12 → 0.0.15

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: 2182695aff86f0885189fd0f2387a3e1807ad4d44986cac3eafb7a5452cf3ea0
4
- data.tar.gz: 1c66dbdc500aea8cfacc6ca8dd415531dc46ad989852154effd8bba7998ac6af
3
+ metadata.gz: 68d7513a831a0ea2e4b4bf55400d1e1d464a5c16aa7577ba2c6c7937d0a62019
4
+ data.tar.gz: dad97d0529ef59f2e233da2a7042b6eeacbc95f77328ac0172ad45fcc24fd9db
5
5
  SHA512:
6
- metadata.gz: fa61f7da21c1802542365734855aac5c805b8c211fd6f8b2a40fea793947841e4ac6a237f5bfccf57daf61dcc14f592702c7e5ca55ccaf48c6e7d0b3f6e7104b
7
- data.tar.gz: 66b276311e545cf440bda25787b17ee63c1de8aa1bb18c951fb91dd44c44818bfbf816f92e5f55d3483af418147015a70752aac4ff24b64d7fdc025589b39204
6
+ metadata.gz: eb4574ee23c377e4919f64f8c84d8798e3f52224788555456cac185e923694bc9dec8647a2e495727652aa398ca44ce1bb5dc4ef57b850f066c1621f0714dcde
7
+ data.tar.gz: efed6f174ab3a74b6fb33d110ed53cb6cd56fef824a928723800b78c7629b620ef6ee647138cdd22b3e8094235eb49b484e7a2a6c88c1e4c9d3fedac354768dc
data/.ruby-version CHANGED
@@ -1 +1 @@
1
- 4.0.2
1
+ 4.0.7
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
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,170 @@
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
+ # | Ractor.new(server, socket) | | |
24
+ # +----+-------------------------------+ +-------------------------------------------------+
25
+ # | ^
26
+ # v |
27
+ # +-- server Ractor ----------------------+ +-- connection Ractor (1 Ractor / connection) --+--+
28
+ # | server.run(socket) | | Stream.new(tx, stream_id, proc) | |
29
+ # | Ractor.new(socket.accept, @proc) --+------>| 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
+ Ractor.new(server, socket) { |s, sock| s.run(sock) }
163
+ DB.serve(path)
164
+
165
+ # $ bundle exec ruby sqlite3.rb
166
+ # $ curl -v --http2-prior-knowledge http://localhost:8888 -X POST -H "Content-Type: application/json" -d '{"name":"Alice","age":18}'
167
+ # $ curl -v --http2-prior-knowledge http://localhost:8888/
168
+ # $ curl -v --http2-prior-knowledge http://localhost:8888/1 -X PUT -H "Content-Type: application/json" -d '{"name":"Bob","age":20}'
169
+ # $ curl -v --http2-prior-knowledge http://localhost:8888/
170
+ # $ 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
@@ -12,20 +12,23 @@ module Biryani
12
12
  class Connection
13
13
  CONNECTION_PREFACE = "PRI * HTTP/2.0\r\n\r\nSM\r\n\r\n".freeze
14
14
  CONNECTION_PREFACE_LENGTH = CONNECTION_PREFACE.length
15
+ DEFAULT_HEADER_TABLE_SIZE = 4_096
16
+ INITIAL_CONNECTION_WINDOW_SIZE = 65_535
15
17
 
16
- private_constant :CONNECTION_PREFACE, :CONNECTION_PREFACE_LENGTH
18
+ private_constant :CONNECTION_PREFACE, :CONNECTION_PREFACE_LENGTH, :DEFAULT_HEADER_TABLE_SIZE, :INITIAL_CONNECTION_WINDOW_SIZE
17
19
  Ractor.make_shareable(CONNECTION_PREFACE)
18
20
  Ractor.make_shareable(CONNECTION_PREFACE_LENGTH)
19
21
 
20
22
  # proc [Proc]
21
23
  def initialize(proc)
22
- @sock = nil # Ractor::Port
24
+ @port = Ractor::Port.new
25
+ @recv_done = false
23
26
  @proc = proc
24
- @streams_ctx = StreamsContext.new(proc)
25
- @encoder = HPACK::Encoder.new(4_096)
26
- @decoder = HPACK::Decoder.new(4_096)
27
- @send_window = Window.new(65_535)
28
- @recv_window = Window.new(65_535)
27
+ @streams_ctx = StreamsContext.new(proc, @port)
28
+ @encoder = HPACK::Encoder.new(DEFAULT_HEADER_TABLE_SIZE)
29
+ @decoder = HPACK::Decoder.new(DEFAULT_HEADER_TABLE_SIZE)
30
+ @send_window = Window.new(INITIAL_CONNECTION_WINDOW_SIZE)
31
+ @recv_window = Window.new(INITIAL_CONNECTION_WINDOW_SIZE)
29
32
  @data_buffer = DataBuffer.new
30
33
  @settings = self.class.default_settings # Hash<Integer, Integer>
31
34
  @peer_settings = self.class.default_settings # Hash<Integer, Integer>
@@ -53,12 +56,15 @@ module Biryani
53
56
 
54
57
  # @param io [IO]
55
58
  def recv_loop(io)
56
- Ractor.new(io, @sock = Ractor::Port.new) do |io_, sock_|
59
+ Ractor.new(io, @port) do |io_, port_|
57
60
  loop do
58
61
  obj = Frame.read(io_)
59
- break if obj.nil?
62
+ if obj.nil?
63
+ port_.send(:eof, move: true)
64
+ break
65
+ end
60
66
 
61
- sock_.send(obj, move: true)
67
+ port_.send([:frame, obj], move: true)
62
68
  end
63
69
  end
64
70
  end
@@ -66,13 +72,14 @@ module Biryani
66
72
  # @param io [IO]
67
73
  def select_loop(io)
68
74
  loop do
69
- break if @sock.closed? && @streams_ctx.empty?
75
+ break if @recv_done && @streams_ctx.empty?
70
76
 
71
- port, obj = Ractor.select(@sock, @streams_ctx.tx)
72
- if port == @sock
77
+ case @port.receive
78
+ in :eof
79
+ @recv_done = true
80
+ in [:frame, obj]
73
81
  recv_dispatch(io, obj)
74
- else
75
- res, stream_id = obj
82
+ in [:response, res, stream_id]
76
83
  handle_response(io, res, stream_id)
77
84
  end
78
85
 
@@ -118,7 +125,7 @@ module Biryani
118
125
  # @return [Array<Object>, Array<ConnectionError>, Array<StreamError>] frames or errors
119
126
  def do_recv_dispatch(frame)
120
127
  receiving_continuation_stream_id = @streams_ctx.receiving_continuation_stream_id
121
- return [ConnectionError.new(ErrorCode::PROTOCOL_ERROR, "invalid frame type #{format('0x%02x', typ)} for stream identifier #{format('0x%02x', stream_id)}")] \
128
+ return [ConnectionError.new(ErrorCode::PROTOCOL_ERROR, "invalid frame type #{format('0x%02x', frame.f_type)} for stream identifier #{format('0x%02x', frame.stream_id)}")] \
122
129
  if !receiving_continuation_stream_id.nil? && frame.stream_id != receiving_continuation_stream_id
123
130
 
124
131
  if frame.stream_id.zero?
@@ -135,13 +142,14 @@ module Biryani
135
142
  def handle_connection_frame(frame)
136
143
  case frame.f_type
137
144
  when FrameType::DATA, FrameType::HEADERS, FrameType::PRIORITY, FrameType::RST_STREAM, FrameType::PUSH_PROMISE, FrameType::CONTINUATION
138
- [ConnectionError.new(ErrorCode::PROTOCOL_ERROR, "invalid frame type #{format('0x%02x', typ)} for stream identifier 0x00")]
145
+ [ConnectionError.new(ErrorCode::PROTOCOL_ERROR, "invalid frame type #{format('0x%02x', frame.f_type)} for stream identifier 0x00")]
139
146
  when FrameType::SETTINGS
140
147
  obj = self.class.handle_settings(frame, @peer_settings, @decoder, @streams_ctx)
141
148
  return [] if obj.nil?
142
149
 
143
150
  settings_ack = obj
144
- [settings_ack]
151
+ max_frame_size = @peer_settings[SettingsID::SETTINGS_MAX_FRAME_SIZE]
152
+ [settings_ack] + @data_buffer.take!(@send_window, @streams_ctx, max_frame_size)
145
153
  when FrameType::PING
146
154
  obj = self.class.handle_ping(frame)
147
155
  return [] if obj.nil?
@@ -178,7 +186,7 @@ module Biryani
178
186
  return [ConnectionError.new(ErrorCode::PROTOCOL_ERROR, "invalid frame type #{format('0x%02x', typ)} for stream identifier #{format('0x%02x', stream_id)}")] \
179
187
  if [FrameType::SETTINGS, FrameType::PING, FrameType::GOAWAY].include?(typ)
180
188
 
181
- max_streams = @peer_settings[SettingsID::SETTINGS_MAX_CONCURRENT_STREAMS]
189
+ max_streams = @settings[SettingsID::SETTINGS_MAX_CONCURRENT_STREAMS]
182
190
  send_initial_window_size = @peer_settings[SettingsID::SETTINGS_INITIAL_WINDOW_SIZE]
183
191
  recv_initial_window_size = @settings[SettingsID::SETTINGS_INITIAL_WINDOW_SIZE]
184
192
  obj = self.class.transition_stream_state_recv(frame, @streams_ctx, @data_buffer, stream_id, max_streams, send_initial_window_size, recv_initial_window_size)
@@ -358,8 +366,9 @@ module Biryani
358
366
  # @param streams_ctx [StreamsContext]
359
367
  # @param decoder [Decoder]
360
368
  #
361
- # @return [Array<WindowUpdate>, ConnectionError]
369
+ # @return [Array<WindowUpdate>, ConnectionError, StreamError]
362
370
  # rubocop: disable Metrics/AbcSize
371
+ # rubocop: disable Metrics/CyclomaticComplexity
363
372
  def self.handle_data(stream_id, data, recv_window, streams_ctx, decoder)
364
373
  ctx = streams_ctx[stream_id]
365
374
  return ConnectionError.new(ErrorCode::FLOW_CONTROL_ERROR, 'DATA Frame length exceeds flow-control window size') \
@@ -367,7 +376,8 @@ module Biryani
367
376
 
368
377
  ctx.content << data
369
378
  if ctx.half_closed_remote?
370
- obj = http_request(ctx.fragment, ctx.content, decoder)
379
+ obj = http_request(stream_id, ctx.fragment, ctx.content, decoder)
380
+ ctx << nil if obj.is_a?(StreamError) # terminate the Stream Ractor waiting for a request
371
381
  return obj if Biryani.err?(obj)
372
382
 
373
383
  ctx << obj
@@ -379,16 +389,18 @@ module Biryani
379
389
  window_updates
380
390
  end
381
391
  # rubocop: enable Metrics/AbcSize
392
+ # rubocop: enable Metrics/CyclomaticComplexity
382
393
 
383
394
  # @param headers [Headers]
384
395
  # @param ctx [StreamContext]
385
396
  # @param decoder [Decoder]
386
397
  #
387
- # @return [nil, ConnectionError]
398
+ # @return [nil, ConnectionError, StreamError]
388
399
  def self.handle_headers(headers, ctx, decoder)
389
400
  ctx.fragment << headers.fragment
390
401
  if ctx.half_closed_remote?
391
- obj = http_request(ctx.fragment, ctx.content, decoder)
402
+ obj = http_request(headers.stream_id, ctx.fragment, ctx.content, decoder)
403
+ ctx << nil if obj.is_a?(StreamError) # terminate the Stream Ractor waiting for a request
392
404
  return obj if Biryani.err?(obj)
393
405
 
394
406
  ctx << obj
@@ -456,21 +468,23 @@ module Biryani
456
468
  nil
457
469
  end
458
470
 
471
+ # @param stream_id [Integer]
459
472
  # @param fragment [String]
460
473
  # @param content [String]
461
474
  # @param decoder [Decoder]
462
475
  #
463
- # @return [HTTP::Request, ConnectionError]
464
- def self.http_request(fragment, content, decoder)
476
+ # @return [HTTP::Request, ConnectionError, StreamError]
477
+ def self.http_request(stream_id, fragment, content, decoder)
465
478
  obj = decoder.decode(fragment)
466
479
  return obj if Biryani.err?(obj)
467
480
 
468
481
  fields = obj
469
482
  builder = HTTP::RequestBuilder.new
470
- err = builder.fields(fields)
471
- return err unless err.nil?
472
-
483
+ builder.fields(fields)
473
484
  builder.build(content)
485
+ rescue HTTP::Error::MalformedRequestError => e
486
+ # https://datatracker.ietf.org/doc/html/rfc9113#section-8.1.1-3
487
+ StreamError.new(ErrorCode::PROTOCOL_ERROR, stream_id, e.message)
474
488
  end
475
489
 
476
490
  # @param res [HTTP::Response]
@@ -486,7 +500,7 @@ module Biryani
486
500
  def self.default_settings
487
501
  # https://datatracker.ietf.org/doc/html/rfc9113#section-6.5.2
488
502
  {
489
- SettingsID::SETTINGS_HEADER_TABLE_SIZE => 4_096,
503
+ SettingsID::SETTINGS_HEADER_TABLE_SIZE => DEFAULT_HEADER_TABLE_SIZE,
490
504
  SettingsID::SETTINGS_ENABLE_PUSH => 1,
491
505
  SettingsID::SETTINGS_MAX_CONCURRENT_STREAMS => 0xffffffff,
492
506
  SettingsID::SETTINGS_INITIAL_WINDOW_SIZE => 65_535,
@@ -20,14 +20,14 @@ module Biryani
20
20
  datas = []
21
21
  @buffer.each do |stream_id, data|
22
22
  frames, remains = streams_ctx.sendable_datas(stream_id, data, send_window, max_frame_size)
23
- datas += frames
23
+ datas.concat(frames)
24
24
  if remains.empty?
25
25
  @buffer.delete(stream_id)
26
26
  else
27
27
  @buffer[stream_id] = remains
28
28
  end
29
29
 
30
- len = frames.map(&:length).sum
30
+ len = frames.sum(&:length)
31
31
  send_window.consume!(len)
32
32
  streams_ctx[stream_id].send_window.consume!(len)
33
33
  end
@@ -51,10 +51,7 @@ module Biryani
51
51
 
52
52
  if padded
53
53
  io = IO::Buffer.for(s)
54
- pad_length = io.get_value(:U8, 0)
55
- data_length = s.bytesize - pad_length - 1
56
- data = io.get_string(1, data_length)
57
- padding = io.get_string(1 + data_length)
54
+ data, padding, pad_length = Frame.read_padded_payload(io, s, 1)
58
55
  return ConnectionError.new(ErrorCode::PROTOCOL_ERROR, 'invalid frame') if padding.bytesize != pad_length
59
56
  else
60
57
  data = s
@@ -64,7 +64,6 @@ module Biryani
64
64
  # @return [Headers]
65
65
  # rubocop: disable Metrics/AbcSize
66
66
  # rubocop: disable Metrics/CyclomaticComplexity
67
- # rubocop: disable Metrics/MethodLength
68
67
  # rubocop: disable Metrics/PerceivedComplexity
69
68
  def self.read(s, flags, stream_id)
70
69
  priority = Frame.read_priority(flags)
@@ -74,14 +73,12 @@ module Biryani
74
73
 
75
74
  if priority && padded
76
75
  io = IO::Buffer.for(s)
77
- pad_length, stream_dependency, weight = io.get_values(%i[U8 U32 U8], 0)
78
- fragment_length = s.bytesize - pad_length - 6
76
+ _pad_length, stream_dependency, weight = io.get_values(%i[U8 U32 U8], 0)
79
77
  # exclusive = (stream_dependency / 2**31).positive?
80
78
  stream_dependency %= 2**31
81
79
  return ConnectionError.new(ErrorCode::PROTOCOL_ERROR, 'cannot depend on itself') if stream_dependency == stream_id
82
80
 
83
- fragment = io.get_string(6, fragment_length)
84
- padding = io.get_string(6 + fragment_length)
81
+ fragment, padding, pad_length = Frame.read_padded_payload(io, s, 6)
85
82
  return ConnectionError.new(ErrorCode::FRAME_SIZE_ERROR, 'invalid frame') if padding.bytesize != pad_length
86
83
  elsif priority
87
84
  io = IO::Buffer.for(s)
@@ -94,10 +91,7 @@ module Biryani
94
91
  return ConnectionError.new(ErrorCode::FRAME_SIZE_ERROR, 'invalid frame') if fragment.bytesize + 5 != s.bytesize
95
92
  elsif padded
96
93
  io = IO::Buffer.for(s)
97
- pad_length = io.get_value(:U8, 0)
98
- fragment_length = s.bytesize - pad_length - 1
99
- fragment = io.get_string(1, fragment_length)
100
- padding = io.get_string(1 + fragment_length)
94
+ fragment, padding, pad_length = Frame.read_padded_payload(io, s, 1)
101
95
  return ConnectionError.new(ErrorCode::PROTOCOL_ERROR, 'invalid frame') if pad_length >= s.bytesize
102
96
  return ConnectionError.new(ErrorCode::PROTOCOL_ERROR, 'invalid frame') if padding.bytesize != pad_length
103
97
  else
@@ -108,7 +102,6 @@ module Biryani
108
102
  end
109
103
  # rubocop: enable Metrics/AbcSize
110
104
  # rubocop: enable Metrics/CyclomaticComplexity
111
- # rubocop: enable Metrics/MethodLength
112
105
  # rubocop: enable Metrics/PerceivedComplexity
113
106
  end
114
107
  end
@@ -53,11 +53,9 @@ module Biryani
53
53
 
54
54
  io = IO::Buffer.for(s)
55
55
  if padded
56
- pad_length, promised_stream_id = io.get_values(%i[U8 U32], 0)
56
+ _pad_length, promised_stream_id = io.get_values(%i[U8 U32], 0)
57
57
  promised_stream_id %= 2**31 # Promised Stream ID (31)
58
- fragment_length = s.bytesize - pad_length - 5
59
- fragment = io.get_string(5, fragment_length)
60
- padding = io.get_string(5 + fragment_length)
58
+ fragment, padding, pad_length = Frame.read_padded_payload(io, s, 5)
61
59
  return ConnectionError.new(ErrorCode::PROTOCOL_ERROR, 'invalid frame') if pad_length >= s.bytesize
62
60
  return ConnectionError.new(ErrorCode::PROTOCOL_ERROR, 'invalid frame') if padding.bytesize != pad_length
63
61
  else
data/lib/biryani/frame.rb CHANGED
@@ -98,6 +98,22 @@ module Biryani
98
98
  def self.to_binary_s_header(payload_length, f_type, flags, stream_id)
99
99
  [payload_length, f_type, flags, stream_id].pack('NCCN')[1..]
100
100
  end
101
+
102
+ # @param io [IO::Buffer]
103
+ # @param s [String]
104
+ # @param header_length [Integer] bytes preceding the payload, including the 1-byte Pad Length field
105
+ #
106
+ # @return [String] payload
107
+ # @return [String] padding
108
+ # @return [Integer] pad_length
109
+ def self.read_padded_payload(io, s, header_length)
110
+ pad_length = io.get_value(:U8, 0)
111
+ payload_length = s.bytesize - pad_length - header_length
112
+ payload = io.get_string(header_length, payload_length)
113
+ padding = io.get_string(header_length + payload_length)
114
+
115
+ [payload, padding, pad_length]
116
+ end
101
117
  end
102
118
  end
103
119
 
@@ -47,6 +47,26 @@ module Biryani
47
47
  @table[index]
48
48
  end
49
49
 
50
+ # @param index [Integer]
51
+ #
52
+ # @return [Array] [name, value]
53
+ def entry_at(index)
54
+ raise Error::HPACKDecodeError if index.zero? || index > STATIC_TABLE_SIZE + count_entries
55
+
56
+ if index <= STATIC_TABLE_SIZE
57
+ STATIC_TABLE[index - 1]
58
+ else
59
+ self[index - 1 - STATIC_TABLE_SIZE]
60
+ end
61
+ end
62
+
63
+ # @param index [Integer]
64
+ #
65
+ # @return [String]
66
+ def name_at(index)
67
+ entry_at(index)[0]
68
+ end
69
+
50
70
  # @param new_limit [Integer]
51
71
  def chomp!(new_limit)
52
72
  while @size > new_limit
@@ -145,15 +145,8 @@ module Biryani
145
145
  # @return [Integer]
146
146
  def self.decode_indexed(io, cursor, dynamic_table)
147
147
  index, c = Integer.decode(io, 7, cursor)
148
- raise Error::HPACKDecodeError if index.zero? || index > STATIC_TABLE_SIZE + dynamic_table.count_entries
149
148
 
150
- field = if index <= STATIC_TABLE_SIZE
151
- STATIC_TABLE[index - 1]
152
- else
153
- dynamic_table[index - 1 - STATIC_TABLE_SIZE]
154
- end
155
-
156
- [field, c]
149
+ [dynamic_table.entry_at(index), c]
157
150
  end
158
151
 
159
152
  # 0 1 2 3 4 5 6 7
@@ -202,13 +195,7 @@ module Biryani
202
195
  # @return [Integer]
203
196
  def self.decode_literal_value_incremental_indexing(io, cursor, dynamic_table)
204
197
  index, c = Integer.decode(io, 6, cursor)
205
- raise Error::HPACKDecodeError if index.zero? || index > STATIC_TABLE_SIZE + dynamic_table.count_entries
206
-
207
- name = if index <= STATIC_TABLE_SIZE
208
- STATIC_TABLE[index - 1][0]
209
- else
210
- dynamic_table[index - 1 - STATIC_TABLE_SIZE][0]
211
- end
198
+ name = dynamic_table.name_at(index)
212
199
  value, c = String.decode(io, c)
213
200
  dynamic_table.store(name, value)
214
201
 
@@ -281,13 +268,7 @@ module Biryani
281
268
  # @return [Integer]
282
269
  def self.decode_literal_value_never_indexed(io, cursor, dynamic_table)
283
270
  index, c = Integer.decode(io, 4, cursor)
284
- raise Error::HPACKDecodeError if index.zero? || index > STATIC_TABLE_SIZE + dynamic_table.count_entries
285
-
286
- name = if index <= STATIC_TABLE_SIZE
287
- STATIC_TABLE[index - 1][0]
288
- else
289
- dynamic_table[index - 1 - STATIC_TABLE_SIZE][0]
290
- end
271
+ name = dynamic_table.name_at(index)
291
272
  value, c = String.decode(io, c)
292
273
 
293
274
  [[name, value], c]
@@ -336,13 +317,7 @@ module Biryani
336
317
  # @return [Integer]
337
318
  def self.decode_literal_value_without_indexing(io, cursor, dynamic_table)
338
319
  index, c = Integer.decode(io, 4, cursor)
339
- raise Error::HPACKDecodeError if index.zero? || index > STATIC_TABLE_SIZE + dynamic_table.count_entries
340
-
341
- name = if index <= STATIC_TABLE_SIZE
342
- STATIC_TABLE[index - 1][0]
343
- else
344
- dynamic_table[index - 1 - STATIC_TABLE_SIZE][0]
345
- end
320
+ name = dynamic_table.name_at(index)
346
321
  value, c = String.decode(io, c)
347
322
 
348
323
  [[name, value], c]
@@ -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
 
@@ -18,7 +18,7 @@ module Biryani
18
18
  res = HTTP::Response.internal_server_error
19
19
  end
20
20
 
21
- tx.send([res, stream_id], move: true)
21
+ tx.send([:response, res, stream_id], move: true)
22
22
  end
23
23
  end
24
24
  end
@@ -2,10 +2,10 @@ module Biryani
2
2
  class StreamsContext
3
3
  attr_accessor :tx
4
4
 
5
- def initialize(proc)
5
+ def initialize(proc, tx)
6
6
  @h = {} # Hash<Integer, StreamContext>
7
7
  @proc = proc
8
- @tx = Ractor::Port.new
8
+ @tx = tx
9
9
  end
10
10
 
11
11
  # @param stream_id [Integer]
@@ -1,3 +1,3 @@
1
1
  module Biryani
2
- VERSION = '0.0.12'.freeze
2
+ VERSION = '0.0.15'.freeze
3
3
  end
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.12
4
+ version: 0.0.15
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: []