Repository: digital-fabric/tipi Branch: master Commit: 7fd15c92e7dd Files: 99 Total size: 173.1 KB Directory structure: gitextract_pwtx6b4h/ ├── .github/ │ ├── FUNDING.yml │ └── workflows/ │ └── test.yml ├── .gitignore ├── CHANGELOG.md ├── Gemfile ├── LICENSE ├── README.md ├── Rakefile ├── TODO.md ├── benchmarks/ │ └── bm_http1_parser.rb ├── bin/ │ ├── benchmark │ ├── h1pd │ └── tipi ├── df/ │ ├── agent.rb │ ├── etc_benchmark.rb │ ├── multi_agent_supervisor.rb │ ├── multi_client.rb │ ├── routing_benchmark.rb │ ├── sample_agent.rb │ ├── server.rb │ ├── server_utils.rb │ ├── sse_page.html │ ├── stress.rb │ └── ws_page.html ├── docs/ │ └── README.md ├── examples/ │ ├── cuba.ru │ ├── full_service.rb │ ├── hanami-api.ru │ ├── hello.rb │ ├── hello.ru │ ├── http1_parser.rb │ ├── http_request_ws_server.rb │ ├── http_server.js │ ├── http_server.rb │ ├── http_server_forked.rb │ ├── http_server_form.rb │ ├── http_server_graceful.rb │ ├── http_server_routes.rb │ ├── http_server_simple.rb │ ├── http_server_static.rb │ ├── http_server_throttled.rb │ ├── http_server_throttled_accept.rb │ ├── http_server_timeout.rb │ ├── http_unix_socket_server.rb │ ├── http_ws_server.rb │ ├── https_server.rb │ ├── https_server_forked.rb │ ├── https_wss_server.rb │ ├── rack_server.rb │ ├── rack_server_forked.rb │ ├── rack_server_https.rb │ ├── rack_server_https_forked.rb │ ├── routing_server.rb │ ├── servername_cb.rb │ ├── source.rb │ ├── streaming.rb │ ├── websocket_client.rb │ ├── websocket_demo.rb │ ├── websocket_secure_server.rb │ ├── websocket_server.rb │ ├── ws_page.html │ ├── wss_page.html │ └── zlib-bench.rb ├── lib/ │ ├── tipi/ │ │ ├── acme.rb │ │ ├── cli.rb │ │ ├── config_dsl.rb │ │ ├── configuration.rb │ │ ├── controller/ │ │ │ ├── bare_polyphony.rb │ │ │ ├── bare_stock.rb │ │ │ ├── extensions.rb │ │ │ ├── stock_http1_adapter.rb │ │ │ ├── web_polyphony.rb │ │ │ └── web_stock.rb │ │ ├── controller.rb │ │ ├── digital_fabric/ │ │ │ ├── agent.rb │ │ │ ├── agent_proxy.rb │ │ │ ├── executive/ │ │ │ │ └── index.html │ │ │ ├── executive.rb │ │ │ ├── protocol.rb │ │ │ ├── request_adapter.rb │ │ │ └── service.rb │ │ ├── digital_fabric.rb │ │ ├── handler.rb │ │ ├── http1_adapter.rb │ │ ├── http2_adapter.rb │ │ ├── http2_stream.rb │ │ ├── rack_adapter.rb │ │ ├── response_extensions.rb │ │ ├── supervisor.rb │ │ ├── version.rb │ │ └── websocket.rb │ └── tipi.rb ├── test/ │ ├── coverage.rb │ ├── eg.rb │ ├── helper.rb │ ├── run.rb │ ├── test_http_server.rb │ └── test_request.rb └── tipi.gemspec ================================================ FILE CONTENTS ================================================ ================================================ FILE: .github/FUNDING.yml ================================================ github: noteflakes ================================================ FILE: .github/workflows/test.yml ================================================ name: Tests on: [push, pull_request] jobs: build: strategy: fail-fast: false matrix: os: [ubuntu-latest, macos-latest] ruby: ['3.2', '3.3', '3.4', 'head'] name: >- ${{matrix.os}}, ${{matrix.ruby}} runs-on: ${{matrix.os}} # env: # POLYPHONY_LIBEV: "1" steps: - name: Setup machine uses: actions/checkout@v1 - name: Setup Ruby uses: ruby/setup-ruby@v1 with: ruby-version: ${{matrix.ruby}} bundler-cache: true # 'bundle install' and cache cache-version: 2 - name: Run tests run: bundle exec rake test ================================================ FILE: .gitignore ================================================ *.gem *.rbc /.config /coverage/ /InstalledFiles /pkg/ /spec/reports/ /spec/examples.txt /test/tmp/ /test/version_tmp/ /tmp/ # Used by dotenv library to load environment variables. # .env # Ignore Byebug command history file. .byebug_history ## Specific to RubyMotion: .dat* .repl_history build/ *.bridgesupport build-iPhoneOS/ build-iPhoneSimulator/ ## Specific to RubyMotion (use of CocoaPods): # # We recommend against adding the Pods directory to your .gitignore. However # you should judge for yourself, the pros and cons are mentioned at: # https://guides.cocoapods.org/using/using-cocoapods.html#should-i-check-the-pods-directory-into-source-control # # vendor/Pods/ ## Documentation cache and generated files: /.yardoc/ /_yardoc/ /doc/ /rdoc/ ## Environment normalization: /.bundle/ /vendor/bundle /lib/bundler/man/ # for a library or gem, you might want to ignore these files since the code is # intended to run in multiple environments; otherwise, check them in: # Gemfile.lock # .ruby-version # .ruby-gemset # unless supporting rvm < 1.11.0 or doing something fancy, ignore this: .rvmrc # Used by RuboCop. Remote config files pulled in from inherit_from directive. # .rubocop-https?--* log log.* lib/tipi_ext* examples/certificate_store.db ================================================ FILE: CHANGELOG.md ================================================ ## 0.56 2025-10-21 - Update localhost, acme-client, websocket, http-2, extralite, qeweney, rack, bundler dependencies (#35) - Drop support for Ruby 3.1 (#35) - Add support for Ruby 3.3 and 3.4 (#35) ## 0.55 2023-07-29 - Simplify HTTP/1 exception handling - Update H1P dependency - Update Extralite dependency (#28) ## 0.54 2023-05-28 - Use `H1P.send_response` for sending response - Update Polyphony and H1P versions ## 0.53 2022-10-04 - Disregard `SystemCallError` in `Tipi.client_loop` ## 0.52 2022-03-03 - Treat HTTP/2 headers as immutable ## 0.51 2022-02-28 - Update dependencies ## 0.50 2022-02-10 - Update Qeweney ## 0.49 2022-02-07 - Update Polyphony ## 0.48 2022-02-04 - Update dependencies - Fix variable name in `Tipi.verify_path` (#16) - thanks @dm1try ## 0.47 2202-02-03 - Update H1P dependency ## 0.46 2022-02-01 - Allow setting valid hosts - Change interface of Qeweney apps to use #run (#15) - Close server listener before terminating connections ## 0.45 2021-10-25 - Remove `http_parser.rb` dependency (#14) - thanks @SwagDevOps - Use `argv` argument in `Tipi.opts_from_argv` (#13) - thanks @SwagDevOps - Ignore `ArgumentError` in `#parse_headers` ## 0.44 2021-09-29 - Implement compatibility mode for HTTP/1 (WIP) - Add option parsing for CLI tool - Implement supervisor-controller-worker model in CLI tool ## 0.43 2021-08-20 - Extract HTTP/1 parser into a separate gem: [H1P](https://github.com/digital-fabric/h1p) ## 0.42 2021-08-16 - HTTP/1 parser: disable UTF-8 parsing for all but header values - Add support for parsing HTTP/1 from callable source - Introduce full_service API for automatic HTTPS - Introduce automatic SSL certificate provisioning - Improve handling of exceptions - Various fixes to DF service and agent pxoy - Fix upgrading to HTTP2 with a request body - Switch to new HTTP/1 parser ## 0.41 2021-07-26 - Fix Rack adapter (#11) - Introduce experimental HTTP/1 parser - More work on DF server - Allow setting chunk size in `#respond_from_io` ## 0.40 2021-06-24 - Implement serving static files using splice_chunks (nice performance boost for files bigger than 1M) - Call shutdown before closing socket - Fix examples (thanks @timhatch!) ## 0.39 2021-06-20 - More work on DF server - Fix HTTP2StreamHandler#send_headers - Various fixes to HTTP/2 adapter - Fix host detection for HTTP/2 connections - Fix HTTP/1 adapter #respond with nil body - Fix HTTP1Adapter#send_headers ## 0.38 2021-03-09 - Don't use chunked transfer encoding for non-streaming responses ## 0.37.2 2021-03-08 - Fix header formatting when header value is an array ## 0.37 2021-02-15 - Update upgrade mechanism to work with updated Qeweney API ## 0.36 2021-02-12 - Use `Qeweney::Status` constants ## 0.35 2021-02-10 - Extract Request class into separate [qeweney](https://github.com/digital-fabric/qeweney) gem ## 0.34 2021-02-07 - Implement digital fabric service and agents - Add multipart and urlencoded form data parsing - Improve request body reading behaviour - Add more `Request` information methods - Add access to connection for HTTP2 requests - Allow calling `Request#send_chunk` with empty chunk - Add support for handling protocol upgrades from within request handler ## 0.33 2020-11-20 - Update code for Polyphony 0.47.5 - Add support for Rack::File body to Tipi::RackAdapter ## 0.32 2020-08-14 - Respond with array of strings instead of concatenating for HTTP 1 - Use read_loop instead of readpartial - Fix http upgrade test ## 0.31 2020-07-28 - Fix websocket server code - Implement configuration layer (WIP) - Improve performance of rack adapter ## 0.30 2020-07-15 - Rename project to Tipi - Rearrange source code - Remove HTTP client code (to be developed eventually into a separate gem) - Fix header rendering in rack adapter (#2) ## 0.29 2020-07-06 - Use IO#read_loop ## 0.28 2020-07-03 - Update with API changes from Polyphony >= 0.41 ## 0.27 2020-04-14 - Remove modulation dependency ## 0.26 2020-03-03 - Fix `Server#listen` ## 0.25 2020-02-19 - Ensure server socket is closed upon stopping loop - Fix `Request#format_header_lines` ## 0.24 2020-01-08 - Move HTTP to separate polyphony-http gem For earlier changes look at the Polyphony changelog. ================================================ FILE: Gemfile ================================================ source 'https://rubygems.org' gemspec ================================================ FILE: LICENSE ================================================ MIT License Copyright (c) 2021 Sharon Rosner Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ================================================ FILE: README.md ================================================

