├── .gitignore ├── Gemfile ├── Gemfile.lock ├── apps ├── cpu_heavy_app.rb ├── file_serving_app.rb └── web_request_app.rb ├── readme.md ├── servers ├── fiber_server.rb ├── http_utils │ ├── http_responder.rb │ └── request_parser.rb ├── multi_threaded_server.rb ├── ractor_server.rb └── single_threaded_server.rb ├── start.rb └── test.txt /.gitignore: -------------------------------------------------------------------------------- 1 | .idea/ 2 | *.iml 3 | 4 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source 'https://rubygems.org' 2 | 3 | gem 'libev_scheduler', github: 'digital-fabric/libev_scheduler' 4 | -------------------------------------------------------------------------------- /Gemfile.lock: -------------------------------------------------------------------------------- 1 | GIT 2 | remote: https://github.com/digital-fabric/libev_scheduler.git 3 | revision: aa98b229673ab851349973274c31757f4699633e 4 | specs: 5 | libev_scheduler (0.2) 6 | 7 | GEM 8 | remote: https://rubygems.org/ 9 | specs: 10 | 11 | PLATFORMS 12 | x86_64-linux 13 | 14 | DEPENDENCIES 15 | libev_scheduler! 16 | 17 | BUNDLED WITH 18 | 2.2.15 19 | -------------------------------------------------------------------------------- /apps/cpu_heavy_app.rb: -------------------------------------------------------------------------------- 1 | class CpuHeavyApp 2 | def call(env) 3 | # this is SLOWER with ractors 4 | # 10.times do |i| 5 | # 1000.downto(1) do |j| 6 | # Math.sqrt(j) * i / 0.2 7 | # end 8 | # end 9 | 10 | 100.times do |i| 11 | Math.sqrt(23467**2436) * i / 0.2 12 | end 13 | 14 | [200, { "Content-Type" => "text/html" }, ["42"]] 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /apps/file_serving_app.rb: -------------------------------------------------------------------------------- 1 | class FileServingApp 2 | # read file from the filesystem based on a path from 3 | # a request, e.g. "/test.txt" 4 | def call(env) 5 | path = Dir.getwd + env['PATH_INFO'] 6 | if File.exist?(path) 7 | body = File.read(path) 8 | [200, { "Content-Type" => "text/html" }, [body]] 9 | else 10 | [404, { "Content-Type" => "text/html" }, ['']] 11 | end 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /apps/web_request_app.rb: -------------------------------------------------------------------------------- 1 | require 'open-uri' 2 | 3 | class WebRequestApp 4 | def call(env) 5 | # unfortunately, URI is not going to work with ractors yet 6 | # https://bugs.ruby-lang.org/issues/17592 7 | 8 | body = URI.open('http://example.com').read 9 | [200, { "Content-Type" => "text/html" }, [body]] 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 | # Ruby 3 HTTP Server 2 | 3 | This project demonstrates a toy implementation of an HTTP server using Ractors / Fibers from Ruby 3, as well as single and multi-threaded implementations 4 | 5 | **Important**: Ractors as Ruby team states are not production ready, you may experience random segfaults and random errors, nevertheless this approach may be viable in the future and presents a great learning opportunity 6 | 7 | ## Usage 8 | 9 | 1. Make sure you have ruby 3.0.1 installed. 10 | 2. Edit start.rb to select the server, and the app you want to run 11 | 3. Run: 12 | ``` 13 | bundle install 14 | bundle exec ruby start.rb 15 | ``` 16 | 17 | You can send requests via 18 | ``` 19 | ab -n 10000 -c 4 http://127.0.0.1:3000/test.txt 20 | ``` 21 | -------------------------------------------------------------------------------- /servers/fiber_server.rb: -------------------------------------------------------------------------------- 1 | require 'socket' 2 | require 'libev_scheduler' 3 | require_relative './http_utils/request_parser' 4 | require_relative './http_utils/http_responder' 5 | 6 | class FiberServer 7 | PORT = ENV.fetch('PORT', 3000) 8 | HOST = ENV.fetch('HOST', '127.0.0.1').freeze 9 | SOCKET_READ_BACKLOG = ENV.fetch('TCP_BACKLOG', 12).to_i 10 | 11 | attr_accessor :app 12 | 13 | # app: Rack app 14 | def initialize(app) 15 | self.app = app 16 | end 17 | 18 | def start 19 | # Fibers are not going to work without a scheduler. 20 | # A scheduler is on for a current thread. 21 | # Some scheduler choices: 22 | # evt: https://github.com/dsh0416/evt 23 | # libev_scheduler: https://github.com/digital-fabric/libev_scheduler 24 | # Async: https://github.com/socketry/async 25 | Fiber.set_scheduler(Libev::Scheduler.new) 26 | 27 | Fiber.schedule do 28 | server = TCPServer.new(HOST, PORT) 29 | server.listen(SOCKET_READ_BACKLOG) 30 | loop do 31 | conn, _addr_info = server.accept 32 | # ideally we need to limit number of fibers 33 | # via a thread pool, as accepting infinite number 34 | # of request is a bad idea: 35 | # we can run out of memory or other resources, 36 | # there are diminishing returns to too many fibers, 37 | # without backpressure to however is sending the requests it's hard 38 | # to properly load balance and queue requests 39 | Fiber.schedule do 40 | request = RequestParser.call(conn) 41 | status, headers, body = app.call(request) 42 | HttpResponder.call(conn, status, headers, body) 43 | rescue => e 44 | puts e.message 45 | ensure 46 | conn&.close 47 | end 48 | end 49 | end 50 | end 51 | end 52 | -------------------------------------------------------------------------------- /servers/http_utils/http_responder.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class HttpResponder 4 | STATUS_MESSAGES = { 5 | 100 => 'Continue', 6 | 101 => 'Switching Protocols', 7 | 200 => 'OK', 8 | 201 => 'Created', 9 | 202 => 'Accepted', 10 | 203 => 'Non-Authoritative Information', 11 | 204 => 'No Content', 12 | 205 => 'Reset Content', 13 | 206 => 'Partial Content', 14 | 207 => 'Multi-Status', 15 | 300 => 'Multiple Choices', 16 | 301 => 'Moved Permanently', 17 | 302 => 'Found', 18 | 303 => 'See Other', 19 | 304 => 'Not Modified', 20 | 305 => 'Use Proxy', 21 | 307 => 'Temporary Redirect', 22 | 400 => 'Bad Request', 23 | 401 => 'Unauthorized', 24 | 402 => 'Payment Required', 25 | 403 => 'Forbidden', 26 | 404 => 'Not Found', 27 | 405 => 'Method Not Allowed', 28 | 406 => 'Not Acceptable', 29 | 407 => 'Proxy Authentication Required', 30 | 408 => 'Request Timeout', 31 | 409 => 'Conflict', 32 | 410 => 'Gone', 33 | 411 => 'Length Required', 34 | 412 => 'Precondition Failed', 35 | 413 => 'Request Entity Too Large', 36 | 414 => 'Request-URI Too Large', 37 | 415 => 'Unsupported Media Type', 38 | 416 => 'Request Range Not Satisfiable', 39 | 417 => 'Expectation Failed', 40 | 422 => 'Unprocessable Entity', 41 | 423 => 'Locked', 42 | 424 => 'Failed Dependency', 43 | 426 => 'Upgrade Required', 44 | 428 => 'Precondition Required', 45 | 429 => 'Too Many Requests', 46 | 431 => 'Request Header Fields Too Large', 47 | 451 => 'Unavailable For Legal Reasons', 48 | 500 => 'Internal Server Error', 49 | 501 => 'Not Implemented', 50 | 502 => 'Bad Gateway', 51 | 503 => 'Service Unavailable', 52 | 504 => 'Gateway Timeout', 53 | 505 => 'HTTP Version Not Supported', 54 | 507 => 'Insufficient Storage', 55 | 511 => 'Network Authentication Required', 56 | }.freeze 57 | 58 | # status: int 59 | # headers: Hash 60 | # body: array of strings 61 | def self.call(conn, status, headers, body) 62 | # status line 63 | status_text = STATUS_MESSAGES[status] 64 | conn.send("HTTP/1.1 #{status} #{status_text}\r\n", 0) 65 | 66 | # headers 67 | # we need to tell how long the body is before sending anything, 68 | # this way the remote client knows when to stop reading 69 | conn.send("Content-Length: #{body.sum(&:length)}\r\n", 0) 70 | headers.each_pair do |name, value| 71 | conn.send("#{name}: #{value}\r\n", 0) 72 | end 73 | 74 | # tell that we don't want to keep the connection open 75 | conn.send("Connection: close\r\n", 0) 76 | 77 | # separate headers from body with an empty line 78 | conn.send("\r\n", 0) 79 | 80 | # body 81 | body.each do |chunk| 82 | conn.send(chunk, 0) 83 | end 84 | end 85 | end 86 | -------------------------------------------------------------------------------- /servers/http_utils/request_parser.rb: -------------------------------------------------------------------------------- 1 | require 'stringio' 2 | require 'uri' 3 | 4 | class RequestParser 5 | MAX_URI_LENGTH = 2083 6 | MAX_HEADER_LENGTH = (112 * 1024) 7 | 8 | class << self 9 | def call(conn) 10 | method, full_path, path, query = read_request_line(conn) 11 | 12 | headers = read_headers(conn) 13 | 14 | body = read_body(conn: conn, method: method, headers: headers) 15 | 16 | peeraddr = conn.peeraddr 17 | addr = conn.addr 18 | port = addr[1] 19 | remote_host = peeraddr[2] 20 | remote_address = peeraddr[3] 21 | { 22 | 'REQUEST_METHOD' => method, 23 | 'PATH_INFO' => path, 24 | 'QUERY_STRING' => query, 25 | "rack.input" => body ? StringIO.new(body) : nil, 26 | "REMOTE_ADDR" => remote_address, 27 | "REMOTE_HOST" => remote_host, 28 | "REQUEST_URI" => make_request_uri( 29 | full_path: full_path, 30 | port: port, 31 | remote_host: remote_host 32 | ) 33 | }.merge(rack_headers(headers)) 34 | end 35 | 36 | private 37 | 38 | def read_request_line(conn) 39 | # e.g. "POST /some-path?query HTTP/1.1" 40 | 41 | # read until we encounter a newline, max length is MAX_URI_LENGTH 42 | request_line = conn.gets("\n", MAX_URI_LENGTH) 43 | 44 | raise StandardError, "EOF" unless request_line 45 | 46 | method, full_path, _http_version = request_line.strip.split(' ', 3) 47 | 48 | path, query = full_path.split('?', 2) 49 | 50 | [method, full_path, path, query] 51 | end 52 | 53 | def read_headers(conn) 54 | headers = {} 55 | loop do 56 | line = conn.gets("\n", MAX_HEADER_LENGTH)&.strip 57 | 58 | break if line.nil? || line.strip.empty? 59 | 60 | # header name and value are separated by colon and space 61 | key, value = line.split(/:\s/, 2) 62 | 63 | # rack expects all headers to be prefixed with HTTP_ 64 | # and upper cased 65 | headers[key] = value 66 | end 67 | 68 | headers 69 | end 70 | 71 | def read_body(conn:, method:, headers:) 72 | return nil unless ['POST', 'PUT'].include?(method) 73 | 74 | remaining_size = headers['content-length'].to_i 75 | 76 | conn.read(remaining_size) 77 | end 78 | 79 | def rack_headers(headers) 80 | # rack expects all headers to be prefixed with HTTP_ 81 | # and upper cased 82 | headers.transform_keys do |key| 83 | "HTTP_#{key.upcase}" 84 | end 85 | end 86 | 87 | def make_request_uri(full_path:, port:, remote_host:) 88 | request_uri = URI::parse(full_path) 89 | request_uri.scheme = 'http' 90 | request_uri.host = remote_host 91 | request_uri.port = port 92 | request_uri.to_s 93 | end 94 | end 95 | end 96 | -------------------------------------------------------------------------------- /servers/multi_threaded_server.rb: -------------------------------------------------------------------------------- 1 | require 'socket' 2 | require_relative './http_utils/request_parser' 3 | require_relative './http_utils/http_responder' 4 | 5 | class ThreadPool 6 | attr_accessor :queue, :running, :size 7 | 8 | def initialize(size:) 9 | self.size = size 10 | 11 | # threadsafe queue to manage work 12 | self.queue = Queue.new 13 | 14 | size.times do 15 | Thread.new(self.queue) do |queue| 16 | # "catch" in Ruby is a lesser known 17 | # way to change flow of the program, 18 | # similar to propagating exceptions 19 | catch(:exit) do 20 | loop do 21 | # `pop` blocks until there's 22 | # something in the queue 23 | task = queue.pop 24 | task.call 25 | end 26 | end 27 | end 28 | end 29 | end 30 | 31 | def perform(&block) 32 | self.queue << block 33 | end 34 | 35 | def shutdown 36 | size.times do 37 | # this is going to make threads 38 | # break out of the infinite loop 39 | perform { throw :exit } 40 | end 41 | end 42 | end 43 | 44 | class MultiThreadedServer 45 | PORT = ENV.fetch('PORT', 3000) 46 | HOST = ENV.fetch('HOST', '127.0.0.1').freeze 47 | SOCKET_READ_BACKLOG = ENV.fetch('TCP_BACKLOG', 12).to_i 48 | WORKERS_COUNT = ENV.fetch('WORKERS', 4).to_i 49 | 50 | attr_accessor :app 51 | 52 | # app: Rack app 53 | def initialize(app) 54 | self.app = app 55 | end 56 | 57 | def start 58 | pool = ThreadPool.new(size: WORKERS_COUNT) 59 | socket = TCPServer.new(HOST, PORT) 60 | socket.listen(SOCKET_READ_BACKLOG) 61 | loop do 62 | conn, _addr_info = socket.accept 63 | # execute the request in one of the threads 64 | pool.perform do 65 | begin 66 | request = RequestParser.call(conn) 67 | status, headers, body = app.call(request) 68 | HttpResponder.call(conn, status, headers, body) 69 | rescue => e 70 | puts e.message 71 | ensure 72 | conn&.close 73 | end 74 | end 75 | end 76 | ensure 77 | pool&.shutdown 78 | end 79 | end 80 | -------------------------------------------------------------------------------- /servers/ractor_server.rb: -------------------------------------------------------------------------------- 1 | require 'socket' 2 | require 'uri' 3 | require_relative './http_utils/request_parser' 4 | require_relative './http_utils/http_responder' 5 | 6 | class RactorServer 7 | PORT = ENV.fetch('PORT', 3000) 8 | HOST = ENV.fetch('HOST', '127.0.0.1').freeze 9 | SOCKET_READ_BACKLOG = ENV.fetch('TCP_BACKLOG', 12).to_i 10 | WORKERS_COUNT = ENV.fetch('WORKERS', 4).to_i 11 | 12 | attr_accessor :app 13 | 14 | # app: Rack app 15 | def initialize(app) 16 | self.app = app 17 | # this is hack to make URI parsing work, 18 | # right now it's broken because this variable 19 | # is not marked as shareable 20 | Ractor.make_shareable(URI::RFC3986_PARSER) 21 | Ractor.make_shareable(URI::DEFAULT_PARSER) 22 | end 23 | 24 | def start 25 | # the queue is going to be used to 26 | # fairly dispatch incoming requests, 27 | # we pass the queue into workers 28 | # and the first free worker gets 29 | # the yielded request 30 | queue = Ractor.new do 31 | loop do 32 | conn = Ractor.receive 33 | Ractor.yield(conn, move: true) 34 | end 35 | end 36 | 37 | # workers determine concurrency 38 | WORKERS_COUNT.times.map do 39 | # we need to pass the queue and the server so they are available 40 | # inside Ractor 41 | Ractor.new(queue, self) do |queue, server| 42 | loop do 43 | # this method blocks until the queue yields a connection 44 | conn = queue.take 45 | request = RequestParser.call(conn) 46 | status, headers, body = server.app.call(request) 47 | HttpResponder.call(conn, status, headers, body) 48 | 49 | # I have found that not rescuing errors does not only kill the ractor, 50 | # but causes random `allocator undefined for Ractor::MovedObject` errors 51 | # which crashes the whole program 52 | rescue => e 53 | puts e.message 54 | ensure 55 | conn&.close 56 | end 57 | end 58 | end 59 | 60 | # the listener is going to accept new connections 61 | # and pass them onto the queue, 62 | # we make it a separate Ractor, because `yield` in queue 63 | # is a blocking operation, we wouldn't be able to accept new connections 64 | # until all previous were processed, and we can't use `send` to send 65 | # connections to workers because then we would send requests to workers 66 | # that might be busy 67 | listener = Ractor.new(queue) do |queue| 68 | socket = TCPServer.new(HOST, PORT) 69 | socket.listen(SOCKET_READ_BACKLOG) 70 | loop do 71 | conn, _addr_info = socket.accept 72 | queue.send(conn, move: true) 73 | end 74 | end 75 | 76 | Ractor.select(listener) 77 | end 78 | end 79 | -------------------------------------------------------------------------------- /servers/single_threaded_server.rb: -------------------------------------------------------------------------------- 1 | require 'socket' 2 | require_relative './http_utils/request_parser' 3 | require_relative './http_utils/http_responder' 4 | 5 | class SingleThreadedServer 6 | PORT = ENV.fetch('PORT', 3000) 7 | HOST = ENV.fetch('HOST', '127.0.0.1').freeze 8 | # number of incoming connections to keep in a buffer 9 | SOCKET_READ_BACKLOG = ENV.fetch('TCP_BACKLOG', 12).to_i 10 | 11 | attr_accessor :app 12 | 13 | # app: Rack app 14 | def initialize(app) 15 | self.app = app 16 | end 17 | 18 | def start 19 | socket = TCPServer.new(HOST, PORT) 20 | socket.listen(SOCKET_READ_BACKLOG) 21 | loop do 22 | conn, _addr_info = socket.accept 23 | request = RequestParser.call(conn) 24 | status, headers, body = app.call(request) 25 | HttpResponder.call(conn, status, headers, body) 26 | rescue => e 27 | puts e.message 28 | ensure 29 | conn&.close 30 | end 31 | end 32 | end 33 | -------------------------------------------------------------------------------- /start.rb: -------------------------------------------------------------------------------- 1 | require_relative 'servers/ractor_server' 2 | require_relative 'servers/fiber_server' 3 | require_relative 'servers/single_threaded_server' 4 | require_relative 'servers/multi_threaded_server' 5 | 6 | require_relative 'apps/file_serving_app' 7 | require_relative 'apps/cpu_heavy_app' 8 | require_relative 'apps/web_request_app' 9 | 10 | APP = CpuHeavyApp 11 | # APP = FileServingApp 12 | # APP = WebRequestApp 13 | 14 | # SERVER = FiberServer 15 | # SERVER = SingleThreadedServer 16 | # SERVER = MultiThreadedServer 17 | SERVER = RactorServer 18 | 19 | SERVER.new(APP.new).start 20 | -------------------------------------------------------------------------------- /test.txt: -------------------------------------------------------------------------------- 1 | this 2 | is 3 | a test 4 | response 5 | --------------------------------------------------------------------------------