├── snippets ├── struct.rb ├── json_dump.rb ├── app.ru ├── default_defined_in_different_ractor.rb ├── test.ru ├── define_method2.rb ├── module_instance_var.rb ├── getter_setter.rb ├── frozen_constants.rb ├── bugs-17722.rb ├── define_unshareable_method.rb ├── frozen_proc.rb ├── exception.rb ├── isolated_procs.rb ├── monitor.rb ├── ractor_object_ownership.rb ├── ractor_accept.rb ├── ractor_server.rb └── define_method.rb ├── lib ├── right_speed │ ├── version.rb │ ├── env.rb │ ├── const.rb │ ├── logger.rb │ ├── worker │ │ ├── base.rb │ │ ├── accepter.rb │ │ ├── roundrobin.rb │ │ └── fair.rb │ ├── connection_closer.rb │ ├── ractor_helper.rb │ ├── server.rb │ ├── listener.rb │ ├── processor.rb │ └── handler.rb ├── right_speed.rb └── rack │ └── handler │ └── right_speed.rb ├── .gitignore ├── test ├── test_helper.rb ├── right_speed_test.rb └── handler_test.rb ├── Rakefile ├── Gemfile ├── .github └── workflows │ └── main.yml ├── LICENSE.txt ├── right_speed.gemspec ├── README.md └── bin └── right_speed /snippets/struct.rb: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /lib/right_speed/version.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module RightSpeed 4 | VERSION = "0.2.0" 5 | end 6 | -------------------------------------------------------------------------------- /snippets/json_dump.rb: -------------------------------------------------------------------------------- 1 | require "json" 2 | r1 = Ractor.new { 3 | JSON.dump({yay: 3}) 4 | } 5 | 6 | pp(r1: r1.take) 7 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /.bundle/ 2 | /.yardoc 3 | /_yardoc/ 4 | /coverage/ 5 | /doc/ 6 | /pkg/ 7 | /spec/reports/ 8 | /tmp/ 9 | 10 | Gemfile.lock 11 | -------------------------------------------------------------------------------- /test/test_helper.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | $LOAD_PATH.unshift File.expand_path("../lib", __dir__) 4 | require "right_speed" 5 | 6 | require "test-unit" 7 | -------------------------------------------------------------------------------- /lib/right_speed.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative "right_speed/version" 4 | 5 | module RightSpeed 6 | end 7 | 8 | require_relative "right_speed/server" 9 | -------------------------------------------------------------------------------- /lib/right_speed/env.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | require "concurrent" 3 | 4 | module RightSpeed 5 | module Env 6 | def self.processors 7 | Concurrent.processor_count 8 | end 9 | end 10 | end 11 | -------------------------------------------------------------------------------- /test/right_speed_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "test_helper" 4 | 5 | class RightSpeedTest < Test::Unit::TestCase 6 | test "VERSION" do 7 | assert do 8 | ::RightSpeed.const_defined?(:VERSION) 9 | end 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "bundler/gem_tasks" 4 | require "rake/testtask" 5 | 6 | Rake::TestTask.new(:test) do |t| 7 | t.libs << "test" 8 | t.libs << "lib" 9 | t.test_files = FileList["test/**/*_test.rb"] 10 | end 11 | 12 | task default: :test 13 | -------------------------------------------------------------------------------- /lib/right_speed/const.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative "version" 4 | 5 | module RightSpeed 6 | SOFTWARE_NAME = "RightSpeed #{VERSION} (#{RUBY_ENGINE} #{RUBY_VERSION}p#{RUBY_PATCHLEVEL} [#{RUBY_PLATFORM}])".freeze 7 | RACK_VERSION = Rack::VERSION.freeze 8 | end 9 | -------------------------------------------------------------------------------- /snippets/app.ru: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | require 'pp' 3 | 4 | class MyApp 5 | def call(env) 6 | # pp(ractor: Ractor.current.object_id, env: env) 7 | [200, {'Content-Type' => 'text/plain', 'Content-Length' => 2.to_s}, ['OK']] 8 | end 9 | end 10 | 11 | run MyApp.new 12 | -------------------------------------------------------------------------------- /snippets/default_defined_in_different_ractor.rb: -------------------------------------------------------------------------------- 1 | require "pp" 2 | 3 | class Foo 4 | attr_reader :map 5 | YAY = {foo: "bar"} 6 | def initialize(map, opts = {}) 7 | @map = map.merge(opts) 8 | end 9 | end 10 | 11 | r = Ractor.new { 12 | Foo.new({yay: "yay"}, Foo::YAY).map 13 | } 14 | 15 | pp r.take 16 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | source "https://rubygems.org" 4 | 5 | # Specify your gem's dependencies in right_speed.gemspec 6 | gemspec 7 | 8 | gem "rake", "~> 13.0" 9 | 10 | gem "test-unit", "~> 3.0" 11 | 12 | # The updated libraries for ractor-safety 13 | gem "http_parser.rb", git: 'https://github.com/tmm1/http_parser.rb.git' 14 | -------------------------------------------------------------------------------- /lib/right_speed/logger.rb: -------------------------------------------------------------------------------- 1 | module RightSpeed 2 | def self.logger 3 | return Ractor.current[:logger] if Ractor.current[:logger] 4 | logger = Logger.new($stderr) 5 | logger.formatter = lambda {|severity, datetime, progname, msg| "[#{datetime}] #{severity} #{msg}\n" } 6 | Ractor.current[:logger] = logger 7 | logger 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /snippets/test.ru: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | require 'pp' 3 | 4 | class MyApp 5 | def call(env) 6 | [ 7 | 200, 8 | { 9 | 'Content-Type' => 'text/plain', 10 | 'Content-Length' => '5', 11 | 'X-Ractor-Id' => Ractor.current.object_id, 12 | }, 13 | ['Yaaay'] 14 | ] 15 | end 16 | end 17 | 18 | run MyApp.new 19 | -------------------------------------------------------------------------------- /snippets/define_method2.rb: -------------------------------------------------------------------------------- 1 | class Yay 2 | end 3 | 4 | x = 1 5 | pr = ->(){ x + 3 } 6 | Ractor.make_shareable(pr) 7 | Yay.define_method(:yay, &pr) 8 | 9 | pr2 = Yay.new.method(:yay).to_proc # this proc is not isolated - the state is not saved 10 | Ractor.make_shareable(pr2) # without this, raise an error 11 | Yay.define_method(:foo, &pr2) 12 | 13 | r1 = Ractor.new { Yay.new.foo } 14 | p(r1: r1.take) 15 | -------------------------------------------------------------------------------- /snippets/module_instance_var.rb: -------------------------------------------------------------------------------- 1 | module Yay 2 | def self.yay 3 | p @yay 4 | end 5 | 6 | def self.yay=(val) 7 | @yay = val 8 | end 9 | 10 | class << self 11 | def value 12 | p @yay 13 | end 14 | 15 | def value=(val) 16 | @yay = val 17 | end 18 | end 19 | end 20 | 21 | Yay.yay = "one" 22 | Yay.yay 23 | 24 | Yay.value = "two" 25 | Yay.value 26 | Yay.yay 27 | -------------------------------------------------------------------------------- /snippets/getter_setter.rb: -------------------------------------------------------------------------------- 1 | module Yay 2 | DEFAULT_VALUE = 1 3 | 4 | def self.value 5 | DEFAULT_VALUE 6 | end 7 | 8 | def self.value=(value) 9 | provider = proc { value } 10 | Ractor.make_shareable(provider) if defined?(Ractor) 11 | define_singleton_method(:value, &provider) 12 | end 13 | end 14 | 15 | pp(here: :before, value: Yay.value) 16 | 17 | Yay.value = 30 18 | 19 | pp(here: :after, value: Yay.value) 20 | -------------------------------------------------------------------------------- /lib/right_speed/worker/base.rb: -------------------------------------------------------------------------------- 1 | require_relative "../logger" 2 | require_relative "../handler" 3 | 4 | module RightSpeed 5 | module Worker 6 | class Base 7 | attr_reader :ractor 8 | 9 | def initialize(id:, handler:) 10 | @id = id 11 | @handler = handler 12 | @ractor = nil 13 | end 14 | 15 | def stop 16 | @ractor # TODO: terminate if possible 17 | end 18 | end 19 | end 20 | end 21 | -------------------------------------------------------------------------------- /snippets/frozen_constants.rb: -------------------------------------------------------------------------------- 1 | 2 | class Yay 3 | VALUE1 = "yay" # Bad 4 | VALUE2 = {yay: "yay"} # Bad 5 | VALUE3 = {yay: "yay"}.freeze # Bad!!! 6 | 7 | VALUE4 = "yay".freeze # OK 8 | VALUE5 = Ractor.make_shareable({yay: "yay"}) # OK 9 | 10 | # frozen_string_literal: true 11 | VALUE6 = "yay" # OK 12 | VALUE7 = {yay: "yay"}.freeze # OK 13 | end 14 | 15 | r1 = Ractor.new { 16 | p(yay: Yay::VALUE5) 17 | } 18 | 19 | begin 20 | r1.take 21 | rescue => e 22 | puts "Exception #{e}" 23 | end 24 | 25 | -------------------------------------------------------------------------------- /snippets/bugs-17722.rb: -------------------------------------------------------------------------------- 1 | class A 2 | m = proc { |val| 3 | instance_variable_set(:"@#{v}", val) 4 | } 5 | Ractor.make_shareable(m) 6 | define_method :"a=", &m 7 | attr_reader :a 8 | 9 | def initialize(opts) 10 | opts.each do |k, v| 11 | puts "#{k} = #{v}" 12 | __send__(:"#{k}=", v) 13 | end 14 | end 15 | end 16 | 17 | ractors = [] 18 | 19 | DEFAULTS = { a: 1 } 20 | Ractor.make_shareable(DEFAULTS) 21 | 22 | 1.times do 23 | ractors << Ractor.new do 24 | a = A.new(DEFAULTS) 25 | end 26 | end 27 | ractors.map(&:take) 28 | -------------------------------------------------------------------------------- /snippets/define_unshareable_method.rb: -------------------------------------------------------------------------------- 1 | begin 2 | 3 | class Yo; end 4 | 5 | Yo.define_method(:yay){ :yo } # unisolated block 6 | r = Ractor.new { 7 | Yo.new.yay 8 | # RuntimeError: defined in a different Ractor 9 | } 10 | r.take 11 | 12 | 13 | yay = ->(){ 14 | :yo 15 | } 16 | Ractor.make_shareable(yay) 17 | Yo.define_method(:yay, &yay) 18 | 19 | 20 | yay = ->(){ 21 | :yo 22 | }.isolate 23 | Yo.define_method(:yay, &yay) 24 | 25 | # isolated_lambda_literal: true 26 | yay = ->(){ :yo }.dup # un-isolate 27 | 28 | 29 | 30 | end 31 | 32 | -------------------------------------------------------------------------------- /snippets/frozen_proc.rb: -------------------------------------------------------------------------------- 1 | x = 1 2 | 3 | p1 = ->(){ x + 1 } 4 | p2 = ->(){ x + 2 } 5 | p3 = ->(){ x + 3 } 6 | 7 | p2.freeze 8 | Ractor.make_shareable(p3) 9 | 10 | x = 0 11 | 12 | pp(p1: p1.call, p2: p2.call, p3: p3.call) 13 | pp(p1f: p1.frozen?, p2f: p2.frozen?, p3f: p3.frozen?) 14 | 15 | VALUE = "yay" 16 | Ractor.make_shareable(VALUE) 17 | 18 | pp(value: VALUE, frozen: VALUE.frozen?) 19 | 20 | 21 | HASH = {key: "value"} 22 | Ractor.make_shareable(HASH) 23 | 24 | pp(hash: HASH, fronzen: HASH[:key].frozen?) 25 | 26 | y = "yay" 27 | p4 = ->(){ y + "4" } 28 | Ractor.make_shareable(p4) 29 | 30 | pp(p4: p4.call) # error! 31 | -------------------------------------------------------------------------------- /snippets/exception.rb: -------------------------------------------------------------------------------- 1 | require "test/unit" 2 | include Test::Unit::Assertions 3 | 4 | require "error_highlight" 5 | 6 | custom_formatter = Object.new 7 | def custom_formatter.message_for(spot) 8 | "\n\n" + spot.inspect 9 | end 10 | ErrorHighlight.formatter = custom_formatter 11 | 12 | class ExceptionTest < Test::Unit::TestCase 13 | def test_yay 14 | assert_raise(NoMethodError) do 15 | begin 16 | Ractor.new { 1.time {} }.take 17 | rescue Ractor::RemoteError => e 18 | p(here: "rescue", e: "#{e}", cause: "#{e.cause}", r: "#{e.ractor}") 19 | raise e.cause 20 | end 21 | end 22 | end 23 | end 24 | -------------------------------------------------------------------------------- /snippets/isolated_procs.rb: -------------------------------------------------------------------------------- 1 | begin 2 | 3 | # Usual Proc 4 | x = 1 5 | p1 = ->() { x + 2 } 6 | 7 | p p1.call #=> 3 8 | 9 | x = 5 10 | p p1.call #=> 7 11 | 12 | 13 | # 14 | 15 | 16 | # Isolated Proc 17 | x = 1 18 | p2 = ->() { x + 2 } 19 | Ractor.make_shareable(p2) 20 | # p2 is isolated 21 | 22 | p p2.call #=> 3 23 | 24 | x = 5 25 | p p2.call #=> 3 (!) 26 | 27 | 28 | ################### 29 | 30 | s1 = "Yaaaaaaay" 31 | p3 = ->(){ s1.upcase } 32 | Ractor.make_shareable(p3) 33 | # Ractor::IsolationError 34 | # p3 is referring unshareable objects 35 | 36 | s2 = "Boooooooo".freeze 37 | p4 = ->(){ s2.upcase } 38 | Ractor.make_shareable(p4) 39 | # OK 40 | 41 | end 42 | -------------------------------------------------------------------------------- /snippets/monitor.rb: -------------------------------------------------------------------------------- 1 | class AtomicReference 2 | def initialize(value) 3 | @value = value 4 | @mutex = Ractor.make_shareable(Mutex.new) 5 | end 6 | 7 | def get 8 | @mutex.synchronize { @value } 9 | end 10 | 11 | def set(value) 12 | @mutex.synchronize do 13 | @value = value 14 | end 15 | end 16 | end 17 | 18 | module Yay 19 | VALUE = Ractor.make_shareable(AtomicReference.new("yay")) 20 | 21 | def self.value 22 | VALUE.get 23 | end 24 | 25 | def self.value=(value) 26 | VALUE.set(value) 27 | end 28 | end 29 | 30 | r1 = Ractor.new do 31 | 1000.times do |i| 32 | Yay.value = "Yay#{i}" 33 | Yay.value 34 | end 35 | Yay.value 36 | end 37 | 38 | r2 = Ractor.new do 39 | 1000.times do |i| 40 | Yay.value = "Boo#{i}" 41 | Yay.value 42 | end 43 | Yay.value 44 | end 45 | 46 | p(r1: r1.take, r2: r2.take) 47 | -------------------------------------------------------------------------------- /snippets/ractor_object_ownership.rb: -------------------------------------------------------------------------------- 1 | listener = Ractor.new do 2 | sleep 3 3 | "listener" 4 | end 5 | 6 | workers = 5.times.map do |i| 7 | Ractor.new(i, listener) do |num, listener| 8 | 3.times do |n| 9 | sleep 1 10 | Ractor.yield "worker#{num}, num:#{n}" 11 | end 12 | Ractor.yield :closing 13 | "worker#{num}, listener:#{listener}" 14 | end 15 | end 16 | 17 | closer = Ractor.new(workers.dup) do |workers| 18 | while workers.size > 0 19 | r, obj = Ractor.select(*workers, move: true) 20 | if obj == :closing 21 | workers.delete(r) 22 | else 23 | p(here: :in_closer, value: obj) 24 | end 25 | end 26 | "closer" 27 | end 28 | 29 | workers.each do |worker| 30 | p(here: :worker, obj: worker, value: worker.take) 31 | end 32 | 33 | p(here: :listener, obj: listener, value: listener.take) 34 | p(here: :closer, obj: closer, value: closer.take) 35 | -------------------------------------------------------------------------------- /snippets/ractor_accept.rb: -------------------------------------------------------------------------------- 1 | require 'socket' 2 | 3 | try_times = 10 4 | worker_num = 2 5 | 6 | listener = TCPServer.new("127.0.0.1", 8228) 7 | listener.listen(100) 8 | workers = worker_num.times.map do |i| 9 | Ractor.new(i, listener.dup) do |index, sock| 10 | while conn = sock.accept 11 | begin 12 | data = conn.read 13 | p "Worker|#{index} Data: #{data}" 14 | conn.close 15 | rescue => e 16 | p "Worker|#{index} #{e.full_message}" 17 | end 18 | end 19 | rescue => e 20 | $stderr.puts "Error, worker#{index}: #{e.full_message}" 21 | end 22 | end 23 | 24 | p "Starting a sender" 25 | sender = Ractor.new(try_times) do |tries| 26 | tries.times.each do 27 | s = TCPSocket.new("127.0.0.1", 8228) 28 | s.write("yay") 29 | ensure 30 | s.close rescue nil 31 | end 32 | end 33 | 34 | sender.take 35 | workers.each{|w| w.take} 36 | p "End" 37 | -------------------------------------------------------------------------------- /snippets/ractor_server.rb: -------------------------------------------------------------------------------- 1 | require 'socket' 2 | 3 | worker_num = 2 4 | 5 | sock = TCPServer.new("127.0.0.1", 8228) 6 | workers = worker_num.times.map do |i| 7 | Ractor.new(i) do |index| 8 | while conn = Ractor.receive 9 | begin 10 | data = conn.readline 11 | conn.write "HTTP/1.1 200 OK\r\n\r\n" 12 | conn.close 13 | conn = nil 14 | rescue => e 15 | p "Worker|#{index} #{e.full_message}" 16 | end 17 | end 18 | end 19 | end 20 | 21 | p "Starting a listener" 22 | listener = Ractor.new(sock, workers) do |sock, workers| 23 | workers_num = workers.size 24 | i = 0 25 | begin 26 | while conn = sock.accept 27 | worker = workers[i % workers_num] 28 | worker.send(conn, move: true) 29 | i += 1 30 | end 31 | rescue => e 32 | p "Listener| #{e.full_message}" 33 | end 34 | end 35 | 36 | workers.each{|w| w.take} 37 | p "End" 38 | -------------------------------------------------------------------------------- /snippets/define_method.rb: -------------------------------------------------------------------------------- 1 | class Boo 2 | def value 3 | "a" 4 | end 5 | end 6 | 7 | module Wow 8 | def value 9 | "b" 10 | end 11 | end 12 | 13 | Boo.prepend(Wow) 14 | 15 | p(boo: Boo.new.value) 16 | 17 | r = Ractor.new do 18 | Boo.new.value 19 | end 20 | 21 | p(ractorX: r.take) # Yay! 22 | 23 | class Foo 24 | def value 25 | "a" 26 | end 27 | end 28 | 29 | a = "b".freeze 30 | Foo.define_method(:value, Ractor.make_shareable(Proc.new { a })) 31 | 32 | p(foo: Foo.new.value) 33 | 34 | r = Ractor.new do 35 | Foo.new.value 36 | end 37 | 38 | p(ractorY: r.take) 39 | 40 | 41 | class Yay 42 | def value 43 | "a" 44 | end 45 | end 46 | 47 | Yay.define_method(:value) { "bbb".freeze } 48 | 49 | p(yay: Yay.new.value) #=> "b" 50 | 51 | r = Ractor.new do 52 | Yay.new.value 53 | # snippets/define_method.rb:12:in `block in
': defined in a different Ractor (RuntimeError) 54 | end 55 | 56 | p(ractor: r.take) 57 | 58 | -------------------------------------------------------------------------------- /lib/right_speed/connection_closer.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative "logger" 4 | 5 | module RightSpeed 6 | class ConnectionCloser 7 | # This class was introduced to serialize closing connections 8 | # (instead of closing those in each Ractor) to try to avoid SEGV. 9 | # But SEGV is still happening, so this class may not be valueable. 10 | 11 | def run(workers) 12 | @ractor = Ractor.new(workers) do |workers| 13 | logger = RightSpeed.logger 14 | while workers.size > 0 15 | r, conn = Ractor.select(*workers, move: true) 16 | if conn == :closing 17 | workers.delete(r) 18 | next 19 | end 20 | begin 21 | conn.close 22 | rescue => e 23 | logger.debug { "Error while closing a connection #{conn}, #{e.class}:#{e.message}" } 24 | end 25 | end 26 | rescue => e 27 | logger.error { "Unexpected error, #{e.class}:#{e.message}" } 28 | end 29 | end 30 | 31 | def wait 32 | @ractor.take 33 | end 34 | end 35 | end 36 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: Ruby 2 | 3 | on: [push,pull_request] 4 | 5 | jobs: 6 | build: 7 | runs-on: ubuntu-latest 8 | steps: 9 | - uses: actions/checkout@v2 10 | - name: Set up Ruby 11 | uses: ruby/setup-ruby@v1 12 | with: 13 | ruby-version: 3.0.2 14 | - name: Run the default task 15 | run: | 16 | gem install bundler 17 | bundle install 18 | bundle exec rake 19 | - name: Run the actual server 20 | run: | 21 | bundle exec ruby bin/right_speed -c snippets/test.ru & 22 | sleep 5 23 | output=$(curl -s http://127.0.0.1:8080/) 24 | kill %1 25 | echo "Output: $output" 26 | test "$output" = "Yaaay" 27 | - name: Run rackup 28 | # Using production not to use middlewares for development (lint, etc) 29 | run: | 30 | bundle exec rackup snippets/test.ru -s right_speed -E production -O Host=127.0.0.1 -O Port=8081 & 31 | sleep 5 32 | output=$(curl -s http://127.0.0.1:8081/) 33 | kill %1 34 | echo "Output: $output" 35 | test "$output" = "Yaaay" 36 | -------------------------------------------------------------------------------- /lib/right_speed/worker/accepter.rb: -------------------------------------------------------------------------------- 1 | require_relative 'base' 2 | 3 | module RightSpeed 4 | module Worker 5 | class Accepter < Base 6 | def run(sock) 7 | @ractor = Ractor.new(@id, sock, @handler) do |id, sock, handler| 8 | logger = RightSpeed.logger 9 | while conn = sock.accept 10 | begin 11 | handler.session(conn).process 12 | # TODO: keep-alive? 13 | Ractor.yield(conn, move: true) # to yield closing connections to ConnectionCloser 14 | rescue => e 15 | logger.error { "Unexpected error: #{e.message}\n" + e.backtrace.map{"\t#{_1}\n"}.join } 16 | # TODO: print backtrace in better way 17 | end 18 | end 19 | logger.info { "Worker#{id}: Finishing the Ractor" } 20 | Ractor.yield(:closing) # to tell the outgoing path will be closed when stopping 21 | end 22 | end 23 | 24 | def wait 25 | # nothing to wait - @ractor.take consumes closed connections unexpectedly 26 | # @ractor.wait ? 27 | end 28 | end 29 | end 30 | end 31 | -------------------------------------------------------------------------------- /lib/right_speed/worker/roundrobin.rb: -------------------------------------------------------------------------------- 1 | require_relative "base" 2 | require_relative "../logger" 3 | 4 | module RightSpeed 5 | module Worker 6 | class RoundRobin < Base 7 | def run 8 | @ractor = Ractor.new(@id, @handler) do |id, handler| 9 | logger = RightSpeed.logger 10 | while conn = Ractor.receive 11 | begin 12 | handler.session(conn).process 13 | # TODO: keep-alive? 14 | Ractor.yield(conn, move: true) # to yield closing connections to ConnectionCloser 15 | rescue => e 16 | logger.error { "Unexpected error: #{e.message}\n" + e.backtrace.map{"\t#{_1}\n"}.join } 17 | # TODO: print backtrace in better way 18 | end 19 | end 20 | logger.info { "Worker#{id}: Finishing the Ractor" } 21 | Ractor.yield(:closing) # to tell the outgoing path will be closed when stopping 22 | end 23 | end 24 | 25 | def process(conn) 26 | @ractor.send(conn, move: true) 27 | end 28 | 29 | def wait 30 | @ractor.take 31 | end 32 | end 33 | end 34 | end 35 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2021 Satoshi Moris Tagomori 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /lib/rack/handler/right_speed.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "right_speed/server" 4 | 5 | module Rack 6 | module Handler 7 | class RightSpeed 8 | def self.run(app, **options) 9 | environment = ENV['RACK_ENV'] || 'development' 10 | default_host = environment == 'development' ? '127.0.0.1' : '0.0.0.0' 11 | 12 | host = options.delete(:Host) || default_host 13 | port = options.delete(:Port) || 8080 14 | workers = options.delete(:Workers) || ::RightSpeed::Env.processors 15 | server = ::RightSpeed::Server.new(app: app, host: host, port: port, workers: workers) 16 | 17 | yield server if block_given? 18 | 19 | server.run 20 | end 21 | 22 | def self.valid_options 23 | environment = ENV['RACK_ENV'] || 'development' 24 | default_host = environment == 'development' ? '127.0.0.1' : '0.0.0.0' 25 | { 26 | "Host=HOST" => "Hostname to listen on (default: #{default_host})", 27 | "Port=PORT" => "Port to listen on (default: 8080)", 28 | "Workers=NUM" => "Number of workers (default: #{::RightSpeed::Env.processors})", 29 | } 30 | end 31 | end 32 | 33 | register :right_speed, ::Rack::Handler::RightSpeed 34 | end 35 | end 36 | -------------------------------------------------------------------------------- /lib/right_speed/worker/fair.rb: -------------------------------------------------------------------------------- 1 | require_relative "base" 2 | require_relative "../logger" 3 | 4 | module RightSpeed 5 | module Worker 6 | class Fair < Base 7 | def run(listener_ractor) 8 | @ractor = Ractor.new(@id, @handler, listener_ractor) do |id, handler, listener| 9 | logger = RightSpeed.logger 10 | while conn = listener.take 11 | begin 12 | handler.session(conn).process 13 | # TODO: keep-alive? 14 | Ractor.yield(conn, move: true) # to yield closing connections to ConnectionCloser 15 | rescue => e 16 | logger.error { "Unexpected error: #{e.message}\n" + e.backtrace.map{"\t#{_1}\n"}.join } 17 | # TODO: print backtrace in better way 18 | end 19 | end 20 | logger.info { "Worker#{id}: Finishing the Ractor" } 21 | Ractor.yield(:closing) # to tell the outgoing path will be closed when stopping 22 | end 23 | end 24 | 25 | def process(conn) 26 | raise "BUG: Worker::Fair#process should never be called" 27 | end 28 | 29 | def wait 30 | # nothing to wait - @ractor.take consumes closed connections unexpectedly 31 | # @ractor.wait ? 32 | end 33 | end 34 | end 35 | end 36 | -------------------------------------------------------------------------------- /right_speed.gemspec: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative "lib/right_speed/version" 4 | 5 | Gem::Specification.new do |spec| 6 | spec.name = "right_speed" 7 | spec.version = RightSpeed::VERSION 8 | spec.authors = ["Satoshi Moris Tagomori"] 9 | spec.email = ["tagomoris@gmail.com"] 10 | 11 | spec.summary = "HTTP server implementation using Ractor" 12 | spec.description = "HTTP server, which provides traffic under the support of Ractor" 13 | spec.homepage = "https://github.com/tagomoris/right_speed" 14 | spec.license = "MIT" 15 | spec.required_ruby_version = Gem::Requirement.new(">= 3.0.2") 16 | 17 | spec.metadata["homepage_uri"] = spec.homepage 18 | 19 | # Specify which files should be added to the gem when it is released. 20 | # The `git ls-files -z` loads the files in the RubyGem that have been added into git. 21 | spec.files = Dir.chdir(File.expand_path(__dir__)) do 22 | `git ls-files -z`.split("\x0").reject { |f| f.match(%r{\A(?:test|spec|features|snippets)/}) } 23 | end 24 | spec.bindir = "bin" 25 | spec.executables = spec.files.grep(%r{\Abin/}) { |f| File.basename(f) } 26 | spec.require_paths = ["lib"] 27 | 28 | spec.add_runtime_dependency "webrick", "~> 1.7" 29 | spec.add_runtime_dependency "rack", "~> 2.2" 30 | spec.add_runtime_dependency "concurrent-ruby", "~> 1.1" 31 | spec.add_runtime_dependency "http_parser.rb", "~> 0.8" 32 | 33 | spec.add_development_dependency "test-unit" 34 | end 35 | -------------------------------------------------------------------------------- /lib/right_speed/ractor_helper.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "uri" 4 | require "rack" 5 | 6 | module RightSpeed 7 | module RactorHelper 8 | def self.uri_hook 9 | # Use 3.1.0-dev! 10 | end 11 | 12 | def self.rack_hook 13 | ip_filter = Ractor.make_shareable(Rack::Request.ip_filter) 14 | overwrite_method(Rack::Request::Helpers, :trusted_proxy?) do |ip| 15 | ip_filter.call(ip) 16 | end 17 | overwrite_method(Rack::Request::Helpers, :query_parser, Rack::Utils.default_query_parser) 18 | overwrite_const(Rack::ShowExceptions, :TEMPLATE, Rack::ShowExceptions::TEMPLATE) 19 | freeze_all_constants(::Rack) 20 | end 21 | 22 | def self.freeze_all_constants(mojule, touch_list=[]) 23 | touch_list << mojule 24 | mojule.constants.each do |const_name| 25 | const = begin 26 | mojule.const_get(const_name) 27 | rescue LoadError 28 | # ignore unloadable modules (autoload, probably) 29 | nil 30 | end 31 | next unless const 32 | if const.is_a?(Module) && !touch_list.include?(const) 33 | # not freeze Module/Class because we're going to do monkey patching... 34 | freeze_all_constants(const, touch_list) 35 | else 36 | const.freeze 37 | end 38 | end 39 | end 40 | 41 | def self.overwrite_method(mojule, name, value=nil, &block) 42 | if block_given? 43 | mojule.define_method(name, Ractor.make_shareable(block)) 44 | else 45 | v = Ractor.make_shareable(value) 46 | mojule.define_method(name, Ractor.make_shareable(->(){ v })) 47 | end 48 | end 49 | 50 | def self.overwrite_const(mojule, name, value) 51 | v = Ractor.make_shareable(value) 52 | mojule.const_set(name, value) 53 | end 54 | end 55 | end 56 | -------------------------------------------------------------------------------- /lib/right_speed/server.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "socket" 4 | require "logger" 5 | require "webrick" 6 | 7 | require_relative "processor" 8 | require_relative "listener" 9 | require_relative "env" 10 | require_relative "ractor_helper" 11 | 12 | module RightSpeed 13 | CONFIG_HOOK_KEY = 'right_speed_config_hooks' 14 | 15 | class Server 16 | DEFAULT_HOST = "127.0.0.1" 17 | DEFAULT_PORT = 8080 18 | DEFAULT_WORKER_TYPE = :roundrobin 19 | DEFAULT_WORKERS = Env.processors 20 | 21 | AVAILABLE_WORKER_TYPES = [:roundrobin, :fair, :accept] 22 | 23 | attr_reader :config_hooks 24 | 25 | def initialize( 26 | app:, 27 | host: DEFAULT_HOST, 28 | port: DEFAULT_PORT, 29 | workers: DEFAULT_WORKERS, 30 | worker_type: DEFAULT_WORKER_TYPE, 31 | backlog: nil 32 | ) 33 | @host = host 34 | @port = port 35 | @app = app 36 | @workers = workers 37 | @worker_type = worker_type 38 | @backlog = backlog 39 | @config_hooks = [] 40 | @logger = nil 41 | end 42 | 43 | def run 44 | logger = RightSpeed.logger 45 | logger.info { "Start running with #{@workers} workers" } 46 | 47 | hooks = @config_hooks + (Ractor.current[RightSpeed::CONFIG_HOOK_KEY] || []) 48 | hooks.each do |hook| 49 | if hook.respond_to?(:call) 50 | hook.call 51 | end 52 | end 53 | 54 | RactorHelper.uri_hook 55 | RactorHelper.rack_hook 56 | 57 | begin 58 | processor = Processor.setup(app: @app, worker_type: @worker_type, workers: @workers) 59 | listener = Listener.setup(worker_type: @worker_type, host: @host, port: @port, backlog: nil) 60 | processor.configure(listener: listener) 61 | processor.run 62 | listener.wait 63 | processor.wait 64 | ensure 65 | listener.stop rescue nil 66 | processor.stop rescue nil 67 | end 68 | end 69 | end 70 | end 71 | -------------------------------------------------------------------------------- /lib/right_speed/listener.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative "logger" 4 | 5 | module RightSpeed 6 | module Listener 7 | def self.setup(worker_type:, host:, port:, backlog: nil) 8 | case worker_type 9 | when :roundrobin 10 | RoundRobinListener.new(host, port, backlog) 11 | when :fair 12 | FairListener.new(host, port, backlog) 13 | else 14 | SimpleListener.new(host, port, backlog) 15 | end 16 | end 17 | 18 | class SimpleListener 19 | attr_reader :sock 20 | 21 | def initialize(host, port, backlog) 22 | @host = host 23 | @port = port 24 | @backlog = backlog 25 | @sock = nil 26 | end 27 | 28 | def run 29 | @running = true 30 | @sock = TCPServer.open(@host, @port) 31 | @sock.listen(@backlog) if @backlog 32 | @sock 33 | end 34 | 35 | def wait 36 | # do nothing 37 | end 38 | 39 | def stop 40 | @running = false 41 | if @sock 42 | @sock.close rescue nil 43 | end 44 | end 45 | end 46 | 47 | class RoundRobinListener < SimpleListener 48 | attr_reader :ractor 49 | 50 | def run(processor) 51 | @running = true 52 | @ractor = Ractor.new(@host, @port, @backlog, processor) do |host, port, backlog, processor| 53 | logger = RightSpeed.logger 54 | sock = TCPServer.open(host, port) 55 | sock.listen(backlog) if backlog 56 | logger.info { "listening #{host}:#{port}" } 57 | while conn = sock.accept 58 | processor.process(conn) 59 | end 60 | end 61 | end 62 | 63 | def wait 64 | @ractor.take 65 | end 66 | 67 | def stop 68 | @running = false 69 | @ractor = nil # TODO: terminate the Ractor if possible 70 | end 71 | end 72 | 73 | class FairListener < RoundRobinListener 74 | def wait 75 | # nothing to wait - @ractor.take consumes accepted connections unexpectedly 76 | end 77 | end 78 | end 79 | end 80 | -------------------------------------------------------------------------------- /test/handler_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "test_helper" 4 | require "json" 5 | require "socket" 6 | require "net/http" 7 | require "uri" 8 | 9 | class HandlerTest < Test::Unit::TestCase 10 | App = lambda do |env| 11 | env['rack.input'] = env['rack.input'].read 12 | json = env.to_json 13 | [200, {'content-length' => json.size, 'content-type' => 'application/json'}, [json]] 14 | end 15 | 16 | def get(session, path, headers) 17 | session.get(path, headers) 18 | end 19 | 20 | def put(session, path, headers, body) 21 | session.put(path, body, headers) 22 | end 23 | 24 | def setup 25 | @handler = RightSpeed::Handler.new(App) 26 | @server = TCPServer.new("127.0.0.1", 0) # any ports available 27 | @server.listen(1) # backlog just 1 for the test request 28 | @port = @server.addr[1] 29 | @writer = lambda do |http_method, path, headers={}, body=nil| 30 | block = case http_method 31 | when :get 32 | ->(session){ get(session, path, headers) } 33 | when :put 34 | ->(session){ put(session, path, headers, body) } 35 | else 36 | raise "boo" 37 | end 38 | Thread.new do 39 | Net::HTTP.start("127.0.0.1", @port) do |session| 40 | block.call(session) 41 | end 42 | end 43 | end 44 | end 45 | 46 | test 'process and get response' do 47 | t = @writer.call(:get, '/path/of/endpoint?query=&key=value', {"user-agent" => "testing"}) 48 | conn = @server.accept 49 | @handler.session(conn).process 50 | 51 | res = t.value 52 | assert_equal("200", res.code) 53 | assert_equal(2, res.size) 54 | assert_equal("application/json", res["Content-Type"]) 55 | 56 | json = JSON.parse(res.body) 57 | assert_equal('/path/of/endpoint', json["PATH_INFO"]) 58 | assert_equal('query=&key=value', json["QUERY_STRING"]) 59 | assert(json["SERVER_SOFTWARE"].start_with?("RightSpeed #{RightSpeed::VERSION} "), json["SERVER_SOFTWARE"]) 60 | assert_equal("http://127.0.0.1:#{@port}/path/of/endpoint?query=&key=value", json["REQUEST_URI"]) 61 | assert_equal("testing", json["HTTP_USER_AGENT"]) 62 | assert_equal("", json["rack.input"]) 63 | end 64 | end 65 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # RightSpeed 2 | 3 | RightSpeed is **an experimental application server** to host Rack applications, on Ractor workers, to test/verify that your application is Ractor-safe/Ractor-ready or not. 4 | Ractor is an experimental feature of Ruby 3.0, thus **this application server is also not for production environments**. 5 | 6 | Currently, RightSpeed supports the very limited set of Rack protocol specifications. Unsupported features are, for example: 7 | 8 | * Writing logs into files 9 | * Daemonizing processes 10 | * Reloading applications without downtime 11 | * Handling session objects (using `rack.session`) 12 | * Handling multipart contents flexisbly (using `rack.multipart.buffer_size` nor `rack.multipart.tempfile_factory`) 13 | * [Hijacking](https://github.com/rack/rack/blob/master/SPEC.rdoc#label-Hijacking) 14 | 15 | ### Is Ractor-based server faster than prefork processes? 16 | 17 | It can be. In our opinion, it may not be a tremendous difference, but could be a little improvement because: 18 | * Accepted connection delivery inter-Ractor should be faster than bringing those over IPC 19 | * JIT compilation can be just once using multiple Ractor 20 | * ... and? 21 | 22 | ## Changelog 23 | 24 | * v0.2.0: 25 | * Add worker-type "fair" and "accept" in addition to "roundrobin" 26 | * v0.1.0: 27 | * The first release just before RubyKaigi Takeout 2021 28 | 29 | ## Usage 30 | 31 | Use the latest Ruby 3.x release! 32 | 33 | Install `right_speed` by `gem` command (`gem i right_speed`), then use it directly: 34 | 35 | ``` 36 | $ right_speed -c config.ru -p 8080 --workers 8 37 | 38 | $ right_speed --help 39 | Usage: right_speed [options] 40 | 41 | OPTIONS 42 | --config, -c PATH The path of the rackup configuration file (default: config.ru) 43 | --port, -p PORT The port number to listen (default: 8080) 44 | --backlog NUM The number of backlog 45 | --workers NUM The number of Ractors (default: CPU cores) 46 | --worker-type TYPE The type of workers (available: roundrobin/fair/accept, default: roundrobin) 47 | --help Show this message 48 | ``` 49 | 50 | Or, use `rackup` with `-s right_speed`: 51 | 52 | ``` 53 | $ rackup config.ru -s right_speed -p 8080 -O Workers=8 54 | ``` 55 | 56 | The default number of worker Ractors is the number of CPU cores. 57 | 58 | ### Worker Types 59 | 60 | The `--worker-type` option is to try some patterns of use of Ractors. 61 | 62 | * `roundrobin` 63 | * Listener Ractor will accept connections, then send those to Worker Ractors in round-robin 64 | * Worker Ractors will consume their input connections one-by-one 65 | * `fair` 66 | * Listener Ractor will accept connections, and yield those to consumers (workers) 67 | * Worker Ractors will take connections from Listener as soon as they become available 68 | * `accept` 69 | * Listener does nothing 70 | * Worker Ractors will accept connections, process requests individually 71 | 72 | Currently, any of above workers cannot work well. We observed SEGV or Ruby runtime busy after traffic in seconds. 73 | 74 | ## Contributing 75 | 76 | Bug reports and pull requests are welcome on GitHub at https://github.com/tagomoris/right_speed. 77 | 78 | ## License 79 | 80 | The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT). 81 | -------------------------------------------------------------------------------- /bin/right_speed: -------------------------------------------------------------------------------- 1 | #!ruby 2 | # frozen_string_literal: true 3 | 4 | require_relative "../lib/right_speed/env" 5 | require_relative "../lib/right_speed/server" 6 | require "getoptlong" 7 | 8 | module RightSpeed 9 | module Command 10 | Options = Struct.new( 11 | :rackup, :port, :backlog, 12 | :workers, :worker_type, 13 | keyword_init: true, 14 | ) 15 | 16 | COMMAND_OPTIONS = [ 17 | ['--config', '-c', GetoptLong::REQUIRED_ARGUMENT], 18 | ['--port', '-p', GetoptLong::REQUIRED_ARGUMENT], 19 | ['--backlog', GetoptLong::REQUIRED_ARGUMENT], 20 | ['--workers', GetoptLong::REQUIRED_ARGUMENT], 21 | ['--worker-type', GetoptLong::REQUIRED_ARGUMENT], 22 | ['--help', GetoptLong::NO_ARGUMENT], 23 | ] 24 | 25 | DEFAULT_RACKUP_PATH = 'config.ru' 26 | DEFAULT_PORT = Server::DEFAULT_PORT 27 | DEFAULT_WORKERS = Env.processors 28 | DEFAULT_WORKER_TYPE = Server::DEFAULT_WORKER_TYPE 29 | 30 | AVAILABLE_WORKER_TYPES = Server::AVAILABLE_WORKER_TYPES.map(&:to_s).join('/') 31 | 32 | def self.show_help(error: false, error_message: nil) 33 | STDERR.puts(error_message, "\n") if error_message 34 | STDERR.puts <<~EOS 35 | Usage: right_speed [options] 36 | 37 | OPTIONS 38 | --config, -c PATH The path of the rackup configuration file (default: #{DEFAULT_RACKUP_PATH}) 39 | --port, -p PORT The port number to listen (default: #{DEFAULT_PORT}) 40 | --backlog NUM The number of backlog 41 | --workers NUM The number of Ractors (default: CPU cores, #{DEFAULT_WORKERS}) 42 | --worker-type TYPE The type of workers (available: #{AVAILABLE_WORKER_TYPES}, default: #{DEFAULT_WORKER_TYPE}) 43 | --help Show this message 44 | EOS 45 | exit(error ? 1 : 0) 46 | end 47 | 48 | def self.integer_value(value, name) 49 | Integer(value) 50 | rescue 51 | show_help(error: true, error_message: "#{name} should be an Integer: #{value}") 52 | end 53 | 54 | def self.parse_command_line_options 55 | optparse = GetoptLong.new 56 | optparse.set_options(*COMMAND_OPTIONS) 57 | options = Options.new( 58 | rackup: DEFAULT_RACKUP_PATH, 59 | port: DEFAULT_PORT, 60 | backlog: nil, 61 | workers: DEFAULT_WORKERS, 62 | worker_type: DEFAULT_WORKER_TYPE, 63 | ) 64 | worker_type = :read 65 | optparse.each_option do |name, value| 66 | case name 67 | when '--config' 68 | options.rackup = value 69 | when '--port' 70 | options.port = integer_value(value, "Port number") 71 | when '--backlog' 72 | options.backlog = integer_value(value, "Backlog") 73 | when '--workers' 74 | options.workers = integer_value(value, "Workers") 75 | when '--worker-type' 76 | options.worker_type = value.to_sym 77 | when '--help' 78 | show_help 79 | else 80 | show_help(error: true, error_messsage: "Unknown option: #{name}") 81 | end 82 | end 83 | options 84 | end 85 | 86 | def self.start 87 | options = parse_command_line_options 88 | server = begin 89 | RightSpeed::Server.new( 90 | port: options.port, 91 | app: options.rackup, 92 | backlog: options.backlog, 93 | workers: options.workers, 94 | worker_type: options.worker_type, 95 | ) 96 | rescue => e 97 | show_help(error: true, error_message: "Failed to launch the server, " + e.message) 98 | end 99 | server.run 100 | end 101 | end 102 | end 103 | 104 | RightSpeed::Command.start 105 | -------------------------------------------------------------------------------- /lib/right_speed/processor.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'rack/builder' 4 | 5 | require_relative 'worker/accepter' 6 | require_relative 'worker/fair' 7 | require_relative 'worker/roundrobin' 8 | require_relative 'connection_closer' 9 | 10 | module RightSpeed 11 | module Processor 12 | def self.setup(app:, worker_type:, workers:) 13 | app = if app.respond_to?(:call) 14 | app 15 | elsif app.is_a?(String) # rackup config path 16 | build_app(app) 17 | else 18 | raise "Unexpected app #{app}" 19 | end 20 | handler = Ractor.make_shareable(Handler.new(app)) 21 | case worker_type 22 | when :roundrobin 23 | RoundRobinProcessor.new(workers, handler) 24 | when :fair 25 | FairProcessor.new(workers, handler) 26 | when :accept 27 | AcceptProcessor.new(workers, handler) 28 | else 29 | raise "Unknown worker type #{worker_type}" 30 | end 31 | end 32 | 33 | def self.build_app(ru) 34 | app = Rack::Builder.parse_file(ru) 35 | if app.respond_to?(:call) 36 | app 37 | elsif app.is_a?(Array) && app[0].respond_to?(:call) 38 | # Rack::Builder returns [app, options] but options will be deprecated 39 | app[0] 40 | else 41 | raise "Failed to build Rack app from #{ru}: #{app}" 42 | end 43 | end 44 | 45 | class Base 46 | def initialize(workers, handler) 47 | raise "BUG: use implementation class" 48 | end 49 | 50 | def configure(listener:) 51 | raise "BUG: not implemented" 52 | end 53 | 54 | def run 55 | raise "BUG: not implemented" 56 | end 57 | 58 | def process(conn) 59 | raise "BUG: not implemented" 60 | end 61 | 62 | def wait 63 | raise "BUG: not implemented" 64 | # ractors.each{|r| r.take} 65 | # finalizer.close rescue nil 66 | end 67 | end 68 | 69 | class RoundRobinProcessor < Base 70 | def initialize(workers, handler) 71 | @worker_num = workers 72 | @handler = handler 73 | @workers = workers.times.map{|i| Worker::RoundRobin.new(id: i, handler: @handler)} 74 | @closer = ConnectionCloser.new 75 | @counter = 0 76 | end 77 | 78 | def configure(listener:) 79 | @listener = listener 80 | end 81 | 82 | def run 83 | @workers.each{|w| w.run} 84 | @closer.run(@workers.map{|w| w.ractor}) 85 | @listener.run(self) 86 | end 87 | 88 | def process(conn) 89 | current, @counter = @counter, @counter + 1 90 | @workers[current % @worker_num].process(conn) 91 | end 92 | 93 | def wait 94 | @workers.each{|w| w.wait} 95 | @closer.wait 96 | end 97 | end 98 | 99 | class FairProcessor < Base 100 | def initialize(workers, handler) 101 | @worker_num = workers 102 | @handler = handler 103 | @workers = workers.times.map{|i| Worker::Fair.new(id: i, handler: @handler)} 104 | @closer = ConnectionCloser.new 105 | end 106 | 107 | def configure(listener:) 108 | @listener = listener 109 | end 110 | 111 | def run 112 | @listener.run(self) 113 | @workers.each{|w| w.run(@listener.ractor)} 114 | @closer.run(@workers.map{|w| w.ractor}) 115 | end 116 | 117 | def process(conn) 118 | Ractor.yield(conn, move: true) 119 | end 120 | 121 | def wait 122 | # listener, workers are using those outgoing to pass connections 123 | @closer.wait 124 | end 125 | end 126 | 127 | class AcceptProcessor < Base 128 | def initialize(workers, handler) 129 | @worker_num = workers 130 | @handler = handler 131 | @workers = workers.times.map{|i| Worker::Accepter.new(id: i, handler: @handler) } 132 | @closer = ConnectionCloser.new 133 | end 134 | 135 | def configure(listener:) 136 | @listener = listener 137 | end 138 | 139 | def run 140 | @listener.run 141 | @workers.each{|w| w.run(@listener.sock)} 142 | @closer.run(@workers.map{|w| w.ractor}) 143 | end 144 | 145 | def wait 146 | # workers are using those outgoing to pass connections 147 | @closer.wait 148 | end 149 | end 150 | end 151 | end 152 | -------------------------------------------------------------------------------- /lib/right_speed/handler.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | require "logger" 3 | require "stringio" 4 | require "http/parser" 5 | require "rack" 6 | 7 | require "pp" 8 | 9 | require_relative "./const" 10 | 11 | module RightSpeed 12 | class Handler 13 | def initialize(app) 14 | @app = app 15 | end 16 | 17 | def session(conn) 18 | Session.new(self, conn) 19 | end 20 | 21 | def process(session, client, request) 22 | # https://github.com/rack/rack/blob/master/SPEC.rdoc 23 | env = { 24 | # TODO: replace the keys using constants: https://github.com/rack/rack/blob/master/lib/rack.rb 25 | 'HTTP_VERSION' => request.http_version, 26 | 'PATH_INFO' => request.path_info, 27 | 'QUERY_STRING' => request.query_string || "", 28 | 'REMOTE_ADDR' => client.addr, 29 | 'REQUEST_METHOD' => request.http_method, 30 | 'REQUEST_PATH' => request.path_info, 31 | 'REQUEST_URI' => request.request_uri, 32 | 'SCRIPT_NAME' => "", 33 | 'SERVER_NAME' => client.server_addr, 34 | 'SERVER_PORT' => client.server_port.to_s, 35 | 'SERVER_PROTOCOL' => request.http_version, 36 | 'SERVER_SOFTWARE' => RightSpeed::SOFTWARE_NAME, 37 | **request.headers_in_env_style, 38 | ### Rack specific keys 39 | 'rack.version' => RightSpeed::RACK_VERSION, 40 | 'rack.url_scheme' => 'http', # http or https, depending on the request URL. 41 | 'rack.input' => request.body, # The input stream. 42 | 'rack.errors' => $stderr, # The error stream. 43 | 'rack.multithread' => true, 44 | 'rack.multiprocess' => false, 45 | 'rack.run_once' => false, 46 | 'rack.hijack?' => false, # https://github.com/rack/rack/blob/master/SPEC.rdoc#label-Hijacking 47 | ### Optional Rack keys 48 | ## 'rack.session' 49 | # A hash like interface for storing request session data. 50 | # The store must implement: 51 | # store(key, value) (aliased as []=); fetch(key, default = nil) (aliased as []); 52 | # delete(key); clear; to_hash (returning unfrozen Hash instance); 53 | 'rack.logger' => session.logger, 54 | # A common object interface for logging messages. 55 | # The object must implement: 56 | # info(message, &block),debug(message, &block),warn(message, &block),error(message, &block),fatal(message, &block) 57 | ## 'rack.multipart.buffer_size' 58 | # An Integer hint to the multipart parser as to what chunk size to use for reads and writes. 59 | ## 'rack.multipart.tempfile_factory' 60 | # An object responding to #call with two arguments, the filename and content_type given for the multipart form field, 61 | # and returning an IO-like object that responds to #<< and optionally #rewind. This factory will be used to instantiate 62 | # the tempfile for each multipart form file upload field, rather than the default class of Tempfile. 63 | } 64 | status, headers, body = @app.call(env) 65 | Response.new(http_version: request.http_version, status_code: status, headers: headers, body: body) 66 | end 67 | 68 | class Client 69 | attr_reader :addr, :port, :server_addr, :server_port 70 | 71 | def initialize(conn) 72 | _, @port, _, @addr = conn.peeraddr 73 | _, @server_port, _, @server_addr = conn.addr 74 | if @server_addr == "::1" 75 | @server_addr = "localhost" 76 | end 77 | end 78 | end 79 | 80 | class Request 81 | attr_reader :http_method, :http_version, :request_url, :headers, :body, :path_info, :query_string 82 | 83 | def initialize(client:, http_method:, http_version:, request_url:, headers:, body:) 84 | @client = client 85 | @http_method = http_method 86 | @http_version = "HTTP/" + http_version.map(&:to_s).join(".") 87 | @request_url = request_url 88 | @headers = headers 89 | @body = StringIO.new(body) 90 | 91 | @path_info, @query_string = request_url.split('?') 92 | end 93 | 94 | def request_uri 95 | "http://#{@client.server_addr}:#{@client.server_port}#{request_url}" 96 | end 97 | 98 | def headers_in_env_style 99 | headers = {} 100 | @headers.each do |key, value| 101 | headers["HTTP_" + key.gsub("-", "_").upcase] = value 102 | end 103 | headers 104 | end 105 | end 106 | 107 | class Response 108 | STATUS_MESSAGE_MAP = { 109 | 200 => "OK", 110 | }.freeze 111 | 112 | attr_reader :body 113 | 114 | def initialize(http_version:, status_code:, headers:, body:) 115 | @http_version = http_version 116 | @status_code = status_code 117 | @status_message = STATUS_MESSAGE_MAP.fetch(status_code, "Unknown") 118 | @headers = headers 119 | @body = body 120 | end 121 | 122 | def status 123 | "#{@http_version} #{@status_code} #{@status_message}\r\n" 124 | end 125 | 126 | def headers 127 | @headers.map{|key, value| "#{key}: #{value}\r\n" }.join + "\r\n" 128 | end 129 | end 130 | 131 | class Session 132 | READ_CHUNK_LENGTH = 1024 133 | 134 | attr_reader :logger 135 | 136 | def initialize(handler, conn) 137 | @logger = RightSpeed.logger 138 | @handler = handler 139 | @conn = conn 140 | @client = Client.new(conn) 141 | 142 | # https://github.com/tmm1/http_parser.rb 143 | @parser = Http::Parser.new(self, default_header_value_type: :mixed) 144 | @reading = true 145 | @method = nil 146 | @url = nil 147 | @headers = nil 148 | @body = String.new 149 | end 150 | 151 | # TODO: implement handling of "Connection" and "Keep-Alive" 152 | # https://developer.mozilla.org/ja/docs/Web/HTTP/Headers/Connection 153 | # https://developer.mozilla.org/ja/docs/Web/HTTP/Headers/Keep-Alive 154 | 155 | def process 156 | while @reading && !@conn.eof? 157 | @parser << @conn.readpartial(READ_CHUNK_LENGTH) 158 | end 159 | end 160 | 161 | def on_headers_complete(headers) 162 | @headers = headers 163 | @method = @parser.http_method 164 | @url = @parser.request_url 165 | end 166 | 167 | def on_body(chunk) 168 | @body << chunk 169 | end 170 | 171 | def on_message_complete 172 | # @logger.debug { 173 | # "complete to read the request, headers:#{@headers}, body:#{@body}" 174 | # } 175 | request = Request.new( 176 | client: @client, http_method: @method, http_version: @parser.http_version, 177 | request_url: @url, headers: @headers, body: @body 178 | ) 179 | response = @handler.process(self, @client, request) 180 | send_response(response) 181 | @reading = false 182 | end 183 | 184 | def send_response(response) 185 | @conn.write response.status 186 | @conn.write response.headers 187 | response.body.each do |part| 188 | @conn.write part 189 | end 190 | end 191 | end 192 | end 193 | end 194 | --------------------------------------------------------------------------------