# Tipi - the All-in-one Web Server for Ruby Apps [![Gem Version](https://badge.fury.io/rb/tipi.svg)](http://rubygems.org/gems/tipi) [![Tipi Test](https://github.com/digital-fabric/tipi/workflows/Tests/badge.svg)](https://github.com/digital-fabric/tipi/actions?query=workflow%3ATests) [![MIT licensed](https://img.shields.io/badge/license-MIT-blue.svg)](https://github.com/digital-fabric/tipi/blob/master/LICENSE) ## What is Tipi? Tipi is an integrated, feature-complete HTTP/S server for Ruby applications. Tipi is built on top of [Polyphony](https://github.com/digital-fabric/polyphony), a robust, high-performance library for building highly-concurrent applications in Ruby. Tipi can be used to serve any Rack application or set of static files directly without having to employ a reverse-proxy such as Nginx. ## Features * High-performance, highly concurrent web server based on [Polyphony](https://github.com/digital-fabric/polyphony) * Full support for HTTP/1, HTTP/2, WebSocket protocols * Built-in SSL termination for secure, encrypted connections * **Automatic SSL certificates** using ACME providers such as Let's Encrypt (WIP) * Automatic ALPN protocol selection for serving HTTP/2 * Request and response body streaming for efficient downloads and uploads * Full support for Rack-based apps ## Benchmarks > Caveat emptor: the following results were obtained with an ad-hoc, manual > process. I am not really familiar with the servers I compared Tipi against, > and I ran them in their default configuration (apart from setting the number > of workers). Take these results with a bunch of salt. | |Tipi|Puma|Falcon|Unicorn| |-|---:|---:|-----:|------:| |HTTP/1.1|138629|34573|40714|7438| |HTTPS/2|56762|n/a|34226|n/a| ### Methodology - All servers ran the same "Hello world" [Rack application](https://github.com/digital-fabric/tipi/blob/master/examples/hello.ru) - Each server was run with 4 forked worker processes: - Tipi: `tipi -w4 -flocalhost:10080:10443 examples/hello.ru` - [Puma](https://github.com/puma/puma): `puma -w 4 examples/hello.ru` - [Falcon](https://github.com/socketry/falcon/): `falcon -n 4 -b http://localhost:9292/ -c examples/hello.ru` - [Unicorn](https://yhbt.net/unicorn/): `unicorn -c u.conf examples/hello.ru` with the configuration file containing the directive `worker_processes 4` - The benchmark results were obtained using `wrk -d60 -t4 -c64 ` - All servers were run on Ruby 2.7.2p137 - Machine specs: i5-8350U@1.7GHzx8 CPU, 8GB of RAM, running Linux kernel version 5.13.7 - Puma does not support HTTP/2. - As far as I could tell Unicorn does not support SSL termination. ## Running Tipi To run Tipi, run the included `tipi` command. Alternatively you can add tipi as a dependency to your Gemfile, then run `bundle exec tipi`. By default Tipi can be used to drive Rack apps or alternatively any app using the [Qeweney](https://github.com/digital-fabric/qeweney) request-response interface. ### Running Rack apps Use the `tipi` command to start your app: ```bash $ bundle exec tipi myapp.ru ``` ### Running Qeweney apps ```bash $ bundle exec tipi myapp.rb ``` The app script file should define an `app` method that returns a proc/lambda taking a single `Qeweney::Request` argument. Here's an example: ```ruby # frozen_string_literal: true def app ->(req) { req.respond('Hello, world!', 'Content-Type' => 'text/plain') } end ``` ## Setting server listening options By default, Tipi serves plain HTTP on port 1234, but you can easily change that by providing command line options as follows: ### HTTP To listen for plain HTTP, use the `-l`/`--listen` option and specify a port number: ```bash $ bundle exec tipi -l9292 myapp.ru ``` ### HTTPS To listen for HTTPS connections, use the `-s`/`--secure` option and specify a host name and a port: ```bash $ bundle exec tipi -sexample.com:9292 myapp.ru ``` ### Full service listening The Tipi full service listens for both HTTP and HTTPS and supports automatic certificate provisioning. To use the full service, use the `-f`/`--full` option, and specify the domain name, the HTTP port, and the HTTPS port, e.g.: ```bash $ bundle exec tipi -fmysite.org:10080:10443 myapp.ru #If serving multiple domains, you can use * as place holder $ bundle exec tipi -f*:10080:10443 myapp.ru ``` If `localhost` is specified as the domain, Tipi will automatically generate a localhost certificate. ## Concurrency settings By default, the `tipi` command starts a single controller and uses [Polyphony](https://github.com/digital-fabric/polyphony) to run each connection on its own fiber. This means that you will have a single process running on a single thread (on a single CPU core). In order to parallelize your app and employ multiple CPU cores, you can tell Tipi to fork multiple worker processes to run your app. The number of workers is controlled using the `-w`/`--workers` option: ```bash # fork 4 worker processes $ bundle exec tipi -w4 myapp.ru ``` You can also set Tipi to spawn multiple threads in each worker when in compatibility mode (see below.) ## Compatibility mode > Note: compatibility mode is still being developed, and currently only supports > HTTP/1 connections. In some apps, using Polyphony is not possible, due to incompatibilities between it and other third-party dependencies. In order to be able to run these apps, Tipi provides a compatibility mode that does not use Polyphony for concurrency, but instead uses a thread-per-connection concurrency model. You can also fork multiple workers, each running multiple threads, if so desired. Note that the concurrency level is the maximum number workers multiplied by the number of threads per worker: ``` concurrency = worker_count * threads_per_worker ``` To run Tipi in compatibility mode, use the `-c`/`--compatibility` option, e.g.: ```bash # 4 workers * 8 threads = 32 max concurrency $ bundle exec tipi -c -w4 -t8 myapp.ru ``` ## Worker process supervision Tipi employs a supervisor-controller-worker process supervision model, which minimizes the memory consumption of forked workers, and which facilitates graceful reloading after updating the application code. This supervision model is made of three levels: - Supervisor - Starts and stops the controller process - Controller - loads the application code and forks workers - Worker - listens for connections, handles incoming requests (If the worker count is 1, the Controller and Worker roles are merged into a single process.) This model allows Tipi to fork workers after loading the app code, and use a much simpler way to perform graceful restarts: - The supervisor starts a new controller process (which may fork one or more worker processes). - Sleep for a certain amount of time (currently 1 second.) - Stop the old controller process. - Each worker process is gracefully stopped and allowed to finish all pending requests, then shutdown all open connections. ## Performing a graceful restart A graceful restart performed by sending `SIGUSR2` to the supervisor process. ## Documentation Documentation for Tipi's API is coming soon... ================================================ FILE: Rakefile ================================================ # frozen_string_literal: true require "bundler/gem_tasks" require "rake/clean" task :default => [:test] task :test do exec 'ruby test/run.rb' end ================================================ FILE: TODO.md ================================================ ## Rethink design - Remove DF code - Remove non-Polyphony code # Miscellaneous - Try using `TCP_DEFER_ACCEPT` with Polyphony on io_uring - does it provide any performance benefit? # What about HTTP/2? It would be a nice exercise in converting a callback-based API to a blocking one: ```ruby parser = Tipi::HTTP2::Parser.new(socket) parser.each_stream(socket) do |stream| spin { handle_stream(stream) } end ``` # Roadmap - Improve Rack spec compliance, add tests - Homogenize HTTP 1 and HTTP 2 headers - downcase symbols - Use `http-2-next` instead of `http-2` for http/2 - https://gitlab.com/honeyryderchuck/http-2-next - Open an issue there, ask what's the difference between the two gems? ## 0.38 - Add more poly CLI commands and options: - serve static files from given directory - serve from rack up file - serve both http and https - use custom certificate files for SSL - set host address to bind to - set port to bind to - set forking process count ## 0.39 Working Sinatra application - app with database access (postgresql) - benchmarks! ================================================ FILE: benchmarks/bm_http1_parser.rb ================================================ # frozen_string_literal: true require 'bundler/setup' HTTP_REQUEST = "GET /foo HTTP/1.1\r\nHost: example.com\r\nAccept: */*\r\n\r\n" def measure_time_and_allocs 4.times { GC.start } GC.disable t0 = Time.now a0 = object_count yield t1 = Time.now a1 = object_count [t1 - t0, a1 - a0] ensure GC.enable end def object_count count = ObjectSpace.count_objects count[:TOTAL] - count[:FREE] end def benchmark_other_http1_parser(iterations) STDOUT << "http_parser.rb: " require 'http_parser.rb' i, o = IO.pipe parser = Http::Parser.new done = false headers = nil parser.on_headers_complete = proc do |h| headers = h headers[':method'] = parser.http_method headers[':path'] = parser.request_url end parser.on_message_complete = proc { done = true } elapsed, allocated = measure_time_and_allocs do iterations.times do o << HTTP_REQUEST done = false while !done msg = i.readpartial(4096) parser << msg end end end puts(format('elapsed: %f, allocated: %d (%f/req), rate: %f ips', elapsed, allocated, allocated.to_f / iterations, iterations / elapsed)) end def benchmark_tipi_http1_parser(iterations) STDOUT << "tipi parser: " require_relative '../lib/tipi_ext' i, o = IO.pipe reader = proc { |len| i.readpartial(len) } parser = Tipi::HTTP1Parser.new(reader) elapsed, allocated = measure_time_and_allocs do iterations.times do o << HTTP_REQUEST headers = parser.parse_headers end end puts(format('elapsed: %f, allocated: %d (%f/req), rate: %f ips', elapsed, allocated, allocated.to_f / iterations, iterations / elapsed)) end def fork_benchmark(method, iterations) pid = fork do send(method, iterations) rescue Exception => e p e p e.backtrace exit! end Process.wait(pid) end x = 500000 # fork_benchmark(:benchmark_other_http1_parser, x) # fork_benchmark(:benchmark_tipi_http1_parser, x) benchmark_tipi_http1_parser(x) ================================================ FILE: bin/benchmark ================================================ #!/usr/bin/env ruby require 'bundler/setup' require 'polyphony' def parse_latency(latency) m = latency.match(/^([\d\.]+)(us|ms|s)$/) return nil unless m value = m[1].to_f case m[2] when 's' then value when 'ms' then value / 1000 when 'us' then value / 1000000 end end def parse_wrk_results(results) lines = results.lines latencies = lines[3].strip.split(/\s+/) throughput = lines[6].strip.split(/\s+/) { latency_avg: parse_latency(latencies[1]), latency_max: parse_latency(latencies[3]), rate: throughput[1].to_f } end def run_wrk(duration: 10, threads: 2, connections: 10, url: ) `wrk -d#{duration} -t#{threads} -c#{connections} #{url}` end [8, 64, 256, 512].each do |c| puts "connections: #{c}" p parse_wrk_results(run_wrk(duration: 10, threads: 4, connections: c, url: "http://localhost:10080/")) end ================================================ FILE: bin/h1pd ================================================ #!/usr/bin/env bash set -e rake compile ruby test/test_http1_parser.rb ruby benchmarks/bm_http1_parser.rb ================================================ FILE: bin/tipi ================================================ #!/usr/bin/env ruby require 'bundler/setup' require 'tipi/cli' trap('SIGINT') { exit } Tipi::CLI.start ================================================ FILE: df/agent.rb ================================================ # frozen_string_literal: true require 'bundler/setup' require 'polyphony' require 'json' require 'tipi/digital_fabric/protocol' require 'tipi/digital_fabric/agent' Protocol = DigitalFabric::Protocol class SampleAgent < DigitalFabric::Agent def initialize(id, server_url) @id = id super(server_url, { host: "#{id}.realiteq.net" }, 'foobar') @name = "agent-#{@id}" end def http_request(req) return streaming_http_request(req) if req.path == '/streaming' return form_http_request(req) if req.path == '/form' req.respond({ id: @id, time: Time.now.to_i }.to_json) end def streaming_http_request(req) req.send_headers({ 'Content-Type': 'text/json' }) 60.times do sleep 1 do_some_activity req.send_chunk({ id: @id, time: Time.now.to_i }.to_json) end req.finish rescue Polyphony::Terminate req.respond(' * shutting down *') if Fiber.current.graceful_shutdown? rescue Exception => e p e puts e.backtrace.join("\n") end def form_http_request(req) body = req.read form_data = Tipi::Request.parse_form_data(body, req.headers) req.respond({ form_data: form_data, headers: req.headers }.to_json, { 'Content-Type': 'text/json' }) end def do_some_activity File.open('/tmp/df-test.log', 'a+') { |f| sleep rand; f.puts "#{Time.now} #{@name} #{generate_data(2**8)}" } end def generate_data(length) charset = Array('A'..'Z') + Array('a'..'z') + Array('0'..'9') Array.new(length) { charset.sample }.join end end # id = ARGV[0] # puts "Starting agent #{id} pid: #{Process.pid}" # spin_loop(interval: 60) { GC.start } # SampleAgent.new(id, '/tmp/df.sock').run # SampleAgent.new(id, 'localhost:4411').run ================================================ FILE: df/etc_benchmark.rb ================================================ # frozen_string_literal: true require 'securerandom' def generate SecureRandom.uuid end count = 100000 GC.disable t0 = Time.now count.times { generate } elapsed = Time.now - t0 puts "rate: #{count / elapsed}/s" ================================================ FILE: df/multi_agent_supervisor.rb ================================================ # frozen_string_literal: true require 'bundler/setup' require 'polyphony' require 'json' require 'fileutils' FileUtils.cd(__dir__) require_relative 'agent' class AgentManager def initialize @running_agents = {} @pending_actions = Queue.new @processor = spin_loop { process_pending_action } end def process_pending_action action = @pending_actions.shift case action[:kind] when :start start_agent(action[:spec]) when :stop stop_agent(action[:spec]) end end def start_agent(spec) return if @running_agents[spec] @running_agents[spec] = spin do while true launch_agent_from_spec(spec) sleep 1 end ensure @running_agents.delete(spec) end end def stop_agent(spec) fiber = @running_agents[spec] return unless fiber fiber.terminate fiber.await end def update return unless @pending_actions.empty? current_specs = @running_agents.keys updated_specs = agent_specs to_start = updated_specs - current_specs to_stop = current_specs - current_specs to_start.each { |s| @pending_actions << { kind: :start, spec: s } } to_stop.each { |s| @pending_actions << { kind: :stop, spec: s } } end def run every(2) { update } end end class RealityAgentManager < AgentManager def agent_specs (1..400).map { |i| { id: i } } end def launch_agent_from_spec(spec) # Polyphony::Process.watch("ruby agent.rb #{spec[:id]}") Polyphony::Process.watch do spin_loop(interval: 60) { GC.start } agent = SampleAgent.new(spec[:id], '/tmp/df.sock') puts "Starting agent #{spec[:id]} pid: #{Process.pid}" agent.run end end end puts "Agent manager pid: #{Process.pid}" manager = RealityAgentManager.new manager.run ================================================ FILE: df/multi_client.rb ================================================ # frozen_string_literal: true require 'bundler/setup' require 'polyphony' require 'http/parser' class Client def initialize(id, host, port, http_host, interval) @id = id @host = host @port = port @http_host = http_host @interval = interval.to_f @interval_delta = @interval / 2 end def run while true connect && issue_requests sleep 5 end end def connect @socket = Polyphony::Net.tcp_connect(@host, @port) rescue SystemCallError false end REQUEST = <<~HTTP GET / HTTP/1.1 Host: %s HTTP def issue_requests @parser = Http::Parser.new @parser.on_message_complete = proc { @got_reply = true } @parser.on_body = proc { |chunk| @response = chunk } while true do_request sleep rand((@interval - @interval_delta)..(@interval + @interval_delta)) end rescue IOError, Errno::EPIPE, Errno::ECONNRESET, Errno::ECONNREFUSED => e # fail quitely ensure @parser = nil end def do_request @got_reply = nil @response = nil @socket << format(REQUEST, @http_host) wait_for_response # if @parser.status_code != 200 # puts "Got status code #{@parser.status_code} from #{@http_host} => #{@parser.headers && @parser.headers['X-Request-ID']}" # end # puts "#{Time.now} [client-#{@id}] #{@http_host} => #{@response || ''}" end def wait_for_response @socket.recv_loop do |data| @parser << data return @response if @got_reply end end end def spin_client(id, host) spin do client = Client.new(id, 'localhost', 4411, host, 30) client.run end end spin_loop(interval: 60) { GC.start } 10000.times { |id| spin_client(id, "#{rand(1..400)}.realiteq.net") } trap('SIGINT') { exit! } puts "Multi client pid: #{Process.pid}" sleep ================================================ FILE: df/routing_benchmark.rb ================================================ # frozen_string_literal: true require 'bundler/setup' require 'polyphony' require 'tipi/digital_fabric' class FakeAgent def initialize(idx) @idx = idx end end def setup_df_service_with_agents(agent_count) server = DigitalFabric::Service.new agent_count.times do |i| server.mount({path: "/#{i}"}, FakeAgent.new(i)) end server end def benchmark_route_compilation(agent_count, iterations) service = setup_df_service_with_agents(agent_count) t0 = Time.now iterations.times { service.compile_agent_routes } elapsed = Time.now - t0 puts "route_compilation: #{agent_count} => #{elapsed / iterations}s (#{1/(elapsed / iterations)} ops/sec)" end class FauxRequest def initialize(agent_count) @agent_count = agent_count end def headers { ':path' => "/#{rand(@agent_count)}"} end end def benchmark_find_agent(agent_count, iterations) service = setup_df_service_with_agents(agent_count) t0 = Time.now request = FauxRequest.new(agent_count) iterations.times do agent = service.find_agent(request) end elapsed = Time.now - t0 puts "routing: #{agent_count} => #{elapsed / iterations}s (#{1/(elapsed / iterations)} ops/sec)" end def benchmark benchmark_route_compilation(100, 1000) benchmark_route_compilation(500, 200) benchmark_route_compilation(1000, 100) benchmark_find_agent(100, 1000) benchmark_find_agent(500, 200) benchmark_find_agent(1000, 100) end benchmark ================================================ FILE: df/sample_agent.rb ================================================ # frozen_string_literal: true require 'bundler/setup' require 'polyphony' require 'json' require 'tipi/digital_fabric/protocol' require 'tipi/digital_fabric/agent' Protocol = DigitalFabric::Protocol class SampleAgent < DigitalFabric::Agent HTML_WS = IO.read(File.join(__dir__, 'ws_page.html')) HTML_SSE = IO.read(File.join(__dir__, 'sse_page.html')) def http_request(req) path = req.headers[':path'] case path when '/agent' send_df_message(Protocol.http_response( req['id'], 'Hello, world!', {}, true )) when '/agent/ws' send_df_message(Protocol.http_response( req['id'], HTML_WS, { 'Content-Type' => 'text/html' }, true )) when '/agent/sse' send_df_message(Protocol.http_response( req['id'], HTML_SSE, { 'Content-Type' => 'text/html' }, true )) when '/agent/sse/events' stream_sse_response(req) else send_df_message(Protocol.http_response( req['id'], nil, { ':status' => 400 }, true )) end end def ws_request(req) send_df_message(Protocol.ws_response(req['id'], {})) 10.times do sleep 1 send_df_message(Protocol.ws_data(req['id'], Time.now.to_s)) end send_df_message(Protocol.ws_close(req['id'])) end def stream_sse_response(req) send_df_message(Protocol.http_response( req['id'], nil, { 'Content-Type' => 'text/event-stream' }, false )) 10.times do sleep 1 send_df_message(Protocol.http_response( req['id'], "data: #{Time.now}\n\n", nil, false )) end send_df_message(Protocol.http_response( req['id'], "retry: 0\n\n", nil, true )) end end agent = SampleAgent.new('127.0.0.1', 4411, { path: '/agent' }) agent.run ================================================ FILE: df/server.rb ================================================ # frozen_string_literal: true require_relative 'server_utils' listeners = [ listen_http, listen_https, listen_unix ] spin_loop(interval: 60) { GC.compact } if GC.respond_to?(:compact) begin log('Starting DF server') Fiber.await(*listeners) rescue Interrupt log('Got SIGINT, shutting down gracefully') @service.graceful_shutdown rescue SystemExit # ignore rescue Exception => e log("Uncaught exception", error: e, source: e.source_fiber, raising: e.raising_fiber, backtrace: e.backtrace) ensure log('DF server stopped') end ================================================ FILE: df/server_utils.rb ================================================ # frozen_string_literal: true require 'bundler/setup' require 'tipi' require 'tipi/digital_fabric' require 'tipi/digital_fabric/executive' require 'json' require 'fileutils' require 'time' require 'polyphony/extensions/debug' FileUtils.cd(__dir__) @service = DigitalFabric::Service.new(token: 'foobar') @executive = DigitalFabric::Executive.new(@service, { host: '@executive.realiteq.net' }) @pid = Process.pid def log(msg, **ctx) text = format( "%s (%d) %s\n", Time.now.strftime('%Y-%m-%d %H:%M:%S.%3N'), @pid, msg ) STDOUT.orig_write text return if ctx.empty? ctx.each { |k, v| STDOUT.orig_write format(" %s: %s\n", k, v.inspect) } end def listen_http spin(:http_listener) do opts = { reuse_addr: true, dont_linger: true, } log('Listening for HTTP on localhost:10080') server = Polyphony::Net.tcp_listen('0.0.0.0', 10080, opts) id = 0 loop do client = server.accept # log("Accept HTTP connection", client: client) spin("http#{id += 1}") do @service.incr_connection_count Tipi.client_loop(client, opts) { |req| @service.http_request(req) } ensure # log("Done with HTTP connection", client: client) @service.decr_connection_count end rescue Polyphony::BaseException raise rescue Exception => e log 'HTTP accept (unknown) error', error: e, backtrace: e.backtrace end end end CERTIFICATE_REGEXP = /(-----BEGIN CERTIFICATE-----\n[^-]+-----END CERTIFICATE-----\n)/.freeze def listen_https spin(:https_listener) do private_key = OpenSSL::PKey::RSA.new IO.read('../../reality/ssl/privkey.pem') c = IO.read('../../reality/ssl/cacert.pem') certificates = c.scan(CERTIFICATE_REGEXP).map { |p| OpenSSL::X509::Certificate.new(p.first) } ctx = OpenSSL::SSL::SSLContext.new ctx.security_level = 0 cert = certificates.shift log "SSL Certificate expires: #{cert.not_after.inspect}" ctx.add_certificate(cert, private_key, certificates) # ctx.ciphers = 'ECDH+aRSA' ctx.max_version = OpenSSL::SSL::TLS1_3_VERSION ctx.min_version = OpenSSL::SSL::SSL3_VERSION ctx.verify_mode = OpenSSL::SSL::VERIFY_NONE # TODO: further limit ciphers # ref: https://github.com/socketry/falcon/blob/3ec805b3ceda0a764a2c5eb68cde33897b6a35ff/lib/falcon/environments/tls.rb # ref: https://github.com/socketry/falcon/blob/3ec805b3ceda0a764a2c5eb68cde33897b6a35ff/lib/falcon/tls.rb opts = { reuse_addr: true, dont_linger: true, secure_context: ctx, alpn_protocols: Tipi::ALPN_PROTOCOLS } log('Listening for HTTPS on localhost:10443') server = Polyphony::Net.tcp_listen('0.0.0.0', 10443, opts) id = 0 loop do client = server.accept rescue nil next unless client # log('Accept HTTPS client connection', client: client) spin("https#{id += 1}") do @service.incr_connection_count Tipi.client_loop(client, opts) { |req| @service.http_request(req) } rescue => e log('Error while handling HTTPS client', client: client, error: e, backtrace: e.backtrace) ensure # log("Done with HTTP connection", client: client) @service.decr_connection_count end # rescue OpenSSL::SSL::SSLError, SystemCallError, TypeError => e # log('HTTPS accept error', error: e) rescue Polyphony::BaseException raise rescue Exception => e log 'HTTPS listener error: ', error: e, backtrace: e.backtrace end end end UNIX_SOCKET_PATH = '/tmp/df.sock' def listen_unix spin(:unix_listener) do log("Listening on #{UNIX_SOCKET_PATH}") FileUtils.rm(UNIX_SOCKET_PATH) if File.exists?(UNIX_SOCKET_PATH) socket = UNIXServer.new(UNIX_SOCKET_PATH) id = 0 loop do client = socket.accept # log('Accept Unix connection', client: client) spin("unix#{id += 1}") do Tipi.client_loop(client, {}) { |req| @service.http_request(req, true) } end rescue Polyphony::BaseException raise rescue Exception => e log 'Unix accept error', error: e, backtrace: e.backtrace end end end def listen_df spin(:df_listener) do opts = { reuse_addr: true, reuse_port: true, dont_linger: true, } log('Listening for DF connections on localhost:4321') server = Polyphony::Net.tcp_listen('0.0.0.0', 4321, opts) id = 0 loop do client = server.accept # log('Accept DF connection', client: client) spin("df#{id += 1}") do Tipi.client_loop(client, {}) { |req| @service.http_request(req, true) } end rescue Polyphony::BaseException raise rescue Exception => e log 'DF accept (unknown) error', error: e, backtrace: e.backtrace end end end if ENV['TRACE'] == '1' Thread.backend.trace_proc = proc do |event, fiber, value, pri| fiber_id = fiber.tag || fiber.inspect case event when :schedule log format("=> %s %s %s %s", event, fiber_id, value.inspect, pri ? '(priority)' : '') when :unblock log format("=> %s %s %s", event, fiber_id, value.inspect) when :spin, :terminate log format("=> %s %s", event, fiber_id) else log format("=> %s", event) end end end ================================================ FILE: df/sse_page.html ================================================ SSE Client

SSE Client

disconnected

================================================ FILE: df/stress.rb ================================================ # frozen_string_literal: true require 'bundler/setup' require 'polyphony' require 'fileutils' FileUtils.cd(__dir__) def monitor_process(cmd) while true puts "Starting #{cmd}" Polyphony::Process.watch(cmd) sleep 5 end end puts "pid: #{Process.pid}" puts 'Starting stress test' spin { monitor_process('ruby server.rb') } spin { monitor_process('ruby multi_agent_supervisor.rb') } spin { monitor_process('ruby multi_client.rb') } sleep ================================================ FILE: df/ws_page.html ================================================ Websocket Client

WebSocket Client

disconnected

================================================ FILE: docs/README.md ================================================ # Polyphony - Easy Concurrency for Ruby > Polyphony \| pəˈlɪf\(ə\)ni \| > 1. _Music_ the style of simultaneously combining a number of parts, each > forming an individual melody and harmonizing with each other. > 2. _Programming_ a Ruby gem for concurrent programming focusing on performance > and developer happiness. Polyphony is a library for building concurrent applications in Ruby. Polyphony harnesses the power of [Ruby fibers](https://ruby-doc.org/core-2.5.1/Fiber.html) to provide a cooperative, sequential coprocess-based concurrency model. Under the hood, Polyphony uses [libev](https://github.com/enki/libev) as a high-performance event reactor that provides timers, I/O watchers and other asynchronous event primitives. Polyphony makes it possible to use normal Ruby built-in classes like `IO`, and `Socket` in a concurrent fashion without having to resort to threads. Polyphony takes care of context-switching automatically whenever a blocking call like `Socket#accept` or `IO#read` is issued. ## Features * **Full-blown, integrated, high-performance HTTP 1 / HTTP 2 / WebSocket server with TLS/SSL termination, automatic ALPN protocol selection, and body streaming**. * Co-operative scheduling of concurrent tasks using Ruby fibers. * High-performance event reactor for handling I/O events and timers. * Natural, sequential programming style that makes it easy to reason about concurrent code. * Abstractions and constructs for controlling the execution of concurrent code: coprocesses, supervisors, throttling, resource pools etc. * Code can use native networking classes and libraries, growing support for third-party gems such as `pg` and `redis`. * Use stdlib classes such as `TCPServer` and `TCPSocket` and `Net::HTTP`. * HTTP 1 / HTTP 2 client agent with persistent connections. * Competitive performance and scalability characteristics, in terms of both throughput and memory consumption. ## Prior Art Polyphony draws inspiration from the following, in no particular order: * [nio4r](https://github.com/socketry/nio4r/) and [async](https://github.com/socketry/async) (Polyphony's C-extension code is largely a spinoff of [nio4r's](https://github.com/socketry/nio4r/tree/master/ext)) * [EventMachine](https://github.com/eventmachine/eventmachine) * [Trio](https://trio.readthedocs.io/) * [Erlang supervisors](http://erlang.org/doc/man/supervisor.html) (and actually, Erlang in general) ## Going further To learn more about using Polyphony to build concurrent applications, read the technical overview below, or look at the [included examples](https://github.com/digital-fabric/polyphony/tree/9e0f3b09213156bdf376ef33684ef267517f06e8/examples/README.md). A thorough reference is forthcoming. ## Contributing to Polyphony Issues and pull requests will be gladly accepted. Please use the git repository at https://github.com/digital-fabric/polyphony as your primary point of departure for contributing. ================================================ FILE: examples/cuba.ru ================================================ # frozen_string_literal: true require 'cuba' require 'cuba/safe' require 'delegate' # See https://github.com/rack/rack/pull/1610 Cuba.use Rack::Session::Cookie, secret: '__a_very_long_string__' Cuba.plugin Cuba::Safe Cuba.define do on get do on 'hello' do res.write 'Hello world!' end on root do res.redirect '/hello' end end end run Cuba ================================================ FILE: examples/full_service.rb ================================================ # frozen_string_literal: true require 'bundler/setup' require 'tipi' ::Exception.__disable_sanitized_backtrace__ = true certificate_db_path = File.expand_path('certificate_store.db', __dir__) certificate_store = Tipi::ACME::SQLiteCertificateStore.new(certificate_db_path) Tipi.full_service( certificate_store: certificate_store ) { |req| req.respond('Hello, world!') } ================================================ FILE: examples/hanami-api.ru ================================================ # frozen_string_literal: true require 'hanami/api' class ExampleApi < Hanami::API get '/hello' do 'Hello world!' end get '/' do redirect '/hello' end get '/404' do 404 end get '/500' do 500 end end run ExampleApi.new ================================================ FILE: examples/hello.rb ================================================ # frozen_string_literal: true run { |req| req.respond('Hello, world!') } ================================================ FILE: examples/hello.ru ================================================ # frozen_string_literal: true run lambda { |env| [ 200, {"Content-Type" => "text/plain"}, ["Hello, world!"] ] } ================================================ FILE: examples/http1_parser.rb ================================================ # frozen_string_literal: true require 'polyphony' require_relative '../lib/tipi_ext' i, o = IO.pipe module ::Kernel def trace(*args) STDOUT.orig_write(format_trace(args)) end def format_trace(args) if args.first.is_a?(String) if args.size > 1 format("%s: %p\n", args.shift, args) else format("%s\n", args.first) end else format("%p\n", args.size == 1 ? args.first : args) end end end f = spin do parser = Tipi::HTTP1Parser.new(i) while true trace '*' * 40 headers = parser.parse_headers break unless headers trace headers body = parser.read_body trace "body: #{body ? body.bytesize : 0} bytes" trace body if body && body.bytesize < 80 end end o << "GET /a HTTP/1.1\r\n\r\n" # o << "GET /a HTTP/1.1\r\nContent-Length: 0\r\n\r\n" # o << "GET / HTTP/1.1\r\nHost: localhost:10080\r\nUser-Agent: curl/7.74.0\r\nAccept: */*\r\n\r\n" o << "post /?q=time&blah=blah HTTP/1\r\nTransfer-Encoding: chunked\r\n\r\na\r\nabcdefghij\r\n0\r\n\r\n" data = " " * 4000000 o << "get /?q=time HTTP/1.1\r\nContent-Length: #{data.bytesize}\r\n\r\n#{data}" o << "get /?q=time HTTP/1.1\r\nCookie: foo\r\nCookie: bar\r\n\r\n" o.close f.await ================================================ FILE: examples/http_request_ws_server.rb ================================================ # frozen_string_literal: true require 'bundler/setup' require 'tipi' require 'tipi/websocket' def ws_handler(conn) timer = spin_loop(interval: 1) do conn << Time.now.to_s end while (msg = conn.recv) conn << "you said: #{msg}" end ensure timer.stop end opts = { reuse_addr: true, dont_linger: true, } HTML = IO.read(File.join(__dir__, 'ws_page.html')) puts "pid: #{Process.pid}" puts 'Listening on port 4411...' Tipi.serve('0.0.0.0', 4411, opts) do |req| if req.upgrade_protocol == 'websocket' conn = req.upgrade_to_websocket ws_handler(conn) else req.respond(HTML, 'Content-Type' => 'text/html') end end ================================================ FILE: examples/http_server.js ================================================ // For the sake of comparing performance, here's a node.js-based HTTP server // doing roughly the same thing as http_server. Preliminary benchmarking shows // the ruby version has a throughput (req/s) of about 2/3 of the JS version. const http = require('http'); const MSG = 'Hello World'; const server = http.createServer((req, res) => { // let requestCopy = { // method: req.method, // request_url: req.url, // headers: req.headers // }; // res.writeHead(200, { 'Content-Type': 'application/json' }); // res.end(JSON.stringify(requestCopy)); res.writeHead(200); res.end(MSG) }); server.listen(1235); console.log('Listening on port 1235'); ================================================ FILE: examples/http_server.rb ================================================ # frozen_string_literal: true require 'bundler/setup' require 'tipi' opts = { reuse_addr: true, dont_linger: true } puts "pid: #{Process.pid}" puts 'Listening on port 10080...' # GC.disable # Thread.current.backend.idle_gc_period = 60 spin_loop(interval: 10) { p Thread.backend.stats } spin_loop(interval: 10) do GC.compact end spin do Tipi.serve('0.0.0.0', 10080, opts) do |req| if req.path == '/stream' req.send_headers('Foo' => 'Bar') sleep 1 req.send_chunk("foo\n") sleep 1 req.send_chunk("bar\n") req.finish elsif req.path == '/upload' body = req.read req.respond("Body: #{body.inspect} (#{body.bytesize} bytes)") else req.respond("Hello world!\n") end # p req.transfer_counts end p 'done...' end.await ================================================ FILE: examples/http_server_forked.rb ================================================ # frozen_string_literal: true require 'bundler/setup' require 'tipi' ::Exception.__disable_sanitized_backtrace__ = true opts = { reuse_addr: true, reuse_port: true, dont_linger: true } server = Tipi.listen('0.0.0.0', 1234, opts) child_pids = [] 8.times do pid = Polyphony.fork do puts "forked pid: #{Process.pid}" server.each do |req| req.respond("Hello world! from pid: #{Process.pid}\n") end rescue Interrupt end child_pids << pid end puts 'Listening on port 1234' trap('SIGINT') { exit! } child_pids.each { |pid| Thread.current.backend.waitpid(pid) } ================================================ FILE: examples/http_server_form.rb ================================================ # frozen_string_literal: true require 'bundler/setup' require 'tipi' opts = { reuse_addr: true, dont_linger: true } puts "pid: #{Process.pid}" puts 'Listening on port 4411...' spin do Tipi.serve('0.0.0.0', 4411, opts) do |req| body = req.read body2 = req.read req.respond("body: #{body} (body2: #{body2.inspect})\n") rescue Exception => e p e end p 'done...' end.await ================================================ FILE: examples/http_server_graceful.rb ================================================ # frozen_string_literal: true require 'bundler/setup' require 'polyphony' require 'tipi' opts = { reuse_addr: true, dont_linger: true } server = spin do Tipi.serve('0.0.0.0', 1234, opts) do |req| req.respond("Hello world!\n") end end trap('SIGHUP') do puts 'got hup' server.interrupt end puts "pid: #{Process.pid}" puts 'Send HUP to stop gracefully' puts 'Listening on port 1234...' suspend ================================================ FILE: examples/http_server_routes.rb ================================================ # frozen_string_literal: true require 'bundler/setup' require 'tipi' opts = { reuse_addr: true, dont_linger: true } puts "pid: #{Process.pid}" puts 'Listening on port 4411...' app = Tipi.route do |req| req.on 'stream' do req.send_headers('Foo' => 'Bar') sleep 1 req.send_chunk("foo\n") sleep 1 req.send_chunk("bar\n") req.finish end req.default do req.respond("Hello world!\n") end end trap('INT') { exit! } Tipi.serve('0.0.0.0', 4411, opts, &app) ================================================ FILE: examples/http_server_simple.rb ================================================ # frozen_string_literal: true require 'bundler/setup' require 'tipi' puts "pid: #{Process.pid}" puts 'Listening on port 1234...' Tipi.serve('0.0.0.0', 1234) do |req| req.respond("Hello world!\n") end ================================================ FILE: examples/http_server_static.rb ================================================ # frozen_string_literal: true require 'bundler/setup' require 'tipi' require 'fileutils' opts = { reuse_addr: true, dont_linger: true } puts "pid: #{Process.pid}" puts 'Listening on port 4411...' root_path = FileUtils.pwd trap('INT') { exit! } Tipi.serve('0.0.0.0', 4411, opts) do |req| path = File.join(root_path, req.path) if File.file?(path) req.serve_file(path) else req.respond(nil, ':status' => Qeweney::Status::NOT_FOUND) end end ================================================ FILE: examples/http_server_throttled.rb ================================================ # frozen_string_literal: true require 'bundler/setup' require 'tipi' $throttler = Polyphony::Throttler.new(1000) opts = { reuse_addr: true, dont_linger: true } server = spin do Tipi.serve('0.0.0.0', 1234, opts) do |req| $throttler.call { req.respond("Hello world!\n") } end end puts "pid: #{Process.pid}" puts 'Listening on port 1234...' server.await ================================================ FILE: examples/http_server_throttled_accept.rb ================================================ # frozen_string_literal: true require 'bundler/setup' require 'tipi' ::Exception.__disable_sanitized_backtrace__ = true opts = { reuse_addr: true, reuse_port: true, dont_linger: true } server = Tipi.listen('0.0.0.0', 1234, opts) puts 'Listening on port 1234' throttler = Polyphony::Throttler.new(interval: 5) server.accept_loop do |socket| throttler.call do spin { Tipi.client_loop(socket, opts) { |req| req.respond("Hello world!\n") } } end end ================================================ FILE: examples/http_server_timeout.rb ================================================ # frozen_string_literal: true require 'bundler/setup' require 'tipi' opts = { reuse_addr: true, dont_linger: true } def timeout_handler(timeout, &handler) ->(req) do cancel_after(timeout) { handler.(req) } rescue Polyphony::Cancel req.respond("timeout\n", ':status' => 502) end end sleep 0 spin do Tipi.serve( '0.0.0.0', 1234, opts, &timeout_handler(0.1) do |req| sleep rand(0.01..0.2) req.respond("Hello timeout world!\n") end ) end puts "pid: #{Process.pid}" puts 'Listening on port 1234...' suspend ================================================ FILE: examples/http_unix_socket_server.rb ================================================ # frozen_string_literal: true require 'bundler/setup' require 'tipi' path = '/tmp/tipi.sock' puts "pid: #{Process.pid}" puts "Listening on #{path}" FileUtils.rm(path) rescue nil socket = UNIXServer.new(path) Tipi.accept_loop(socket, {}) do |req| req.respond("Hello world!\n") rescue Exception => e p e end ================================================ FILE: examples/http_ws_server.rb ================================================ # frozen_string_literal: true require 'bundler/setup' require 'tipi' require 'tipi/websocket' def ws_handler(conn) timer = spin_loop(interval: 1) do conn << Time.now.to_s end while (msg = conn.recv) conn << "you said: #{msg}" end rescue Exception => e p e ensure timer.stop end opts = { reuse_addr: true, dont_linger: true, upgrade: { websocket: Tipi::Websocket.handler(&method(:ws_handler)) } } HTML = IO.read(File.join(__dir__, 'ws_page.html')) puts "pid: #{Process.pid}" puts 'Listening on port 4411...' Tipi.serve('0.0.0.0', 4411, opts) do |req| req.respond(HTML, 'Content-Type' => 'text/html') end ================================================ FILE: examples/https_server.rb ================================================ # frozen_string_literal: true require 'bundler/setup' require 'tipi' require 'localhost/authority' ::Exception.__disable_sanitized_backtrace__ = true authority = Localhost::Authority.fetch opts = { reuse_addr: true, dont_linger: true, } puts "pid: #{Process.pid}" puts 'Listening on port 1234...' ctx = authority.server_context server = Polyphony::Net.tcp_listen('0.0.0.0', 1234, opts) loop do socket = server.accept client = OpenSSL::SSL::SSLSocket.new(socket, ctx) client.sync_close = true spin do state = {} accept_thread = Thread.new do puts "call client accept" client.accept state[:result] = :ok rescue Exception => e puts error: e state[:result] = e end "wait for accept thread" accept_thread.join "accept thread done" if state[:result].is_a?(Exception) puts "Exception in SSL handshake: #{state[:result].inspect}" next end Tipi.client_loop(client, opts) do |req| p path: req.path if req.path == '/stream' req.send_headers('Foo' => 'Bar') sleep 0.5 req.send_chunk("foo\n") sleep 0.5 req.send_chunk("bar\n", done: true) elsif req.path == '/upload' body = req.read req.respond("Body: #{body.inspect} (#{body.bytesize} bytes)") else req.respond("Hello world!\n") end end ensure client ? client.close : socket.close end end ================================================ FILE: examples/https_server_forked.rb ================================================ # frozen_string_literal: true require 'bundler/setup' require 'tipi' require 'localhost/authority' ::Exception.__disable_sanitized_backtrace__ = true authority = Localhost::Authority.fetch opts = { reuse_addr: true, dont_linger: true, secure_context: authority.server_context } server = Tipi.listen('0.0.0.0', 1234, opts) puts 'Listening on port 1234' child_pids = [] 4.times do pid = Polyphony.fork do puts "forked pid: #{Process.pid}" server.each do |req| req.respond("Hello world!\n") end rescue Interrupt end child_pids << pid end child_pids.each { |pid| Thread.current.backend.waitpid(pid) } ================================================ FILE: examples/https_wss_server.rb ================================================ # frozen_string_literal: true require 'bundler/setup' require 'tipi' require 'tipi/websocket' require 'localhost/authority' def ws_handler(conn) timer = spin do throttled_loop(1) do conn << Time.now.to_s rescue StandardError nil end end while (msg = conn.recv) puts "msg: #{msg}" # conn << "you said: #{msg}" end ensure timer.stop end authority = Localhost::Authority.fetch opts = { reuse_addr: true, dont_linger: true, secure_context: authority.server_context, upgrade: { websocket: Tipi::Websocket.handler(&method(:ws_handler)) } } HTML = IO.read(File.join(__dir__, 'wss_page.html')) puts "pid: #{Process.pid}" puts 'Listening on port 1234...' Tipi.serve('0.0.0.0', 1234, opts) do |req| req.respond(HTML, 'Content-Type' => 'text/html') end ================================================ FILE: examples/rack_server.rb ================================================ # frozen_string_literal: true require 'bundler/setup' require 'tipi' app_path = ARGV.first || File.expand_path('./config.ru', __dir__) unless File.file?(app_path) STDERR.puts "Please provide rack config file (there are some in the examples directory.)" exit! end app = Tipi::RackAdapter.load(app_path) opts = { reuse_addr: true, dont_linger: true } puts 'listening on port 1234' puts "pid: #{Process.pid}" Tipi.serve('0.0.0.0', 1234, opts, &app) ================================================ FILE: examples/rack_server_forked.rb ================================================ # frozen_string_literal: true require 'bundler/setup' require 'tipi' app_path = ARGV.first || File.expand_path('./config.ru', __dir__) unless File.file?(app_path) STDERR.puts "Please provide rack config file (there are some in the examples directory.)" exit! end app = Tipi::RackAdapter.load(app_path) opts = { reuse_addr: true, dont_linger: true } server = Tipi.listen('0.0.0.0', 1234, opts) puts 'listening on port 1234' child_pids = [] 4.times do child_pids << Polyphony.fork do puts "forked pid: #{Process.pid}" server.each(&app) end end child_pids.each { |pid| Thread.current.backend.waitpid(pid) } ================================================ FILE: examples/rack_server_https.rb ================================================ # frozen_string_literal: true require 'bundler/setup' require 'tipi' require 'localhost/authority' app_path = ARGV.first || File.expand_path('./config.ru', __dir__) app = Tipi::RackAdapter.load(app_path) authority = Localhost::Authority.fetch opts = { reuse_addr: true, dont_linger: true, secure_context: authority.server_context } puts 'listening on port 1234' puts "pid: #{Process.pid}" Tipi.serve('0.0.0.0', 1234, opts, &app) ================================================ FILE: examples/rack_server_https_forked.rb ================================================ # frozen_string_literal: true require 'bundler/setup' require 'tipi' require 'localhost/authority' app_path = ARGV.first || File.expand_path('./config.ru', __dir__) app = Tipi::RackAdapter.load(app_path) authority = Localhost::Authority.fetch opts = { reuse_addr: true, reuse_port: true, dont_linger: true, secure_context: authority.server_context } server = Tipi.listen('0.0.0.0', 1234, opts) puts 'Listening on port 1234' child_pids = [] 4.times do child_pids << Polyphony.fork do puts "forked pid: #{Process.pid}" server.each(&app) end end child_pids.each { |pid| Thread.current.backend.waitpid(pid) } ================================================ FILE: examples/routing_server.rb ================================================ # frozen_string_literal: true require 'bundler/setup' require 'tipi' opts = { reuse_addr: true, dont_linger: true } puts "pid: #{Process.pid}" puts 'Listening on port 4411...' app = Tipi.route do |r| r.on_root do r.redirect '/hello' end r.on 'hello' do r.on_get 'world' do r.respond 'Hello world' end r.on_get do r.respond 'Hello' end r.on_post do puts 'Someone said Hello' r.redirect '/' end end end spin do Tipi.serve('0.0.0.0', 4411, opts, &app) end.await ================================================ FILE: examples/servername_cb.rb ================================================ # frozen_string_literal: true require 'openssl' require 'fiber' ctx = OpenSSL::SSL::SSLContext.new f = Fiber.new { |peer| loop { p peer: peer; _name, peer = peer.transfer nil } } ctx.servername_cb = proc { |_socket, name| p servername_cb: name f.transfer([name, Fiber.current]).tap { |r| p result: r } } socket = Socket.new(:INET, :STREAM).tap do |s| s.setsockopt(Socket::SOL_SOCKET, Socket::SO_REUSEADDR, 1) s.setsockopt(::Socket::SOL_SOCKET, ::Socket::SO_REUSEPORT, 1) s.bind(Socket.sockaddr_in(12345, '0.0.0.0')) s.listen(Socket::SOMAXCONN) end server = OpenSSL::SSL::SSLServer.new(socket, ctx) Thread.new do sleep 0.5 socket = TCPSocket.new('127.0.0.1', 12345) client = OpenSSL::SSL::SSLSocket.new(socket) client.hostname = 'example.com' p client: client client.connect rescue => e p client_error: e end while true conn = server.accept p accepted: conn break end ================================================ FILE: examples/source.rb ================================================ # frozen_string_literal: true run { |req| req.serve_file(__FILE__) } ================================================ FILE: examples/streaming.rb ================================================ # frozen_string_literal: true run { |req| req.send_headers('Content-Type' => 'text/event-stream') 10.times { |i| sleep 0.1 req.send_chunk("data: #{i.to_s * 40}\n") } req.finish } ================================================ FILE: examples/websocket_client.rb ================================================ # frozen_string_literal: true require 'bundler/setup' require 'polyphony' require 'websocket' ::Exception.__disable_sanitized_backtrace__ = true class WebsocketClient def initialize(url, headers = {}) @socket = TCPSocket.new('127.0.0.1', 1234) do_handshake(url, headers) end def do_handshake(url, headers) handshake = WebSocket::Handshake::Client.new(url: url, headers: headers) @socket << handshake.to_s @socket.read_loop do |data| handshake << data break if handshake.finished? end raise 'Websocket handshake failed' unless handshake.valid? @version = handshake.version @reader = WebSocket::Frame::Incoming::Client.new(version: @version) end def receive @socket.read_loop do |data| @reader << data parsed = @reader.next return parsed if parsed end end def send(data) frame = WebSocket::Frame::Outgoing::Client.new( version: @version, data: data, type: :text ) @socket << frame.to_s end alias_method :<<, :send def close @socket.close end end (1..3).each do |i| spin do client = WebsocketClient.new('ws://127.0.0.1:1234/', { Cookie: "SESSIONID=#{i * 10}" }) (1..3).each do |j| sleep rand(0.2..0.5) client.send "Hello from client #{i} (#{j})" puts "server reply: #{client.receive}" end client.close end end suspend ================================================ FILE: examples/websocket_demo.rb ================================================ # frozen_string_literal: true require 'bundler/setup' require 'tipi' require 'tipi/websocket' class WebsocketClient def initialize(url, headers = {}) @socket = TCPSocket.new('127.0.0.1', 1234) do_handshake(url, headers) end def do_handshake(url, headers) handshake = WebSocket::Handshake::Client.new(url: url, headers: headers) @socket << handshake.to_s @socket.read_loop do |data| handshake << data break if handshake.finished? end raise 'Websocket handshake failed' unless handshake.valid? @version = handshake.version @reader = WebSocket::Frame::Incoming::Client.new(version: @version) end def receive parsed = @reader.next return parsed if parsed @socket.read_loop do |data| @reader << data parsed = @reader.next return parsed if parsed end end def send(data) frame = WebSocket::Frame::Outgoing::Client.new( version: @version, data: data, type: :text ) @socket << frame.to_s end alias_method :<<, :send def close @socket.close end end server = spin do websocket_handler = Tipi::Websocket.handler do |conn| while (msg = conn.recv) conn << "you said: #{msg}" end end opts = { upgrade: { websocket: websocket_handler } } puts 'Listening on port http://127.0.0.1:1234/' Tipi.serve('0.0.0.0', 1234, opts) do |req| req.respond("Hello world!\n") end end sleep 0.01 # wait for server to start clients = (1..3).map do |i| spin do client = WebsocketClient.new('ws://127.0.0.1:1234/', { Cookie: "SESSIONID=#{i * 10}" }) (1..3).each do |j| sleep rand(0.2..0.5) client.send "Hello from client #{i} (#{j})" puts "server reply: #{client.receive}" end client.close end end Fiber.await(*clients) ================================================ FILE: examples/websocket_secure_server.rb ================================================ # frozen_string_literal: true require 'bundler/setup' require 'tipi' require 'localhost/authority' def ws_handler(conn) while (msg = conn.recv) conn << "you said: #{msg}" end end authority = Localhost::Authority.fetch opts = { reuse_addr: true, dont_linger: true, upgrade: { websocket: Polyphony::Websocket.handler(&method(:ws_handler)) }, secure_context: authority.server_context } puts "pid: #{Process.pid}" puts 'Listening on port 1234...' Tipi.serve('0.0.0.0', 1234, opts) do |req| req.respond("Hello world!\n") end ================================================ FILE: examples/websocket_server.rb ================================================ # frozen_string_literal: true require 'bundler/setup' require 'tipi' require 'tipi/websocket' def ws_handler(conn) while (msg = conn.recv) conn << "you said: #{msg}" end end opts = { reuse_addr: true, dont_linger: true, upgrade: { websocket: Tipi::Websocket.handler(&method(:ws_handler)) } } puts "pid: #{Process.pid}" puts 'Listening on port 1234...' Tipi.serve('0.0.0.0', 1234, opts) do |req| req.respond("Hello world!\n") end ================================================ FILE: examples/ws_page.html ================================================ Websocket Client

disconnected

================================================ FILE: examples/wss_page.html ================================================ Websocket Client

disconnected

================================================ FILE: examples/zlib-bench.rb ================================================ # frozen_string_literal: true FILE_SIZES = { 's.tmp' => 2**10, 'm.tmp' => 2**17, 'l.tmp' => 2**20, 'xl.tmp' => 2**24 } def create_files FILE_SIZES.each { |fn, size| IO.write(File.join('/tmp', fn), '*' * size) } end create_files run { |req| file_path = File.join('/tmp', req.path) if File.file?(file_path) req.serve_file(file_path) else req.respond(nil, ':status' => 404) end } ================================================ FILE: lib/tipi/acme.rb ================================================ # frozen_string_literal: true require 'openssl' require 'acme-client' require 'localhost/authority' module Tipi module ACME class Error < StandardError end class CertificateManager def initialize(master_ctx:, store:, challenge_handler:, valid_hosts:) @master_ctx = master_ctx @store = store @challenge_handler = challenge_handler @valid_hosts = valid_hosts @contexts = {} @requests = Polyphony::Queue.new @worker = spin { run } setup_sni_callback end ACME_CHALLENGE_PATH_REGEXP = /\/\.well\-known\/acme\-challenge/.freeze def challenge_routing_app(app) ->(req) do (req.path =~ ACME_CHALLENGE_PATH_REGEXP ? @challenge_handler : app) .(req) rescue => e puts "Error while handling request: #{e.inspect} (headers: #{req.headers})" req.respond(nil, ':status' => Qeweney::Status::BAD_REQUEST) end end IP_REGEXP = /^\d+\.\d+\.\d+\.\d+$/ def setup_sni_callback @master_ctx.servername_cb = proc { |_socket, name| get_ctx(name) } end def get_ctx(name) state = { ctx: nil } if @valid_hosts return nil unless @valid_hosts.include?(name) end ready_ctx = @contexts[name] return ready_ctx if ready_ctx return @master_ctx if name =~ IP_REGEXP @requests << [name, state] wait_for_ctx(state) # Eventually we might want to return an error returned in # state[:error]. For the time being we handle errors by returning the # master context state[:ctx] || @master_ctx rescue => e @master_ctx end MAX_WAIT_FOR_CTX_DURATION = 30 def wait_for_ctx(state) t0 = Time.now period = 0.00001 while !state[:ctx] && !state[:error] orig_sleep period if period < 0.1 period *= 2 elsif Time.now - t0 > MAX_WAIT_FOR_CTX_DURATION raise "Timeout waiting for certificate provisioning" end end end def run loop do name, state = @requests.shift state[:ctx] = get_context(name) rescue => e state[:error] = e if state end end LOCALHOST_REGEXP = /\.?localhost$/.freeze def get_context(name) @contexts[name] = setup_context(name) end def setup_context(name) ctx = provision_context(name) transfer_ctx_settings(ctx) ctx end def provision_context(name) return localhost_context if name =~ LOCALHOST_REGEXP info = get_certificate(name) ctx = OpenSSL::SSL::SSLContext.new chain = parse_certificate(info[:certificate]) cert = chain.shift ctx.add_certificate(cert, info[:private_key], chain) ctx end def transfer_ctx_settings(ctx) ctx.alpn_protocols = @master_ctx.alpn_protocols ctx.alpn_select_cb = @master_ctx.alpn_select_cb ctx.ciphers = @master_ctx.ciphers end CERTIFICATE_REGEXP = /(-----BEGIN CERTIFICATE-----\n[^-]+-----END CERTIFICATE-----\n)/.freeze def parse_certificate(certificate) certificate .scan(CERTIFICATE_REGEXP) .map { |p| OpenSSL::X509::Certificate.new(p.first) } end def get_expired_stamp(certificate) chain = parse_certificate(certificate) cert = chain.shift cert.not_after end def get_certificate(name) entry = @store.get(name) return entry if entry provision_certificate(name).tap do |entry| @store.set(name, **entry) end end def localhost_context @localhost_authority ||= Localhost::Authority.fetch @localhost_authority.server_context end def private_key @private_key ||= OpenSSL::PKey::RSA.new(4096) end ACME_DIRECTORY = 'https://acme-v02.api.letsencrypt.org/directory' def acme_client @acme_client ||= setup_acme_client end def setup_acme_client client = Acme::Client.new( private_key: private_key, directory: ACME_DIRECTORY ) account = client.new_account( contact: 'mailto:info@noteflakes.com', terms_of_service_agreed: true ) client end def provision_certificate(name) order = acme_client.new_order(identifiers: [name]) authorization = order.authorizations.first challenge = authorization.http @challenge_handler.add(challenge) challenge.request_validation while challenge.status == 'pending' sleep(0.25) challenge.reload end raise ACME::Error, "Invalid CSR" if challenge.status == 'invalid' private_key = OpenSSL::PKey::RSA.new(4096) csr = Acme::Client::CertificateRequest.new( private_key: private_key, subject: { common_name: name } ) order.finalize(csr: csr) while order.status == 'processing' sleep(0.25) order.reload end certificate = begin order.certificate(force_chain: 'DST Root CA X3') rescue Acme::Client::Error::ForcedChainNotFound order.certificate end expired_stamp = get_expired_stamp(certificate) puts "Certificate for #{name} expires: #{expired_stamp.inspect}" { private_key: private_key, certificate: certificate, expired_stamp: expired_stamp } end end class HTTPChallengeHandler def initialize @challenges = {} end def add(challenge) path = "/.well-known/acme-challenge/#{challenge.token}" @challenges[path] = challenge end def remove(challenge) path = "/.well-known/acme-challenge/#{challenge.token}" @challenges.delete(path) end def call(req) challenge = @challenges[req.path] # handle incoming request challenge = @challenges[req.path] return req.respond(nil, ':status' => 400) unless challenge req.respond(challenge.file_content, 'content-type' => challenge.content_type) end end class CertificateStore def set(name, private_key:, certificate:, expired_stamp:) raise NotImplementedError end def get(name) raise NotImplementedError end end class InMemoryCertificateStore def initialize @store = {} end def set(name, private_key:, certificate:, expired_stamp:) @store[name] = { private_key: private_key, certificate: certificate, expired_stamp: expired_stamp } end def get(name) entry = @store[name] return nil unless entry if Time.now >= entry[:expired_stamp] @store.delete(name) return nil end entry end end class SQLiteCertificateStore attr_reader :db def initialize(path) require 'extralite' @db = Extralite::Database.new(path) @db.query(" create table if not exists certificates ( name primary key not null, private_key not null, certificate not null, expired_stamp not null );" ) end def set(name, private_key:, certificate:, expired_stamp:) @db.query(" insert into certificates values (?, ?, ?, ?) ", name, private_key.to_s, certificate, expired_stamp.to_i) rescue Extralite::Error => e p error_in_set: e raise e end def get(name) remove_expired_certificates entry = @db.query_single_row(" select name, private_key, certificate, expired_stamp from certificates where name = ? ", name) return nil unless entry entry[:expired_stamp] = Time.at(entry[:expired_stamp]) entry[:private_key] = OpenSSL::PKey::RSA.new(entry[:private_key]) entry rescue Extralite::Error => e p error_in_get: e raise e end def remove_expired_certificates @db.query(" delete from certificates where expired_stamp < ? ", Time.now.to_i) rescue Extralite::Error => e p error_in_remove_expired_certificates: e raise e end end end end ================================================ FILE: lib/tipi/cli.rb ================================================ # frozen_string_literal: true require 'tipi' require 'fileutils' require 'tipi/supervisor' require 'optparse' module Tipi DEFAULT_OPTS = { app_type: :web, mode: :polyphony, workers: 1, threads: 1, listen: ['http', 'localhost', 1234], path: '.', } def self.opts_from_argv(argv) opts = DEFAULT_OPTS.dup parser = OptionParser.new do |o| o.banner = "Usage: tipi [options] path" o.on('-h', '--help', 'Show this help') { puts o; exit } o.on('-wNUM', '--workers NUM', 'Number of worker processes (default: 1)') do |v| opts[:workers] = v end o.on('-tNUM', '--threads NUM', 'Number of worker threads (default: 1)') do |v| opts[:threads] = v opts[:mode] = :stock end o.on('-c', '--compatibility', 'Use compatibility mode') do opts[:mode] = :stock end o.on('-lSPEC', '--listen SPEC', 'Setup HTTP listener') do |v| opts[:listen] = parse_listen_spec('http', v) end o.on('-sSPEC', '--secure SPEC', 'Setup HTTPS listener (for localhost)') do |v| opts[:listen] = parse_listen_spec('https', v) end o.on('-fSPEC', '--full-service SPEC', 'Setup HTTP/HTTPS listeners (with automatic certificates)') do |v| opts[:listen] = parse_listen_spec('full', v) end o.on('-v', '--verbose', 'Verbose output') do opts[:verbose] = true end end.parse!(argv) opts[:path] = argv.shift unless argv.empty? verify_path(opts[:path]) opts end def self.parse_listen_spec(type, spec) [type, *spec.split(':').map { |s| str_to_native_type(s) }] end def self.str_to_native_type(str) case str when /^\d+$/ str.to_i else str end end def self.verify_path(path) return if File.file?(path) || File.directory?(path) puts "Invalid path specified #{path}" exit! end module CLI BANNER = "\n" + " ooo\n" + " oo\n" + " o\n" + " \\|/ Tipi - a better web server for a better world\n" + " / \\ \n" + " / \\ https://github.com/digital-fabric/tipi\n" + "⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺\n" def self.start(argv = ARGV.dup) opts = Tipi.opts_from_argv(argv) display_banner if STDOUT.tty? && !opts[:silent] Tipi::Supervisor.run(opts) end def self.display_banner puts BANNER end end end ================================================ FILE: lib/tipi/config_dsl.rb ================================================ # frozen_string_literal: true module Tipi module Configuration class Interpreter # make_blank_slate def initialize(assembler) @assembler = assembler end def gzip_response @assembler.emit 'req = Tipi::GZip.wrap(req)' end def log(out) @assembler.wrap_current_frame 'logger.log_request(req) do |req|' end def error(&block) assembler.emit_exception_handler &block end def match(pattern, &block) @assembler.emit_conditional "if req.path =~ #{pattern.inspect}", &block end end class Assembler def self.from_source(code) new.from_source code end def from_source(code) @stack = [new_frame] @app_procs = {} @interpreter = Interpreter.new self @interpreter.instance_eval code loop do frame = @stack.pop return assemble_app_proc(frame).join("\n") if @stack.empty? @stack.last[:body] << assemble_frame(frame) end end def new_frame { prelude: [], body: [] } end def add_frame(&block) @stack.push new_frame yield ensure frame = @stack.pop emit assemble(frame) end def wrap_current_frame(head) frame = @stack.pop wrapper = new_frame wrapper[:body] << head @stack.push wrapper @stack.push frame end def emit(code) @stack.last[:body] << code end def emit_prelude(code) @stack.last[:prelude] << code end def emit_exception_handler(&block) proc_id = add_app_proc block @stack.last[:rescue_proc_id] = proc_id end def emit_block(conditional, &block) proc_id = add_app_proc block @stack.last[:branched] = true emit conditional add_frame &block end def add_app_proc(proc) id = :"proc#{@app_procs.size}" @app_procs[id] = proc id end def assemble_frame(frame) indent = 0 lines = [] emit_code lines, frame[:prelude], indent if frame[:rescue_proc_id] emit_code lines, 'begin', indent indent += 1 end emit_code lines, frame[:body], indent if frame[:rescue_proc_id] emit_code lines, 'rescue => e', indent emit_code lines, " app_procs[#{frame[:rescue_proc_id].inspect}].call(req, e)", indent emit_code lines, 'end', indent indent -= 1 end lines end def assemble_app_proc(frame) indent = 0 lines = [] emit_code lines, frame[:prelude], indent emit_code lines, 'proc do |req|', indent emit_code lines, frame[:body], indent + 1 emit_code lines, 'end', indent lines end def emit_code(lines, code, indent) if code.is_a? Array code.each { |l| emit_code lines, l, indent + 1 } else lines << (indent_line code, indent) end end @@indents = Hash.new { |h, k| h[k] = ' ' * k } def indent_line(code, indent) indent == 0 ? code : "#{ @@indents[indent] }#{code}" end end end end def assemble(code) Tipi::Configuration::Assembler.from_source(code) end code = assemble <<~RUBY gzip_response log STDOUT RUBY puts code ================================================ FILE: lib/tipi/configuration.rb ================================================ # frozen_string_literal: true require_relative './handler' module Tipi module Configuration class << self def supervise_config current_runner = nil while (config = receive) old_runner, current_runner = current_runner, spin { run(config) } old_runner&.stop end end def run(config) start_listeners(config) config[:forked] ? forked_supervise(config) : simple_supervise(config) end def start_listeners(config) puts "Listening on port 1234" @server = Polyphony::Net.tcp_listen('0.0.0.0', 1234, { reuse_addr: true, dont_linger: true }) end def simple_supervise(config) virtual_hosts = setup_virtual_hosts(config) start_acceptors(config, virtual_hosts) suspend # supervise(restart: true) end def forked_supervise(config) config[:forked].times do spin { Polyphony.watch_process { simple_supervise(config) } } end suspend end def setup_virtual_hosts(config) { '*': Tipi::DefaultHandler.new(config) } end def start_acceptors(config, virtual_hosts) spin do puts "pid: #{Process.pid}" while (connection = @server.accept) spin { virtual_hosts[:'*'].call(connection) } end end end end end end ================================================ FILE: lib/tipi/controller/bare_polyphony.rb ================================================ ================================================ FILE: lib/tipi/controller/bare_stock.rb ================================================ # frozen_string_literal: true module Tipi module Apps module Bare def start(opts) end end end end ================================================ FILE: lib/tipi/controller/extensions.rb ================================================ # frozen_string_literal: true require 'tipi' module Kernel def run(app = nil, &block) Tipi.app = app || block end end module Tipi class << self attr_writer :app def app return @app if @app raise 'No app define. The app to run should be set using `Tipi.app = ...`' end def run_sites(site_map) sites = site_map.each_with_object({}) { |(k, v), h| h[k] = v.to_proc } valid_hosts = sites.keys @app = ->(req) { handler = sites[req.host] if handler handler.call(req) else req.respond(nil, ':status' => Qeweney::Status::NOT_FOUND) end } @app.define_singleton_method(:valid_hosts) { valid_hosts } end end end ================================================ FILE: lib/tipi/controller/stock_http1_adapter.rb ================================================ # frozen_string_literal: true require 'tipi/http1_adapter' module Tipi class StockHTTP1Adapter < HTTP1Adapter def initialize(conn, opts) super(conn, opts) end def each(&block) end end end ================================================ FILE: lib/tipi/controller/web_polyphony.rb ================================================ # frozen_string_literal: true require 'tipi' require 'localhost/authority' require_relative './extensions' module Tipi class Controller def initialize(opts) @opts = opts @path = File.expand_path(@opts['path']) @service = prepare_service end WORKER_COUNT_RANGE = (1..32).freeze def run worker_count = (@opts['workers'] || 1).to_i.clamp(WORKER_COUNT_RANGE) return run_worker if worker_count == 1 supervise_workers(worker_count) end private def supervise_workers(worker_count) supervisor = spin(:web_worker_supervisor) do worker_count.times do spin(:web_worker) do pid = Polyphony.fork { run_worker } puts "Forked worker pid: #{pid}" Polyphony.backend_waitpid(pid) puts "Done worker pid: #{pid}" end end supervise(restart: :always) rescue Polyphony::Terminate # TODO: find out how Terminate can leak like that (it's supposed to be # caught in Fiber#run) end trap('SIGTERM') { supervisor.terminate(graceful: true) } trap('SIGINT') do trap('SIGINT') { exit! } supervisor.terminate(graceful: true) end supervisor.await rescue Polyphony::Terminate # TODO: find out how Terminate can leak etc. end def run_worker server = start_server(@service) trap('SIGTERM') { server&.terminate(graceful: true) } trap('SIGINT') do trap('SIGINT') { exit! } server&.terminate(graceful: true) end raise 'Server not started' unless server server.await rescue Polyphony::Terminate # TODO: find out why this exception leaks from the server fiber # ignore end def prepare_service if File.file?(@path) File.extname(@path) == '.ru' ? rack_service : tipi_service elsif File.directory?(@path) static_service else raise "Invalid path specified #{@path}" end end def start_app if File.extname(@path) == '.ru' start_rack_app else require(@path) end end def rack_service puts "Loading Rack app from #{@path}" app = Tipi::RackAdapter.load(@path) web_service(app) end def tipi_service puts "Loading Tipi app from #{@path}" require(@path) app = Tipi.app web_service(app) # proc { spin { Object.run } } end def static_service puts "Serving static files from #{@path}" app = proc do |req| full_path = find_path(@path, req.path) if full_path req.serve_file(full_path) else req.respond(nil, ':status' => Qeweney::Status::NOT_FOUND) end end web_service(app) end def web_service(app) app = add_connection_headers(app) prepare_listener(@opts['listen'], app) end def prepare_listener(spec, app) case spec.shift when 'http' case spec.size when 2 host, port = spec port ||= 80 when 1 host = '0.0.0.0' port = spec.first || 80 else raise "Invalid listener spec" end prepare_http_listener(port, app) when 'https' case spec.size when 2 host, port = spec port ||= 80 when 1 host = 'localhost' port = spec.first || 80 else raise "Invalid listener spec" end port ||= 443 prepare_https_listener(host, port, app) when 'full' host, http_port, https_port = spec http_port ||= 80 https_port ||= 443 prepare_full_service_listeners(host, http_port, https_port, app) end end def prepare_http_listener(port, app) puts "Listening for HTTP on localhost:#{port}" proc do spin_accept_loop('HTTP', port) do |socket| Tipi.client_loop(socket, @opts, &app) end end end LOCALHOST_REGEXP = /^(.+\.)?localhost$/.freeze def prepare_https_listener(host, port, app) localhost = host =~ LOCALHOST_REGEXP return prepare_localhost_https_listener(port, app) if localhost raise "No certificate found for #{host}" # TODO: implement loading certificate end def prepare_localhost_https_listener(port, app) puts "Listening for HTTPS on localhost:#{port}" authority = Localhost::Authority.fetch ctx = authority.server_context ctx.ciphers = 'ECDH+aRSA' Polyphony::Net.setup_alpn(ctx, Tipi::ALPN_PROTOCOLS) proc do https_listener = spin_accept_loop('HTTPS', port) do |socket| start_https_connection_fiber(socket, ctx, nil, app) rescue Exception => e puts "Exception in https_listener block: #{e.inspect}\n#{e.backtrace.inspect}" end end end def prepare_full_service_listeners(host, http_port, https_port, app) puts "Listening for HTTP on localhost:#{http_port}" puts "Listening for HTTPS on localhost:#{https_port}" redirect_app = http_redirect_app(https_port) ctx = OpenSSL::SSL::SSLContext.new ctx.ciphers = 'ECDH+aRSA' Polyphony::Net.setup_alpn(ctx, Tipi::ALPN_PROTOCOLS) certificate_store = create_certificate_store proc do challenge_handler = Tipi::ACME::HTTPChallengeHandler.new certificate_manager = Tipi::ACME::CertificateManager.new( master_ctx: ctx, store: certificate_store, challenge_handler: challenge_handler, valid_hosts: app.respond_to?(:valid_hosts) ? app.valid_hosts : nil ) http_app = certificate_manager.challenge_routing_app(redirect_app) http_listener = spin_accept_loop('HTTP', http_port) do |socket| Tipi.client_loop(socket, @opts, &http_app) end ssl_accept_thread_pool = Polyphony::ThreadPool.new(4) https_listener = spin_accept_loop('HTTPS', https_port) do |socket| start_https_connection_fiber(socket, ctx, ssl_accept_thread_pool, app) rescue Exception => e puts "Exception in https_listener block: #{e.inspect}\n#{e.backtrace.inspect}" end end end def http_redirect_app(https_port) case https_port when 443, 10443 ->(req) { req.redirect("https://#{req.host}#{req.path}") } else ->(req) { req.redirect("https://#{req.host}:#{https_port}#{req.path}") } end end INVALID_PATH_REGEXP = /\/?(\.\.|\.)\// def find_path(base, path) return nil if path =~ INVALID_PATH_REGEXP full_path = File.join(base, path) return full_path if File.file?(full_path) return find_path(full_path, 'index') if File.directory?(full_path) qualified = "#{full_path}.html" return qualified if File.file?(qualified) nil end SOCKET_OPTS = { reuse_addr: true, reuse_port: true, dont_linger: true, }.freeze def spin_accept_loop(name, port, &block) spin(:accept_loop) do server = Polyphony::Net.tcp_listen('0.0.0.0', port, SOCKET_OPTS) loop do socket = server.accept spin_connection_handler(name, socket, block) rescue Polyphony::BaseException => e raise rescue Exception => e puts "#{name} listener uncaught exception: #{e.inspect}" end ensure finalize_listener(server) if server end end def spin_connection_handler(name, socket, block) spin(:connection_handler) do block.(socket) rescue Polyphony::BaseException raise rescue Exception => e puts "Uncaught error in #{name} handler: #{e.inspect}" p e.backtrace end end def finalize_listener(server) fiber = Fiber.current server.close gracefully_terminate_conections(fiber) if fiber.graceful_shutdown? rescue Polyphony::BaseException raise rescue Exception => e trace "Exception in finalize_listener: #{e.inspect}" end def gracefully_terminate_conections(fiber) supervisor = spin(:connection_termination_supervisor) { supervise }.detach fiber.attach_all_children_to(supervisor) # terminating the supervisor will supervisor.terminate(graceful: true) end def add_connection_headers(app) app # proc do |req| # conn = req.adapter.conn # # req.headers[':peer'] = conn.peeraddr(false)[2] # req.headers[':scheme'] ||= conn.is_a?(OpenSSL::SSL::SSLSocket) ? 'https' : 'http' # app.(req) # end end def ssl_accept(client) client.accept true rescue Polyphony::BaseException raise rescue Exception => e p e e end def start_https_connection_fiber(socket, ctx, thread_pool, app) client = OpenSSL::SSL::SSLSocket.new(socket, ctx) client.sync_close = true result = thread_pool ? thread_pool.process { ssl_accept(client) } : ssl_accept(client) if result.is_a?(Exception) puts "Exception in SSL handshake: #{result.inspect}" return end Tipi.client_loop(client, @opts, &app) rescue => e puts "Uncaught error in HTTPS connection fiber: #{e.inspect} bt: #{e.backtrace.inspect}" ensure (client ? client.close : socket.close) rescue nil end CERTIFICATE_STORE_DEFAULT_DIR = File.expand_path('~/.tipi').freeze CERTIFICATE_STORE_DEFAULT_DB_PATH = File.join( CERTIFICATE_STORE_DEFAULT_DIR, 'certificates.db').freeze def create_certificate_store FileUtils.mkdir(CERTIFICATE_STORE_DEFAULT_DIR) rescue nil Tipi::ACME::SQLiteCertificateStore.new(CERTIFICATE_STORE_DEFAULT_DB_PATH) end def start_server(service) spin(:web_server) do service.call supervise(restart: :always) end end end end ================================================ FILE: lib/tipi/controller/web_stock.rb ================================================ # frozen_string_literal: true require 'ever' require 'localhost/authority' require 'http/parser' require 'qeweney' require 'tipi/rack_adapter' require_relative './extensions' module Tipi class Listener def initialize(server, &handler) @server = server @handler = handler end def accept socket, _addrinfo = @server.accept @handler.call(socket) end end class Connection def io_ready raise NotImplementedError end end class HTTP1Connection < Connection attr_reader :io def initialize(io, evloop, &app) @io = io @evloop = evloop @parser = Http::Parser.new(self) @app = app setup_read_request end def setup_read_request @request_complete = nil @request = nil @response_buffer = nil end def on_headers_complete(headers) headers = normalize_headers(headers) headers[':path'] = @parser.request_url headers[':method'] = @parser.http_method.downcase scheme = (proto = headers['x-forwarded-proto']) ? proto.downcase : scheme_from_connection headers[':scheme'] = scheme @request = Qeweney::Request.new(headers, self) end def normalize_headers(headers) headers.each_with_object({}) do |(k, v), h| k = k.downcase hk = h[k] if hk hk = h[k] = [hk] unless hk.is_a?(Array) v.is_a?(Array) ? hk.concat(v) : hk << v else h[k] = v end end end def scheme_from_connection @io.is_a?(OpenSSL::SSL::SSLSocket) ? 'https' : 'http' end def on_body(chunk) @request.buffer_body_chunk(chunk) end def on_message_complete @request_complete = true end def io_ready if !@request_complete handle_read_request else handle_write_response end end def handle_read_request result = @io.read_nonblock(16384, exception: false) case result when :wait_readable watch_io(false) when :wait_writable watch_io(true) when nil close_io else @parser << result if @request_complete handle_request # @response = handle_request(@request_headers, @request_body) # handle_write_response else watch_io(false) end end rescue HTTP::Parser::Error, SystemCallError, IOError close_io end def watch_io(rw) @evloop.watch_io(self, @io, rw, true) # @evloop.emit([:watch_io, self, @io, rw, true]) end def close_io @evloop.emit([:close_io, self, @io]) end def handle_request @app.call(@request) # req = Qeweney::Request.new(headers, self) # response_body = "Hello, world!" # "HTTP/1.1 200 OK\nContent-Length: #{response_body.bytesize}\n\n#{response_body}" end # response API CRLF = "\r\n" CRLF_ZERO_CRLF_CRLF = "\r\n0\r\n\r\n" # Sends response including headers and body. Waits for the request to complete # if not yet completed. The body is sent using chunked transfer encoding. # @param request [Qeweney::Request] HTTP request # @param body [String] response body # @param headers def respond(request, body, headers) formatted_headers = format_headers(headers, body, false) request.tx_incr(formatted_headers.bytesize + (body ? body.bytesize : 0)) if body handle_write(formatted_headers + body) else handle_write(formatted_headers) end end # Sends response headers. If empty_response is truthy, the response status # code will default to 204, otherwise to 200. # @param request [Qeweney::Request] HTTP request # @param headers [Hash] response headers # @param empty_response [boolean] whether a response body will be sent # @param chunked [boolean] whether to use chunked transfer encoding # @return [void] def send_headers(request, headers, empty_response: false, chunked: true) formatted_headers = format_headers(headers, !empty_response, http1_1?(request) && chunked) request.tx_incr(formatted_headers.bytesize) handle_write(formatted_headers) end def http1_1?(request) request.headers[':protocol'] == 'http/1.1' end # Sends a response body chunk. If no headers were sent, default headers are # sent using #send_headers. if the done option is true(thy), an empty chunk # will be sent to signal response completion to the client. # @param request [Qeweney::Request] HTTP request # @param chunk [String] response body chunk # @param done [boolean] whether the response is completed # @return [void] def send_chunk(request, chunk, done: false) data = +'' data << "#{chunk.bytesize.to_s(16)}\r\n#{chunk}\r\n" if chunk data << "0\r\n\r\n" if done return if data.empty? request.tx_incr(data.bytesize) handle_write(data) end # Finishes the response to the current request. If no headers were sent, # default headers are sent using #send_headers. # @return [void] def finish(request) request.tx_incr(5) handle_write("0\r\n\r\n") end INTERNAL_HEADER_REGEXP = /^:/.freeze # Formats response headers into an array. If empty_response is true(thy), # the response status code will default to 204, otherwise to 200. # @param headers [Hash] response headers # @param body [boolean] whether a response body will be sent # @param chunked [boolean] whether to use chunked transfer encoding # @return [String] formatted response headers def format_headers(headers, body, chunked) status = headers[':status'] status ||= (body ? Qeweney::Status::OK : Qeweney::Status::NO_CONTENT) lines = format_status_line(body, status, chunked) headers.each do |k, v| next if k =~ INTERNAL_HEADER_REGEXP collect_header_lines(lines, k, v) end lines << CRLF lines end def format_status_line(body, status, chunked) if !body empty_status_line(status) else with_body_status_line(status, body, chunked) end end def empty_status_line(status) if status == 204 +"HTTP/1.1 #{status}\r\n" else +"HTTP/1.1 #{status}\r\nContent-Length: 0\r\n" end end def with_body_status_line(status, body, chunked) if chunked +"HTTP/1.1 #{status}\r\nTransfer-Encoding: chunked\r\n" else +"HTTP/1.1 #{status}\r\nContent-Length: #{body.is_a?(String) ? body.bytesize : body.to_i}\r\n" end end def collect_header_lines(lines, key, value) if value.is_a?(Array) value.inject(lines) { |_, item| lines << "#{key}: #{item}\r\n" } else lines << "#{key}: #{value}\r\n" end end def handle_write(data = nil) if data if @response_buffer @response_buffer << data else @response_buffer = +data end end result = @io.write_nonblock(@response_buffer, exception: false) case result when :wait_readable watch_io(false) when :wait_writable watch_io(true) when nil close_io else setup_read_request watch_io(false) end end end class Controller def initialize(opts) @opts = opts @path = File.expand_path(@opts['path']) @service = prepare_service end WORKER_COUNT_RANGE = (1..32).freeze def run worker_count = (@opts['workers'] || 1).to_i.clamp(WORKER_COUNT_RANGE) return run_worker if worker_count == 1 supervise_workers(worker_count) end private def supervise_workers(worker_count) supervisor = spin do worker_count.times do pid = fork { run_worker } puts "Forked worker pid: #{pid}" Process.wait(pid) puts "Done worker pid: #{pid}" end # supervise(restart: :always) rescue Polyphony::Terminate # TODO: find out how Terminate can leak like that (it's supposed to be # caught in Fiber#run) end # trap('SIGTERM') { supervisor.terminate(graceful: true) } # trap('SIGINT') do # trap('SIGINT') { exit! } # supervisor.terminate(graceful: true) # end # supervisor.await end def run_worker @evloop = Ever::Loop.new start_server(@service) trap('SIGTERM') { @evloop.stop } trap('SIGINT') do trap('SIGINT') { exit! } @evloop.stop end run_evloop end def run_evloop @evloop.each do |event| case event when Listener event.accept when Connection event.io_ready when Array cmd, key, io, rw, oneshot = event case cmd when :watch_io @evloop.watch_io(key, io, rw, oneshot) when :close_io io.close end end end end def prepare_service if File.file?(@path) File.extname(@path) == '.ru' ? rack_service : tipi_service elsif File.directory?(@path) static_service else raise "Invalid path specified #{@path}" end end def start_app if File.extname(@path) == '.ru' start_rack_app else require(@path) end end def rack_service puts "Loading Rack app from #{@path}" app = Tipi::RackAdapter.load(@path) web_service(app) end def tipi_service puts "Loading Tipi app from #{@path}" require(@path) app = Tipi.app if !app raise "No app define. The app to run should be set using `Tipi.app = ...`" end web_service(app) end def static_service puts "Serving static files from #{@path}" app = proc do |req| p req: req full_path = find_path(@path, req.path) if full_path req.serve_file(full_path) else req.respond(nil, ':status' => Qeweney::Status::NOT_FOUND) end end web_service(app) end def web_service(app) app = add_connection_headers(app) prepare_listener(@opts['listen'], app) end def prepare_listener(spec, app) case spec.shift when 'http' case spec.size when 2 host, port = spec port ||= 80 when 1 host = '0.0.0.0' port = spec.first || 80 else raise "Invalid listener spec" end prepare_http_listener(port, app) when 'https' case spec.size when 2 host, port = spec port ||= 80 when 1 host = 'localhost' port = spec.first || 80 else raise "Invalid listener spec" end port ||= 443 prepare_https_listener(host, port, app) when 'full' host, http_port, https_port = spec http_port ||= 80 https_port ||= 443 prepare_full_service_listeners(host, http_port, https_port, app) end end def prepare_http_listener(port, app) puts "Listening for HTTP on localhost:#{port}" proc do start_listener('HTTP', port) do |socket| start_client(socket, &app) end end end def start_client(socket, &app) conn = HTTP1Connection.new(socket, @evloop, &app) conn.watch_io(false) end LOCALHOST_REGEXP = /^(.+\.)?localhost$/.freeze def prepare_https_listener(host, port, app) localhost = host =~ LOCALHOST_REGEXP return prepare_localhost_https_listener(port, app) if localhost raise "No certificate found for #{host}" # TODO: implement loading certificate end def prepare_localhost_https_listener(port, app) puts "Listening for HTTPS on localhost:#{port}" authority = Localhost::Authority.fetch ctx = authority.server_context ctx.ciphers = 'ECDH+aRSA' Polyphony::Net.setup_alpn(ctx, Tipi::ALPN_PROTOCOLS) proc do https_listener = spin_accept_loop('HTTPS', port) do |socket| start_https_connection_fiber(socket, ctx, nil, app) rescue Exception => e puts "Exception in https_listener block: #{e.inspect}\n#{e.backtrace.inspect}" end end end def prepare_full_service_listeners(host, http_port, https_port, app) puts "Listening for HTTP on localhost:#{http_port}" puts "Listening for HTTPS on localhost:#{https_port}" redirect_host = (https_port == 443) ? host : "#{host}:#{https_port}" redirect_app = ->(r) { r.redirect("https://#{redirect_host}#{r.path}") } ctx = OpenSSL::SSL::SSLContext.new ctx.ciphers = 'ECDH+aRSA' Polyphony::Net.setup_alpn(ctx, Tipi::ALPN_PROTOCOLS) certificate_store = create_certificate_store proc do challenge_handler = Tipi::ACME::HTTPChallengeHandler.new certificate_manager = Tipi::ACME::CertificateManager.new( master_ctx: ctx, store: certificate_store, challenge_handler: challenge_handler ) http_app = certificate_manager.challenge_routing_app(redirect_app) http_listener = spin_accept_loop('HTTP', http_port) do |socket| Tipi.client_loop(socket, @opts, &http_app) end ssl_accept_thread_pool = Polyphony::ThreadPool.new(4) https_listener = spin_accept_loop('HTTPS', https_port) do |socket| start_https_connection_fiber(socket, ctx, ssl_accept_thread_pool, app) rescue Exception => e puts "Exception in https_listener block: #{e.inspect}\n#{e.backtrace.inspect}" end end end INVALID_PATH_REGEXP = /\/?(\.\.|\.)\// def find_path(base, path) return nil if path =~ INVALID_PATH_REGEXP full_path = File.join(base, path) return full_path if File.file?(full_path) return find_path(full_path, 'index') if File.directory?(full_path) qualified = "#{full_path}.html" return qualified if File.file?(qualified) nil end SOCKET_OPTS = { reuse_addr: true, reuse_port: true, dont_linger: true, }.freeze def start_listener(name, port, &block) host = '0.0.0.0' socket = ::Socket.new(:INET, :STREAM).tap do |s| s.setsockopt(Socket::SOL_SOCKET, Socket::SO_REUSEADDR, 1) s.setsockopt(::Socket::SOL_SOCKET, ::Socket::SO_REUSEPORT, 1) s.setsockopt(Socket::SOL_SOCKET, Socket::SO_LINGER, [0, 0].pack('ii')) addr = ::Socket.sockaddr_in(port, host) s.bind(addr) s.listen(Socket::SOMAXCONN) end listener = Listener.new(socket, &block) @evloop.watch_io(listener, socket, false, false) end def spin_accept_loop(name, port, &block) spin do server = Polyphony::Net.tcp_listen('0.0.0.0', port, SOCKET_OPTS) loop do socket = server.accept spin_connection_handler(name, socket, block) rescue Polyphony::BaseException => e raise rescue Exception => e puts "#{name} listener uncaught exception: #{e.inspect}" end ensure finalize_listener(server) if server end end def spin_connection_handler(name, socket, block) spin do block.(socket) rescue Polyphony::BaseException raise rescue Exception => e puts "Uncaught error in #{name} handler: #{e.inspect}" p e.backtrace end end def finalize_listener(server) fiber = Fiber.current gracefully_terminate_conections(fiber) if fiber.graceful_shutdown? server.close rescue Polyphony::BaseException raise rescue Exception => e trace "Exception in finalize_listener: #{e.inspect}" end def gracefully_terminate_conections(fiber) supervisor = spin { supervise }.detach fiber.attach_all_children_to(supervisor) # terminating the supervisor will supervisor.terminate(graceful: true) end def add_connection_headers(app) app # proc do |req| # conn = req.adapter.conn # # req.headers[':peer'] = conn.peeraddr(false)[2] # req.headers[':scheme'] ||= conn.is_a?(OpenSSL::SSL::SSLSocket) ? 'https' : 'http' # app.(req) # end end def ssl_accept(client) client.accept true rescue Polyphony::BaseException raise rescue Exception => e p e e end def start_https_connection_fiber(socket, ctx, thread_pool, app) client = OpenSSL::SSL::SSLSocket.new(socket, ctx) client.sync_close = true result = thread_pool ? thread_pool.process { ssl_accept(client) } : ssl_accept(client) if result.is_a?(Exception) puts "Exception in SSL handshake: #{result.inspect}" return end Tipi.client_loop(client, @opts, &app) rescue => e puts "Uncaught error in HTTPS connection fiber: #{e.inspect} bt: #{e.backtrace.inspect}" ensure (client ? client.close : socket.close) rescue nil end CERTIFICATE_STORE_DEFAULT_DIR = File.expand_path('~/.tipi').freeze CERTIFICATE_STORE_DEFAULT_DB_PATH = File.join( CERTIFICATE_STORE_DEFAULT_DIR, 'certificates.db').freeze def create_certificate_store FileUtils.mkdir(CERTIFICATE_STORE_DEFAULT_DIR) rescue nil Tipi::ACME::SQLiteCertificateStore.new(CERTIFICATE_STORE_DEFAULT_DB_PATH) end def start_server(service) service.call end end end ================================================ FILE: lib/tipi/controller.rb ================================================ # frozen_string_literal: true require 'bundler/setup' require 'json' # get opts from STDIN opts = JSON.parse(ARGV[0]) rescue nil mod_path = "./controller/#{opts['app_type']}_#{opts['mode']}" require_relative mod_path controller = Tipi::Controller.new(opts) controller.run ================================================ FILE: lib/tipi/digital_fabric/agent.rb ================================================ # frozen_string_literal: true require_relative './protocol' require_relative './request_adapter' require 'msgpack' require 'tipi/websocket' require 'tipi/request' module DigitalFabric class Agent def initialize(server_url, route, token) @server_url = server_url @route = route @token = token @requests = {} @long_running_requests = {} @name = '' end class TimeoutError < RuntimeError end class GracefulShutdown < RuntimeError end @@id = 0 def run @fiber = Fiber.current @keep_alive_timer = spin_loop("#{@fiber.tag}-keep_alive", interval: 5) { keep_alive } while true connect_and_process_incoming_requests return if @shutdown sleep 5 end ensure @keep_alive_timer.stop end def connect_and_process_incoming_requests # log 'Connecting...' @socket = connect_to_server @last_recv = @last_send = Time.now df_upgrade @connected = true @msgpack_reader = MessagePack::Unpacker.new process_incoming_requests rescue IOError, Errno::ECONNREFUSED, Errno::ECONNRESET, Errno::EPIPE, TimeoutError log 'Disconnected' if @connected @connected = nil end def connect_to_server if @server_url =~ /^([^\:]+)\:(\d+)$/ host = Regexp.last_match(1) port = Regexp.last_match(2) Polyphony::Net.tcp_connect(host, port) else UNIXSocket.new(@server_url) end end UPGRADE_REQUEST = <<~HTTP GET / HTTP/1.1 Host: localhost Connection: upgrade Upgrade: df DF-Token: %s DF-Mount: %s HTTP def df_upgrade @socket << format(UPGRADE_REQUEST, @token, mount_point) while (line = @socket.gets) break if line.chomp.empty? end # log 'Connection upgraded' end def mount_point if @route[:host] "host=#{@route[:host]}" elsif @route[:path] "path=#{@route[:path]}" else nil end end def log(msg) puts "#{Time.now} (#{@name}) #{msg}" end def process_incoming_requests @socket.feed_loop(@msgpack_reader, :feed_each) do |msg| recv_df_message(msg) return if @shutdown && @requests.empty? end rescue IOError, SystemCallError, TimeoutError # ignore end def keep_alive return unless @connected now = Time.now if now - @last_send >= Protocol::SEND_TIMEOUT send_df_message(Protocol.ping) end # if now - @last_recv >= Protocol::RECV_TIMEOUT # raise TimeoutError # end rescue IOError, SystemCallError => e # transmit exception to fiber running the agent @fiber.raise(e) end def recv_df_message(msg) @last_recv = Time.now case msg[Protocol::Attribute::KIND] when Protocol::SHUTDOWN recv_shutdown when Protocol::HTTP_REQUEST recv_http_request(msg) when Protocol::HTTP_REQUEST_BODY recv_http_request_body(msg) when Protocol::WS_REQUEST recv_ws_request(msg) when Protocol::CONN_DATA, Protocol::CONN_CLOSE, Protocol::WS_DATA, Protocol::WS_CLOSE fiber = @requests[msg[Protocol::Attribute::ID]] fiber << msg if fiber end end def send_df_message(msg) # we mark long-running requests by applying simple heuristics to sent DF # messages. This is so we can correctly stop long-running requests # upon graceful shutdown if is_long_running_request_response?(msg) id = msg[Protocol::Attribute::ID] @long_running_requests[id] = @requests[id] end @last_send = Time.now @socket << msg.to_msgpack end def is_long_running_request_response?(msg) case msg[Protocol::Attribute::KIND] when Protocol::HTTP_UPGRADE true when Protocol::HTTP_RESPONSE !msg[Protocol::Attribute::HttpResponse::COMPLETE] end end def recv_shutdown # puts "Received shutdown message (#{@requests.size} pending requests)" # puts " (Long running requests: #{@long_running_requests.size})" @shutdown = true @long_running_requests.values.each { |f| f.terminate(graceful: true) } end def recv_http_request(msg) req = prepare_http_request(msg) id = msg[Protocol::Attribute::ID] @requests[id] = spin("#{Fiber.current.tag}.#{id}") do http_request(req) rescue IOError, Errno::ECONNREFUSED, Errno::EPIPE # ignore rescue Polyphony::Terminate => e req.respond(nil, { ':status' => Qeweney::Status::SERVICE_UNAVAILABLE }) if Fiber.current.graceful_shutdown? raise e ensure @requests.delete(id) @long_running_requests.delete(id) @fiber.terminate if @shutdown && @requests.empty? end end def prepare_http_request(msg) headers = msg[Protocol::Attribute::HttpRequest::HEADERS] body_chunk = msg[Protocol::Attribute::HttpRequest::BODY_CHUNK] complete = msg[Protocol::Attribute::HttpRequest::COMPLETE] req = Qeweney::Request.new(headers, RequestAdapter.new(self, msg)) req.buffer_body_chunk(body_chunk) if body_chunk req end def recv_http_request_body(msg) fiber = @requests[msg[Protocol::Attribute::ID]] return unless fiber fiber << msg[Protocol::Attribute::HttpRequestBody::BODY] end def get_http_request_body(id, limit) send_df_message(Protocol.http_get_request_body(id, limit)) receive end def recv_ws_request(msg) req = Qeweney::Request.new(msg[Protocol::Attribute::WS::HEADERS], RequestAdapter.new(self, msg)) id = msg[Protocol::Attribute::ID] @requests[id] = @long_running_requests[id] = spin("#{Fiber.current.tag}.#{id}-ws") do ws_request(req) rescue IOError, Errno::ECONNREFUSED, Errno::EPIPE # ignore ensure @requests.delete(id) @long_running_requests.delete(id) @fiber.terminate if @shutdown && @requests.empty? end end # default handler for HTTP request def http_request(req) req.respond(nil, { ':status': Qeweney::Status::SERVICE_UNAVAILABLE }) end # default handler for WS request def ws_request(req) req.respond(nil, { ':status': Qeweney::Status::SERVICE_UNAVAILABLE }) end end end ================================================ FILE: lib/tipi/digital_fabric/agent_proxy.rb ================================================ # frozen_string_literal: true require_relative './protocol' require 'msgpack' require 'tipi/websocket' module DigitalFabric class AgentProxy def initialize(service, req) @service = service @req = req @conn = req.adapter.conn @msgpack_reader = MessagePack::Unpacker.new @requests = {} @current_request_count = 0 @last_request_id = 0 @last_recv = @last_send = Time.now run end def current_request_count @current_request_count end class TimeoutError < RuntimeError end class GracefulShutdown < RuntimeError end def run @fiber = Fiber.current @service.mount(route, self) @mounted = true # keep_alive_timer = spin_loop("#{@fiber.tag}-keep_alive", interval: 5) { keep_alive } process_incoming_messages(false) rescue GracefulShutdown puts "Proxy got graceful shutdown, left: #{@requests.size} requests" if @requests.size > 0 move_on_after(15) { process_incoming_messages(true) } ensure # keep_alive_timer&.stop unmount end def process_incoming_messages(shutdown = false) return if shutdown && @requests.empty? @conn.feed_loop(@msgpack_reader, :feed_each) do |msg| recv_df_message(msg) return if shutdown && @requests.empty? end rescue TimeoutError, IOError, SystemCallError # ignore and just return in order to terminate the proxy end def unmount return unless @mounted @service.unmount(self) @mounted = nil end def send_shutdown send_df_message(Protocol.shutdown) @fiber.raise GracefulShutdown.new end def keep_alive now = Time.now if now - @last_send >= Protocol::SEND_TIMEOUT send_df_message(Protocol.ping) end # if now - @last_recv >= Protocol::RECV_TIMEOUT # raise TimeoutError # end rescue TimeoutError, IOError end def route case @req.headers['df-mount'] when /^\s*host\s*=\s*([^\s]+)/ { host: Regexp.last_match(1) } when /^\s*path\s*=\s*([^\s]+)/ { path: Regexp.last_match(1) } when /catch_all/ { catch_all: true } else nil end end def recv_df_message(message) @last_recv = Time.now # puts "<<< #{message.inspect}" case message[Protocol::Attribute::KIND] when Protocol::PING return when Protocol::UNMOUNT return unmount when Protocol::STATS_REQUEST return handle_stats_request(message[Protocol::Attribute::ID]) end handler = @requests[message[Protocol::Attribute::ID]] if !handler # puts "Unknown request id in #{message}" return end handler << message end def send_df_message(message) # puts ">>> #{message.inspect}" unless message[Protocol::Attribute::KIND] == Protocol::PING @last_send = Time.now @conn << message.to_msgpack end # HTTP / WebSocket def register_request_fiber id = (@last_request_id += 1) @requests[id] = Fiber.current id end def unregister_request_fiber(id) @requests.delete(id) end def with_request @current_request_count += 1 id = (@last_request_id += 1) @requests[id] = Fiber.current yield id ensure @current_request_count -= 1 @requests.delete(id) end def http_request(req) t0 = Time.now t1 = nil with_request do |id| msg = Protocol.http_request(id, req.headers, req.next_chunk(true), req.complete?) send_df_message(msg) while (message = receive) kind = message[Protocol::Attribute::KIND] unless t1 t1 = Time.now if kind == Protocol::HTTP_RESPONSE headers = message[Protocol::Attribute::HttpResponse::HEADERS] status = (headers && headers[':status']) || 200 if status < Qeweney::Status::BAD_REQUEST @service.record_latency_measurement(t1 - t0, req) end end end attributes = message[Protocol::Attribute::HttpRequest::HEADERS..-1] return if http_request_message(id, req, kind, attributes) end end rescue => e p "Internal server error: #{e.inspect}" puts e.backtrace.join("\n") http_request_send_error_response(e) end def http_request_send_error_response(error) response = format("Error: %s\n%s", error.inspect, error.backtrace.join("\n")) req.respond(response, ':status' => Qeweney::Status::INTERNAL_SERVER_ERROR) rescue IOError, SystemCallError # ignore end # @return [Boolean] true if response is complete def http_request_message(id, req, kind, message) case kind when Protocol::HTTP_UPGRADE http_custom_upgrade(id, req, *message) true when Protocol::HTTP_GET_REQUEST_BODY http_get_request_body(id, req, *message) false when Protocol::HTTP_RESPONSE http_response(id, req, *message) else # invalid message true end end def send_transfer_count(key, rx, tx) send_df_message(Protocol.transfer_count(key, rx, tx)) end def handle_stats_request(id) stats = @service.get_stats send_df_message(Protocol.stats_response(id, stats)) end HTTP_RESPONSE_UPGRADE_HEADERS = { ':status' => Qeweney::Status::SWITCHING_PROTOCOLS } def http_custom_upgrade(id, req, headers) # send upgrade response upgrade_headers = headers ? headers.merge(HTTP_RESPONSE_UPGRADE_HEADERS) : HTTP_RESPONSE_UPGRADE_HEADERS req.send_headers(upgrade_headers, true) conn = req.adapter.conn reader = spin("#{Fiber.current.tag}.#{id}") do conn.recv_loop do |data| send_df_message(Protocol.conn_data(id, data)) end end while (message = receive) return if http_custom_upgrade_message(conn, message) end ensure reader.stop end def http_custom_upgrade_message(conn, message) case message[Protocol::Attribute::KIND] when Protocol::CONN_DATA conn << message[:Protocol::Attribute::ConnData::DATA] false when Protocol::CONN_CLOSE true else # invalid message true end end def http_response(id, req, body, headers, complete, transfer_count_key) if !req.headers_sent? && complete req.respond(body, headers|| {}) if transfer_count_key rx, tx = req.transfer_counts send_transfer_count(transfer_count_key, rx, tx) end true else req.send_headers(headers) if headers && !req.headers_sent? req.send_chunk(body, done: complete) if body or complete if complete && transfer_count_key rx, tx = req.transfer_counts send_transfer_count(transfer_count_key, rx, tx) end complete end rescue IOError, SystemCallError # ignore error end def http_get_request_body(id, req, limit) case limit when nil body = req.read else limit = limit.to_i body = nil req.each_chunk do |chunk| (body ||= +'') << chunk break if body.bytesize >= limit end end send_df_message(Protocol.http_request_body(id, body, req.complete?)) end def http_upgrade(req, protocol) if protocol == 'websocket' handle_websocket_upgrade(req) else # other protocol upgrades should be handled by the agent, so we just run # the request normally. The agent is expected to upgrade the connection # using a http_upgrade message. From that moment on, two-way # communication is handled using conn_data and conn_close messages. http_request(req) end end def handle_websocket_upgrade(req) with_request do |id| send_df_message(Protocol.ws_request(id, req.headers)) response = receive case response[0] when Protocol::WS_RESPONSE headers = response[2] || {} status = headers[':status'] || Qeweney::Status::SWITCHING_PROTOCOLS if status != Qeweney::Status::SWITCHING_PROTOCOLS req.respond(nil, headers) return end ws = Tipi::Websocket.new(req.adapter.conn, req.headers) run_websocket_connection(id, ws) else req.respond(nil, ':status' => Qeweney::Status::SERVICE_UNAVAILABLE) end end rescue IOError, SystemCallError # ignore end def run_websocket_connection(id, websocket) reader = spin("#{Fiber.current}.#{id}-ws") do websocket.recv_loop do |data| send_df_message(Protocol.ws_data(id, data)) end end while (message = receive) case message[Protocol::Attribute::KIND] when Protocol::WS_DATA websocket << message[Protocol::Attribute::WS::DATA] when Protocol::WS_CLOSE return else raise "Unexpected websocket message #{message.inspect}" end end ensure reader.stop websocket.close end end end ================================================ FILE: lib/tipi/digital_fabric/executive/index.html ================================================ Digital Fabric Executive

Digital Fabric Executive

================================================ FILE: lib/tipi/digital_fabric/executive.rb ================================================ # frozen_string_literal: true require 'tipi/digital_fabric' require 'json' module DigitalFabric # agent for managing DF service class Executive INDEX_HTML = IO.read(File.join(__dir__, 'executive/index.html')) attr_reader :last_service_stats def initialize(service, route = { path: '/executive' }) @service = service route[:executive] = true @service.mount(route, self) @current_request_count = 0 # @updater = spin_loop(:executive_updater, interval: 10) { update_service_stats } update_service_stats end def current_request_count @current_request_count end def http_request(req) @current_request_count += 1 case req.path when '/' req.respond(INDEX_HTML, 'Content-Type' => 'text/html') when '/stats' message = last_service_stats req.respond(message.to_json, { 'Content-Type' => 'text.json' }) when '/stream/stats' stream_stats(req) when '/upload' req.respond("body: #{req.read.inspect}") else req.respond('Invalid path', { ':status' => Qeweney::Status::NOT_FOUND }) end rescue => e puts "Error: #{e.inspect}" ensure @current_request_count -= 1 end def stream_stats(req) req.send_headers({ 'Content-Type' => 'text/event-stream' }) every(10) do message = last_service_stats req.send_chunk(format_sse_event(message.to_json)) end rescue IOError, SystemCallError # ignore ensure req.send_chunk("retry: 0\n\n", true) rescue nil end def format_sse_event(data) "data: #{data}\n\n" end def update_service_stats @last_service_stats = { service: @service.stats, machine: machine_stats } end TOP_CPU_REGEXP = /%Cpu(.+)/.freeze TOP_CPU_IDLE_REGEXP = /([\d\.]+) id/.freeze TOP_MEM_REGEXP = /MiB Mem(.+)/.freeze TOP_MEM_FREE_REGEXP = /([\d\.]+) free/.freeze LOADAVG_REGEXP = /^([\d\.]+)/.freeze def machine_stats top = `top -bn1 | head -n4` unless top =~ TOP_CPU_REGEXP && Regexp.last_match(1) =~ TOP_CPU_IDLE_REGEXP p top =~ TOP_CPU_REGEXP p Regexp.last_match(1) p Regexp.last_match(1) =~ TOP_CPU_IDLE_REGEXP raise 'Invalid output from top (cpu)' end cpu_utilization = 100 - Regexp.last_match(1).to_i unless top =~ TOP_MEM_REGEXP && Regexp.last_match(1) =~ TOP_MEM_FREE_REGEXP raise 'Invalid output from top (mem)' end mem_free = Regexp.last_match(1).to_f stats = `cat /proc/loadavg` raise 'Invalid output from /proc/loadavg' unless stats =~ LOADAVG_REGEXP load_avg = Regexp.last_match(1).to_f { mem_free: mem_free, cpu_utilization: cpu_utilization, load_avg: load_avg } end end end ================================================ FILE: lib/tipi/digital_fabric/protocol.rb ================================================ # frozen_string_literal: true module DigitalFabric module Protocol PING = 'ping' SHUTDOWN = 'shutdown' UNMOUNT = 'unmount' HTTP_REQUEST = 'http_request' HTTP_RESPONSE = 'http_response' HTTP_UPGRADE = 'http_upgrade' HTTP_GET_REQUEST_BODY = 'http_get_request_body' HTTP_REQUEST_BODY = 'http_request_body' CONN_DATA = 'conn_data' CONN_CLOSE = 'conn_close' WS_REQUEST = 'ws_request' WS_RESPONSE = 'ws_response' WS_DATA = 'ws_data' WS_CLOSE = 'ws_close' TRANSFER_COUNT = 'transfer_count' STATS_REQUEST = 'stats_request' STATS_RESPONSE = 'stats_response' SEND_TIMEOUT = 15 RECV_TIMEOUT = SEND_TIMEOUT + 5 module Attribute KIND = 0 ID = 1 module HttpRequest HEADERS = 2 BODY_CHUNK = 3 COMPLETE = 4 end module HttpResponse BODY = 2 HEADERS = 3 COMPLETE = 4 TRANSFER_COUNT_KEY = 5 end module HttpUpgrade HEADERS = 2 end module HttpGetRequestBody LIMIT = 2 end module HttpRequestBody BODY = 2 COMPLETE = 3 end module ConnectionData DATA = 2 end module WS HEADERS = 2 DATA = 2 end module TransferCount KEY = 1 RX = 2 TX = 3 end module Stats STATS = 2 end end class << self def ping [ PING ] end def shutdown [ SHUTDOWN ] end def unmount [ UNMOUNT ] end DF_UPGRADE_RESPONSE = <<~HTTP.gsub("\n", "\r\n") HTTP/1.1 101 Switching Protocols Upgrade: df Connection: Upgrade HTTP def df_upgrade_response DF_UPGRADE_RESPONSE end def http_request(id, headers, buffered_chunk, complete) [ HTTP_REQUEST, id, headers, buffered_chunk, complete ] end def http_response(id, body, headers, complete, transfer_count_key = nil) [ HTTP_RESPONSE, id, body, headers, complete, transfer_count_key ] end def http_upgrade(id, headers) [ HTTP_UPGRADE, id, headers ] end def http_get_request_body(id, limit = nil) [ HTTP_GET_REQUEST_BODY, id, limit ] end def http_request_body(id, body, complete) [ HTTP_REQUEST_BODY, id, body, complete ] end def connection_data(id, data) [ CONN_DATA, id, data ] end def connection_close(id) [ CONN_CLOSE, id ] end def ws_request(id, headers) [ WS_REQUEST, id, headers ] end def ws_response(id, headers) [ WS_RESPONSE, id, headers ] end def ws_data(id, data) [ WS_DATA, id, data ] end def ws_close(id) [ WS_CLOSE, id ] end def transfer_count(key, rx, tx) [ TRANSFER_COUNT, key, rx, tx ] end def stats_request(id) [ STATS_REQUEST, id ] end def stats_response(id, stats) [ STATS_RESPONSE, id, stats ] end end end end ================================================ FILE: lib/tipi/digital_fabric/request_adapter.rb ================================================ # frozen_string_literal: true require_relative './protocol' module DigitalFabric class RequestAdapter def initialize(agent, msg) @agent = agent @id = msg[Protocol::Attribute::ID] end def protocol 'df' end def get_body_chunk(request) @agent.get_http_request_body(@id, 1) end def respond(request, body, headers) @agent.send_df_message( Protocol.http_response(@id, body, headers, true) ) end def send_headers(request, headers, opts = {}) @agent.send_df_message( Protocol.http_response(@id, nil, headers, false) ) end def send_chunk(request, body, done: ) @agent.send_df_message( Protocol.http_response(@id, body, nil, done) ) end def finish(request) @agent.send_df_message( Protocol.http_response(@id, nil, nil, true) ) end end end ================================================ FILE: lib/tipi/digital_fabric/service.rb ================================================ # frozen_string_literal: true require_relative './protocol' require_relative './agent_proxy' require 'securerandom' module DigitalFabric class Service attr_reader :token attr_reader :timer def initialize(token: ) @token = token @agents = {} @routes = {} @counters = { connections: 0, http_requests: 0, errors: 0 } @connection_count = 0 @current_request_count = 0 @http_latency_accumulator = 0 @http_latency_counter = 0 @http_latency_max = 0 @last_counters = @counters.merge(stamp: Time.now.to_f - 1) @fiber = Fiber.current # @timer = Polyphony::Timer.new('service_timer', resolution: 5) end def calculate_stats now = Time.now.to_f elapsed = now - @last_counters[:stamp] connections = @counters[:connections] - @last_counters[:connections] http_requests = @counters[:http_requests] - @last_counters[:http_requests] errors = @counters[:errors] - @last_counters[:errors] @last_counters = @counters.merge(stamp: now) average_latency = @http_latency_counter == 0 ? 0 : @http_latency_accumulator / @http_latency_counter @http_latency_accumulator = 0 @http_latency_counter = 0 max_latency = @http_latency_max @http_latency_max = 0 cpu, rss = pid_cpu_and_rss(Process.pid) backend_stats = Thread.backend.stats op_rate = backend_stats[:op_count] / elapsed switch_rate = backend_stats[:switch_count] / elapsed poll_rate = backend_stats[:poll_count] / elapsed object_space_stats = ObjectSpace.count_objects { service: { agent_count: @agents.size, connection_count: @connection_count, connection_rate: connections / elapsed, error_rate: errors / elapsed, http_request_rate: http_requests / elapsed, latency_avg: average_latency, latency_max: max_latency, pending_requests: @current_request_count, }, backend: { op_rate: op_rate, pending_ops: backend_stats[:pending_ops], poll_rate: poll_rate, runqueue_size: backend_stats[:runqueue_size], runqueue_high_watermark: backend_stats[:runqueue_max_length], switch_rate: switch_rate, }, process: { cpu_usage: cpu, rss: rss.to_f / 1024, objects_total: object_space_stats[:TOTAL], objects_free: object_space_stats[:FREE] } } end def pid_cpu_and_rss(pid) s = `ps -p #{pid} -o %cpu,rss` cpu, rss = s.lines[1].chomp.strip.split(' ') [cpu.to_f, rss.to_i] rescue Polyphony::BaseException raise rescue Exception [nil, nil] end def get_stats calculate_stats end def incr_connection_count @connection_count += 1 end def decr_connection_count @connection_count -= 1 end attr_reader :stats def total_request_count count = 0 @agents.keys.each do |agent| if agent.respond_to?(:current_request_count) count += agent.current_request_count end end count end def record_latency_measurement(latency, req) @http_latency_accumulator += latency @http_latency_counter += 1 @http_latency_max = latency if latency > @http_latency_max return if latency < 1.0 puts format('slow request (%.1f): %p', latency, req.headers) end def http_request(req, allow_df_upgrade = false) @current_request_count += 1 @counters[:http_requests] += 1 @counters[:connections] += 1 if req.headers[':first'] return upgrade_request(req, allow_df_upgrade) if req.upgrade_protocol inject_request_headers(req) agent = find_agent(req) unless agent @counters[:errors] += 1 return req.respond(nil, ':status' => Qeweney::Status::SERVICE_UNAVAILABLE) end agent.http_request(req) rescue IOError, SystemCallError, HTTP2::Error::StreamClosed @counters[:errors] += 1 rescue => e @counters[:errors] += 1 puts '*' * 40 p req p e puts e.backtrace.join("\n") req.respond(e.inspect, ':status' => Qeweney::Status::INTERNAL_SERVER_ERROR) ensure @current_request_count -= 1 req.adapter.conn.close if @shutdown end def inject_request_headers(req) req.headers['x-request-id'] = SecureRandom.uuid conn = req.adapter.conn req.headers['x-forwarded-for'] = conn.peeraddr(false)[2] req.headers['x-forwarded-proto'] ||= conn.is_a?(OpenSSL::SSL::SSLSocket) ? 'https' : 'http' end def upgrade_request(req, allow_df_upgrade) case (protocol = req.upgrade_protocol) when 'df' if allow_df_upgrade df_upgrade(req) else req.respond(nil, ':status' => Qeweney::Status::SERVICE_UNAVAILABLE) end else agent = find_agent(req) unless agent @counters[:errors] += 1 return req.respond(nil, ':status' => Qeweney::Status::SERVICE_UNAVAILABLE) end agent.http_upgrade(req, protocol) end end def df_upgrade(req) # we don't want to count connected agents @current_request_count -= 1 if req.headers['df-token'] != @token return req.respond(nil, ':status' => Qeweney::Status::FORBIDDEN) end req.adapter.conn << Protocol.df_upgrade_response AgentProxy.new(self, req) ensure @current_request_count += 1 end def mount(route, agent) if route[:path] route[:path_regexp] = path_regexp(route[:path]) end @executive = agent if route[:executive] @agents[agent] = route @routing_changed = true end def unmount(agent) route = @agents[agent] return unless route @executive = nil if route[:executive] @agents.delete(agent) @routing_changed = true end INVALID_HOST = 'INVALID_HOST' def find_agent(req) compile_agent_routes if @routing_changed host = req.headers[':authority'] || req.headers['host'] || INVALID_HOST path = req.headers[':path'] route = @route_keys.find do |route| (host == route[:host]) || (path =~ route[:path_regexp]) end return @routes[route] if route nil end def compile_agent_routes @routing_changed = false @routes.clear @agents.keys.reverse.each do |agent| route = @agents[agent] @routes[route] ||= agent end @route_keys = @routes.keys end def path_regexp(path) /^#{path}/ end def graceful_shutdown @shutdown = true @agents.keys.each do |agent| if agent.respond_to?(:send_shutdown) agent.send_shutdown else @agents.delete(agent) end end move_on_after(60) do while !@agents.empty? sleep 0.25 end end end end end ================================================ FILE: lib/tipi/digital_fabric.rb ================================================ module DigitalFabric end ::DF = DigitalFabric require_relative 'digital_fabric/service' require_relative 'digital_fabric/agent_proxy' ================================================ FILE: lib/tipi/handler.rb ================================================ # frozen_string_literal: true require_relative './rack_adapter' require_relative './http1_adapter' require_relative './http2_adapter' module Tipi class DefaultHandler def initialize(config) @config = config app_path = ARGV.first || './config.ru' @app = Tipi::RackAdapter.load(app_path) end def call(socket) socket.no_delay if socket.respond_to?(:no_delay) adapter = protocol_adapter(socket, {}) adapter.each(&@app) ensure socket.close end ALPN_PROTOCOLS = %w[h2 http/1.1].freeze H2_PROTOCOL = 'h2' def protocol_adapter(socket, opts) use_http2 = socket.respond_to?(:alpn_protocol) && socket.alpn_protocol == H2_PROTOCOL klass = use_http2 ? HTTP2Adapter : HTTP1Adapter klass.new(socket, opts) end end end ================================================ FILE: lib/tipi/http1_adapter.rb ================================================ # frozen_string_literal: true require 'h1p' require 'qeweney/request' require_relative './http2_adapter' module Tipi # HTTP1 protocol implementation class HTTP1Adapter attr_reader :conn # Initializes a protocol adapter instance def initialize(conn, opts) @conn = conn @opts = opts @first = true @parser = H1P::Parser.new(@conn, :server) end def each(&block) while true headers = @parser.parse_headers break unless headers # handle_request returns true if connection is not persistent or was # upgraded break if handle_request(headers, &block) end rescue SystemCallError, IOError, H1P::Error # connection or parser error, ignore ensure finalize_client_loop end def handle_request(headers, &block) scheme = (proto = headers['x-forwarded-proto']) ? proto.downcase : scheme_from_connection headers[':scheme'] = scheme @protocol = headers[':protocol'] if @first headers[':first'] = true @first = nil end return true if upgrade_connection(headers, &block) request = Qeweney::Request.new(headers, self) if !@parser.complete? request.buffer_body_chunk(@parser.read_body_chunk(true)) end block.call(request) return !persistent_connection?(headers) end def persistent_connection?(headers) if headers[':protocol'] == 'http/1.1' return headers['connection'] != 'close' else connection = headers['connection'] return connection && connection != 'close' end end def finalize_client_loop @parser = nil @splicing_pipe = nil @conn.shutdown if @conn.respond_to?(:shutdown) rescue nil @conn.close end # Reads a body chunk for the current request. Transfers control to the parse # loop, and resumes once the parse_loop has fired the on_body callback def get_body_chunk(request, buffered_only = false) @parser.read_body_chunk(buffered_only) end def get_body(request) @parser.read_body end def complete?(request) @parser.complete? end def protocol @protocol end # Upgrades the connection to a different protocol, if the 'Upgrade' header is # given. By default the only supported upgrade protocol is HTTP2. Additional # protocols, notably WebSocket, can be specified by passing a hash to the # :upgrade option when starting a server: # # def ws_handler(conn) # conn << 'hi' # msg = conn.recv # conn << "You said #{msg}" # conn << 'bye' # conn.close # end # # opts = { # upgrade: { # websocket: Tipi::Websocket.handler(&method(:ws_handler)) # } # } # Tipi.serve('0.0.0.0', 1234, opts) { |req| ... } # # @param headers [Hash] request headers # @return [boolean] truthy if the connection has been upgraded def upgrade_connection(headers, &block) upgrade_protocol = headers['upgrade'] return nil unless upgrade_protocol upgrade_protocol = upgrade_protocol.downcase.to_sym upgrade_handler = @opts[:upgrade] && @opts[:upgrade][upgrade_protocol] return upgrade_with_handler(upgrade_handler, headers) if upgrade_handler return upgrade_to_http2(headers, &block) if upgrade_protocol == :h2c nil end def upgrade_with_handler(handler, headers) @parser = nil handler.(self, headers) true end def upgrade_to_http2(headers, &block) headers = http2_upgraded_headers(headers) body = @parser.read_body HTTP2Adapter.upgrade_each(@conn, @opts, headers, body, &block) true end # Returns headers for HTTP2 upgrade # @param headers [Hash] request headers # @return [Hash] headers for HTTP2 upgrade def http2_upgraded_headers(headers) headers.merge( ':scheme' => 'http', ':authority' => headers['host'] ) end def websocket_connection(request) Tipi::Websocket.new(@conn, request.headers) end def scheme_from_connection @conn.is_a?(OpenSSL::SSL::SSLSocket) ? 'https' : 'http' end # response API CRLF = "\r\n" # Sends response including headers and body. Waits for the request to complete # if not yet completed. The body is sent using chunked transfer encoding. # @param request [Qeweney::Request] HTTP request # @param body [String] response body # @param headers def respond(request, body, headers) written = H1P.send_response(@conn, headers, body) request.tx_incr(written) end CHUNK_LENGTH_PROC = ->(len) { "#{len.to_s(16)}\r\n" } def respond_from_io(request, io, headers, chunk_size = 2**14) formatted_headers = format_headers(headers, true, true) request.tx_incr(formatted_headers.bytesize) # assume chunked encoding Thread.current.backend.splice_chunks( io, @conn, formatted_headers, "0\r\n\r\n", CHUNK_LENGTH_PROC, "\r\n", chunk_size ) end # Sends response headers. If empty_response is truthy, the response status # code will default to 204, otherwise to 200. # @param request [Qeweney::Request] HTTP request # @param headers [Hash] response headers # @param empty_response [boolean] whether a response body will be sent # @param chunked [boolean] whether to use chunked transfer encoding # @return [void] def send_headers(request, headers, empty_response: false, chunked: true) formatted_headers = format_headers(headers, !empty_response, http1_1?(request) && chunked) request.tx_incr(formatted_headers.bytesize) @conn.write(formatted_headers) end def http1_1?(request) request.headers[':protocol'] == 'http/1.1' end # Sends a response body chunk. If no headers were sent, default headers are # sent using #send_headers. if the done option is true(thy), an empty chunk # will be sent to signal response completion to the client. # @param request [Qeweney::Request] HTTP request # @param chunk [String] response body chunk # @param done [boolean] whether the response is completed # @return [void] def send_chunk(request, chunk, done: false) if done data = chunk ? "#{chunk.bytesize.to_s(16)}\r\n#{chunk}\r\n0\r\n\r\n" : "0\r\n\r\n" elsif chunk data = "#{chunk.bytesize.to_s(16)}\r\n#{chunk}\r\n" else return end request.tx_incr(data.bytesize) @conn.write(data) end def send_chunk_from_io(request, io, r, w, chunk_size) len = w.splice(io, chunk_size) if len > 0 Thread.current.backend.chain( [:write, @conn, "#{len.to_s(16)}\r\n"], [:splice, r, @conn, len], [:write, @conn, "\r\n"] ) else @conn.write("0\r\n\r\n") end len end # Finishes the response to the current request. If no headers were sent, # default headers are sent using #send_headers. # @return [void] def finish(request) request.tx_incr(5) @conn << "0\r\n\r\n" end def close @conn.shutdown if @conn.respond_to?(:shutdown) rescue nil @conn.close end private INTERNAL_HEADER_REGEXP = /^:/.freeze # Formats response headers into an array. If empty_response is true(thy), # the response status code will default to 204, otherwise to 200. # @param headers [Hash] response headers # @param body [boolean] whether a response body will be sent # @param chunked [boolean] whether to use chunked transfer encoding # @return [String] formatted response headers def format_headers(headers, body, chunked) status = headers[':status'] status ||= (body ? Qeweney::Status::OK : Qeweney::Status::NO_CONTENT) lines = format_status_line(body, status, chunked) headers.each do |k, v| next if k =~ INTERNAL_HEADER_REGEXP collect_header_lines(lines, k, v) end lines << CRLF lines end def format_status_line(body, status, chunked) if !body empty_status_line(status) else with_body_status_line(status, body, chunked) end end def empty_status_line(status) if status == 204 +"HTTP/1.1 #{status}\r\n" else +"HTTP/1.1 #{status}\r\nContent-Length: 0\r\n" end end def with_body_status_line(status, body, chunked) if chunked +"HTTP/1.1 #{status}\r\nTransfer-Encoding: chunked\r\n" else +"HTTP/1.1 #{status}\r\nContent-Length: #{body.is_a?(String) ? body.bytesize : body.to_i}\r\n" end end def collect_header_lines(lines, key, value) if value.is_a?(Array) value.inject(lines) { |_, item| lines << "#{key}: #{item}\r\n" } else lines << "#{key}: #{value}\r\n" end end end end ================================================ FILE: lib/tipi/http2_adapter.rb ================================================ # frozen_string_literal: true require 'http/2' require_relative './http2_stream' # patch to fix bug in HTTP2::Stream class HTTP2::Stream def end_stream?(frame) case frame[:type] when :data, :headers, :continuation frame[:flags]&.include?(:end_stream) else false end end end module Tipi # HTTP2 server adapter class HTTP2Adapter def self.upgrade_each(socket, opts, headers, body, &block) adapter = new(socket, opts, headers, body) adapter.each(&block) end def initialize(conn, opts, upgrade_headers = nil, upgrade_body = nil) @conn = conn @opts = opts @upgrade_headers = upgrade_headers @upgrade_body = upgrade_body @first = true @rx = (upgrade_headers && upgrade_headers[':rx']) || 0 @tx = (upgrade_headers && upgrade_headers[':tx']) || 0 @interface = ::HTTP2::Server.new @connection_fiber = Fiber.current @interface.on(:frame, &method(:send_frame)) @streams = {} end def send_frame(data) if @transfer_count_request @transfer_count_request.tx_incr(data.bytesize) end @conn << data rescue Polyphony::BaseException raise rescue Exception => e @connection_fiber.transfer e end UPGRADE_MESSAGE = <<~HTTP.gsub("\n", "\r\n") HTTP/1.1 101 Switching Protocols Connection: Upgrade Upgrade: h2c HTTP def upgrade @conn << UPGRADE_MESSAGE @tx += UPGRADE_MESSAGE.bytesize settings = @upgrade_headers['http2-settings'] @interface.upgrade(settings, @upgrade_headers, @upgrade_body || '') ensure @upgrade_headers = nil end # Iterates over incoming requests def each(&block) @interface.on(:stream) { |stream| start_stream(stream, &block) } upgrade if @upgrade_headers @conn.recv_loop do |data| @rx += data.bytesize @interface << data end rescue SystemCallError, IOError, HTTP2::Error::Error # ignore ensure finalize_client_loop end def get_rx_count count = @rx @rx = 0 count end def get_tx_count count = @tx @tx = 0 count end def start_stream(stream, &block) stream = HTTP2StreamHandler.new(self, stream, @conn, @first, &block) @first = nil if @first @streams[stream] = true end def finalize_client_loop @interface = nil @streams.each_key(&:stop) @conn.shutdown if @conn.respond_to?(:shutdown) rescue nil @conn.close end def close @conn.shutdown if @conn.respond_to?(:shutdown) rescue nil @conn.close end def set_request_for_transfer_count(request) @transfer_count_request = request end def unset_request_for_transfer_count(request) return unless @transfer_count_request == request @transfer_count_request = nil end end end ================================================ FILE: lib/tipi/http2_stream.rb ================================================ # frozen_string_literal: true require 'http/2' require 'qeweney/request' module Tipi # Manages an HTTP 2 stream class HTTP2StreamHandler attr_reader :conn def initialize(adapter, stream, conn, first, &block) @adapter = adapter @stream = stream @conn = conn @first = first @connection_fiber = Fiber.current @stream_fiber = spin { run(&block) } # Stream callbacks occur on the connection fiber (see HTTP2Adapter#each). # The request handler is run on a separate fiber for each stream, allowing # concurrent handling of incoming requests on the same HTTP/2 connection. # # The different stream adapter APIs suspend the stream fiber, waiting for # stream callbacks to be called. The callbacks, in turn, transfer control to # the stream fiber, effectively causing the return of the adapter API calls. # # Note: the request handler is run once headers are received. Reading the # request body, if present, is at the discretion of the request handler. # This mirrors the behaviour of the HTTP/1 adapter. stream.on(:headers, &method(:on_headers)) stream.on(:data, &method(:on_data)) stream.on(:half_close, &method(:on_half_close)) end def run(&block) request = receive error = nil block.(request) @connection_fiber.schedule rescue Polyphony::BaseException raise rescue Exception => e error = e ensure @connection_fiber.schedule error end def on_headers(headers) @request = Qeweney::Request.new(headers.to_h, self) @request.rx_incr(@adapter.get_rx_count) @request.tx_incr(@adapter.get_tx_count) if @first @request.headers[':first'] = true @first = false end @stream_fiber << @request end def on_data(data) data = data.to_s # chunks might be wrapped in a HTTP2::Buffer (@buffered_chunks ||= []) << data @get_body_chunk_fiber&.schedule end def on_half_close @get_body_chunk_fiber&.schedule @complete = true end def protocol 'h2' end def with_transfer_count(request) @adapter.set_request_for_transfer_count(request) yield ensure @adapter.unset_request_for_transfer_count(request) end def get_body_chunk(request, buffered_only = false) @buffered_chunks ||= [] return @buffered_chunks.shift unless @buffered_chunks.empty? return nil if @complete begin @get_body_chunk_fiber = Fiber.current suspend ensure @get_body_chunk_fiber = nil end @buffered_chunks.shift end def get_body(request) @buffered_chunks ||= [] return @buffered_chunks.join if @complete while !@complete begin @get_body_chunk_fiber = Fiber.current suspend ensure @get_body_chunk_fiber = nil end end @buffered_chunks.join end def complete?(request) @complete end # response API def respond(request, body, headers) headers = normalize_status_header(headers) with_transfer_count(request) do @stream.headers(transform_headers(headers)) @headers_sent = true @stream.data(body || '') end rescue HTTP2::Error::StreamClosed # ignore end def respond_from_io(request, io, headers, chunk_size = 2**16) headers = normalize_status_header(headers) with_transfer_count(request) do @stream.headers(transform_headers(headers)) @headers_sent = true while (chunk = io.read(chunk_size)) @stream.data(chunk) end end rescue HTTP2::Error::StreamClosed # ignore end def transform_headers(headers) headers.each_with_object([]) do |(k, v), a| if v.is_a?(Array) v.each { |vv| a << [k, vv.to_s] } else a << [k, v.to_s] end end end def send_headers(request, headers, empty_response: false) return if @headers_sent status = empty_response ? Qeweney::Status::NO_CONTENT : Qeweney::Status::OK headers = normalize_status_header(headers, status) with_transfer_count(request) do @stream.headers(transform_headers(headers), end_stream: false) end @headers_sent = true rescue HTTP2::Error::StreamClosed # ignore end def send_chunk(request, chunk, done: false) send_headers({}, false) unless @headers_sent if chunk with_transfer_count(request) do @stream.data(chunk, end_stream: done) end elsif done @stream.close end rescue HTTP2::Error::StreamClosed # ignore end def finish(request) if @headers_sent @stream.close else headers[':status'] ||= Qeweney::Status::NO_CONTENT with_transfer_count(request) do @stream.headers(transform_headers(headers), end_stream: true) end end rescue HTTP2::Error::StreamClosed # ignore end def stop return if @complete @stream.close @stream_fiber.schedule(Polyphony::MoveOn.new) end private def normalize_status_header(headers, default_status = Qeweney::Status::OK) if !headers[':status'] headers.merge(':status' => default_status.to_s) elsif !headers[':status'].is_a?(String) headers.merge(headers[':status'].to_s) else headers end end end end ================================================ FILE: lib/tipi/rack_adapter.rb ================================================ # frozen_string_literal: true require 'rack' module Tipi module RackAdapter class << self def run(app) ->(req) { respond(req, app.(env(req))) } end def load(path) src = IO.read(path) instance_eval(src, path, 1) end def env(request) Qeweney.rack_env_from_request(request) end def respond(request, (status_code, headers, body)) headers[':status'] = status_code.to_s content = if body.respond_to?(:to_path) File.open(body.to_path, 'rb') { |f| f.read } else body.first end request.respond(content, headers) end end end end ================================================ FILE: lib/tipi/response_extensions.rb ================================================ # frozen_string_literal: true require 'qeweney/request' module Tipi module ResponseExtensions SPLICE_CHUNKS_SIZE_THRESHOLD = 2**20 def serve_io(io, opts) if !opts[:stat] || opts[:stat].size >= SPLICE_CHUNKS_SIZE_THRESHOLD @adapter.respond_from_io(self, io, opts[:headers], opts[:chunk_size] || 2**14) else respond(io.read, opts[:headers] || {}) end end end end ================================================ FILE: lib/tipi/supervisor.rb ================================================ # frozen_string_literal: true require 'polyphony' require 'json' module Tipi module Supervisor class << self def run(opts) puts "Start supervisor pid: #{Process.pid}" @opts = opts @controller_watcher = start_controller_watcher supervise_loop end def start_controller_watcher spin do cmd = controller_cmd puts "Starting controller..." pid = Kernel.spawn(*cmd) @controller_pid = pid puts "Controller pid: #{pid}" _pid, status = Polyphony.backend_waitpid(pid) puts "Controller has terminated with status: #{status.inspect}" terminated = true ensure if pid && !terminated puts "Terminate controller #{pid.inspect}" Polyphony::Process.kill_process(pid) end Fiber.current.parent << pid end end def controller_cmd [ 'ruby', File.join(__dir__, 'controller.rb'), @opts.to_json ] end def supervise_loop this_fiber = Fiber.current trap('SIGUSR2') { this_fiber << :replace_controller } loop do case (msg = receive) when :replace_controller replace_controller when Integer pid = msg if pid == @controller_pid puts 'Detected dead controller. Restarting...' exit! @controller_watcher.restart end else raise "Invalid message received: #{msg.inspect}" end end end def replace_controller puts "Replacing controller" old_watcher = @controller_watcher @controller_watcher = start_controller_watcher # TODO: we'll want to get some kind of signal from the new controller once it's ready sleep 1 old_watcher.terminate(graceful: true) end end end end ================================================ FILE: lib/tipi/version.rb ================================================ # frozen_string_literal: true module Tipi VERSION = '0.56' end ================================================ FILE: lib/tipi/websocket.rb ================================================ # frozen_string_literal: true require 'digest/sha1' require 'websocket' module Tipi # Websocket connection class Websocket def self.handler(&block) proc do |adapter, headers| req = Qeweney::Request.new(headers, adapter) websocket = req.upgrade_to_websocket block.(websocket) end end def initialize(conn, headers) @conn = conn @headers = headers @version = headers['sec-websocket-version'].to_i @reader = ::WebSocket::Frame::Incoming::Server.new(version: @version) end def recv if (msg = @reader.next) return msg.to_s end @conn.recv_loop do |data| @reader << data if (msg = @reader.next) return msg.to_s end end nil end def recv_loop if (msg = @reader.next) yield msg.to_s end @conn.recv_loop do |data| @reader << data while (msg = @reader.next) yield msg.to_s end end end OutgoingFrame = ::WebSocket::Frame::Outgoing::Server def send(data) frame = OutgoingFrame.new( version: @version, data: data, type: :text ) @conn << frame.to_s end alias_method :<<, :send def close @conn.close end end end ================================================ FILE: lib/tipi.rb ================================================ # frozen_string_literal: true require 'polyphony' require_relative './tipi/http1_adapter' require_relative './tipi/http2_adapter' require_relative './tipi/configuration' require_relative './tipi/response_extensions' require_relative './tipi/acme' require 'qeweney/request' class Qeweney::Request include Tipi::ResponseExtensions end module Tipi ALPN_PROTOCOLS = %w[h2 http/1.1].freeze H2_PROTOCOL = 'h2' class << self def serve(host, port, opts = {}, &handler) opts[:alpn_protocols] = ALPN_PROTOCOLS server = Polyphony::Net.tcp_listen(host, port, opts) accept_loop(server, opts, &handler) ensure server&.close end def listen(host, port, opts = {}) opts[:alpn_protocols] = ALPN_PROTOCOLS Polyphony::Net.tcp_listen(host, port, opts).tap do |socket| socket.define_singleton_method(:each) do |&block| ::Tipi.accept_loop(socket, opts, &block) end end end def accept_loop(server, opts, &handler) server.accept_loop do |client| spin { client_loop(client, opts, &handler) } rescue OpenSSL::SSL::SSLError # disregard end end def client_loop(client, opts, &handler) client.no_delay if client.respond_to?(:no_delay) adapter = protocol_adapter(client, opts) adapter.each(&handler) rescue SystemCallError # disregard ensure client.close rescue nil end def protocol_adapter(socket, opts) use_http2 = socket.respond_to?(:alpn_protocol) && socket.alpn_protocol == H2_PROTOCOL klass = use_http2 ? HTTP2Adapter : HTTP1Adapter klass.new(socket, opts) end def route(&block) proc { |req| req.route(&block) } end end end ================================================ FILE: test/coverage.rb ================================================ # frozen_string_literal: true require 'coverage' require 'simplecov' class << SimpleCov::LinesClassifier alias_method :orig_whitespace_line?, :whitespace_line? def whitespace_line?(line) line.strip =~ /^(begin|end|ensure|else|\})|(\s*rescue\s.+)$/ || orig_whitespace_line?(line) end end module Coverage EXCLUDE = %w{coverage eg helper run }.map { |n| File.expand_path("test/#{n}.rb") } LIB_FILES = Dir["#{File.join(FileUtils.pwd, 'lib')}/polyphony/**/*.rb"] class << self def relevant_lines_for_filename(filename) @classifier ||= SimpleCov::LinesClassifier.new @classifier.classify(IO.read(filename).lines) end def start @result = {} trace = TracePoint.new(:line) do |tp| next if tp.path =~ /\(/ absolute = File.expand_path(tp.path) next unless LIB_FILES.include?(absolute)# =~ /^#{LIB_DIR}/ @result[absolute] ||= relevant_lines_for_filename(absolute) @result[absolute][tp.lineno - 1] = 1 end trace.enable end def result @result end end end SimpleCov.start ================================================ FILE: test/eg.rb ================================================ # frozen_string_literal: true module Kernel RE_CONST = /^[A-Z]/.freeze RE_ATTR = /^@(.+)$/.freeze def eg(hash) Module.new.tap do |m| s = m.singleton_class hash.each do |k, v| case k when RE_CONST m.const_set(k, v) when RE_ATTR m.instance_variable_set(k, v) else block = if v.respond_to?(:to_proc) proc { |*args, &block| instance_exec { v.(*args, &block) } } else proc { v } end s.define_method(k, &block) end end end end end ================================================ FILE: test/helper.rb ================================================ # frozen_string_literal: true require 'bundler/setup' require 'fileutils' require_relative './eg' require_relative './coverage' if ENV['COVERAGE'] require 'minitest/autorun' require 'polyphony' ::Exception.__disable_sanitized_backtrace__ = true class Minitest::Test def setup # trace "* setup #{self.name}" Fiber.current.setup_main_fiber Fiber.current.instance_variable_set(:@auto_watcher, nil) Thread.current.backend.finalize Thread.current.backend = Polyphony::Backend.new sleep 0 end def teardown # trace "* teardown #{self.name}" Fiber.current.shutdown_all_children if Fiber.current.children.size > 0 puts "Children left after #{self.name}: #{Fiber.current.children.inspect}" exit! end Fiber.current.instance_variable_set(:@auto_watcher, nil) rescue => e puts e puts e.backtrace.join("\n") exit! end end module Kernel def capture_exception yield rescue Exception => e e end def trace(*args) STDOUT.orig_write(format_trace(args)) end def format_trace(args) if args.first.is_a?(String) if args.size > 1 format("%s: %p\n", args.shift, args) else format("%s\n", args.first) end else format("%p\n", args.size == 1 ? args.first : args) end end end class IO # Creates two mockup sockets for simulating server-client communication def self.server_client_mockup server_in, client_out = IO.pipe client_in, server_out = IO.pipe server_connection = mockup_connection(server_in, server_out, client_out) client_connection = mockup_connection(client_in, client_out, server_out) [server_connection, client_connection] end def self.mockup_connection(input, output, output2) eg( __read_method__: -> { :readpartial }, read: ->(*args) { input.read(*args) }, read_loop: ->(*args, &block) { input.read_loop(*args, &block) }, recv_loop: ->(*args, &block) { input.read_loop(*args, &block) }, readpartial: ->(*args) { input.readpartial(*args) }, recv: ->(*args) { input.readpartial(*args) }, '<<': ->(*args) { output.write(*args) }, write: ->(*args) { output.write(*args) }, close: -> { output.close }, eof?: -> { output2.closed? } ) end end module Minitest::Assertions def assert_in_range exp_range, act msg = message(msg) { "Expected #{mu_pp(act)} to be in range #{mu_pp(exp_range)}" } assert exp_range.include?(act), msg end end ================================================ FILE: test/run.rb ================================================ # frozen_string_literal: true Dir.glob("#{__dir__}/test_*.rb").each do |path| require(path) end ================================================ FILE: test/test_http_server.rb ================================================ # frozen_string_literal: true require_relative 'helper' require 'tipi' class String def crlf_lines gsub "\n", "\r\n" end end class HTTP1ServerTest < Minitest::Test def teardown @server&.interrupt if @server&.alive? sleep 0.01 super end def spin_server(opts = {}, &handler) server_connection, client_connection = IO.server_client_mockup coproc = spin do Tipi.client_loop(server_connection, opts, &handler) end [coproc, client_connection, server_connection] end def test_that_server_uses_content_length_in_http_1_0 @server, connection = spin_server do |req| req.respond('Hello, world!', {}) end # using HTTP 1.0, server should close connection after responding connection << "GET / HTTP/1.0\r\n\r\n" response = connection.readpartial(8192) expected = <<~HTTP.chomp.crlf_lines.chomp HTTP/1.1 200 OK Content-Length: 13 Hello, world! HTTP assert_equal(expected, response) end def test_that_server_uses_chunked_encoding_in_http_1_1 @server, connection = spin_server do |req| req.respond('Hello, world!') end # using HTTP 1.0, server should close connection after responding connection << "GET / HTTP/1.1\r\n\r\n" response = connection.readpartial(8192) expected = <<~HTTP.crlf_lines.chomp HTTP/1.1 200 OK Content-Length: 13 Hello, world! HTTP assert_equal(expected, response) end def test_that_server_maintains_connection_when_using_keep_alives @server, connection = spin_server do |req| req.respond('Hi', {}) end connection << "GET / HTTP/1.0\r\nConnection: keep-alive\r\n\r\n" response = connection.readpartial(8192) sleep 0.01 assert !connection.eof? assert_equal("HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\nHi", response) connection << "GET / HTTP/1.1\r\n\r\n" response = connection.readpartial(8192) sleep 0.01 assert !connection.eof? expected = <<~HTTP.crlf_lines.chomp HTTP/1.1 200 OK Content-Length: 2 Hi HTTP assert_equal(expected, response) connection << "GET / HTTP/1.0\r\n\r\n" response = connection.readpartial(8192) sleep 0.01 assert connection.eof? assert_equal("HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\nHi", response) end def test_pipelining_client @server, connection = spin_server do |req| if req.headers['foo'] == 'bar' req.respond('Hello, foobar!', {}) else req.respond('Hello, world!', {}) end end connection << "GET / HTTP/1.1\r\n\r\nGET / HTTP/1.1\r\nFoo: bar\r\n\r\n" sleep 0.01 response = connection.readpartial(8192) expected = <<~HTTP.crlf_lines.chomp HTTP/1.1 200 OK Content-Length: 13 Hello, world!HTTP/1.1 200 OK Content-Length: 14 Hello, foobar! HTTP assert_equal(expected, response) end def test_body_chunks chunks = [] request = nil @server, connection = spin_server do |req| request = req req.send_headers req.each_chunk do |c| chunks << c req << c.upcase end req.finish end connection << <<~HTTP.crlf_lines POST / HTTP/1.1 Transfer-Encoding: chunked 6 foobar HTTP sleep 0.01 assert request assert_equal %w[foobar], chunks assert !request.complete? connection << "6\r\nbazbud\r\n" sleep 0.01 assert_equal %w[foobar bazbud], chunks assert !request.complete? connection << "0\r\n\r\n" sleep 0.01 assert_equal %w[foobar bazbud], chunks assert request.complete? sleep 0.01 response = connection.readpartial(8192) expected = <<~HTTP.crlf_lines HTTP/1.1 200 Transfer-Encoding: chunked 6 FOOBAR 6 BAZBUD 0 HTTP assert_equal(expected, response) end def test_upgrade done = nil opts = { upgrade: { echo: lambda do |adapter, _headers| conn = adapter.conn conn << <<~HTTP.crlf_lines HTTP/1.1 101 Switching Protocols Upgrade: echo Connection: Upgrade HTTP conn.read_loop { |data| conn << data } done = true end } } @server, connection = spin_server(opts) do |req| req.respond('Hi') end connection << "GET / HTTP/1.1\r\n\r\n" response = connection.readpartial(8192) sleep 0.01 assert !connection.eof? expected = <<~HTTP.crlf_lines.chomp HTTP/1.1 200 OK Content-Length: 2 Hi HTTP assert_equal(expected, response) connection << <<~HTTP.crlf_lines GET / HTTP/1.1 Upgrade: echo Connection: upgrade HTTP snooze response = connection.readpartial(8192) snooze assert !connection.eof? expected = <<~HTTP.crlf_lines HTTP/1.1 101 Switching Protocols Upgrade: echo Connection: Upgrade HTTP assert_equal(expected, response) assert !done connection << 'foo' assert_equal 'foo', connection.readpartial(8192) connection << 'bar' assert_equal 'bar', connection.readpartial(8192) connection.close assert !done sleep 0.01 assert done end def test_big_download chunk_size = 1000 chunk_count = 1000 chunk = '*' * chunk_size @server, connection = spin_server do |req| req.send_headers chunk_count.times do |i| req << chunk snooze end req.finish req.adapter.close end response = +'' count = 0 connection << "GET / HTTP/1.1\r\n\r\n" while (data = connection.read(chunk_size)) response << data count += 1 snooze end chunks = "#{chunk_size.to_s(16)}\n#{'*' * chunk_size}\n" * chunk_count expected = <<~HTTP.crlf_lines HTTP/1.1 200 Transfer-Encoding: chunked #{chunks}0 HTTP assert_equal expected, response assert count >= chunk_count end end ================================================ FILE: test/test_request.rb ================================================ # frozen_string_literal: true require_relative 'helper' require 'tipi' class String def http_lines gsub "\n", "\r\n" end end class RequestHeadersTest < Minitest::Test def teardown @server&.interrupt if @server&.alive? snooze super end def spin_server(opts = {}, &handler) server_connection, client_connection = IO.server_client_mockup coproc = spin do Tipi.client_loop(server_connection, opts, &handler) end [coproc, client_connection, server_connection] end def test_request_headers req = nil @server, connection = spin_server do |r| req = r req.respond('Hello, world!') end connection << "GET /titi HTTP/1.1\r\nHost: blah.com\r\nFoo: bar\r\nhi: 1\r\nHi: 2\r\nhi: 3\r\n\r\n" sleep 0.01 assert_kind_of Qeweney::Request, req assert_equal 'blah.com', req.headers['host'] assert_equal 'bar', req.headers['foo'] assert_equal ['1', '2', '3'], req.headers['hi'] assert_equal 'GET', req.headers[':method'] assert_equal '/titi', req.headers[':path'] end def test_request_host req = nil @server, connection = spin_server do |r| req = r req.respond('Hello, world!') end connection << "GET /titi HTTP/1.1\nHost: blah.com\nFoo: bar\nhi: 1\nHi: 2\nhi: 3\n\n" sleep 0.01 assert_equal 'blah.com', req.host end def test_request_connection req = nil @server, connection = spin_server do |r| req = r req.respond('Hello, world!') end connection << "GET /titi HTTP/1.1\nConnection: keep-alive\nFoo: bar\nhi: 1\nHi: 2\nhi: 3\n\n" sleep 0.01 assert_equal 'keep-alive', req.connection end def test_request_upgrade_protocol req = nil @server, connection = spin_server do |r| req = r req.respond('Hello, world!') end connection << "GET /titi HTTP/1.1\nConnection: upgrade\nUpgrade: foobar\n\n" sleep 0.01 assert_equal 'foobar', req.upgrade_protocol end end ================================================ FILE: tipi.gemspec ================================================ require_relative './lib/tipi/version' Gem::Specification.new do |s| s.name = 'tipi' s.version = Tipi::VERSION s.licenses = ['MIT'] s.summary = 'Tipi - the All-in-one Web Server for Ruby Apps' s.author = 'Sharon Rosner' s.email = 'sharon@noteflakes.com' s.files = `git ls-files`.split s.homepage = 'http://github.com/digital-fabric/tipi' s.metadata = { "source_code_uri" => "https://github.com/digital-fabric/tipi", "documentation_uri" => "https://www.rubydoc.info/gems/tipi", "homepage_uri" => "https://github.com/digital-fabric/tipi", "changelog_uri" => "https://github.com/digital-fabric/tipi/blob/master/CHANGELOG.md" } s.rdoc_options = ["--title", "tipi", "--main", "README.md"] s.extra_rdoc_files = ["README.md"] s.require_paths = ["lib"] s.required_ruby_version = '>= 3.2' s.executables = ['tipi'] s.add_runtime_dependency 'base64', '~>0.3' s.add_runtime_dependency 'mutex_m', '~>0.3' s.add_runtime_dependency 'polyphony', '~>1.4' s.add_runtime_dependency 'ever', '~>0.2' s.add_runtime_dependency 'qeweney', '~>0.24' s.add_runtime_dependency 'extralite', '~>2.13' s.add_runtime_dependency 'h1p', '~>1.1' s.add_runtime_dependency 'http-2', '~>1.1' s.add_runtime_dependency 'rack', '>=2.0.8', '<3.3.0' s.add_runtime_dependency 'websocket', '~>1.2.11' s.add_runtime_dependency 'acme-client', '~>2.0.26' s.add_runtime_dependency 'localhost', '~>1.6.0' # for digital fabric s.add_runtime_dependency 'msgpack', '~>1.8.0' s.add_development_dependency 'logger', '~>1.7' s.add_development_dependency 'ostruct', '~>0.3' s.add_development_dependency 'rake', '~>13.3.0' s.add_development_dependency 'minitest', '~>5.26.0' s.add_development_dependency 'simplecov', '~>0.22.0' s.add_development_dependency 'memory_profiler', '~>1.1.0' s.add_development_dependency 'cuba', '~>4.0.3' end