From f5869bdeed7d72158a86845e7f2f712038daa797 Mon Sep 17 00:00:00 2001 From: Pirate Praveen Date: Mon, 4 Apr 2022 08:54:10 +0100 Subject: [PATCH] Import puma_5.6.4.orig.tar.gz [dgit import orig puma_5.6.4.orig.tar.gz] --- .codeclimate.yml | 2 + .gitattributes | 3 + .github/ISSUE_TEMPLATE/bug_report.md | 48 + .github/ISSUE_TEMPLATE/feature_request.md | 20 + .github/pull_request_template.md | 13 + .github/workflows/mri.yml | 86 + .github/workflows/non_mri.yml | 73 + .gitignore | 29 + .rubocop.yml | 81 + .rubocop_todo.yml | 54 + 5.0-Upgrade.md | 98 + CODE_OF_CONDUCT.md | 77 + CONTRIBUTING.md | 216 ++ Gemfile | 26 + History.md | 2518 +++++++++++++++++ LICENSE | 29 + README.md | 375 +++ Rakefile | 103 + Release.md | 17 + SECURITY.md | 13 + benchmarks/wrk/big_body.sh | 8 + benchmarks/wrk/big_response.sh | 6 + benchmarks/wrk/cpu_spin.sh | 103 + benchmarks/wrk/hello.sh | 8 + .../wrk/jruby_ssl_realistic_response.sh | 8 + benchmarks/wrk/lua/big_body.lua | 3 + benchmarks/wrk/many_long_headers.sh | 6 + benchmarks/wrk/more_conns_than_threads.sh | 6 + benchmarks/wrk/realistic_response.sh | 6 + benchmarks/wrk/ssl_realistic_response.sh | 8 + bin/puma | 10 + bin/puma-wild | 25 + bin/pumactl | 12 + docs/architecture.md | 74 + docs/compile_options.md | 21 + docs/deployment.md | 102 + docs/fork_worker.md | 33 + .../puma-connection-flow-no-reactor.png | Bin 0 -> 16029 bytes docs/images/puma-connection-flow.png | Bin 0 -> 16165 bytes docs/images/puma-general-arch.png | Bin 0 -> 13088 bytes docs/jungle/README.md | 9 + docs/jungle/rc.d/README.md | 74 + docs/jungle/rc.d/puma | 61 + docs/jungle/rc.d/puma.conf | 10 + docs/kubernetes.md | 66 + docs/nginx.md | 80 + docs/plugins.md | 38 + docs/rails_dev_mode.md | 28 + docs/restart.md | 64 + docs/signals.md | 98 + docs/stats.md | 142 + docs/systemd.md | 247 ++ examples/CA/cacert.pem | 23 + examples/CA/newcerts/cert_1.pem | 19 + examples/CA/newcerts/cert_2.pem | 19 + examples/CA/private/cakeypair.pem | 30 + examples/CA/serial | 1 + examples/plugins/redis_stop_puma.rb | 46 + examples/puma/cert_puma.pem | 21 + examples/puma/client-certs/ca.crt | 19 + examples/puma/client-certs/ca.key | 27 + examples/puma/client-certs/client.crt | 19 + examples/puma/client-certs/client.key | 27 + examples/puma/client-certs/client_expired.crt | 19 + examples/puma/client-certs/client_expired.key | 27 + examples/puma/client-certs/client_unknown.crt | 19 + examples/puma/client-certs/client_unknown.key | 27 + .../puma/client-certs/generate_client_test.rb | 133 + examples/puma/client-certs/keystore.jks | Bin 0 -> 3740 bytes .../client-certs/run_server_with_certs.rb | 26 + examples/puma/client-certs/server.crt | 19 + examples/puma/client-certs/server.key | 27 + examples/puma/client-certs/server.p12 | Bin 0 -> 3266 bytes examples/puma/client-certs/unknown_ca.crt | 19 + examples/puma/client-certs/unknown_ca.key | 27 + examples/puma/csr_puma.pem | 11 + examples/puma/generate_server_test.rb | 56 + examples/puma/keystore.jks | Bin 0 -> 2253 bytes examples/puma/puma_keypair.pem | 27 + examples/puma/server.p12 | Bin 0 -> 2550 bytes examples/qc_config.rb | 13 + ext/puma_http11/PumaHttp11Service.java | 17 + ext/puma_http11/ext_help.h | 15 + ext/puma_http11/extconf.rb | 65 + ext/puma_http11/http11_parser.c | 1057 +++++++ ext/puma_http11/http11_parser.h | 65 + ext/puma_http11/http11_parser.java.rl | 145 + ext/puma_http11/http11_parser.rl | 149 + ext/puma_http11/http11_parser_common.rl | 54 + ext/puma_http11/mini_ssl.c | 706 +++++ ext/puma_http11/no_ssl/PumaHttp11Service.java | 15 + ext/puma_http11/org/jruby/puma/Http11.java | 226 ++ .../org/jruby/puma/Http11Parser.java | 455 +++ ext/puma_http11/org/jruby/puma/MiniSSL.java | 407 +++ ext/puma_http11/puma_http11.c | 484 ++++ lib/puma.rb | 77 + lib/puma/app/status.rb | 93 + lib/puma/binder.rb | 504 ++++ lib/puma/cli.rb | 245 ++ lib/puma/client.rb | 585 ++++ lib/puma/cluster.rb | 546 ++++ lib/puma/cluster/worker.rb | 173 ++ lib/puma/cluster/worker_handle.rb | 94 + lib/puma/commonlogger.rb | 108 + lib/puma/configuration.rb | 371 +++ lib/puma/const.rb | 252 ++ lib/puma/control_cli.rb | 306 ++ lib/puma/detect.rb | 42 + lib/puma/dsl.rb | 1008 +++++++ lib/puma/error_logger.rb | 104 + lib/puma/events.rb | 177 ++ lib/puma/io_buffer.rb | 11 + lib/puma/jruby_restart.rb | 26 + lib/puma/json_serialization.rb | 96 + lib/puma/launcher.rb | 546 ++++ lib/puma/minissl.rb | 360 +++ lib/puma/minissl/context_builder.rb | 81 + lib/puma/null_io.rb | 56 + lib/puma/plugin.rb | 111 + lib/puma/plugin/tmp_restart.rb | 36 + lib/puma/queue_close.rb | 26 + lib/puma/rack/builder.rb | 297 ++ lib/puma/rack/urlmap.rb | 93 + lib/puma/rack_default.rb | 9 + lib/puma/reactor.rb | 116 + lib/puma/request.rb | 472 +++ lib/puma/runner.rb | 177 ++ lib/puma/server.rb | 627 ++++ lib/puma/single.rb | 67 + lib/puma/state_file.rb | 70 + lib/puma/systemd.rb | 46 + lib/puma/thread_pool.rb | 396 +++ lib/puma/util.rb | 143 + lib/rack/handler/puma.rb | 114 + puma.gemspec | 31 + test/bundle_app_config_test/.bundle/config | 2 + test/bundle_app_config_test/Gemfile | 1 + test/bundle_app_config_test/config.ru | 1 + test/bundle_preservation_test/.gitignore | 1 + .../Gemfile.bundle_env_preservation_test | 1 + test/bundle_preservation_test/config.ru | 1 + .../bundle_preservation_test/version1/Gemfile | 1 + .../version1/config.ru | 1 + .../version1/config/puma.rb | 1 + .../bundle_preservation_test/version2/Gemfile | 1 + .../version2/config.ru | 1 + .../version2/config/puma.rb | 1 + test/config/ab_rs.rb | 22 + test/config/app.rb | 9 + test/config/control_no_token.rb | 5 + test/config/cpu_spin.rb | 17 + test/config/custom_log_formatter.rb | 3 + test/config/plugin1.rb | 1 + .../prune_bundler_print_json_defined.rb | 4 + .../config/prune_bundler_print_nio_defined.rb | 4 + test/config/prune_bundler_with_deps.rb | 7 + .../prune_bundler_with_multiple_workers.rb | 14 + test/config/settings.rb | 2 + test/config/ssl_config.rb | 13 + test/config/ssl_self_signed_config.rb | 7 + test/config/state_file_testing_config.rb | 13 + test/config/suppress_exception.rb | 1 + test/config/t1_conf.rb | 3 + test/config/t2_conf.rb | 3 + test/config/t3_conf.rb | 5 + test/config/with_float_convert.rb | 1 + test/config/with_integer_convert.rb | 9 + test/config/with_rackup_from_dsl.rb | 1 + test/config/with_symbol_convert.rb | 1 + test/config/worker_shutdown_timeout_2.rb | 1 + test/helper.rb | 251 ++ test/helpers/apps.rb | 12 + test/helpers/config_file.rb | 16 + test/helpers/integration.rb | 393 +++ test/helpers/ssl.rb | 27 + test/helpers/tmp_path.rb | 24 + test/minitest/verbose.rb | 5 + test/minitest/verbose_progress_plugin.rb | 34 + test/rackup/big_response.ru | 1 + test/rackup/close_listeners.ru | 6 + test/rackup/hello-bind.ru | 2 + test/rackup/hello-env.ru | 2 + test/rackup/hello.ru | 1 + test/rackup/hello_with_delay.ru | 4 + test/rackup/lobster.ru | 4 + test/rackup/many_long_headers.ru | 9 + test/rackup/realistic_response.ru | 11 + test/rackup/sleep.ru | 9 + test/rackup/sleep_pid.ru | 8 + test/rackup/sleep_step.ru | 10 + test/rackup/write_to_stdout.ru | 6 + test/rackup/write_to_stdout_on_boot.ru | 2 + test/test_app_status.rb | 91 + test/test_binder.rb | 519 ++++ test/test_busy_worker.rb | 102 + test/test_cli.rb | 489 ++++ test/test_config.rb | 557 ++++ test/test_error_logger.rb | 95 + test/test_events.rb | 238 ++ test/test_http10.rb | 27 + test/test_http11.rb | 241 ++ test/test_integration_cluster.rb | 653 +++++ test/test_integration_pumactl.rb | 135 + test/test_integration_single.rb | 215 ++ test/test_integration_ssl.rb | 149 + test/test_integration_systemd.rb | 83 + test/test_iobuffer.rb | 37 + test/test_json_serialization.rb | 107 + test/test_launcher.rb | 206 ++ test/test_minissl.rb | 43 + test/test_null_io.rb | 67 + test/test_out_of_band_server.rb | 160 ++ test/test_persistent.rb | 247 ++ test/test_plugin.rb | 39 + test/test_preserve_bundler_env.rb | 110 + test/test_puma_localhost_authority.rb | 95 + test/test_puma_server.rb | 1368 +++++++++ test/test_puma_server_ssl.rb | 375 +++ test/test_pumactl.rb | 264 ++ test/test_rack_handler.rb | 270 ++ test/test_rack_server.rb | 126 + test/test_redirect_io.rb | 109 + test/test_request_invalid.rb | 220 ++ test/test_response_header.rb | 144 + test/test_thread_pool.rb | 311 ++ test/test_unix_socket.rb | 46 + test/test_web_server.rb | 115 + test/test_worker_gem_independence.rb | 145 + .../new_json/Gemfile | 4 + .../new_json/config.ru | 2 + .../new_json/config/puma.rb | 1 + .../Gemfile | 4 + .../config.ru | 2 + .../config/puma.rb | 2 + .../new_nio4r/Gemfile | 4 + .../new_nio4r/config.ru | 1 + .../new_nio4r/config/puma.rb | 1 + .../old_json/Gemfile | 4 + .../old_json/config.ru | 2 + .../old_json/config/puma.rb | 1 + .../Gemfile | 4 + .../config.ru | 2 + .../config/puma.rb | 2 + .../old_nio4r/Gemfile | 4 + .../old_nio4r/config.ru | 1 + .../old_nio4r/config/puma.rb | 1 + tools/Dockerfile | 16 + tools/trickletest.rb | 44 + win_gem_test/Rakefile_wintest | 11 + win_gem_test/package_gem.rb | 21 + win_gem_test/puma.ps1 | 67 + 251 files changed, 27885 insertions(+) create mode 100644 .codeclimate.yml create mode 100644 .gitattributes create mode 100644 .github/ISSUE_TEMPLATE/bug_report.md create mode 100644 .github/ISSUE_TEMPLATE/feature_request.md create mode 100644 .github/pull_request_template.md create mode 100644 .github/workflows/mri.yml create mode 100644 .github/workflows/non_mri.yml create mode 100644 .gitignore create mode 100644 .rubocop.yml create mode 100644 .rubocop_todo.yml create mode 100644 5.0-Upgrade.md create mode 100644 CODE_OF_CONDUCT.md create mode 100644 CONTRIBUTING.md create mode 100644 Gemfile create mode 100644 History.md create mode 100644 LICENSE create mode 100644 README.md create mode 100644 Rakefile create mode 100644 Release.md create mode 100644 SECURITY.md create mode 100755 benchmarks/wrk/big_body.sh create mode 100755 benchmarks/wrk/big_response.sh create mode 100755 benchmarks/wrk/cpu_spin.sh create mode 100755 benchmarks/wrk/hello.sh create mode 100755 benchmarks/wrk/jruby_ssl_realistic_response.sh create mode 100644 benchmarks/wrk/lua/big_body.lua create mode 100755 benchmarks/wrk/many_long_headers.sh create mode 100755 benchmarks/wrk/more_conns_than_threads.sh create mode 100755 benchmarks/wrk/realistic_response.sh create mode 100755 benchmarks/wrk/ssl_realistic_response.sh create mode 100755 bin/puma create mode 100644 bin/puma-wild create mode 100755 bin/pumactl create mode 100644 docs/architecture.md create mode 100644 docs/compile_options.md create mode 100644 docs/deployment.md create mode 100644 docs/fork_worker.md create mode 100644 docs/images/puma-connection-flow-no-reactor.png create mode 100644 docs/images/puma-connection-flow.png create mode 100644 docs/images/puma-general-arch.png create mode 100644 docs/jungle/README.md create mode 100644 docs/jungle/rc.d/README.md create mode 100755 docs/jungle/rc.d/puma create mode 100644 docs/jungle/rc.d/puma.conf create mode 100644 docs/kubernetes.md create mode 100644 docs/nginx.md create mode 100644 docs/plugins.md create mode 100644 docs/rails_dev_mode.md create mode 100644 docs/restart.md create mode 100644 docs/signals.md create mode 100644 docs/stats.md create mode 100644 docs/systemd.md create mode 100644 examples/CA/cacert.pem create mode 100644 examples/CA/newcerts/cert_1.pem create mode 100644 examples/CA/newcerts/cert_2.pem create mode 100644 examples/CA/private/cakeypair.pem create mode 100644 examples/CA/serial create mode 100644 examples/plugins/redis_stop_puma.rb create mode 100644 examples/puma/cert_puma.pem create mode 100644 examples/puma/client-certs/ca.crt create mode 100644 examples/puma/client-certs/ca.key create mode 100644 examples/puma/client-certs/client.crt create mode 100644 examples/puma/client-certs/client.key create mode 100644 examples/puma/client-certs/client_expired.crt create mode 100644 examples/puma/client-certs/client_expired.key create mode 100644 examples/puma/client-certs/client_unknown.crt create mode 100644 examples/puma/client-certs/client_unknown.key create mode 100644 examples/puma/client-certs/generate_client_test.rb create mode 100644 examples/puma/client-certs/keystore.jks create mode 100644 examples/puma/client-certs/run_server_with_certs.rb create mode 100644 examples/puma/client-certs/server.crt create mode 100644 examples/puma/client-certs/server.key create mode 100644 examples/puma/client-certs/server.p12 create mode 100644 examples/puma/client-certs/unknown_ca.crt create mode 100644 examples/puma/client-certs/unknown_ca.key create mode 100644 examples/puma/csr_puma.pem create mode 100644 examples/puma/generate_server_test.rb create mode 100644 examples/puma/keystore.jks create mode 100644 examples/puma/puma_keypair.pem create mode 100644 examples/puma/server.p12 create mode 100644 examples/qc_config.rb create mode 100644 ext/puma_http11/PumaHttp11Service.java create mode 100644 ext/puma_http11/ext_help.h create mode 100644 ext/puma_http11/extconf.rb create mode 100644 ext/puma_http11/http11_parser.c create mode 100644 ext/puma_http11/http11_parser.h create mode 100644 ext/puma_http11/http11_parser.java.rl create mode 100644 ext/puma_http11/http11_parser.rl create mode 100644 ext/puma_http11/http11_parser_common.rl create mode 100644 ext/puma_http11/mini_ssl.c create mode 100644 ext/puma_http11/no_ssl/PumaHttp11Service.java create mode 100644 ext/puma_http11/org/jruby/puma/Http11.java create mode 100644 ext/puma_http11/org/jruby/puma/Http11Parser.java create mode 100644 ext/puma_http11/org/jruby/puma/MiniSSL.java create mode 100644 ext/puma_http11/puma_http11.c create mode 100644 lib/puma.rb create mode 100644 lib/puma/app/status.rb create mode 100644 lib/puma/binder.rb create mode 100644 lib/puma/cli.rb create mode 100644 lib/puma/client.rb create mode 100644 lib/puma/cluster.rb create mode 100644 lib/puma/cluster/worker.rb create mode 100644 lib/puma/cluster/worker_handle.rb create mode 100644 lib/puma/commonlogger.rb create mode 100644 lib/puma/configuration.rb create mode 100644 lib/puma/const.rb create mode 100644 lib/puma/control_cli.rb create mode 100644 lib/puma/detect.rb create mode 100644 lib/puma/dsl.rb create mode 100644 lib/puma/error_logger.rb create mode 100644 lib/puma/events.rb create mode 100644 lib/puma/io_buffer.rb create mode 100644 lib/puma/jruby_restart.rb create mode 100644 lib/puma/json_serialization.rb create mode 100644 lib/puma/launcher.rb create mode 100644 lib/puma/minissl.rb create mode 100644 lib/puma/minissl/context_builder.rb create mode 100644 lib/puma/null_io.rb create mode 100644 lib/puma/plugin.rb create mode 100644 lib/puma/plugin/tmp_restart.rb create mode 100644 lib/puma/queue_close.rb create mode 100644 lib/puma/rack/builder.rb create mode 100644 lib/puma/rack/urlmap.rb create mode 100644 lib/puma/rack_default.rb create mode 100644 lib/puma/reactor.rb create mode 100644 lib/puma/request.rb create mode 100644 lib/puma/runner.rb create mode 100644 lib/puma/server.rb create mode 100644 lib/puma/single.rb create mode 100644 lib/puma/state_file.rb create mode 100644 lib/puma/systemd.rb create mode 100644 lib/puma/thread_pool.rb create mode 100644 lib/puma/util.rb create mode 100644 lib/rack/handler/puma.rb create mode 100644 puma.gemspec create mode 100644 test/bundle_app_config_test/.bundle/config create mode 100644 test/bundle_app_config_test/Gemfile create mode 100644 test/bundle_app_config_test/config.ru create mode 100644 test/bundle_preservation_test/.gitignore create mode 100644 test/bundle_preservation_test/Gemfile.bundle_env_preservation_test create mode 100644 test/bundle_preservation_test/config.ru create mode 100644 test/bundle_preservation_test/version1/Gemfile create mode 100644 test/bundle_preservation_test/version1/config.ru create mode 100644 test/bundle_preservation_test/version1/config/puma.rb create mode 100644 test/bundle_preservation_test/version2/Gemfile create mode 100644 test/bundle_preservation_test/version2/config.ru create mode 100644 test/bundle_preservation_test/version2/config/puma.rb create mode 100644 test/config/ab_rs.rb create mode 100644 test/config/app.rb create mode 100644 test/config/control_no_token.rb create mode 100644 test/config/cpu_spin.rb create mode 100644 test/config/custom_log_formatter.rb create mode 100644 test/config/plugin1.rb create mode 100644 test/config/prune_bundler_print_json_defined.rb create mode 100644 test/config/prune_bundler_print_nio_defined.rb create mode 100644 test/config/prune_bundler_with_deps.rb create mode 100644 test/config/prune_bundler_with_multiple_workers.rb create mode 100644 test/config/settings.rb create mode 100644 test/config/ssl_config.rb create mode 100644 test/config/ssl_self_signed_config.rb create mode 100644 test/config/state_file_testing_config.rb create mode 100644 test/config/suppress_exception.rb create mode 100644 test/config/t1_conf.rb create mode 100644 test/config/t2_conf.rb create mode 100644 test/config/t3_conf.rb create mode 100644 test/config/with_float_convert.rb create mode 100644 test/config/with_integer_convert.rb create mode 100644 test/config/with_rackup_from_dsl.rb create mode 100644 test/config/with_symbol_convert.rb create mode 100644 test/config/worker_shutdown_timeout_2.rb create mode 100644 test/helper.rb create mode 100644 test/helpers/apps.rb create mode 100644 test/helpers/config_file.rb create mode 100644 test/helpers/integration.rb create mode 100644 test/helpers/ssl.rb create mode 100644 test/helpers/tmp_path.rb create mode 100644 test/minitest/verbose.rb create mode 100644 test/minitest/verbose_progress_plugin.rb create mode 100644 test/rackup/big_response.ru create mode 100644 test/rackup/close_listeners.ru create mode 100644 test/rackup/hello-bind.ru create mode 100644 test/rackup/hello-env.ru create mode 100644 test/rackup/hello.ru create mode 100644 test/rackup/hello_with_delay.ru create mode 100644 test/rackup/lobster.ru create mode 100644 test/rackup/many_long_headers.ru create mode 100644 test/rackup/realistic_response.ru create mode 100644 test/rackup/sleep.ru create mode 100644 test/rackup/sleep_pid.ru create mode 100644 test/rackup/sleep_step.ru create mode 100644 test/rackup/write_to_stdout.ru create mode 100644 test/rackup/write_to_stdout_on_boot.ru create mode 100644 test/test_app_status.rb create mode 100644 test/test_binder.rb create mode 100644 test/test_busy_worker.rb create mode 100644 test/test_cli.rb create mode 100644 test/test_config.rb create mode 100644 test/test_error_logger.rb create mode 100644 test/test_events.rb create mode 100644 test/test_http10.rb create mode 100644 test/test_http11.rb create mode 100644 test/test_integration_cluster.rb create mode 100644 test/test_integration_pumactl.rb create mode 100644 test/test_integration_single.rb create mode 100644 test/test_integration_ssl.rb create mode 100644 test/test_integration_systemd.rb create mode 100644 test/test_iobuffer.rb create mode 100644 test/test_json_serialization.rb create mode 100644 test/test_launcher.rb create mode 100644 test/test_minissl.rb create mode 100644 test/test_null_io.rb create mode 100644 test/test_out_of_band_server.rb create mode 100644 test/test_persistent.rb create mode 100644 test/test_plugin.rb create mode 100644 test/test_preserve_bundler_env.rb create mode 100644 test/test_puma_localhost_authority.rb create mode 100644 test/test_puma_server.rb create mode 100644 test/test_puma_server_ssl.rb create mode 100644 test/test_pumactl.rb create mode 100644 test/test_rack_handler.rb create mode 100644 test/test_rack_server.rb create mode 100644 test/test_redirect_io.rb create mode 100644 test/test_request_invalid.rb create mode 100644 test/test_response_header.rb create mode 100644 test/test_thread_pool.rb create mode 100644 test/test_unix_socket.rb create mode 100644 test/test_web_server.rb create mode 100644 test/test_worker_gem_independence.rb create mode 100644 test/worker_gem_independence_test/new_json/Gemfile create mode 100644 test/worker_gem_independence_test/new_json/config.ru create mode 100644 test/worker_gem_independence_test/new_json/config/puma.rb create mode 100644 test/worker_gem_independence_test/new_json_with_puma_stats_after_fork/Gemfile create mode 100644 test/worker_gem_independence_test/new_json_with_puma_stats_after_fork/config.ru create mode 100644 test/worker_gem_independence_test/new_json_with_puma_stats_after_fork/config/puma.rb create mode 100644 test/worker_gem_independence_test/new_nio4r/Gemfile create mode 100644 test/worker_gem_independence_test/new_nio4r/config.ru create mode 100644 test/worker_gem_independence_test/new_nio4r/config/puma.rb create mode 100644 test/worker_gem_independence_test/old_json/Gemfile create mode 100644 test/worker_gem_independence_test/old_json/config.ru create mode 100644 test/worker_gem_independence_test/old_json/config/puma.rb create mode 100644 test/worker_gem_independence_test/old_json_with_puma_stats_after_fork/Gemfile create mode 100644 test/worker_gem_independence_test/old_json_with_puma_stats_after_fork/config.ru create mode 100644 test/worker_gem_independence_test/old_json_with_puma_stats_after_fork/config/puma.rb create mode 100644 test/worker_gem_independence_test/old_nio4r/Gemfile create mode 100644 test/worker_gem_independence_test/old_nio4r/config.ru create mode 100644 test/worker_gem_independence_test/old_nio4r/config/puma.rb create mode 100644 tools/Dockerfile create mode 100644 tools/trickletest.rb create mode 100644 win_gem_test/Rakefile_wintest create mode 100644 win_gem_test/package_gem.rb create mode 100644 win_gem_test/puma.ps1 diff --git a/.codeclimate.yml b/.codeclimate.yml new file mode 100644 index 0000000..8e0dd4a --- /dev/null +++ b/.codeclimate.yml @@ -0,0 +1,2 @@ +exclude_patterns: +- "ext/" diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..77f597a --- /dev/null +++ b/.gitattributes @@ -0,0 +1,3 @@ +# Auto detect text files and perform LF normalization +* text eol=lf +*.png binary diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 0000000..040bc80 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -0,0 +1,48 @@ +--- +name: Bug report +about: Create a report to help us improve +title: '' +labels: '' +assignees: '' + +--- + +**Describe the bug** +A clear and concise description of what the bug is. + +**Puma config:** + +Please copy-paste your Puma config AND your command line options here. + +**To Reproduce** +Please add reproduction steps here. + +Your issue will be solved very quickly if you can reproduce it with a "hello world" rack application. To do this, copy this into a file called `hello.ru`: + +``` +run lambda { |env| [200, {"Content-Type" => "text/plain"}, ["Hello World"]] } +``` + +Run it with: + +``` +bundle exec puma -C hello.ru +``` + +If you cannot reproduce with a hello world application or other simple application, we will have a lot more difficulty helping you fix your issue, because it may be application-specific and not a bug in Puma at all. + +There is also a Dockerfile available for reproducing Linux-specific issues. To use: + +``` +$ docker build -f tools/docker/Dockerfile -t puma . +$ docker run -p 9292:9292 -it puma +``` + +This will help you to create a container that reproduces your issue. + +**Expected behavior** +A clear and concise description of what you expected to happen. + +**Desktop (please complete the following information):** + - OS: [e.g. Mac, Linux] + - Puma Version [e.g. 4.1.1] diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md new file mode 100644 index 0000000..bbcbbe7 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -0,0 +1,20 @@ +--- +name: Feature request +about: Suggest an idea for this project +title: '' +labels: '' +assignees: '' + +--- + +**Is your feature request related to a problem? Please describe.** +A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] + +**Describe the solution you'd like** +A clear and concise description of what you want to happen. + +**Describe alternatives you've considered** +A clear and concise description of any alternative solutions or features you've considered. + +**Additional context** +Add any other context or screenshots about the feature request here. diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 0000000..cc5dd72 --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,13 @@ +### Description +Please describe your pull request. Thank you for contributing! You're the best. + +### Your checklist for this pull request + + +- [ ] I have reviewed the [guidelines for contributing](../blob/master/CONTRIBUTING.md) to this repository. +- [ ] I have added (or updated) appropriate tests if this PR fixes a bug or adds a feature. +- [ ] My pull request is 100 lines added/removed or less so that it can be easily reviewed. +- [ ] If this PR doesn't need tests (docs change), I added `[ci skip]` to the title of the PR. +- [ ] If this closes any issues, I have added "Closes `#issue`" to the PR description or my commit messages. +- [ ] I have updated the documentation accordingly. +- [ ] All new and existing tests passed, including Rubocop. diff --git a/.github/workflows/mri.yml b/.github/workflows/mri.yml new file mode 100644 index 0000000..922dbe8 --- /dev/null +++ b/.github/workflows/mri.yml @@ -0,0 +1,86 @@ +name: MRI + +on: [push, pull_request, workflow_dispatch] + +jobs: + build: + name: >- + ${{ matrix.os }} ${{ matrix.ruby }}${{ matrix.no-ssl }}${{ matrix.yjit }} + env: + CI: true + TESTOPTS: -v + + runs-on: ${{ matrix.os }} + if: | + !( contains(github.event.pull_request.title, '[ci skip]') + || contains(github.event.pull_request.title, '[skip ci]')) + strategy: + fail-fast: false + matrix: + os: [ ubuntu-20.04, ubuntu-18.04, macos-10.15, macos-11, windows-2022 ] + ruby: [ 2.2, 2.3, 2.4, 2.5, 2.6, 2.7, '3.0', 3.1, head ] + no-ssl: [''] + yjit: [''] + include: + - { os: windows-2022 , ruby: ucrt } + - { os: windows-2022 , ruby: 2.7 , no-ssl: ' no SSL' } + - { os: ubuntu-20.04 , ruby: head , yjit: ' yjit' } + - { os: ubuntu-20.04 , ruby: 2.7 , no-ssl: ' no SSL' } + + exclude: + - { os: ubuntu-20.04 , ruby: 2.2 } + - { os: ubuntu-20.04 , ruby: 2.3 } + - { os: windows-2022 , ruby: head } + - { os: macos-10.15 , ruby: 2.6 } + - { os: macos-10.15 , ruby: 2.7 } + - { os: macos-10.15 , ruby: '3.0'} + - { os: macos-10.15 , ruby: 3.1 } + - { os: macos-11 , ruby: 2.2 } + - { os: macos-11 , ruby: 2.3 } + - { os: macos-11 , ruby: 2.4 } + + steps: + - name: repo checkout + uses: actions/checkout@v2 + + - name: load ruby + uses: MSP-Greg/setup-ruby-pkgs@v1 + with: + ruby-version: ${{ matrix.ruby }} + apt-get: ragel + brew: ragel + mingw: openssl ragel + bundler-cache: true + setup-ruby-ref: MSP-Greg/ruby-setup-ruby/gem-update + timeout-minutes: 10 + + # Windows error thrown, doesn't affect CI + - name: update rubygems for Ruby 2.2 + if: matrix.ruby < '2.3' + run: gem update --system 2.7.11 --no-document + continue-on-error: true + timeout-minutes: 5 + + - name: Compile Puma without SSL support + if: matrix.no-ssl == ' no SSL' + shell: bash + run: echo 'DISABLE_SSL=true' >> $GITHUB_ENV + + - name: set WERRORFLAG + shell: bash + run: echo 'MAKE_WARNINGS_INTO_ERRORS=true' >> $GITHUB_ENV + + - name: compile + run: bundle exec rake compile + + - name: rubocop + run: bundle exec rake rubocop + + - name: Use yjit + if: matrix.yjit == ' yjit' + shell: bash + run: echo 'RUBYOPT=--yjit' >> $GITHUB_ENV + + - name: test + timeout-minutes: 10 + run: bundle exec rake test:all diff --git a/.github/workflows/non_mri.yml b/.github/workflows/non_mri.yml new file mode 100644 index 0000000..693cac1 --- /dev/null +++ b/.github/workflows/non_mri.yml @@ -0,0 +1,73 @@ +name: non_MRI + +on: [push, pull_request, workflow_dispatch] + +jobs: + build: + name: >- + ${{ matrix.os }} ${{ matrix.ruby }}${{ matrix.no-ssl }} + env: + CI: true + TESTOPTS: -v + + runs-on: ${{ matrix.os }} + if: | + !( contains(github.event.pull_request.title, '[ci skip]') + || contains(github.event.pull_request.title, '[skip ci]')) + strategy: + fail-fast: false + matrix: + include: + - { os: ubuntu-20.04 , ruby: jruby } + - { os: ubuntu-20.04 , ruby: jruby, no-ssl: ' no SSL' } + - { os: ubuntu-20.04 , ruby: jruby-head, allow-failure: true } + - { os: ubuntu-20.04 , ruby: truffleruby } + - { os: ubuntu-20.04 , ruby: truffleruby-head, allow-failure: true } + - { os: macos-10.15 , ruby: jruby } + - { os: macos-10.15 , ruby: truffleruby } + - { os: macos-11 , ruby: jruby } + - { os: macos-11 , ruby: truffleruby } + + steps: + - name: repo checkout + uses: actions/checkout@v2 + + - name: set JAVA_HOME + if: startsWith(matrix.os, 'macos') + shell: bash + run: | + echo JAVA_HOME=$JAVA_HOME_11_X64 >> $GITHUB_ENV + + - name: load ruby, ragel + uses: MSP-Greg/setup-ruby-pkgs@v1 + with: + ruby-version: ${{ matrix.ruby }} + apt-get: ragel + brew: ragel + mingw: _upgrade_ openssl ragel + bundler-cache: true + timeout-minutes: 10 + + - name: Compile Puma without SSL support + if: matrix.no-ssl == ' no SSL' + shell: bash + run: echo 'DISABLE_SSL=true' >> $GITHUB_ENV + + - name: set WERRORFLAG + shell: bash + run: echo 'MAKE_WARNINGS_INTO_ERRORS=true' >> $GITHUB_ENV + + - name: compile + run: bundle exec rake compile + + - name: test + id: test + timeout-minutes: 12 + continue-on-error: ${{ matrix.allow-failure || false }} + if: success() # only run if previous steps have succeeded + run: bundle exec rake test:all + + - name: >- + Test outcome: ${{ steps.test.outcome }} + # every step must define a `uses` or `run` key + run: echo NOOP diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e48aa28 --- /dev/null +++ b/.gitignore @@ -0,0 +1,29 @@ +scratch/ +*.bundle +*.log +*.o +*.so +*.jar +*.rbc +doc +log +pkg +tmp +t/ +.rbx/ +Gemfile.lock +.idea/ +vendor/ +/test/test_puma.state +/test/test_server.sock +/test/test_control.sock +.DS_Store + +# windows local build artifacts +/win_gem_test/shared/ +/win_gem_test/packages/ +/win_gem_test/test_logs/ +/Rakefile_wintest +*.gem +/lib/puma/puma_http11.rb +/lib/puma/puma_http11.su diff --git a/.rubocop.yml b/.rubocop.yml new file mode 100644 index 0000000..2ed6655 --- /dev/null +++ b/.rubocop.yml @@ -0,0 +1,81 @@ +AllCops: + DisabledByDefault: true + TargetRubyVersion: 2.2 + DisplayCopNames: true + StyleGuideCopsOnly: false + Exclude: + - 'tmp/**/*' + - '**/vendor/bundle/**/*' + - 'examples/**/*' + - 'pkg/**/*' + - 'Rakefile' + +Layout/SpaceAfterColon: + Enabled: true + +Layout/SpaceAroundKeyword: + Enabled: true + +Layout/SpaceBeforeBlockBraces: + EnforcedStyleForEmptyBraces: no_space + Enabled: true + +Layout/SpaceBeforeFirstArg: + Enabled: true + +Layout/SpaceInsideParens: + Enabled: true + +Layout/Tab: + Enabled: true + +Layout/TrailingBlankLines: + Enabled: true + +Layout/TrailingWhitespace: + Enabled: true + +Lint/Debugger: + Enabled: true + +Naming/MethodName: + Enabled: true + EnforcedStyle: snake_case + Exclude: + - 'test/**/**' + +Naming/VariableName: + Enabled: true + +Style/MethodDefParentheses: + Enabled: true + +Style/TrailingCommaInArguments: + Enabled: true + +Performance: + Enabled: true + +Metrics/ParameterLists: + Max: 7 + +Performance/RedundantMatch: + Enabled: true + +Performance/RedundantBlockCall: + Enabled: true + +Performance/StringReplacement: + Enabled: true + +Layout/AccessModifierIndentation: + EnforcedStyle: indent + +Style/WhileUntilModifier: + Enabled: true + +Style/TernaryParentheses: + Enabled: true + +Style/RedundantReturn: + Enabled: true diff --git a/.rubocop_todo.yml b/.rubocop_todo.yml new file mode 100644 index 0000000..80ba5ae --- /dev/null +++ b/.rubocop_todo.yml @@ -0,0 +1,54 @@ +inherit_from: "./.rubocop.yml" + +# 29 offenses +Layout/SpaceAroundOperators: + Enabled: true + +# 21 offenses +Layout/SpaceInsideBlockBraces: + Enabled: true + +# 16 offenses +Layout/SpaceAroundEqualsInParameterDefault: + Enabled: true + EnforcedStyle: no_space + +# 15 offenses +Layout/SpaceInsideHashLiteralBraces: + Enabled: true + EnforcedStyle: no_space + +# 8 offenses +Layout/EmptyLines: + Enabled: true + +# 4 offenses +Layout/EmptyLinesAroundClassBody: + Enabled: true + Exclude: + - 'test/**/*' + +# 6 offenses +Layout/EmptyLinesAroundMethodBody: + Enabled: true + +# 5 offenses +Layout/EmptyLinesAroundModuleBody: + Enabled: true + +# 5 offenses +Layout/IndentationWidth: + Enabled: true + +# >200 offenses for 80 +# 58 offenses for 100 +# 18 offenses for 120 +Metrics/LineLength: + Max: 120 + AllowHeredoc: true + AllowURI: true + URISchemes: + - http + - https + IgnoreCopDirectives: false + IgnoredPatterns: [] diff --git a/5.0-Upgrade.md b/5.0-Upgrade.md new file mode 100644 index 0000000..06041ad --- /dev/null +++ b/5.0-Upgrade.md @@ -0,0 +1,98 @@ +# Welcome to Puma 5: Spoony Bard. + +![Spoony Bard](https://i1.kym-cdn.com/entries/icons/original/000/006/385/Spoony_Bard.jpg "Spoony Bard") + +>Note: Puma 5 now automatically uses `WEB_CONCURRENCY` env var if set see [this post for an explanation](https://github.com/puma/puma/issues/2393#issuecomment-702352208). If your memory use goes up after upgrading to Puma 5 it indicates you're now running with multiple workers (processes). You can decrease memory use by tuning this number to be lower. + +Puma 5 brings new experimental performance features, a few quality-of-life features and loads of bugfixes. Here's what you should do: + +1. Review the Upgrade section below to see if any of 5.0's breaking changes will affect you. +2. Upgrade to version 5.0 in your Gemfile and deploy. +3. Try the new performance experiments outlined below and report your results back to the Puma issue tracker. + +Puma 5 was named Spoony Bard by our newest supercontributor, [@wjordan](https://github.com/puma/puma/commits?author=wjordan). Will brought you one of our new perf features for this release, as well as [many other fixes and refactors.](https://github.com/puma/puma/commits?author=wjordan) If you'd like to name a Puma release in the future, take a look at [CONTRIBUTING.md](CONTRIBUTING.md) and get started helping us out :) + +Puma 5 also welcomes [@MSP-Greg](https://github.com/puma/puma/commits?author=MSP-Greg) as our newest committer. Greg has been instrumental in improving our CI setup and SSL features. Greg also [named our 4.3.0 release](https://github.com/puma/puma/releases/tag/v4.3.0): Mysterious Traveller. + +## What's New + +Puma 5 contains three new "experimental" performance features for cluster-mode Pumas running on MRI. + +If you try any of these features, please report your results to [our report issue](https://github.com/puma/puma/issues/2258). + +Part of the reason we're calling them _experimental_ is because we're not sure if they'll actually have any benefit. People's workloads in the real world are often not what we anticipate, and synthetic benchmarks are usually not of any help in figuring out if a change will be beneficial or not. + +We do not believe any of the new features will have a negative effect or impact the stability of your application. This is either a "it works" or "it does nothing" experiment. + +If any of the features turn out to be particularly beneficial, we may make them defaults in future versions of Puma. + +### Lower latency, better throughput + +From our friends at GitLab, the new experimental `wait_for_less_busy_worker` config option may reduce latency and improve throughput for high-load Puma apps on MRI. See the [pull request](https://github.com/puma/puma/pull/2079) for more discussion. + +Users of this option should see reduced request queue latency and possibly less overall latency. + +Add the following to your `puma.rb` to try it: + +```ruby +wait_for_less_busy_worker +# or +wait_for_less_busy_worker 0.001 +``` + +Production testing at GitLab suggests values between `0.001` and `0.010` are best. + +### Better memory usage + +5.0 brings two new options to your config which may improve memory usage. + +#### nakayoshi_fork + +`nakayoshi_fork` calls GC a handful of times and compacts the heap on Ruby 2.7+ before forking. This may reduce memory usage of Puma on MRI with preload enabled. It's inspired by [Koichi Sasada's work](https://github.com/ko1/nakayoshi_fork). + +To use it, you can add this to your `puma.rb`: + +```ruby +nakayoshi_fork +``` + +#### fork_worker + +Puma 5 introduces an experimental new cluster-mode configuration option, `fork_worker` (`--fork-worker` from the CLI). This mode causes Puma to fork additional workers from worker 0, instead of directly from the master process: + +``` +10000 \_ puma 4.3.3 (tcp://0.0.0.0:9292) [puma] +10001 \_ puma: cluster worker 0: 10000 [puma] +10002 \_ puma: cluster worker 1: 10000 [puma] +10003 \_ puma: cluster worker 2: 10000 [puma] +10004 \_ puma: cluster worker 3: 10000 [puma] +``` + +It is compatible with phased restarts. It also may improve memory usage because the worker process loads additional code after processing requests. + +To learn more about using `refork` and `fork_worker`, see ['Fork Worker'](docs/fork_worker.md). + +### What else is new? + +* **Loads of bugfixes**. +* Faster phased restarts and worker timeouts. +* pumactl now has a `thread-backtraces` command to print thread backtraces, bringing thread backtrace printing to all platforms, not just *BSD and Mac. (#2053) +* Added incrementing `requests_count` to `Puma.stats`. (#2106) +* Faster phased restart and worker timeout. (#2220) +* Added `state_permission` to config DSL to set state file permissions (#2238) +* Ruby 2.2 support will be dropped in Puma 6. This is the final major release series for Ruby 2.2. + +## Upgrade + +* Setting the `WEB_CONCURRENCY` environment variable will now configure the number of workers (processes) that Puma will boot and enable preloading of the application. +* If you did not explicitly set `environment` before, Puma now checks `RAILS_ENV` and will use that, if available in addition to `RACK_ENV`. +* If you have been using the `--control` CLI option, update your scripts to use `--control-url`. +* If you are using `worker_directory` in your config file, change it to `directory`. +* If you are running MRI, default thread count on Puma is now 5, not 16. This may change the amount of threads running in your threadpool. We believe 5 is a better default for most Ruby web applications on MRI. Higher settings increase latency by causing GVL contention. +* If you are using a worker count of more than 1, set using `WEB_CONCURRENCY`, Puma will now preload the application by default (disable with `preload_app! false`). We believe this is a better default, but may cause issues in non-Rails applications if you do not have the proper `before` and `after` fork hooks configured. See documentation for your framework. Rails users do not need to change anything. **Please note that it is not possible to use [the phased restart](docs/restart.md) with preloading.** +* tcp mode and daemonization have been removed without replacement. For daemonization, please use a modern process management solution, such as systemd or monit. +* `connected_port` was renamed to `connected_ports` and now returns an Array, not an Integer. + +Then, update your Gemfile: + +`gem 'puma', '< 6'` diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 0000000..7963b26 --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,77 @@ +# Contributor Covenant Code of Conduct + +## Our Pledge + +In the interest of fostering an open and welcoming environment, we as +contributors and maintainers pledge to making participation in our project and +our community a harassment-free experience for everyone, regardless of age, body +size, disability, ethnicity, sex characteristics, gender identity and expression, +level of experience, education, socio-economic status, nationality, personal +appearance, race, religion, or sexual identity and orientation. + +## Our Standards + +Examples of behavior that contributes to creating a positive environment +include: + +* Using welcoming and inclusive language +* Being respectful of differing viewpoints and experiences +* Gracefully accepting constructive criticism +* Focusing on what is best for the community +* Showing empathy towards other community members + +Examples of unacceptable behavior by participants include: + +* The use of sexualized language or imagery and unwelcome sexual attention or + advances +* Trolling, insulting/derogatory comments, and personal or political attacks +* Public or private harassment +* Publishing others' private information, such as a physical or electronic + address, without explicit permission +* Other conduct which could reasonably be considered inappropriate in a + professional setting + +## Our Responsibilities + +Project maintainers are responsible for clarifying the standards of acceptable +behavior and are expected to take appropriate and fair corrective action in +response to any instances of unacceptable behavior. + +Project maintainers have the right and responsibility to remove, edit, or +reject comments, commits, code, wiki edits, issues, and other contributions +that are not aligned to this Code of Conduct, or to ban temporarily or +permanently any contributor for other behaviors that they deem inappropriate, +threatening, offensive, or harmful. + +## Scope + +This Code of Conduct applies both within project spaces and in public spaces +when an individual is representing the project or its community. Examples of +representing a project or community include using an official project e-mail +address, posting via an official social media account, or acting as an appointed +representative at an online or offline event. Representation of a project may be +further defined and clarified by project maintainers. + +## Enforcement + +Instances of abusive, harassing, or otherwise unacceptable behavior may be +reported by contacting the project team at nate.berkopec@speedshop.co, +richard.schneeman+no-recruiters@gmail.com, or evan@phx.io. All +complaints will be reviewed and investigated and will result in a response that +is deemed necessary and appropriate to the circumstances. The project team is +obligated to maintain confidentiality with regard to the reporter of an incident. +Further details of specific enforcement policies may be posted separately. + +Project maintainers who do not follow or enforce the Code of Conduct in good +faith may face temporary or permanent repercussions as determined by other +members of the project's leadership. + +## Attribution + +This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, +available at https://www.contributor-covenant.org/version/1/4/code-of-conduct.html + +[homepage]: https://www.contributor-covenant.org + +For answers to common questions about this code of conduct, see +https://www.contributor-covenant.org/faq diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..eb7a285 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,216 @@ +# Contributing to Puma + +By participating in this project, you agree to follow the [code of conduct]. + +[code of conduct]: https://github.com/puma/puma/blob/master/CODE_OF_CONDUCT.md + +There are lots of ways to contribute to Puma. Some examples include: + +* creating a [bug report] or [feature request] +* verifying [existing bug reports] and adding [reproduction steps] +* reviewing [pull requests] and testing the changes locally on your machine +* writing or editing [documentation] +* improving test coverage +* fixing a [reproducing bug] or adding a new feature + +[bug report]: https://github.com/puma/puma/issues/new?template=bug_report.md +[feature request]: https://github.com/puma/puma/issues/new?template=feature_request.md +[existing bug reports]: https://github.com/puma/puma/issues?q=is%3Aopen+is%3Aissue+label%3Aneeds-repro +[pull requests]: https://github.com/puma/puma/pulls +[documentation]: https://github.com/puma/puma/tree/master/docs +[reproduction steps]: https://github.com/puma/puma/blob/CONTRIBUTING.md#reproduction-steps +[reproducing bug]: https://github.com/puma/puma/issues?utf8=%E2%9C%93&q=is%3Aopen+is%3Aissue+label%3Abug + +Newbies welcome! We would be happy to help you make your first contribution to a F/OSS project. + +## Setup + +First step: join us on Matrix at [#puma-contrib:matrix.org](https://matrix.to/#/!blREBEDhVeXTYdjTVT:matrix.org?via=matrix.org) + + +#### Clone the repo + +Clone the Puma repository: +```sh +git clone git@github.com:puma/puma.git && cd puma +``` + +#### Ragel + +You need to install [ragel] (use Ragel version 7.0.0.9) to generate Puma's extension code. + +macOS: + +```sh +brew install ragel +``` + +Linux: +```sh +apt-get install ragel +``` + +Windows (Ruby 2.5 and later): +```sh +ridk exec pacman -S mingw-w64-x86_64-openssl mingw-w64-x86_64-ragel +``` + +#### Install Ruby dependencies + +Install the Ruby dependencies: +```sh +bundle install +``` + +#### Compile the native extensions + +To run Puma locally, you must compile the native extension. Running the `test` rake task does this automatically, but you may need to manually run the compile command if you want to run Puma and haven't run the tests yet: + +Ubuntu, macOS, etc: +```sh +bundle exec rake compile +``` + +Windows: +```sh +bundle exec rake -rdevkit compile +``` + +#### Run your local Puma + +Now, you should be able to run Puma locally: + +```sh +bundle exec bin/puma test/rackup/hello.ru +# -or- +bundle exec ruby -Ilib bin/puma test/rackup/hello.ru +``` + +Alternatively, you can reference your local copy in a project's `Gemfile`: + +```ruby +gem "puma", path: "/path/to/local/puma" +``` + +See the [Bundler docs](https://bundler.io/man/gemfile.5.html#PATH) for more details. + +[ragel]: https://www.colm.net/open-source/ragel/ + +## Running tests + +To run the entire test suite: +```sh +bundle exec rake test:all +``` + +To run a single test file: +```sh +bundle exec ruby test/test_binder.rb +``` + +You can also run tests with [`m`](https://github.com/qrush/m): +```sh +bundle exec m test/test_binder.rb +``` + +To run a single test: +```sh +bundle exec m test/test_binder.rb:37 +``` + +To run a single test with 5 seconds as the test case timeout: +```sh +TEST_CASE_TIMEOUT=5 bundle exec m test/test_binder.rb:37 +``` + +#### File limits + +Puma's test suite opens up a lot of sockets. This may exceed the default limit of your operating system. If your file limits are low, you may experience "too many open file" errors when running the Puma test suite. + +Check your file limit: + +``` +ulimit -Sn +``` + +We find that values of 4000 or more work well. [Learn more about your file limits and how to change them here.](https://wilsonmar.github.io/maximum-limits/) + +## How to contribute + +Puma could use your help in several areas! + +**The [contrib-wanted] label indicates that an issue might approachable to first-time contributors.**\ + +**Reproducing bug reports**: The [needs-repro] label indicates than an issue lacks reproduction steps. You can help by reproducing the issue and sharing the steps you took in the comments. + +**Helping with our native extensions**: If you are interested in writing C or Java, we could really use your help. Check out the issue labels for [c-ext] and [JRuby]. + +**Fixing bugs**: Issues with the [bug] label have working reproduction steps, which you can use to write a test and submit a patch. + +**Writing features**: The [feature] label highlights requests for new functionality. Write tests and code up our new feature! + +**Code review**: Take a look at open pull requests and offer your feedback. Code review is not just for maintainers. We need your help and eyeballs! + +**Write documentation**: Puma needs more docs in many areas, especially where we have open issues with the [docs] label. + +[bug]: https://github.com/puma/puma/issues?q=is%3Aopen+is%3Aissue+label%3Abug +[c-ext]: https://github.com/puma/puma/issues?q=is%3Aopen+is%3Aissue+label%3Ac-ext +[contrib-wanted]: https://github.com/puma/puma/issues?q=is%3Aopen+is%3Aissue+label%3Acontrib-wanted +[docs]: https://github.com/puma/puma/issues?q=is%3Aopen+is%3Aissue+label%3Adocs +[feature]: https://github.com/puma/puma/issues?q=is%3Aopen+is%3Aissue+label%3Afeature +[jruby]: https://github.com/puma/puma/issues?q=is%3Aopen+is%3Aissue+label%3Ajruby +[needs-repro]: https://github.com/puma/puma/issues?q=is%3Aopen+is%3Aissue+label%3Aneeds-repro + +## Reproduction steps + +Reproducing a bug helps identify the root cause of that bug so it can be fixed. + +To get started, create a rackup file and config file and then run your test app +with: +```sh +bundle exec puma -C +``` + +For example, using a test rack app ([`test/rackup/hello.ru`][rackup]) and a +test config file ([`test/config/settings.rb`][config]): +```sh +bundle exec puma -C test/config/settings.rb test/rackup/hello.ru +``` + +There is also a Dockerfile available for reproducing Linux-specific issues: +```sh +docker build -f tools/Dockerfile -t puma . +docker run -p 9292:9292 -it puma +``` + +[rackup]: https://github.com/puma/puma/blob/master/test/rackup/hello.ru +[config]: https://github.com/puma/puma/blob/master/test/config/settings.rb + +## Pull requests + +Code contributions should generally include test coverage. If you aren't sure how to +test your changes, please open a pull request and leave a comment asking for +help. + +There's no need to update the changelog ([`History.md`](History.md)); that is done [when a new release is made](Release.md). + +Puma uses [GitHub Actions](https://docs.github.com/en/actions) for CI testing. Please consider running the workflows in your fork before creating a PR. It is possible to enable GitHub Actions on your fork in the repositories' `Actions` tab. + +## Backports + +Puma does not have a backport "policy" - maintainers will not consistently backport bugfixes to previous minor or major versions (we do treat security differently, see [`SECURITY.md`](SECURITY.md). + +As a contributor, you may make pull requests against `-stable` branches to backport fixes, and maintainers will release them once they're merged. For example, if you'd like to make a backport for 4.3.x, you can make a pull request against `4-3-stable`. If there is no appropriate branch for the release you'd like to backport against, please just open an issue and we'll make one for you. + +## Join the community + +If you're looking to contribute to Puma, please join us on Matrix at [#puma-contrib:matrix.org](https://matrix.to/#/!blREBEDhVeXTYdjTVT:matrix.org?via=matrix.org). + +## Bibliography/Reading + +Puma can be a bit intimidating for your first contribution because there's a lot of concepts here that you've probably never had to think about before - Rack, sockets, forking, threads etc. Here are some helpful links for learning more about things related to Puma: + +* [Puma's Architecture docs](https://github.com/puma/puma/blob/master/docs/architecture.md) +* [The Rack specification](https://github.com/rack/rack/blob/master/SPEC.rdoc) +* The Ruby docs for IO.pipe, TCPServer/Socket. +* [nio4r documentation](https://github.com/socketry/nio4r/wiki/Getting-Started) diff --git a/Gemfile b/Gemfile new file mode 100644 index 0000000..f40b267 --- /dev/null +++ b/Gemfile @@ -0,0 +1,26 @@ +source "https://rubygems.org" + +gemspec + +gem "rdoc" +gem "rake-compiler", "~> 1.1.1" + +gem "json", "~> 2.3" +gem "nio4r", "~> 2.0" +gem "rack", ">= 1.6.13" +gem "minitest", "~> 5.11" +gem "minitest-retry" +gem "minitest-proveit" +gem "minitest-stub-const" +gem "sd_notify" + +gem "jruby-openssl", :platform => "jruby" + +gem "rubocop", "~> 0.64.0" + +if %w(2.2.7 2.2.8 2.2.9 2.2.10 2.3.4 2.4.1).include? RUBY_VERSION + gem "stopgap_13632", "~> 1.0", :platforms => ["mri", "mingw", "x64_mingw"] +end + +gem 'm' +gem "localhost", require: false diff --git a/History.md b/History.md new file mode 100644 index 0000000..0a92f3f --- /dev/null +++ b/History.md @@ -0,0 +1,2518 @@ +## 5.6.4 / 2022-03-30 + +* Security + * Close several HTTP Request Smuggling exploits (CVE-2022-24790) + +## 5.6.2 / 2022-02-11 + +* Bugfix/Security + * Response body will always be `close`d. (GHSA-rmj8-8hhh-gv5h, related to [#2809]) + +## 5.6.1 / 2022-01-26 + +* Bugfixes + * Reverted a commit which appeared to be causing occasional blank header values ([#2809]) + +## 5.6.0 / 2022-01-25 + +* Features + * Support `localhost` integration in `ssl_bind` ([#2764], [#2708]) + * Allow backlog parameter to be set with ssl_bind DSL ([#2780]) + * Remove yaml (psych) requirement in StateFile ([#2784]) + * Allow culling of oldest workers, previously was only youngest ([#2773], [#2794]) + * Add worker_check_interval configuration option ([#2759]) + * Always send lowlevel_error response to client ([#2731], [#2341]) + * Support for cert_pem and key_pem with ssl_bind DSL ([#2728]) + +* Bugfixes + * Keep thread names under 15 characters, prevents breakage on some OSes ([#2733]) + * Fix two 'old-style-definition' compile warning ([#2807], [#2806]) + * Log environment correctly using option value ([#2799]) + * Fix warning from Ruby master (will be 3.2.0) ([#2785]) + * extconf.rb - fix openssl with old Windows builds ([#2757]) + * server.rb - rescue handling (`Errno::EBADF`) for `@notify.close` ([#2745]) + +* Refactor + * server.rb - refactor code using @options[:remote_address] ([#2742]) + * [jruby] a couple refactorings - avoid copy-ing bytes ([#2730]) + +## 5.5.2 / 2021-10-12 + +* Bugfixes + * Allow UTF-8 in HTTP header values + +## 5.5.1 / 2021-10-12 + +* Feature (added as mistake - we don't normally do this on bugfix releases, sorry!) + * Allow setting APP_ENV in preference to RACK_ENV or RAILS_ENV ([#2702]) + +* Security + * Do not allow LF as a line ending in a header (CVE-2021-41136) + +## 5.5.0 / 2021-09-19 + +* Features + * Automatic SSL certificate provisioning for localhost, via localhost gem ([#2610], [#2257]) + * add support for the PROXY protocol (v1 only) ([#2654], [#2651]) + * Add a semantic CLI option for no config file ([#2689]) + +* Bugfixes + * More elaborate exception handling - lets some dead pumas die. ([#2700], [#2699]) + * allow multiple after_worker_fork hooks ([#2690]) + * Preserve BUNDLE_APP_CONFIG on worker fork ([#2688], [#2687]) + +* Performance + * Fix performance of server-side SSL connection close. ([#2675]) + +## 5.4.0 / 2021-07-28 + +* Features + * Better/expanded names for threadpool threads ([#2657]) + * Allow pkg_config for OpenSSL ([#2648], [#1412]) + * Add `rack_url_scheme` to Puma::DSL, allows setting of `rack.url_scheme` header ([#2586], [#2569]) + +* Bugfixes + * `Binder#parse` - allow for symlinked unix path, add create_activated_fds debug ENV ([#2643], [#2638]) + * Fix deprecation warning: minissl.c - Use Random.bytes if available ([#2642]) + * Client certificates: set session id context while creating SSLContext ([#2633]) + * Fix deadlock issue in thread pool ([#2656]) + +* Refactor + * Replace `IO.select` with `IO#wait_*` when checking a single IO ([#2666]) + +## 5.3.2 / 2021-05-21 + +* Bugfixes + * Gracefully handle Rack not accepting CLI options ([#2630], [#2626]) + * Fix sigterm misbehavior ([#2629]) + * Improvements to keepalive-connection shedding ([#2628]) + +## 5.3.1 / 2021-05-11 + +* Security + * Close keepalive connections after the maximum number of fast inlined requests (CVE-2021-29509) ([#2625]) + +## 5.3.0 / 2021-05-07 + +* Features + * Add support for Linux's abstract sockets ([#2564], [#2526]) + * Add debug to worker timeout and startup ([#2559], [#2528]) + * Print warning when running one-worker cluster ([#2565], [#2534]) + * Don't close systemd activated socket on pumactl restart ([#2563], [#2504]) + +* Bugfixes + * systemd - fix event firing ([#2591], [#2572]) + * Immediately unlink temporary files ([#2613]) + * Improve parsing of HTTP_HOST header ([#2605], [#2584]) + * Handle fatal error that has no backtrace ([#2607], [#2552]) + * Fix timing out requests too early ([#2606], [#2574]) + * Handle segfault in Ruby 2.6.6 on thread-locals ([#2567], [#2566]) + * Server#closed_socket? - parameter may be a MiniSSL::Socket ([#2596]) + * Define UNPACK_TCP_STATE_FROM_TCP_INFO in the right place ([#2588], [#2556]) + * request.rb - fix chunked assembly for ascii incompatible encodings, add test ([#2585], [#2583]) + +* Performance + * Reset peerip only if remote_addr_header is set ([#2609]) + * Reduce puma_parser struct size ([#2590]) + +* Refactor + * Refactor drain on shutdown ([#2600]) + * Micro optimisations in `wait_for_less_busy_worker` feature ([#2579]) + * Lots of test fixes + +## 5.2.2 / 2021-02-22 + +* Bugfixes + * Add `#flush` and `#sync` methods to `Puma::NullIO` ([#2553]) + * Restore `sync=true` on `STDOUT` and `STDERR` streams ([#2557]) + +## 5.2.1 / 2021-02-05 + +* Bugfixes + * Fix TCP cork/uncork operations to work with ssl clients ([#2550]) + * Require rack/common_logger explicitly if :verbose is true ([#2547]) + * MiniSSL::Socket#write - use data.byteslice(wrote..-1) ([#2543]) + * Set `@env[CONTENT_LENGTH]` value as string. ([#2549]) + +## 5.2.0 / 2021-01-27 + +* Features + * 10x latency improvement for MRI on ssl connections by reducing overhead ([#2519]) + * Add option to specify the desired IO selector backend for libev ([#2522]) + * Add ability to set OpenSSL verification flags (MRI only) ([#2490]) + * Uses `flush` after writing messages to avoid mutating $stdout and $stderr using `sync=true` ([#2486]) + +* Bugfixes + * MiniSSL - Update dhparam to 2048 bit for use with SSL_CTX_set_tmp_dh ([#2535]) + * Change 'Goodbye!' message to be output after listeners are closed ([#2529]) + * Fix ssl bind logging with 0.0.0.0 and localhost ([#2533]) + * Fix compiler warnings, but skipped warnings related to ragel state machine generated code ([#1953]) + * Fix phased restart errors related to nio4r gem when using the Puma control server ([#2516]) + * Add `#string` method to `Puma::NullIO` ([#2520]) + * Fix binding via Rack handler to IPv6 addresses ([#2521]) + +* Refactor + * Refactor MiniSSL::Context on MRI, fix MiniSSL::Socket#write ([#2519]) + * Remove `Server#read_body` ([#2531]) + * Fail build if compiling extensions raises warnings on GH Actions, configurable via `MAKE_WARNINGS_INTO_ERRORS` ([#1953]) + +## 5.1.1 / 2020-12-10 + +* Bugfixes + * Fix over eager matching against banned header names ([#2510]) + +## 5.1.0 / 2020-11-30 + +* Features + * Phased restart availability is now always logged, even if it is not available. + * Prints the loaded configuration if the environment variable `PUMA_LOG_CONFIG` is present ([#2472]) + * Integrate with systemd's watchdog and notification features ([#2438]) + * Adds max_fast_inline as a configuration option for the Server object ([#2406]) + * You can now fork workers from worker 0 using SIGURG w/o fork_worker enabled [#2449] + * Add option to bind to systemd activated sockets ([#2362]) + * Add compile option to change the `QUERY_STRING` max length ([#2485]) + +* Bugfixes + * Fix JRuby handling in Puma::DSL#ssl_bind ([#2489]) + * control_cli.rb - all normal output should be to @stdout ([#2487]) + * Catch 'Error in reactor loop escaped: mode not supported for this object: r' ([#2477]) + * Ignore Rails' reaper thread (and any thread marked forksafe) for warning ([#2475]) + * Ignore illegal (by Rack spec) response header ([#2439]) + * Close idle connections immediately on shutdown ([#2460]) + * Fix some instances of phased restart errors related to the `json` gem ([#2473]) + * Remove use of `json` gem to fix phased restart errors ([#2479]) + * Fix grouping regexp of ILLEGAL_HEADER_KEY_REGEX ([#2495]) + +## 5.0.4 / 2020-10-27 + +* Bugfixes + * Pass preloaded application into new workers if available when using `preload_app` ([#2461], [#2454]) + +## 5.0.3 / 2020-10-26 + +* Bugfixes + * Add Client#io_ok?, check before Reactor#register ([#2432]) + * Fix hang on shutdown in refork ([#2442]) + * Fix `Bundler::GemNotFound` errors for `nio4r` gem during phased restarts ([#2427], [#2018]) + * Server run thread safety fix ([#2435]) + * Fire `on_booted` after server starts ([#2431], [#2212]) + * Cleanup daemonization in rc.d script ([#2409]) + +* Refactor + * Remove accept_nonblock.rb, add test_integration_ssl.rb ([#2448]) + * Refactor status.rb - dry it up a bit ([#2450]) + * Extract req/resp methods to new request.rb from server.rb ([#2419]) + * Refactor Reactor and Client request buffering ([#2279]) + * client.rb - remove JRuby specific 'finish' code ([#2412]) + * Consolidate fast_write calls in Server, extract early_hints assembly ([#2405]) + * Remove upstart from docs ([#2408]) + * Extract worker process into separate class ([#2374]) + * Consolidate option handling in Server, Server small refactors, doc changes ([#2389]) + +## 5.0.2 / 2020-09-28 + +* Bugfixes + * Reverted API changes to Server. + +## 5.0.1 / 2020-09-28 + +* Bugfixes + * Fix LoadError in CentOS 8 ([#2381]) + * Better error handling during force shutdown ([#2271]) + * Prevent connections from entering Reactor after shutdown begins ([#2377]) + * Fix error backtrace debug logging && Do not log request dump if it is not parsed ([#2376]) + * Split TCP_CORK and TCP_INFO ([#2372]) + * Do not log EOFError when a client connection is closed without write ([#2384]) + +* Refactor + * Change Events#ssl_error signature from (error, peeraddr, peercert) to (error, ssl_socket) ([#2375]) + * Consolidate option handling in Server, Server small refactors, doc chang ([#2373]) + +## 5.0.0 / 2020-09-17 + +* Features + * Allow compiling without OpenSSL and dynamically load files needed for SSL, add 'no ssl' CI ([#2305]) + * EXPERIMENTAL: Add `fork_worker` option and `refork` command for reduced memory usage by forking from a worker process instead of the master process. ([#2099]) + * EXPERIMENTAL: Added `wait_for_less_busy_worker` config. This may reduce latency on MRI through inserting a small delay before re-listening on the socket if worker is busy ([#2079]). + * EXPERIMENTAL: Added `nakayoshi_fork` option. Reduce memory usage in preloaded cluster-mode apps by GCing before fork and compacting, where available. ([#2093], [#2256]) + * Added pumactl `thread-backtraces` command to print thread backtraces ([#2054]) + * Added incrementing `requests_count` to `Puma.stats`. ([#2106]) + * Increased maximum URI path length from 2048 to 8192 bytes ([#2167], [#2344]) + * `lowlevel_error_handler` is now called during a forced threadpool shutdown, and if a callable with 3 arguments is set, we now also pass the status code ([#2203]) + * Faster phased restart and worker timeout ([#2220]) + * Added `state_permission` to config DSL to set state file permissions ([#2238]) + * Added `Puma.stats_hash`, which returns a stats in Hash instead of a JSON string ([#2086], [#2253]) + * `rack.multithread` and `rack.multiprocess` now dynamically resolved by `max_thread` and `workers` respectively ([#2288]) + +* Deprecations, Removals and Breaking API Changes + * `--control` has been removed. Use `--control-url` ([#1487]) + * `worker_directory` has been removed. Use `directory`. + * min_threads now set by environment variables PUMA_MIN_THREADS and MIN_THREADS. ([#2143]) + * max_threads now set by environment variables PUMA_MAX_THREADS and MAX_THREADS. ([#2143]) + * max_threads default to 5 in MRI or 16 for all other interpreters. ([#2143]) + * `preload_app!` is on by default if number of workers > 1 and set via `WEB_CONCURRENCY` ([#2143]) + * Puma::Plugin.workers_supported? has been removed. Use Puma.forkable? instead. ([#2143]) + * `tcp_mode` has been removed without replacement. ([#2169]) + * Daemonization has been removed without replacement. ([#2170]) + * Changed #connected_port to #connected_ports ([#2076]) + * Configuration: `environment` is read from `RAILS_ENV`, if `RACK_ENV` can't be found ([#2022]) + * Log binding on http:// for TCP bindings to make it clickable ([#2300]) + +* Bugfixes + * Fix JSON loading issues on phased-restarts ([#2269]) + * Improve shutdown reliability ([#2312], [#2338]) + * Close client http connections made to an ssl server with TLSv1.3 ([#2116]) + * Do not set user_config to quiet by default to allow for file config ([#2074]) + * Always close SSL connection in Puma::ControlCLI ([#2211]) + * Windows update extconf.rb for use with ssp and varied Ruby/MSYS2 combinations ([#2069]) + * Ensure control server Unix socket is closed on shutdown ([#2112]) + * Preserve `BUNDLE_GEMFILE` env var when using `prune_bundler` ([#1893]) + * Send 408 request timeout even when queue requests is disabled ([#2119]) + * Rescue IO::WaitReadable instead of EAGAIN for blocking read ([#2121]) + * Ensure `BUNDLE_GEMFILE` is unspecified in workers if unspecified in master when using `prune_bundler` ([#2154]) + * Rescue and log exceptions in hooks defined by users (on_worker_boot, after_worker_fork etc) ([#1551]) + * Read directly from the socket in #read_and_drop to avoid raising further SSL errors ([#2198]) + * Set `Connection: closed` header when queue requests is disabled ([#2216]) + * Pass queued requests to thread pool on server shutdown ([#2122]) + * Fixed a few minor concurrency bugs in ThreadPool that may have affected non-GVL Rubies ([#2220]) + * Fix `out_of_band` hook never executed if the number of worker threads is > 1 ([#2177]) + * Fix ThreadPool#shutdown timeout accuracy ([#2221]) + * Fix `UserFileDefaultOptions#fetch` to properly use `default` ([#2233]) + * Improvements to `out_of_band` hook ([#2234]) + * Prefer the rackup file specified by the CLI ([#2225]) + * Fix for spawning subprocesses with fork_worker option ([#2267]) + * Set `CONTENT_LENGTH` for chunked requests ([#2287]) + * JRuby - Add Puma::MiniSSL::Engine#init? and #teardown methods, run all SSL tests ([#2317]) + * Improve shutdown reliability ([#2312]) + * Resolve issue with threadpool waiting counter decrement when thread is killed + * Constrain rake-compiler version to 0.9.4 to fix `ClassNotFound` exception when using MiniSSL with Java8. + * Fix recursive `prune_bundler` ([#2319]). + * Ensure that TCP_CORK is usable + * Fix corner case when request body is chunked ([#2326]) + * Fix filehandle leak in MiniSSL ([#2299]) + +* Refactor + * Remove unused loader argument from Plugin initializer ([#2095]) + * Simplify `Configuration.random_token` and remove insecure fallback ([#2102]) + * Simplify `Runner#start_control` URL parsing ([#2111]) + * Removed the IOBuffer extension and replaced with Ruby ([#1980]) + * Update `Rack::Handler::Puma.run` to use `**options` ([#2189]) + * ThreadPool concurrency refactoring ([#2220]) + * JSON parse cluster worker stats instead of regex ([#2124]) + * Support parallel tests in verbose progress reporting ([#2223]) + * Refactor error handling in server accept loop ([#2239]) + +## 4.3.10 / 2021-10-12 + +* Bugfixes + * Allow UTF-8 in HTTP header values + +## 4.3.9 / 2021-10-12 + +* Security + * Do not allow LF as a line ending in a header (CVE-2021-41136) + +## 4.3.8 / 2021-05-11 + +* Security + * Close keepalive connections after the maximum number of fast inlined requests (CVE-2021-29509) ([#2625]) + +## 4.3.7 / 2020-11-30 + +* Bugfixes + * Backport set CONTENT_LENGTH for chunked requests (Originally: [#2287], backport: [#2496]) + +## 4.3.6 / 2020-09-05 + +* Bugfixes + * Explicitly include ctype.h to fix compilation warning and build error on macOS with Xcode 12 ([#2304]) + * Don't require json at boot ([#2269]) + +## 4.3.4/4.3.5 and 3.12.5/3.12.6 / 2020-05-22 + +Each patchlevel release contains a separate security fix. We recommend simply upgrading to 4.3.5/3.12.6. + +* Security + * Fix: Fixed two separate HTTP smuggling vulnerabilities that used the Transfer-Encoding header. CVE-2020-11076 and CVE-2020-11077. + +## 4.3.3 and 3.12.4 / 2020-02-28 + +* Bugfixes + * Fix: Fixes a problem where we weren't splitting headers correctly on newlines ([#2132]) +* Security + * Fix: Prevent HTTP Response splitting via CR in early hints. CVE-2020-5249. + +## 4.3.2 and 3.12.3 / 2020-02-27 (YANKED) + +* Security + * Fix: Prevent HTTP Response splitting via CR/LF in header values. CVE-2020-5247. + +## 4.3.1 and 3.12.2 / 2019-12-05 + +* Security + * Fix: a poorly-behaved client could use keepalive requests to monopolize Puma's reactor and create a denial of service attack. CVE-2019-16770. + +## 4.3.0 / 2019-11-07 + +* Features + * Strip whitespace at end of HTTP headers ([#2010]) + * Optimize HTTP parser for JRuby ([#2012]) + * Add SSL support for the control app and cli ([#2046], [#2052]) + +* Bugfixes + * Fix Errno::EINVAL when SSL is enabled and browser rejects cert ([#1564]) + * Fix pumactl defaulting puma to development if an environment was not specified ([#2035]) + * Fix closing file stream when reading pid from pidfile ([#2048]) + * Fix a typo in configuration option `--extra_runtime_dependencies` ([#2050]) + +## 4.2.1 / 2019-10-07 + +* 3 bugfixes + * Fix socket activation of systemd (pre-existing) unix binder files ([#1842], [#1988]) + * Deal with multiple calls to bind correctly ([#1986], [#1994], [#2006]) + * Accepts symbols for `verify_mode` ([#1222]) + +## 4.2.0 / 2019-09-23 + +* 6 features + * Pumactl has a new -e environment option and reads `config/puma/.rb` config files ([#1885]) + * Semicolons are now allowed in URL paths (MRI only), useful for Angular or Redmine ([#1934]) + * Allow extra dependencies to be defined when using prune_bundler ([#1105]) + * Puma now reports the correct port when binding to port 0, also reports other listeners when binding to localhost ([#1786]) + * Sending SIGINFO to any Puma worker now prints currently active threads and their backtraces ([#1320]) + * Puma threads all now have their name set on Ruby 2.3+ ([#1968]) +* 4 bugfixes + * Fix some misbehavior with phased restart and externally SIGTERMed workers ([#1908], [#1952]) + * Fix socket closing on error ([#1941]) + * Removed unnecessary SIGINT trap for JRuby that caused some race conditions ([#1961]) + * Fix socket files being left around after process stopped ([#1970]) +* Absolutely thousands of lines of test improvements and fixes thanks to @MSP-Greg + +## 4.1.1 / 2019-09-05 + +* 3 bugfixes + * Revert our attempt to not dup STDOUT/STDERR ([#1946]) + * Fix socket close on error ([#1941]) + * Fix workers not shutting down correctly ([#1908]) + +## 4.1.0 / 2019-08-08 + +* 4 features + * Add REQUEST_PATH on parse error message ([#1831]) + * You can now easily add custom log formatters with the `log_formatter` config option ([#1816]) + * Puma.stats now provides process start times ([#1844]) + * Add support for disabling TLSv1.1 ([#1836]) + +* 7 bugfixes + * Fix issue where Puma was creating zombie process entries ([#1887]) + * Fix bugs with line-endings and chunked encoding ([#1812]) + * RACK_URL_SCHEME is now set correctly in all conditions ([#1491]) + * We no longer mutate global STDOUT/STDERR, particularly the sync setting ([#1837]) + * SSL read_nonblock no longer blocks ([#1857]) + * Swallow connection errors when sending early hints ([#1822]) + * Backtrace no longer dumped when invalid pumactl commands are run ([#1863]) + +* 5 other + * Avoid casting worker_timeout twice ([#1838]) + * Removed a call to private that wasn't doing anything ([#1882]) + * README, Rakefile, docs and test cleanups ([#1848], [#1847], [#1846], [#1853], #1859, [#1850], [#1866], [#1870], [#1872], [#1833], [#1888]) + * Puma.io has proper documentation now (https://puma.io/puma/) + * Added the Contributor Covenant CoC + +* 1 known issue + * Some users are still experiencing issues surrounding socket activation and Unix sockets ([#1842]) + +## 4.0.1 / 2019-07-11 + +* 2 bugfixes + * Fix socket removed after reload - should fix problems with systemd socket activation. ([#1829]) + * Add extconf tests for DTLS_method & TLS_server_method, use in minissl.rb. Should fix "undefined symbol: DTLS_method" when compiling against old OpenSSL versions. ([#1832]) +* 1 other + * Removed unnecessary RUBY_VERSION checks. ([#1827]) + +## 4.0.0 / 2019-06-25 + +* 9 features + * Add support for disabling TLSv1.0 ([#1562]) + * Request body read time metric ([#1569]) + * Add out_of_band hook ([#1648]) + * Re-implement (native) IOBuffer for JRuby ([#1691]) + * Min worker timeout ([#1716]) + * Add option to suppress SignalException on SIGTERM ([#1690]) + * Allow mutual TLS CA to be set using `ssl_bind` DSL ([#1689]) + * Reactor now uses nio4r instead of `select` ([#1728]) + * Add status to pumactl with pidfile ([#1824]) + +* 10 bugfixes + * Do not accept new requests on shutdown ([#1685], [#1808]) + * Fix 3 corner cases when request body is chunked ([#1508]) + * Change pid existence check's condition branches ([#1650]) + * Don't call .stop on a server that doesn't exist ([#1655]) + * Implemented NID_X9_62_prime256v1 (P-256) curve over P-521 ([#1671]) + * Fix @notify.close can't modify frozen IOError (RuntimeError) ([#1583]) + * Fix Java 8 support ([#1773]) + * Fix error `uninitialized constant Puma::Cluster` ([#1731]) + * Fix `not_token` being able to be set to true ([#1803]) + * Fix "Hang on SIGTERM with ruby 2.6 in clustered mode" (PR [#1741], [#1674], [#1720], [#1730], [#1755]) + +## 3.12.1 / 2019-03-19 + +* 1 features + * Internal strings are frozen ([#1649]) +* 3 bugfixes + * Fix chunked ending check ([#1607]) + * Rack handler should use provided default host ([#1700]) + * Better support for detecting runtimes that support `fork` ([#1630]) + +## 3.12.0 / 2018-07-13 + +* 5 features: + * You can now specify which SSL ciphers the server should support, default is unchanged ([#1478]) + * The setting for Puma's `max_threads` is now in `Puma.stats` ([#1604]) + * Pool capacity is now in `Puma.stats` ([#1579]) + * Installs restricted to Ruby 2.2+ ([#1506]) + * `--control` is now deprecated in favor of `--control-url` ([#1487]) + +* 2 bugfixes: + * Workers will no longer accept more web requests than they have capacity to process. This prevents an issue where one worker would accept lots of requests while starving other workers ([#1563]) + * In a test env puma now emits the stack on an exception ([#1557]) + +## 3.11.4 / 2018-04-12 + +* 2 features: + * Manage puma as a service using rc.d ([#1529]) + * Server stats are now available from a top level method ([#1532]) +* 5 bugfixes: + * Fix parsing CLI options ([#1482]) + * Order of stderr and stdout is made before redirecting to a log file ([#1511]) + * Init.d fix of `ps -p` to check if pid exists ([#1545]) + * Early hints bugfix ([#1550]) + * Purge interrupt queue when closing socket fails ([#1553]) + +## 3.11.3 / 2018-03-05 + +* 3 bugfixes: + * Add closed? to MiniSSL::Socket for use in reactor ([#1510]) + * Handle EOFError at the toplevel of the server threads ([#1524]) ([#1507]) + * Deal with zero sized bodies when using SSL ([#1483]) + +## 3.11.2 / 2018-01-19 + +* 1 bugfix: + * Deal with read\_nonblock returning nil early + +## 3.11.1 / 2018-01-18 + +* 1 bugfix: + * Handle read\_nonblock returning nil when the socket close ([#1502]) + +## 3.11.0 / 2017-11-20 + +* 2 features: + * HTTP 103 Early Hints ([#1403]) + * 421/451 status codes now have correct status messages attached ([#1435]) + +* 9 bugfixes: + * Environment config files (/config/puma/.rb) load correctly ([#1340]) + * Specify windows dependencies correctly ([#1434], [#1436]) + * puma/events required in test helper ([#1418]) + * Correct control CLI's option help text ([#1416]) + * Remove a warning for unused variable in mini_ssl ([#1409]) + * Correct pumactl docs argument ordering ([#1427]) + * Fix an uninitialized variable warning in server.rb ([#1430]) + * Fix docs typo/error in Launcher init ([#1429]) + * Deal with leading spaces in RUBYOPT ([#1455]) + +* 2 other: + * Add docs about internals ([#1425], [#1452]) + * Tons of test fixes from @MSP-Greg ([#1439], [#1442], [#1464]) + +## 3.10.0 / 2017-08-17 + +* 3 features: + * The status server has a new /gc and /gc-status command. ([#1384]) + * The persistent and first data timeouts are now configurable ([#1111]) + * Implemented RFC 2324 ([#1392]) + +* 12 bugfixes: + * Not really a Puma bug, but @NickolasVashchenko created a gem to workaround a Ruby bug that some users of Puma may be experiencing. See README for more. ([#1347]) + * Fix hangups with SSL and persistent connections. ([#1334]) + * Fix Rails double-binding to a port ([#1383]) + * Fix incorrect thread names ([#1368]) + * Fix issues with /etc/hosts and JRuby where localhost addresses were not correct. ([#1318]) + * Fix compatibility with RUBYOPT="--enable-frozen-string-literal" ([#1376]) + * Fixed some compiler warnings ([#1388]) + * We actually run the integration tests in CI now ([#1390]) + * No longer shipping unnecessary directories in the gemfile ([#1391]) + * If RUBYOPT is nil, we no longer blow up on restart. ([#1385]) + * Correct response to SIGINT ([#1377]) + * Proper exit code returned when we receive a TERM signal ([#1337]) + +* 3 refactors: + * Various test improvements from @grosser + * Rubocop ([#1325]) + * Hoe has been removed ([#1395]) + +* 1 known issue: + * Socket activation doesn't work in JRuby. Their fault, not ours. ([#1367]) + +## 3.9.1 / 2017-06-03 + +* 2 bugfixes: + * Fixed compatibility with older Bundler versions ([#1314]) + * Some internal test/development cleanup ([#1311], [#1313]) + +## 3.9.0 / 2017-06-01 + +* 2 features: + * The ENV is now reset to its original values when Puma restarts via USR1/USR2 ([#1260]) (MRI only, no JRuby support) + * Puma will no longer accept more clients than the maximum number of threads. ([#1278]) + +* 9 bugfixes: + * Reduce information leakage by preventing HTTP parse errors from writing environment hashes to STDERR ([#1306]) + * Fix SSL/WebSocket compatibility ([#1274]) + * HTTP headers with empty values are no longer omitted from responses. ([#1261]) + * Fix a Rack env key which was set to nil. ([#1259]) + * peercert has been implemented for JRuby ([#1248]) + * Fix port settings when using rails s ([#1277], [#1290]) + * Fix compat w/LibreSSL ([#1285]) + * Fix restarting Puma w/symlinks and a new Gemfile ([#1282]) + * Replace Dir.exists? with Dir.exist? ([#1294]) + +* 1 known issue: + * A bug in MRI 2.2+ can result in IOError: stream closed. See [#1206]. This issue has existed since at least Puma 3.6, and probably further back. + +* 1 refactor: + * Lots of test fixups from @grosser. + +## 3.8.2 / 2017-03-14 + +* 1 bugfix: + * Deal with getsockopt with TCP\_INFO failing for sockets that say they're TCP but aren't really. ([#1241]) + +## 3.8.1 / 2017-03-10 + +* 1 bugfix: + * Remove method call to method that no longer exists ([#1239]) + +## 3.8.0 / 2017-03-09 + +* 2 bugfixes: + * Port from rack handler does not take precedence over config file in Rails 5.1.0.beta2+ and 5.0.1.rc3+ ([#1234]) + * The `tmp/restart.txt` plugin no longer restricts the user from running more than one server from the same folder at a time ([#1226]) + +* 1 feature: + * Closed clients are aborted to save capacity ([#1227]) + +* 1 refactor: + * Bundler is no longer a dependency from tests ([#1213]) + +## 3.7.1 / 2017-02-20 + +* 2 bugfixes: + * Fix typo which blew up MiniSSL ([#1182]) + * Stop overriding command-line options with the config file ([#1203]) + +## 3.7.0 / 2017-01-04 + +* 6 minor features: + * Allow rack handler to accept ssl host. ([#1129]) + * Refactor TTOU processing. TTOU now handles multiple signals at once. ([#1165]) + * Pickup any remaining chunk data as the next request. + * Prevent short term thread churn - increased auto trim default to 30 seconds. + * Raise error when `stdout` or `stderr` is not writable. ([#1175]) + * Add Rack 2.0 support to gemspec. ([#1068]) + +* 5 refactors: + * Compare host and server name only once per call. ([#1091]) + * Minor refactor on Thread pool ([#1088]) + * Removed a ton of unused constants, variables and files. + * Use MRI macros when allocating heap memory + * Use hooks for on\_booted event. ([#1160]) + +* 14 bugfixes: + * Add eof? method to NullIO? ([#1169]) + * Fix Puma startup in provided init.d script ([#1061]) + * Fix default SSL mode back to none. ([#1036]) + * Fixed the issue of @listeners getting nil io ([#1120]) + * Make `get_dh1024` compatible with OpenSSL v1.1.0 ([#1178]) + * More gracefully deal with SSL sessions. Fixes [#1002] + * Move puma.rb to just autoloads. Fixes [#1063] + * MiniSSL: Provide write as <<. Fixes [#1089] + * Prune bundler should inherit fds ([#1114]) + * Replace use of Process.getpgid which does not behave as intended on all platforms ([#1110]) + * Transfer encoding header should be downcased before comparison ([#1135]) + * Use same write log logic for hijacked requests. ([#1081]) + * Fix `uninitialized constant Puma::StateFile` ([#1138]) + * Fix access priorities of each level in LeveledOptions ([#1118]) + +* 3 others: + + * Lots of tests added/fixed/improved. Switched to Minitest from Test::Unit. Big thanks to @frodsan. + * Lots of documentation added/improved. + * Add license indicators to the HTTP extension. ([#1075]) + +## 3.6.2 / 2016-11-22 + +* 1 bug fix: + + * Revert [#1118]/Fix access priorities of each level in LeveledOptions. This + had an unintentional side effect of changing the importance of command line + options, such as -p. + +## 3.6.1 / 2016-11-21 + +* 8 bug fixes: + + * Fix Puma start in init.d script. + * Fix default SSL mode back to none. Fixes [#1036] + * Fixed the issue of @listeners getting nil io, fix rails restart ([#1120]) + * More gracefully deal with SSL sessions. Fixes [#1002] + * Prevent short term thread churn. + * Provide write as <<. Fixes [#1089] + * Fix access priorities of each level in LeveledOptions - fixes TTIN. + * Stub description files updated for init.d. + +* 2 new project committers: + + * Nate Berkopec (@nateberkopec) + * Richard Schneeman (@schneems) + +## 3.6.0 / 2016-07-24 + +* 12 bug fixes: + * Add ability to detect a shutting down server. Fixes [#932] + * Add support for Expect: 100-continue. Fixes [#519] + * Check SSLContext better. Fixes [#828] + * Clarify behavior of '-t num'. Fixes [#984] + * Don't default to VERIFY_PEER. Fixes [#1028] + * Don't use ENV['PWD'] on windows. Fixes [#1023] + * Enlarge the scope of catching app exceptions. Fixes [#1027] + * Execute background hooks after daemonizing. Fixes [#925] + * Handle HUP as a stop unless there is IO redirection. Fixes [#911] + * Implement chunked request handling. Fixes [#620] + * Just rescue exception to return a 500. Fixes [#1027] + * Redirect IO in the jruby daemon mode. Fixes [#778] + +## 3.5.2 / 2016-07-20 + +* 1 bug fix: + * Don't let persistent_timeout be nil + +* 1 PR merged: + * Merge pull request [#1021] from benzrf/patch-1 + +## 3.5.1 / 2016-07-20 + +* 1 bug fix: + * Be sure to only listen on host:port combos once. Fixes [#1022] + +## 3.5.0 / 2016-07-18 + +* 1 minor features: + * Allow persistent_timeout to be configured via the dsl. + +* 9 bug fixes: + * Allow a bare % in a query string. Fixes [#958] + * Explicitly listen on all localhost addresses. Fixes [#782] + * Fix `TCPLogger` log error in tcp cluster mode. + * Fix puma/puma[#968] Cannot bind SSL port due to missing verify_mode option + * Fix puma/puma[#968] Default verify_mode to peer + * Log any exceptions in ThreadPool. Fixes [#1010] + * Silence connection errors in the reactor. Fixes [#959] + * Tiny fixes in hook documentation for [#840] + * It should not log requests if we want it to be quiet + +* 5 doc fixes: + * Add How to stop Puma on Heroku using plugins to the example directory + * Provide both hot and phased restart in jungle script + * Update reference to the instances management script + * Update default number of threads + * Fix typo in example config + +* 14 PRs merged: + * Merge pull request [#1007] from willnet/patch-1 + * Merge pull request [#1014] from jeznet/patch-1 + * Merge pull request [#1015] from bf4/patch-1 + * Merge pull request [#1017] from jorihardman/configurable_persistent_timeout + * Merge pull request [#954] from jf/master + * Merge pull request [#955] from jf/add-request-info-to-standard-error-rescue + * Merge pull request [#956] from maxkwallace/master + * Merge pull request [#960] from kmayer/kmayer-plugins-heroku-restart + * Merge pull request [#969] from frankwong15/master + * Merge pull request [#970] from willnet/delete-blank-document + * Merge pull request [#974] from rocketjob/feature/name_threads + * Merge pull request [#977] from snow/master + * Merge pull request [#981] from zach-chai/patch-1 + * Merge pull request [#993] from scorix/master + +## 3.4.0 / 2016-04-07 + +* 2 minor features: + * Add ability to force threads to stop on shutdown. Fixes [#938] + * Detect and commit seppuku when fork(2) fails. Fixes [#529] + +* 3 unknowns: + * Ignore errors trying to update the backport tables. Fixes [#788] + * Invoke the lowlevel_error in more places to allow for exception tracking. Fixes [#894] + * Update the query string when an absolute URI is used. Fixes [#937] + +* 5 doc fixes: + * Add Process Monitors section to top-level README + * Better document the hooks. Fixes [#840] + * docs/system.md sample config refinements and elaborations + * Fix typos at couple of places. + * Cleanup warnings + +* 3 PRs merged: + * Merge pull request [#945] from dekellum/systemd-docs-refined + * Merge pull request [#946] from vipulnsward/rm-pid + * Merge pull request [#947] from vipulnsward/housekeeping-typos + +## 3.3.0 / 2016-04-05 + +* 2 minor features: + * Allow overriding options of Configuration object + * Rename to inherit_ssl_listener like inherit_tcp|unix + +* 2 doc fixes: + * Add docs/systemd.md (with socket activation sub-section) + * Document UNIX signals with cluster on README.md + +* 3 PRs merged: + * Merge pull request [#936] from prathamesh-sonpatki/allow-overriding-config-options + * Merge pull request [#940] from kyledrake/signalsdoc + * Merge pull request [#942] from dekellum/socket-activate-improve + +## 3.2.0 / 2016-03-20 + +* 1 deprecation removal: + * Delete capistrano.rb + +* 3 bug fixes: + * Detect gems.rb as well as Gemfile + * Simplify and fix logic for directory to use when restarting for all phases + * Speed up phased-restart start + +* 2 PRs merged: + * Merge pull request [#927] from jlecour/gemfile_variants + * Merge pull request [#931] from joneslee85/patch-10 + +## 3.1.1 / 2016-03-17 + +* 4 bug fixes: + * Disable USR1 usage on JRuby + * Fixes [#922] - Correctly define file encoding as UTF-8 + * Set a more explicit SERVER_SOFTWARE Rack variable + * Show RUBY_ENGINE_VERSION if available. Fixes [#923] + +* 3 PRs merged: + * Merge pull request [#912] from tricknotes/fix-allow-failures-in-travis-yml + * Merge pull request [#921] from swrobel/patch-1 + * Merge pull request [#924] from tbrisker/patch-1 + +## 3.1.0 / 2016-03-05 + +* 1 minor feature: + * Add 'import' directive to config file. Fixes [#916] + +* 5 bug fixes: + * Add 'fetch' to options. Fixes [#913] + * Fix jruby daemonization. Fixes [#918] + * Recreate the proper args manually. Fixes [#910] + * Require 'time' to get iso8601. Fixes [#914] + +## 3.0.2 / 2016-02-26 + +* 5 bug fixes: + + * Fix 'undefined local variable or method `pid` for #' when execute pumactl with `--pid` option. + * Fix 'undefined method `windows?` for Puma:Module' when execute pumactl. + * Harden tmp_restart against errors related to the restart file + * Make `plugin :tmp_restart` behavior correct in Windows. + * fix uninitialized constant Puma::ControlCLI::StateFile + +* 3 PRs merged: + + * Merge pull request [#901] from mitto/fix-pumactl-uninitialized-constant-statefile + * Merge pull request [#902] from corrupt952/fix_undefined_method_and_variable_when_execute_pumactl + * Merge pull request [#905] from Eric-Guo/master + +## 3.0.1 / 2016-02-25 + +* 1 bug fix: + + * Removed the experimental support for async.callback as it broke + websockets entirely. Seems no server has both hijack and async.callback + and thus faye is totally confused what to do and doesn't work. + +## 3.0.0 / 2016-02-25 + +* 2 major changes: + + * Ruby pre-2.0 is no longer supported. We'll do our best to not add + features that break those rubies but will no longer be testing + with them. + * Don't log requests by default. Fixes [#852] + +* 2 major features: + + * Plugin support! Plugins can interact with configuration as well + as provide augment server functionality! + * Experimental env['async.callback'] support + +* 4 minor features: + + * Listen to unix socket with provided backlog if any + * Improves the clustered stats to report worker stats + * Pass the env to the lowlevel_error handler. Fixes [#854] + * Treat path-like hosts as unix sockets. Fixes [#824] + +* 5 bug fixes: + + * Clean thread locals when using keepalive. Fixes [#823] + * Cleanup compiler warnings. Fixes [#815] + * Expose closed? for use by the reactor. Fixes [#835] + * Move signal handlers to separate method to prevent space leak. Fixes [#798] + * Signal not full on worker exit [#876] + +* 5 doc fixes: + + * Update README.md with various grammar fixes + * Use newest version of Minitest + * Add directory configuration docs, fix typo [ci skip] + * Remove old COPYING notice. Fixes [#849] + +* 10 merged PRs: + + * Merge pull request [#871] from deepj/travis + * Merge pull request [#874] from wallclockbuilder/master + * Merge pull request [#883] from dadah89/igor/trim_only_worker + * Merge pull request [#884] from uistudio/async-callback + * Merge pull request [#888] from mlarraz/tick_minitest + * Merge pull request [#890] from todd/directory_docs + * Merge pull request [#891] from ctaintor/improve_clustered_status + * Merge pull request [#893] from spastorino/add_missing_require + * Merge pull request [#897] from zendesk/master + * Merge pull request [#899] from kch/kch-readme-fixes + +## 2.16.0 / 2016-01-27 + +* 7 minor features: + + * Add 'set_remote_address' config option + * Allow to run puma in silent mode + * Expose cli options in DSL + * Support passing JRuby keystore info in ssl_bind DSL + * Allow umask for unix:/// style control urls + * Expose `old_worker_count` in stats url + * Support TLS client auth (verify_mode) in jruby + +* 7 bug fixes: + + * Don't persist before_fork hook in state file + * Reload bundler before pulling in rack. Fixes [#859] + * Remove NEWRELIC_DISPATCHER env variable + * Cleanup C code + * Use Timeout.timeout instead of Object.timeout + * Make phased restarts faster + * Ignore the case of certain headers, because HTTP + +* 1 doc changes: + + * Test against the latest Ruby 2.1, 2.2, 2.3, head and JRuby 9.0.4.0 on Travis + +* 12 merged PRs + * Merge pull request [#822] from kwugirl/remove_NEWRELIC_DISPATCHER + * Merge pull request [#833] from joemiller/jruby-client-tls-auth + * Merge pull request [#837] from YuriSolovyov/ssl-keystore-jruby + * Merge pull request [#839] from mezuka/master + * Merge pull request [#845] from deepj/timeout-deprecation + * Merge pull request [#846] from sriedel/strip_before_fork + * Merge pull request [#850] from deepj/travis + * Merge pull request [#853] from Jeffrey6052/patch-1 + * Merge pull request [#857] from zendesk/faster_phased_restarts + * Merge pull request [#858] from mlarraz/fix_some_warnings + * Merge pull request [#860] from zendesk/expose_old_worker_count + * Merge pull request [#861] from zendesk/allow_control_url_umask + +## 2.15.3 / 2015-11-07 + +* 1 bug fix: + + * Fix JRuby parser + +## 2.15.2 / 2015-11-06 + +* 2 bug fixes: + * ext/puma_http11: handle duplicate headers as per RFC + * Only set ctx.ca iff there is a params['ca'] to set with. + +* 2 PRs merged: + * Merge pull request [#818] from unleashed/support-duplicate-headers + * Merge pull request [#819] from VictorLowther/fix-ca-and-verify_null-exception + +## 2.15.1 / 2015-11-06 + +* 1 bug fix: + + * Allow older openssl versions + +## 2.15.0 / 2015-11-06 + +* 6 minor features: + * Allow setting ca without setting a verify mode + * Make jungle for init.d support rbenv + * Use SSL_CTX_use_certificate_chain_file for full chain + * cluster: add worker_boot_timeout option + * configuration: allow empty tags to mean no tag desired + * puma/cli: support specifying STD{OUT,ERR} redirections and append mode + +* 5 bug fixes: + * Disable SSL Compression + * Fix bug setting worker_directory when using a symlink directory + * Fix error message in DSL that was slightly inaccurate + * Pumactl: set correct process name. Fixes [#563] + * thread_pool: fix race condition when shutting down workers + +* 10 doc fixes: + * Add before_fork explanation in Readme.md + * Correct spelling in DEPLOYMENT.md + * Correct spelling in docs/nginx.md + * Fix spelling errors. + * Fix typo in deployment description + * Fix typos (it's -> its) in events.rb and server.rb + * fixing for typo mentioned in [#803] + * Spelling correction for README + * thread_pool: fix typos in comment + * More explicit docs for worker_timeout + +* 18 PRs merged: + * Merge pull request [#768] from nathansamson/patch-1 + * Merge pull request [#773] from rossta/spelling_corrections + * Merge pull request [#774] from snow/master + * Merge pull request [#781] from sunsations/fix-typo + * Merge pull request [#791] from unleashed/allow_empty_tags + * Merge pull request [#793] from robdimarco/fix-working-directory-symlink-bug + * Merge pull request [#794] from peterkeen/patch-1 + * Merge pull request [#795] from unleashed/redirects-from-cmdline + * Merge pull request [#796] from cschneid/fix_dsl_message + * Merge pull request [#799] from annafw/master + * Merge pull request [#800] from liamseanbrady/fix_typo + * Merge pull request [#801] from scottjg/ssl-chain-file + * Merge pull request [#802] from scottjg/ssl-crimes + * Merge pull request [#804] from burningTyger/patch-2 + * Merge pull request [#809] from unleashed/threadpool-fix-race-in-shutdown + * Merge pull request [#810] from vlmonk/fix-pumactl-restart-bug + * Merge pull request [#814] from schneems/schneems/worker_timeout-docs + * Merge pull request [#817] from unleashed/worker-boot-timeout + +## 2.14.0 / 2015-09-18 + +* 1 minor feature: + * Make building with SSL support optional + +* 1 bug fix: + * Use Rack::Builder if available. Fixes [#735] + +## 2.13.4 / 2015-08-16 + +* 1 bug fix: + * Use the environment possible set by the config early and from + the config file later (if set). + +## 2.13.3 / 2015-08-15 + +Seriously, I need to revamp config with tests. + +* 1 bug fix: + * Fix preserving options before cleaning for state. Fixes [#769] + +## 2.13.2 / 2015-08-15 + +The "clearly I don't have enough tests for the config" release. + +* 1 bug fix: + * Fix another place binds wasn't initialized. Fixes [#767] + +## 2.13.1 / 2015-08-15 + +* 2 bug fixes: + * Fix binds being masked in config files. Fixes [#765] + * Use options from the config file properly in pumactl. Fixes [#764] + +## 2.13.0 / 2015-08-14 + +* 1 minor feature: + * Add before_fork hooks option. + +* 3 bug fixes: + * Check for OPENSSL_NO_ECDH before using ECDH + * Eliminate logging overhead from JRuby SSL + * Prefer cli options over config file ones. Fixes [#669] + +* 1 deprecation: + * Add deprecation warning to capistrano.rb. Fixes [#673] + +* 4 PRs merged: + * Merge pull request [#668] from kcollignon/patch-1 + * Merge pull request [#754] from nathansamson/before_boot + * Merge pull request [#759] from BenV/fix-centos6-build + * Merge pull request [#761] from looker/no-log + +## 2.12.3 / 2015-08-03 + +* 8 minor bugs fixed: + * Fix Capistrano 'uninitialized constant Puma' error. + * Fix some ancient and incorrect error handling code + * Fix uninitialized constant error + * Remove toplevel rack interspection, require rack on load instead + * Skip empty parts when chunking + * Switch from inject to each in config_ru_binds iteration + * Wrap SSLv3 spec in version guard. + * ruby 1.8.7 compatibility patches + +* 4 PRs merged: + * Merge pull request [#742] from deivid-rodriguez/fix_missing_require + * Merge pull request [#743] from matthewd/skip-empty-chunks + * Merge pull request [#749] from huacnlee/fix-cap-uninitialized-puma-error + * Merge pull request [#751] from costi/compat_1_8_7 + +* 1 test fix: + * Add 1.8.7, rbx-1 (allow failures) to Travis. + +## 2.12.2 / 2015-07-17 + +* 2 bug fix: + * Pull over and use Rack::URLMap. Fixes [#741] + * Stub out peercert on JRuby for now. Fixes [#739] + +## 2.12.1 / 2015-07-16 + +* 2 bug fixes: + * Use a constant format. Fixes [#737] + * Use strerror for Windows sake. Fixes [#733] + +* 1 doc change: + * typo fix: occured -> occurred + +* 1 PR merged: + * Merge pull request [#736] from paulanunda/paulanunda/typo-fix + +## 2.12.0 / 2015-07-14 + +* 13 bug fixes: + * Add thread reaping to thread pool + * Do not automatically use chunked responses when hijacked + * Do not suppress Content-Length on partial hijack + * Don't allow any exceptions to terminate a thread + * Handle ENOTCONN client disconnects when setting REMOTE_ADDR + * Handle very early exit of cluster mode. Fixes [#722] + * Install rack when running tests on travis to use rack/lint + * Make puma -v and -h return success exit code + * Make pumactl load config/puma.rb by default + * Pass options from pumactl properly when pruning. Fixes [#694] + * Remove rack dependency. Fixes [#705] + * Remove the default Content-Type: text/plain + * Add Client Side Certificate Auth + +* 8 doc/test changes: + * Added example sourcing of environment vars + * Added tests for bind configuration on rackup file + * Fix example config text + * Update DEPLOYMENT.md + * Update Readme with example of custom error handler + * ci: Improve Travis settings + * ci: Start running tests against JRuby 9k on Travis + * ci: Convert to container infrastructure for travisci + +* 2 ops changes: + * Check for system-wide rbenv + * capistrano: Add additional env when start rails + +* 16 PRs merged: + * Merge pull request [#686] from jjb/patch-2 + * Merge pull request [#693] from rob-murray/update-example-config + * Merge pull request [#697] from spk/tests-bind-on-rackup-file + * Merge pull request [#699] from deees/fix/require_rack_builder + * Merge pull request [#701] from deepj/master + * Merge pull request [#702] from Jimdo/thread-reaping + * Merge pull request [#703] from deepj/travis + * Merge pull request [#704] from grega/master + * Merge pull request [#709] from lian/master + * Merge pull request [#711] from julik/master + * Merge pull request [#712] from yakara-ltd/pumactl-default-config + * Merge pull request [#715] from RobotJiang/master + * Merge pull request [#725] from rwz/master + * Merge pull request [#726] from strenuus/handle-client-disconnect + * Merge pull request [#729] from allaire/patch-1 + * Merge pull request [#730] from iamjarvo/container-infrastructure + +## 2.11.3 / 2015-05-18 + +* 5 bug fixes: + * Be sure to unlink tempfiles after a request. Fixes [#690] + * Coerce the key to a string before checking. (thar be symbols). Fixes [#684] + * Fix hang on bad SSL handshake + * Remove `enable_SSLv3` support from JRuby + +* 1 PR merged: + * Merge pull request [#698] from looker/hang-handshake + +## 2.11.2 / 2015-04-11 + +* 2 minor features: + * Add `on_worker_fork` hook, which allows to mimic Unicorn's behavior + * Add shutdown_debug config option + +* 4 bug fixes: + * Fix the Config constants not being available in the DSL. Fixes [#683] + * Ignore multiple port declarations + * Proper 'Connection' header handling compatible with HTTP 1.[01] protocols + * Use "Puma" instead of "puma" to reporting to New Relic + +* 1 doc fixes: + * Add Gitter badge. + +* 6 PRs merged: + * Merge pull request [#657] from schneems/schneems/puma-once-port + * Merge pull request [#658] from Tomohiro/newrelic-dispatcher-default-update + * Merge pull request [#662] from basecrm/connection-compatibility + * Merge pull request [#664] from fxposter/on-worker-fork + * Merge pull request [#667] from JuanitoFatas/doc/gemspec + * Merge pull request [#672] from chulkilee/refactor + +## 2.11.1 / 2015-02-11 + +* 2 bug fixes: + * Avoid crash in strange restart conditions + * Inject the GEM_HOME that bundler into puma-wild's env. Fixes [#653] + +* 2 PRs merged: + * Merge pull request [#644] from bpaquet/master + * Merge pull request [#646] from mkonecny/master + +## 2.11.0 / 2015-01-20 + +* 9 bug fixes: + * Add mode as an additional bind option to unix sockets. Fixes [#630] + * Advertise HTTPS properly after a hot restart + * Don't write lowlevel_error_handler to state + * Fix phased restart with stuck requests + * Handle spaces in the path properly. Fixes [#622] + * Set a default REMOTE_ADDR to avoid using peeraddr on unix sockets. Fixes [#583] + * Skip device number checking on jruby. Fixes [#586] + * Update extconf.rb to compile correctly on OS X + * redirect io right after daemonizing so startup errors are shown. Fixes [#359] + +* 6 minor features: + * Add a configuration option that prevents puma from queueing requests. + * Add reload_worker_directory + * Add the ability to pass environment variables to the init script (for Jungle). + * Add the proctitle tag to the worker. Fixes [#633] + * Infer a proctitle tag based on the directory + * Update lowlevel error message to be more meaningful. + +* 10 PRs merged: + * Merge pull request [#478] from rubencaro/master + * Merge pull request [#610] from kwilczynski/master + * Merge pull request [#611] from jasonl/better-lowlevel-message + * Merge pull request [#616] from jc00ke/master + * Merge pull request [#623] from raldred/patch-1 + * Merge pull request [#628] from rdpoor/master + * Merge pull request [#634] from deepj/master + * Merge pull request [#637] from raskhadafi/patch-1 + * Merge pull request [#639] from ebeigarts/fix-phased-restarts + * Merge pull request [#640] from codehotter/issue-612-dependent-requests-deadlock + +## 2.10.2 / 2014-11-26 + +* 1 bug fix: + * Conditionalize thread local cleaning, fixes perf degradation fix + The code to clean out all Thread locals adds pretty significant + overhead to a each request, so it has to be turned on explicitly + if a user needs it. + +## 2.10.1 / 2014-11-24 + +* 1 bug fix: + * Load the app after daemonizing because the app might start threads. + + This change means errors loading the app are now reported only in the redirected + stdout/stderr. + + If you're app has problems starting up, start it without daemon mode initially + to test. + +## 2.10.0 / 2014-11-23 + +* 3 minor features: + * Added on_worker_shutdown hook mechanism + * Allow binding to ipv6 addresses for ssl URIs + * Warn about any threads started during app preload + +* 5 bug fixes: + * Clean out a threads local data before doing work + * Disable SSLv3. Fixes [#591] + * First change the directory to use the correct Gemfile. + * Only use config.ru binds if specified. Fixes [#606] + * Strongish cipher suite with FS support for some browsers + +* 2 doc changes: + * Change umask examples to more permissive values + * fix typo in README.md + +* 9 Merged PRs: + * Merge pull request [#560] from raskhadafi/prune_bundler-bug + * Merge pull request [#566] from sheltond/master + * Merge pull request [#593] from andruby/patch-1 + * Merge pull request [#594] from hassox/thread-cleanliness + * Merge pull request [#596] from burningTyger/patch-1 + * Merge pull request [#601] from sorentwo/friendly-umask + * Merge pull request [#602] from 1334/patch-1 + * Merge pull request [#608] from Gu1/master + * Merge pull request [#538] from memiux/? + +## 2.9.2 / 2014-10-25 + +* 8 bug fixes: + * Fix puma-wild handling a restart properly. Fixes [#550] + * JRuby SSL POODLE update + * Keep deprecated features warnings + * Log the current time when Puma shuts down. + * Fix cross-platform extension library detection + * Use the correct Windows names for OpenSSL. + * Better error logging during startup + * Fixing sexist error messages + +* 6 PRs merged: + * Merge pull request [#549] from bsnape/log-shutdown-time + * Merge pull request [#553] from lowjoel/master + * Merge pull request [#568] from mariuz/patch-1 + * Merge pull request [#578] from danielbuechele/patch-1 + * Merge pull request [#581] from alexch/slightly-better-logging + * Merge pull request [#590] from looker/jruby_disable_sslv3 + +## 2.9.1 / 2014-09-05 + +* 4 bug fixes: + * Cleanup the SSL related structures properly, fixes memory leak + * Fix thread spawning edge case. + * Force a worker check after a worker boots, don't wait 5sec. Fixes [#574] + * Implement SIGHUP for logs reopening + +* 2 PRs merged: + * Merge pull request [#561] from theoldreader/sighup + * Merge pull request [#570] from havenwood/spawn-thread-edge-case + +## 2.9.0 / 2014-07-12 + +* 1 minor feature: + * Add SSL support for JRuby + +* 3 bug fixes: + * Typo BUNDLER_GEMFILE -> BUNDLE_GEMFILE + * Use fast_write because we can't trust syswrite + * pumactl - do not modify original ARGV + +* 4 doc fixes: + * BSD-3-Clause over BSD to avoid confusion + * Deploy doc: clarification of the GIL + * Fix typo in DEPLOYMENT.md + * Update README.md + +* 6 PRs merged: + * Merge pull request [#520] from misfo/patch-2 + * Merge pull request [#530] from looker/jruby-ssl + * Merge pull request [#537] from vlmonk/patch-1 + * Merge pull request [#540] from allaire/patch-1 + * Merge pull request [#544] from chulkilee/bsd-3-clause + * Merge pull request [#551] from jcxplorer/patch-1 + +## 2.8.2 / 2014-04-12 + +* 4 bug fixes: + * During upgrade, change directory in main process instead of workers. + * Close the client properly on error + * Capistrano: fallback from phased restart to start when not started + * Allow tag option in conf file + +* 4 doc fixes: + * Fix Puma daemon service README typo + * `preload_app!` instead of `preload_app` + * add preload_app and prune_bundler to example config + * allow changing of worker_timeout in config file + +* 11 PRs merged: + * Merge pull request [#487] from ckuttruff/master + * Merge pull request [#492] from ckuttruff/master + * Merge pull request [#493] from alepore/config_tag + * Merge pull request [#503] from mariuz/patch-1 + * Merge pull request [#505] from sammcj/patch-1 + * Merge pull request [#506] from FlavourSys/config_worker_timeout + * Merge pull request [#510] from momer/rescue-block-handle-servers-fix + * Merge pull request [#511] from macool/patch-1 + * Merge pull request [#514] from edogawaconan/refactor_env + * Merge pull request [#517] from misfo/patch-1 + * Merge pull request [#518] from LongMan/master + +## 2.8.1 / 2014-03-06 + +* 1 bug fixes: + * Run puma-wild with proper deps for prune_bundler + +* 2 doc changes: + * Described the configuration file finding behavior added in 2.8.0 and how to disable it. + * Start the deployment doc + +* 6 PRs merged: + * Merge pull request [#471] from arthurnn/fix_test + * Merge pull request [#485] from joneslee85/patch-9 + * Merge pull request [#486] from joshwlewis/patch-1 + * Merge pull request [#490] from tobinibot/patch-1 + * Merge pull request [#491] from brianknight10/clarify-no-config + +## 2.8.0 / 2014-02-28 + +* 8 minor features: + * Add ability to autoload a config file. Fixes [#438] + * Add ability to detect and terminate hung workers. Fixes [#333] + * Add booted_workers to stats response + * Add config to customize the default error message + * Add prune_bundler option + * Add worker indexes, expose them via on_worker_boot. Fixes [#440] + * Add pretty process name + * Show the ruby version in use + +* 7 bug fixes: + * Added 408 status on timeout. + * Be more hostile with sockets that write block. Fixes [#449] + * Expect at_exit to exclusively remove the pidfile. Fixes [#444] + * Expose latency and listen backlog via bind query. Fixes [#370] + * JRuby raises IOError if the socket is there. Fixes [#377] + * Process requests fairly. Fixes [#406] + * Rescue SystemCallError as well. Fixes [#425] + +* 4 doc changes: + * Add 2.1.0 to the matrix + * Add Code Climate badge to README + * Create signals.md + * Set the license to BSD. Fixes [#432] + +* 14 PRs merged: + * Merge pull request [#428] from alexeyfrank/capistrano_default_hooks + * Merge pull request [#429] from namusyaka/revert-const_defined + * Merge pull request [#431] from mrb/master + * Merge pull request [#433] from alepore/process-name + * Merge pull request [#437] from ibrahima/master + * Merge pull request [#446] from sudara/master + * Merge pull request [#451] from pwiebe/status_408 + * Merge pull request [#453] from joevandyk/patch-1 + * Merge pull request [#470] from arthurnn/fix_458 + * Merge pull request [#472] from rubencaro/master + * Merge pull request [#480] from jjb/docs-on-running-test-suite + * Merge pull request [#481] from schneems/master + * Merge pull request [#482] from prathamesh-sonpatki/signals-doc-cleanup + * Merge pull request [#483] from YotpoLtd/master + +## 2.7.1 / 2013-12-05 + +* 1 bug fix: + * Keep STDOUT/STDERR the right mode. Fixes [#422] + +## 2.7.0 / 2013-12-03 + +* 1 minor feature: + * Adding TTIN and TTOU to increment/decrement workers + +* N bug fixes: + * Always use our Process.daemon because it's not busted + * Add capistrano restart failback to start. + * Change position of `cd` so that rvm gemset is loaded + * Clarify some platform specifics + * Do not close the pipe sockets when retrying + * Fix String#byteslice for Ruby 1.9.1, 1.9.2 + * Fix compatibility with 1.8.7. + * Handle IOError closed stream in IO.select + * Increase the max URI path length to 2048 chars from 1024 chars + * Upstart jungle use config/puma.rb instead + +## 2.6.0 / 2013-09-13 + +* 2 minor features: + * Add support for event hooks + ** Add a hook for state transitions + * Add phased restart to capistrano recipe. + +* 4 bug fixes: + * Convince workers to stop by SIGKILL after timeout + * Define RSTRING_NOT_MODIFIED for Rubinius performance + * Handle BrokenPipe, StandardError and IOError in fat_wrote and break out + * Return success status to the invoking environment + +## 2.5.1 / 2013-08-13 + +* 2 bug fixes: + * Keep jruby daemon mode from retrying on a hot restart + * Extract version from const.rb in gemspec + +## 2.5.0 / 2013-08-08 + +* 2 minor features: + * Allow configuring pumactl with config.rb + * make `pumactl restart` start puma if not running + +* 6 bug fixes: + * Autodetect ruby managers and home directory in upstart script + * Convert header values to string before sending. + * Correctly report phased-restart availability + * Fix pidfile creation/deletion race on jruby daemonization + * Use integers when comparing thread counts + * Fix typo in using lopez express (raw tcp) mode + +* 6 misc changes: + * Fix typo in phased-restart response + * Uncomment setuid/setgid by default in upstart + * Use Puma::Const::PUMA_VERSION in gemspec + * Update upstart comments to reflect new commandline + * Remove obsolete pumactl instructions; refer to pumactl for details + * Make Bundler used puma.gemspec version agnostic + +## 2.4.1 / 2013-08-07 + +* 1 experimental feature: + * Support raw tcp servers (aka Lopez Express mode) + +## 2.4.0 / 2013-07-22 + +* 5 minor features: + * Add PUMA_JRUBY_DAEMON_OPTS to get around agent starting twice + * Add ability to drain accept socket on shutdown + * Add port to DSL + * Adds support for using puma config file in capistrano deploys. + * Make phased_restart fallback to restart if not available + +* 10 bug fixes: + + * Be sure to only delete the pid in the master. Fixes [#334] + * Call out -C/--config flags + * Change parser symbol names to avoid clash. Fixes [#179] + * Convert thread pool sizes to integers + * Detect when the jruby daemon child doesn't start properly + * Fix typo in CLI help + * Improve the logging output when hijack is used. Fixes [#332] + * Remove unnecessary thread pool size conversions + * Setup :worker_boot as an Array. Fixes [#317] + * Use 127.0.0.1 as REMOTE_ADDR of unix client. Fixes [#309] + + +## 2.3.2 / 2013-07-08 + +* 1 bug fix: + * Move starting control server to after daemonization. + +## 2.3.1 / 2013-07-06 + +* 2 bug fixes: + * Include the right files in the Manifest. + * Disable inheriting connections on restart on windows. Fixes [#166] + +* 1 doc change: + * Better document some platform constraints + +## 2.3.0 / 2013-07-05 + +* 1 major bug fix: + * Stabilize control server, add support in cluster mode + +* 5 minor bug fixes: + * Add ability to cleanup stale unix sockets + * Check status data better. Fixes [#292] + * Convert raw IO errors to ConnectionError. Fixes [#274] + * Fix sending Content-Type and Content-Length for no body status. Fixes [#304] + * Pass state path through to `pumactl start`. Fixes [#287] + +* 2 internal changes: + * Refactored modes into seperate classes that CLI uses + * Changed CLI to take an Events object instead of stdout/stderr (API change) + +## 2.2.2 / 2013-07-02 + +* 1 bug fix: + * Fix restart_command in the config + +## 2.2.1 / 2013-07-02 + +* 1 minor feature: + * Introduce preload flag + +* 1 bug fix: + * Pass custom restart command in JRuby + +## 2.2.0 / 2013-07-01 + +* 1 major feature: + * Add ability to preload rack app + +* 2 minor bugfixes: + * Don't leak info when not in development. Fixes [#256] + * Load the app, then bind the ports + +## 2.1.1 / 2013-06-20 + +* 2 minor bug fixes: + + * Fix daemonization on jruby + * Load the application before daemonizing. Fixes [#285] + +## 2.1.0 / 2013-06-18 + +* 3 minor features: + * Allow listening socket to be configured via Capistrano variable + * Output results from 'stat's command when using pumactl + * Support systemd socket activation + +* 15 bug fixes: + * Deal with pipes closing while stopping. Fixes [#270] + * Error out early if there is no app configured + * Handle ConnectionError rather than the lowlevel exceptions + * tune with `-C` config file and `on_worker_boot` + * use `-w` + * Fixed some typos in upstart scripts + * Make sure to use bytesize instead of size (MiniSSL write) + * Fix an error in puma-manager.conf + * fix: stop leaking sockets on restart (affects ruby 1.9.3 or before) + * Ignore errors on the cross-thread pipe. Fixes [#246] + * Ignore errors while uncorking the socket (it might already be closed) + * Ignore the body on a HEAD request. Fixes [#278] + * Handle all engine data when possible. Fixes [#251]. + * Handle all read exceptions properly. Fixes [#252] + * Handle errors from the server better + +* 3 doc changes: + * Add note about on_worker_boot hook + * Add some documentation for Clustered mode + * Added quotes to /etc/puma.conf + +## 2.0.1 / 2013-04-30 + +* 1 bug fix: + * Fix not starting on JRuby properly + +## 2.0.0 / 2013-04-29 + +RailsConf 2013 edition! + +* 2 doc changes: + * Start with rackup -s Puma, NOT rackup -s puma. + * Minor doc fixes in the README.md, Capistrano section + +* 2 bug fixes: + * Fix reading RACK_ENV properly. Fixes [#234] + * Make cap recipe handle tmp/sockets; fixes [#228] + +* 3 minor changes: + * Fix capistrano recipe + * Fix stdout/stderr logs to sync outputs + * allow binding to IPv6 addresses + +## 2.0.0.b7 / 2013-03-18 + +* 5 minor enhancements: + * Add -q option for :start + * Add -V, --version + * Add default Rack handler helper + * Upstart support + * Set worker directory from configuration file + +* 12 bug fixes: + * Close the binder in the right place. Fixes [#192] + * Handle early term in workers. Fixes [#206] + * Make sure that the default port is 80 when the request doesn't include HTTP_X_FORWARDED_PROTO. + * Prevent Errno::EBADF errors on restart when running ruby 2.0 + * Record the proper @master_pid + * Respect the header HTTP_X_FORWARDED_PROTO when the host doesn't include a port number. + * Retry EAGAIN/EWOULDBLOCK during syswrite + * Run exec properly to restart. Fixes [#154] + * Set Rack run_once to false + * Syncronize all access to @timeouts. Fixes [#208] + * Write out the state post-daemonize. Fixes [#189] + * Prevent crash when all workers are gone + +## 2.0.0.b6 / 2013-02-06 + +* 2 minor enhancements: + * Add hook for running when a worker boots + * Advertise the Configuration object for apps to use. + +* 1 bug fix: + * Change directory in working during upgrade. Fixes [#185] + +## 2.0.0.b5 / 2013-02-05 + +* 2 major features: + * Add phased worker upgrade + * Add support for the rack hijack protocol + +* 2 minor features: + * Add -R to specify the restart command + * Add config file option to specify the restart command + +* 5 bug fixes: + * Cleanup pipes properly. Fixes [#182] + * Daemonize earlier so that we don't lose app threads. Fixes [#183] + * Drain the notification pipe. Fixes [#176], thanks @cryo28 + * Move write_pid to after we daemonize. Fixes [#180] + * Redirect IO properly and emit message for checkpointing + +## 2.0.0.b4 / 2012-12-12 + +* 4 bug fixes: + * Properly check #syswrite's value for variable sized buffers. Fixes [#170] + * Shutdown status server properly + * Handle char vs byte and mixing syswrite with write properly + * made MiniSSL validate key/cert file existence + +## 2.0.0.b3 / 2012-11-22 + +* 1 bug fix: + * Package right files in gem + +## 2.0.0.b2 / 2012-11-18 +* 5 minor feature: + * Now Puma is bundled with an capistrano recipe. Just require + 'puma/capistrano' in you deploy.rb + * Only inject CommonLogger in development mode + * Add -p option to pumactl + * Add ability to use pumactl to start a server + * Add options to daemonize puma + +* 7 bug fixes: + * Reset the IOBuffer properly. Fixes [#148] + * Shutdown gracefully on JRuby with Ctrl-C + * Various methods to get newrelic to start. Fixes [#128] + * fixing syntax error at capistrano recipe + * Force ECONNRESET when read returns nil + * Be sure to empty the drain the todo before shutting down. Fixes [#155] + * allow for alternate locations for status app + +## 2.0.0.b1 / 2012-09-11 + +* 1 major feature: + * Optional worker process mode (-w) to allow for process scaling in + addition to thread scaling + +* 1 bug fix: + * Introduce Puma::MiniSSL to be able to properly control doing + nonblocking SSL + +NOTE: SSL support in JRuby is not supported at present. Support will +be added back in a future date when a java Puma::MiniSSL is added. + +## 1.6.3 / 2012-09-04 + +* 1 bug fix: + * Close sockets waiting in the reactor when a hot restart is performed + so that browsers reconnect on the next request + +## 1.6.2 / 2012-08-27 + +* 1 bug fix: + * Rescue StandardError instead of IOError to handle SystemCallErrors + as well as other application exceptions inside the reactor. + +## 1.6.1 / 2012-07-23 + +* 1 packaging bug fixed: + * Include missing files + +## 1.6.0 / 2012-07-23 + +* 1 major bug fix: + * Prevent slow clients from starving the server by introducing a + dedicated IO reactor thread. Credit for reporting goes to @meh. + +## 1.5.0 / 2012-07-19 + +* 7 contributors to this release: + * Christian Mayer + * Darío Javier Cravero + * Dirkjan Bussink + * Gianluca Padovani + * Santiago Pastorino + * Thibault Jouan + * tomykaira + +* 6 bug fixes: + * Define RSTRING_NOT_MODIFIED for Rubinius + * Convert status to integer. Fixes [#123] + * Delete pidfile when stopping the server + * Allow compilation with -Werror=format-security option + * Fix wrong HTTP version for a HTTP/1.0 request + * Use String#bytesize instead of String#length + +* 3 minor features: + * Added support for setting RACK_ENV via the CLI, config file, and rack app + * Allow Server#run to run sync. Fixes [#111] + * Puma can now run on windows + +## 1.4.0 / 2012-06-04 + +* 1 bug fix: + * SCRIPT_NAME should be passed from env to allow mounting apps + +* 1 experimental feature: + * Add puma.socket key for direct socket access + +## 1.3.1 / 2012-05-15 + +* 2 bug fixes: + * use #bytesize instead of #length for Content-Length header + * Use StringIO properly. Fixes [#98] + +## 1.3.0 / 2012-05-08 + +* 2 minor features: + * Return valid Rack responses (passes Lint) from status server + * Add -I option to specify $LOAD_PATH directories + +* 4 bug fixes: + * Don't join the server thread inside the signal handle. Fixes [#94] + * Make NullIO#read mimic IO#read + * Only stop the status server if it's started. Fixes [#84] + * Set RACK_ENV early in cli also. Fixes [#78] + +* 1 new contributor: + * Jesse Cooke + +## 1.2.2 / 2012-04-28 + +* 4 bug fixes: + * Report a lowlevel error to stderr + * Set a fallback SERVER_NAME and SERVER_PORT + * Keep the encoding of the body correct. Fixes [#79] + * show error.to_s along with backtrace for low-level error + +## 1.2.1 / 2012-04-11 + +* 1 bug fix: + * Fix rack.url_scheme for SSL servers. Fixes [#65] + +## 1.2.0 / 2012-04-11 + +* 1 major feature: + * When possible, the internal restart does a "hot restart" meaning + the server sockets remains open, so no connections are lost. + +* 1 minor feature: + * More helpful fallback error message + +* 6 bug fixes: + * Pass the proper args to unknown_error. Fixes [#54], [#58] + * Stop the control server before restarting. Fixes [#61] + * Fix reporting https only on a true SSL connection + * Set the default content type to 'text/plain'. Fixes [#63] + * Use REUSEADDR. Fixes [#60] + * Shutdown gracefully on SIGTERM. Fixes [#53] + +* 2 new contributors: + * Seamus Abshere + * Steve Richert + +## 1.1.1 / 2012-03-30 + +* 1 bugfix: + * Include puma/compat.rb in the gem (oops!) + +## 1.1.0 / 2012-03-30 + +* 1 bugfix: + * Make sure that the unix socket has the perms 0777 by default + +* 1 minor feature: + * Add umask param to the unix:// bind to set the umask + +## 1.0.0 / 2012-03-29 + +* Released! + +## Ignore - this is for maintainers to copy-paste during release +## Master + +* Features + * Your feature goes here (#Github Number) + +* Bugfixes + * Your bugfix goes here (#Github Number) + +[#2809]:https://github.com/puma/puma/pull/2809 "PR by @dentarg, merged 2022-01-26" +[#2764]:https://github.com/puma/puma/pull/2764 "PR by @dentarg, merged 2022-01-18" +[#2708]:https://github.com/puma/puma/issues/2708 "Issue by @erikaxel, closed 2022-01-18" +[#2780]:https://github.com/puma/puma/pull/2780 "PR by @dalibor, merged 2022-01-01" +[#2784]:https://github.com/puma/puma/pull/2784 "PR by @MSP-Greg, merged 2022-01-01" +[#2773]:https://github.com/puma/puma/pull/2773 "PR by @ob-stripe, merged 2022-01-01" +[#2794]:https://github.com/puma/puma/pull/2794 "PR by @johnnyshields, merged 2022-01-10" +[#2759]:https://github.com/puma/puma/pull/2759 "PR by @ob-stripe, merged 2021-12-11" +[#2731]:https://github.com/puma/puma/pull/2731 "PR by @baelter, merged 2021-11-02" +[#2341]:https://github.com/puma/puma/issues/2341 "Issue by @cjlarose, closed 2021-11-02" +[#2728]:https://github.com/puma/puma/pull/2728 "PR by @dalibor, merged 2021-10-31" +[#2733]:https://github.com/puma/puma/pull/2733 "PR by @ob-stripe, merged 2021-12-12" +[#2807]:https://github.com/puma/puma/pull/2807 "PR by @MSP-Greg, merged 2022-01-25" +[#2806]:https://github.com/puma/puma/issues/2806 "Issue by @olleolleolle, closed 2022-01-25" +[#2799]:https://github.com/puma/puma/pull/2799 "PR by @ags, merged 2022-01-22" +[#2785]:https://github.com/puma/puma/pull/2785 "PR by @MSP-Greg, merged 2022-01-02" +[#2757]:https://github.com/puma/puma/pull/2757 "PR by @MSP-Greg, merged 2021-11-24" +[#2745]:https://github.com/puma/puma/pull/2745 "PR by @MSP-Greg, merged 2021-11-03" +[#2742]:https://github.com/puma/puma/pull/2742 "PR by @MSP-Greg, merged 2021-12-12" +[#2730]:https://github.com/puma/puma/pull/2730 "PR by @kares, merged 2021-11-01" +[#2702]:https://github.com/puma/puma/pull/2702 "PR by @jacobherrington, merged 2021-09-21" +[#2610]:https://github.com/puma/puma/pull/2610 "PR by @ye-lin-aung, merged 2021-08-18" +[#2257]:https://github.com/puma/puma/issues/2257 "Issue by @nateberkopec, closed 2021-08-18" +[#2654]:https://github.com/puma/puma/pull/2654 "PR by @Roguelazer, merged 2021-09-07" +[#2651]:https://github.com/puma/puma/issues/2651 "Issue by @Roguelazer, closed 2021-09-07" +[#2689]:https://github.com/puma/puma/pull/2689 "PR by @jacobherrington, merged 2021-09-05" +[#2700]:https://github.com/puma/puma/pull/2700 "PR by @ioquatix, merged 2021-09-16" +[#2699]:https://github.com/puma/puma/issues/2699 "Issue by @ioquatix, closed 2021-09-16" +[#2690]:https://github.com/puma/puma/pull/2690 "PR by @doits, merged 2021-09-06" +[#2688]:https://github.com/puma/puma/pull/2688 "PR by @jdelStrother, merged 2021-09-03" +[#2687]:https://github.com/puma/puma/issues/2687 "Issue by @jdelStrother, closed 2021-09-03" +[#2675]:https://github.com/puma/puma/pull/2675 "PR by @devwout, merged 2021-09-08" +[#2657]:https://github.com/puma/puma/pull/2657 "PR by @olivierbellone, merged 2021-07-13" +[#2648]:https://github.com/puma/puma/pull/2648 "PR by @MSP-Greg, merged 2021-06-27" +[#1412]:https://github.com/puma/puma/issues/1412 "Issue by @x-yuri, closed 2021-06-27" +[#2586]:https://github.com/puma/puma/pull/2586 "PR by @MSP-Greg, merged 2021-05-26" +[#2569]:https://github.com/puma/puma/issues/2569 "Issue by @tarragon, closed 2021-05-26" +[#2643]:https://github.com/puma/puma/pull/2643 "PR by @MSP-Greg, merged 2021-06-27" +[#2638]:https://github.com/puma/puma/issues/2638 "Issue by @gingerlime, closed 2021-06-27" +[#2642]:https://github.com/puma/puma/pull/2642 "PR by @MSP-Greg, merged 2021-06-16" +[#2633]:https://github.com/puma/puma/pull/2633 "PR by @onlined, merged 2021-06-04" +[#2656]:https://github.com/puma/puma/pull/2656 "PR by @olivierbellone, merged 2021-07-07" +[#2666]:https://github.com/puma/puma/pull/2666 "PR by @MSP-Greg, merged 2021-07-25" +[#2630]:https://github.com/puma/puma/pull/2630 "PR by @seangoedecke, merged 2021-05-20" +[#2626]:https://github.com/puma/puma/issues/2626 "Issue by @rorymckinley, closed 2021-05-20" +[#2629]:https://github.com/puma/puma/pull/2629 "PR by @ye-lin-aung, merged 2021-05-20" +[#2628]:https://github.com/puma/puma/pull/2628 "PR by @wjordan, merged 2021-05-20" +[#2625]:https://github.com/puma/puma/issues/2625 "Issue by @jarthod, closed 2021-05-11" +[#2564]:https://github.com/puma/puma/pull/2564 "PR by @MSP-Greg, merged 2021-04-24" +[#2526]:https://github.com/puma/puma/issues/2526 "Issue by @nerdrew, closed 2021-04-24" +[#2559]:https://github.com/puma/puma/pull/2559 "PR by @ylecuyer, merged 2021-03-11" +[#2528]:https://github.com/puma/puma/issues/2528 "Issue by @cjlarose, closed 2021-03-11" +[#2565]:https://github.com/puma/puma/pull/2565 "PR by @CGA1123, merged 2021-03-09" +[#2534]:https://github.com/puma/puma/issues/2534 "Issue by @nateberkopec, closed 2021-03-09" +[#2563]:https://github.com/puma/puma/pull/2563 "PR by @MSP-Greg, merged 2021-03-06" +[#2504]:https://github.com/puma/puma/issues/2504 "Issue by @fsateler, closed 2021-03-06" +[#2591]:https://github.com/puma/puma/pull/2591 "PR by @MSP-Greg, merged 2021-05-05" +[#2572]:https://github.com/puma/puma/issues/2572 "Issue by @josefbilendo, closed 2021-05-05" +[#2613]:https://github.com/puma/puma/pull/2613 "PR by @smcgivern, merged 2021-04-27" +[#2605]:https://github.com/puma/puma/pull/2605 "PR by @pascalbetz, merged 2021-04-26" +[#2584]:https://github.com/puma/puma/issues/2584 "Issue by @kaorihinata, closed 2021-04-26" +[#2607]:https://github.com/puma/puma/pull/2607 "PR by @calvinxiao, merged 2021-04-23" +[#2552]:https://github.com/puma/puma/issues/2552 "Issue by @feliperaul, closed 2021-05-24" +[#2606]:https://github.com/puma/puma/pull/2606 "PR by @wjordan, merged 2021-04-20" +[#2574]:https://github.com/puma/puma/issues/2574 "Issue by @darkhelmet, closed 2021-04-20" +[#2567]:https://github.com/puma/puma/pull/2567 "PR by @kddnewton, merged 2021-04-19" +[#2566]:https://github.com/puma/puma/issues/2566 "Issue by @kddnewton, closed 2021-04-19" +[#2596]:https://github.com/puma/puma/pull/2596 "PR by @MSP-Greg, merged 2021-04-18" +[#2588]:https://github.com/puma/puma/pull/2588 "PR by @dentarg, merged 2021-04-02" +[#2556]:https://github.com/puma/puma/issues/2556 "Issue by @gamecreature, closed 2021-04-02" +[#2585]:https://github.com/puma/puma/pull/2585 "PR by @MSP-Greg, merged 2021-03-26" +[#2583]:https://github.com/puma/puma/issues/2583 "Issue by @jboler, closed 2021-03-26" +[#2609]:https://github.com/puma/puma/pull/2609 "PR by @calvinxiao, merged 2021-04-26" +[#2590]:https://github.com/puma/puma/pull/2590 "PR by @calvinxiao, merged 2021-04-05" +[#2600]:https://github.com/puma/puma/pull/2600 "PR by @wjordan, merged 2021-04-30" +[#2579]:https://github.com/puma/puma/pull/2579 "PR by @ghiculescu, merged 2021-03-17" +[#2553]:https://github.com/puma/puma/pull/2553 "PR by @olivierbellone, merged 2021-02-10" +[#2557]:https://github.com/puma/puma/pull/2557 "PR by @cjlarose, merged 2021-02-22" +[#2550]:https://github.com/puma/puma/pull/2550 "PR by @MSP-Greg, merged 2021-02-05" +[#2547]:https://github.com/puma/puma/pull/2547 "PR by @wildmaples, merged 2021-02-03" +[#2543]:https://github.com/puma/puma/pull/2543 "PR by @MSP-Greg, merged 2021-02-01" +[#2549]:https://github.com/puma/puma/pull/2549 "PR by @nmb, merged 2021-02-04" +[#2519]:https://github.com/puma/puma/pull/2519 "PR by @MSP-Greg, merged 2021-01-26" +[#2522]:https://github.com/puma/puma/pull/2522 "PR by @jcmfernandes, merged 2021-01-12" +[#2490]:https://github.com/puma/puma/pull/2490 "PR by @Bonias, merged 2020-12-07" +[#2486]:https://github.com/puma/puma/pull/2486 "PR by @ccverak, merged 2020-12-02" +[#2535]:https://github.com/puma/puma/pull/2535 "PR by @MSP-Greg, merged 2021-01-27" +[#2529]:https://github.com/puma/puma/pull/2529 "PR by @MSP-Greg, merged 2021-01-24" +[#2533]:https://github.com/puma/puma/pull/2533 "PR by @MSP-Greg, merged 2021-01-24" +[#1953]:https://github.com/puma/puma/issues/1953 "Issue by @nateberkopec, closed 2020-12-01" +[#2516]:https://github.com/puma/puma/pull/2516 "PR by @cjlarose, merged 2020-12-17" +[#2520]:https://github.com/puma/puma/pull/2520 "PR by @dentarg, merged 2021-01-04" +[#2521]:https://github.com/puma/puma/pull/2521 "PR by @ojab, merged 2021-01-04" +[#2531]:https://github.com/puma/puma/pull/2531 "PR by @wjordan, merged 2021-01-19" +[#2510]:https://github.com/puma/puma/pull/2510 "PR by @micke, merged 2020-12-10" +[#2472]:https://github.com/puma/puma/pull/2472 "PR by @ccverak, merged 2020-11-02" +[#2438]:https://github.com/puma/puma/pull/2438 "PR by @ekohl, merged 2020-10-26" +[#2406]:https://github.com/puma/puma/pull/2406 "PR by @fdel15, merged 2020-10-19" +[#2449]:https://github.com/puma/puma/pull/2449 "PR by @MSP-Greg, merged 2020-10-28" +[#2362]:https://github.com/puma/puma/pull/2362 "PR by @ekohl, merged 2020-11-10" +[#2485]:https://github.com/puma/puma/pull/2485 "PR by @elct9620, merged 2020-11-18" +[#2489]:https://github.com/puma/puma/pull/2489 "PR by @MSP-Greg, merged 2020-11-27" +[#2487]:https://github.com/puma/puma/pull/2487 "PR by @MSP-Greg, merged 2020-11-17" +[#2477]:https://github.com/puma/puma/pull/2477 "PR by @MSP-Greg, merged 2020-11-16" +[#2475]:https://github.com/puma/puma/pull/2475 "PR by @nateberkopec, merged 2020-11-02" +[#2439]:https://github.com/puma/puma/pull/2439 "PR by @kuei0221, merged 2020-10-26" +[#2460]:https://github.com/puma/puma/pull/2460 "PR by @cjlarose, merged 2020-10-27" +[#2473]:https://github.com/puma/puma/pull/2473 "PR by @cjlarose, merged 2020-11-01" +[#2479]:https://github.com/puma/puma/pull/2479 "PR by @cjlarose, merged 2020-11-10" +[#2495]:https://github.com/puma/puma/pull/2495 "PR by @JuanitoFatas, merged 2020-11-27" +[#2461]:https://github.com/puma/puma/pull/2461 "PR by @cjlarose, merged 2020-10-27" +[#2454]:https://github.com/puma/puma/issues/2454 "Issue by @majksner, closed 2020-10-27" +[#2432]:https://github.com/puma/puma/pull/2432 "PR by @MSP-Greg, merged 2020-10-25" +[#2442]:https://github.com/puma/puma/pull/2442 "PR by @wjordan, merged 2020-10-22" +[#2427]:https://github.com/puma/puma/pull/2427 "PR by @cjlarose, merged 2020-10-20" +[#2018]:https://github.com/puma/puma/issues/2018 "Issue by @gingerlime, closed 2020-10-20" +[#2435]:https://github.com/puma/puma/pull/2435 "PR by @wjordan, merged 2020-10-20" +[#2431]:https://github.com/puma/puma/pull/2431 "PR by @wjordan, merged 2020-10-16" +[#2212]:https://github.com/puma/puma/issues/2212 "Issue by @junaruga, closed 2020-10-16" +[#2409]:https://github.com/puma/puma/pull/2409 "PR by @fliiiix, merged 2020-10-03" +[#2448]:https://github.com/puma/puma/pull/2448 "PR by @MSP-Greg, merged 2020-10-25" +[#2450]:https://github.com/puma/puma/pull/2450 "PR by @MSP-Greg, merged 2020-10-25" +[#2419]:https://github.com/puma/puma/pull/2419 "PR by @MSP-Greg, merged 2020-10-09" +[#2279]:https://github.com/puma/puma/pull/2279 "PR by @wjordan, merged 2020-10-06" +[#2412]:https://github.com/puma/puma/pull/2412 "PR by @MSP-Greg, merged 2020-10-06" +[#2405]:https://github.com/puma/puma/pull/2405 "PR by @MSP-Greg, merged 2020-10-05" +[#2408]:https://github.com/puma/puma/pull/2408 "PR by @fliiiix, merged 2020-10-03" +[#2374]:https://github.com/puma/puma/pull/2374 "PR by @cjlarose, merged 2020-09-29" +[#2389]:https://github.com/puma/puma/pull/2389 "PR by @MSP-Greg, merged 2020-09-29" +[#2381]:https://github.com/puma/puma/pull/2381 "PR by @joergschray, merged 2020-09-24" +[#2271]:https://github.com/puma/puma/pull/2271 "PR by @wjordan, merged 2020-09-24" +[#2377]:https://github.com/puma/puma/pull/2377 "PR by @cjlarose, merged 2020-09-23" +[#2376]:https://github.com/puma/puma/pull/2376 "PR by @alexeevit, merged 2020-09-22" +[#2372]:https://github.com/puma/puma/pull/2372 "PR by @ahorek, merged 2020-09-22" +[#2384]:https://github.com/puma/puma/pull/2384 "PR by @schneems, merged 2020-09-27" +[#2375]:https://github.com/puma/puma/pull/2375 "PR by @MSP-Greg, merged 2020-09-23" +[#2373]:https://github.com/puma/puma/pull/2373 "PR by @MSP-Greg, merged 2020-09-23" +[#2305]:https://github.com/puma/puma/pull/2305 "PR by @MSP-Greg, merged 2020-09-14" +[#2099]:https://github.com/puma/puma/pull/2099 "PR by @wjordan, merged 2020-05-11" +[#2079]:https://github.com/puma/puma/pull/2079 "PR by @ayufan, merged 2020-05-11" +[#2093]:https://github.com/puma/puma/pull/2093 "PR by @schneems, merged 2019-12-18" +[#2256]:https://github.com/puma/puma/pull/2256 "PR by @nateberkopec, merged 2020-05-11" +[#2054]:https://github.com/puma/puma/pull/2054 "PR by @composerinteralia, merged 2019-11-11" +[#2106]:https://github.com/puma/puma/pull/2106 "PR by @ylecuyer, merged 2020-02-11" +[#2167]:https://github.com/puma/puma/pull/2167 "PR by @ChrisBr, closed 2020-07-06" +[#2344]:https://github.com/puma/puma/pull/2344 "PR by @dentarg, merged 2020-08-26" +[#2203]:https://github.com/puma/puma/pull/2203 "PR by @zanker-stripe, merged 2020-03-31" +[#2220]:https://github.com/puma/puma/pull/2220 "PR by @wjordan, merged 2020-04-14" +[#2238]:https://github.com/puma/puma/pull/2238 "PR by @sthirugn, merged 2020-05-07" +[#2086]:https://github.com/puma/puma/pull/2086 "PR by @bdewater, merged 2019-12-17" +[#2253]:https://github.com/puma/puma/pull/2253 "PR by @schneems, merged 2020-05-11" +[#2288]:https://github.com/puma/puma/pull/2288 "PR by @FTLam11, merged 2020-06-02" +[#1487]:https://github.com/puma/puma/pull/1487 "PR by @jxa, merged 2018-05-09" +[#2143]:https://github.com/puma/puma/pull/2143 "PR by @jalevin, merged 2020-04-21" +[#2169]:https://github.com/puma/puma/pull/2169 "PR by @nateberkopec, merged 2020-03-10" +[#2170]:https://github.com/puma/puma/pull/2170 "PR by @nateberkopec, merged 2020-03-10" +[#2076]:https://github.com/puma/puma/pull/2076 "PR by @drews256, merged 2020-02-27" +[#2022]:https://github.com/puma/puma/pull/2022 "PR by @olleolleolle, merged 2019-11-11" +[#2300]:https://github.com/puma/puma/pull/2300 "PR by @alexeevit, merged 2020-07-06" +[#2269]:https://github.com/puma/puma/pull/2269 "PR by @MSP-Greg, merged 2020-08-31" +[#2312]:https://github.com/puma/puma/pull/2312 "PR by @MSP-Greg, merged 2020-07-20" +[#2338]:https://github.com/puma/puma/issues/2338 "Issue by @micahhainlinestitchfix, closed 2020-08-18" +[#2116]:https://github.com/puma/puma/pull/2116 "PR by @MSP-Greg, merged 2020-05-15" +[#2074]:https://github.com/puma/puma/issues/2074 "Issue by @jchristie55332, closed 2020-02-19" +[#2211]:https://github.com/puma/puma/pull/2211 "PR by @MSP-Greg, merged 2020-03-30" +[#2069]:https://github.com/puma/puma/pull/2069 "PR by @MSP-Greg, merged 2019-11-09" +[#2112]:https://github.com/puma/puma/pull/2112 "PR by @wjordan, merged 2020-03-03" +[#1893]:https://github.com/puma/puma/pull/1893 "PR by @seven1m, merged 2020-02-18" +[#2119]:https://github.com/puma/puma/pull/2119 "PR by @wjordan, merged 2020-02-20" +[#2121]:https://github.com/puma/puma/pull/2121 "PR by @wjordan, merged 2020-02-21" +[#2154]:https://github.com/puma/puma/pull/2154 "PR by @cjlarose, merged 2020-03-10" +[#1551]:https://github.com/puma/puma/issues/1551 "Issue by @austinthecoder, closed 2020-03-10" +[#2198]:https://github.com/puma/puma/pull/2198 "PR by @eregon, merged 2020-03-24" +[#2216]:https://github.com/puma/puma/pull/2216 "PR by @praboud-stripe, merged 2020-04-06" +[#2122]:https://github.com/puma/puma/pull/2122 "PR by @wjordan, merged 2020-04-10" +[#2177]:https://github.com/puma/puma/issues/2177 "Issue by @GuiTeK, closed 2020-04-08" +[#2221]:https://github.com/puma/puma/pull/2221 "PR by @wjordan, merged 2020-04-17" +[#2233]:https://github.com/puma/puma/pull/2233 "PR by @ayufan, merged 2020-04-25" +[#2234]:https://github.com/puma/puma/pull/2234 "PR by @wjordan, merged 2020-04-30" +[#2225]:https://github.com/puma/puma/issues/2225 "Issue by @nateberkopec, closed 2020-04-27" +[#2267]:https://github.com/puma/puma/pull/2267 "PR by @wjordan, merged 2020-05-20" +[#2287]:https://github.com/puma/puma/pull/2287 "PR by @eugeneius, merged 2020-05-31" +[#2317]:https://github.com/puma/puma/pull/2317 "PR by @MSP-Greg, merged 2020-09-01" +[#2319]:https://github.com/puma/puma/issues/2319 "Issue by @AlexWayfer, closed 2020-09-03" +[#2326]:https://github.com/puma/puma/pull/2326 "PR by @rkistner, closed 2020-09-04" +[#2299]:https://github.com/puma/puma/issues/2299 "Issue by @JohnPhillips31416, closed 2020-09-17" +[#2095]:https://github.com/puma/puma/pull/2095 "PR by @bdewater, merged 2019-12-25" +[#2102]:https://github.com/puma/puma/pull/2102 "PR by @bdewater, merged 2020-02-07" +[#2111]:https://github.com/puma/puma/pull/2111 "PR by @wjordan, merged 2020-02-20" +[#1980]:https://github.com/puma/puma/pull/1980 "PR by @nateberkopec, merged 2020-02-27" +[#2189]:https://github.com/puma/puma/pull/2189 "PR by @jkowens, merged 2020-03-19" +[#2124]:https://github.com/puma/puma/pull/2124 "PR by @wjordan, merged 2020-04-14" +[#2223]:https://github.com/puma/puma/pull/2223 "PR by @wjordan, merged 2020-04-20" +[#2239]:https://github.com/puma/puma/pull/2239 "PR by @wjordan, merged 2020-05-15" +[#2496]:https://github.com/puma/puma/pull/2496 "PR by @TheRusskiy, merged 2020-11-30" +[#2304]:https://github.com/puma/puma/issues/2304 "Issue by @mpeltomaa, closed 2020-09-05" +[#2132]:https://github.com/puma/puma/issues/2132 "Issue by @bmclean, closed 2020-02-28" +[#2010]:https://github.com/puma/puma/pull/2010 "PR by @nateberkopec, merged 2019-10-07" +[#2012]:https://github.com/puma/puma/pull/2012 "PR by @headius, merged 2019-10-07" +[#2046]:https://github.com/puma/puma/pull/2046 "PR by @composerinteralia, merged 2019-10-21" +[#2052]:https://github.com/puma/puma/pull/2052 "PR by @composerinteralia, merged 2019-11-02" +[#1564]:https://github.com/puma/puma/issues/1564 "Issue by @perlun, closed 2019-10-07" +[#2035]:https://github.com/puma/puma/pull/2035 "PR by @AndrewSpeed, merged 2019-10-18" +[#2048]:https://github.com/puma/puma/pull/2048 "PR by @hahmed, merged 2019-10-21" +[#2050]:https://github.com/puma/puma/pull/2050 "PR by @olleolleolle, merged 2019-10-25" +[#1842]:https://github.com/puma/puma/issues/1842 "Issue by @nateberkopec, closed 2019-09-18" +[#1988]:https://github.com/puma/puma/issues/1988 "Issue by @mcg, closed 2019-10-01" +[#1986]:https://github.com/puma/puma/issues/1986 "Issue by @flaminestone, closed 2019-10-01" +[#1994]:https://github.com/puma/puma/issues/1994 "Issue by @LimeBlast, closed 2019-10-01" +[#2006]:https://github.com/puma/puma/pull/2006 "PR by @nateberkopec, merged 2019-10-01" +[#1222]:https://github.com/puma/puma/issues/1222 "Issue by @seanmckinley, closed 2019-10-04" +[#1885]:https://github.com/puma/puma/pull/1885 "PR by @spk, merged 2019-08-10" +[#1934]:https://github.com/puma/puma/pull/1934 "PR by @zarelit, merged 2019-08-28" +[#1105]:https://github.com/puma/puma/pull/1105 "PR by @daveallie, merged 2019-09-02" +[#1786]:https://github.com/puma/puma/pull/1786 "PR by @evanphx, merged 2019-09-11" +[#1320]:https://github.com/puma/puma/pull/1320 "PR by @nateberkopec, merged 2019-09-12" +[#1968]:https://github.com/puma/puma/pull/1968 "PR by @nateberkopec, merged 2019-09-15" +[#1908]:https://github.com/puma/puma/pull/1908 "PR by @MSP-Greg, merged 2019-08-23" +[#1952]:https://github.com/puma/puma/pull/1952 "PR by @MSP-Greg, merged 2019-09-19" +[#1941]:https://github.com/puma/puma/pull/1941 "PR by @MSP-Greg, merged 2019-09-02" +[#1961]:https://github.com/puma/puma/pull/1961 "PR by @nateberkopec, merged 2019-09-11" +[#1970]:https://github.com/puma/puma/pull/1970 "PR by @MSP-Greg, merged 2019-09-18" +[#1946]:https://github.com/puma/puma/pull/1946 "PR by @nateberkopec, merged 2019-09-02" +[#1831]:https://github.com/puma/puma/pull/1831 "PR by @spk, merged 2019-07-27" +[#1816]:https://github.com/puma/puma/pull/1816 "PR by @ylecuyer, merged 2019-08-01" +[#1844]:https://github.com/puma/puma/pull/1844 "PR by @ylecuyer, merged 2019-08-01" +[#1836]:https://github.com/puma/puma/pull/1836 "PR by @MSP-Greg, merged 2019-08-06" +[#1887]:https://github.com/puma/puma/pull/1887 "PR by @MSP-Greg, merged 2019-08-06" +[#1812]:https://github.com/puma/puma/pull/1812 "PR by @kou, merged 2019-08-03" +[#1491]:https://github.com/puma/puma/pull/1491 "PR by @olleolleolle, merged 2019-07-17" +[#1837]:https://github.com/puma/puma/pull/1837 "PR by @montanalow, merged 2019-07-25" +[#1857]:https://github.com/puma/puma/pull/1857 "PR by @Jesus, merged 2019-08-03" +[#1822]:https://github.com/puma/puma/pull/1822 "PR by @Jesus, merged 2019-08-01" +[#1863]:https://github.com/puma/puma/pull/1863 "PR by @dzunk, merged 2019-08-04" +[#1838]:https://github.com/puma/puma/pull/1838 "PR by @bogn83, merged 2019-07-14" +[#1882]:https://github.com/puma/puma/pull/1882 "PR by @okuramasafumi, merged 2019-08-06" +[#1848]:https://github.com/puma/puma/pull/1848 "PR by @nateberkopec, merged 2019-07-16" +[#1847]:https://github.com/puma/puma/pull/1847 "PR by @nateberkopec, merged 2019-07-16" +[#1846]:https://github.com/puma/puma/pull/1846 "PR by @nateberkopec, merged 2019-07-16" +[#1853]:https://github.com/puma/puma/pull/1853 "PR by @Jesus, merged 2019-07-18" +[#1850]:https://github.com/puma/puma/pull/1850 "PR by @nateberkopec, merged 2019-07-27" +[#1866]:https://github.com/puma/puma/pull/1866 "PR by @josacar, merged 2019-07-28" +[#1870]:https://github.com/puma/puma/pull/1870 "PR by @MSP-Greg, merged 2019-07-30" +[#1872]:https://github.com/puma/puma/pull/1872 "PR by @MSP-Greg, merged 2019-07-30" +[#1833]:https://github.com/puma/puma/issues/1833 "Issue by @julik, closed 2019-07-09" +[#1888]:https://github.com/puma/puma/pull/1888 "PR by @ClikeX, merged 2019-08-06" +[#1829]:https://github.com/puma/puma/pull/1829 "PR by @Fudoshiki, merged 2019-07-09" +[#1832]:https://github.com/puma/puma/pull/1832 "PR by @MSP-Greg, merged 2019-07-08" +[#1827]:https://github.com/puma/puma/pull/1827 "PR by @amrrbakry, merged 2019-06-27" +[#1562]:https://github.com/puma/puma/pull/1562 "PR by @skrobul, merged 2019-02-20" +[#1569]:https://github.com/puma/puma/pull/1569 "PR by @rianmcguire, merged 2019-02-20" +[#1648]:https://github.com/puma/puma/pull/1648 "PR by @wjordan, merged 2019-02-20" +[#1691]:https://github.com/puma/puma/pull/1691 "PR by @kares, merged 2019-02-20" +[#1716]:https://github.com/puma/puma/pull/1716 "PR by @mdkent, merged 2019-02-20" +[#1690]:https://github.com/puma/puma/pull/1690 "PR by @mic-kul, merged 2019-03-11" +[#1689]:https://github.com/puma/puma/pull/1689 "PR by @michaelherold, merged 2019-03-11" +[#1728]:https://github.com/puma/puma/pull/1728 "PR by @evanphx, merged 2019-03-20" +[#1824]:https://github.com/puma/puma/pull/1824 "PR by @spk, merged 2019-06-24" +[#1685]:https://github.com/puma/puma/pull/1685 "PR by @mainameiz, merged 2019-02-20" +[#1808]:https://github.com/puma/puma/pull/1808 "PR by @schneems, merged 2019-06-10" +[#1508]:https://github.com/puma/puma/pull/1508 "PR by @florin555, merged 2019-02-20" +[#1650]:https://github.com/puma/puma/pull/1650 "PR by @adam101, merged 2019-02-20" +[#1655]:https://github.com/puma/puma/pull/1655 "PR by @mipearson, merged 2019-02-20" +[#1671]:https://github.com/puma/puma/pull/1671 "PR by @eric-norcross, merged 2019-02-20" +[#1583]:https://github.com/puma/puma/pull/1583 "PR by @chwevans, merged 2019-02-20" +[#1773]:https://github.com/puma/puma/pull/1773 "PR by @enebo, merged 2019-04-14" +[#1731]:https://github.com/puma/puma/issues/1731 "Issue by @Fudoshiki, closed 2019-03-20" +[#1803]:https://github.com/puma/puma/pull/1803 "PR by @Jesus, merged 2019-05-28" +[#1741]:https://github.com/puma/puma/pull/1741 "PR by @MSP-Greg, merged 2019-03-19" +[#1674]:https://github.com/puma/puma/issues/1674 "Issue by @atitan, closed 2019-06-12" +[#1720]:https://github.com/puma/puma/issues/1720 "Issue by @voxik, closed 2019-03-20" +[#1730]:https://github.com/puma/puma/issues/1730 "Issue by @nearapogee, closed 2019-07-16" +[#1755]:https://github.com/puma/puma/issues/1755 "Issue by @vbalazs, closed 2019-07-26" +[#1649]:https://github.com/puma/puma/pull/1649 "PR by @schneems, merged 2018-10-17" +[#1607]:https://github.com/puma/puma/pull/1607 "PR by @harmdewit, merged 2018-08-15" +[#1700]:https://github.com/puma/puma/pull/1700 "PR by @schneems, merged 2019-01-05" +[#1630]:https://github.com/puma/puma/pull/1630 "PR by @eregon, merged 2018-09-11" +[#1478]:https://github.com/puma/puma/pull/1478 "PR by @eallison91, merged 2018-05-09" +[#1604]:https://github.com/puma/puma/pull/1604 "PR by @schneems, merged 2018-07-02" +[#1579]:https://github.com/puma/puma/pull/1579 "PR by @schneems, merged 2018-06-14" +[#1506]:https://github.com/puma/puma/pull/1506 "PR by @dekellum, merged 2018-05-09" +[#1563]:https://github.com/puma/puma/pull/1563 "PR by @dannyfallon, merged 2018-05-01" +[#1557]:https://github.com/puma/puma/pull/1557 "PR by @swrobel, merged 2018-05-09" +[#1529]:https://github.com/puma/puma/pull/1529 "PR by @desnudopenguino, merged 2018-03-20" +[#1532]:https://github.com/puma/puma/pull/1532 "PR by @schneems, merged 2018-03-21" +[#1482]:https://github.com/puma/puma/pull/1482 "PR by @shayonj, merged 2018-03-19" +[#1511]:https://github.com/puma/puma/pull/1511 "PR by @jemiam, merged 2018-03-19" +[#1545]:https://github.com/puma/puma/pull/1545 "PR by @hoshinotsuyoshi, merged 2018-03-28" +[#1550]:https://github.com/puma/puma/pull/1550 "PR by @eileencodes, merged 2018-03-29" +[#1553]:https://github.com/puma/puma/pull/1553 "PR by @eugeneius, merged 2018-04-02" +[#1510]:https://github.com/puma/puma/issues/1510 "Issue by @vincentwoo, closed 2018-03-06" +[#1524]:https://github.com/puma/puma/pull/1524 "PR by @tuwukee, closed 2018-03-06" +[#1507]:https://github.com/puma/puma/issues/1507 "Issue by @vincentwoo, closed 2018-03-19" +[#1483]:https://github.com/puma/puma/issues/1483 "Issue by @igravious, closed 2018-03-06" +[#1502]:https://github.com/puma/puma/issues/1502 "Issue by @vincentwoo, closed 2020-03-09" +[#1403]:https://github.com/puma/puma/pull/1403 "PR by @eileencodes, merged 2017-10-04" +[#1435]:https://github.com/puma/puma/pull/1435 "PR by @juliancheal, merged 2017-10-11" +[#1340]:https://github.com/puma/puma/pull/1340 "PR by @ViliusLuneckas, merged 2017-10-16" +[#1434]:https://github.com/puma/puma/pull/1434 "PR by @jumbosushi, merged 2017-10-10" +[#1436]:https://github.com/puma/puma/pull/1436 "PR by @luislavena, merged 2017-10-11" +[#1418]:https://github.com/puma/puma/pull/1418 "PR by @eileencodes, merged 2017-09-22" +[#1416]:https://github.com/puma/puma/pull/1416 "PR by @hiimtaylorjones, merged 2017-09-22" +[#1409]:https://github.com/puma/puma/pull/1409 "PR by @olleolleolle, merged 2017-09-13" +[#1427]:https://github.com/puma/puma/issues/1427 "Issue by @garybernhardt, closed 2017-10-04" +[#1430]:https://github.com/puma/puma/pull/1430 "PR by @MSP-Greg, merged 2017-10-09" +[#1429]:https://github.com/puma/puma/pull/1429 "PR by @perlun, merged 2017-10-09" +[#1455]:https://github.com/puma/puma/pull/1455 "PR by @perlun, merged 2017-11-16" +[#1425]:https://github.com/puma/puma/pull/1425 "PR by @vizcay, merged 2017-10-01" +[#1452]:https://github.com/puma/puma/pull/1452 "PR by @eprothro, merged 2017-11-16" +[#1439]:https://github.com/puma/puma/pull/1439 "PR by @MSP-Greg, merged 2017-10-16" +[#1442]:https://github.com/puma/puma/pull/1442 "PR by @MSP-Greg, merged 2017-10-19" +[#1464]:https://github.com/puma/puma/pull/1464 "PR by @MSP-Greg, merged 2017-11-20" +[#1384]:https://github.com/puma/puma/pull/1384 "PR by @noahgibbs, merged 2017-08-03" +[#1111]:https://github.com/puma/puma/pull/1111 "PR by @alexlance, merged 2017-06-04" +[#1392]:https://github.com/puma/puma/pull/1392 "PR by @hoffm, merged 2017-08-11" +[#1347]:https://github.com/puma/puma/pull/1347 "PR by @NikolayRys, merged 2017-06-28" +[#1334]:https://github.com/puma/puma/pull/1334 "PR by @respire, merged 2017-06-13" +[#1383]:https://github.com/puma/puma/pull/1383 "PR by @schneems, merged 2017-08-02" +[#1368]:https://github.com/puma/puma/pull/1368 "PR by @bongole, merged 2017-08-03" +[#1318]:https://github.com/puma/puma/pull/1318 "PR by @nateberkopec, merged 2017-08-03" +[#1376]:https://github.com/puma/puma/pull/1376 "PR by @pat, merged 2017-08-03" +[#1388]:https://github.com/puma/puma/pull/1388 "PR by @nateberkopec, merged 2017-08-08" +[#1390]:https://github.com/puma/puma/pull/1390 "PR by @junaruga, merged 2017-08-16" +[#1391]:https://github.com/puma/puma/pull/1391 "PR by @junaruga, merged 2017-08-16" +[#1385]:https://github.com/puma/puma/pull/1385 "PR by @grosser, merged 2017-08-16" +[#1377]:https://github.com/puma/puma/pull/1377 "PR by @shayonj, merged 2017-08-16" +[#1337]:https://github.com/puma/puma/pull/1337 "PR by @shayonj, merged 2017-08-16" +[#1325]:https://github.com/puma/puma/pull/1325 "PR by @palkan, merged 2017-06-04" +[#1395]:https://github.com/puma/puma/pull/1395 "PR by @junaruga, merged 2017-08-16" +[#1367]:https://github.com/puma/puma/issues/1367 "Issue by @dekellum, closed 2017-08-17" +[#1314]:https://github.com/puma/puma/pull/1314 "PR by @grosser, merged 2017-06-02" +[#1311]:https://github.com/puma/puma/pull/1311 "PR by @grosser, merged 2017-06-02" +[#1313]:https://github.com/puma/puma/pull/1313 "PR by @grosser, merged 2017-06-03" +[#1260]:https://github.com/puma/puma/pull/1260 "PR by @grosser, merged 2017-04-11" +[#1278]:https://github.com/puma/puma/pull/1278 "PR by @evanphx, merged 2017-04-28" +[#1306]:https://github.com/puma/puma/pull/1306 "PR by @jules2689, merged 2017-05-31" +[#1274]:https://github.com/puma/puma/pull/1274 "PR by @evanphx, merged 2017-05-01" +[#1261]:https://github.com/puma/puma/pull/1261 "PR by @jacksonrayhamilton, merged 2017-04-07" +[#1259]:https://github.com/puma/puma/pull/1259 "PR by @jacksonrayhamilton, merged 2017-04-07" +[#1248]:https://github.com/puma/puma/pull/1248 "PR by @davidarnold, merged 2017-04-18" +[#1277]:https://github.com/puma/puma/pull/1277 "PR by @schneems, merged 2017-05-01" +[#1290]:https://github.com/puma/puma/pull/1290 "PR by @schneems, merged 2017-05-12" +[#1285]:https://github.com/puma/puma/pull/1285 "PR by @fmauNeko, merged 2017-05-12" +[#1282]:https://github.com/puma/puma/pull/1282 "PR by @grosser, merged 2017-05-09" +[#1294]:https://github.com/puma/puma/pull/1294 "PR by @masry707, merged 2017-05-15" +[#1206]:https://github.com/puma/puma/pull/1206 "PR by @NikolayRys, closed 2017-06-27" +[#1241]:https://github.com/puma/puma/issues/1241 "Issue by @renchap, closed 2017-03-14" +[#1239]:https://github.com/puma/puma/pull/1239 "PR by @schneems, merged 2017-03-10" +[#1234]:https://github.com/puma/puma/pull/1234 "PR by @schneems, merged 2017-03-09" +[#1226]:https://github.com/puma/puma/pull/1226 "PR by @eileencodes, merged 2017-03-09" +[#1227]:https://github.com/puma/puma/pull/1227 "PR by @sirupsen, merged 2017-02-27" +[#1213]:https://github.com/puma/puma/pull/1213 "PR by @junaruga, merged 2017-02-28" +[#1182]:https://github.com/puma/puma/issues/1182 "Issue by @brunowego, closed 2017-02-09" +[#1203]:https://github.com/puma/puma/pull/1203 "PR by @twalpole, merged 2017-02-09" +[#1129]:https://github.com/puma/puma/pull/1129 "PR by @chtitux, merged 2016-12-12" +[#1165]:https://github.com/puma/puma/pull/1165 "PR by @sriedel, merged 2016-12-21" +[#1175]:https://github.com/puma/puma/pull/1175 "PR by @jemiam, merged 2016-12-21" +[#1068]:https://github.com/puma/puma/pull/1068 "PR by @junaruga, merged 2016-09-05" +[#1091]:https://github.com/puma/puma/pull/1091 "PR by @frodsan, merged 2016-09-17" +[#1088]:https://github.com/puma/puma/pull/1088 "PR by @frodsan, merged 2016-11-20" +[#1160]:https://github.com/puma/puma/pull/1160 "PR by @frodsan, merged 2016-11-24" +[#1169]:https://github.com/puma/puma/pull/1169 "PR by @scbrubaker02, merged 2016-12-12" +[#1061]:https://github.com/puma/puma/pull/1061 "PR by @michaelsauter, merged 2016-09-05" +[#1036]:https://github.com/puma/puma/issues/1036 "Issue by @matobinder, closed 2016-08-03" +[#1120]:https://github.com/puma/puma/pull/1120 "PR by @prathamesh-sonpatki, merged 2016-11-21" +[#1178]:https://github.com/puma/puma/pull/1178 "PR by @Koronen, merged 2016-12-21" +[#1002]:https://github.com/puma/puma/issues/1002 "Issue by @mattyb, closed 2016-07-26" +[#1063]:https://github.com/puma/puma/issues/1063 "Issue by @mperham, closed 2016-09-05" +[#1089]:https://github.com/puma/puma/issues/1089 "Issue by @AdamBialas, closed 2016-09-17" +[#1114]:https://github.com/puma/puma/pull/1114 "PR by @sj26, merged 2016-12-13" +[#1110]:https://github.com/puma/puma/pull/1110 "PR by @montdidier, merged 2016-12-12" +[#1135]:https://github.com/puma/puma/pull/1135 "PR by @jkraemer, merged 2016-11-19" +[#1081]:https://github.com/puma/puma/pull/1081 "PR by @frodsan, merged 2016-09-08" +[#1138]:https://github.com/puma/puma/pull/1138 "PR by @steakknife, merged 2016-12-13" +[#1118]:https://github.com/puma/puma/pull/1118 "PR by @hiroara, merged 2016-11-20" +[#1075]:https://github.com/puma/puma/issues/1075 "Issue by @pvalena, closed 2016-09-06" +[#932]:https://github.com/puma/puma/issues/932 "Issue by @everplays, closed 2016-07-24" +[#519]:https://github.com/puma/puma/issues/519 "Issue by @tmornini, closed 2016-07-25" +[#828]:https://github.com/puma/puma/issues/828 "Issue by @Zapotek, closed 2016-07-24" +[#984]:https://github.com/puma/puma/issues/984 "Issue by @erichmenge, closed 2016-07-24" +[#1028]:https://github.com/puma/puma/issues/1028 "Issue by @matobinder, closed 2016-07-24" +[#1023]:https://github.com/puma/puma/issues/1023 "Issue by @fera2k, closed 2016-07-24" +[#1027]:https://github.com/puma/puma/issues/1027 "Issue by @rosenfeld, closed 2016-07-24" +[#925]:https://github.com/puma/puma/issues/925 "Issue by @lokenmakwana, closed 2016-07-24" +[#911]:https://github.com/puma/puma/issues/911 "Issue by @veganstraightedge, closed 2016-07-24" +[#620]:https://github.com/puma/puma/issues/620 "Issue by @javanthropus, closed 2016-07-25" +[#778]:https://github.com/puma/puma/issues/778 "Issue by @niedhui, closed 2016-07-24" +[#1021]:https://github.com/puma/puma/pull/1021 "PR by @sarahzrf, merged 2016-07-20" +[#1022]:https://github.com/puma/puma/issues/1022 "Issue by @AKovtunov, closed 2017-08-16" +[#958]:https://github.com/puma/puma/issues/958 "Issue by @lalitlogical, closed 2016-04-23" +[#782]:https://github.com/puma/puma/issues/782 "Issue by @Tonkpils, closed 2016-07-19" +[#1010]:https://github.com/puma/puma/issues/1010 "Issue by @mneumark, closed 2016-07-19" +[#959]:https://github.com/puma/puma/issues/959 "Issue by @mwpastore, closed 2016-04-22" +[#840]:https://github.com/puma/puma/issues/840 "Issue by @maxkwallace, closed 2016-04-07" +[#1007]:https://github.com/puma/puma/pull/1007 "PR by @willnet, merged 2016-06-24" +[#1014]:https://github.com/puma/puma/pull/1014 "PR by @szymon-jez, merged 2016-07-11" +[#1015]:https://github.com/puma/puma/pull/1015 "PR by @bf4, merged 2016-07-19" +[#1017]:https://github.com/puma/puma/pull/1017 "PR by @jorihardman, merged 2016-07-19" +[#954]:https://github.com/puma/puma/pull/954 "PR by @jf, merged 2016-04-12" +[#955]:https://github.com/puma/puma/pull/955 "PR by @jf, merged 2016-04-22" +[#956]:https://github.com/puma/puma/pull/956 "PR by @maxkwallace, merged 2016-04-12" +[#960]:https://github.com/puma/puma/pull/960 "PR by @kmayer, merged 2016-04-15" +[#969]:https://github.com/puma/puma/pull/969 "PR by @frankwong15, merged 2016-05-10" +[#970]:https://github.com/puma/puma/pull/970 "PR by @willnet, merged 2016-04-26" +[#974]:https://github.com/puma/puma/pull/974 "PR by @reidmorrison, merged 2016-05-10" +[#977]:https://github.com/puma/puma/pull/977 "PR by @snow, merged 2016-05-10" +[#981]:https://github.com/puma/puma/pull/981 "PR by @zach-chai, merged 2016-07-19" +[#993]:https://github.com/puma/puma/pull/993 "PR by @scorix, merged 2016-07-19" +[#938]:https://github.com/puma/puma/issues/938 "Issue by @vandrijevik, closed 2016-04-07" +[#529]:https://github.com/puma/puma/issues/529 "Issue by @mperham, closed 2016-04-07" +[#788]:https://github.com/puma/puma/issues/788 "Issue by @herregroen, closed 2016-04-07" +[#894]:https://github.com/puma/puma/issues/894 "Issue by @rafbm, closed 2016-04-07" +[#937]:https://github.com/puma/puma/issues/937 "Issue by @huangxiangdan, closed 2016-04-07" +[#945]:https://github.com/puma/puma/pull/945 "PR by @dekellum, merged 2016-04-07" +[#946]:https://github.com/puma/puma/pull/946 "PR by @vipulnsward, merged 2016-04-07" +[#947]:https://github.com/puma/puma/pull/947 "PR by @vipulnsward, merged 2016-04-07" +[#936]:https://github.com/puma/puma/pull/936 "PR by @prathamesh-sonpatki, merged 2016-04-01" +[#940]:https://github.com/puma/puma/pull/940 "PR by @kyledrake, merged 2016-04-01" +[#942]:https://github.com/puma/puma/pull/942 "PR by @dekellum, merged 2016-04-01" +[#927]:https://github.com/puma/puma/pull/927 "PR by @jlecour, merged 2016-03-18" +[#931]:https://github.com/puma/puma/pull/931 "PR by @runlevel5, merged 2016-03-18" +[#922]:https://github.com/puma/puma/issues/922 "Issue by @LavirtheWhiolet, closed 2016-03-07" +[#923]:https://github.com/puma/puma/issues/923 "Issue by @donv, closed 2016-03-06" +[#912]:https://github.com/puma/puma/pull/912 "PR by @tricknotes, merged 2016-03-06" +[#921]:https://github.com/puma/puma/pull/921 "PR by @swrobel, merged 2016-03-06" +[#924]:https://github.com/puma/puma/pull/924 "PR by @tbrisker, merged 2016-03-07" +[#916]:https://github.com/puma/puma/issues/916 "Issue by @ma11hew28, closed 2016-03-06" +[#913]:https://github.com/puma/puma/issues/913 "Issue by @Casara, closed 2016-03-06" +[#918]:https://github.com/puma/puma/issues/918 "Issue by @rodrigdav, closed 2016-03-06" +[#910]:https://github.com/puma/puma/issues/910 "Issue by @ball-hayden, closed 2016-03-05" +[#914]:https://github.com/puma/puma/issues/914 "Issue by @osheroff, closed 2016-03-06" +[#901]:https://github.com/puma/puma/pull/901 "PR by @mitto, merged 2016-02-26" +[#902]:https://github.com/puma/puma/pull/902 "PR by @corrupt952, merged 2016-02-26" +[#905]:https://github.com/puma/puma/pull/905 "PR by @Eric-Guo, merged 2016-02-26" +[#852]:https://github.com/puma/puma/issues/852 "Issue by @asia653, closed 2016-02-25" +[#854]:https://github.com/puma/puma/issues/854 "Issue by @ollym, closed 2016-02-25" +[#824]:https://github.com/puma/puma/issues/824 "Issue by @MattWalston, closed 2016-02-25" +[#823]:https://github.com/puma/puma/issues/823 "Issue by @pneuman, closed 2016-02-25" +[#815]:https://github.com/puma/puma/issues/815 "Issue by @nate-dipiazza, closed 2016-02-25" +[#835]:https://github.com/puma/puma/issues/835 "Issue by @mwpastore, closed 2016-02-25" +[#798]:https://github.com/puma/puma/issues/798 "Issue by @schneems, closed 2016-02-25" +[#876]:https://github.com/puma/puma/issues/876 "Issue by @osheroff, closed 2016-02-25" +[#849]:https://github.com/puma/puma/issues/849 "Issue by @apotheon, closed 2016-02-25" +[#871]:https://github.com/puma/puma/pull/871 "PR by @deepj, merged 2016-02-25" +[#874]:https://github.com/puma/puma/pull/874 "PR by @wallclockbuilder, merged 2016-02-25" +[#883]:https://github.com/puma/puma/pull/883 "PR by @dadah89, merged 2016-02-25" +[#884]:https://github.com/puma/puma/pull/884 "PR by @furkanmustafa, merged 2016-02-25" +[#888]:https://github.com/puma/puma/pull/888 "PR by @mlarraz, merged 2016-02-25" +[#890]:https://github.com/puma/puma/pull/890 "PR by @todd, merged 2016-02-25" +[#891]:https://github.com/puma/puma/pull/891 "PR by @ctaintor, merged 2016-02-25" +[#893]:https://github.com/puma/puma/pull/893 "PR by @spastorino, merged 2016-02-25" +[#897]:https://github.com/puma/puma/pull/897 "PR by @vanchi-zendesk, merged 2016-02-25" +[#899]:https://github.com/puma/puma/pull/899 "PR by @kch, merged 2016-02-25" +[#859]:https://github.com/puma/puma/issues/859 "Issue by @boxofrad, closed 2016-01-28" +[#822]:https://github.com/puma/puma/pull/822 "PR by @kwugirl, merged 2016-01-28" +[#833]:https://github.com/puma/puma/pull/833 "PR by @joemiller, merged 2016-01-28" +[#837]:https://github.com/puma/puma/pull/837 "PR by @YurySolovyov, merged 2016-01-28" +[#839]:https://github.com/puma/puma/pull/839 "PR by @ka8725, merged 2016-01-15" +[#845]:https://github.com/puma/puma/pull/845 "PR by @deepj, merged 2016-01-28" +[#846]:https://github.com/puma/puma/pull/846 "PR by @sriedel, merged 2016-01-15" +[#850]:https://github.com/puma/puma/pull/850 "PR by @deepj, merged 2016-01-15" +[#853]:https://github.com/puma/puma/pull/853 "PR by @Jeffrey6052, merged 2016-01-28" +[#857]:https://github.com/puma/puma/pull/857 "PR by @osheroff, merged 2016-01-15" +[#858]:https://github.com/puma/puma/pull/858 "PR by @mlarraz, merged 2016-01-28" +[#860]:https://github.com/puma/puma/pull/860 "PR by @osheroff, merged 2016-01-15" +[#861]:https://github.com/puma/puma/pull/861 "PR by @osheroff, merged 2016-01-15" +[#818]:https://github.com/puma/puma/pull/818 "PR by @unleashed, merged 2015-11-06" +[#819]:https://github.com/puma/puma/pull/819 "PR by @VictorLowther, merged 2015-11-06" +[#563]:https://github.com/puma/puma/issues/563 "Issue by @deathbob, closed 2015-11-06" +[#803]:https://github.com/puma/puma/issues/803 "Issue by @burningTyger, closed 2016-04-07" +[#768]:https://github.com/puma/puma/pull/768 "PR by @nathansamson, merged 2015-11-06" +[#773]:https://github.com/puma/puma/pull/773 "PR by @rossta, merged 2015-11-06" +[#774]:https://github.com/puma/puma/pull/774 "PR by @snow, merged 2015-11-06" +[#781]:https://github.com/puma/puma/pull/781 "PR by @sunsations, merged 2015-11-06" +[#791]:https://github.com/puma/puma/pull/791 "PR by @unleashed, merged 2015-10-01" +[#793]:https://github.com/puma/puma/pull/793 "PR by @robdimarco, merged 2015-11-06" +[#794]:https://github.com/puma/puma/pull/794 "PR by @peterkeen, merged 2015-11-06" +[#795]:https://github.com/puma/puma/pull/795 "PR by @unleashed, merged 2015-11-06" +[#796]:https://github.com/puma/puma/pull/796 "PR by @cschneid, merged 2015-10-13" +[#799]:https://github.com/puma/puma/pull/799 "PR by @annawinkler, merged 2015-11-06" +[#800]:https://github.com/puma/puma/pull/800 "PR by @liamseanbrady, merged 2015-11-06" +[#801]:https://github.com/puma/puma/pull/801 "PR by @scottjg, merged 2015-11-06" +[#802]:https://github.com/puma/puma/pull/802 "PR by @scottjg, merged 2015-11-06" +[#804]:https://github.com/puma/puma/pull/804 "PR by @burningTyger, merged 2015-11-06" +[#809]:https://github.com/puma/puma/pull/809 "PR by @unleashed, merged 2015-11-06" +[#810]:https://github.com/puma/puma/pull/810 "PR by @vlmonk, merged 2015-11-06" +[#814]:https://github.com/puma/puma/pull/814 "PR by @schneems, merged 2015-11-04" +[#817]:https://github.com/puma/puma/pull/817 "PR by @unleashed, merged 2015-11-06" +[#735]:https://github.com/puma/puma/issues/735 "Issue by @trekr5, closed 2015-08-04" +[#769]:https://github.com/puma/puma/issues/769 "Issue by @dovestyle, closed 2015-08-16" +[#767]:https://github.com/puma/puma/issues/767 "Issue by @kapso, closed 2015-08-15" +[#765]:https://github.com/puma/puma/issues/765 "Issue by @monfresh, closed 2015-08-15" +[#764]:https://github.com/puma/puma/issues/764 "Issue by @keithpitt, closed 2015-08-15" +[#669]:https://github.com/puma/puma/pull/669 "PR by @chulkilee, closed 2015-08-14" +[#673]:https://github.com/puma/puma/pull/673 "PR by @chulkilee, closed 2015-08-14" +[#668]:https://github.com/puma/puma/pull/668 "PR by @kcollignon, merged 2015-08-14" +[#754]:https://github.com/puma/puma/pull/754 "PR by @nathansamson, merged 2015-08-14" +[#759]:https://github.com/puma/puma/pull/759 "PR by @BenV, merged 2015-08-14" +[#761]:https://github.com/puma/puma/pull/761 "PR by @dmarcotte, merged 2015-08-14" +[#742]:https://github.com/puma/puma/pull/742 "PR by @deivid-rodriguez, merged 2015-07-17" +[#743]:https://github.com/puma/puma/pull/743 "PR by @matthewd, merged 2015-07-18" +[#749]:https://github.com/puma/puma/pull/749 "PR by @huacnlee, merged 2015-08-04" +[#751]:https://github.com/puma/puma/pull/751 "PR by @costi, merged 2015-07-31" +[#741]:https://github.com/puma/puma/issues/741 "Issue by @GUI, closed 2015-07-17" +[#739]:https://github.com/puma/puma/issues/739 "Issue by @hab278, closed 2015-07-17" +[#737]:https://github.com/puma/puma/issues/737 "Issue by @dmill, closed 2015-07-16" +[#733]:https://github.com/puma/puma/issues/733 "Issue by @Eric-Guo, closed 2015-07-15" +[#736]:https://github.com/puma/puma/pull/736 "PR by @paulanunda, merged 2015-07-15" +[#722]:https://github.com/puma/puma/issues/722 "Issue by @mikeki, closed 2015-07-14" +[#694]:https://github.com/puma/puma/issues/694 "Issue by @yld, closed 2015-06-10" +[#705]:https://github.com/puma/puma/issues/705 "Issue by @TheTeaNerd, closed 2015-07-14" +[#686]:https://github.com/puma/puma/pull/686 "PR by @jjb, merged 2015-06-10" +[#693]:https://github.com/puma/puma/pull/693 "PR by @rob-murray, merged 2015-06-10" +[#697]:https://github.com/puma/puma/pull/697 "PR by @spk, merged 2015-06-10" +[#699]:https://github.com/puma/puma/pull/699 "PR by @deees, merged 2015-05-19" +[#701]:https://github.com/puma/puma/pull/701 "PR by @deepj, merged 2015-05-19" +[#702]:https://github.com/puma/puma/pull/702 "PR by @OleMchls, merged 2015-06-10" +[#703]:https://github.com/puma/puma/pull/703 "PR by @deepj, merged 2015-06-10" +[#704]:https://github.com/puma/puma/pull/704 "PR by @grega, merged 2015-06-10" +[#709]:https://github.com/puma/puma/pull/709 "PR by @lian, merged 2015-06-10" +[#711]:https://github.com/puma/puma/pull/711 "PR by @julik, merged 2015-06-10" +[#712]:https://github.com/puma/puma/pull/712 "PR by @chewi, merged 2015-07-14" +[#715]:https://github.com/puma/puma/pull/715 "PR by @0RaymondJiang0, merged 2015-07-14" +[#725]:https://github.com/puma/puma/pull/725 "PR by @rwz, merged 2015-07-14" +[#726]:https://github.com/puma/puma/pull/726 "PR by @jshafton, merged 2015-07-14" +[#729]:https://github.com/puma/puma/pull/729 "PR by @allaire, merged 2015-07-14" +[#730]:https://github.com/puma/puma/pull/730 "PR by @iamjarvo, merged 2015-07-14" +[#690]:https://github.com/puma/puma/issues/690 "Issue by @bachue, closed 2015-04-21" +[#684]:https://github.com/puma/puma/issues/684 "Issue by @tomquas, closed 2015-04-13" +[#698]:https://github.com/puma/puma/pull/698 "PR by @dmarcotte, merged 2015-05-04" +[#683]:https://github.com/puma/puma/issues/683 "Issue by @indirect, closed 2015-04-11" +[#657]:https://github.com/puma/puma/pull/657 "PR by @schneems, merged 2015-02-19" +[#658]:https://github.com/puma/puma/pull/658 "PR by @tomohiro, merged 2015-02-23" +[#662]:https://github.com/puma/puma/pull/662 "PR by @iaintshine, merged 2015-03-06" +[#664]:https://github.com/puma/puma/pull/664 "PR by @fxposter, merged 2015-03-09" +[#667]:https://github.com/puma/puma/pull/667 "PR by @JuanitoFatas, merged 2015-03-12" +[#672]:https://github.com/puma/puma/pull/672 "PR by @chulkilee, merged 2015-03-15" +[#653]:https://github.com/puma/puma/issues/653 "Issue by @dvrensk, closed 2015-02-11" +[#644]:https://github.com/puma/puma/pull/644 "PR by @bpaquet, merged 2015-01-29" +[#646]:https://github.com/puma/puma/pull/646 "PR by @mkonecny, merged 2015-02-05" +[#630]:https://github.com/puma/puma/issues/630 "Issue by @jelmd, closed 2015-01-20" +[#622]:https://github.com/puma/puma/issues/622 "Issue by @sabamotto, closed 2015-01-20" +[#583]:https://github.com/puma/puma/issues/583 "Issue by @rwojsznis, closed 2015-01-20" +[#586]:https://github.com/puma/puma/issues/586 "Issue by @ponchik, closed 2015-01-20" +[#359]:https://github.com/puma/puma/issues/359 "Issue by @natew, closed 2014-12-13" +[#633]:https://github.com/puma/puma/issues/633 "Issue by @joevandyk, closed 2015-01-20" +[#478]:https://github.com/puma/puma/pull/478 "PR by @rubencaro, merged 2015-01-20" +[#610]:https://github.com/puma/puma/pull/610 "PR by @kwilczynski, merged 2014-11-27" +[#611]:https://github.com/puma/puma/pull/611 "PR by @jasonl, merged 2015-01-20" +[#616]:https://github.com/puma/puma/pull/616 "PR by @jc00ke, merged 2014-12-10" +[#623]:https://github.com/puma/puma/pull/623 "PR by @raldred, merged 2015-01-20" +[#628]:https://github.com/puma/puma/pull/628 "PR by @rdpoor, merged 2015-01-20" +[#634]:https://github.com/puma/puma/pull/634 "PR by @deepj, merged 2015-01-20" +[#637]:https://github.com/puma/puma/pull/637 "PR by @raskhadafi, merged 2015-01-20" +[#639]:https://github.com/puma/puma/pull/639 "PR by @ebeigarts, merged 2015-01-20" +[#640]:https://github.com/puma/puma/pull/640 "PR by @bailsman, merged 2015-01-20" +[#591]:https://github.com/puma/puma/issues/591 "Issue by @renier, closed 2014-11-24" +[#606]:https://github.com/puma/puma/issues/606 "Issue by @, closed 2014-11-24" +[#560]:https://github.com/puma/puma/pull/560 "PR by @raskhadafi, merged 2014-11-24" +[#566]:https://github.com/puma/puma/pull/566 "PR by @sheltond, merged 2014-11-24" +[#593]:https://github.com/puma/puma/pull/593 "PR by @andruby, merged 2014-10-30" +[#594]:https://github.com/puma/puma/pull/594 "PR by @hassox, merged 2014-10-31" +[#596]:https://github.com/puma/puma/pull/596 "PR by @burningTyger, merged 2014-11-01" +[#601]:https://github.com/puma/puma/pull/601 "PR by @sorentwo, merged 2014-11-24" +[#602]:https://github.com/puma/puma/pull/602 "PR by @1334, merged 2014-11-24" +[#608]:https://github.com/puma/puma/pull/608 "PR by @Gu1, merged 2014-11-24" +[#538]:https://github.com/puma/puma/pull/538 "PR by @memiux, merged 2014-11-24" +[#550]:https://github.com/puma/puma/issues/550 "Issue by @, closed 2014-10-30" +[#549]:https://github.com/puma/puma/pull/549 "PR by @bsnape, merged 2014-10-16" +[#553]:https://github.com/puma/puma/pull/553 "PR by @lowjoel, merged 2014-10-16" +[#568]:https://github.com/puma/puma/pull/568 "PR by @mariuz, merged 2014-10-16" +[#578]:https://github.com/puma/puma/pull/578 "PR by @danielbuechele, merged 2014-10-16" +[#581]:https://github.com/puma/puma/pull/581 "PR by @alexch, merged 2014-10-16" +[#590]:https://github.com/puma/puma/pull/590 "PR by @dmarcotte, merged 2014-10-16" +[#574]:https://github.com/puma/puma/issues/574 "Issue by @minasmart, closed 2014-09-05" +[#561]:https://github.com/puma/puma/pull/561 "PR by @krasnoukhov, merged 2014-08-04" +[#570]:https://github.com/puma/puma/pull/570 "PR by @havenwood, merged 2014-08-20" +[#520]:https://github.com/puma/puma/pull/520 "PR by @misfo, merged 2014-06-16" +[#530]:https://github.com/puma/puma/pull/530 "PR by @dmarcotte, merged 2014-06-16" +[#537]:https://github.com/puma/puma/pull/537 "PR by @vlmonk, merged 2014-06-16" +[#540]:https://github.com/puma/puma/pull/540 "PR by @allaire, merged 2014-05-27" +[#544]:https://github.com/puma/puma/pull/544 "PR by @chulkilee, merged 2014-06-03" +[#551]:https://github.com/puma/puma/pull/551 "PR by @jcxplorer, merged 2014-07-02" +[#487]:https://github.com/puma/puma/pull/487 "PR by @, merged 2014-03-06" +[#492]:https://github.com/puma/puma/pull/492 "PR by @, merged 2014-03-06" +[#493]:https://github.com/puma/puma/pull/493 "PR by @alepore, merged 2014-03-07" +[#503]:https://github.com/puma/puma/pull/503 "PR by @mariuz, merged 2014-04-12" +[#505]:https://github.com/puma/puma/pull/505 "PR by @sammcj, merged 2014-04-12" +[#506]:https://github.com/puma/puma/pull/506 "PR by @dsander, merged 2014-04-12" +[#510]:https://github.com/puma/puma/pull/510 "PR by @momer, merged 2014-04-12" +[#511]:https://github.com/puma/puma/pull/511 "PR by @macool, merged 2014-04-12" +[#514]:https://github.com/puma/puma/pull/514 "PR by @nanaya, merged 2014-04-12" +[#517]:https://github.com/puma/puma/pull/517 "PR by @misfo, merged 2014-04-12" +[#518]:https://github.com/puma/puma/pull/518 "PR by @alxgsv, merged 2014-04-12" +[#471]:https://github.com/puma/puma/pull/471 "PR by @arthurnn, merged 2014-02-28" +[#485]:https://github.com/puma/puma/pull/485 "PR by @runlevel5, merged 2014-03-01" +[#486]:https://github.com/puma/puma/pull/486 "PR by @joshwlewis, merged 2014-03-02" +[#490]:https://github.com/puma/puma/pull/490 "PR by @tobinibot, merged 2014-03-06" +[#491]:https://github.com/puma/puma/pull/491 "PR by @brianknight10, merged 2014-03-06" +[#438]:https://github.com/puma/puma/issues/438 "Issue by @mperham, closed 2014-01-25" +[#333]:https://github.com/puma/puma/issues/333 "Issue by @SamSaffron, closed 2014-01-26" +[#440]:https://github.com/puma/puma/issues/440 "Issue by @sudara, closed 2014-01-25" +[#449]:https://github.com/puma/puma/issues/449 "Issue by @cezarsa, closed 2014-02-04" +[#444]:https://github.com/puma/puma/issues/444 "Issue by @le0pard, closed 2014-01-25" +[#370]:https://github.com/puma/puma/issues/370 "Issue by @pelcasandra, closed 2014-01-26" +[#377]:https://github.com/puma/puma/issues/377 "Issue by @mrbrdo, closed 2014-01-26" +[#406]:https://github.com/puma/puma/issues/406 "Issue by @simonrussell, closed 2014-01-25" +[#425]:https://github.com/puma/puma/issues/425 "Issue by @jhass, closed 2014-01-26" +[#432]:https://github.com/puma/puma/pull/432 "PR by @anatol, closed 2014-01-25" +[#428]:https://github.com/puma/puma/pull/428 "PR by @alexeyfrank, merged 2014-01-25" +[#429]:https://github.com/puma/puma/pull/429 "PR by @namusyaka, merged 2013-12-16" +[#431]:https://github.com/puma/puma/pull/431 "PR by @mrb, merged 2014-01-25" +[#433]:https://github.com/puma/puma/pull/433 "PR by @alepore, merged 2014-02-28" +[#437]:https://github.com/puma/puma/pull/437 "PR by @ibrahima, merged 2014-01-25" +[#446]:https://github.com/puma/puma/pull/446 "PR by @sudara, merged 2014-01-27" +[#451]:https://github.com/puma/puma/pull/451 "PR by @pwiebe, merged 2014-02-04" +[#453]:https://github.com/puma/puma/pull/453 "PR by @joevandyk, merged 2014-02-28" +[#470]:https://github.com/puma/puma/pull/470 "PR by @arthurnn, merged 2014-02-28" +[#472]:https://github.com/puma/puma/pull/472 "PR by @rubencaro, merged 2014-02-21" +[#480]:https://github.com/puma/puma/pull/480 "PR by @jjb, merged 2014-02-26" +[#481]:https://github.com/puma/puma/pull/481 "PR by @schneems, merged 2014-02-25" +[#482]:https://github.com/puma/puma/pull/482 "PR by @prathamesh-sonpatki, merged 2014-02-26" +[#483]:https://github.com/puma/puma/pull/483 "PR by @maxilev, merged 2014-02-26" +[#422]:https://github.com/puma/puma/issues/422 "Issue by @alexandru-calinoiu, closed 2013-12-05" +[#334]:https://github.com/puma/puma/issues/334 "Issue by @srgpqt, closed 2013-07-18" +[#179]:https://github.com/puma/puma/issues/179 "Issue by @betelgeuse, closed 2013-07-18" +[#332]:https://github.com/puma/puma/issues/332 "Issue by @SamSaffron, closed 2013-07-18" +[#317]:https://github.com/puma/puma/issues/317 "Issue by @masterkain, closed 2013-07-11" +[#309]:https://github.com/puma/puma/issues/309 "Issue by @masterkain, closed 2013-07-09" +[#166]:https://github.com/puma/puma/issues/166 "Issue by @emassip, closed 2013-07-06" +[#292]:https://github.com/puma/puma/issues/292 "Issue by @pulse00, closed 2013-07-06" +[#274]:https://github.com/puma/puma/issues/274 "Issue by @mrbrdo, closed 2013-07-06" +[#304]:https://github.com/puma/puma/issues/304 "Issue by @nandosola, closed 2013-07-06" +[#287]:https://github.com/puma/puma/issues/287 "Issue by @runlevel5, closed 2013-07-06" +[#256]:https://github.com/puma/puma/issues/256 "Issue by @rkh, closed 2013-07-01" +[#285]:https://github.com/puma/puma/issues/285 "Issue by @mkwiatkowski, closed 2013-06-20" +[#270]:https://github.com/puma/puma/issues/270 "Issue by @iamroody, closed 2013-06-01" +[#246]:https://github.com/puma/puma/issues/246 "Issue by @amencarini, closed 2013-06-01" +[#278]:https://github.com/puma/puma/issues/278 "Issue by @titanous, closed 2013-06-18" +[#251]:https://github.com/puma/puma/issues/251 "Issue by @cure, closed 2013-06-18" +[#252]:https://github.com/puma/puma/issues/252 "Issue by @vixns, closed 2013-06-01" +[#234]:https://github.com/puma/puma/issues/234 "Issue by @jgarber, closed 2013-04-08" +[#228]:https://github.com/puma/puma/issues/228 "Issue by @joelmats, closed 2013-04-29" +[#192]:https://github.com/puma/puma/issues/192 "Issue by @steverandy, closed 2013-02-09" +[#206]:https://github.com/puma/puma/issues/206 "Issue by @moll, closed 2013-03-19" +[#154]:https://github.com/puma/puma/issues/154 "Issue by @trevor, closed 2013-03-19" +[#208]:https://github.com/puma/puma/issues/208 "Issue by @ochronus, closed 2013-03-18" +[#189]:https://github.com/puma/puma/issues/189 "Issue by @tolot27, closed 2013-02-09" +[#185]:https://github.com/puma/puma/issues/185 "Issue by @nicolai86, closed 2013-02-06" +[#182]:https://github.com/puma/puma/issues/182 "Issue by @sriedel, closed 2013-02-05" +[#183]:https://github.com/puma/puma/issues/183 "Issue by @concept47, closed 2013-02-05" +[#176]:https://github.com/puma/puma/issues/176 "Issue by @cryo28, closed 2013-02-05" +[#180]:https://github.com/puma/puma/issues/180 "Issue by @tscolari, closed 2013-02-05" +[#170]:https://github.com/puma/puma/issues/170 "Issue by @nixme, closed 2012-11-29" +[#148]:https://github.com/puma/puma/issues/148 "Issue by @rafaelss, closed 2012-11-18" +[#128]:https://github.com/puma/puma/issues/128 "Issue by @fbjork, closed 2012-10-20" +[#155]:https://github.com/puma/puma/issues/155 "Issue by @ehlertij, closed 2012-10-13" +[#123]:https://github.com/puma/puma/pull/123 "PR by @jcoene, closed 2012-07-19" +[#111]:https://github.com/puma/puma/pull/111 "PR by @kenkeiter, closed 2012-07-19" +[#98]:https://github.com/puma/puma/pull/98 "PR by @Flink, closed 2012-05-15" +[#94]:https://github.com/puma/puma/issues/94 "Issue by @ender672, closed 2012-05-08" +[#84]:https://github.com/puma/puma/issues/84 "Issue by @sigursoft, closed 2012-04-29" +[#78]:https://github.com/puma/puma/issues/78 "Issue by @dstrelau, closed 2012-04-28" +[#79]:https://github.com/puma/puma/issues/79 "Issue by @jammi, closed 2012-04-28" +[#65]:https://github.com/puma/puma/issues/65 "Issue by @bporterfield, closed 2012-04-11" +[#54]:https://github.com/puma/puma/issues/54 "Issue by @masterkain, closed 2012-04-10" +[#58]:https://github.com/puma/puma/pull/58 "PR by @paneq, closed 2012-04-10" +[#61]:https://github.com/puma/puma/issues/61 "Issue by @dustalov, closed 2012-04-10" +[#63]:https://github.com/puma/puma/issues/63 "Issue by @seamusabshere, closed 2012-04-11" +[#60]:https://github.com/puma/puma/issues/60 "Issue by @paneq, closed 2012-04-11" +[#53]:https://github.com/puma/puma/pull/53 "PR by @sxua, closed 2012-04-11" diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..14bfc85 --- /dev/null +++ b/LICENSE @@ -0,0 +1,29 @@ +BSD 3-Clause License + +Copyright (c) 2019, Evan Phoenix. Some code by Zed Shaw, (c) 2005. +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + +1. Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. + +2. Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + +3. Neither the name of the copyright holder nor the names of its + contributors may be used to endorse or promote products derived from + this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..aaa571a --- /dev/null +++ b/README.md @@ -0,0 +1,375 @@ +

+ +

+ +# Puma: A Ruby Web Server Built For Parallelism + +[![Actions MRI](https://github.com/puma/puma/workflows/MRI/badge.svg?branch=master)](https://github.com/puma/puma/actions?query=workflow%3AMRI) +[![Actions non MRI](https://github.com/puma/puma/workflows/non_MRI/badge.svg?branch=master)](https://github.com/puma/puma/actions?query=workflow%3Anon_MRI) +[![Code Climate](https://codeclimate.com/github/puma/puma.svg)](https://codeclimate.com/github/puma/puma) +[![SemVer](https://api.dependabot.com/badges/compatibility_score?dependency-name=puma&package-manager=bundler&version-scheme=semver)](https://dependabot.com/compatibility-score.html?dependency-name=puma&package-manager=bundler&version-scheme=semver) +[![StackOverflow](https://img.shields.io/badge/stackoverflow-Puma-blue.svg)]( https://stackoverflow.com/questions/tagged/puma ) + +Puma is a **simple, fast, multi-threaded, and highly parallel HTTP 1.1 server for Ruby/Rack applications**. + +## Built For Speed & Parallelism + +Puma processes requests using a C-optimized Ragel extension (inherited from Mongrel) that provides fast, accurate HTTP 1.1 protocol parsing in a portable way. Puma then serves the request using a thread pool. Each request is served in a separate thread, so truly parallel Ruby implementations (JRuby, Rubinius) will use all available CPU cores. + +Originally designed as a server for [Rubinius](https://github.com/rubinius/rubinius), Puma also works well with Ruby (MRI) and JRuby. + +On MRI, there is a Global VM Lock (GVL) that ensures only one thread can run Ruby code at a time. But if you're doing a lot of blocking IO (such as HTTP calls to external APIs like Twitter), Puma still improves MRI's throughput by allowing IO waiting to be done in parallel. + +## Quick Start + +``` +$ gem install puma +$ puma +``` + +Without arguments, puma will look for a rackup (.ru) file in +working directory called `config.ru`. + +## SSL Connection Support + +Puma will install/compile with support for ssl sockets, assuming OpenSSL +development files are installed on the system. + +If the system does not have OpenSSL development files installed, Puma will +install/compile, but it will not allow ssl connections. + +## Frameworks + +### Rails + +Puma is the default server for Rails, included in the generated Gemfile. + +Start your server with the `rails` command: + +``` +$ rails server +``` + +Many configuration options and Puma features are not available when using `rails server`. It is recommended that you use Puma's executable instead: + +``` +$ bundle exec puma +``` + +### Sinatra + +You can run your Sinatra application with Puma from the command line like this: + +``` +$ ruby app.rb -s Puma +``` + +In order to actually configure Puma using a config file, like `puma.rb`, however, you need to use the `puma` executable. To do this, you must add a rackup file to your Sinatra app: + +```ruby +# config.ru +require './app' +run Sinatra::Application +``` + +You can then start your application using: + +``` +$ bundle exec puma +``` + +## Configuration + +Puma provides numerous options. Consult `puma -h` (or `puma --help`) for a full list of CLI options, or see `Puma::DSL` or [dsl.rb](https://github.com/puma/puma/blob/master/lib/puma/dsl.rb). + +You can also find several configuration examples as part of the +[test](https://github.com/puma/puma/tree/master/test/config) suite. + +For debugging purposes, you can set the environment variable `PUMA_LOG_CONFIG` with a value +and the loaded configuration will be printed as part of the boot process. + +### Thread Pool + +Puma uses a thread pool. You can set the minimum and maximum number of threads that are available in the pool with the `-t` (or `--threads`) flag: + +``` +$ puma -t 8:32 +``` + +Puma will automatically scale the number of threads, from the minimum until it caps out at the maximum, based on how much traffic is present. The current default is `0:16` and on MRI is `0:5`. Feel free to experiment, but be careful not to set the number of maximum threads to a large number, as you may exhaust resources on the system (or cause contention for the Global VM Lock, when using MRI). + +Be aware that additionally Puma creates threads on its own for internal purposes (e.g. handling slow clients). So, even if you specify -t 1:1, expect around 7 threads created in your application. + +### Clustered mode + +Puma also offers "clustered mode". Clustered mode `fork`s workers from a master process. Each child process still has its own thread pool. You can tune the number of workers with the `-w` (or `--workers`) flag: + +``` +$ puma -t 8:32 -w 3 +``` + +Note that threads are still used in clustered mode, and the `-t` thread flag setting is per worker, so `-w 2 -t 16:16` will spawn 32 threads in total, with 16 in each worker process. + +In clustered mode, Puma can "preload" your application. This loads all the application code *prior* to forking. Preloading reduces total memory usage of your application via an operating system feature called [copy-on-write](https://en.wikipedia.org/wiki/Copy-on-write) (Ruby 2.0+ only). Use the `--preload` flag from the command line: + +``` +$ puma -w 3 --preload +``` + +If you're using a configuration file, use the `preload_app!` method: + +```ruby +# config/puma.rb +workers 3 +preload_app! +``` + +Additionally, you can specify a block in your configuration file that will be run on boot of each worker: + +```ruby +# config/puma.rb +on_worker_boot do + # configuration here +end +``` + +This code can be used to setup the process before booting the application, allowing +you to do some Puma-specific things that you don't want to embed in your application. +For instance, you could fire a log notification that a worker booted or send something to statsd. This can be called multiple times. + +Constants loaded by your application (such as `Rails`) will not be available in `on_worker_boot`. +However, these constants _will_ be available if `preload_app!` is enabled, either explicitly in your `puma` config or automatically if +using 2 or more workers in cluster mode. +If `preload_app!` is not enabled and 1 worker is used, then `on_worker_boot` will fire, but your app will not be preloaded and constants will not be available. + +`before_fork` specifies a block to be run before workers are forked: + +```ruby +# config/puma.rb +before_fork do + # configuration here +end +``` + +Preloading can’t be used with phased restart, since phased restart kills and restarts workers one-by-one, and `preload_app!` copies the code of master into the workers. + +### Error handling + +If puma encounters an error outside of the context of your application, it will respond with a 500 and a simple +textual error message (see `Puma::Server#lowlevel_error` or [server.rb](https://github.com/puma/puma/blob/master/lib/puma/server.rb)). +You can specify custom behavior for this scenario. For example, you can report the error to your third-party +error-tracking service (in this example, [rollbar](https://rollbar.com)): + +```ruby +lowlevel_error_handler do |e| + Rollbar.critical(e) + [500, {}, ["An error has occurred, and engineers have been informed. Please reload the page. If you continue to have problems, contact support@example.com\n"]] +end +``` + +### Binding TCP / Sockets + +Bind Puma to a socket with the `-b` (or `--bind`) flag: + +``` +$ puma -b tcp://127.0.0.1:9292 +``` + +To use a UNIX Socket instead of TCP: + +``` +$ puma -b unix:///var/run/puma.sock +``` + +If you need to change the permissions of the UNIX socket, just add a umask parameter: + +``` +$ puma -b 'unix:///var/run/puma.sock?umask=0111' +``` + +Need a bit of security? Use SSL sockets: + +``` +$ puma -b 'ssl://127.0.0.1:9292?key=path_to_key&cert=path_to_cert' +``` +#### Self-signed SSL certificates (via the [`localhost`] gem, for development use): + +Puma supports the [`localhost`] gem for self-signed certificates. This is particularly useful if you want to use Puma with SSL locally, and self-signed certificates will work for your use-case. Currently, the integration can only be used in MRI. + +Puma automatically configures SSL when the [`localhost`] gem is loaded in a `development` environment: + +```ruby +# Add the gem to your Gemfile +group(:development) do + gem 'localhost' +end + +# And require it implicitly using bundler +require "bundler" +Bundler.require(:default, ENV["RACK_ENV"].to_sym) + +# Alternatively, you can require the gem in config.ru: +require './app' +require 'localhost' +run Sinatra::Application +``` + +Additionally, Puma must be listening to an SSL socket: + +```shell +$ puma -b 'ssl://localhost:9292' config.ru + +# The following options allow you to reach Puma over HTTP as well: +$ puma -b ssl://localhost:9292 -b tcp://localhost:9393 config.ru +``` + +[`localhost`]: https://github.com/socketry/localhost + +#### Controlling SSL Cipher Suites + +To use or avoid specific SSL cipher suites, use `ssl_cipher_filter` or `ssl_cipher_list` options. + +##### Ruby: + +``` +$ puma -b 'ssl://127.0.0.1:9292?key=path_to_key&cert=path_to_cert&ssl_cipher_filter=!aNULL:AES+SHA' +``` + +##### JRuby: + +``` +$ puma -b 'ssl://127.0.0.1:9292?keystore=path_to_keystore&keystore-pass=keystore_password&ssl_cipher_list=TLS_RSA_WITH_AES_128_CBC_SHA,TLS_RSA_WITH_AES_256_CBC_SHA' +``` + +See https://www.openssl.org/docs/man1.1.1/man1/ciphers.html for cipher filter format and full list of cipher suites. + +Disable TLS v1 with the `no_tlsv1` option: + +``` +$ puma -b 'ssl://127.0.0.1:9292?key=path_to_key&cert=path_to_cert&no_tlsv1=true' +``` + +#### Controlling OpenSSL Verification Flags + +To enable verification flags offered by OpenSSL, use `verification_flags` (not available for JRuby): + +``` +$ puma -b 'ssl://127.0.0.1:9292?key=path_to_key&cert=path_to_cert&verification_flags=PARTIAL_CHAIN' +``` + +You can also set multiple verification flags (by separating them with coma): + +``` +$ puma -b 'ssl://127.0.0.1:9292?key=path_to_key&cert=path_to_cert&verification_flags=PARTIAL_CHAIN,CRL_CHECK' +``` + +List of available flags: `USE_CHECK_TIME`, `CRL_CHECK`, `CRL_CHECK_ALL`, `IGNORE_CRITICAL`, `X509_STRICT`, `ALLOW_PROXY_CERTS`, `POLICY_CHECK`, `EXPLICIT_POLICY`, `INHIBIT_ANY`, `INHIBIT_MAP`, `NOTIFY_POLICY`, `EXTENDED_CRL_SUPPORT`, `USE_DELTAS`, `CHECK_SS_SIGNATURE`, `TRUSTED_FIRST`, `SUITEB_128_LOS_ONLY`, `SUITEB_192_LOS`, `SUITEB_128_LOS`, `PARTIAL_CHAIN`, `NO_ALT_CHAINS`, `NO_CHECK_TIME` +(see https://www.openssl.org/docs/manmaster/man3/X509_VERIFY_PARAM_set_hostflags.html#VERIFICATION-FLAGS). + +### Control/Status Server + +Puma has a built-in status and control app that can be used to query and control Puma. + +``` +$ puma --control-url tcp://127.0.0.1:9293 --control-token foo +``` + +Puma will start the control server on localhost port 9293. All requests to the control server will need to include control token (in this case, `token=foo`) as a query parameter. This allows for simple authentication. Check out `Puma::App::Status` or [status.rb](https://github.com/puma/puma/blob/master/lib/puma/app/status.rb) to see what the status app has available. + +You can also interact with the control server via `pumactl`. This command will restart Puma: + +``` +$ pumactl --control-url 'tcp://127.0.0.1:9293' --control-token foo restart +``` + +To see a list of `pumactl` options, use `pumactl --help`. + +### Configuration File + +You can also provide a configuration file with the `-C` (or `--config`) flag: + +``` +$ puma -C /path/to/config +``` + +If no configuration file is specified, Puma will look for a configuration file at `config/puma.rb`. If an environment is specified (via the `--environment` flag or through the `APP_ENV`, `RACK_ENV`, or `RAILS_ENV` environment variables) Puma looks for a configuration file at `config/puma/.rb` and then falls back to `config/puma.rb`. + +If you want to prevent Puma from looking for a configuration file in those locations, include the `--no-config` flag: + +``` +$ puma --no-config + +# or + +$ puma -C "-" +``` + +The other side-effects of setting the environment are whether to show stack traces (in `development` or `test`), and setting RACK_ENV may potentially affect middleware looking for this value to change their behavior. The default puma RACK_ENV value is `development`. You can see all config default values in `Puma::Configuration#puma_default_options` or [configuration.rb](https://github.com/puma/puma/blob/61c6213fbab/lib/puma/configuration.rb#L182-L204). + +Check out `Puma::DSL` or [dsl.rb](https://github.com/puma/puma/blob/master/lib/puma/dsl.rb) to see all available options. + +## Restart + +Puma includes the ability to restart itself. When available (MRI, Rubinius, JRuby), Puma performs a "hot restart". This is the same functionality available in *Unicorn* and *NGINX* which keep the server sockets open between restarts. This makes sure that no pending requests are dropped while the restart is taking place. + +For more, see the [Restart documentation](docs/restart.md). + +## Signals + +Puma responds to several signals. A detailed guide to using UNIX signals with Puma can be found in the [Signals documentation](docs/signals.md). + +## Platform Constraints + +Some platforms do not support all Puma features. + + * **JRuby**, **Windows**: server sockets are not seamless on restart, they must be closed and reopened. These platforms have no way to pass descriptors into a new process that is exposed to Ruby. Also, cluster mode is not supported due to a lack of fork(2). + * **Windows**: Cluster mode is not supported due to a lack of fork(2). + * **Kubernetes**: The way Kubernetes handles pod shutdowns interacts poorly with server processes implementing graceful shutdown, like Puma. See the [kubernetes section of the documentation](docs/kubernetes.md) for more details. + +## Known Bugs + +For MRI versions 2.2.7, 2.2.8, 2.2.9, 2.2.10, 2.3.4 and 2.4.1, you may see ```stream closed in another thread (IOError)```. It may be caused by a [Ruby bug](https://bugs.ruby-lang.org/issues/13632). It can be fixed with the gem https://rubygems.org/gems/stopgap_13632: + +```ruby +if %w(2.2.7 2.2.8 2.2.9 2.2.10 2.3.4 2.4.1).include? RUBY_VERSION + begin + require 'stopgap_13632' + rescue LoadError + end +end +``` + +## Deployment + +Puma has support for Capistrano with an [external gem](https://github.com/seuros/capistrano-puma). + +It is common to use process monitors with Puma. Modern process monitors like systemd or rc.d +provide continuous monitoring and restarts for increased +reliability in production environments: + +* [rc.d](docs/jungle/rc.d/README.md) +* [systemd](docs/systemd.md) + +Community guides: + +* [Deploying Puma on OpenBSD using relayd and httpd](https://gist.github.com/anon987654321/4532cf8d6c59c1f43ec8973faa031103) + +## Community Extensions + +### Plugins + +* [puma-metrics](https://github.com/harmjanblok/puma-metrics) — export Puma metrics to Prometheus +* [puma-plugin-statsd](https://github.com/yob/puma-plugin-statsd) — send Puma metrics to statsd +* [puma-plugin-systemd](https://github.com/sj26/puma-plugin-systemd) — deeper integration with systemd for notify, status and watchdog + +### Monitoring + +* [puma-status](https://github.com/ylecuyer/puma-status) — Monitor CPU/Mem/Load of running puma instances from the CLI + +## Contributing + +Find details for contributing in the [contribution guide](CONTRIBUTING.md). + +## License + +Puma is copyright Evan Phoenix and contributors, licensed under the BSD 3-Clause license. See the included LICENSE file for details. diff --git a/Rakefile b/Rakefile new file mode 100644 index 0000000..4ca600c --- /dev/null +++ b/Rakefile @@ -0,0 +1,103 @@ +require "bundler/setup" +require "rake/testtask" +require "rake/extensiontask" +require "rake/javaextensiontask" +require "rubocop/rake_task" +require_relative 'lib/puma/detect' +require 'rubygems/package_task' +require 'bundler/gem_tasks' + +gemspec = Gem::Specification.load("puma.gemspec") +Gem::PackageTask.new(gemspec).define + +# Add rubocop task +RuboCop::RakeTask.new + +# generate extension code using Ragel (C and Java) +desc "Generate extension code (C and Java) using Ragel" +task :ragel + +file 'ext/puma_http11/http11_parser.c' => ['ext/puma_http11/http11_parser.rl'] do |t| + begin + sh "ragel #{t.prerequisites.last} -C -G2 -I ext/puma_http11 -o #{t.name}" + rescue + fail "Could not build wrapper using Ragel (it failed or not installed?)" + end +end +task :ragel => ['ext/puma_http11/http11_parser.c'] + +file 'ext/puma_http11/org/jruby/puma/Http11Parser.java' => ['ext/puma_http11/http11_parser.java.rl'] do |t| + begin + sh "ragel #{t.prerequisites.last} -J -G2 -I ext/puma_http11 -o #{t.name}" + rescue + fail "Could not build wrapper using Ragel (it failed or not installed?)" + end +end +task :ragel => ['ext/puma_http11/org/jruby/puma/Http11Parser.java'] + +if !Puma.jruby? + # compile extensions using rake-compiler + # C (MRI, Rubinius) + Rake::ExtensionTask.new("puma_http11", gemspec) do |ext| + # place extension inside namespace + ext.lib_dir = "lib/puma" + + CLEAN.include "lib/puma/{1.8,1.9}" + CLEAN.include "lib/puma/puma_http11.rb" + end +else + # Java (JRuby) + # ::Rake::JavaExtensionTask.source_files supplies the list of files to + # compile. At present, it only works with a glob prefixed with @ext_dir. + # override it so we can select the files + class ::Rake::JavaExtensionTask + def source_files + if ENV["DISABLE_SSL"] + # uses no_ssl/PumaHttp11Service.java, removes MiniSSL.java + FileList[ + File.join(@ext_dir, "no_ssl/PumaHttp11Service.java"), + File.join(@ext_dir, "org/jruby/puma/Http11.java"), + File.join(@ext_dir, "org/jruby/puma/Http11Parser.java") + ] + else + FileList[ + File.join(@ext_dir, "PumaHttp11Service.java"), + File.join(@ext_dir, "org/jruby/puma/Http11.java"), + File.join(@ext_dir, "org/jruby/puma/Http11Parser.java"), + File.join(@ext_dir, "org/jruby/puma/MiniSSL.java") + ] + end + end + end + + Rake::JavaExtensionTask.new("puma_http11", gemspec) do |ext| + ext.lib_dir = "lib/puma" + end +end + +# the following is a fat-binary stub that will be used when +# require 'puma/puma_http11' and will use either 1.8 or 1.9 version depending +# on RUBY_VERSION +file "lib/puma/puma_http11.rb" do |t| + File.open(t.name, "w") do |f| + f.puts "RUBY_VERSION =~ /(\d+.\d+)/" + f.puts 'require "puma/#{$1}/puma_http11"' + end +end + +Rake::TestTask.new(:test) + +# tests require extension be compiled, but depend on the platform +if Puma.jruby? + task :test => [:java] +else + task :test => [:compile] +end + +namespace :test do + desc "Run all tests" + + task :all => :test +end + +task :default => [:rubocop, "test:all"] diff --git a/Release.md b/Release.md new file mode 100644 index 0000000..572e88f --- /dev/null +++ b/Release.md @@ -0,0 +1,17 @@ +## Before Release + +- Make sure tests pass and your last local commit matches master. +- Run tests with latest jruby +- Update the version in `const.rb`. +- On minor or major version updates i.e. from 3.10.x to 3.11.x update the "codename" in `const.rb`. +- Create history entries with https://github.com/MSP-Greg/issue-pr-link + +# Release process + +Using "3.7.1" as a version example. + +1. `bundle exec rake release` +2. `gem push --key github --host https://rubygems.pkg.github.com/puma pkg/puma-VERSION.gem` +3. Switch to latest JRuby version +4. `rake java gem` +5. `gem push pkg/puma-VERSION-java.gem` diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..7dde296 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,13 @@ +# Security Policy + +## Supported Versions + +| Version | Supported | +| :------------ | :--------: | +| Latest release in 5.x | ✅ | +| Latest release in 4.x | ✅ | +| All other releases | ❌ | + +## Reporting a Vulnerability + +Contact [Evan Phoenix.](https://github.com/evanphx) diff --git a/benchmarks/wrk/big_body.sh b/benchmarks/wrk/big_body.sh new file mode 100755 index 0000000..6554d58 --- /dev/null +++ b/benchmarks/wrk/big_body.sh @@ -0,0 +1,8 @@ +# You are encouraged to use @ioquatix's wrk fork, located here: https://github.com/ioquatix/wrk + +bundle exec bin/puma -t 4 test/rackup/hello.ru & +PID1=$! +sleep 5 +wrk -c 4 -s benchmarks/wrk/lua/big_body.lua --latency http://localhost:9292 + +kill $PID1 diff --git a/benchmarks/wrk/big_response.sh b/benchmarks/wrk/big_response.sh new file mode 100755 index 0000000..2ecf5f5 --- /dev/null +++ b/benchmarks/wrk/big_response.sh @@ -0,0 +1,6 @@ +bundle exec bin/puma -t 4 test/rackup/big_response.ru & +PID1=$! +sleep 5 +wrk -c 4 -d 60 --latency http://localhost:9292 + +kill $PID1 diff --git a/benchmarks/wrk/cpu_spin.sh b/benchmarks/wrk/cpu_spin.sh new file mode 100755 index 0000000..2dcb63c --- /dev/null +++ b/benchmarks/wrk/cpu_spin.sh @@ -0,0 +1,103 @@ +#!/bin/bash + +set -eo pipefail + +ITERATIONS=400000 +HOST=127.0.0.1:9292 +URL="http://$HOST/cpu/$ITERATIONS" + +MIN_WORKERS=1 +MAX_WORKERS=4 + +MIN_THREADS=4 +MAX_THREADS=4 + +DURATION=2 +MIN_CONCURRENT=1 +MAX_CONCURRENT=8 + +retry() { + local tries="$1" + local sleep="$2" + shift 2 + + for i in $(seq 1 $tries); do + if eval "$@"; then + return 0 + fi + + sleep "$sleep" + done + + return 1 +} + +ms() { + VALUE=$(cat) + FRAC=${VALUE%%[ums]*} + case "$VALUE" in + *us) + echo "scale=1; ${FRAC}/1000" | bc + ;; + + *ms) + echo "scale=1; ${FRAC}/1" | bc + ;; + + *s) + echo "scale=1; ${FRAC}*1000/1" | bc + ;; + esac +} + +run_wrk() { + mkdir tmp &>/dev/null || true + result=$(wrk -H "Connection: Close" -c "$wrk_c" -t "$wrk_t" -d "$DURATION" --latency "$@" | tee -a tmp/wrk.txt) + req_sec=$(echo "$result" | grep "^Requests/sec:" | awk '{print $2}') + latency_avg=$(echo "$result" | grep "^\s*Latency.*%" | awk '{print $2}' | ms) + latency_stddev=$(echo "$result" | grep "^\s*Latency.*%" | awk '{print $3}' | ms) + latency_50=$(echo "$result" | grep "^\s*50%" | awk '{print $2}' | ms) + latency_75=$(echo "$result" | grep "^\s*75%" | awk '{print $2}' | ms) + latency_90=$(echo "$result" | grep "^\s*90%" | awk '{print $2}' | ms) + latency_99=$(echo "$result" | grep "^\s*99%" | awk '{print $2}' | ms) + + echo -e "$workers\t$threads\t$wrk_c\t$wrk_t\t$req_sec\t$latency_avg\t$latency_stddev\t$latency_50\t$latency_75\t$latency_90\t$latency_99" +} + +run_concurrency_tests() { + echo + echo -e "PUMA_W\tPUMA_T\tWRK_C\tWRK_T\tREQ_SEC\tL_AVG\tL_DEV\tL_50%\tL_75%\tL_90%\tL_99%" + for wrk_c in $(seq $MIN_CONCURRENT $MAX_CONCURRENT); do + wrk_t="$wrk_c" + eval "$@" + sleep 1 + done + echo +} + +with_puma() { + # start puma and wait for 10s for it to start + bundle exec bin/puma -w "$workers" -t "$threads" -b "tcp://$HOST" -C test/config/cpu_spin.rb & + local puma_pid=$! + trap "kill $puma_pid" EXIT + + # wait for Puma to be up + if ! retry 10 1s curl --fail "$URL" &>/dev/null; then + echo "Failed to connect to $URL." + return 1 + fi + + # execute testing command + eval "$@" + kill "$puma_pid" || true + trap - EXIT + wait +} + +for workers in $(seq $MIN_WORKERS $MAX_WORKERS); do + for threads in $(seq $MIN_THREADS $MAX_THREADS); do + with_puma \ + run_concurrency_tests \ + run_wrk "$URL" + done +done diff --git a/benchmarks/wrk/hello.sh b/benchmarks/wrk/hello.sh new file mode 100755 index 0000000..ca51b74 --- /dev/null +++ b/benchmarks/wrk/hello.sh @@ -0,0 +1,8 @@ +# You are encouraged to use @ioquatix's wrk fork, located here: https://github.com/ioquatix/wrk + +bundle exec bin/puma -t 4 test/rackup/hello.ru & +PID1=$! +sleep 5 +wrk -c 4 -d 30 --latency http://localhost:9292 + +kill $PID1 diff --git a/benchmarks/wrk/jruby_ssl_realistic_response.sh b/benchmarks/wrk/jruby_ssl_realistic_response.sh new file mode 100755 index 0000000..5eeb6dd --- /dev/null +++ b/benchmarks/wrk/jruby_ssl_realistic_response.sh @@ -0,0 +1,8 @@ +bundle exec ruby bin/puma \ + -t 4 -b "ssl://localhost:9292?keystore=examples/puma/keystore.jks&keystore-pass=blahblah&verify_mode=none" \ + test/rackup/realistic_response.ru & +PID1=$! +sleep 5 +wrk -c 4 -d 30 --latency https://localhost:9292 + +kill $PID1 diff --git a/benchmarks/wrk/lua/big_body.lua b/benchmarks/wrk/lua/big_body.lua new file mode 100644 index 0000000..953049b --- /dev/null +++ b/benchmarks/wrk/lua/big_body.lua @@ -0,0 +1,3 @@ +wrk.method = "POST" +wrk.body = string.rep("body", 1000000) +wrk.headers["Content-Type"] = "application/x-www-form-urlencoded" diff --git a/benchmarks/wrk/many_long_headers.sh b/benchmarks/wrk/many_long_headers.sh new file mode 100755 index 0000000..f0378c0 --- /dev/null +++ b/benchmarks/wrk/many_long_headers.sh @@ -0,0 +1,6 @@ +bundle exec bin/puma -t 4 test/rackup/many_long_headers.ru & +PID1=$! +sleep 5 +wrk -c 4 -d 30 --latency http://localhost:9292 + +kill $PID1 diff --git a/benchmarks/wrk/more_conns_than_threads.sh b/benchmarks/wrk/more_conns_than_threads.sh new file mode 100755 index 0000000..3f72f2d --- /dev/null +++ b/benchmarks/wrk/more_conns_than_threads.sh @@ -0,0 +1,6 @@ +bundle exec bin/puma -t 6 test/rackup/hello.ru & +PID1=$! +sleep 5 +wrk -c 12 --latency http://localhost:9292 + +kill $PID1 diff --git a/benchmarks/wrk/realistic_response.sh b/benchmarks/wrk/realistic_response.sh new file mode 100755 index 0000000..b8e74a6 --- /dev/null +++ b/benchmarks/wrk/realistic_response.sh @@ -0,0 +1,6 @@ +bundle exec bin/puma -t 4 test/rackup/realistic_response.ru & +PID1=$! +sleep 5 +wrk -c 4 -d 30 --latency http://localhost:9292 + +kill $PID1 diff --git a/benchmarks/wrk/ssl_realistic_response.sh b/benchmarks/wrk/ssl_realistic_response.sh new file mode 100755 index 0000000..da55da6 --- /dev/null +++ b/benchmarks/wrk/ssl_realistic_response.sh @@ -0,0 +1,8 @@ +bundle exec ruby bin/puma \ + -t 4 -b "ssl://localhost:9292?key=examples%2Fpuma%2Fpuma_keypair.pem&cert=examples%2Fpuma%2Fcert_puma.pem&verify_mode=none" \ + test/rackup/realistic_response.ru & +PID1=$! +sleep 5 +wrk -c 4 -d 30 --latency https://localhost:9292 + +kill $PID1 diff --git a/bin/puma b/bin/puma new file mode 100755 index 0000000..9c67c0f --- /dev/null +++ b/bin/puma @@ -0,0 +1,10 @@ +#!/usr/bin/env ruby +# +# Copyright (c) 2011 Evan Phoenix +# + +require 'puma/cli' + +cli = Puma::CLI.new ARGV + +cli.run diff --git a/bin/puma-wild b/bin/puma-wild new file mode 100644 index 0000000..5db409f --- /dev/null +++ b/bin/puma-wild @@ -0,0 +1,25 @@ +#!/usr/bin/env ruby +# +# Copyright (c) 2014 Evan Phoenix +# + +require 'rubygems' + +cli_arg = ARGV.shift + +inc = "" + +if cli_arg == "-I" + inc = ARGV.shift + $LOAD_PATH.concat inc.split(":") +end + +module Puma; end + +Puma.const_set("WILD_ARGS", ["-I", inc]) + +require 'puma/cli' + +cli = Puma::CLI.new ARGV + +cli.run diff --git a/bin/pumactl b/bin/pumactl new file mode 100755 index 0000000..51ab353 --- /dev/null +++ b/bin/pumactl @@ -0,0 +1,12 @@ +#!/usr/bin/env ruby + +require 'puma/control_cli' + +cli = Puma::ControlCLI.new ARGV.dup + +begin + cli.run +rescue => e + STDERR.puts e.message + exit 1 +end diff --git a/docs/architecture.md b/docs/architecture.md new file mode 100644 index 0000000..83f438b --- /dev/null +++ b/docs/architecture.md @@ -0,0 +1,74 @@ +# Architecture + +## Overview + +![https://bit.ly/2iJuFky](images/puma-general-arch.png) + +Puma is a threaded Ruby HTTP application server processing requests across a TCP +and/or UNIX socket. + + +Puma processes (there can be one or many) accept connections from the socket via +a thread (in the [`Reactor`](../lib/puma/reactor.rb) class). The connection, +once fully buffered and read, moves into the `todo` list, where an available +thread will pick it up (in the [`ThreadPool`](../lib/puma/thread_pool.rb) +class). + +Puma works in two main modes: cluster and single. In single mode, only one Puma +process boots. In cluster mode, a `master` process is booted, which prepares +(and may boot) the application and then uses the `fork()` system call to create +one or more `child` processes. These `child` processes all listen to the same +socket. The `master` process does not listen to the socket or process requests - +its purpose is primarily to manage and listen for UNIX signals and possibly kill +or boot `child` processes. + +We sometimes call `child` processes (or Puma processes in `single` mode) +_workers_, and we sometimes call the threads created by Puma's +[`ThreadPool`](../lib/puma/thread_pool.rb) _worker threads_. + +## How Requests Work + +![https://bit.ly/2zwzhEK](images/puma-connection-flow.png) + +* Upon startup, Puma listens on a TCP or UNIX socket. + * The backlog of this socket is configured with a default of 1024, but the + actual backlog value is capped by the `net.core.somaxconn` sysctl value. + The backlog determines the size of the queue for unaccepted connections. If + the backlog is full, the operating system is not accepting new connections. + * This socket backlog is distinct from the `backlog` of work as reported by + `Puma.stats` or the control server. The backlog that `Puma.stats` refers to + represents the number of connections in the process' `todo` set waiting for + a thread from the [`ThreadPool`](../lib/puma/thread_pool.rb). +* By default, a single, separate thread (created by the + [`Reactor`](../lib/puma/reactor.rb) class) reads and buffers requests from the + socket. + * When at least one worker thread is available for work, the reactor thread + listens to the socket and accepts a request (if one is waiting). + * The reactor thread waits for the entire HTTP request to be received. + * Puma exposes the time spent waiting for the HTTP request body to be + received to the Rack app as `env['puma.request_body_wait']` + (milliseconds). + * Once fully buffered and received, the connection is pushed into the "todo" + set. +* Worker threads pop work off the "todo" set for processing. + * The worker thread processes the request via `call`ing the configured Rack + application. The Rack application generates the HTTP response. + * The worker thread writes the response to the connection. While Puma buffers + requests via a separate thread, it does not use a separate thread for + responses. + * Once done, the thread becomes available to process another connection in the + "todo" set. + +### `queue_requests` + +![https://bit.ly/2zxCJ1Z](images/puma-connection-flow-no-reactor.png) + +The `queue_requests` option is `true` by default, enabling the separate reactor +thread used to buffer requests as described above. + +If set to `false`, this buffer will not be used for connections while waiting +for the request to arrive. + +In this mode, when a connection is accepted, it is added to the "todo" queue +immediately, and a worker will synchronously do any waiting necessary to read +the HTTP request from the socket. diff --git a/docs/compile_options.md b/docs/compile_options.md new file mode 100644 index 0000000..178f05a --- /dev/null +++ b/docs/compile_options.md @@ -0,0 +1,21 @@ +# Compile Options + +There are some `cflags` provided to change Puma's default configuration for its +C extension. + +## Query String, `PUMA_QUERY_STRING_MAX_LENGTH` + +By default, the max length of `QUERY_STRING` is `1024 * 10`. But you may want to +adjust it to accept longer queries in GET requests. + +For manual install, pass the `PUMA_QUERY_STRING_MAX_LENGTH` option like this: + +``` +gem install puma -- --with-cflags="-D PUMA_QUERY_STRING_MAX_LENGTH=64000" +``` + +For Bundler, use its configuration system: + +``` +bundle config build.puma "--with-cflags='-D PUMA_QUERY_STRING_MAX_LENGTH=64000'" +``` diff --git a/docs/deployment.md b/docs/deployment.md new file mode 100644 index 0000000..2364aa6 --- /dev/null +++ b/docs/deployment.md @@ -0,0 +1,102 @@ +# Deployment engineering for Puma + +Puma expects to be run in a deployed environment eventually. You can use it as +your development server, but most people use it in their production deployments. + +To that end, this document serves as a foundation of wisdom regarding deploying +Puma to production while increasing happiness and decreasing downtime. + +## Specifying Puma + +Most people will specify Puma by including `gem "puma"` in a Gemfile, so we'll +assume this is how you're using Puma. + +## Single vs. Cluster mode + +Initially, Puma was conceived as a thread-only web server, but support for +processes was added in version 2. + +To run `puma` in single mode (i.e., as a development environment), set the +number of workers to 0; anything higher will run in cluster mode. + +Here are some tips for cluster mode: + +### MRI + +* Use cluster mode and set the number of workers to 1.5x the number of CPU cores + in the machine, starting from a minimum of 2. +* Set the number of threads to desired concurrent requests/number of workers. + Puma defaults to 5, and that's a decent number. + +#### Migrating from Unicorn + +* If you're migrating from unicorn though, here are some settings to start with: + * Set workers to half the number of unicorn workers you're using + * Set threads to 2 + * Enjoy 50% memory savings +* As you grow more confident in the thread-safety of your app, you can tune the + workers down and the threads up. + +#### Ubuntu / Systemd (Systemctl) Installation + +See [systemd.md](systemd.md) + +#### Worker utilization + +**How do you know if you've got enough (or too many workers)?** + +A good question. Due to MRI's GIL, only one thread can be executing Ruby code at +a time. But since so many apps are waiting on IO from DBs, etc., they can +utilize threads to use the process more efficiently. + +Generally, you never want processes that are pegged all the time. That can mean +there is more work to do than the process can get through. On the other hand, if +you have processes that sit around doing nothing, then they're just eating up +resources. + +Watch your CPU utilization over time and aim for about 70% on average. 70% +utilization means you've got capacity still but aren't starving threads. + +**Measuring utilization** + +Using a timestamp header from an upstream proxy server (e.g., `nginx` or +`haproxy`) makes it possible to indicate how long requests have been waiting for +a Puma thread to become available. + +* Have your upstream proxy set a header with the time it received the request: + * nginx: `proxy_set_header X-Request-Start "${msec}";` + * haproxy >= 1.9: `http-request set-header X-Request-Start + t=%[date()]%[date_us()]` + * haproxy < 1.9: `http-request set-header X-Request-Start t=%[date()]` +* In your Rack middleware, determine the amount of time elapsed since + `X-Request-Start`. +* To improve accuracy, you will want to subtract time spent waiting for slow + clients: + * `env['puma.request_body_wait']` contains the number of milliseconds Puma + spent waiting for the client to send the request body. + * haproxy: `%Th` (TLS handshake time) and `%Ti` (idle time before request) + can can also be added as headers. + +## Should I daemonize? + +The Puma 5.0 release removed daemonization. For older versions and alternatives, +continue reading. + +I prefer not to daemonize my servers and use something like `runit` or `systemd` +to monitor them as child processes. This gives them fast response to crashes and +makes it easy to figure out what is going on. Additionally, unlike `unicorn`, +Puma does not require daemonization to do zero-downtime restarts. + +I see people using daemonization because they start puma directly via Capistrano +task and thus want it to live on past the `cap deploy`. To these people, I say: +You need to be using a process monitor. Nothing is making sure Puma stays up in +this scenario! You're just waiting for something weird to happen, Puma to die, +and to get paged at 3 AM. Do yourself a favor, at least the process monitoring +your OS comes with, be it `sysvinit` or `systemd`. Or branch out and use `runit` +or hell, even `monit`. + +## Restarting + +You probably will want to deploy some new code at some point, and you'd like +Puma to start running that new code. There are a few options for restarting +Puma, described separately in our [restart documentation](restart.md). diff --git a/docs/fork_worker.md b/docs/fork_worker.md new file mode 100644 index 0000000..f2afb25 --- /dev/null +++ b/docs/fork_worker.md @@ -0,0 +1,33 @@ +# Fork-Worker Cluster Mode [Experimental] + +Puma 5 introduces an experimental new cluster-mode configuration option, `fork_worker` (`--fork-worker` from the CLI). This mode causes Puma to fork additional workers from worker 0, instead of directly from the master process: + +``` +10000 \_ puma 4.3.3 (tcp://0.0.0.0:9292) [puma] +10001 \_ puma: cluster worker 0: 10000 [puma] +10002 \_ puma: cluster worker 1: 10000 [puma] +10003 \_ puma: cluster worker 2: 10000 [puma] +10004 \_ puma: cluster worker 3: 10000 [puma] +``` + +Similar to the `preload_app!` option, the `fork_worker` option allows your application to be initialized only once for copy-on-write memory savings, and it has two additional advantages: + +1. **Compatible with phased restart.** Because the master process itself doesn't preload the application, this mode works with phased restart (`SIGUSR1` or `pumactl phased-restart`). When worker 0 reloads as part of a phased restart, it initializes a new copy of your application first, then the other workers reload by forking from this new worker already containing the new preloaded application. + + This allows a phased restart to complete as quickly as a hot restart (`SIGUSR2` or `pumactl restart`), while still minimizing downtime by staggering the restart across cluster workers. + +2. **'Refork' for additional copy-on-write improvements in running applications.** Fork-worker mode introduces a new `refork` command that re-loads all nonzero workers by re-forking them from worker 0. + + This command can potentially improve memory utilization in large or complex applications that don't fully pre-initialize on startup, because the re-forked workers can share copy-on-write memory with a worker that has been running for a while and serving requests. + + You can trigger a refork by sending the cluster the `SIGURG` signal or running the `pumactl refork` command at any time. A refork will also automatically trigger once, after a certain number of requests have been processed by worker 0 (default 1000). To configure the number of requests before the auto-refork, pass a positive integer argument to `fork_worker` (e.g., `fork_worker 1000`), or `0` to disable. + +### Limitations + +- Not compatible with the `preload_app!` option + +- This mode is still very experimental so there may be bugs or edge-cases, particularly around expected behavior of existing hooks. Please open a [bug report](https://github.com/puma/puma/issues/new?template=bug_report.md) if you encounter any issues. + +- In order to fork new workers cleanly, worker 0 shuts down its server and stops serving requests so there are no open file descriptors or other kinds of shared global state between processes, and to maximize copy-on-write efficiency across the newly-forked workers. This may temporarily reduce total capacity of the cluster during a phased restart / refork. + + In a cluster with `n` workers, a normal phased restart stops and restarts workers one by one while the application is loaded in each process, so `n-1` workers are available serving requests during the restart. In a phased restart in fork-worker mode, the application is first loaded in worker 0 while `n-1` workers are available, then worker 0 remains stopped while the rest of the workers are reloaded one by one, leaving only `n-2` workers to be available for a brief period of time. Reloading the rest of the workers should be quick because the application is preloaded at that point, but there may be situations where it can take longer (slow clients, long-running application code, slow worker-fork hooks, etc). diff --git a/docs/images/puma-connection-flow-no-reactor.png b/docs/images/puma-connection-flow-no-reactor.png new file mode 100644 index 0000000000000000000000000000000000000000..05ef8d01fef4050f14713f4276441b285191300d GIT binary patch literal 16029 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 Ma%!^W(q;kw2Y`saQ2+n{ literal 0 HcmV?d00001 diff --git a/docs/jungle/README.md b/docs/jungle/README.md new file mode 100644 index 0000000..46713f9 --- /dev/null +++ b/docs/jungle/README.md @@ -0,0 +1,9 @@ +# Puma as a service + +## Systemd + +See [/docs/systemd](https://github.com/puma/puma/blob/master/docs/systemd.md). + +## rc.d + +See `/docs/jungle/rc.d` for FreeBSD's rc.d scripts diff --git a/docs/jungle/rc.d/README.md b/docs/jungle/rc.d/README.md new file mode 100644 index 0000000..2c5ddf5 --- /dev/null +++ b/docs/jungle/rc.d/README.md @@ -0,0 +1,74 @@ +# Puma as a service using rc.d + +Manage multiple Puma servers as services on one box using FreeBSD's rc.d service. + +## Dependencies + +* `jq` - a command-line json parser is needed to parse the json in the config file + +## Installation + + # Copy the puma script to the rc.d directory (make sure everyone has read/execute perms) + sudo cp puma /usr/local/etc/rc.d/ + + # Create an empty configuration file + sudo touch /usr/local/etc/puma.conf + + # Enable the puma service + sudo echo 'puma_enable="YES"' >> /etc/rc.conf + +## Managing the jungle + +Puma apps are referenced in /usr/local/etc/puma.conf by default. + +Start the jungle running: + +`service puma start` + +This script will run at boot time. + + +You can also stop the jungle (stops ALL puma instances) by running: + +`service puma stop` + + +To restart the jungle: + +`service puma restart` + +## Conventions + +* The script expects: + * a config file to exist under `config/puma.rb` in your app. E.g.: `/home/apps/my-app/config/puma.rb`. + +You can always change those defaults by editing the scripts. + +## Here's what a minimal app's config file should have + +``` +{ + "servers" : [ + { + "dir": "/path/to/rails/project", + "user": "deploy-user", + "ruby_version": "ruby.version", + "ruby_env": "rbenv" + } + ] +} +``` + +## Before starting... + +You need to customise `puma.conf` to: + +* Set the right user your app should be running on unless you want root to execute it! +* Set the directory of the app +* Set the ruby version to execute +* Set the ruby environment (currently set to rbenv, since that is the only ruby environment currently supported) +* Add additional server instances following the scheme in the example + +## Notes: + +Only rbenv is currently supported. diff --git a/docs/jungle/rc.d/puma b/docs/jungle/rc.d/puma new file mode 100755 index 0000000..e800223 --- /dev/null +++ b/docs/jungle/rc.d/puma @@ -0,0 +1,61 @@ +#!/bin/sh +# + +# PROVIDE: puma + +. /etc/rc.subr + +name="puma" +start_cmd="puma_start" +stop_cmd="puma_stop" +restart_cmd="puma_restart" +rcvar=puma_enable +required_files=/usr/local/etc/puma.conf + +puma_start() +{ + server_count=$(/usr/local/bin/jq ".servers[] .ruby_env" /usr/local/etc/puma.conf | wc -l) + i=0 + while [ "$i" -lt "$server_count" ]; do + rb_env=$(/usr/local/bin/jq -r ".servers[$i].ruby_env" /usr/local/etc/puma.conf) + dir=$(/usr/local/bin/jq -r ".servers[$i].dir" /usr/local/etc/puma.conf) + user=$(/usr/local/bin/jq -r ".servers[$i].user" /usr/local/etc/puma.conf) + rb_ver=$(/usr/local/bin/jq -r ".servers[$i].ruby_version" /usr/local/etc/puma.conf) + case $rb_env in + "rbenv") + cd $dir && rbenv shell $rb_ver && /usr/sbin/daemon -u $user bundle exec puma -C $dir/config/puma.rb + ;; + *) + ;; + esac + i=$(( i + 1 )) + done +} + +puma_stop() +{ + pkill ruby +} + +puma_restart() +{ + server_count=$(/usr/local/bin/jq ".servers[] .ruby_env" /usr/local/etc/puma.conf | wc -l) + i=0 + while [ "$i" -lt "$server_count" ]; do + rb_env=$(/usr/local/bin/jq -r ".servers[$i].ruby_env" /usr/local/etc/puma.conf) + dir=$(/usr/local/bin/jq -r ".servers[$i].dir" /usr/local/etc/puma.conf) + user=$(/usr/local/bin/jq -r ".servers[$i].user" /usr/local/etc/puma.conf) + rb_ver=$(/usr/local/bin/jq -r ".servers[$i].ruby_version" /usr/local/etc/puma.conf) + case $rb_env in + "rbenv") + cd $dir && rbenv shell $rb_ver && /usr/sbin/daemon -u $user bundle exec puma -C $dir/config/puma.rb + ;; + *) + ;; + esac + i=$(( i + 1 )) + done +} + +load_rc_config $name +run_rc_command "$1" diff --git a/docs/jungle/rc.d/puma.conf b/docs/jungle/rc.d/puma.conf new file mode 100644 index 0000000..600537a --- /dev/null +++ b/docs/jungle/rc.d/puma.conf @@ -0,0 +1,10 @@ +{ + "servers" : [ + { + "dir": "/path/to/rails/project", + "user": "deploy-user", + "ruby_version": "ruby.version", + "ruby_env": "rbenv" + } + ] +} diff --git a/docs/kubernetes.md b/docs/kubernetes.md new file mode 100644 index 0000000..348af93 --- /dev/null +++ b/docs/kubernetes.md @@ -0,0 +1,66 @@ +# Kubernetes + +## Running Puma in Kubernetes + +In general running Puma in Kubernetes works as-is, no special configuration is needed beyond what you would write anyway to get a new Kubernetes Deployment going. There is one known interaction between the way Kubernetes handles pod termination and how Puma handles `SIGINT`, where some request might be sent to Puma after it has already entered graceful shutdown mode and is no longer accepting requests. This can lead to dropped requests during rolling deploys. A workaround for this is listed at the end of this article. + +## Basic setup + +Assuming you already have a running cluster and docker image repository, you can run a simple Puma app with the following example Dockerfile and Deployment specification. These are meant as examples only and are deliberately very minimal to the point of skipping many options that are recommended for running in production, like healthchecks and envvar configuration with ConfigMaps. In general you should check the [Kubernetes documentation](https://kubernetes.io/docs/home/) and [Docker documentation](https://docs.docker.com/) for a more comprehensive overview of the available options. + +A basic Dockerfile example: +``` +FROM ruby:2.5.1-alpine # can be updated to newer ruby versions +RUN apk update && apk add build-base # and any other packages you need + +# Only rebuild gem bundle if Gemfile changes +COPY Gemfile Gemfile.lock ./ +RUN bundle install + +# Copy over the rest of the files +COPY . . + +# Open up port and start the service +EXPOSE 9292 +CMD bundle exec rackup -o 0.0.0.0 +``` + +A sample `deployment.yaml`: +``` +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: my-awesome-puma-app +spec: + selector: + matchLabels: + app: my-awesome-puma-app + template: + metadata: + labels: + app: my-awesome-puma-app + service: my-awesome-puma-app + spec: + containers: + - name: my-awesome-puma-app + image: + ports: + - containerPort: 9292 +``` + +## Graceful shutdown and pod termination + +For some high-throughput systems, it is possible that some HTTP requests will return responses with response codes in the 5XX range during a rolling deploy to a new version. This is caused by [the way that Kubernetes terminates a pod during rolling deploys](https://cloud.google.com/blog/products/gcp/kubernetes-best-practices-terminating-with-grace): + +1. The replication controller determines a pod should be shut down. +2. The Pod is set to the “Terminating” State and removed from the endpoints list of all Services, so that it receives no more requests. +3. The pods pre-stop hook get called. The default for this is to send `SIGTERM` to the process inside the pod. +4. The pod has up to `terminationGracePeriodSeconds` (default: 30 seconds) to gracefully shut down. Puma will do this (after it receives SIGTERM) by closing down the socket that accepts new requests and finishing any requests already running before exiting the Puma process. +5. If the pod is still running after `terminationGracePeriodSeconds` has elapsed, the pod receives `SIGKILL` to make sure the process inside it stops. After that, the container exits and all other Kubernetes objects associated with it are cleaned up. + +There is a subtle race condition between step 2 and 3: The replication controller does not synchronously remove the pod from the Services AND THEN call the pre-stop hook of the pod, but rather it asynchronously sends "remove this pod from your endpoints" requests to the Services and then immediately proceeds to invoke the pods' pre-stop hook. If the Service controller (typically something like nginx or haproxy) receives this request handles this request "too" late (due to internal lag or network latency between the replication and Service controllers) then it is possible that the Service controller will send one or more requests to a Puma process which has already shut down its listening socket. These requests will then fail with 5XX error codes. + +The way Kubernetes works this way, rather than handling step 2 synchronously, is due to the CAP theorem: in a distributed system there is no way to guarantee that any message will arrive promptly. In particular, waiting for all Service controllers to report back might get stuck for an indefinite time if one of them has already been terminated or if there has been a net split. A way to work around this is to add a sleep to the pre-stop hook of the same time as the `terminationGracePeriodSeconds` time. This will allow the Puma process to keep serving new requests during the entire grace period, although it will no longer receive new requests after all Service controllers have propagated the removal of the pod from their endpoint lists. Then, after `terminationGracePeriodSeconds`, the pod receives `SIGKILL` and closes down. If your process can't handle SIGKILL properly, for example because it needs to release locks in different services, you can also sleep for a shorter period (and/or increase `terminationGracePeriodSeconds`) as long as the time slept is longer than the time that your Service controllers take to propagate the pod removal. The downside of this workaround is that all pods will take at minimum the amount of time slept to shut down and this will increase the time required for your rolling deploy. + +More discussions and links to relevant articles can be found in https://github.com/puma/puma/issues/2343. diff --git a/docs/nginx.md b/docs/nginx.md new file mode 100644 index 0000000..099587a --- /dev/null +++ b/docs/nginx.md @@ -0,0 +1,80 @@ +# Nginx configuration example file + +This is a very common setup using an upstream. It was adapted from some Capistrano recipe I found on the Internet a while ago. + +``` +upstream myapp { + server unix:///myapp/tmp/puma.sock; +} + +server { + listen 80; + server_name myapp.com; + + # ~2 seconds is often enough for most folks to parse HTML/CSS and + # retrieve needed images/icons/frames, connections are cheap in + # nginx so increasing this is generally safe... + keepalive_timeout 5; + + # path for static files + root /myapp/public; + access_log /myapp/log/nginx.access.log; + error_log /myapp/log/nginx.error.log info; + + # this rewrites all the requests to the maintenance.html + # page if it exists in the doc root. This is for capistrano's + # disable web task + if (-f $document_root/maintenance.html) { + rewrite ^(.*)$ /maintenance.html last; + break; + } + + location / { + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header Host $host; + + # If the file exists as a static file serve it directly without + # running all the other rewrite tests on it + if (-f $request_filename) { + break; + } + + # check for index.html for directory index + # if it's there on the filesystem then rewrite + # the url to add /index.html to the end of it + # and then break to send it to the next config rules. + if (-f $request_filename/index.html) { + rewrite (.*) $1/index.html break; + } + + # this is the meat of the rack page caching config + # it adds .html to the end of the url and then checks + # the filesystem for that file. If it exists, then we + # rewrite the url to have explicit .html on the end + # and then send it on its way to the next config rule. + # if there is no file on the fs then it sets all the + # necessary headers and proxies to our upstream pumas + if (-f $request_filename.html) { + rewrite (.*) $1.html break; + } + + if (!-f $request_filename) { + proxy_pass http://myapp; + break; + } + } + + # Now this supposedly should work as it gets the filenames with querystrings that Rails provides. + # BUT there's a chance it could break the ajax calls. + location ~* \.(ico|css|gif|jpe?g|png|js)(\?[0-9]+)?$ { + expires max; + break; + } + + # Error pages + # error_page 500 502 503 504 /500.html; + location = /500.html { + root /myapp/current/public; + } +} +``` diff --git a/docs/plugins.md b/docs/plugins.md new file mode 100644 index 0000000..c7500c1 --- /dev/null +++ b/docs/plugins.md @@ -0,0 +1,38 @@ +## Plugins + +Puma 3.0 added support for plugins that can augment configuration and service +operations. + +There are two canonical plugins to aid in the development of new plugins: + +* [tmp\_restart](https://github.com/puma/puma/blob/master/lib/puma/plugin/tmp_restart.rb): + Restarts the server if the file `tmp/restart.txt` is touched +* [heroku](https://github.com/puma/puma-heroku/blob/master/lib/puma/plugin/heroku.rb): + Packages up the default configuration used by Puma on Heroku (being sunset + with the release of Puma 5.0) + +Plugins are activated in a Puma configuration file (such as `config/puma.rb'`) +by adding `plugin "name"`, such as `plugin "heroku"`. + +Plugins are activated based on path requirements so, activating the `heroku` +plugin is much like `require "puma/plugin/heroku"`. This allows gems to provide +multiple plugins (as well as unrelated gems to provide Puma plugins). + +The `tmp_restart` plugin comes with Puma, so it is always available. + +To use the `heroku` plugin, add `puma-heroku` to your Gemfile or install it. + +### API + +## Server-wide hooks + +Plugins can use a couple of hooks at the server level: `start` and `config`. + +`start` runs when the server has started and allows the plugin to initiate other +functionality to augment Puma. + +`config` runs when the server is being configured and receives a `Puma::DSL` +object that is useful for additional configuration. + +Public methods in [`Puma::Plugin`](../lib/puma/plugin.rb) are treated as a +public API for plugins. diff --git a/docs/rails_dev_mode.md b/docs/rails_dev_mode.md new file mode 100644 index 0000000..add3cee --- /dev/null +++ b/docs/rails_dev_mode.md @@ -0,0 +1,28 @@ +# Running Puma in Rails Development Mode + +## "Loopback requests" + +Be cautious of "loopback requests," where a Rails application executes a request to a server that, in turn, results in another request back to the same Rails application before the first request completes. Having a loopback request will trigger [Rails' load interlock](https://guides.rubyonrails.org/threading_and_code_execution.html#load-interlock) mechanism. The load interlock mechanism prevents a thread from using Rails autoloading mechanism to load constants while the application code is still running inside another thread. + +This issue only occurs in the development environment as Rails' load interlock is not used in production environments. Although we're not sure, we believe this issue may not occur with the new `zeitwerk` code loader. + +### Solutions + +#### 1. Bypass Rails' load interlock with `.permit_concurrent_loads` + +Wrap the first request inside a block that will allow concurrent loads: [`ActiveSupport::Dependencies.interlock.permit_concurrent_loads`](https://guides.rubyonrails.org/threading_and_code_execution.html#permit-concurrent-loads). Anything wrapped inside the `.permit_concurrent_loads` block will bypass the load interlock mechanism, allowing new threads to access the Rails environment and boot properly. + +###### Example + +```ruby +response = ActiveSupport::Dependencies.interlock.permit_concurrent_loads do + # Your HTTP request code here. For example: + Faraday.post url, data: 'foo' +end + +do_something_with response +``` + +#### 2. Use multiple processes on Puma + +Alternatively, you may also enable multiple (single-threaded) workers on Puma. By doing so, you are sidestepping the problem by creating multiple processes rather than new threads. However, this workaround is not ideal because debugging tools such as [byebug](https://github.com/deivid-rodriguez/byebug/issues/487) and [pry](https://github.com/pry/pry/issues/2153), work poorly with any multi-process web server. diff --git a/docs/restart.md b/docs/restart.md new file mode 100644 index 0000000..cf95a60 --- /dev/null +++ b/docs/restart.md @@ -0,0 +1,64 @@ +Puma provides three distinct kinds of restart operations, each for different use cases. This document describes "hot restarts" and "phased restarts." The third kind of restart operation is called "refork" and is described in the documentation for [`fork_worker`](fork_worker.md). + +## Hot restart + +To perform a "hot" restart, Puma performs an `exec` operation to start the process up again, so no memory is shared between the old process and the new process. As a result, it is safe to issue a restart at any place where you would manually stop Puma and start it again. In particular, it is safe to upgrade Puma itself using a hot restart. + +If the new process is unable to load, it will simply exit. You should therefore run Puma under a process monitor when using it in production. + +### How-to + +Any of the following will cause a Puma server to perform a hot restart: + +* Send the `puma` process the `SIGUSR2` signal +* Issue a `GET` request to the Puma status/control server with the path `/restart` +* Issue `pumactl restart` (this uses the control server method if available, otherwise sends the `SIGUSR2` signal to the process) + +### Supported configurations + +* Works in cluster mode and single mode +* Supported on all platforms + +### Client experience + +* All platforms: clients with an in-flight request are served responses before the connection is closed gracefully. Puma gracefully disconnects any idle HTTP persistent connections before restarting. +* On MRI or TruffleRuby on Linux and BSD: Clients who connect just before the server restarts may experience increased latency while the server stops and starts again, but their connections will not be closed prematurely. +* On Windows and JRuby: Clients who connect just before a restart may experience "connection reset" errors. + +### Additional notes + +* Only one version of the application is running at a time. +* `on_restart` is invoked just before the server shuts down. This can be used to clean up resources (like long-lived database connections) gracefully. Since Ruby 2.0, it is not typically necessary to explicitly close file descriptors on restart. This is because any file descriptor opened by Ruby will have the `FD_CLOEXEC` flag set, meaning that file descriptors are closed on `exec`. `on_restart` is useful, though, if your application needs to perform any more graceful protocol-specific shutdown procedures before closing connections. + +## Phased restart + +Phased restarts replace all running workers in a Puma cluster. This is a useful way to upgrade the application that Puma is serving gracefully. A phased restart works by first killing an old worker, then starting a new worker, waiting until the new worker has successfully started before proceeding to the next worker. This process continues until all workers are replaced. The master process is not restarted. + +### How-to + +Any of the following will cause a Puma server to perform a phased restart: + +* Send the `puma` process the `SIGUSR1` signal +* Issue a `GET` request to the Puma status/control server with the path `/phased-restart` +* Issue `pumactl phased-restart` (this uses the control server method if available, otherwise sends the `SIGUSR1` signal to the process) + +### Supported configurations + +* Works in cluster mode only +* To support upgrading the application that Puma is serving, ensure `prune_bundler` is enabled and that `preload_app!` is disabled +* Supported on all platforms where cluster mode is supported + +### Client experience + +* In-flight requests are always served responses before the connection is closed gracefully +* Idle persistent connections are gracefully disconnected +* New connections are not lost, and clients will not experience any increase in latency (as long as the number of configured workers is greater than one) + +### Additional notes + +* When a phased restart begins, the Puma master process changes its current working directory to the directory specified by the `directory` option. If `directory` is set to symlink, this is automatically re-evaluated, so this mechanism can be used to upgrade the application. +* On a single server, it's possible that two versions of the application are running concurrently during a phased restart. +* `on_restart` is not invoked +* Phased restarts can be slow for Puma clusters with many workers. Hot restarts often complete more quickly, but at the cost of increased latency during the restart. +* Phased restarts cannot be used to upgrade any gems loaded by the Puma master process, including `puma` itself, anything in `extra_runtime_dependencies`, or dependencies thereof. Upgrading other gems is safe. +* If you remove the gems from old releases as part of your deployment strategy, there are additional considerations. Do not put any gems into `extra_runtime_dependencies` that have native extensions or have dependencies that have native extensions (one common example is `puma_worker_killer` and its dependency on `ffi`). Workers will fail on boot during a phased restart. The underlying issue is recorded in [an issue on the rubygems project](https://github.com/rubygems/rubygems/issues/4004). Hot restarts are your only option here if you need these dependencies. diff --git a/docs/signals.md b/docs/signals.md new file mode 100644 index 0000000..48e288c --- /dev/null +++ b/docs/signals.md @@ -0,0 +1,98 @@ +The [unix signal](https://en.wikipedia.org/wiki/Unix_signal) is a method of sending messages between [processes](https://en.wikipedia.org/wiki/Process_(computing)). When a signal is sent, the operating system interrupts the target process's normal flow of execution. There are standard signals that are used to stop a process, but there are also custom signals that can be used for other purposes. This document is an attempt to list all supported signals that Puma will respond to. In general, signals need only be sent to the master process of a cluster. + +## Sending Signals + +If you are new to signals, it can be helpful to see how they are used. When a process starts in a *nix-like operating system, it will have a [PID - or process identifier](https://en.wikipedia.org/wiki/Process_identifier) that can be used to send signals to the process. For demonstration, we will create an infinitely running process by tailing a file: + +```sh +$ echo "foo" >> my.log +$ irb +> pid = Process.spawn 'tail -f my.log' +``` + +From here, we can see that the tail process is running by using the `ps` command: + +```sh +$ ps aux | grep tail +schneems 87152 0.0 0.0 2432772 492 s032 S+ 12:46PM 0:00.00 tail -f my.log +``` + +You can send a signal in Ruby using the [Process module](https://www.ruby-doc.org/core-2.1.1/Process.html#kill-method): + +``` +$ irb +> puts pid +=> 87152 +Process.detach(pid) # https://ruby-doc.org/core-2.1.1/Process.html#method-c-detach +Process.kill("TERM", pid) +``` + +Now you will see via `ps` that there is no more `tail` process. Sometimes when referring to signals, the `SIG` prefix will be used. For example, `SIGTERM` is equivalent to sending `TERM` via `Process.kill`. + +## Puma Signals + +Puma cluster responds to these signals: + +- `TTIN` increment the worker count by 1 +- `TTOU` decrement the worker count by 1 +- `TERM` send `TERM` to worker. The worker will attempt to finish then exit. +- `USR2` restart workers. This also reloads the Puma configuration file, if there is one. +- `USR1` restart workers in phases, a rolling restart. This will not reload the configuration file. +- `HUP ` reopen log files defined in stdout_redirect configuration parameter. If there is no stdout_redirect option provided, it will behave like `INT` +- `INT ` equivalent of sending Ctrl-C to cluster. Puma will attempt to finish then exit. +- `CHLD` +- `URG ` refork workers in phases from worker 0 if `fork_workers` option is enabled. +- `INFO` print backtraces of all puma threads + +## Callbacks order in case of different signals + +### Start application + +``` +puma configuration file reloaded, if there is one +* Pruning Bundler environment +puma configuration file reloaded, if there is one + +before_fork +on_worker_fork +after_worker_fork + +Gemfile in context + +on_worker_boot + +Code of the app is loaded and running +``` + +### Send USR2 + +``` +on_worker_shutdown +on_restart + +puma configuration file reloaded, if there is one + +before_fork +on_worker_fork +after_worker_fork + +Gemfile in context + +on_worker_boot + +Code of the app is loaded and running +``` + +### Send USR1 + +``` +on_worker_shutdown +on_worker_fork +after_worker_fork + +Gemfile in context + +on_worker_boot + +Code of the app is loaded and running +``` diff --git a/docs/stats.md b/docs/stats.md new file mode 100644 index 0000000..e3d9846 --- /dev/null +++ b/docs/stats.md @@ -0,0 +1,142 @@ +## Accessing stats + +Stats can be accessed in two ways: + +### control server + +`$ pumactl stats` or `GET /stats` + +[Read more about `pumactl` and the control server in the README.](https://github.com/puma/puma#controlstatus-server). + +### Puma.stats + +`Puma.stats` produces a JSON string. `Puma.stats_hash` produces a ruby hash. + +#### in single mode + +Invoke `Puma.stats` anywhere in runtime, e.g. in a rails initializer: + +```ruby +# config/initializers/puma_stats.rb + +Thread.new do + loop do + sleep 30 + puts Puma.stats + end +end +``` + +#### in cluster mode + +Invoke `Puma.stats` from the master process + +```ruby +# config/puma.rb + +before_fork do + Thread.new do + loop do + puts Puma.stats + sleep 30 + end + end +end +``` + + +## Explanation of stats + +`Puma.stats` returns different information and a different structure depending on if Puma is in single vs. cluster mode. There is one top-level attribute that is common to both modes: + +* started_at: when Puma was started + +### single mode and individual workers in cluster mode + +When Puma runs in single mode, these stats are available at the top level. When Puma runs in cluster mode, these stats are available within the `worker_status` array in a hash labeled `last_status`, in an array of hashes where one hash represents each worker. + +* backlog: requests that are waiting for an available thread to be available. if this is above 0, you need more capacity [always true?] +* running: how many threads are running +* pool_capacity: the number of requests that the server is capable of taking right now. For example, if the number is 5, then it means there are 5 threads sitting idle ready to take a request. If one request comes in, then the value would be 4 until it finishes processing. If the minimum threads allowed is zero, this number will still have a maximum value of the maximum threads allowed. +* max_threads: the maximum number of threads Puma is configured to spool per worker +* requests_count: the number of requests this worker has served since starting + + +### cluster mode + +* phase: which phase of restart the process is in, during [phased restart](https://github.com/puma/puma/blob/master/docs/restart.md) +* workers: ?? +* booted_workers: how many workers currently running? +* old_workers: ?? +* worker_status: array of hashes of info for each worker (see below) + +### worker status + +* started_at: when the worker started +* pid: the process id of the worker process +* index: each worker gets a number. if Puma is configured to have 3 workers, then this will be 0, 1, or 2 +* booted: if it's done booting [?] +* last_checkin: Last time the worker responded to the master process' heartbeat check. +* last_status: a hash of info about the worker's state handling requests. See the explanation for this in "single mode and individual workers in cluster mode" section above. + + +## Examples + +Here are two example stats hashes produced by `Puma.stats`: + +### single + +```json +{ + "started_at": "2021-01-14T07:12:35Z", + "backlog": 0, + "running": 5, + "pool_capacity": 5, + "max_threads": 5, + "requests_count": 3 +} +``` + +### cluster + +```json +{ + "started_at": "2021-01-14T07:09:17Z", + "workers": 2, + "phase": 0, + "booted_workers": 2, + "old_workers": 0, + "worker_status": [ + { + "started_at": "2021-01-14T07:09:24Z", + "pid": 64136, + "index": 0, + "phase": 0, + "booted": true, + "last_checkin": "2021-01-14T07:11:09Z", + "last_status": { + "backlog": 0, + "running": 5, + "pool_capacity": 5, + "max_threads": 5, + "requests_count": 2 + } + }, + { + "started_at": "2021-01-14T07:09:24Z", + "pid": 64137, + "index": 1, + "phase": 0, + "booted": true, + "last_checkin": "2021-01-14T07:11:09Z", + "last_status": { + "backlog": 0, + "running": 5, + "pool_capacity": 5, + "max_threads": 5, + "requests_count": 1 + } + } + ] +} +``` diff --git a/docs/systemd.md b/docs/systemd.md new file mode 100644 index 0000000..6dd6401 --- /dev/null +++ b/docs/systemd.md @@ -0,0 +1,247 @@ +# systemd + +[systemd](https://www.freedesktop.org/wiki/Software/systemd/) is a commonly +available init system (PID 1) on many Linux distributions. It offers process +monitoring (including automatic restarts) and other useful features for running +Puma in production. + +## Service Configuration + +Below is a sample puma.service configuration file for systemd, which can be +copied or symlinked to `/etc/systemd/system/puma.service`, or if desired, using +an application or instance-specific name. + +Note that this uses the systemd preferred "simple" type where the start command +remains running in the foreground (does not fork and exit). + +~~~~ ini +[Unit] +Description=Puma HTTP Server +After=network.target + +# Uncomment for socket activation (see below) +# Requires=puma.socket + +[Service] +# Puma supports systemd's `Type=notify` and watchdog service +# monitoring, if the [sd_notify](https://github.com/agis/ruby-sdnotify) gem is installed, +# as of Puma 5.1 or later. +# On earlier versions of Puma or JRuby, change this to `Type=simple` and remove +# the `WatchdogSec` line. +Type=notify + +# If your Puma process locks up, systemd's watchdog will restart it within seconds. +WatchdogSec=10 + +# Preferably configure a non-privileged user +# User= + +# The path to your application code root directory. +# Also replace the "" placeholders below with this path. +# Example /home/username/myapp +WorkingDirectory= + +# Helpful for debugging socket activation, etc. +# Environment=PUMA_DEBUG=1 + +# SystemD will not run puma even if it is in your path. You must specify +# an absolute URL to puma. For example /usr/local/bin/puma +# Alternatively, create a binstub with `bundle binstubs puma --path ./sbin` in the WorkingDirectory +ExecStart=//bin/puma -C /puma.rb + +# Variant: Rails start. +# ExecStart=//bin/puma -C /config/puma.rb ../config.ru + +# Variant: Use `bundle exec --keep-file-descriptors puma` instead of binstub +# Variant: Specify directives inline. +# ExecStart=//puma -b tcp://0.0.0.0:9292 -b ssl://0.0.0.0:9293?key=key.pem&cert=cert.pem + + +Restart=always + +[Install] +WantedBy=multi-user.target +~~~~ + +See +[systemd.exec](https://www.freedesktop.org/software/systemd/man/systemd.exec.html) +for additional details. + +## Socket Activation + +systemd and Puma also support socket activation, where systemd opens the +listening socket(s) in advance and provides them to the Puma master process on +startup. Among other advantages, this keeps listening sockets open across puma +restarts and achieves graceful restarts, including when upgraded Puma, and is +compatible with both clustered mode and application preload. + +**Note:** Any wrapper scripts which `exec`, or other indirections in `ExecStart` +may result in activated socket file descriptors being closed before reaching the +puma master process. For example, if using `bundle exec`, pass the +`--keep-file-descriptors` flag. `bundle exec` can be avoided by using a `puma` +executable generated by `bundle binstubs puma`. This is tracked in [#1499]. + +**Note:** Socket activation doesn't currently work on JRuby. This is tracked in +[#1367]. + +Configure one or more `ListenStream` sockets in a companion `*.socket` unit file +to use socket activation. Also, uncomment the associated `Requires` directive +for the socket unit in the service file (see above.) Here is a sample +puma.socket, matching the ports used in the above puma.service: + +~~~~ ini +[Unit] +Description=Puma HTTP Server Accept Sockets + +[Socket] +ListenStream=0.0.0.0:9292 +ListenStream=0.0.0.0:9293 + +# AF_UNIX domain socket +# SocketUser, SocketGroup, etc. may be needed for Unix domain sockets +# ListenStream=/run/puma.sock + +# Socket options matching Puma defaults +NoDelay=true +ReusePort=true +Backlog=1024 + +[Install] +WantedBy=sockets.target +~~~~ + +See +[systemd.socket](https://www.freedesktop.org/software/systemd/man/systemd.socket.html) +for additional configuration details. + +Note that the above configurations will work with Puma in either single process +or cluster mode. + +### Sockets and symlinks + +When using releases folders, you should set the socket path using the shared +folder path (ex. `/srv/projet/shared/tmp/puma.sock`), not the release folder +path (`/srv/projet/releases/1234/tmp/puma.sock`). + +Puma will detect the release path socket as different than the one provided by +systemd and attempt to bind it again, resulting in the exception `There is +already a server bound to:`. + +### Binding + +By default, you need to configure Puma to have binds matching with all +ListenStream statements. Any mismatched systemd ListenStreams will be closed by +Puma. + +To automatically bind to all activated sockets, the option +`--bind-to-activated-sockets` can be used. This matches the config DSL +`bind_to_activated_sockets` statement. This will cause Puma to create a bind +automatically for any activated socket. When systemd socket activation is not +enabled, this option does nothing. + +This also accepts an optional argument `only` (DSL: `'only'`) to discard any +binds that's not socket activated. + +## Usage + +Without socket activation, use `systemctl` as root (i.e., via `sudo`) as with +other system services: + +~~~~ sh +# After installing or making changes to puma.service +systemctl daemon-reload + +# Enable so it starts on boot +systemctl enable puma.service + +# Initial startup. +systemctl start puma.service + +# Check status +systemctl status puma.service + +# A normal restart. Warning: listener's sockets will be closed +# while a new puma process initializes. +systemctl restart puma.service +~~~~ + +With socket activation, several but not all of these commands should be run for +both socket and service: + +~~~~ sh +# After installing or making changes to either puma.socket or +# puma.service. +systemctl daemon-reload + +# Enable both socket and service, so they start on boot. Alternatively +# you could leave puma.service disabled, and systemd will start it on +# the first use (with startup lag on the first request) +systemctl enable puma.socket puma.service + +# Initial startup. The Requires directive (see above) ensures the +# socket is started before the service. +systemctl start puma.socket puma.service + +# Check the status of both socket and service. +systemctl status puma.socket puma.service + +# A "hot" restart, with systemd keeping puma.socket listening and +# providing to the new puma (master) instance. +systemctl restart puma.service + +# A normal restart, needed to handle changes to +# puma.socket, such as changing the ListenStream ports. Note +# daemon-reload (above) should be run first. +systemctl restart puma.socket puma.service +~~~~ + +Here is sample output from `systemctl status` with both service and socket +running: + +~~~~ +● puma.socket - Puma HTTP Server Accept Sockets + Loaded: loaded (/etc/systemd/system/puma.socket; enabled; vendor preset: enabled) + Active: active (running) since Thu 2016-04-07 08:40:19 PDT; 1h 2min ago + Listen: 0.0.0.0:9233 (Stream) + 0.0.0.0:9234 (Stream) + +Apr 07 08:40:19 hx systemd[874]: Listening on Puma HTTP Server Accept Sockets. + +● puma.service - Puma HTTP Server + Loaded: loaded (/etc/systemd/system/puma.service; enabled; vendor preset: enabled) + Active: active (running) since Thu 2016-04-07 08:40:19 PDT; 1h 2min ago + Main PID: 28320 (ruby) + CGroup: /system.slice/puma.service + ├─28320 puma 3.3.0 (tcp://0.0.0.0:9233,ssl://0.0.0.0:9234?key=key.pem&cert=cert.pem) [app] + ├─28323 puma: cluster worker 0: 28320 [app] + └─28327 puma: cluster worker 1: 28320 [app] + +Apr 07 08:40:19 hx puma[28320]: Puma starting in cluster mode... +Apr 07 08:40:19 hx puma[28320]: * Version 3.3.0 (ruby 2.2.4-p230), codename: Jovial Platypus +Apr 07 08:40:19 hx puma[28320]: * Min threads: 0, max threads: 16 +Apr 07 08:40:19 hx puma[28320]: * Environment: production +Apr 07 08:40:19 hx puma[28320]: * Process workers: 2 +Apr 07 08:40:19 hx puma[28320]: * Phased restart available +Apr 07 08:40:19 hx puma[28320]: * Activated tcp://0.0.0.0:9233 +Apr 07 08:40:19 hx puma[28320]: * Activated ssl://0.0.0.0:9234?key=key.pem&cert=cert.pem +Apr 07 08:40:19 hx puma[28320]: Use Ctrl-C to stop +~~~~ + +### capistrano3-puma + +By default, [capistrano3-puma](https://github.com/seuros/capistrano-puma) uses +`pumactl` for deployment restarts outside of systemd. To learn the exact +commands that this tool would use for `ExecStart` and `ExecStop`, use the +following `cap` commands in dry-run mode, and update from the above forking +service configuration accordingly. Note also that the configured `User` should +likely be the same as the capistrano3-puma `:puma_user` option. + +~~~~ sh +stage=production # or different stage, as needed +cap $stage puma:start --dry-run +cap $stage puma:stop --dry-run +~~~~ + +[Restart]: https://www.freedesktop.org/software/systemd/man/systemd.service.html#Restart= +[#1367]: https://github.com/puma/puma/issues/1367 +[#1499]: https://github.com/puma/puma/issues/1499 diff --git a/examples/CA/cacert.pem b/examples/CA/cacert.pem new file mode 100644 index 0000000..e99ecb3 --- /dev/null +++ b/examples/CA/cacert.pem @@ -0,0 +1,23 @@ +-----BEGIN CERTIFICATE----- +MIIDxzCCAq+gAwIBAgIBADANBgkqhkiG9w0BAQUFADA5MQswCQYDVQQGEwJVUzEO +MAwGA1UECgwFbG9jYWwxDTALBgNVBAsMBGFlcm8xCzAJBgNVBAMMAkNBMB4XDTEy +MDExNDAwMTcyN1oXDTE3MDExMjAwMTcyN1owOTELMAkGA1UEBhMCVVMxDjAMBgNV +BAoMBWxvY2FsMQ0wCwYDVQQLDARhZXJvMQswCQYDVQQDDAJDQTCCASIwDQYJKoZI +hvcNAQEBBQADggEPADCCAQoCggEBAN8bteVsrQxfKHzYuwP1vjH2r6qavPK/agCK +bbPXZmWfUUGjL4ZT4jmnz/B6QNBBKTE/zWcuLXvyRR2FUCi8c5itUvraJVIuBPT/ +lvAZfbyIMpdHG1RPwA6jgTTXm7hnfZc0lCzsFRLk106XrjKeIkZOWffnVLNS2dyH +X51/yZAS8wFyfx58gabC3hvzJLWw/fDSB/qQsOjp5XCCrP30ILPads/P2dEFNZ1M +bjGNErVjmEWEorbUsh6Gu3OyElicVf9hgHspFYNwl1rc5IX7Z5eQM9Yd/Lm1mlvU +iM839ZPn2UOtS9EDdeeZImTSALSUoFJjMdt8+synSDUuGPczUzECAwEAAaOB2TCB +1jAPBgNVHRMBAf8EBTADAQH/MDEGCWCGSAGG+EIBDQQkFiJSdWJ5L09wZW5TU0wg +R2VuZXJhdGVkIENlcnRpZmljYXRlMB0GA1UdDgQWBBReztszntEK4mwESl/gPjc8 +VKU2ljAOBgNVHQ8BAf8EBAMCAQYwYQYDVR0jBFowWIAUXs7bM57RCuJsBEpf4D43 +PFSlNpahPaQ7MDkxCzAJBgNVBAYTAlVTMQ4wDAYDVQQKDAVsb2NhbDENMAsGA1UE +CwwEYWVybzELMAkGA1UEAwwCQ0GCAQAwDQYJKoZIhvcNAQEFBQADggEBABC6pRY+ +c+MKGG6hWv9FKTW5drw/9bfKxl+dVcKPP5YWuoAMtStkCVnDleQ7K2oN4o7kwr7Q +cU3mmYJZjqRu43JBebzupBGKqe/mNWGN0EuCMT7khFEXbO3bwpcL0fhCO7+RZccx +GF/LKglLgQSE+/SKOHlHdJZlS3EgPghrtoSiptx9ytXzkgCoEKypbAEmcArWvzzF +81ZYjkLAwCrrB/qNAKnI0AKXMCiqnZu+8a16p5z+HGCjpTLB3NQ3YlyFF0jbr/ow +R1Fb07t0uO2o22nuua+iK8lKqWLE6eQUIu/YB6DMEgjk+D6of+WQ+38GC35QyPKA +9nQ8kMf2RkiGN6M= +-----END CERTIFICATE----- diff --git a/examples/CA/newcerts/cert_1.pem b/examples/CA/newcerts/cert_1.pem new file mode 100644 index 0000000..618a247 --- /dev/null +++ b/examples/CA/newcerts/cert_1.pem @@ -0,0 +1,19 @@ +-----BEGIN CERTIFICATE----- +MIIC/jCCAeagAwIBAgIBATANBgkqhkiG9w0BAQUFADA5MQswCQYDVQQGEwJVUzEO +MAwGA1UECgwFbG9jYWwxDTALBgNVBAsMBGFlcm8xCzAJBgNVBAMMAkNBMB4XDTEy +MDExNDAwMTcyN1oXDTEzMDExMzAwMTcyN1owSDELMAkGA1UEBhMCVVMxDjAMBgNV +BAoMBWxvY2FsMQ0wCwYDVQQLDARhZXJvMQswCQYDVQQLDAJDQTENMAsGA1UEAwwE +cHVtYTCBnzANBgkqhkiG9w0BAQEFAAOBjQAwgYkCgYEAxkKjXHIYV4CVFB4YZuVk +sXqdb7X9+igKPSkxFZoyjwW+AGiN27OwTvETLQiPMaB1WiJtDF9NEmlwYXl4dHWz +5QPVJtQ5ud7FdCJWBUc+K6LS4xwixwis/FNZVfVcyxkR+cm7mVwxwrxle1lJasoJ +ouqtVePdt+j+9hcJfLIaZD8CAwEAAaOBhTCBgjAMBgNVHRMBAf8EAjAAMDEGCWCG +SAGG+EIBDQQkFiJSdWJ5L09wZW5TU0wgR2VuZXJhdGVkIENlcnRpZmljYXRlMB0G +A1UdDgQWBBQDrltCvbaqNl/TvFNb/NEIEnbJ2jALBgNVHQ8EBAMCBaAwEwYDVR0l +BAwwCgYIKwYBBQUHAwEwDQYJKoZIhvcNAQEFBQADggEBAF8xrbIbIz7YW6ydjM6g +gesu8T6q5RcJo9k2CWhd4RgJdfVJSZbCtAAMoRQTErX9Ng6afdlMWD1KnCfbP0IJ +dq+Umh1BMfzhpYR35NPO/RZPR7Et0OMmmWCbwJBzgI6z8R8qiSuR/to6C7BjiWzo +rp7S2fenkB6DfzZvHvIDojQ0OpnD2oYBOn/UyAma4I7XzXWe9IIUMARjS5CYZsv9 +HBU3B+e5F9ANi3lRc7x5jIAqVt292HaH+c1UCn0/r/73cW1Z/iNYA9PgS2QKdmYq +b3oQRFk0wM5stNVsrn7xGftmOETv8pD6r4P8jyw7Ib+ypr10WrFOm7uOscPS4QE2 +Mf0= +-----END CERTIFICATE----- diff --git a/examples/CA/newcerts/cert_2.pem b/examples/CA/newcerts/cert_2.pem new file mode 100644 index 0000000..7e68d62 --- /dev/null +++ b/examples/CA/newcerts/cert_2.pem @@ -0,0 +1,19 @@ +-----BEGIN CERTIFICATE----- +MIIC/jCCAeagAwIBAgIBAjANBgkqhkiG9w0BAQUFADA5MQswCQYDVQQGEwJVUzEO +MAwGA1UECgwFbG9jYWwxDTALBgNVBAsMBGFlcm8xCzAJBgNVBAMMAkNBMB4XDTEy +MDExNDAwMjcyN1oXDTEzMDExMzAwMjcyN1owSDELMAkGA1UEBhMCVVMxDjAMBgNV +BAoMBWxvY2FsMQ0wCwYDVQQLDARhZXJvMQswCQYDVQQLDAJDQTENMAsGA1UEAwwE +cHVtYTCBnzANBgkqhkiG9w0BAQEFAAOBjQAwgYkCgYEArxfNMp+g/pKhsDEkB3KR +1MAkbfnN/UKMvfXwlnXpz7YX1LHHnMutiI/PqymAp6BPcu+umuW2qMHQyqqtyATm +Z9jr3t837nhmxwG1noRaKRtsckn9FD43ZlpPg0Q5QnhS4oOsXwJzilqPjdDFYrKN +3TSvIGM2+hVqpVoGYAHDKbMCAwEAAaOBhTCBgjAMBgNVHRMBAf8EAjAAMDEGCWCG +SAGG+EIBDQQkFiJSdWJ5L09wZW5TU0wgR2VuZXJhdGVkIENlcnRpZmljYXRlMB0G +A1UdDgQWBBTyDyJlmYBDwfWdRj6lWGvoY43k9DALBgNVHQ8EBAMCBaAwEwYDVR0l +BAwwCgYIKwYBBQUHAwEwDQYJKoZIhvcNAQEFBQADggEBAIbBVfoVCG8RyVesPW+q +5i0wAMbHZ1fwv1RKp17c68DYDs0YYPi0bA0ss8AgpU6thWmskxPiFaE6D5x8iv9f +zkcHxgr1Mrbx6RLx9tLUVehSmRv3aiVO4k9Mp6vf+rJK1AYeaGBmvoqTBLwy7Jrt +ytKMdqMJj5jKWkWgEGgTnjzbcOClmCQab9isigIzTxMyC/LjeKZe8pPeVX6OM8bY +y8XGZp9B7uwdPzqt/g25IzTC0KsQwq8cB0raAtZzIyTNv42zcUjmQNVazAozCTcq +MsEtK2z7TYBC3udTsdyS2qVqCpsk7IMOBGrw8vk4SNhO+coiDObW2K/HNvhl0tZC +oQI= +-----END CERTIFICATE----- diff --git a/examples/CA/private/cakeypair.pem b/examples/CA/private/cakeypair.pem new file mode 100644 index 0000000..c90fd80 --- /dev/null +++ b/examples/CA/private/cakeypair.pem @@ -0,0 +1,30 @@ +-----BEGIN RSA PRIVATE KEY----- +Proc-Type: 4,ENCRYPTED +DEK-Info: DES-EDE3-CBC,8FE374A296255ED1 + +g6YSW6TUA/9dSscxCWPm11bG6DedWJ6fanU6V7O2n9WbGOLE0ogz877D/5gPr94+ +WJHnCb0O4gyKQA307XA9nq+HAPTyJFKroEz1CPXVrITV8AO+vJ/PUc1y1LQ1ymMk +fcvI3ZNdbDBr7OL7luYch7qoVULJ4kwJTU7WT9XzINiSnS3Ccqh6ZEPFyKIcxP2s +11WkpxdDJ911nCXVUoa9Hd5tQk7mHZuf7XL01up08SDobx/imaU9VN8QQG6AFE55 +jVtfv7MxP+9gHmHQxuhYuDMnu5GIwuJPFHvI7Gi9jcwvee/GhcKBnKdpFc92fJJ8 ++TIqR2D21EHDBoep1fMGgbPOl+9z1hdE78Sj6tHwjeRF93mhJWyYNQWQ5ViKLnoF +j11idWOXwkOCFttRBMd74QG6GyxTvs8FNDOXmm361Muk94a4fbKRJvKvYZlBnYKu +fOmJNFf2zEVVHjBCbvM4swAT09cWLxRMRTiFb5y7QAEmtFO4WLavlmnNCdMq/uC4 +CpFqGtoiaCimunjTfvkBaJngSfTYSrd4cStnx/c0XK++dni+bLXUHOyMxvihl5vn +SiFlzWTmoWf1gxNZgOSKY432R6T1CQXfnAd3x/FCJjfPqFt+RAFXjlVFNA0FZyVE +sCxhVx1eZsr7aMJ5H9RehUr6b9swUEm4UGX5H3/GG7GNCZU+fA+Wfi9cl1zqJFey +Ho5UjjmRgdV1qapioqCd+Ce/mG0LxRPt/hYdA6G5h4zheRc3KZ7YbIwWRwlkm2w5 +is4ToZKwheycaaQnUfOdHUTtZ4Kv0kRof+LMcDUDTrsydWF4T4xGxGD7/CVJkH1G +5OTVsfv6Tw7kEMYaXYBQPs0u3GSxY3CZ+k5wATr9PBBYcArSkt5WNQYCJfO/MnWF +z/31hp/ziCIoesgo6uZMO4Dr5Pka54nc4O4KOblvUUMX07WkYGrc4nxBGvhQ5Jl4 +A8dJBPCK3OlsVCnHYrDQ0cemhLOYPuiyKTtCUIs2nHuiM4RwoCRJgsVBUnKK+tTx +AkM9uQvYsrZ/DoBooBdXJQy3uiHH86zEskiy72H8Wgcu8GbLt2JgCyhXkwDzrIRf +hnAN4FS2VNOt5dDTVHBWG1vIxxlM2+LrYpY/QqihNgotZ+C4VWHkoDwbF478JgxM +5Yk+0X9kGvLQbZCJFXdAKAyr/AzRH+Hx1cDvSi7gypf8qOEZwD1rq7f0qw8jnqfG +3QIFoN1/+xTAV8lTlGhvbQYz1XHVBH9l7TSQDLIrnwHTIv+PdZbTveGftCCnLdDo +wBLBnw4mKVCtnHrEgXMQF62yuwueQ8zhdh8jf3osYV/COlRZwQQGgZtnQCeeyDIh +8GJR9b4uv22QDNv7J2vcqTEWJdnpAZvIBFGuCBCAgev+URLGW2ELXfWQwNgc5+yP +nGRXo+IwD1uhvEqtuin+cAn/sJhOa66g0ZcV/3AcrdQhbicn12YM71cMvA/XRKf5 +rpo8bAEwDqyoFoywH4IHM3HNV45rS+brskz6tZC5ELondCPVmUqgVu7ELHlJfPXx +RbzbMPJEGr8WjWUiTDhrD2vWgoJ6NRKkDAUYm6KQb8Sbajd2JAAlYntLz5jKqNqN +-----END RSA PRIVATE KEY----- diff --git a/examples/CA/serial b/examples/CA/serial new file mode 100644 index 0000000..2f01c85 --- /dev/null +++ b/examples/CA/serial @@ -0,0 +1 @@ +0003 \ No newline at end of file diff --git a/examples/plugins/redis_stop_puma.rb b/examples/plugins/redis_stop_puma.rb new file mode 100644 index 0000000..5140e6c --- /dev/null +++ b/examples/plugins/redis_stop_puma.rb @@ -0,0 +1,46 @@ +require 'puma/plugin' +require 'redis' + +# How to stop Puma on Heroku +# - You can't use normal methods because the dyno is not accessible +# - There's no file system, no way to send signals +# but ... +# - You can use Redis or Memcache; any network distributed key-value +# store + +# 1. Add this plugin to your 'lib' directory +# 2. In the `puma.rb` config file add the following lines +# === Plugins === +# require './lib/puma/plugin/redis_stop_puma' +# plugin 'redis_stop_puma' +# 3. Now, when you set the redis key "puma::restart::web.1", your web.1 dyno +# will restart +# 4. Sniffing the Heroku logs for R14 errors is application (and configuration) +# specific. I use the Logentries service, watch for the pattern and the call +# a webhook back into my app to set the Redis key. YMMV + +# You can test this locally by setting the DYNO environment variable when +# when starting puma, e.g. `DYNO=pants.1 puma` + +Puma::Plugin.create do + def start(launcher) + + hostname = ENV['DYNO'] + return unless hostname + + redis = Redis.new(url: ENV.fetch('REDIS_URL', nil)) + return unless redis.ping == 'PONG' + + in_background do + while true + sleep 2 + if message = redis.get("puma::restart::#{hostname}") + redis.del("puma::restart::#{hostname}") + $stderr.puts message + launcher.stop + break + end + end + end + end +end diff --git a/examples/puma/cert_puma.pem b/examples/puma/cert_puma.pem new file mode 100644 index 0000000..a073099 --- /dev/null +++ b/examples/puma/cert_puma.pem @@ -0,0 +1,21 @@ +-----BEGIN CERTIFICATE----- +MIIDgjCCAmqgAwIBAgIBAjANBgkqhkiG9w0BAQsFADA5MQswCQYDVQQGEwJVUzEO +MAwGA1UECgwFbG9jYWwxDTALBgNVBAsMBGFlcm8xCzAJBgNVBAMMAkNBMB4XDTIw +MDgwMTAwMDAwMFoXDTI0MDgwMTAwMDAwMFowSDELMAkGA1UEBhMCVVMxDjAMBgNV +BAoMBWxvY2FsMQ0wCwYDVQQLDARhZXJvMQswCQYDVQQLDAJDQTENMAsGA1UEAwwE +cHVtYTCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAL9pa0gvlNdwge/u +aZNE5hTyBLH3eQzdduGcYjoSy6AVgab8M8s5O7RPMEDORV6xRuGfNgRI40Kkx4E8 +w5icHSp9WumsEi5FrKIcIXttquLdcBkUEq9N/mPVZAlg5Jr5IePzWafM0gTdKlR1 +LN/UHcVaMHWt4/Kz2ja9wlUhaKly7+UG1JdHhQ1yrAVVUTLN9YT8VTkyaB11+K0m +KpdvHcyFuB4yBcvCd4iGSIqf7wjlEIRp8Pa9C6tR8gAlCi4APlzmngYod3wbXAhE +psjvSXCWCdeHKD/wAgBz1abA4yNnSIhb4KFFkGMn+F74ZjeCZN287lz/18gQLn06 +3EXVKIECAwEAAaOBhTCBgjAMBgNVHRMBAf8EAjAAMDEGCWCGSAGG+EIBDQQkFiJS +dWJ5L09wZW5TU0wgR2VuZXJhdGVkIENlcnRpZmljYXRlMB0GA1UdDgQWBBTyDyJl +mYBDwfWdRj6lWGvoY43k9DALBgNVHQ8EBAMCBaAwEwYDVR0lBAwwCgYIKwYBBQUH +AwEwDQYJKoZIhvcNAQELBQADggEBAH2YCJJY9RFO8a7wW+9LonTVL1QLvpS0bgqC +1CvW3ANCXriCXHUHc/aLmneRfXhrezCcAgqyqG2+HxJ3fLllec7lbiznnV7DaAmn +Jhgmlho9fw2FxPA4iZ5DQvCALS0Ho4Vo+kPVExbhH4XKZkVTJosms5TWmDaeyfN0 +PyWDeyKsjqi9oXqqAZKBo9DFWxkJUThzpxXdWo2S9cKt7EWJJlgdlmQGyoo39Xdu +86MxNGfaS+7ChzcXQVu1B/t0fpFKfkVCEKvNEQ50v/D01Ed0hgQPOaqgFvFMp0eU +B0b1xpKQ2OshyOK1048ou3Gv+fAw+xtcC840T0DDMte73sysXtg= +-----END CERTIFICATE----- diff --git a/examples/puma/client-certs/ca.crt b/examples/puma/client-certs/ca.crt new file mode 100644 index 0000000..a69ea6f --- /dev/null +++ b/examples/puma/client-certs/ca.crt @@ -0,0 +1,19 @@ +-----BEGIN CERTIFICATE----- +MIIDDjCCAfagAwIBAgIBAzANBgkqhkiG9w0BAQsFADA4MRMwEQYKCZImiZPyLGQB +GRYDbmV0MRQwEgYKCZImiZPyLGQBGRYEcHVtYTELMAkGA1UEAwwCQ0EwHhcNMjAw +ODAxMDAwMDAwWhcNMjQwODAxMDAwMDAwWjA4MRMwEQYKCZImiZPyLGQBGRYDbmV0 +MRQwEgYKCZImiZPyLGQBGRYEcHVtYTELMAkGA1UEAwwCQ0EwggEiMA0GCSqGSIb3 +DQEBAQUAA4IBDwAwggEKAoIBAQDIHxrFcS2JkRQbXLFosb32unVkVuwHSPSt6Dpl +2jUQHP/bceAx/d9waHYf8rlbCFAIoduZDOc7XCJUidgcG5NfLJyQpkkWOU8CGWH+ +Ipl4AE8auYCcy/0T7BQqaRC41HPmrJG1CC40rqcY47lUO2haI+vj5TZFHNhAbRat +rR1iD1veis2gBZtrMzd4IlpvEHGv6ghfnSc20za4exmapjp/uAAIOXpeFX8QHumA +bty4dd+iHpKjDzUrhG9Qa5v28ii2K1AcbczUQ7FzSp2/GoRSjF+WY6i86N9Z1M97 +2PEgy0IG5l6JHu1P0/rd00hN0h0Owzv3V5ldMLZap7+pVFQTAgMBAAGjIzAhMA8G +A1UdEwEB/wQFMAMBAf8wDgYDVR0PAQH/BAQDAgEGMA0GCSqGSIb3DQEBCwUAA4IB +AQA3GWpy4bYLOHLENDTUBeclF6cDdYiautD6gxd1SDLMWOhAUF7ywwT87OAJdr1I ++W1TUv5BRG21rNm1QsZfLbStdKA1mpiET/9nYN7m1YauL5hI3yD49TGuO9/sxcE5 +zNW7D3VBVNq+pyT21/TvLAgxCNvjjm7byzyIOcoRUyZx8WhCf8nUT6cEShXqEg4Q +iUBSLI38tiQoZneuVzDRlXBY0PqoB19l2Kg9yThHjPTVhw5EAQSDKXCCvaxAbVw6 +ZPLNnOdK6DvqEZ3GC5WlaHQdmLxmN4OfV6AEtpgqgGY9u8K1ylTr3ET7xLK7bhcA +oZsggEVZr1Ifx9BWIazRNwlw +-----END CERTIFICATE----- diff --git a/examples/puma/client-certs/ca.key b/examples/puma/client-certs/ca.key new file mode 100644 index 0000000..59bc391 --- /dev/null +++ b/examples/puma/client-certs/ca.key @@ -0,0 +1,27 @@ +-----BEGIN RSA PRIVATE KEY----- +MIIEpgIBAAKCAQEAyB8axXEtiZEUG1yxaLG99rp1ZFbsB0j0reg6Zdo1EBz/23Hg +Mf3fcGh2H/K5WwhQCKHbmQznO1wiVInYHBuTXyyckKZJFjlPAhlh/iKZeABPGrmA +nMv9E+wUKmkQuNRz5qyRtQguNK6nGOO5VDtoWiPr4+U2RRzYQG0Wra0dYg9b3orN +oAWbazM3eCJabxBxr+oIX50nNtM2uHsZmqY6f7gACDl6XhV/EB7pgG7cuHXfoh6S +ow81K4RvUGub9vIotitQHG3M1EOxc0qdvxqEUoxflmOovOjfWdTPe9jxIMtCBuZe +iR7tT9P63dNITdIdDsM791eZXTC2Wqe/qVRUEwIDAQABAoIBAQCrFcxxV5yyqxEh +g1E4TBw3Ppj1u0n1wG1N7+ddA/uxVtl15hjhJEVNeEDkd0H3jVe+yYFPizR0DwRa +ea4D+Z84Eo+XKlH5ae0dwk2AUlwZt0npcwV9BvfJfF6RE1l0akzbvFSlC+VUrKu2 +H5llZZSE24jjQCXxWAOYsKpeuE0ScsIvgKIW7i7sSKE8x8bRyEY6nF8ayfwLoDId +O1eIYM9bYt+y/K0MnZLuKxKRMXDiORSTu9ujR5NVmDkb4DJgVhkz8DcN9K9UV5FE +tRZzD13fOJS/RnKjoGCtJV4G8vzWvtqcaQaxxCCfYQS+XChqUExNxooOVQjlx+AE +HWrJ/oEhAoGBAPkrY5P6OfV7GOC5CxcI4wv955vf17rT85VSyqHRztiLv90IFi0B +snBjocJo466Xsi85QNkRHyW/DG0xuZ/lJyJyM79prOV0PXRlEr8loR3RT7OqK8KQ +nX4Ip7ELixT7Kspwh+p3S/Z2/9kR0XXJ8aJCb5Xz31YvswEew7Z7kbNHAoGBAM2b +gjw6BGzP9Ni9xTQKKK6NBimlsKqg/pnvEVSMb4lNNB/nPlVEzFUc5iFHX5luaaCr +dsaa7vOjUZ14aLQcuGTDrWKc82Vn2g6A+//TXbm0zWK5x1fctFdUxHPmBNiTO5xc ++29cGv/laehyphkIfQsLsmnOeMvDkyNeX0L0JYbVAoGBAL4so5/5x+rYvTAni5NV +MRWiAPgzbJAn3S4HNqkzXVBhuVqWJXbMaMjnAjtDmyNSnKj2ZcxHCSLiIjXlUev8 +FlZwG5borRGkGpOP4TMLIWGEs/RI2YVyowHi0TqLuOeWnB5OrS4DR3MheDzRILFq +JIbXdhtZOwio91LPjEjnH1lZAoGBAI5mnAa2cAYk6YGLvZ9TQeXSymfh17/1jSB0 +EV6rfTxs+iL2d5d69MImJ8T4t99+Ny4OU08uUzzu6kHT+UB1e8heNiHMbk7XZJET +CHWgoJNUA8PSw5u4wjaSARX8Q3L0Vh7vzzzLX+/HplhVv3ArDt+tlD3vwH3v0GJ4 +pCWtDqiZAoGBAMjf2InVEJsQD/uOFblpgzTxNXoWnYDA2udIpHOgcdw3+JhZzOXx +01WReomSBtuc0XStJdZKZbLuYgtG3XEJPA8rqS4SVm+M05XTCnDVkpENAZf14u8r +8dbPJUWzoVDkEuJespgixpJ18JAytDXvl5gzFp6Gr2O0Dd7arN/8IIP8 +-----END RSA PRIVATE KEY----- diff --git a/examples/puma/client-certs/client.crt b/examples/puma/client-certs/client.crt new file mode 100644 index 0000000..9703ebd --- /dev/null +++ b/examples/puma/client-certs/client.crt @@ -0,0 +1,19 @@ +-----BEGIN CERTIFICATE----- +MIIDBDCCAeygAwIBAgIBCzANBgkqhkiG9w0BAQsFADA4MRMwEQYKCZImiZPyLGQB +GRYDbmV0MRQwEgYKCZImiZPyLGQBGRYEcHVtYTELMAkGA1UEAwwCQ0EwHhcNMjAw +ODAxMDAwMDAwWhcNMjQwODAxMDAwMDAwWjA/MRMwEQYKCZImiZPyLGQBGRYDbmV0 +MRQwEgYKCZImiZPyLGQBGRYEcHVtYTESMBAGA1UEAwwJbG9jYWxob3N0MIIBIjAN +BgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAoywLJRT7wZM1P3Abg9NIT+NJ8TkS +IBz8ZgyyhSW1lU/tcsNEJakIl22XAeLEHfMknMzDqvU1G256hqM+/Tgohreat+yc +ofy+2OOu4un+44qIGX82i2PCt+afVvbGhPpHioyAJRDXZd9V4FtESH+6As7m+6MH +E4yX547E6XmmJ9lp8z9FnBMi1a6C01bI6mZhMuReLl2bS0FWjqrwNN3PpizBD+Vv +FbUIndC+UiD0kYPxTwSDCpEq/To0FrNCdAj94Y4oZVuvx8B1mHiqwkqmlOLUZslS +u7CFInqbswq5LnGFRIVSg3bu4bgCbCEmJhfqxp5MODT4VBXa2sCOnG7BBwIDAQAB +oxIwEDAOBgNVHQ8BAf8EBAMCBLAwDQYJKoZIhvcNAQELBQADggEBAApaKvnGiRqs +Vz4B06f/mDLmN7gb0e/evEypB2S06fp9f/oWWHXHCxpx9bKbiHFNNiinoc97/Spb +GzylHV/n9j8GEtZmJMiSJZubN8vpn1Jg0Usz3ZtAy0fe9ephjBIXPpwrsGF6Oz3u +U/b2zTpJX3oJ3Eq6Q9hLplYU3bNHfrw6PAbzbB91zVTFsqZUTctKrMRgp68FA4fw +VWQkb4QwLK1UVJIT/nR3v7nJDktgpR4mfdrtWbWxOGm/ed2oOilpqVhVN99P+bUn +3cFkQ6PSYK8En9jJhBMe/zQqpSyy09Tfi/Zy6nzZv0ah53bpeCFLZFi+0CU8HwPr +MuD1XQN72zg= +-----END CERTIFICATE----- diff --git a/examples/puma/client-certs/client.key b/examples/puma/client-certs/client.key new file mode 100644 index 0000000..15820ee --- /dev/null +++ b/examples/puma/client-certs/client.key @@ -0,0 +1,27 @@ +-----BEGIN RSA PRIVATE KEY----- +MIIEowIBAAKCAQEAoywLJRT7wZM1P3Abg9NIT+NJ8TkSIBz8ZgyyhSW1lU/tcsNE +JakIl22XAeLEHfMknMzDqvU1G256hqM+/Tgohreat+ycofy+2OOu4un+44qIGX82 +i2PCt+afVvbGhPpHioyAJRDXZd9V4FtESH+6As7m+6MHE4yX547E6XmmJ9lp8z9F +nBMi1a6C01bI6mZhMuReLl2bS0FWjqrwNN3PpizBD+VvFbUIndC+UiD0kYPxTwSD +CpEq/To0FrNCdAj94Y4oZVuvx8B1mHiqwkqmlOLUZslSu7CFInqbswq5LnGFRIVS +g3bu4bgCbCEmJhfqxp5MODT4VBXa2sCOnG7BBwIDAQABAoIBAGsjyE2Y8ZWxKw10 +dxyf5qNOAoc5igU8Ax6ex7lVgV2BFdB9FooD63hCpRy/4TYpKKksam4eg7h3Wkx9 +dCagcTvD4vtRiadzZXzUQ0kLjCmsFKFpPk9YOcq2y3k2oDNAgykeCCZOYKCrfJ/M +TZGtDF47rL8d1M+pSTTqMbF8BvWyZ3hTKkB4dNbF4uNQ5EPD8fgkmPOMR9Ul5R3X +XvrzDWYJb0+qElbtP4Y570KQTmbBpTj2soFb2fLuPv4NpBTNx3xIja4fs7wYP46M +k1dI+wQnrC512rpacowOtWKlqx3yBrtKNjg39faPHQQpfPqkJNYZZPVA9rc255SG +l4B7y4ECgYEA1v+8z4r+lkrX/t0L2WmuHHjE6no+1c3C4RdHkkUYvgUWdE7fjRGH +fpcfZu/aTcnTj+LAkZpEty8gDZKqb8tpliMbpnkzx7Li4AYQof5QYcWQb9rJYihT +70fJgLN2QQCAD7VC00AuUam7D8r4o4uImmrOxa5jqZFHztphKXMU4p8CgYEAwkoc +vZ/LRcbiKm72/CJ8RI9YgkQanFye/6cYwsVyrydmaevxdCq68hcafPSHwJSraEK9 +zo2T6qaZZr1zdwdSFutsBfOw6g7MMPfxtUPtmHJnwoFsEBSjwSwddwi/RLXiZGUK +I31z1sRO4XoLzhvESZZP9aCURT3MSwqFTWsasJkCgYEApyKpoeHYpfdK0FsAciRA +cOvFkM41eLn7LEaPofrLEDUeTo5eJOkinttWUwxUdbJXH/zTXJ1Dm/Arh8Gjc0L7 +MvbZ8OE5yp2a1zJ/zZ7I2CjgbsPzV7YoAdSZpc5dOIzuAMgVSeoT1/INdGqCPYkk +SX6MfYpi+Zfx7bFAZRuMedsCgYA2Qrp6HumHSD8buLfTvNHV1+7RGrIP3zIslf8t +TjV0Q12v0UwytEhXmio0oZpUJ3Ejghg+Wn3n97U540kfAfVkH0Wg9+j9xTozpttj +U2BExhbCVKDYcNs29NoZx2CbkOx0O1+0f7HdVh/tisdHPav5HTihkcI3AEZQ4tRN +xc7DaQKBgFtj7G/nbhU1Rr1d7HJk8to88zHvUPPWI8AaqyQKDKD2nOSAysA0v9h6 +z7FG0SidXeCZF8NnlxRcR6+Zf/oDgx/akKGXGGGifgAIjkq53CQewNyg8iQda0cF +3wU8z+qAajPnhXEZ7T/OO1pRUUQRvM1obOLVORTzP+ZTD9/RZu4F +-----END RSA PRIVATE KEY----- diff --git a/examples/puma/client-certs/client_expired.crt b/examples/puma/client-certs/client_expired.crt new file mode 100644 index 0000000..cd20e04 --- /dev/null +++ b/examples/puma/client-certs/client_expired.crt @@ -0,0 +1,19 @@ +-----BEGIN CERTIFICATE----- +MIIDBDCCAeygAwIBAgIBFzANBgkqhkiG9w0BAQsFADA4MRMwEQYKCZImiZPyLGQB +GRYDbmV0MRQwEgYKCZImiZPyLGQBGRYEcHVtYTELMAkGA1UEAwwCQ0EwHhcNMTkw +ODAxMDAwMDAwWhcNMjAwODAxMDAwMDAwWjA/MRMwEQYKCZImiZPyLGQBGRYDbmV0 +MRQwEgYKCZImiZPyLGQBGRYEcHVtYTESMBAGA1UEAwwJbG9jYWxob3N0MIIBIjAN +BgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAoywLJRT7wZM1P3Abg9NIT+NJ8TkS +IBz8ZgyyhSW1lU/tcsNEJakIl22XAeLEHfMknMzDqvU1G256hqM+/Tgohreat+yc +ofy+2OOu4un+44qIGX82i2PCt+afVvbGhPpHioyAJRDXZd9V4FtESH+6As7m+6MH +E4yX547E6XmmJ9lp8z9FnBMi1a6C01bI6mZhMuReLl2bS0FWjqrwNN3PpizBD+Vv +FbUIndC+UiD0kYPxTwSDCpEq/To0FrNCdAj94Y4oZVuvx8B1mHiqwkqmlOLUZslS +u7CFInqbswq5LnGFRIVSg3bu4bgCbCEmJhfqxp5MODT4VBXa2sCOnG7BBwIDAQAB +oxIwEDAOBgNVHQ8BAf8EBAMCBLAwDQYJKoZIhvcNAQELBQADggEBAHDfDovSTCmM +sxDCfTGQUYwnvOohTP0hjUHB6BzNAPVKYoBiq064m/JoDFmOGGtF3CqhWqtE2psl +eOK8fA/QomyFaIAowhx8qrswMP7T/rRldAG+9QHBYZGkPtbB8evK6XqrMEQTf2Ux +FlN3p7BZl9rhtuManMb+Wud3HfLYjXn2nTRvkOTi93MP05Vrho8KegZ9Kj4wY1rK +gOnkbI6bv+1r9yjsZuUKPH/OjFkpmAoOab5hX5R5CmEefGAet2KPCNrApuwfvRHT +x9jVwtOYBHq3DVcBDBu+O38L+WlKGeXvUK4AzvxQaVUysCG3DA1zrvyI3Y8Jy2Jk +KfmWZWlvXlM= +-----END CERTIFICATE----- diff --git a/examples/puma/client-certs/client_expired.key b/examples/puma/client-certs/client_expired.key new file mode 100644 index 0000000..15820ee --- /dev/null +++ b/examples/puma/client-certs/client_expired.key @@ -0,0 +1,27 @@ +-----BEGIN RSA PRIVATE KEY----- +MIIEowIBAAKCAQEAoywLJRT7wZM1P3Abg9NIT+NJ8TkSIBz8ZgyyhSW1lU/tcsNE +JakIl22XAeLEHfMknMzDqvU1G256hqM+/Tgohreat+ycofy+2OOu4un+44qIGX82 +i2PCt+afVvbGhPpHioyAJRDXZd9V4FtESH+6As7m+6MHE4yX547E6XmmJ9lp8z9F +nBMi1a6C01bI6mZhMuReLl2bS0FWjqrwNN3PpizBD+VvFbUIndC+UiD0kYPxTwSD +CpEq/To0FrNCdAj94Y4oZVuvx8B1mHiqwkqmlOLUZslSu7CFInqbswq5LnGFRIVS +g3bu4bgCbCEmJhfqxp5MODT4VBXa2sCOnG7BBwIDAQABAoIBAGsjyE2Y8ZWxKw10 +dxyf5qNOAoc5igU8Ax6ex7lVgV2BFdB9FooD63hCpRy/4TYpKKksam4eg7h3Wkx9 +dCagcTvD4vtRiadzZXzUQ0kLjCmsFKFpPk9YOcq2y3k2oDNAgykeCCZOYKCrfJ/M +TZGtDF47rL8d1M+pSTTqMbF8BvWyZ3hTKkB4dNbF4uNQ5EPD8fgkmPOMR9Ul5R3X +XvrzDWYJb0+qElbtP4Y570KQTmbBpTj2soFb2fLuPv4NpBTNx3xIja4fs7wYP46M +k1dI+wQnrC512rpacowOtWKlqx3yBrtKNjg39faPHQQpfPqkJNYZZPVA9rc255SG +l4B7y4ECgYEA1v+8z4r+lkrX/t0L2WmuHHjE6no+1c3C4RdHkkUYvgUWdE7fjRGH +fpcfZu/aTcnTj+LAkZpEty8gDZKqb8tpliMbpnkzx7Li4AYQof5QYcWQb9rJYihT +70fJgLN2QQCAD7VC00AuUam7D8r4o4uImmrOxa5jqZFHztphKXMU4p8CgYEAwkoc +vZ/LRcbiKm72/CJ8RI9YgkQanFye/6cYwsVyrydmaevxdCq68hcafPSHwJSraEK9 +zo2T6qaZZr1zdwdSFutsBfOw6g7MMPfxtUPtmHJnwoFsEBSjwSwddwi/RLXiZGUK +I31z1sRO4XoLzhvESZZP9aCURT3MSwqFTWsasJkCgYEApyKpoeHYpfdK0FsAciRA +cOvFkM41eLn7LEaPofrLEDUeTo5eJOkinttWUwxUdbJXH/zTXJ1Dm/Arh8Gjc0L7 +MvbZ8OE5yp2a1zJ/zZ7I2CjgbsPzV7YoAdSZpc5dOIzuAMgVSeoT1/INdGqCPYkk +SX6MfYpi+Zfx7bFAZRuMedsCgYA2Qrp6HumHSD8buLfTvNHV1+7RGrIP3zIslf8t +TjV0Q12v0UwytEhXmio0oZpUJ3Ejghg+Wn3n97U540kfAfVkH0Wg9+j9xTozpttj +U2BExhbCVKDYcNs29NoZx2CbkOx0O1+0f7HdVh/tisdHPav5HTihkcI3AEZQ4tRN +xc7DaQKBgFtj7G/nbhU1Rr1d7HJk8to88zHvUPPWI8AaqyQKDKD2nOSAysA0v9h6 +z7FG0SidXeCZF8NnlxRcR6+Zf/oDgx/akKGXGGGifgAIjkq53CQewNyg8iQda0cF +3wU8z+qAajPnhXEZ7T/OO1pRUUQRvM1obOLVORTzP+ZTD9/RZu4F +-----END RSA PRIVATE KEY----- diff --git a/examples/puma/client-certs/client_unknown.crt b/examples/puma/client-certs/client_unknown.crt new file mode 100644 index 0000000..8cdfc87 --- /dev/null +++ b/examples/puma/client-certs/client_unknown.crt @@ -0,0 +1,19 @@ +-----BEGIN CERTIFICATE----- +MIIDBTCCAe2gAwIBAgIBEzANBgkqhkiG9w0BAQsFADA5MRMwEQYKCZImiZPyLGQB +GRYDbmV0MRQwEgYKCZImiZPyLGQBGRYEcHVtYTEMMAoGA1UEAwwDQ0FVMB4XDTIw +MDgwMTAwMDAwMFoXDTI0MDgwMTAwMDAwMFowPzETMBEGCgmSJomT8ixkARkWA25l +dDEUMBIGCgmSJomT8ixkARkWBHB1bWExEjAQBgNVBAMMCWxvY2FsaG9zdDCCASIw +DQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAKQo9ve3XI5fiKE5AKI3Qm7aeGwy +uIR5RBo58Na4saBSfCaKZvNwzuEimKN6S4ShUlV0egKwCs/KvzbhQJpIxVsLQCGs +AQDxdOGP1FyizQvzDhzL7BC2I/Dp3A5V6uXkxo1qTqoBdTYeJ/mdIVgZ7bgTnUdu +uY9KErU/ugR8MbqZyQel/04PSmkCTdhzP41w0xvmc7Jgab0vJ0oXU4knXIyKsLeq +BkL6fevPW4fdaU+ppXBvpsgWfACk/WZs+DyhPndcX7JNrcnYIIAJy75wivqw2RDB +m59XNhoDwbR21EkcZnlQL59pv4ASA7DrJs+tFoGqsCs7DyEioePNp6ZraSkCAwEA +AaMSMBAwDgYDVR0PAQH/BAQDAgSwMA0GCSqGSIb3DQEBCwUAA4IBAQBMbPmIekCi +TMLtclXtkhKUefb9hoALhABwRbTbzPjz+MGJX2BMwLt8bAs6AMY8jEazvfm9+G0m +1liBAGWFojSzAykNIq/zAG2tgDVolQp3x4JcyFDJ0cuR8DIdll2CCnLM2jP8Nek8 +wSyuWDsm1U5kmos/dP/ialCvvCLHULoSCBhsyBFKmH2ViXdW09Wg4jSQ4RIToBBn +w/VbObeKA8NBUW6/7w6MS1oeFL3PlZJ205zVrZ68nqm/ckYyaBAdlQNTmlThNSQ5 +eL7w2QgfYDM72Sk4/rjZsW0kH+V0ZqmIuyVBE6jo5EulGPLo2eQEfPmhEdbo5rFi +foV+QKM8z3K0 +-----END CERTIFICATE----- diff --git a/examples/puma/client-certs/client_unknown.key b/examples/puma/client-certs/client_unknown.key new file mode 100644 index 0000000..96b41ac --- /dev/null +++ b/examples/puma/client-certs/client_unknown.key @@ -0,0 +1,27 @@ +-----BEGIN RSA PRIVATE KEY----- +MIIEpQIBAAKCAQEApCj297dcjl+IoTkAojdCbtp4bDK4hHlEGjnw1rixoFJ8Jopm +83DO4SKYo3pLhKFSVXR6ArAKz8q/NuFAmkjFWwtAIawBAPF04Y/UXKLNC/MOHMvs +ELYj8OncDlXq5eTGjWpOqgF1Nh4n+Z0hWBntuBOdR265j0oStT+6BHwxupnJB6X/ +Tg9KaQJN2HM/jXDTG+ZzsmBpvS8nShdTiSdcjIqwt6oGQvp9689bh91pT6mlcG+m +yBZ8AKT9Zmz4PKE+d1xfsk2tydgggAnLvnCK+rDZEMGbn1c2GgPBtHbUSRxmeVAv +n2m/gBIDsOsmz60WgaqwKzsPISKh482npmtpKQIDAQABAoIBAQCOZybN/pbgvojU +apFdJpiPdx8tpNYhvNxR7983NOKJU+R0vmzOUxZzgEJu1cC63gKBNNg+ip3mYVd8 +cOxMqkHhV6IbU41PVyXwIYezkFpVOlQMsO0oFgiZjRSiru9k3A9NT2HL4hXei0xc +IW1ycpOfsgwmkiuP3E7cQdrI1z+AQVG0VKaETHg8acqrC7Dp4VGuz9t5bXjZKU2+ +GjZ/bNFfuHHeDndZ6xKb+4nVCf0HouxwGzGArMMrI0XXStoR5w6DSn35j5AaqX1a +FzEHn+VolmdAJnK9kH/1tPmLYAzlnnpF4MmiPQhaQZFCGa+Slg71fFmHdKEqnFCO +Q24S2faNAoGBANJgTZ/KDHaXCd4qjVTVp1dXhnY2SAxCBGdftRiuviF+ZwJPNx1B +vhtBYOccneMv5AZ7DhXXLRhCcEN8F/FxQd+coZWgZU7TjW+9fLgHJDvQKUUmrQmn +fanhEmdkHT4bte/uQ0+GUbi7uTxVi9b2keSWvEmYsb30IXruvABAGVsDAoGBAMfC +005AW6kBluxmS1e0uC4vMCvp9ICFRonBk+ZXrt2wCDJjfnsl3tCyP31DxWpw/P51 +PtTxAnHsYwDvIjsoCghGECfvSmzdx32zzSLZ9maf5GF95tMgYnxwQN/nG5DLEjHF +kizkXYAjt1bjEy+Ih/52x42gtzMph9BJFNvIJL1jAoGAGRvhZ+bnoefZB6kwgSWW ++Xe61rUX2E6w0926cZ25l6nMhZwKyfUkyX/+HtdtiMYYgyWAwt6RxUl4uLVA7lJE +OHorVv5z2Pqq8OE+14AStQjdRCGfmX1iJDp2xdxPGTCZgG+BnSY87r2JGEhljlyT +gSL0ihwtaqyOqmuACM+dtx0CgYEAhi6SLcABUfclX8oe1d0o0q0T2IuglyvvA92p +8VH4viTefKpkbWg00U7KYuRBGYyoBGzRNcxmbgvxPNFk1wPAKWqWs5yDC7m1pPQ/ +2Sc74heJGwutHyhjv17P1RayZ4JgyFoEJG+JdueG4bBKVOWLJBy5UqMgLBe7iOdu +QWuhci0CgYEAxYbtLUBarxuOe6B2WIGm7ovuT/R5jZVND3hBPjEDbcIZVVEzljkI +Kp4nq973guCc2qSPFvMpw4T66G2GrQ+xEoaGSTPVB3l//7w6gJki9gJQ08PzMmsc +GXI1TsTqxRY6tw3tKnyWd6n3k/gSOKw9sF4+imvkpZqZmsHKNsfEUng= +-----END RSA PRIVATE KEY----- diff --git a/examples/puma/client-certs/generate_client_test.rb b/examples/puma/client-certs/generate_client_test.rb new file mode 100644 index 0000000..da1b56a --- /dev/null +++ b/examples/puma/client-certs/generate_client_test.rb @@ -0,0 +1,133 @@ +# frozen_string_literal: false + +=begin +run code to generate all certs +certs before date will be the first of the current month +expire in four years + +JRuby: +see https://github.com/puma/puma/commit/4ae0de4f4cc +after running this Ruby code, delete keystore.jks and server.p12, then +cd examples/puma/client-certs +openssl pkcs12 -chain -CAfile ./ca.crt -export -password pass:jruby_puma -inkey server.key -in server.crt -name server -out server.p12 +keytool -importkeystore -srckeystore server.p12 -srcstoretype pkcs12 -srcstorepass jruby_puma -destkeystore keystore.jks -deststoretype JKS -storepass jruby_puma +keytool -importcert -alias ca -noprompt -trustcacerts -file ca.crt -keystore keystore.jks -storepass jruby_puma +=end + +require "openssl" + +module Generate + + KEY_LEN = 2048 + SIGN_ALGORITHM = OpenSSL::Digest::SHA256 + + CA_EXTS = [ + ["basicConstraints","CA:TRUE",true], + ["keyUsage","cRLSign,keyCertSign",true], + ] + EE_EXTS = [ + #["keyUsage","keyEncipherment,digitalSignature",true], + ["keyUsage","keyEncipherment,dataEncipherment,digitalSignature",true], + ] + + class << self + def run + set_dates + output_info + setup_issue + write_files + end + + private + + def setup_issue + ca = OpenSSL::X509::Name.parse "/DC=net/DC=puma/CN=CA" + ca_u = OpenSSL::X509::Name.parse "/DC=net/DC=puma/CN=CAU" + svr = OpenSSL::X509::Name.parse "/DC=net/DC=puma/CN=localhost" + cli = OpenSSL::X509::Name.parse "/DC=net/DC=puma/CN=localhost" + cli_u = OpenSSL::X509::Name.parse "/DC=net/DC=puma/CN=localhost" + + [:@ca_key, :@svr_key, :@cli_key, :@ca_key_u, :@cli_key_u].each do |k| + instance_variable_set k, OpenSSL::PKey::RSA.generate(KEY_LEN) + end + + @ca_cert = issue_cert ca , @ca_key , 3, @before, @after, CA_EXTS, nil , nil , SIGN_ALGORITHM.new + @svr_cert = issue_cert svr, @svr_key, 7, @before, @after, EE_EXTS, @ca_cert, @ca_key, SIGN_ALGORITHM.new + @cli_cert = issue_cert cli, @cli_key, 11, @before, @after, EE_EXTS, @ca_cert, @ca_key, SIGN_ALGORITHM.new + + # unknown certs + @ca_cert_u = issue_cert ca_u , @ca_key_u , 17, @before, @after, CA_EXTS, nil , nil , SIGN_ALGORITHM.new + @cli_cert_u = issue_cert cli_u, @cli_key_u, 19, @before, @after, EE_EXTS, @ca_cert_u, @ca_key_u, SIGN_ALGORITHM.new + + # expired cert is identical to client cert with different dates + @cli_cert_exp = issue_cert cli, @cli_key, 23, @b_exp, @a_exp, EE_EXTS, @ca_cert, @ca_key, SIGN_ALGORITHM.new + end + + def issue_cert(dn, key, serial, not_before, not_after, extensions, issuer, issuer_key, digest) + cert = OpenSSL::X509::Certificate.new + issuer = cert unless issuer + issuer_key = key unless issuer_key + cert.version = 2 + cert.serial = serial + cert.subject = dn + cert.issuer = issuer.subject + cert.public_key = key.public_key + cert.not_before = not_before + cert.not_after = not_after + ef = OpenSSL::X509::ExtensionFactory.new + ef.subject_certificate = cert + ef.issuer_certificate = issuer + extensions.each {|oid, value, critical| + cert.add_extension(ef.create_extension(oid, value, critical)) + } + cert.sign(issuer_key, digest) + cert + end + + def write_files + Dir.chdir __dir__ do + File.write "ca.crt" , @ca_cert.to_pem , mode: 'wb' + File.write "ca.key" , @ca_key.to_pem , mode: 'wb' + File.write "server.crt", @svr_cert.to_pem, mode: 'wb' + File.write "server.key", @svr_key.to_pem , mode: 'wb' + File.write "client.crt", @cli_cert.to_pem, mode: 'wb' + File.write "client.key", @cli_key.to_pem , mode: 'wb' + + File.write "unknown_ca.crt", @ca_cert_u.to_pem, mode: 'wb' + File.write "unknown_ca.key", @ca_key_u.to_pem , mode: 'wb' + + File.write "client_unknown.crt", @cli_cert_u.to_pem, mode: 'wb' + File.write "client_unknown.key", @cli_key_u.to_pem , mode: 'wb' + + File.write "client_expired.crt", @cli_cert_exp.to_pem, mode: 'wb' + File.write "client_expired.key", @cli_key.to_pem , mode: 'wb' + end + end + + def set_dates + now = Time.now.utc + mo = now.month + yr = now.year + zone = '+00:00' + + @before = Time.new yr , mo, 1, 0, 0, 0, zone + @after = Time.new yr+4, mo, 1, 0, 0, 0, zone + + @b_exp = Time.new yr-1, mo, 1, 0, 0, 0, zone + @a_exp = Time.new yr , mo, 1, 0, 0, 0, zone + end + + def output_info + puts "" + puts " Key length: #{KEY_LEN}" + puts "sign_algorithm: #{SIGN_ALGORITHM}" + puts "" + puts "Normal cert dates: #{@before} to #{@after}" + puts "" + puts "Expired cert dates: #{@b_exp} to #{@a_exp}" + puts "" + end + end +end + +Generate.run diff --git a/examples/puma/client-certs/keystore.jks b/examples/puma/client-certs/keystore.jks new file mode 100644 index 0000000000000000000000000000000000000000..17654b4adbdde88929f2476da21ff50ef2899d0d GIT binary patch literal 3740 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 e + puts "error: #{e.message}" + end + end +end + +Generate.run diff --git a/examples/puma/keystore.jks b/examples/puma/keystore.jks new file mode 100644 index 0000000000000000000000000000000000000000..f1909ecdc7dc10cf94055fc90ab7786f6fbff74e GIT binary patch literal 2253 zcmb7_c{J4f8^`B68#9bOq$UlQT*P;#EK`ce5)nxWSC%0|jD1jp84;BwS+bjybxI}u z2-PK*N@N+^weL%sv5b_ZE9Z1h|NcJb^ZfC8&U2pUbI$uY@6Xo4)&c|qfo=z|6L8-k zZ|7|Yc)vl|&LW^71HJ_cA<-&G01hZaMF9W-fivK9tY#;a(A&TBB7_5C&XhhemI)po z(hTL-p4&?*vA(JseEYE)(aJAOqW#uKiBbZ90-;;Qamj4-15Rfz#Y_qLs-o`Vt6V(= zr?kno_1RM`=VzDTIX4q(AFcW@6Ta^;hpc;^zO`9_it1}0;O=hUGx8&++ppI^XNyCZ zqfhCQ#$#9ZcMOfcE3~;Ty49C+ZNc-Ses)HNMS>+^BAI_hY zOKhsORBp@r6KBsCD4e%HEG|N8cgHa`ql-slMpAT4{Qk7Lxwdg{OUS;aR^x-hF;(Q@ zGS%)(4R20P9mD#`q16D2duvaKlBTR>&8F6P&7%W}S*gfMZG7gw(4ZXP&R~fCD>DMc zKAg8mlNesf=%cC(ov72W&S$rC!iFl2S5JQab^+_sDxX$dTBs_2l&L!HjgVLUbVhR8 zMY=8Tt*4XcC(*q*ahor1M?VZ z^86{18C&^in>0rAgrnIP$}X8tA}%xY#J+dpZ|BAN+6_lYaMt7VTMw&iCJU5qrVYIq ztK3s|yHa(uV*0%9h%i^GE}NytGEE%?H=|ByL4ee;vt9*FdY^~dQHOG6qTF2{EfD1p z<<7YKak7-ZbkB&u$6e6sc8MK$!bSI%@P}>3{8-(m&e0$Qx9ZDQRsTHwE3&s93_ief zIGc?Zj%HpmUyCiCHQju^HQRX}y)Wk2_sCB(SDLf}%X^Loo$pU|ZR+>0GRbjxv%pff&(dQ0RN?mPnmO7Wuy=LEYV{jwp>VqcH zST*z0RZl&>3eHxq$bU@C8c+Agldn4>`ZWb1YpXYEd8Mm?O&+k(L4UzGZFUve>GaK! zbx)Q=U~~DC#TBIJDkXCqtlXPJ(e@P=F3py*mQ0_Atag4AC(=JNEmd??rtQ9UnKc;U zS1_Nv z0#3JBa6xlDZmI!3FV!&`5gFOfY@`u0pNO#iqS_s7A#5IXHLL9Pg{1crmOH+PuvOCl zi4X`Z24uh(AOq@|4}${G?F(X%DCHQFn6DTB5I{gcB3=MQAz?Ofq&U>Z8ZQJ2{sgoj z;tIvp`3fEb3jAaNLAbMfuF5^ zjfDyB7(B=TpbWul(a<4fm!A>TbYC9wp5`ZZK=FnBcE z<%rma{9W|IjYA)ZN1vYo^;!)bpBjzkX~Ip$^&d3RbsFwvW0h%kvn(++L)QJhxU1f! zV}riCCB(|jw_MpIl+#4!H{AI0#iEuD_@MH+Al2b6Y@;0*^k{skqF=MF-UjDU;?J2x zcBLCfWBgeN8!N&`-t`R|BEbV2wDw3*Ih}%SiLTm5KzyjD#>SZ3%;WG+id-jiSL+1I ztd<}OXf?=jhpAko67||12mX_V&74!dX{eqXO1fMqB!FG`W?cTVNv!<{(=f^Pz?$Ql zyB32qSo_7{M^Cee8tuq{AzO(Kg#i$tfF2Ff89Vx6#R1?49121}JQC#;V*r(} zAChusgIq$@&-l9eSX-aoZ|vsd=IODqY?xn)Ee z{LRZTI{whsYsNKxVr6F^u)=US42sAH#edc+zy(1xl7IWlMIiWK!0$?JZ`ig{0EBjr zKiQTiYO(m3YtJ#eAIMfeC-5rexeuDrrP4hB(|4?5I0W$ptlr9`-l2tfT?MnD=x4>= zuVlrj*Q(t@TPJ-~r*j+|JWxf7(u!#^$09J%Z@JoWxd!^&$b$#@3Zgx}8?ePCM`fem zyBk_7-cqbc>At6#`))Z`fM1d zXp2oFA>ZHB;!%B;3-E_8_nUpGzo8{{!oHGkJuv)^X}F=jNZChGp}^PNl`iAJ7`QO8 zG+%P@bdhlipAqk$sKxW30hp!M(>y#_*0%}@)=u~$0YI~Zn~ SHJ`dDUw{v?KO|H!dig&k>e!tC literal 0 HcmV?d00001 diff --git a/examples/puma/puma_keypair.pem b/examples/puma/puma_keypair.pem new file mode 100644 index 0000000..c8057c8 --- /dev/null +++ b/examples/puma/puma_keypair.pem @@ -0,0 +1,27 @@ +-----BEGIN RSA PRIVATE KEY----- +MIIEpAIBAAKCAQEAv2lrSC+U13CB7+5pk0TmFPIEsfd5DN124ZxiOhLLoBWBpvwz +yzk7tE8wQM5FXrFG4Z82BEjjQqTHgTzDmJwdKn1a6awSLkWsohwhe22q4t1wGRQS +r03+Y9VkCWDkmvkh4/NZp8zSBN0qVHUs39QdxVowda3j8rPaNr3CVSFoqXLv5QbU +l0eFDXKsBVVRMs31hPxVOTJoHXX4rSYql28dzIW4HjIFy8J3iIZIip/vCOUQhGnw +9r0Lq1HyACUKLgA+XOaeBih3fBtcCESmyO9JcJYJ14coP/ACAHPVpsDjI2dIiFvg +oUWQYyf4XvhmN4Jk3bzuXP/XyBAufTrcRdUogQIDAQABAoIBAQC3fm3UE5kSNs65 +ncoj3cbbiW8q1Fx9EslmWq5nkaEW48cYt2lHhqRPpCJT3enubu/OVvxHe0AxoRmI +MSIo6G+lTeqbW9NJ/I0UEveeBXHube2KfQ20dIZMWkK+It7EGdR9W8o07ErhUsvD +j1jnccbgbCdMiNiez/9+vsbPKWnWFaok28+k3SHzsxVQm9zW3X9RVEACcoWeUW1O +6SWUwdEttP9+X+zAOONGtuzbR4lYwI7rl0DDoQ1oY6oROLU9XTXEs5hlSSvxQQLV +u4AHogcUlnNPbd31NnjYtsBeh/TKJ4njYqooCTHjtkUtYbOCt8X6Yd7U4KwtViNd +RIrTSxHJAoGBAOi33Bbbnty03gm2tYO16RgVTsPnzruJsCWOJbviNW/NIzB85j8a +S1OCH+10/MBUEOl/Q7S7xFsBVvmNA9pyw1RiRHGxRVTRl4XbpPhDhDCo/m3U4/6Y +MyLAprOcUJP/b6pbNzjnowuQDqvFm/I60tazSPobhMoxo06NbOFiNqF7AoGBANKP +qXKI8P06TQ+gK+DfXwAslcHtQzTPEoVl5GuvUcL+HOMzR7zQwLVOwA0FCyBtYewd +byOhsDqOQFyQCGCk9JUmvbrzx5fg5IWWz245pvwIrQ0X2H7YemeRsGS5DGQXpuvi +2aQWbXdI27v83OTCDZbo27mNTCPqy7MLKa+Wp+czAoGAO+z8c7ZiFhtNAdtWqm/x +cg4qli/fAFPYVBNijBsX/44nfZjsAVvYAc0EQ7VYUH5VTItE+AlR6s1RhDlXwKzE +t7oGPfCUFd9S0VlaBcP9Cjq6KbYkb67pnA1X3/Bkn3erXYbXlYOwbI3P+VONcLbN +DBRmumDTtO1LTDMG0pLj1nkCgYEAvo+mGzI0Z/lLpMig7XM61z2Ci2/fUvvVF0WP +5KVWqdKw8i6GzitfPLd4uE/IMiDMbpR08Rp0E4qKVTtFWbHwaMwXCgt2p82xA/Xo +5SjoJ1DyzNa36JSisvj3WzDeNffx6an0rrxddYdK1meSwrWc9ubndJacQiVNFU0U +/QSsEGECgYBinPeuJEPabDpvTsAvtuQHoh1jLAVZutvCdFUvG0Ozf2GJQ0evDYml +UqWG9YElKfTN8NDLQehNANuUMRjDIqNF7B6Y0/dg2HJJjv5OXoYUDhGzV7RitFyV +qfdp99aSQpIsmevzUMbHa/0Uh5e3bnnIH+9QYQJSaI7D/8oD2OhBVQ== +-----END RSA PRIVATE KEY----- diff --git a/examples/puma/server.p12 b/examples/puma/server.p12 new file mode 100644 index 0000000000000000000000000000000000000000..f8ffc7fab80e9fcb289d7c2a7d63b552494406c2 GIT binary patch literal 2550 zcmY+^cQhLc{|4}gB$6N%t42y|_9~jRsG`-YW>ciMMy;rI?NOuAqKK+Ji^irgs?-)j zE-~9$b%k<`phlyp(ilZwuY1n#_ulvY<9oj6JkL4LpC2?4whiRqL=#~x5SYRCeu>RKkdF2>3c*~Z*v!#!oEf7%pW3Bk&ioDXb)ZzqFldaCj z78lVK4M|e;UmW|yek_-aaO^cI=R*I$PP+v|`Tuh8NsVRe^?KNZlfY!DY}!OsPOL0i z?itA)2WkrvyV>ltWFV?ITOiUJI0V=mtmpBs5b#V9G2MW>7f>WLidfFy1zY8UlVkQq z5`m$^iHC1{zs6byQ~PIBW*JmiDtq3hx^c&b_eH}(v|{G*UG?uZq*QW$b54KQ=?~dY zhhD2K0PhKFsqhTb|qE<5W+Uq%Br;C*+T=)s#?e zJdQTXTY%0=QXjg%*-PjL(IOUm6v^M~!5uf_mQxaj9^UZgbj%JB_#pXU>bPU+pv^r} zzO1_kS+tpn7{i)44B3$oKfGaw_Db!T<@f;0^P8DxsgcA7T5_Dvt>>~vJ%8u18W&tZ zl5oE)mfmJ2<|YXRm%=N_mou2SpKDQGHPt*nK%+8uzsY)6xWPahJ_EKgtV z-8L%5HYF$DpM{str1(PdAoNqdoLbBE_c2@1;kgWIzPJYk18sFEjp`%GcTQH)b@9Wf zp1LI!%?A&g{i`M8?V}DpPErq549LqSbcc_~s~)b($S-Ij*!sVqC?bNfAR^e{B))la z+7RylX9LawJn`l02`pd!M-$G!H4zmvTzlYriQ{igKt#~H@H@*7TLVDtbvYMiaQ3Tv z=J`LX2w@bOJ=tVhH=cCC`OU9OBB|?3-g%o9iJwF@3|GN|C-k=dZ(np&x|K%ysIz&21&5iyV7vO!^g)u)@U3r zy*#_{6aI{=3aGmvow?V)yD#|WyyRVO$;KmQe}4JAolHjpj?Xeq*TbynGt;X62ABHB zInoGcos`Nluh)jz^(dm;T;F6($Eej0oG6&RjovpMv&<-hm zu524JCzP?urLab9XG6^~G3rO3=CgO4c$$3`eUz~P#ey~)`e4A7_<&g+vP6MB;*SP_ zJ2P@4;bM9zf7av16EZK>PZ!4dza(-`3{6DuoKL@f`;vI^bnj2=JAZ~=Q&k}Pq!xzE zGiRnukzujgk;+EUNv8uAx!l8U)abpaJHG*TajcV7Xdwj|U?yA{_MOjS2+tQSrt)fN z;Ek0tBMj^s!g=s)R+>k8MunwG<)yVQ#rVsvH2{*iSM_U{FJ|GpyOru%=22iRsG|j* z*C44jCN#t9p+!9D1XbXzar*B>d@XgEv$k+2n|Rwc--Jy+sc1xb=%l_xXcK!?%=BXI zDln!2)?NEqRG$mkmFQAn8SeDk9))`BWRa_th}2;)GXkjeeLfbLcSTqSR%k4X2X?p6 zl*san>P9Uv;m>uwo*?X|Rx&xw>38%5@?S*}GOyn`=!>YdN&>vsQRLM#Vur-0nLgIV zr*hf=#tFep`YWn5L7jf23VHgBeZ`>U(Oa|1?SrFyo_f~H7NzA7h=sz{S-Q==n`hp~ z3zz#5BIm&V?e^?M$o*!Xmy;0CDeoM2!?(#40YCma^?}n}cFyxWNp9pQtfsR+y!xrk zs+`;WG2$p6exZm#8E9UXe`IUy8!6&=)OoEF)7HPoJSHRds*5mR93OYPKksMtCV%8FJXqK1 z+}S^rt!6s&YsoeOV>=|ZuE4w_;V4%j)< zf`|;_g6gw&VK=>us3hfwb#A)8IREzG0Wt=c45-XXjg)7X1+KF%f$6mj7%+ z6KQ|bA?6h3I{Nf~bc6|F;1W=PA0PhzW z43f|o^f@#P0=|IY1d4$HAi)DMbaN^cnS$i8w%V;PX%aG<^8-O0o;$fe`qR_rZ$AGG Dcm}%~ literal 0 HcmV?d00001 diff --git a/examples/qc_config.rb b/examples/qc_config.rb new file mode 100644 index 0000000..b67be07 --- /dev/null +++ b/examples/qc_config.rb @@ -0,0 +1,13 @@ + full_hostname = `hostname`.strip + domainname = full_hostname.split('.')[1..-1].join('.') + hostname = full_hostname.split('.')[0] + + CA[:hostname] = hostname + CA[:domainname] = domainname + CA[:CA_dir] = File.join Dir.pwd, "CA" + CA[:password] = 'puma' + + CERTS << { + :type => 'server', + :hostname => 'puma' + } diff --git a/ext/puma_http11/PumaHttp11Service.java b/ext/puma_http11/PumaHttp11Service.java new file mode 100644 index 0000000..00f63aa --- /dev/null +++ b/ext/puma_http11/PumaHttp11Service.java @@ -0,0 +1,17 @@ +package puma; + +import java.io.IOException; + +import org.jruby.Ruby; +import org.jruby.runtime.load.BasicLibraryService; + +import org.jruby.puma.Http11; +import org.jruby.puma.MiniSSL; + +public class PumaHttp11Service implements BasicLibraryService { + public boolean basicLoad(final Ruby runtime) throws IOException { + Http11.createHttp11(runtime); + MiniSSL.createMiniSSL(runtime); + return true; + } +} diff --git a/ext/puma_http11/ext_help.h b/ext/puma_http11/ext_help.h new file mode 100644 index 0000000..ba09e6d --- /dev/null +++ b/ext/puma_http11/ext_help.h @@ -0,0 +1,15 @@ +#ifndef ext_help_h +#define ext_help_h + +#define RAISE_NOT_NULL(T) if(T == NULL) rb_raise(rb_eArgError, "%s", "NULL found for " # T " when shouldn't be."); +#define DATA_GET(from,type,data_type,name) TypedData_Get_Struct(from,type,data_type,name); RAISE_NOT_NULL(name); +#define REQUIRE_TYPE(V, T) if(TYPE(V) != T) rb_raise(rb_eTypeError, "%s", "Wrong argument type for " # V " required " # T); +#define ARRAY_SIZE(x) (sizeof(x)/sizeof(x[0])) + +#ifdef DEBUG +#define TRACE() fprintf(stderr, "> %s:%d:%s\n", __FILE__, __LINE__, __FUNCTION__) +#else +#define TRACE() +#endif + +#endif diff --git a/ext/puma_http11/extconf.rb b/ext/puma_http11/extconf.rb new file mode 100644 index 0000000..8b007a3 --- /dev/null +++ b/ext/puma_http11/extconf.rb @@ -0,0 +1,65 @@ +require 'mkmf' + +dir_config("puma_http11") + +if $mingw && RUBY_VERSION >= '2.4' + append_cflags '-fstack-protector-strong -D_FORTIFY_SOURCE=2' + append_ldflags '-fstack-protector-strong -l:libssp.a' + have_library 'ssp' +end + +unless ENV["DISABLE_SSL"] + dir_config("openssl") + + found_ssl = if (!$mingw || RUBY_VERSION >= '2.4') && (t = pkg_config 'openssl') + puts 'using OpenSSL pkgconfig (openssl.pc)' + true + elsif %w'crypto libeay32'.find {|crypto| have_library(crypto, 'BIO_read')} && + %w'ssl ssleay32'.find {|ssl| have_library(ssl, 'SSL_CTX_new')} + true + else + puts '** Puma will be compiled without SSL support' + false + end + + if found_ssl + have_header "openssl/bio.h" + + # below is yes for 1.0.2 & later + have_func "DTLS_method" , "openssl/ssl.h" + + # below are yes for 1.1.0 & later + have_func "TLS_server_method" , "openssl/ssl.h" + have_func "SSL_CTX_set_min_proto_version(NULL, 0)", "openssl/ssl.h" + + have_func "X509_STORE_up_ref" + have_func "SSL_CTX_set_ecdh_auto(NULL, 0)" , "openssl/ssl.h" + + # below are yes for 3.0.0 & later, use for OpenSSL 3 detection + have_func "SSL_get1_peer_certificate" , "openssl/ssl.h" + + # Random.bytes available in Ruby 2.5 and later, Random::DEFAULT deprecated in 3.0 + if Random.respond_to?(:bytes) + $defs.push "-DHAVE_RANDOM_BYTES" + puts "checking for Random.bytes... yes" + else + puts "checking for Random.bytes... no" + end + end +end + +if ENV["MAKE_WARNINGS_INTO_ERRORS"] + # Make all warnings into errors + # Except `implicit-fallthrough` since most failures comes from ragel state machine generated code + if respond_to?(:append_cflags, true) # Ruby 2.5 and later + append_cflags(config_string('WERRORFLAG') || '-Werror') + append_cflags '-Wno-implicit-fallthrough' + else + # flag may not exist on some platforms, -Werror may not be defined on some platforms, but + # works with all in current CI + $CFLAGS << " #{config_string('WERRORFLAG') || '-Werror'}" + $CFLAGS << ' -Wno-implicit-fallthrough' + end +end + +create_makefile("puma/puma_http11") diff --git a/ext/puma_http11/http11_parser.c b/ext/puma_http11/http11_parser.c new file mode 100644 index 0000000..a122a9a --- /dev/null +++ b/ext/puma_http11/http11_parser.c @@ -0,0 +1,1057 @@ + +#line 1 "ext/puma_http11/http11_parser.rl" +/** + * Copyright (c) 2005 Zed A. Shaw + * You can redistribute it and/or modify it under the same terms as Ruby. + * License 3-clause BSD + */ +#include "http11_parser.h" +#include +#include +#include +#include +#include + +/* + * capitalizes all lower-case ASCII characters, + * converts dashes to underscores, and underscores to commas. + */ +static void snake_upcase_char(char *c) +{ + if (*c >= 'a' && *c <= 'z') + *c &= ~0x20; + else if (*c == '_') + *c = ','; + else if (*c == '-') + *c = '_'; +} + +#define LEN(AT, FPC) (FPC - buffer - parser->AT) +#define MARK(M,FPC) (parser->M = (FPC) - buffer) +#define PTR_TO(F) (buffer + parser->F) + +/** Machine **/ + + +#line 81 "ext/puma_http11/http11_parser.rl" + + +/** Data **/ + +#line 42 "ext/puma_http11/http11_parser.c" +static const int puma_parser_start = 1; +static const int puma_parser_first_final = 46; +static const int puma_parser_error = 0; + + +#line 85 "ext/puma_http11/http11_parser.rl" + +int puma_parser_init(puma_parser *parser) { + int cs = 0; + +#line 53 "ext/puma_http11/http11_parser.c" + { + cs = puma_parser_start; + } + +#line 89 "ext/puma_http11/http11_parser.rl" + parser->cs = cs; + parser->body_start = 0; + parser->content_len = 0; + parser->mark = 0; + parser->nread = 0; + parser->field_len = 0; + parser->field_start = 0; + parser->request = Qnil; + parser->body = Qnil; + + return 1; +} + + +/** exec **/ +size_t puma_parser_execute(puma_parser *parser, const char *buffer, size_t len, size_t off) { + const char *p, *pe; + int cs = parser->cs; + + assert(off <= len && "offset past end of buffer"); + + p = buffer+off; + pe = buffer+len; + + /* assert(*pe == '\0' && "pointer does not end on NUL"); */ + assert((size_t) (pe - p) == len - off && "pointers aren't same distance"); + + +#line 87 "ext/puma_http11/http11_parser.c" + { + if ( p == pe ) + goto _test_eof; + switch ( cs ) + { +case 1: + switch( (*p) ) { + case 36: goto tr0; + case 95: goto tr0; + } + if ( (*p) < 48 ) { + if ( 45 <= (*p) && (*p) <= 46 ) + goto tr0; + } else if ( (*p) > 57 ) { + if ( 65 <= (*p) && (*p) <= 90 ) + goto tr0; + } else + goto tr0; + goto st0; +st0: +cs = 0; + goto _out; +tr0: +#line 37 "ext/puma_http11/http11_parser.rl" + { MARK(mark, p); } + goto st2; +st2: + if ( ++p == pe ) + goto _test_eof2; +case 2: +#line 118 "ext/puma_http11/http11_parser.c" + switch( (*p) ) { + case 32: goto tr2; + case 36: goto st27; + case 95: goto st27; + } + if ( (*p) < 48 ) { + if ( 45 <= (*p) && (*p) <= 46 ) + goto st27; + } else if ( (*p) > 57 ) { + if ( 65 <= (*p) && (*p) <= 90 ) + goto st27; + } else + goto st27; + goto st0; +tr2: +#line 50 "ext/puma_http11/http11_parser.rl" + { + parser->request_method(parser, PTR_TO(mark), LEN(mark, p)); + } + goto st3; +st3: + if ( ++p == pe ) + goto _test_eof3; +case 3: +#line 143 "ext/puma_http11/http11_parser.c" + switch( (*p) ) { + case 42: goto tr4; + case 43: goto tr5; + case 47: goto tr6; + case 58: goto tr7; + } + if ( (*p) < 65 ) { + if ( 45 <= (*p) && (*p) <= 57 ) + goto tr5; + } else if ( (*p) > 90 ) { + if ( 97 <= (*p) && (*p) <= 122 ) + goto tr5; + } else + goto tr5; + goto st0; +tr4: +#line 37 "ext/puma_http11/http11_parser.rl" + { MARK(mark, p); } + goto st4; +st4: + if ( ++p == pe ) + goto _test_eof4; +case 4: +#line 167 "ext/puma_http11/http11_parser.c" + switch( (*p) ) { + case 32: goto tr8; + case 35: goto tr9; + } + goto st0; +tr8: +#line 53 "ext/puma_http11/http11_parser.rl" + { + parser->request_uri(parser, PTR_TO(mark), LEN(mark, p)); + } + goto st5; +tr31: +#line 37 "ext/puma_http11/http11_parser.rl" + { MARK(mark, p); } +#line 56 "ext/puma_http11/http11_parser.rl" + { + parser->fragment(parser, PTR_TO(mark), LEN(mark, p)); + } + goto st5; +tr33: +#line 56 "ext/puma_http11/http11_parser.rl" + { + parser->fragment(parser, PTR_TO(mark), LEN(mark, p)); + } + goto st5; +tr37: +#line 69 "ext/puma_http11/http11_parser.rl" + { + parser->request_path(parser, PTR_TO(mark), LEN(mark,p)); + } +#line 53 "ext/puma_http11/http11_parser.rl" + { + parser->request_uri(parser, PTR_TO(mark), LEN(mark, p)); + } + goto st5; +tr41: +#line 60 "ext/puma_http11/http11_parser.rl" + { MARK(query_start, p); } +#line 61 "ext/puma_http11/http11_parser.rl" + { + parser->query_string(parser, PTR_TO(query_start), LEN(query_start, p)); + } +#line 53 "ext/puma_http11/http11_parser.rl" + { + parser->request_uri(parser, PTR_TO(mark), LEN(mark, p)); + } + goto st5; +tr44: +#line 61 "ext/puma_http11/http11_parser.rl" + { + parser->query_string(parser, PTR_TO(query_start), LEN(query_start, p)); + } +#line 53 "ext/puma_http11/http11_parser.rl" + { + parser->request_uri(parser, PTR_TO(mark), LEN(mark, p)); + } + goto st5; +st5: + if ( ++p == pe ) + goto _test_eof5; +case 5: +#line 229 "ext/puma_http11/http11_parser.c" + if ( (*p) == 72 ) + goto tr10; + goto st0; +tr10: +#line 37 "ext/puma_http11/http11_parser.rl" + { MARK(mark, p); } + goto st6; +st6: + if ( ++p == pe ) + goto _test_eof6; +case 6: +#line 241 "ext/puma_http11/http11_parser.c" + if ( (*p) == 84 ) + goto st7; + goto st0; +st7: + if ( ++p == pe ) + goto _test_eof7; +case 7: + if ( (*p) == 84 ) + goto st8; + goto st0; +st8: + if ( ++p == pe ) + goto _test_eof8; +case 8: + if ( (*p) == 80 ) + goto st9; + goto st0; +st9: + if ( ++p == pe ) + goto _test_eof9; +case 9: + if ( (*p) == 47 ) + goto st10; + goto st0; +st10: + if ( ++p == pe ) + goto _test_eof10; +case 10: + if ( 48 <= (*p) && (*p) <= 57 ) + goto st11; + goto st0; +st11: + if ( ++p == pe ) + goto _test_eof11; +case 11: + if ( (*p) == 46 ) + goto st12; + if ( 48 <= (*p) && (*p) <= 57 ) + goto st11; + goto st0; +st12: + if ( ++p == pe ) + goto _test_eof12; +case 12: + if ( 48 <= (*p) && (*p) <= 57 ) + goto st13; + goto st0; +st13: + if ( ++p == pe ) + goto _test_eof13; +case 13: + if ( (*p) == 13 ) + goto tr18; + if ( 48 <= (*p) && (*p) <= 57 ) + goto st13; + goto st0; +tr18: +#line 65 "ext/puma_http11/http11_parser.rl" + { + parser->http_version(parser, PTR_TO(mark), LEN(mark, p)); + } + goto st14; +tr26: +#line 46 "ext/puma_http11/http11_parser.rl" + { MARK(mark, p); } +#line 47 "ext/puma_http11/http11_parser.rl" + { + parser->http_field(parser, PTR_TO(field_start), parser->field_len, PTR_TO(mark), LEN(mark, p)); + } + goto st14; +tr29: +#line 47 "ext/puma_http11/http11_parser.rl" + { + parser->http_field(parser, PTR_TO(field_start), parser->field_len, PTR_TO(mark), LEN(mark, p)); + } + goto st14; +st14: + if ( ++p == pe ) + goto _test_eof14; +case 14: +#line 322 "ext/puma_http11/http11_parser.c" + if ( (*p) == 10 ) + goto st15; + goto st0; +st15: + if ( ++p == pe ) + goto _test_eof15; +case 15: + switch( (*p) ) { + case 13: goto st16; + case 33: goto tr21; + case 124: goto tr21; + case 126: goto tr21; + } + if ( (*p) < 45 ) { + if ( (*p) > 39 ) { + if ( 42 <= (*p) && (*p) <= 43 ) + goto tr21; + } else if ( (*p) >= 35 ) + goto tr21; + } else if ( (*p) > 46 ) { + if ( (*p) < 65 ) { + if ( 48 <= (*p) && (*p) <= 57 ) + goto tr21; + } else if ( (*p) > 90 ) { + if ( 94 <= (*p) && (*p) <= 122 ) + goto tr21; + } else + goto tr21; + } else + goto tr21; + goto st0; +st16: + if ( ++p == pe ) + goto _test_eof16; +case 16: + if ( (*p) == 10 ) + goto tr22; + goto st0; +tr22: +#line 73 "ext/puma_http11/http11_parser.rl" + { + parser->body_start = p - buffer + 1; + parser->header_done(parser, p + 1, pe - p - 1); + {p++; cs = 46; goto _out;} + } + goto st46; +st46: + if ( ++p == pe ) + goto _test_eof46; +case 46: +#line 373 "ext/puma_http11/http11_parser.c" + goto st0; +tr21: +#line 40 "ext/puma_http11/http11_parser.rl" + { MARK(field_start, p); } +#line 41 "ext/puma_http11/http11_parser.rl" + { snake_upcase_char((char *)p); } + goto st17; +tr23: +#line 41 "ext/puma_http11/http11_parser.rl" + { snake_upcase_char((char *)p); } + goto st17; +st17: + if ( ++p == pe ) + goto _test_eof17; +case 17: +#line 389 "ext/puma_http11/http11_parser.c" + switch( (*p) ) { + case 33: goto tr23; + case 58: goto tr24; + case 124: goto tr23; + case 126: goto tr23; + } + if ( (*p) < 45 ) { + if ( (*p) > 39 ) { + if ( 42 <= (*p) && (*p) <= 43 ) + goto tr23; + } else if ( (*p) >= 35 ) + goto tr23; + } else if ( (*p) > 46 ) { + if ( (*p) < 65 ) { + if ( 48 <= (*p) && (*p) <= 57 ) + goto tr23; + } else if ( (*p) > 90 ) { + if ( 94 <= (*p) && (*p) <= 122 ) + goto tr23; + } else + goto tr23; + } else + goto tr23; + goto st0; +tr24: +#line 42 "ext/puma_http11/http11_parser.rl" + { + parser->field_len = LEN(field_start, p); + } + goto st18; +tr27: +#line 46 "ext/puma_http11/http11_parser.rl" + { MARK(mark, p); } + goto st18; +st18: + if ( ++p == pe ) + goto _test_eof18; +case 18: +#line 428 "ext/puma_http11/http11_parser.c" + switch( (*p) ) { + case 13: goto tr26; + case 32: goto tr27; + case 127: goto st0; + } + if ( (*p) > 8 ) { + if ( 10 <= (*p) && (*p) <= 31 ) + goto st0; + } else if ( (*p) >= 0 ) + goto st0; + goto tr25; +tr25: +#line 46 "ext/puma_http11/http11_parser.rl" + { MARK(mark, p); } + goto st19; +st19: + if ( ++p == pe ) + goto _test_eof19; +case 19: +#line 448 "ext/puma_http11/http11_parser.c" + switch( (*p) ) { + case 13: goto tr29; + case 127: goto st0; + } + if ( (*p) > 8 ) { + if ( 10 <= (*p) && (*p) <= 31 ) + goto st0; + } else if ( (*p) >= 0 ) + goto st0; + goto st19; +tr9: +#line 53 "ext/puma_http11/http11_parser.rl" + { + parser->request_uri(parser, PTR_TO(mark), LEN(mark, p)); + } + goto st20; +tr38: +#line 69 "ext/puma_http11/http11_parser.rl" + { + parser->request_path(parser, PTR_TO(mark), LEN(mark,p)); + } +#line 53 "ext/puma_http11/http11_parser.rl" + { + parser->request_uri(parser, PTR_TO(mark), LEN(mark, p)); + } + goto st20; +tr42: +#line 60 "ext/puma_http11/http11_parser.rl" + { MARK(query_start, p); } +#line 61 "ext/puma_http11/http11_parser.rl" + { + parser->query_string(parser, PTR_TO(query_start), LEN(query_start, p)); + } +#line 53 "ext/puma_http11/http11_parser.rl" + { + parser->request_uri(parser, PTR_TO(mark), LEN(mark, p)); + } + goto st20; +tr45: +#line 61 "ext/puma_http11/http11_parser.rl" + { + parser->query_string(parser, PTR_TO(query_start), LEN(query_start, p)); + } +#line 53 "ext/puma_http11/http11_parser.rl" + { + parser->request_uri(parser, PTR_TO(mark), LEN(mark, p)); + } + goto st20; +st20: + if ( ++p == pe ) + goto _test_eof20; +case 20: +#line 501 "ext/puma_http11/http11_parser.c" + switch( (*p) ) { + case 32: goto tr31; + case 60: goto st0; + case 62: goto st0; + case 127: goto st0; + } + if ( (*p) > 31 ) { + if ( 34 <= (*p) && (*p) <= 35 ) + goto st0; + } else if ( (*p) >= 0 ) + goto st0; + goto tr30; +tr30: +#line 37 "ext/puma_http11/http11_parser.rl" + { MARK(mark, p); } + goto st21; +st21: + if ( ++p == pe ) + goto _test_eof21; +case 21: +#line 522 "ext/puma_http11/http11_parser.c" + switch( (*p) ) { + case 32: goto tr33; + case 60: goto st0; + case 62: goto st0; + case 127: goto st0; + } + if ( (*p) > 31 ) { + if ( 34 <= (*p) && (*p) <= 35 ) + goto st0; + } else if ( (*p) >= 0 ) + goto st0; + goto st21; +tr5: +#line 37 "ext/puma_http11/http11_parser.rl" + { MARK(mark, p); } + goto st22; +st22: + if ( ++p == pe ) + goto _test_eof22; +case 22: +#line 543 "ext/puma_http11/http11_parser.c" + switch( (*p) ) { + case 43: goto st22; + case 58: goto st23; + } + if ( (*p) < 48 ) { + if ( 45 <= (*p) && (*p) <= 46 ) + goto st22; + } else if ( (*p) > 57 ) { + if ( (*p) > 90 ) { + if ( 97 <= (*p) && (*p) <= 122 ) + goto st22; + } else if ( (*p) >= 65 ) + goto st22; + } else + goto st22; + goto st0; +tr7: +#line 37 "ext/puma_http11/http11_parser.rl" + { MARK(mark, p); } + goto st23; +st23: + if ( ++p == pe ) + goto _test_eof23; +case 23: +#line 568 "ext/puma_http11/http11_parser.c" + switch( (*p) ) { + case 32: goto tr8; + case 34: goto st0; + case 35: goto tr9; + case 60: goto st0; + case 62: goto st0; + case 127: goto st0; + } + if ( 0 <= (*p) && (*p) <= 31 ) + goto st0; + goto st23; +tr6: +#line 37 "ext/puma_http11/http11_parser.rl" + { MARK(mark, p); } + goto st24; +st24: + if ( ++p == pe ) + goto _test_eof24; +case 24: +#line 588 "ext/puma_http11/http11_parser.c" + switch( (*p) ) { + case 32: goto tr37; + case 34: goto st0; + case 35: goto tr38; + case 60: goto st0; + case 62: goto st0; + case 63: goto tr39; + case 127: goto st0; + } + if ( 0 <= (*p) && (*p) <= 31 ) + goto st0; + goto st24; +tr39: +#line 69 "ext/puma_http11/http11_parser.rl" + { + parser->request_path(parser, PTR_TO(mark), LEN(mark,p)); + } + goto st25; +st25: + if ( ++p == pe ) + goto _test_eof25; +case 25: +#line 611 "ext/puma_http11/http11_parser.c" + switch( (*p) ) { + case 32: goto tr41; + case 34: goto st0; + case 35: goto tr42; + case 60: goto st0; + case 62: goto st0; + case 127: goto st0; + } + if ( 0 <= (*p) && (*p) <= 31 ) + goto st0; + goto tr40; +tr40: +#line 60 "ext/puma_http11/http11_parser.rl" + { MARK(query_start, p); } + goto st26; +st26: + if ( ++p == pe ) + goto _test_eof26; +case 26: +#line 631 "ext/puma_http11/http11_parser.c" + switch( (*p) ) { + case 32: goto tr44; + case 34: goto st0; + case 35: goto tr45; + case 60: goto st0; + case 62: goto st0; + case 127: goto st0; + } + if ( 0 <= (*p) && (*p) <= 31 ) + goto st0; + goto st26; +st27: + if ( ++p == pe ) + goto _test_eof27; +case 27: + switch( (*p) ) { + case 32: goto tr2; + case 36: goto st28; + case 95: goto st28; + } + if ( (*p) < 48 ) { + if ( 45 <= (*p) && (*p) <= 46 ) + goto st28; + } else if ( (*p) > 57 ) { + if ( 65 <= (*p) && (*p) <= 90 ) + goto st28; + } else + goto st28; + goto st0; +st28: + if ( ++p == pe ) + goto _test_eof28; +case 28: + switch( (*p) ) { + case 32: goto tr2; + case 36: goto st29; + case 95: goto st29; + } + if ( (*p) < 48 ) { + if ( 45 <= (*p) && (*p) <= 46 ) + goto st29; + } else if ( (*p) > 57 ) { + if ( 65 <= (*p) && (*p) <= 90 ) + goto st29; + } else + goto st29; + goto st0; +st29: + if ( ++p == pe ) + goto _test_eof29; +case 29: + switch( (*p) ) { + case 32: goto tr2; + case 36: goto st30; + case 95: goto st30; + } + if ( (*p) < 48 ) { + if ( 45 <= (*p) && (*p) <= 46 ) + goto st30; + } else if ( (*p) > 57 ) { + if ( 65 <= (*p) && (*p) <= 90 ) + goto st30; + } else + goto st30; + goto st0; +st30: + if ( ++p == pe ) + goto _test_eof30; +case 30: + switch( (*p) ) { + case 32: goto tr2; + case 36: goto st31; + case 95: goto st31; + } + if ( (*p) < 48 ) { + if ( 45 <= (*p) && (*p) <= 46 ) + goto st31; + } else if ( (*p) > 57 ) { + if ( 65 <= (*p) && (*p) <= 90 ) + goto st31; + } else + goto st31; + goto st0; +st31: + if ( ++p == pe ) + goto _test_eof31; +case 31: + switch( (*p) ) { + case 32: goto tr2; + case 36: goto st32; + case 95: goto st32; + } + if ( (*p) < 48 ) { + if ( 45 <= (*p) && (*p) <= 46 ) + goto st32; + } else if ( (*p) > 57 ) { + if ( 65 <= (*p) && (*p) <= 90 ) + goto st32; + } else + goto st32; + goto st0; +st32: + if ( ++p == pe ) + goto _test_eof32; +case 32: + switch( (*p) ) { + case 32: goto tr2; + case 36: goto st33; + case 95: goto st33; + } + if ( (*p) < 48 ) { + if ( 45 <= (*p) && (*p) <= 46 ) + goto st33; + } else if ( (*p) > 57 ) { + if ( 65 <= (*p) && (*p) <= 90 ) + goto st33; + } else + goto st33; + goto st0; +st33: + if ( ++p == pe ) + goto _test_eof33; +case 33: + switch( (*p) ) { + case 32: goto tr2; + case 36: goto st34; + case 95: goto st34; + } + if ( (*p) < 48 ) { + if ( 45 <= (*p) && (*p) <= 46 ) + goto st34; + } else if ( (*p) > 57 ) { + if ( 65 <= (*p) && (*p) <= 90 ) + goto st34; + } else + goto st34; + goto st0; +st34: + if ( ++p == pe ) + goto _test_eof34; +case 34: + switch( (*p) ) { + case 32: goto tr2; + case 36: goto st35; + case 95: goto st35; + } + if ( (*p) < 48 ) { + if ( 45 <= (*p) && (*p) <= 46 ) + goto st35; + } else if ( (*p) > 57 ) { + if ( 65 <= (*p) && (*p) <= 90 ) + goto st35; + } else + goto st35; + goto st0; +st35: + if ( ++p == pe ) + goto _test_eof35; +case 35: + switch( (*p) ) { + case 32: goto tr2; + case 36: goto st36; + case 95: goto st36; + } + if ( (*p) < 48 ) { + if ( 45 <= (*p) && (*p) <= 46 ) + goto st36; + } else if ( (*p) > 57 ) { + if ( 65 <= (*p) && (*p) <= 90 ) + goto st36; + } else + goto st36; + goto st0; +st36: + if ( ++p == pe ) + goto _test_eof36; +case 36: + switch( (*p) ) { + case 32: goto tr2; + case 36: goto st37; + case 95: goto st37; + } + if ( (*p) < 48 ) { + if ( 45 <= (*p) && (*p) <= 46 ) + goto st37; + } else if ( (*p) > 57 ) { + if ( 65 <= (*p) && (*p) <= 90 ) + goto st37; + } else + goto st37; + goto st0; +st37: + if ( ++p == pe ) + goto _test_eof37; +case 37: + switch( (*p) ) { + case 32: goto tr2; + case 36: goto st38; + case 95: goto st38; + } + if ( (*p) < 48 ) { + if ( 45 <= (*p) && (*p) <= 46 ) + goto st38; + } else if ( (*p) > 57 ) { + if ( 65 <= (*p) && (*p) <= 90 ) + goto st38; + } else + goto st38; + goto st0; +st38: + if ( ++p == pe ) + goto _test_eof38; +case 38: + switch( (*p) ) { + case 32: goto tr2; + case 36: goto st39; + case 95: goto st39; + } + if ( (*p) < 48 ) { + if ( 45 <= (*p) && (*p) <= 46 ) + goto st39; + } else if ( (*p) > 57 ) { + if ( 65 <= (*p) && (*p) <= 90 ) + goto st39; + } else + goto st39; + goto st0; +st39: + if ( ++p == pe ) + goto _test_eof39; +case 39: + switch( (*p) ) { + case 32: goto tr2; + case 36: goto st40; + case 95: goto st40; + } + if ( (*p) < 48 ) { + if ( 45 <= (*p) && (*p) <= 46 ) + goto st40; + } else if ( (*p) > 57 ) { + if ( 65 <= (*p) && (*p) <= 90 ) + goto st40; + } else + goto st40; + goto st0; +st40: + if ( ++p == pe ) + goto _test_eof40; +case 40: + switch( (*p) ) { + case 32: goto tr2; + case 36: goto st41; + case 95: goto st41; + } + if ( (*p) < 48 ) { + if ( 45 <= (*p) && (*p) <= 46 ) + goto st41; + } else if ( (*p) > 57 ) { + if ( 65 <= (*p) && (*p) <= 90 ) + goto st41; + } else + goto st41; + goto st0; +st41: + if ( ++p == pe ) + goto _test_eof41; +case 41: + switch( (*p) ) { + case 32: goto tr2; + case 36: goto st42; + case 95: goto st42; + } + if ( (*p) < 48 ) { + if ( 45 <= (*p) && (*p) <= 46 ) + goto st42; + } else if ( (*p) > 57 ) { + if ( 65 <= (*p) && (*p) <= 90 ) + goto st42; + } else + goto st42; + goto st0; +st42: + if ( ++p == pe ) + goto _test_eof42; +case 42: + switch( (*p) ) { + case 32: goto tr2; + case 36: goto st43; + case 95: goto st43; + } + if ( (*p) < 48 ) { + if ( 45 <= (*p) && (*p) <= 46 ) + goto st43; + } else if ( (*p) > 57 ) { + if ( 65 <= (*p) && (*p) <= 90 ) + goto st43; + } else + goto st43; + goto st0; +st43: + if ( ++p == pe ) + goto _test_eof43; +case 43: + switch( (*p) ) { + case 32: goto tr2; + case 36: goto st44; + case 95: goto st44; + } + if ( (*p) < 48 ) { + if ( 45 <= (*p) && (*p) <= 46 ) + goto st44; + } else if ( (*p) > 57 ) { + if ( 65 <= (*p) && (*p) <= 90 ) + goto st44; + } else + goto st44; + goto st0; +st44: + if ( ++p == pe ) + goto _test_eof44; +case 44: + switch( (*p) ) { + case 32: goto tr2; + case 36: goto st45; + case 95: goto st45; + } + if ( (*p) < 48 ) { + if ( 45 <= (*p) && (*p) <= 46 ) + goto st45; + } else if ( (*p) > 57 ) { + if ( 65 <= (*p) && (*p) <= 90 ) + goto st45; + } else + goto st45; + goto st0; +st45: + if ( ++p == pe ) + goto _test_eof45; +case 45: + if ( (*p) == 32 ) + goto tr2; + goto st0; + } + _test_eof2: cs = 2; goto _test_eof; + _test_eof3: cs = 3; goto _test_eof; + _test_eof4: cs = 4; goto _test_eof; + _test_eof5: cs = 5; goto _test_eof; + _test_eof6: cs = 6; goto _test_eof; + _test_eof7: cs = 7; goto _test_eof; + _test_eof8: cs = 8; goto _test_eof; + _test_eof9: cs = 9; goto _test_eof; + _test_eof10: cs = 10; goto _test_eof; + _test_eof11: cs = 11; goto _test_eof; + _test_eof12: cs = 12; goto _test_eof; + _test_eof13: cs = 13; goto _test_eof; + _test_eof14: cs = 14; goto _test_eof; + _test_eof15: cs = 15; goto _test_eof; + _test_eof16: cs = 16; goto _test_eof; + _test_eof46: cs = 46; goto _test_eof; + _test_eof17: cs = 17; goto _test_eof; + _test_eof18: cs = 18; goto _test_eof; + _test_eof19: cs = 19; goto _test_eof; + _test_eof20: cs = 20; goto _test_eof; + _test_eof21: cs = 21; goto _test_eof; + _test_eof22: cs = 22; goto _test_eof; + _test_eof23: cs = 23; goto _test_eof; + _test_eof24: cs = 24; goto _test_eof; + _test_eof25: cs = 25; goto _test_eof; + _test_eof26: cs = 26; goto _test_eof; + _test_eof27: cs = 27; goto _test_eof; + _test_eof28: cs = 28; goto _test_eof; + _test_eof29: cs = 29; goto _test_eof; + _test_eof30: cs = 30; goto _test_eof; + _test_eof31: cs = 31; goto _test_eof; + _test_eof32: cs = 32; goto _test_eof; + _test_eof33: cs = 33; goto _test_eof; + _test_eof34: cs = 34; goto _test_eof; + _test_eof35: cs = 35; goto _test_eof; + _test_eof36: cs = 36; goto _test_eof; + _test_eof37: cs = 37; goto _test_eof; + _test_eof38: cs = 38; goto _test_eof; + _test_eof39: cs = 39; goto _test_eof; + _test_eof40: cs = 40; goto _test_eof; + _test_eof41: cs = 41; goto _test_eof; + _test_eof42: cs = 42; goto _test_eof; + _test_eof43: cs = 43; goto _test_eof; + _test_eof44: cs = 44; goto _test_eof; + _test_eof45: cs = 45; goto _test_eof; + + _test_eof: {} + _out: {} + } + +#line 117 "ext/puma_http11/http11_parser.rl" + + if (!puma_parser_has_error(parser)) + parser->cs = cs; + parser->nread += p - (buffer + off); + + assert(p <= pe && "buffer overflow after parsing execute"); + assert(parser->nread <= len && "nread longer than length"); + assert(parser->body_start <= len && "body starts after buffer end"); + assert(parser->mark < len && "mark is after buffer end"); + assert(parser->field_len <= len && "field has length longer than whole buffer"); + assert(parser->field_start < len && "field starts after buffer end"); + + return(parser->nread); +} + +int puma_parser_finish(puma_parser *parser) +{ + if (puma_parser_has_error(parser) ) { + return -1; + } else if (puma_parser_is_finished(parser) ) { + return 1; + } else { + return 0; + } +} + +int puma_parser_has_error(puma_parser *parser) { + return parser->cs == puma_parser_error; +} + +int puma_parser_is_finished(puma_parser *parser) { + return parser->cs >= puma_parser_first_final; +} diff --git a/ext/puma_http11/http11_parser.h b/ext/puma_http11/http11_parser.h new file mode 100644 index 0000000..2debc89 --- /dev/null +++ b/ext/puma_http11/http11_parser.h @@ -0,0 +1,65 @@ +/** + * Copyright (c) 2005 Zed A. Shaw + * You can redistribute it and/or modify it under the same terms as Ruby. + * License 3-clause BSD + */ + +#ifndef http11_parser_h +#define http11_parser_h + +#define RSTRING_NOT_MODIFIED 1 +#include "ruby.h" + +#include + +#if defined(_WIN32) +#include +#endif + +#define BUFFER_LEN 1024 + +struct puma_parser; + +typedef void (*element_cb)(struct puma_parser* hp, + const char *at, size_t length); + +typedef void (*field_cb)(struct puma_parser* hp, + const char *field, size_t flen, + const char *value, size_t vlen); + +typedef struct puma_parser { + int cs; + int content_len; + size_t body_start; + size_t nread; + size_t mark; + size_t field_start; + size_t field_len; + size_t query_start; + + VALUE request; + VALUE body; + + field_cb http_field; + element_cb request_method; + element_cb request_uri; + element_cb fragment; + element_cb request_path; + element_cb query_string; + element_cb http_version; + element_cb header_done; + + char buf[BUFFER_LEN]; + +} puma_parser; + +int puma_parser_init(puma_parser *parser); +int puma_parser_finish(puma_parser *parser); +size_t puma_parser_execute(puma_parser *parser, const char *data, + size_t len, size_t off); +int puma_parser_has_error(puma_parser *parser); +int puma_parser_is_finished(puma_parser *parser); + +#define puma_parser_nread(parser) (parser)->nread + +#endif diff --git a/ext/puma_http11/http11_parser.java.rl b/ext/puma_http11/http11_parser.java.rl new file mode 100644 index 0000000..d5f3ca6 --- /dev/null +++ b/ext/puma_http11/http11_parser.java.rl @@ -0,0 +1,145 @@ +package org.jruby.puma; + +import org.jruby.Ruby; +import org.jruby.RubyHash; +import org.jruby.util.ByteList; + +public class Http11Parser { + +/** Machine **/ + +%%{ + + machine puma_parser; + + action mark {parser.mark = fpc; } + + action start_field { parser.field_start = fpc; } + action snake_upcase_field { /* FIXME stub */ } + action write_field { + parser.field_len = fpc-parser.field_start; + } + + action start_value { parser.mark = fpc; } + action write_value { + Http11.http_field(runtime, parser.data, parser.buffer, parser.field_start, parser.field_len, parser.mark, fpc-parser.mark); + } + action request_method { + Http11.request_method(runtime, parser.data, parser.buffer, parser.mark, fpc-parser.mark); + } + action request_uri { + Http11.request_uri(runtime, parser.data, parser.buffer, parser.mark, fpc-parser.mark); + } + action fragment { + Http11.fragment(runtime, parser.data, parser.buffer, parser.mark, fpc-parser.mark); + } + + action start_query {parser.query_start = fpc; } + action query_string { + Http11.query_string(runtime, parser.data, parser.buffer, parser.query_start, fpc-parser.query_start); + } + + action http_version { + Http11.http_version(runtime, parser.data, parser.buffer, parser.mark, fpc-parser.mark); + } + + action request_path { + Http11.request_path(runtime, parser.data, parser.buffer, parser.mark, fpc-parser.mark); + } + + action done { + parser.body_start = fpc + 1; + http.header_done(runtime, parser.data, parser.buffer, fpc + 1, pe - fpc - 1); + fbreak; + } + + include puma_parser_common "http11_parser_common.rl"; + +}%% + +/** Data **/ +%% write data noentry; + + public static interface ElementCB { + public void call(Ruby runtime, RubyHash data, ByteList buffer, int at, int length); + } + + public static interface FieldCB { + public void call(Ruby runtime, RubyHash data, ByteList buffer, int field, int flen, int value, int vlen); + } + + public static class HttpParser { + int cs; + int body_start; + int content_len; + int nread; + int mark; + int field_start; + int field_len; + int query_start; + + RubyHash data; + ByteList buffer; + + public void init() { + cs = 0; + + %% write init; + + body_start = 0; + content_len = 0; + mark = 0; + nread = 0; + field_len = 0; + field_start = 0; + } + } + + public final HttpParser parser = new HttpParser(); + + public int execute(Ruby runtime, Http11 http, ByteList buffer, int off) { + int p, pe; + int cs = parser.cs; + int len = buffer.length(); + assert off<=len : "offset past end of buffer"; + + p = off; + pe = len; + // get a copy of the bytes, since it may not start at 0 + // FIXME: figure out how to just use the bytes in-place + byte[] data = buffer.bytes(); + parser.buffer = buffer; + + %% write exec; + + parser.cs = cs; + parser.nread += (p - off); + + assert p <= pe : "buffer overflow after parsing execute"; + assert parser.nread <= len : "nread longer than length"; + assert parser.body_start <= len : "body starts after buffer end"; + assert parser.mark < len : "mark is after buffer end"; + assert parser.field_len <= len : "field has length longer than whole buffer"; + assert parser.field_start < len : "field starts after buffer end"; + + return parser.nread; + } + + public int finish() { + if(has_error()) { + return -1; + } else if(is_finished()) { + return 1; + } else { + return 0; + } + } + + public boolean has_error() { + return parser.cs == puma_parser_error; + } + + public boolean is_finished() { + return parser.cs == puma_parser_first_final; + } +} diff --git a/ext/puma_http11/http11_parser.rl b/ext/puma_http11/http11_parser.rl new file mode 100644 index 0000000..f2ee69c --- /dev/null +++ b/ext/puma_http11/http11_parser.rl @@ -0,0 +1,149 @@ +/** + * Copyright (c) 2005 Zed A. Shaw + * You can redistribute it and/or modify it under the same terms as Ruby. + * License 3-clause BSD + */ +#include "http11_parser.h" +#include +#include +#include +#include +#include + +/* + * capitalizes all lower-case ASCII characters, + * converts dashes to underscores, and underscores to commas. + */ +static void snake_upcase_char(char *c) +{ + if (*c >= 'a' && *c <= 'z') + *c &= ~0x20; + else if (*c == '_') + *c = ','; + else if (*c == '-') + *c = '_'; +} + +#define LEN(AT, FPC) (FPC - buffer - parser->AT) +#define MARK(M,FPC) (parser->M = (FPC) - buffer) +#define PTR_TO(F) (buffer + parser->F) + +/** Machine **/ + +%%{ + + machine puma_parser; + + action mark { MARK(mark, fpc); } + + + action start_field { MARK(field_start, fpc); } + action snake_upcase_field { snake_upcase_char((char *)fpc); } + action write_field { + parser->field_len = LEN(field_start, fpc); + } + + action start_value { MARK(mark, fpc); } + action write_value { + parser->http_field(parser, PTR_TO(field_start), parser->field_len, PTR_TO(mark), LEN(mark, fpc)); + } + action request_method { + parser->request_method(parser, PTR_TO(mark), LEN(mark, fpc)); + } + action request_uri { + parser->request_uri(parser, PTR_TO(mark), LEN(mark, fpc)); + } + action fragment { + parser->fragment(parser, PTR_TO(mark), LEN(mark, fpc)); + } + + action start_query { MARK(query_start, fpc); } + action query_string { + parser->query_string(parser, PTR_TO(query_start), LEN(query_start, fpc)); + } + + action http_version { + parser->http_version(parser, PTR_TO(mark), LEN(mark, fpc)); + } + + action request_path { + parser->request_path(parser, PTR_TO(mark), LEN(mark,fpc)); + } + + action done { + parser->body_start = fpc - buffer + 1; + parser->header_done(parser, fpc + 1, pe - fpc - 1); + fbreak; + } + + include puma_parser_common "http11_parser_common.rl"; + +}%% + +/** Data **/ +%% write data noentry; + +int puma_parser_init(puma_parser *parser) { + int cs = 0; + %% write init; + parser->cs = cs; + parser->body_start = 0; + parser->content_len = 0; + parser->mark = 0; + parser->nread = 0; + parser->field_len = 0; + parser->field_start = 0; + parser->request = Qnil; + parser->body = Qnil; + + return 1; +} + + +/** exec **/ +size_t puma_parser_execute(puma_parser *parser, const char *buffer, size_t len, size_t off) { + const char *p, *pe; + int cs = parser->cs; + + assert(off <= len && "offset past end of buffer"); + + p = buffer+off; + pe = buffer+len; + + /* assert(*pe == '\0' && "pointer does not end on NUL"); */ + assert((size_t) (pe - p) == len - off && "pointers aren't same distance"); + + %% write exec; + + if (!puma_parser_has_error(parser)) + parser->cs = cs; + parser->nread += p - (buffer + off); + + assert(p <= pe && "buffer overflow after parsing execute"); + assert(parser->nread <= len && "nread longer than length"); + assert(parser->body_start <= len && "body starts after buffer end"); + assert(parser->mark < len && "mark is after buffer end"); + assert(parser->field_len <= len && "field has length longer than whole buffer"); + assert(parser->field_start < len && "field starts after buffer end"); + + return(parser->nread); +} + +int puma_parser_finish(puma_parser *parser) +{ + if (puma_parser_has_error(parser) ) { + return -1; + } else if (puma_parser_is_finished(parser) ) { + return 1; + } else { + return 0; + } +} + +int puma_parser_has_error(puma_parser *parser) { + return parser->cs == puma_parser_error; +} + +int puma_parser_is_finished(puma_parser *parser) { + return parser->cs >= puma_parser_first_final; +} diff --git a/ext/puma_http11/http11_parser_common.rl b/ext/puma_http11/http11_parser_common.rl new file mode 100644 index 0000000..d156d04 --- /dev/null +++ b/ext/puma_http11/http11_parser_common.rl @@ -0,0 +1,54 @@ +%%{ + + machine puma_parser_common; + +#### HTTP PROTOCOL GRAMMAR +# line endings + CRLF = "\r\n"; + +# character types + CTL = (cntrl | 127); + safe = ("$" | "-" | "_" | "."); + extra = ("!" | "*" | "'" | "(" | ")" | ","); + reserved = (";" | "/" | "?" | ":" | "@" | "&" | "=" | "+"); + unsafe = (CTL | " " | "\"" | "#" | "%" | "<" | ">"); + national = any -- (alpha | digit | reserved | extra | safe | unsafe); + unreserved = (alpha | digit | safe | extra | national); + escape = ("%" xdigit xdigit); + uchar = (unreserved | escape | "%"); + pchar = (uchar | ":" | "@" | "&" | "=" | "+" | ";"); + tspecials = ("(" | ")" | "<" | ">" | "@" | "," | ";" | ":" | "\\" | "\"" | "/" | "[" | "]" | "?" | "=" | "{" | "}" | " " | "\t"); + +# elements + token = (ascii -- (CTL | tspecials)); + +# URI schemes and absolute paths + scheme = ( alpha | digit | "+" | "-" | "." )* ; + absolute_uri = (scheme ":" (uchar | reserved )*); + + path = ( pchar+ ( "/" pchar* )* ) ; + query = ( uchar | reserved )* %query_string ; + param = ( pchar | "/" )* ; + params = ( param ( ";" param )* ) ; + rel_path = ( path? %request_path ) ("?" %start_query query)?; + absolute_path = ( "/"+ rel_path ); + + Request_URI = ( "*" | absolute_uri | absolute_path ) >mark %request_uri; + Fragment = ( uchar | reserved )* >mark %fragment; + Method = ( upper | digit | safe ){1,20} >mark %request_method; + + http_number = ( digit+ "." digit+ ) ; + HTTP_Version = ( "HTTP/" http_number ) >mark %http_version ; + Request_Line = ( Method " " Request_URI ("#" Fragment){0,1} " " HTTP_Version CRLF ) ; + + field_name = ( token -- ":" )+ >start_field $snake_upcase_field %write_field; + + field_value = ( (any -- CTL) | "\t" )* >start_value %write_value; + + message_header = field_name ":" " "* field_value :> CRLF; + + Request = Request_Line ( message_header )* ( CRLF @done ); + +main := Request; + +}%% diff --git a/ext/puma_http11/mini_ssl.c b/ext/puma_http11/mini_ssl.c new file mode 100644 index 0000000..4d26032 --- /dev/null +++ b/ext/puma_http11/mini_ssl.c @@ -0,0 +1,706 @@ +#define RSTRING_NOT_MODIFIED 1 + +#include +#include +#include + +#ifdef HAVE_OPENSSL_BIO_H + +#include +#include +#include +#include +#include + +#ifndef SSL_OP_NO_COMPRESSION +#define SSL_OP_NO_COMPRESSION 0 +#endif + +typedef struct { + BIO* read; + BIO* write; + SSL* ssl; + SSL_CTX* ctx; +} ms_conn; + +typedef struct { + unsigned char* buf; + int bytes; +} ms_cert_buf; + +VALUE eError; + +void engine_free(void *ptr) { + ms_conn *conn = ptr; + ms_cert_buf* cert_buf = (ms_cert_buf*)SSL_get_app_data(conn->ssl); + if(cert_buf) { + OPENSSL_free(cert_buf->buf); + free(cert_buf); + } + SSL_free(conn->ssl); + SSL_CTX_free(conn->ctx); + + free(conn); +} + +const rb_data_type_t engine_data_type = { + "MiniSSL/ENGINE", + { 0, engine_free, 0 }, + 0, 0, RUBY_TYPED_FREE_IMMEDIATELY, +}; + +#ifndef HAVE_SSL_GET1_PEER_CERTIFICATE +DH *get_dh2048(void) { + /* `openssl dhparam -C 2048` + * -----BEGIN DH PARAMETERS----- + * MIIBCAKCAQEAjmh1uQHdTfxOyxEbKAV30fUfzqMDF/ChPzjfyzl2jcrqQMhrk76o + * 2NPNXqxHwsddMZ1RzvU8/jl+uhRuPWjXCFZbhET4N1vrviZM3VJhV8PPHuiVOACO + * y32jFd+Szx4bo2cXSK83hJ6jRd+0asP1awWjz9/06dFkrILCXMIfQLo0D8rqmppn + * EfDDAwuudCpM9kcDmBRAm9JsKbQ6gzZWjkc5+QWSaQofojIHbjvj3xzguaCJn+oQ + * vHWM+hsAnaOgEwCyeZ3xqs+/5lwSbkE/tqJW98cEZGygBUVo9jxZRZx6KOfjpdrb + * yenO9LJr/qtyrZB31WJbqxI0m0AKTAO8UwIBAg== + * -----END DH PARAMETERS----- + */ + static unsigned char dh2048_p[] = { + 0x8E, 0x68, 0x75, 0xB9, 0x01, 0xDD, 0x4D, 0xFC, 0x4E, 0xCB, + 0x11, 0x1B, 0x28, 0x05, 0x77, 0xD1, 0xF5, 0x1F, 0xCE, 0xA3, + 0x03, 0x17, 0xF0, 0xA1, 0x3F, 0x38, 0xDF, 0xCB, 0x39, 0x76, + 0x8D, 0xCA, 0xEA, 0x40, 0xC8, 0x6B, 0x93, 0xBE, 0xA8, 0xD8, + 0xD3, 0xCD, 0x5E, 0xAC, 0x47, 0xC2, 0xC7, 0x5D, 0x31, 0x9D, + 0x51, 0xCE, 0xF5, 0x3C, 0xFE, 0x39, 0x7E, 0xBA, 0x14, 0x6E, + 0x3D, 0x68, 0xD7, 0x08, 0x56, 0x5B, 0x84, 0x44, 0xF8, 0x37, + 0x5B, 0xEB, 0xBE, 0x26, 0x4C, 0xDD, 0x52, 0x61, 0x57, 0xC3, + 0xCF, 0x1E, 0xE8, 0x95, 0x38, 0x00, 0x8E, 0xCB, 0x7D, 0xA3, + 0x15, 0xDF, 0x92, 0xCF, 0x1E, 0x1B, 0xA3, 0x67, 0x17, 0x48, + 0xAF, 0x37, 0x84, 0x9E, 0xA3, 0x45, 0xDF, 0xB4, 0x6A, 0xC3, + 0xF5, 0x6B, 0x05, 0xA3, 0xCF, 0xDF, 0xF4, 0xE9, 0xD1, 0x64, + 0xAC, 0x82, 0xC2, 0x5C, 0xC2, 0x1F, 0x40, 0xBA, 0x34, 0x0F, + 0xCA, 0xEA, 0x9A, 0x9A, 0x67, 0x11, 0xF0, 0xC3, 0x03, 0x0B, + 0xAE, 0x74, 0x2A, 0x4C, 0xF6, 0x47, 0x03, 0x98, 0x14, 0x40, + 0x9B, 0xD2, 0x6C, 0x29, 0xB4, 0x3A, 0x83, 0x36, 0x56, 0x8E, + 0x47, 0x39, 0xF9, 0x05, 0x92, 0x69, 0x0A, 0x1F, 0xA2, 0x32, + 0x07, 0x6E, 0x3B, 0xE3, 0xDF, 0x1C, 0xE0, 0xB9, 0xA0, 0x89, + 0x9F, 0xEA, 0x10, 0xBC, 0x75, 0x8C, 0xFA, 0x1B, 0x00, 0x9D, + 0xA3, 0xA0, 0x13, 0x00, 0xB2, 0x79, 0x9D, 0xF1, 0xAA, 0xCF, + 0xBF, 0xE6, 0x5C, 0x12, 0x6E, 0x41, 0x3F, 0xB6, 0xA2, 0x56, + 0xF7, 0xC7, 0x04, 0x64, 0x6C, 0xA0, 0x05, 0x45, 0x68, 0xF6, + 0x3C, 0x59, 0x45, 0x9C, 0x7A, 0x28, 0xE7, 0xE3, 0xA5, 0xDA, + 0xDB, 0xC9, 0xE9, 0xCE, 0xF4, 0xB2, 0x6B, 0xFE, 0xAB, 0x72, + 0xAD, 0x90, 0x77, 0xD5, 0x62, 0x5B, 0xAB, 0x12, 0x34, 0x9B, + 0x40, 0x0A, 0x4C, 0x03, 0xBC, 0x53 + }; + static unsigned char dh2048_g[] = { 0x02 }; + + DH *dh; +#if !(OPENSSL_VERSION_NUMBER < 0x10100005L || defined(LIBRESSL_VERSION_NUMBER)) + BIGNUM *p, *g; +#endif + + dh = DH_new(); + +#if OPENSSL_VERSION_NUMBER < 0x10100005L || defined(LIBRESSL_VERSION_NUMBER) + dh->p = BN_bin2bn(dh2048_p, sizeof(dh2048_p), NULL); + dh->g = BN_bin2bn(dh2048_g, sizeof(dh2048_g), NULL); + + if ((dh->p == NULL) || (dh->g == NULL)) { + DH_free(dh); + return NULL; + } +#else + p = BN_bin2bn(dh2048_p, sizeof(dh2048_p), NULL); + g = BN_bin2bn(dh2048_g, sizeof(dh2048_g), NULL); + + if (p == NULL || g == NULL || !DH_set0_pqg(dh, p, NULL, g)) { + DH_free(dh); + BN_free(p); + BN_free(g); + return NULL; + } +#endif + + return dh; +} +#endif + +static void +sslctx_free(void *ptr) { + SSL_CTX *ctx = ptr; + SSL_CTX_free(ctx); +} + +static const rb_data_type_t sslctx_type = { + "MiniSSL/SSLContext", + { + 0, sslctx_free, + }, + 0, 0, RUBY_TYPED_FREE_IMMEDIATELY, +}; + +ms_conn* engine_alloc(VALUE klass, VALUE* obj) { + ms_conn* conn; + + *obj = TypedData_Make_Struct(klass, ms_conn, &engine_data_type, conn); + + conn->read = BIO_new(BIO_s_mem()); + BIO_set_nbio(conn->read, 1); + + conn->write = BIO_new(BIO_s_mem()); + BIO_set_nbio(conn->write, 1); + + conn->ssl = 0; + conn->ctx = 0; + + return conn; +} + +static int engine_verify_callback(int preverify_ok, X509_STORE_CTX* ctx) { + X509* err_cert; + SSL* ssl; + int bytes; + unsigned char* buf = NULL; + + if(!preverify_ok) { + err_cert = X509_STORE_CTX_get_current_cert(ctx); + if(err_cert) { + /* + * Save the failed certificate for inspection/logging. + */ + bytes = i2d_X509(err_cert, &buf); + if(bytes > 0) { + ms_cert_buf* cert_buf = (ms_cert_buf*)malloc(sizeof(ms_cert_buf)); + cert_buf->buf = buf; + cert_buf->bytes = bytes; + ssl = X509_STORE_CTX_get_ex_data(ctx, SSL_get_ex_data_X509_STORE_CTX_idx()); + SSL_set_app_data(ssl, cert_buf); + } + } + } + + return preverify_ok; +} + +static VALUE +sslctx_alloc(VALUE klass) { + SSL_CTX *ctx; + long mode = 0 | + SSL_MODE_ENABLE_PARTIAL_WRITE | + SSL_MODE_ACCEPT_MOVING_WRITE_BUFFER | + SSL_MODE_RELEASE_BUFFERS; + +#ifdef HAVE_TLS_SERVER_METHOD + ctx = SSL_CTX_new(TLS_method()); + // printf("\nctx using TLS_method security_level %d\n", SSL_CTX_get_security_level(ctx)); +#else + ctx = SSL_CTX_new(SSLv23_method()); +#endif + if (!ctx) { + rb_raise(eError, "SSL_CTX_new"); + } + SSL_CTX_set_mode(ctx, mode); + + return TypedData_Wrap_Struct(klass, &sslctx_type, ctx); +} + +VALUE +sslctx_initialize(VALUE self, VALUE mini_ssl_ctx) { + SSL_CTX* ctx; + +#ifdef HAVE_SSL_CTX_SET_MIN_PROTO_VERSION + int min; +#endif + int ssl_options; + VALUE key, cert, ca, verify_mode, ssl_cipher_filter, no_tlsv1, no_tlsv1_1, + verification_flags, session_id_bytes, cert_pem, key_pem; +#ifndef HAVE_SSL_GET1_PEER_CERTIFICATE + DH *dh; +#endif + BIO *bio; + X509 *x509; + EVP_PKEY *pkey; + +#if OPENSSL_VERSION_NUMBER < 0x10002000L + EC_KEY *ecdh; +#endif + + TypedData_Get_Struct(self, SSL_CTX, &sslctx_type, ctx); + + key = rb_funcall(mini_ssl_ctx, rb_intern_const("key"), 0); + + cert = rb_funcall(mini_ssl_ctx, rb_intern_const("cert"), 0); + + ca = rb_funcall(mini_ssl_ctx, rb_intern_const("ca"), 0); + + cert_pem = rb_funcall(mini_ssl_ctx, rb_intern_const("cert_pem"), 0); + + key_pem = rb_funcall(mini_ssl_ctx, rb_intern_const("key_pem"), 0); + + verify_mode = rb_funcall(mini_ssl_ctx, rb_intern_const("verify_mode"), 0); + + ssl_cipher_filter = rb_funcall(mini_ssl_ctx, rb_intern_const("ssl_cipher_filter"), 0); + + no_tlsv1 = rb_funcall(mini_ssl_ctx, rb_intern_const("no_tlsv1"), 0); + + no_tlsv1_1 = rb_funcall(mini_ssl_ctx, rb_intern_const("no_tlsv1_1"), 0); + + if (!NIL_P(cert)) { + StringValue(cert); + SSL_CTX_use_certificate_chain_file(ctx, RSTRING_PTR(cert)); + } + + if (!NIL_P(key)) { + StringValue(key); + SSL_CTX_use_PrivateKey_file(ctx, RSTRING_PTR(key), SSL_FILETYPE_PEM); + } + + if (!NIL_P(cert_pem)) { + bio = BIO_new(BIO_s_mem()); + BIO_puts(bio, RSTRING_PTR(cert_pem)); + x509 = PEM_read_bio_X509(bio, NULL, NULL, NULL); + + SSL_CTX_use_certificate(ctx, x509); + } + + if (!NIL_P(key_pem)) { + bio = BIO_new(BIO_s_mem()); + BIO_puts(bio, RSTRING_PTR(key_pem)); + pkey = PEM_read_bio_PrivateKey(bio, NULL, NULL, NULL); + + SSL_CTX_use_PrivateKey(ctx, pkey); + } + + verification_flags = rb_funcall(mini_ssl_ctx, rb_intern_const("verification_flags"), 0); + + if (!NIL_P(verification_flags)) { + X509_VERIFY_PARAM *param = SSL_CTX_get0_param(ctx); + X509_VERIFY_PARAM_set_flags(param, NUM2INT(verification_flags)); + SSL_CTX_set1_param(ctx, param); + } + + if (!NIL_P(ca)) { + StringValue(ca); + SSL_CTX_load_verify_locations(ctx, RSTRING_PTR(ca), NULL); + } + + ssl_options = SSL_OP_CIPHER_SERVER_PREFERENCE | SSL_OP_SINGLE_ECDH_USE | SSL_OP_NO_COMPRESSION; + +#ifdef HAVE_SSL_CTX_SET_MIN_PROTO_VERSION + if (RTEST(no_tlsv1_1)) { + min = TLS1_2_VERSION; + } + else if (RTEST(no_tlsv1)) { + min = TLS1_1_VERSION; + } + else { + min = TLS1_VERSION; + } + + SSL_CTX_set_min_proto_version(ctx, min); + + SSL_CTX_set_options(ctx, ssl_options); + +#else + /* As of 1.0.2f, SSL_OP_SINGLE_DH_USE key use is always on */ + ssl_options |= SSL_OP_NO_SSLv2 | SSL_OP_NO_SSLv3 | SSL_OP_SINGLE_DH_USE; + + if (RTEST(no_tlsv1)) { + ssl_options |= SSL_OP_NO_TLSv1; + } + if(RTEST(no_tlsv1_1)) { + ssl_options |= SSL_OP_NO_TLSv1 | SSL_OP_NO_TLSv1_1; + } + SSL_CTX_set_options(ctx, ssl_options); +#endif + + SSL_CTX_set_session_cache_mode(ctx, SSL_SESS_CACHE_OFF); + + if (!NIL_P(ssl_cipher_filter)) { + StringValue(ssl_cipher_filter); + SSL_CTX_set_cipher_list(ctx, RSTRING_PTR(ssl_cipher_filter)); + } + else { + SSL_CTX_set_cipher_list(ctx, "HIGH:!aNULL@STRENGTH"); + } + +#if OPENSSL_VERSION_NUMBER < 0x10002000L + // Remove this case if OpenSSL 1.0.1 (now EOL) support is no + // longer needed. + ecdh = EC_KEY_new_by_curve_name(NID_X9_62_prime256v1); + if (ecdh) { + SSL_CTX_set_tmp_ecdh(ctx, ecdh); + EC_KEY_free(ecdh); + } +#elif OPENSSL_VERSION_NUMBER < 0x10100000L || defined(LIBRESSL_VERSION_NUMBER) + SSL_CTX_set_ecdh_auto(ctx, 1); +#endif + + if (NIL_P(verify_mode)) { + /* SSL_CTX_set_verify(ctx, SSL_VERIFY_NONE, NULL); */ + } else { + SSL_CTX_set_verify(ctx, NUM2INT(verify_mode), engine_verify_callback); + } + + // Random.bytes available in Ruby 2.5 and later, Random::DEFAULT deprecated in 3.0 + session_id_bytes = rb_funcall( +#ifdef HAVE_RANDOM_BYTES + rb_cRandom, +#else + rb_const_get(rb_cRandom, rb_intern_const("DEFAULT")), +#endif + rb_intern_const("bytes"), + 1, ULL2NUM(SSL_MAX_SSL_SESSION_ID_LENGTH)); + + SSL_CTX_set_session_id_context(ctx, + (unsigned char *) RSTRING_PTR(session_id_bytes), + SSL_MAX_SSL_SESSION_ID_LENGTH); + + // printf("\ninitialize end security_level %d\n", SSL_CTX_get_security_level(ctx)); + +#ifdef HAVE_SSL_GET1_PEER_CERTIFICATE + // https://www.openssl.org/docs/man3.0/man3/SSL_CTX_set_dh_auto.html + SSL_CTX_set_dh_auto(ctx, 1); +#else + dh = get_dh2048(); + SSL_CTX_set_tmp_dh(ctx, dh); +#endif + + rb_obj_freeze(self); + return self; +} + +VALUE engine_init_server(VALUE self, VALUE sslctx) { + ms_conn* conn; + VALUE obj; + SSL_CTX* ctx; + SSL* ssl; + + conn = engine_alloc(self, &obj); + + TypedData_Get_Struct(sslctx, SSL_CTX, &sslctx_type, ctx); + + ssl = SSL_new(ctx); + conn->ssl = ssl; + SSL_set_app_data(ssl, NULL); + SSL_set_bio(ssl, conn->read, conn->write); + SSL_set_accept_state(ssl); + return obj; +} + +VALUE engine_init_client(VALUE klass) { + VALUE obj; + ms_conn* conn = engine_alloc(klass, &obj); +#ifdef HAVE_DTLS_METHOD + conn->ctx = SSL_CTX_new(DTLS_method()); +#else + conn->ctx = SSL_CTX_new(DTLSv1_method()); +#endif + conn->ssl = SSL_new(conn->ctx); + SSL_set_app_data(conn->ssl, NULL); + SSL_set_verify(conn->ssl, SSL_VERIFY_NONE, NULL); + + SSL_set_bio(conn->ssl, conn->read, conn->write); + + SSL_set_connect_state(conn->ssl); + return obj; +} + +VALUE engine_inject(VALUE self, VALUE str) { + ms_conn* conn; + long used; + + TypedData_Get_Struct(self, ms_conn, &engine_data_type, conn); + + StringValue(str); + + used = BIO_write(conn->read, RSTRING_PTR(str), (int)RSTRING_LEN(str)); + + if(used == 0 || used == -1) { + return Qfalse; + } + + return INT2FIX(used); +} + +NORETURN(void raise_error(SSL* ssl, int result)); + +void raise_error(SSL* ssl, int result) { + char buf[512]; + char msg[512]; + const char* err_str; + int err = errno; + int mask = 4095; + int ssl_err = SSL_get_error(ssl, result); + int verify_err = (int) SSL_get_verify_result(ssl); + + if(SSL_ERROR_SYSCALL == ssl_err) { + snprintf(msg, sizeof(msg), "System error: %s - %d", strerror(err), err); + + } else if(SSL_ERROR_SSL == ssl_err) { + if(X509_V_OK != verify_err) { + err_str = X509_verify_cert_error_string(verify_err); + snprintf(msg, sizeof(msg), + "OpenSSL certificate verification error: %s - %d", + err_str, verify_err); + + } else { + err = (int) ERR_get_error(); + ERR_error_string_n(err, buf, sizeof(buf)); + snprintf(msg, sizeof(msg), "OpenSSL error: %s - %d", buf, err & mask); + } + } else { + snprintf(msg, sizeof(msg), "Unknown OpenSSL error: %d", ssl_err); + } + + ERR_clear_error(); + rb_raise(eError, "%s", msg); +} + +VALUE engine_read(VALUE self) { + ms_conn* conn; + char buf[512]; + int bytes, error; + + TypedData_Get_Struct(self, ms_conn, &engine_data_type, conn); + + ERR_clear_error(); + + bytes = SSL_read(conn->ssl, (void*)buf, sizeof(buf)); + + if(bytes > 0) { + return rb_str_new(buf, bytes); + } + + if(SSL_want_read(conn->ssl)) return Qnil; + + error = SSL_get_error(conn->ssl, bytes); + + if(error == SSL_ERROR_ZERO_RETURN) { + rb_eof_error(); + } else { + raise_error(conn->ssl, bytes); + } + + return Qnil; +} + +VALUE engine_write(VALUE self, VALUE str) { + ms_conn* conn; + int bytes; + + TypedData_Get_Struct(self, ms_conn, &engine_data_type, conn); + + StringValue(str); + + ERR_clear_error(); + + bytes = SSL_write(conn->ssl, (void*)RSTRING_PTR(str), (int)RSTRING_LEN(str)); + if(bytes > 0) { + return INT2FIX(bytes); + } + + if(SSL_want_write(conn->ssl)) return Qnil; + + raise_error(conn->ssl, bytes); + + return Qnil; +} + +VALUE engine_extract(VALUE self) { + ms_conn* conn; + int bytes; + size_t pending; + // https://www.openssl.org/docs/manmaster/man3/BIO_f_buffer.html + // crypto/bio/bf_buff.c DEFAULT_BUFFER_SIZE + char buf[4096]; + + TypedData_Get_Struct(self, ms_conn, &engine_data_type, conn); + + pending = BIO_pending(conn->write); + if(pending > 0) { + bytes = BIO_read(conn->write, buf, sizeof(buf)); + if(bytes > 0) { + return rb_str_new(buf, bytes); + } else if(!BIO_should_retry(conn->write)) { + raise_error(conn->ssl, bytes); + } + } + + return Qnil; +} + +VALUE engine_shutdown(VALUE self) { + ms_conn* conn; + int ok; + + TypedData_Get_Struct(self, ms_conn, &engine_data_type, conn); + + ERR_clear_error(); + + ok = SSL_shutdown(conn->ssl); + if (ok == 0) { + return Qfalse; + } + + return Qtrue; +} + +VALUE engine_init(VALUE self) { + ms_conn* conn; + + TypedData_Get_Struct(self, ms_conn, &engine_data_type, conn); + + return SSL_in_init(conn->ssl) ? Qtrue : Qfalse; +} + +VALUE engine_peercert(VALUE self) { + ms_conn* conn; + X509* cert; + int bytes; + unsigned char* buf = NULL; + ms_cert_buf* cert_buf = NULL; + VALUE rb_cert_buf; + + TypedData_Get_Struct(self, ms_conn, &engine_data_type, conn); + +#ifdef HAVE_SSL_GET1_PEER_CERTIFICATE + cert = SSL_get1_peer_certificate(conn->ssl); +#else + cert = SSL_get_peer_certificate(conn->ssl); +#endif + if(!cert) { + /* + * See if there was a failed certificate associated with this client. + */ + cert_buf = (ms_cert_buf*)SSL_get_app_data(conn->ssl); + if(!cert_buf) { + return Qnil; + } + buf = cert_buf->buf; + bytes = cert_buf->bytes; + + } else { + bytes = i2d_X509(cert, &buf); + X509_free(cert); + + if(bytes < 0) { + return Qnil; + } + } + + rb_cert_buf = rb_str_new((const char*)(buf), bytes); + if(!cert_buf) { + OPENSSL_free(buf); + } + + return rb_cert_buf; +} + +/* @see Puma::MiniSSL::Socket#ssl_version_state + * @version 5.0.0 + */ +static VALUE +engine_ssl_vers_st(VALUE self) { + ms_conn* conn; + TypedData_Get_Struct(self, ms_conn, &engine_data_type, conn); + return rb_ary_new3(2, rb_str_new2(SSL_get_version(conn->ssl)), rb_str_new2(SSL_state_string(conn->ssl))); +} + +VALUE noop(VALUE self) { + return Qnil; +} + +void Init_mini_ssl(VALUE puma) { + VALUE mod, eng, sslctx; + +/* Fake operation for documentation (RDoc, YARD) */ +#if 0 == 1 + puma = rb_define_module("Puma"); +#endif + + SSL_library_init(); + OpenSSL_add_ssl_algorithms(); + SSL_load_error_strings(); + ERR_load_crypto_strings(); + + mod = rb_define_module_under(puma, "MiniSSL"); + + eng = rb_define_class_under(mod, "Engine", rb_cObject); + rb_undef_alloc_func(eng); + + sslctx = rb_define_class_under(mod, "SSLContext", rb_cObject); + rb_define_alloc_func(sslctx, sslctx_alloc); + rb_define_method(sslctx, "initialize", sslctx_initialize, 1); + rb_undef_method(sslctx, "initialize_copy"); + + + // OpenSSL Build / Runtime/Load versions + + /* Version of OpenSSL that Puma was compiled with */ + rb_define_const(mod, "OPENSSL_VERSION", rb_str_new2(OPENSSL_VERSION_TEXT)); + +#if !defined(LIBRESSL_VERSION_NUMBER) && OPENSSL_VERSION_NUMBER >= 0x10100000 + /* Version of OpenSSL that Puma loaded with */ + rb_define_const(mod, "OPENSSL_LIBRARY_VERSION", rb_str_new2(OpenSSL_version(OPENSSL_VERSION))); +#else + rb_define_const(mod, "OPENSSL_LIBRARY_VERSION", rb_str_new2(SSLeay_version(SSLEAY_VERSION))); +#endif + +#if defined(OPENSSL_NO_SSL3) || defined(OPENSSL_NO_SSL3_METHOD) + /* True if SSL3 is not available */ + rb_define_const(mod, "OPENSSL_NO_SSL3", Qtrue); +#else + rb_define_const(mod, "OPENSSL_NO_SSL3", Qfalse); +#endif + +#if defined(OPENSSL_NO_TLS1) || defined(OPENSSL_NO_TLS1_METHOD) + /* True if TLS1 is not available */ + rb_define_const(mod, "OPENSSL_NO_TLS1", Qtrue); +#else + rb_define_const(mod, "OPENSSL_NO_TLS1", Qfalse); +#endif + +#if defined(OPENSSL_NO_TLS1_1) || defined(OPENSSL_NO_TLS1_1_METHOD) + /* True if TLS1_1 is not available */ + rb_define_const(mod, "OPENSSL_NO_TLS1_1", Qtrue); +#else + rb_define_const(mod, "OPENSSL_NO_TLS1_1", Qfalse); +#endif + + rb_define_singleton_method(mod, "check", noop, 0); + + eError = rb_define_class_under(mod, "SSLError", rb_eStandardError); + + rb_define_singleton_method(eng, "server", engine_init_server, 1); + rb_define_singleton_method(eng, "client", engine_init_client, 0); + + rb_define_method(eng, "inject", engine_inject, 1); + rb_define_method(eng, "read", engine_read, 0); + + rb_define_method(eng, "write", engine_write, 1); + rb_define_method(eng, "extract", engine_extract, 0); + + rb_define_method(eng, "shutdown", engine_shutdown, 0); + + rb_define_method(eng, "init?", engine_init, 0); + + rb_define_method(eng, "peercert", engine_peercert, 0); + + rb_define_method(eng, "ssl_vers_st", engine_ssl_vers_st, 0); +} + +#else + +NORETURN(VALUE raise_error(VALUE self)); + +VALUE raise_error(VALUE self) { + rb_raise(rb_eStandardError, "SSL not available in this build"); +} + +void Init_mini_ssl(VALUE puma) { + VALUE mod; + + mod = rb_define_module_under(puma, "MiniSSL"); + rb_define_class_under(mod, "SSLError", rb_eStandardError); + + rb_define_singleton_method(mod, "check", raise_error, 0); +} +#endif diff --git a/ext/puma_http11/no_ssl/PumaHttp11Service.java b/ext/puma_http11/no_ssl/PumaHttp11Service.java new file mode 100644 index 0000000..5701e83 --- /dev/null +++ b/ext/puma_http11/no_ssl/PumaHttp11Service.java @@ -0,0 +1,15 @@ +package puma; + +import java.io.IOException; + +import org.jruby.Ruby; +import org.jruby.runtime.load.BasicLibraryService; + +import org.jruby.puma.Http11; + +public class PumaHttp11Service implements BasicLibraryService { + public boolean basicLoad(final Ruby runtime) throws IOException { + Http11.createHttp11(runtime); + return true; + } +} diff --git a/ext/puma_http11/org/jruby/puma/Http11.java b/ext/puma_http11/org/jruby/puma/Http11.java new file mode 100644 index 0000000..cd7a5d3 --- /dev/null +++ b/ext/puma_http11/org/jruby/puma/Http11.java @@ -0,0 +1,226 @@ +package org.jruby.puma; + +import org.jruby.Ruby; +import org.jruby.RubyClass; +import org.jruby.RubyHash; +import org.jruby.RubyModule; +import org.jruby.RubyNumeric; +import org.jruby.RubyObject; +import org.jruby.RubyString; + +import org.jruby.anno.JRubyMethod; + +import org.jruby.runtime.ObjectAllocator; +import org.jruby.runtime.builtin.IRubyObject; + +import org.jruby.exceptions.RaiseException; + +import org.jruby.util.ByteList; + +/** + * @author Ola Bini + * @author Charles Oliver Nutter + */ +public class Http11 extends RubyObject { + public final static int MAX_FIELD_NAME_LENGTH = 256; + public final static String MAX_FIELD_NAME_LENGTH_ERR = "HTTP element FIELD_NAME is longer than the 256 allowed length."; + public final static int MAX_FIELD_VALUE_LENGTH = 80 * 1024; + public final static String MAX_FIELD_VALUE_LENGTH_ERR = "HTTP element FIELD_VALUE is longer than the 81920 allowed length."; + public final static int MAX_REQUEST_URI_LENGTH = 1024 * 12; + public final static String MAX_REQUEST_URI_LENGTH_ERR = "HTTP element REQUEST_URI is longer than the 12288 allowed length."; + public final static int MAX_FRAGMENT_LENGTH = 1024; + public final static String MAX_FRAGMENT_LENGTH_ERR = "HTTP element REQUEST_PATH is longer than the 1024 allowed length."; + public final static int MAX_REQUEST_PATH_LENGTH = 8192; + public final static String MAX_REQUEST_PATH_LENGTH_ERR = "HTTP element REQUEST_PATH is longer than the 8192 allowed length."; + public final static int MAX_QUERY_STRING_LENGTH = 1024 * 10; + public final static String MAX_QUERY_STRING_LENGTH_ERR = "HTTP element QUERY_STRING is longer than the 10240 allowed length."; + public final static int MAX_HEADER_LENGTH = 1024 * (80 + 32); + public final static String MAX_HEADER_LENGTH_ERR = "HTTP element HEADER is longer than the 114688 allowed length."; + + public static final ByteList CONTENT_TYPE_BYTELIST = new ByteList(ByteList.plain("CONTENT_TYPE")); + public static final ByteList CONTENT_LENGTH_BYTELIST = new ByteList(ByteList.plain("CONTENT_LENGTH")); + public static final ByteList HTTP_PREFIX_BYTELIST = new ByteList(ByteList.plain("HTTP_")); + public static final ByteList COMMA_SPACE_BYTELIST = new ByteList(ByteList.plain(", ")); + public static final ByteList REQUEST_METHOD_BYTELIST = new ByteList(ByteList.plain("REQUEST_METHOD")); + public static final ByteList REQUEST_URI_BYTELIST = new ByteList(ByteList.plain("REQUEST_URI")); + public static final ByteList FRAGMENT_BYTELIST = new ByteList(ByteList.plain("FRAGMENT")); + public static final ByteList REQUEST_PATH_BYTELIST = new ByteList(ByteList.plain("REQUEST_PATH")); + public static final ByteList QUERY_STRING_BYTELIST = new ByteList(ByteList.plain("QUERY_STRING")); + public static final ByteList HTTP_VERSION_BYTELIST = new ByteList(ByteList.plain("HTTP_VERSION")); + + private static ObjectAllocator ALLOCATOR = new ObjectAllocator() { + public IRubyObject allocate(Ruby runtime, RubyClass klass) { + return new Http11(runtime, klass); + } + }; + + public static void createHttp11(Ruby runtime) { + RubyModule mPuma = runtime.defineModule("Puma"); + mPuma.defineClassUnder("HttpParserError",runtime.getClass("IOError"),runtime.getClass("IOError").getAllocator()); + + RubyClass cHttpParser = mPuma.defineClassUnder("HttpParser",runtime.getObject(),ALLOCATOR); + cHttpParser.defineAnnotatedMethods(Http11.class); + } + + private Ruby runtime; + private Http11Parser hp; + private RubyString body; + + public Http11(Ruby runtime, RubyClass clazz) { + super(runtime,clazz); + this.runtime = runtime; + this.hp = new Http11Parser(); + this.hp.parser.init(); + } + + public static void validateMaxLength(Ruby runtime, int len, int max, String msg) { + if(len>max) { + throw newHTTPParserError(runtime, msg); + } + } + + private static RaiseException newHTTPParserError(Ruby runtime, String msg) { + return runtime.newRaiseException(getHTTPParserError(runtime), msg); + } + + private static RubyClass getHTTPParserError(Ruby runtime) { + // Cheaper to look this up lazily than cache eagerly and consume a field, since it's rarely encountered + return (RubyClass)runtime.getModule("Puma").getConstant("HttpParserError"); + } + + public static void http_field(Ruby runtime, RubyHash req, ByteList buffer, int field, int flen, int value, int vlen) { + RubyString f; + IRubyObject v; + validateMaxLength(runtime, flen, MAX_FIELD_NAME_LENGTH, MAX_FIELD_NAME_LENGTH_ERR); + validateMaxLength(runtime, vlen, MAX_FIELD_VALUE_LENGTH, MAX_FIELD_VALUE_LENGTH_ERR); + + ByteList b = new ByteList(buffer,field,flen); + for(int i = 0,j = b.length();i 0 && Character.isWhitespace(buffer.get(value + vlen - 1))) vlen--; + + if (b.equals(CONTENT_LENGTH_BYTELIST) || b.equals(CONTENT_TYPE_BYTELIST)) { + f = RubyString.newString(runtime, b); + } else { + f = RubyString.newStringShared(runtime, HTTP_PREFIX_BYTELIST); + f.cat(b); + } + + b = new ByteList(buffer, value, vlen); + v = req.fastARef(f); + if (v == null || v.isNil()) { + req.fastASet(f, RubyString.newString(runtime, b)); + } else { + RubyString vs = v.convertToString(); + vs.cat(COMMA_SPACE_BYTELIST); + vs.cat(b); + } + } + + public static void request_method(Ruby runtime, RubyHash req, ByteList buffer, int at, int length) { + RubyString val = RubyString.newString(runtime,new ByteList(buffer,at,length)); + req.fastASet(RubyString.newStringShared(runtime, REQUEST_METHOD_BYTELIST),val); + } + + public static void request_uri(Ruby runtime, RubyHash req, ByteList buffer, int at, int length) { + validateMaxLength(runtime, length, MAX_REQUEST_URI_LENGTH, MAX_REQUEST_URI_LENGTH_ERR); + RubyString val = RubyString.newString(runtime,new ByteList(buffer,at,length)); + req.fastASet(RubyString.newStringShared(runtime, REQUEST_URI_BYTELIST),val); + } + + public static void fragment(Ruby runtime, RubyHash req, ByteList buffer, int at, int length) { + validateMaxLength(runtime, length, MAX_FRAGMENT_LENGTH, MAX_FRAGMENT_LENGTH_ERR); + RubyString val = RubyString.newString(runtime,new ByteList(buffer,at,length)); + req.fastASet(RubyString.newStringShared(runtime, FRAGMENT_BYTELIST),val); + } + + public static void request_path(Ruby runtime, RubyHash req, ByteList buffer, int at, int length) { + validateMaxLength(runtime, length, MAX_REQUEST_PATH_LENGTH, MAX_REQUEST_PATH_LENGTH_ERR); + RubyString val = RubyString.newString(runtime,new ByteList(buffer,at,length)); + req.fastASet(RubyString.newStringShared(runtime, REQUEST_PATH_BYTELIST),val); + } + + public static void query_string(Ruby runtime, RubyHash req, ByteList buffer, int at, int length) { + validateMaxLength(runtime, length, MAX_QUERY_STRING_LENGTH, MAX_QUERY_STRING_LENGTH_ERR); + RubyString val = RubyString.newString(runtime,new ByteList(buffer,at,length)); + req.fastASet(RubyString.newStringShared(runtime, QUERY_STRING_BYTELIST),val); + } + + public static void http_version(Ruby runtime, RubyHash req, ByteList buffer, int at, int length) { + RubyString val = RubyString.newString(runtime,new ByteList(buffer,at,length)); + req.fastASet(RubyString.newStringShared(runtime, HTTP_VERSION_BYTELIST),val); + } + + public void header_done(Ruby runtime, RubyHash req, ByteList buffer, int at, int length) { + body = RubyString.newStringShared(runtime, new ByteList(buffer, at, length)); + } + + @JRubyMethod + public IRubyObject initialize() { + this.hp.parser.init(); + return this; + } + + @JRubyMethod + public IRubyObject reset() { + this.hp.parser.init(); + return runtime.getNil(); + } + + @JRubyMethod + public IRubyObject finish() { + this.hp.finish(); + return this.hp.is_finished() ? runtime.getTrue() : runtime.getFalse(); + } + + @JRubyMethod + public IRubyObject execute(IRubyObject req_hash, IRubyObject data, IRubyObject start) { + int from = RubyNumeric.fix2int(start); + ByteList d = ((RubyString)data).getByteList(); + if(from >= d.length()) { + throw newHTTPParserError(runtime, "Requested start is after data buffer end."); + } else { + Http11Parser hp = this.hp; + Http11Parser.HttpParser parser = hp.parser; + + parser.data = (RubyHash) req_hash; + + hp.execute(runtime, this, d,from); + + validateMaxLength(runtime, parser.nread,MAX_HEADER_LENGTH, MAX_HEADER_LENGTH_ERR); + + if(hp.has_error()) { + throw newHTTPParserError(runtime, "Invalid HTTP format, parsing fails. Are you trying to open an SSL connection to a non-SSL Puma?"); + } else { + return runtime.newFixnum(parser.nread); + } + } + } + + @JRubyMethod(name = "error?") + public IRubyObject has_error() { + return this.hp.has_error() ? runtime.getTrue() : runtime.getFalse(); + } + + @JRubyMethod(name = "finished?") + public IRubyObject is_finished() { + return this.hp.is_finished() ? runtime.getTrue() : runtime.getFalse(); + } + + @JRubyMethod + public IRubyObject nread() { + return runtime.newFixnum(this.hp.parser.nread); + } + + @JRubyMethod + public IRubyObject body() { + return body; + } +}// Http11 diff --git a/ext/puma_http11/org/jruby/puma/Http11Parser.java b/ext/puma_http11/org/jruby/puma/Http11Parser.java new file mode 100644 index 0000000..191feeb --- /dev/null +++ b/ext/puma_http11/org/jruby/puma/Http11Parser.java @@ -0,0 +1,455 @@ + +// line 1 "ext/puma_http11/http11_parser.java.rl" +package org.jruby.puma; + +import org.jruby.Ruby; +import org.jruby.RubyHash; +import org.jruby.util.ByteList; + +public class Http11Parser { + +/** Machine **/ + + +// line 58 "ext/puma_http11/http11_parser.java.rl" + + +/** Data **/ + +// line 20 "ext/puma_http11/org/jruby/puma/Http11Parser.java" +private static byte[] init__puma_parser_actions_0() +{ + return new byte [] { + 0, 1, 0, 1, 2, 1, 3, 1, 4, 1, 5, 1, + 6, 1, 7, 1, 8, 1, 9, 1, 11, 1, 12, 1, + 13, 2, 0, 8, 2, 1, 2, 2, 4, 5, 2, 10, + 7, 2, 12, 7, 3, 9, 10, 7 + }; +} + +private static final byte _puma_parser_actions[] = init__puma_parser_actions_0(); + + +private static short[] init__puma_parser_key_offsets_0() +{ + return new short [] { + 0, 0, 8, 17, 27, 29, 30, 31, 32, 33, 34, 36, + 39, 41, 44, 45, 61, 62, 78, 85, 91, 99, 107, 117, + 125, 134, 142, 150, 159, 168, 177, 186, 195, 204, 213, 222, + 231, 240, 249, 258, 267, 276, 285, 294, 303, 312, 313 + }; +} + +private static final short _puma_parser_key_offsets[] = init__puma_parser_key_offsets_0(); + + +private static char[] init__puma_parser_trans_keys_0() +{ + return new char [] { + 36, 95, 45, 46, 48, 57, 65, 90, 32, 36, 95, 45, + 46, 48, 57, 65, 90, 42, 43, 47, 58, 45, 57, 65, + 90, 97, 122, 32, 35, 72, 84, 84, 80, 47, 48, 57, + 46, 48, 57, 48, 57, 13, 48, 57, 10, 13, 33, 124, + 126, 35, 39, 42, 43, 45, 46, 48, 57, 65, 90, 94, + 122, 10, 33, 58, 124, 126, 35, 39, 42, 43, 45, 46, + 48, 57, 65, 90, 94, 122, 13, 32, 127, 0, 8, 10, + 31, 13, 127, 0, 8, 10, 31, 32, 60, 62, 127, 0, + 31, 34, 35, 32, 60, 62, 127, 0, 31, 34, 35, 43, + 58, 45, 46, 48, 57, 65, 90, 97, 122, 32, 34, 35, + 60, 62, 127, 0, 31, 32, 34, 35, 60, 62, 63, 127, + 0, 31, 32, 34, 35, 60, 62, 127, 0, 31, 32, 34, + 35, 60, 62, 127, 0, 31, 32, 36, 95, 45, 46, 48, + 57, 65, 90, 32, 36, 95, 45, 46, 48, 57, 65, 90, + 32, 36, 95, 45, 46, 48, 57, 65, 90, 32, 36, 95, + 45, 46, 48, 57, 65, 90, 32, 36, 95, 45, 46, 48, + 57, 65, 90, 32, 36, 95, 45, 46, 48, 57, 65, 90, + 32, 36, 95, 45, 46, 48, 57, 65, 90, 32, 36, 95, + 45, 46, 48, 57, 65, 90, 32, 36, 95, 45, 46, 48, + 57, 65, 90, 32, 36, 95, 45, 46, 48, 57, 65, 90, + 32, 36, 95, 45, 46, 48, 57, 65, 90, 32, 36, 95, + 45, 46, 48, 57, 65, 90, 32, 36, 95, 45, 46, 48, + 57, 65, 90, 32, 36, 95, 45, 46, 48, 57, 65, 90, + 32, 36, 95, 45, 46, 48, 57, 65, 90, 32, 36, 95, + 45, 46, 48, 57, 65, 90, 32, 36, 95, 45, 46, 48, + 57, 65, 90, 32, 36, 95, 45, 46, 48, 57, 65, 90, + 32, 0 + }; +} + +private static final char _puma_parser_trans_keys[] = init__puma_parser_trans_keys_0(); + + +private static byte[] init__puma_parser_single_lengths_0() +{ + return new byte [] { + 0, 2, 3, 4, 2, 1, 1, 1, 1, 1, 0, 1, + 0, 1, 1, 4, 1, 4, 3, 2, 4, 4, 2, 6, + 7, 6, 6, 3, 3, 3, 3, 3, 3, 3, 3, 3, + 3, 3, 3, 3, 3, 3, 3, 3, 3, 1, 0 + }; +} + +private static final byte _puma_parser_single_lengths[] = init__puma_parser_single_lengths_0(); + + +private static byte[] init__puma_parser_range_lengths_0() +{ + return new byte [] { + 0, 3, 3, 3, 0, 0, 0, 0, 0, 0, 1, 1, + 1, 1, 0, 6, 0, 6, 2, 2, 2, 2, 4, 1, + 1, 1, 1, 3, 3, 3, 3, 3, 3, 3, 3, 3, + 3, 3, 3, 3, 3, 3, 3, 3, 3, 0, 0 + }; +} + +private static final byte _puma_parser_range_lengths[] = init__puma_parser_range_lengths_0(); + + +private static short[] init__puma_parser_index_offsets_0() +{ + return new short [] { + 0, 0, 6, 13, 21, 24, 26, 28, 30, 32, 34, 36, + 39, 41, 44, 46, 57, 59, 70, 76, 81, 88, 95, 102, + 110, 119, 127, 135, 142, 149, 156, 163, 170, 177, 184, 191, + 198, 205, 212, 219, 226, 233, 240, 247, 254, 261, 263 + }; +} + +private static final short _puma_parser_index_offsets[] = init__puma_parser_index_offsets_0(); + + +private static byte[] init__puma_parser_indicies_0() +{ + return new byte [] { + 0, 0, 0, 0, 0, 1, 2, 3, 3, 3, 3, 3, + 1, 4, 5, 6, 7, 5, 5, 5, 1, 8, 9, 1, + 10, 1, 11, 1, 12, 1, 13, 1, 14, 1, 15, 1, + 16, 15, 1, 17, 1, 18, 17, 1, 19, 1, 20, 21, + 21, 21, 21, 21, 21, 21, 21, 21, 1, 22, 1, 23, + 24, 23, 23, 23, 23, 23, 23, 23, 23, 1, 26, 27, + 1, 1, 1, 25, 29, 1, 1, 1, 28, 30, 1, 1, + 1, 1, 1, 31, 32, 1, 1, 1, 1, 1, 33, 34, + 35, 34, 34, 34, 34, 1, 8, 1, 9, 1, 1, 1, + 1, 35, 36, 1, 38, 1, 1, 39, 1, 1, 37, 40, + 1, 42, 1, 1, 1, 1, 41, 43, 1, 45, 1, 1, + 1, 1, 44, 2, 46, 46, 46, 46, 46, 1, 2, 47, + 47, 47, 47, 47, 1, 2, 48, 48, 48, 48, 48, 1, + 2, 49, 49, 49, 49, 49, 1, 2, 50, 50, 50, 50, + 50, 1, 2, 51, 51, 51, 51, 51, 1, 2, 52, 52, + 52, 52, 52, 1, 2, 53, 53, 53, 53, 53, 1, 2, + 54, 54, 54, 54, 54, 1, 2, 55, 55, 55, 55, 55, + 1, 2, 56, 56, 56, 56, 56, 1, 2, 57, 57, 57, + 57, 57, 1, 2, 58, 58, 58, 58, 58, 1, 2, 59, + 59, 59, 59, 59, 1, 2, 60, 60, 60, 60, 60, 1, + 2, 61, 61, 61, 61, 61, 1, 2, 62, 62, 62, 62, + 62, 1, 2, 63, 63, 63, 63, 63, 1, 2, 1, 1, + 0 + }; +} + +private static final byte _puma_parser_indicies[] = init__puma_parser_indicies_0(); + + +private static byte[] init__puma_parser_trans_targs_0() +{ + return new byte [] { + 2, 0, 3, 27, 4, 22, 24, 23, 5, 20, 6, 7, + 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 46, 17, + 18, 19, 14, 18, 19, 14, 5, 21, 5, 21, 22, 23, + 5, 24, 20, 25, 5, 26, 20, 5, 26, 20, 28, 29, + 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40, 41, + 42, 43, 44, 45 + }; +} + +private static final byte _puma_parser_trans_targs[] = init__puma_parser_trans_targs_0(); + + +private static byte[] init__puma_parser_trans_actions_0() +{ + return new byte [] { + 1, 0, 11, 0, 1, 1, 1, 1, 13, 13, 1, 0, + 0, 0, 0, 0, 0, 0, 19, 0, 0, 28, 23, 3, + 5, 7, 31, 7, 0, 9, 25, 1, 15, 0, 0, 0, + 37, 0, 37, 21, 40, 17, 40, 34, 0, 34, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0 + }; +} + +private static final byte _puma_parser_trans_actions[] = init__puma_parser_trans_actions_0(); + + +static final int puma_parser_start = 1; +static final int puma_parser_first_final = 46; +static final int puma_parser_error = 0; + + +// line 62 "ext/puma_http11/http11_parser.java.rl" + + public static interface ElementCB { + public void call(Ruby runtime, RubyHash data, ByteList buffer, int at, int length); + } + + public static interface FieldCB { + public void call(Ruby runtime, RubyHash data, ByteList buffer, int field, int flen, int value, int vlen); + } + + public static class HttpParser { + int cs; + int body_start; + int content_len; + int nread; + int mark; + int field_start; + int field_len; + int query_start; + + RubyHash data; + ByteList buffer; + + public void init() { + cs = 0; + + +// line 216 "ext/puma_http11/org/jruby/puma/Http11Parser.java" + { + cs = puma_parser_start; + } + +// line 88 "ext/puma_http11/http11_parser.java.rl" + + body_start = 0; + content_len = 0; + mark = 0; + nread = 0; + field_len = 0; + field_start = 0; + } + } + + public final HttpParser parser = new HttpParser(); + + public int execute(Ruby runtime, Http11 http, ByteList buffer, int off) { + int p, pe; + int cs = parser.cs; + int len = buffer.length(); + assert off<=len : "offset past end of buffer"; + + p = off; + pe = len; + // get a copy of the bytes, since it may not start at 0 + // FIXME: figure out how to just use the bytes in-place + byte[] data = buffer.bytes(); + parser.buffer = buffer; + + +// line 248 "ext/puma_http11/org/jruby/puma/Http11Parser.java" + { + int _klen; + int _trans = 0; + int _acts; + int _nacts; + int _keys; + int _goto_targ = 0; + + _goto: while (true) { + switch ( _goto_targ ) { + case 0: + if ( p == pe ) { + _goto_targ = 4; + continue _goto; + } + if ( cs == 0 ) { + _goto_targ = 5; + continue _goto; + } +case 1: + _match: do { + _keys = _puma_parser_key_offsets[cs]; + _trans = _puma_parser_index_offsets[cs]; + _klen = _puma_parser_single_lengths[cs]; + if ( _klen > 0 ) { + int _lower = _keys; + int _mid; + int _upper = _keys + _klen - 1; + while (true) { + if ( _upper < _lower ) + break; + + _mid = _lower + ((_upper-_lower) >> 1); + if ( data[p] < _puma_parser_trans_keys[_mid] ) + _upper = _mid - 1; + else if ( data[p] > _puma_parser_trans_keys[_mid] ) + _lower = _mid + 1; + else { + _trans += (_mid - _keys); + break _match; + } + } + _keys += _klen; + _trans += _klen; + } + + _klen = _puma_parser_range_lengths[cs]; + if ( _klen > 0 ) { + int _lower = _keys; + int _mid; + int _upper = _keys + (_klen<<1) - 2; + while (true) { + if ( _upper < _lower ) + break; + + _mid = _lower + (((_upper-_lower) >> 1) & ~1); + if ( data[p] < _puma_parser_trans_keys[_mid] ) + _upper = _mid - 2; + else if ( data[p] > _puma_parser_trans_keys[_mid+1] ) + _lower = _mid + 2; + else { + _trans += ((_mid - _keys)>>1); + break _match; + } + } + _trans += _klen; + } + } while (false); + + _trans = _puma_parser_indicies[_trans]; + cs = _puma_parser_trans_targs[_trans]; + + if ( _puma_parser_trans_actions[_trans] != 0 ) { + _acts = _puma_parser_trans_actions[_trans]; + _nacts = (int) _puma_parser_actions[_acts++]; + while ( _nacts-- > 0 ) + { + switch ( _puma_parser_actions[_acts++] ) + { + case 0: +// line 15 "ext/puma_http11/http11_parser.java.rl" + {parser.mark = p; } + break; + case 1: +// line 17 "ext/puma_http11/http11_parser.java.rl" + { parser.field_start = p; } + break; + case 2: +// line 18 "ext/puma_http11/http11_parser.java.rl" + { /* FIXME stub */ } + break; + case 3: +// line 19 "ext/puma_http11/http11_parser.java.rl" + { + parser.field_len = p-parser.field_start; + } + break; + case 4: +// line 23 "ext/puma_http11/http11_parser.java.rl" + { parser.mark = p; } + break; + case 5: +// line 24 "ext/puma_http11/http11_parser.java.rl" + { + Http11.http_field(runtime, parser.data, parser.buffer, parser.field_start, parser.field_len, parser.mark, p-parser.mark); + } + break; + case 6: +// line 27 "ext/puma_http11/http11_parser.java.rl" + { + Http11.request_method(runtime, parser.data, parser.buffer, parser.mark, p-parser.mark); + } + break; + case 7: +// line 30 "ext/puma_http11/http11_parser.java.rl" + { + Http11.request_uri(runtime, parser.data, parser.buffer, parser.mark, p-parser.mark); + } + break; + case 8: +// line 33 "ext/puma_http11/http11_parser.java.rl" + { + Http11.fragment(runtime, parser.data, parser.buffer, parser.mark, p-parser.mark); + } + break; + case 9: +// line 37 "ext/puma_http11/http11_parser.java.rl" + {parser.query_start = p; } + break; + case 10: +// line 38 "ext/puma_http11/http11_parser.java.rl" + { + Http11.query_string(runtime, parser.data, parser.buffer, parser.query_start, p-parser.query_start); + } + break; + case 11: +// line 42 "ext/puma_http11/http11_parser.java.rl" + { + Http11.http_version(runtime, parser.data, parser.buffer, parser.mark, p-parser.mark); + } + break; + case 12: +// line 46 "ext/puma_http11/http11_parser.java.rl" + { + Http11.request_path(runtime, parser.data, parser.buffer, parser.mark, p-parser.mark); + } + break; + case 13: +// line 50 "ext/puma_http11/http11_parser.java.rl" + { + parser.body_start = p + 1; + http.header_done(runtime, parser.data, parser.buffer, p + 1, pe - p - 1); + { p += 1; _goto_targ = 5; if (true) continue _goto;} + } + break; +// line 404 "ext/puma_http11/org/jruby/puma/Http11Parser.java" + } + } + } + +case 2: + if ( cs == 0 ) { + _goto_targ = 5; + continue _goto; + } + if ( ++p != pe ) { + _goto_targ = 1; + continue _goto; + } +case 4: +case 5: + } + break; } + } + +// line 114 "ext/puma_http11/http11_parser.java.rl" + + parser.cs = cs; + parser.nread += (p - off); + + assert p <= pe : "buffer overflow after parsing execute"; + assert parser.nread <= len : "nread longer than length"; + assert parser.body_start <= len : "body starts after buffer end"; + assert parser.mark < len : "mark is after buffer end"; + assert parser.field_len <= len : "field has length longer than whole buffer"; + assert parser.field_start < len : "field starts after buffer end"; + + return parser.nread; + } + + public int finish() { + if(has_error()) { + return -1; + } else if(is_finished()) { + return 1; + } else { + return 0; + } + } + + public boolean has_error() { + return parser.cs == puma_parser_error; + } + + public boolean is_finished() { + return parser.cs == puma_parser_first_final; + } +} diff --git a/ext/puma_http11/org/jruby/puma/MiniSSL.java b/ext/puma_http11/org/jruby/puma/MiniSSL.java new file mode 100644 index 0000000..6371e99 --- /dev/null +++ b/ext/puma_http11/org/jruby/puma/MiniSSL.java @@ -0,0 +1,407 @@ +package org.jruby.puma; + +import org.jruby.Ruby; +import org.jruby.RubyClass; +import org.jruby.RubyModule; +import org.jruby.RubyObject; +import org.jruby.RubyString; +import org.jruby.anno.JRubyMethod; +import org.jruby.exceptions.RaiseException; +import org.jruby.javasupport.JavaEmbedUtils; +import org.jruby.runtime.Block; +import org.jruby.runtime.ObjectAllocator; +import org.jruby.runtime.ThreadContext; +import org.jruby.runtime.builtin.IRubyObject; +import org.jruby.util.ByteList; + +import javax.net.ssl.KeyManagerFactory; +import javax.net.ssl.TrustManagerFactory; +import javax.net.ssl.SSLContext; +import javax.net.ssl.SSLEngine; +import javax.net.ssl.SSLEngineResult; +import javax.net.ssl.SSLException; +import javax.net.ssl.SSLPeerUnverifiedException; +import javax.net.ssl.SSLSession; +import java.io.FileInputStream; +import java.io.InputStream; +import java.io.IOException; +import java.nio.Buffer; +import java.nio.ByteBuffer; +import java.security.KeyManagementException; +import java.security.KeyStore; +import java.security.KeyStoreException; +import java.security.NoSuchAlgorithmException; +import java.security.UnrecoverableKeyException; +import java.security.cert.CertificateEncodingException; +import java.security.cert.CertificateException; +import java.util.concurrent.ConcurrentHashMap; +import java.util.Map; + +import static javax.net.ssl.SSLEngineResult.Status; +import static javax.net.ssl.SSLEngineResult.HandshakeStatus; + +public class MiniSSL extends RubyObject { + private static ObjectAllocator ALLOCATOR = new ObjectAllocator() { + public IRubyObject allocate(Ruby runtime, RubyClass klass) { + return new MiniSSL(runtime, klass); + } + }; + + public static void createMiniSSL(Ruby runtime) { + RubyModule mPuma = runtime.defineModule("Puma"); + RubyModule ssl = mPuma.defineModuleUnder("MiniSSL"); + + mPuma.defineClassUnder("SSLError", + runtime.getClass("IOError"), + runtime.getClass("IOError").getAllocator()); + + RubyClass eng = ssl.defineClassUnder("Engine",runtime.getObject(),ALLOCATOR); + eng.defineAnnotatedMethods(MiniSSL.class); + } + + /** + * Fairly transparent wrapper around {@link java.nio.ByteBuffer} which adds the enhancements we need + */ + private static class MiniSSLBuffer { + ByteBuffer buffer; + + private MiniSSLBuffer(int capacity) { buffer = ByteBuffer.allocate(capacity); } + private MiniSSLBuffer(byte[] initialContents) { buffer = ByteBuffer.wrap(initialContents); } + + public void clear() { buffer.clear(); } + public void compact() { buffer.compact(); } + public void flip() { ((Buffer) buffer).flip(); } + public boolean hasRemaining() { return buffer.hasRemaining(); } + public int position() { return buffer.position(); } + + public ByteBuffer getRawBuffer() { + return buffer; + } + + /** + * Writes bytes to the buffer after ensuring there's room + */ + private void put(byte[] bytes, final int offset, final int length) { + if (buffer.remaining() < length) { + resize(buffer.limit() + length); + } + buffer.put(bytes, offset, length); + } + + /** + * Ensures that newCapacity bytes can be written to this buffer, only re-allocating if necessary + */ + public void resize(int newCapacity) { + if (newCapacity > buffer.capacity()) { + ByteBuffer dstTmp = ByteBuffer.allocate(newCapacity); + flip(); + dstTmp.put(buffer); + buffer = dstTmp; + } else { + buffer.limit(newCapacity); + } + } + + /** + * Drains the buffer to a ByteList, or returns null for an empty buffer + */ + public ByteList asByteList() { + flip(); + if (!buffer.hasRemaining()) { + buffer.clear(); + return null; + } + + byte[] bss = new byte[buffer.limit()]; + + buffer.get(bss); + buffer.clear(); + return new ByteList(bss, false); + } + + @Override + public String toString() { return buffer.toString(); } + } + + private SSLEngine engine; + private boolean closed; + private boolean handshake; + private MiniSSLBuffer inboundNetData; + private MiniSSLBuffer outboundAppData; + private MiniSSLBuffer outboundNetData; + + public MiniSSL(Ruby runtime, RubyClass klass) { + super(runtime, klass); + } + + private static Map keyManagerFactoryMap = new ConcurrentHashMap(); + private static Map trustManagerFactoryMap = new ConcurrentHashMap(); + + @JRubyMethod(meta = true) + public static synchronized IRubyObject server(ThreadContext context, IRubyObject recv, IRubyObject miniSSLContext) + throws KeyStoreException, IOException, CertificateException, NoSuchAlgorithmException, UnrecoverableKeyException { + // Create the KeyManagerFactory and TrustManagerFactory for this server + String keystoreFile = miniSSLContext.callMethod(context, "keystore").convertToString().asJavaString(); + char[] password = miniSSLContext.callMethod(context, "keystore_pass").convertToString().asJavaString().toCharArray(); + + KeyStore ks = KeyStore.getInstance(KeyStore.getDefaultType()); + InputStream is = new FileInputStream(keystoreFile); + try { + ks.load(is, password); + } finally { + is.close(); + } + KeyManagerFactory kmf = KeyManagerFactory.getInstance("SunX509"); + kmf.init(ks, password); + keyManagerFactoryMap.put(keystoreFile, kmf); + + KeyStore ts = KeyStore.getInstance(KeyStore.getDefaultType()); + is = new FileInputStream(keystoreFile); + try { + ts.load(is, password); + } finally { + is.close(); + } + TrustManagerFactory tmf = TrustManagerFactory.getInstance("SunX509"); + tmf.init(ts); + trustManagerFactoryMap.put(keystoreFile, tmf); + + RubyClass klass = (RubyClass) recv; + return klass.newInstance(context, + new IRubyObject[] { miniSSLContext }, + Block.NULL_BLOCK); + } + + @JRubyMethod + public IRubyObject initialize(ThreadContext threadContext, IRubyObject miniSSLContext) + throws KeyStoreException, NoSuchAlgorithmException, KeyManagementException { + + String keystoreFile = miniSSLContext.callMethod(threadContext, "keystore").convertToString().asJavaString(); + KeyManagerFactory kmf = keyManagerFactoryMap.get(keystoreFile); + TrustManagerFactory tmf = trustManagerFactoryMap.get(keystoreFile); + if(kmf == null || tmf == null) { + throw new KeyStoreException("Could not find KeyManagerFactory/TrustManagerFactory for keystore: " + keystoreFile); + } + + SSLContext sslCtx = SSLContext.getInstance("TLS"); + + sslCtx.init(kmf.getKeyManagers(), tmf.getTrustManagers(), null); + closed = false; + handshake = false; + engine = sslCtx.createSSLEngine(); + + String[] protocols; + if(miniSSLContext.callMethod(threadContext, "no_tlsv1").isTrue()) { + protocols = new String[] { "TLSv1.1", "TLSv1.2" }; + } else { + protocols = new String[] { "TLSv1", "TLSv1.1", "TLSv1.2" }; + } + + if(miniSSLContext.callMethod(threadContext, "no_tlsv1_1").isTrue()) { + protocols = new String[] { "TLSv1.2" }; + } + + engine.setEnabledProtocols(protocols); + engine.setUseClientMode(false); + + long verify_mode = miniSSLContext.callMethod(threadContext, "verify_mode").convertToInteger("to_i").getLongValue(); + if ((verify_mode & 0x1) != 0) { // 'peer' + engine.setWantClientAuth(true); + } + if ((verify_mode & 0x2) != 0) { // 'force_peer' + engine.setNeedClientAuth(true); + } + + IRubyObject sslCipherListObject = miniSSLContext.callMethod(threadContext, "ssl_cipher_list"); + if (!sslCipherListObject.isNil()) { + String[] sslCipherList = sslCipherListObject.convertToString().asJavaString().split(","); + engine.setEnabledCipherSuites(sslCipherList); + } + + SSLSession session = engine.getSession(); + inboundNetData = new MiniSSLBuffer(session.getPacketBufferSize()); + outboundAppData = new MiniSSLBuffer(session.getApplicationBufferSize()); + outboundAppData.flip(); + outboundNetData = new MiniSSLBuffer(session.getPacketBufferSize()); + + return this; + } + + @JRubyMethod + public IRubyObject inject(IRubyObject arg) { + ByteList bytes = arg.convertToString().getByteList(); + inboundNetData.put(bytes.unsafeBytes(), bytes.getBegin(), bytes.getRealSize()); + return this; + } + + private enum SSLOperation { + WRAP, + UNWRAP + } + + private SSLEngineResult doOp(SSLOperation sslOp, MiniSSLBuffer src, MiniSSLBuffer dst) throws SSLException { + SSLEngineResult res = null; + boolean retryOp = true; + while (retryOp) { + switch (sslOp) { + case WRAP: + res = engine.wrap(src.getRawBuffer(), dst.getRawBuffer()); + break; + case UNWRAP: + res = engine.unwrap(src.getRawBuffer(), dst.getRawBuffer()); + break; + default: + throw new IllegalStateException("Unknown SSLOperation: " + sslOp); + } + + switch (res.getStatus()) { + case BUFFER_OVERFLOW: + // increase the buffer size to accommodate the overflowing data + int newSize = Math.max(engine.getSession().getPacketBufferSize(), engine.getSession().getApplicationBufferSize()); + dst.resize(newSize + dst.position()); + // retry the operation + retryOp = true; + break; + case BUFFER_UNDERFLOW: + // need to wait for more data to come in before we retry + retryOp = false; + break; + case CLOSED: + closed = true; + retryOp = false; + break; + default: + // other case is OK. We're done here. + retryOp = false; + } + if (res.getHandshakeStatus() == HandshakeStatus.FINISHED) { + handshake = true; + } + } + + // after each op, run any delegated tasks if needed + if(res.getHandshakeStatus() == HandshakeStatus.NEED_TASK) { + Runnable runnable; + while ((runnable = engine.getDelegatedTask()) != null) { + runnable.run(); + } + } + + return res; + } + + @JRubyMethod + public IRubyObject read() { + try { + inboundNetData.flip(); + + if(!inboundNetData.hasRemaining()) { + return getRuntime().getNil(); + } + + MiniSSLBuffer inboundAppData = new MiniSSLBuffer(engine.getSession().getApplicationBufferSize()); + doOp(SSLOperation.UNWRAP, inboundNetData, inboundAppData); + + HandshakeStatus handshakeStatus = engine.getHandshakeStatus(); + boolean done = false; + SSLEngineResult res = null; + while (!done) { + switch (handshakeStatus) { + case NEED_WRAP: + res = doOp(SSLOperation.WRAP, inboundAppData, outboundNetData); + break; + case NEED_UNWRAP: + res = doOp(SSLOperation.UNWRAP, inboundNetData, inboundAppData); + if (res.getStatus() == Status.BUFFER_UNDERFLOW) { + // need more data before we can shake more hands + done = true; + } + break; + default: + done = true; + } + if (!done) { + handshakeStatus = res.getHandshakeStatus(); + } + } + + if (inboundNetData.hasRemaining()) { + inboundNetData.compact(); + } else { + inboundNetData.clear(); + } + + ByteList appDataByteList = inboundAppData.asByteList(); + if (appDataByteList == null) { + return getRuntime().getNil(); + } + + return RubyString.newString(getRuntime(), appDataByteList); + } catch (SSLException e) { + RaiseException re = getRuntime().newEOFError(e.getMessage()); + re.initCause(e); + throw re; + } + } + + @JRubyMethod + public IRubyObject write(IRubyObject arg) { + byte[] bls = arg.convertToString().getBytes(); + outboundAppData = new MiniSSLBuffer(bls); + + return getRuntime().newFixnum(bls.length); + } + + @JRubyMethod + public IRubyObject extract(ThreadContext context) { + try { + ByteList dataByteList = outboundNetData.asByteList(); + if (dataByteList != null) { + return RubyString.newString(context.runtime, dataByteList); + } + + if (!outboundAppData.hasRemaining()) { + return context.nil; + } + + outboundNetData.clear(); + doOp(SSLOperation.WRAP, outboundAppData, outboundNetData); + dataByteList = outboundNetData.asByteList(); + if (dataByteList == null) { + return context.nil; + } + + return RubyString.newString(context.runtime, dataByteList); + } catch (SSLException e) { + RaiseException ex = context.runtime.newRuntimeError(e.toString()); + ex.initCause(e); + throw ex; + } + } + + @JRubyMethod + public IRubyObject peercert() throws CertificateEncodingException { + try { + return JavaEmbedUtils.javaToRuby(getRuntime(), engine.getSession().getPeerCertificates()[0].getEncoded()); + } catch (SSLPeerUnverifiedException e) { + return getRuntime().getNil(); + } + } + + @JRubyMethod(name = "init?") + public IRubyObject isInit(ThreadContext context) { + return handshake ? getRuntime().getFalse() : getRuntime().getTrue(); + } + + @JRubyMethod + public IRubyObject shutdown() { + if (closed || engine.isInboundDone() && engine.isOutboundDone()) { + if (engine.isOutboundDone()) { + engine.closeOutbound(); + } + return getRuntime().getTrue(); + } else { + return getRuntime().getFalse(); + } + } +} diff --git a/ext/puma_http11/puma_http11.c b/ext/puma_http11/puma_http11.c new file mode 100644 index 0000000..19242dd --- /dev/null +++ b/ext/puma_http11/puma_http11.c @@ -0,0 +1,484 @@ +/** + * Copyright (c) 2005 Zed A. Shaw + * You can redistribute it and/or modify it under the same terms as Ruby. + * License 3-clause BSD + */ + +#define RSTRING_NOT_MODIFIED 1 + +#include "ruby.h" +#include "ext_help.h" +#include +#include +#include +#include "http11_parser.h" + +#ifndef MANAGED_STRINGS + +#ifndef RSTRING_PTR +#define RSTRING_PTR(s) (RSTRING(s)->ptr) +#endif +#ifndef RSTRING_LEN +#define RSTRING_LEN(s) (RSTRING(s)->len) +#endif + +#define rb_extract_chars(e, sz) (*sz = RSTRING_LEN(e), RSTRING_PTR(e)) +#define rb_free_chars(e) /* nothing */ + +#endif + +static VALUE eHttpParserError; + +#define HTTP_PREFIX "HTTP_" +#define HTTP_PREFIX_LEN (sizeof(HTTP_PREFIX) - 1) + +static VALUE global_request_method; +static VALUE global_request_uri; +static VALUE global_fragment; +static VALUE global_query_string; +static VALUE global_http_version; +static VALUE global_request_path; + +/** Defines common length and error messages for input length validation. */ +#define QUOTE(s) #s +#define EXPLAIN_MAX_LENGTH_VALUE(s) QUOTE(s) +#define DEF_MAX_LENGTH(N,length) const size_t MAX_##N##_LENGTH = length; const char *MAX_##N##_LENGTH_ERR = "HTTP element " # N " is longer than the " EXPLAIN_MAX_LENGTH_VALUE(length) " allowed length (was %d)" + +/** Validates the max length of given input and throws an HttpParserError exception if over. */ +#define VALIDATE_MAX_LENGTH(len, N) if(len > MAX_##N##_LENGTH) { rb_raise(eHttpParserError, MAX_##N##_LENGTH_ERR, len); } + +/** Defines global strings in the init method. */ +#define DEF_GLOBAL(N, val) global_##N = rb_str_new2(val); rb_global_variable(&global_##N) + + +/* Defines the maximum allowed lengths for various input elements.*/ +#ifndef PUMA_QUERY_STRING_MAX_LENGTH +#define PUMA_QUERY_STRING_MAX_LENGTH (1024 * 10) +#endif + +DEF_MAX_LENGTH(FIELD_NAME, 256); +DEF_MAX_LENGTH(FIELD_VALUE, 80 * 1024); +DEF_MAX_LENGTH(REQUEST_URI, 1024 * 12); +DEF_MAX_LENGTH(FRAGMENT, 1024); /* Don't know if this length is specified somewhere or not */ +DEF_MAX_LENGTH(REQUEST_PATH, 8192); +DEF_MAX_LENGTH(QUERY_STRING, PUMA_QUERY_STRING_MAX_LENGTH); +DEF_MAX_LENGTH(HEADER, (1024 * (80 + 32))); + +struct common_field { + const size_t len; + const char *name; + int raw; + VALUE value; +}; + +/* + * A list of common HTTP headers we expect to receive. + * This allows us to avoid repeatedly creating identical string + * objects to be used with rb_hash_aset(). + */ +static struct common_field common_http_fields[] = { +# define f(N) { (sizeof(N) - 1), N, 0, Qnil } +# define fr(N) { (sizeof(N) - 1), N, 1, Qnil } + f("ACCEPT"), + f("ACCEPT_CHARSET"), + f("ACCEPT_ENCODING"), + f("ACCEPT_LANGUAGE"), + f("ALLOW"), + f("AUTHORIZATION"), + f("CACHE_CONTROL"), + f("CONNECTION"), + f("CONTENT_ENCODING"), + fr("CONTENT_LENGTH"), + fr("CONTENT_TYPE"), + f("COOKIE"), + f("DATE"), + f("EXPECT"), + f("FROM"), + f("HOST"), + f("IF_MATCH"), + f("IF_MODIFIED_SINCE"), + f("IF_NONE_MATCH"), + f("IF_RANGE"), + f("IF_UNMODIFIED_SINCE"), + f("KEEP_ALIVE"), /* Firefox sends this */ + f("MAX_FORWARDS"), + f("PRAGMA"), + f("PROXY_AUTHORIZATION"), + f("RANGE"), + f("REFERER"), + f("TE"), + f("TRAILER"), + f("TRANSFER_ENCODING"), + f("UPGRADE"), + f("USER_AGENT"), + f("VIA"), + f("X_FORWARDED_FOR"), /* common for proxies */ + f("X_REAL_IP"), /* common for proxies */ + f("WARNING") +# undef f +}; + +static void init_common_fields(void) +{ + unsigned i; + struct common_field *cf = common_http_fields; + char tmp[256]; /* MAX_FIELD_NAME_LENGTH */ + memcpy(tmp, HTTP_PREFIX, HTTP_PREFIX_LEN); + + for(i = 0; i < ARRAY_SIZE(common_http_fields); cf++, i++) { + if(cf->raw) { + cf->value = rb_str_new(cf->name, cf->len); + } else { + memcpy(tmp + HTTP_PREFIX_LEN, cf->name, cf->len + 1); + cf->value = rb_str_new(tmp, HTTP_PREFIX_LEN + cf->len); + } + rb_global_variable(&cf->value); + } +} + +static VALUE find_common_field_value(const char *field, size_t flen) +{ + unsigned i; + struct common_field *cf = common_http_fields; + for(i = 0; i < ARRAY_SIZE(common_http_fields); i++, cf++) { + if (cf->len == flen && !memcmp(cf->name, field, flen)) + return cf->value; + } + return Qnil; +} + +void http_field(puma_parser* hp, const char *field, size_t flen, + const char *value, size_t vlen) +{ + VALUE f = Qnil; + VALUE v; + + VALIDATE_MAX_LENGTH(flen, FIELD_NAME); + VALIDATE_MAX_LENGTH(vlen, FIELD_VALUE); + + f = find_common_field_value(field, flen); + + if (f == Qnil) { + /* + * We got a strange header that we don't have a memoized value for. + * Fallback to creating a new string to use as a hash key. + */ + + size_t new_size = HTTP_PREFIX_LEN + flen; + assert(new_size < BUFFER_LEN); + + memcpy(hp->buf, HTTP_PREFIX, HTTP_PREFIX_LEN); + memcpy(hp->buf + HTTP_PREFIX_LEN, field, flen); + + f = rb_str_new(hp->buf, new_size); + } + + while (vlen > 0 && isspace(value[vlen - 1])) vlen--; + + /* check for duplicate header */ + v = rb_hash_aref(hp->request, f); + + if (v == Qnil) { + v = rb_str_new(value, vlen); + rb_hash_aset(hp->request, f, v); + } else { + /* if duplicate header, normalize to comma-separated values */ + rb_str_cat2(v, ", "); + rb_str_cat(v, value, vlen); + } +} + +void request_method(puma_parser* hp, const char *at, size_t length) +{ + VALUE val = Qnil; + + val = rb_str_new(at, length); + rb_hash_aset(hp->request, global_request_method, val); +} + +void request_uri(puma_parser* hp, const char *at, size_t length) +{ + VALUE val = Qnil; + + VALIDATE_MAX_LENGTH(length, REQUEST_URI); + + val = rb_str_new(at, length); + rb_hash_aset(hp->request, global_request_uri, val); +} + +void fragment(puma_parser* hp, const char *at, size_t length) +{ + VALUE val = Qnil; + + VALIDATE_MAX_LENGTH(length, FRAGMENT); + + val = rb_str_new(at, length); + rb_hash_aset(hp->request, global_fragment, val); +} + +void request_path(puma_parser* hp, const char *at, size_t length) +{ + VALUE val = Qnil; + + VALIDATE_MAX_LENGTH(length, REQUEST_PATH); + + val = rb_str_new(at, length); + rb_hash_aset(hp->request, global_request_path, val); +} + +void query_string(puma_parser* hp, const char *at, size_t length) +{ + VALUE val = Qnil; + + VALIDATE_MAX_LENGTH(length, QUERY_STRING); + + val = rb_str_new(at, length); + rb_hash_aset(hp->request, global_query_string, val); +} + +void http_version(puma_parser* hp, const char *at, size_t length) +{ + VALUE val = rb_str_new(at, length); + rb_hash_aset(hp->request, global_http_version, val); +} + +/** Finalizes the request header to have a bunch of stuff that's + needed. */ + +void header_done(puma_parser* hp, const char *at, size_t length) +{ + hp->body = rb_str_new(at, length); +} + + +void HttpParser_free(void *data) { + TRACE(); + + if(data) { + xfree(data); + } +} + +void HttpParser_mark(void *ptr) { + puma_parser *hp = ptr; + if(hp->request) rb_gc_mark(hp->request); + if(hp->body) rb_gc_mark(hp->body); +} + +const rb_data_type_t HttpParser_data_type = { + "HttpParser", + { HttpParser_mark, HttpParser_free, 0 }, + 0, 0, RUBY_TYPED_FREE_IMMEDIATELY, +}; + +VALUE HttpParser_alloc(VALUE klass) +{ + puma_parser *hp = ALLOC_N(puma_parser, 1); + TRACE(); + hp->http_field = http_field; + hp->request_method = request_method; + hp->request_uri = request_uri; + hp->fragment = fragment; + hp->request_path = request_path; + hp->query_string = query_string; + hp->http_version = http_version; + hp->header_done = header_done; + hp->request = Qnil; + + puma_parser_init(hp); + + return TypedData_Wrap_Struct(klass, &HttpParser_data_type, hp); +} + +/** + * call-seq: + * parser.new -> parser + * + * Creates a new parser. + */ +VALUE HttpParser_init(VALUE self) +{ + puma_parser *http = NULL; + DATA_GET(self, puma_parser, &HttpParser_data_type, http); + puma_parser_init(http); + + return self; +} + + +/** + * call-seq: + * parser.reset -> nil + * + * Resets the parser to it's initial state so that you can reuse it + * rather than making new ones. + */ +VALUE HttpParser_reset(VALUE self) +{ + puma_parser *http = NULL; + DATA_GET(self, puma_parser, &HttpParser_data_type, http); + puma_parser_init(http); + + return Qnil; +} + + +/** + * call-seq: + * parser.finish -> true/false + * + * Finishes a parser early which could put in a "good" or bad state. + * You should call reset after finish it or bad things will happen. + */ +VALUE HttpParser_finish(VALUE self) +{ + puma_parser *http = NULL; + DATA_GET(self, puma_parser, &HttpParser_data_type, http); + puma_parser_finish(http); + + return puma_parser_is_finished(http) ? Qtrue : Qfalse; +} + + +/** + * call-seq: + * parser.execute(req_hash, data, start) -> Integer + * + * Takes a Hash and a String of data, parses the String of data filling in the Hash + * returning an Integer to indicate how much of the data has been read. No matter + * what the return value, you should call HttpParser#finished? and HttpParser#error? + * to figure out if it's done parsing or there was an error. + * + * This function now throws an exception when there is a parsing error. This makes + * the logic for working with the parser much easier. You can still test for an + * error, but now you need to wrap the parser with an exception handling block. + * + * The third argument allows for parsing a partial request and then continuing + * the parsing from that position. It needs all of the original data as well + * so you have to append to the data buffer as you read. + */ +VALUE HttpParser_execute(VALUE self, VALUE req_hash, VALUE data, VALUE start) +{ + puma_parser *http = NULL; + int from = 0; + char *dptr = NULL; + long dlen = 0; + + DATA_GET(self, puma_parser, &HttpParser_data_type, http); + + from = FIX2INT(start); + dptr = rb_extract_chars(data, &dlen); + + if(from >= dlen) { + rb_free_chars(dptr); + rb_raise(eHttpParserError, "%s", "Requested start is after data buffer end."); + } else { + http->request = req_hash; + puma_parser_execute(http, dptr, dlen, from); + + rb_free_chars(dptr); + VALIDATE_MAX_LENGTH(puma_parser_nread(http), HEADER); + + if(puma_parser_has_error(http)) { + rb_raise(eHttpParserError, "%s", "Invalid HTTP format, parsing fails. Are you trying to open an SSL connection to a non-SSL Puma?"); + } else { + return INT2FIX(puma_parser_nread(http)); + } + } +} + + + +/** + * call-seq: + * parser.error? -> true/false + * + * Tells you whether the parser is in an error state. + */ +VALUE HttpParser_has_error(VALUE self) +{ + puma_parser *http = NULL; + DATA_GET(self, puma_parser, &HttpParser_data_type, http); + + return puma_parser_has_error(http) ? Qtrue : Qfalse; +} + + +/** + * call-seq: + * parser.finished? -> true/false + * + * Tells you whether the parser is finished or not and in a good state. + */ +VALUE HttpParser_is_finished(VALUE self) +{ + puma_parser *http = NULL; + DATA_GET(self, puma_parser, &HttpParser_data_type, http); + + return puma_parser_is_finished(http) ? Qtrue : Qfalse; +} + + +/** + * call-seq: + * parser.nread -> Integer + * + * Returns the amount of data processed so far during this processing cycle. It is + * set to 0 on initialize or reset calls and is incremented each time execute is called. + */ +VALUE HttpParser_nread(VALUE self) +{ + puma_parser *http = NULL; + DATA_GET(self, puma_parser, &HttpParser_data_type, http); + + return INT2FIX(http->nread); +} + +/** + * call-seq: + * parser.body -> nil or String + * + * If the request included a body, returns it. + */ +VALUE HttpParser_body(VALUE self) { + puma_parser *http = NULL; + DATA_GET(self, puma_parser, &HttpParser_data_type, http); + + return http->body; +} + +#ifdef HAVE_OPENSSL_BIO_H +void Init_mini_ssl(VALUE mod); +#endif + +void Init_puma_http11(void) +{ + + VALUE mPuma = rb_define_module("Puma"); + VALUE cHttpParser = rb_define_class_under(mPuma, "HttpParser", rb_cObject); + + DEF_GLOBAL(request_method, "REQUEST_METHOD"); + DEF_GLOBAL(request_uri, "REQUEST_URI"); + DEF_GLOBAL(fragment, "FRAGMENT"); + DEF_GLOBAL(query_string, "QUERY_STRING"); + DEF_GLOBAL(http_version, "HTTP_VERSION"); + DEF_GLOBAL(request_path, "REQUEST_PATH"); + + eHttpParserError = rb_define_class_under(mPuma, "HttpParserError", rb_eIOError); + rb_global_variable(&eHttpParserError); + + rb_define_alloc_func(cHttpParser, HttpParser_alloc); + rb_define_method(cHttpParser, "initialize", HttpParser_init, 0); + rb_define_method(cHttpParser, "reset", HttpParser_reset, 0); + rb_define_method(cHttpParser, "finish", HttpParser_finish, 0); + rb_define_method(cHttpParser, "execute", HttpParser_execute, 3); + rb_define_method(cHttpParser, "error?", HttpParser_has_error, 0); + rb_define_method(cHttpParser, "finished?", HttpParser_is_finished, 0); + rb_define_method(cHttpParser, "nread", HttpParser_nread, 0); + rb_define_method(cHttpParser, "body", HttpParser_body, 0); + init_common_fields(); + +#ifdef HAVE_OPENSSL_BIO_H + Init_mini_ssl(mPuma); +#endif +} diff --git a/lib/puma.rb b/lib/puma.rb new file mode 100644 index 0000000..a77098a --- /dev/null +++ b/lib/puma.rb @@ -0,0 +1,77 @@ +# frozen_string_literal: true + +# Standard libraries +require 'socket' +require 'tempfile' +require 'time' +require 'etc' +require 'uri' +require 'stringio' + +require 'thread' + +require 'puma/puma_http11' +require 'puma/detect' +require 'puma/json_serialization' + +module Puma + autoload :Const, 'puma/const' + autoload :Server, 'puma/server' + autoload :Launcher, 'puma/launcher' + + # at present, MiniSSL::Engine is only defined in extension code (puma_http11), + # not in minissl.rb + HAS_SSL = const_defined?(:MiniSSL, false) && MiniSSL.const_defined?(:Engine, false) + + HAS_UNIX_SOCKET = Object.const_defined? :UNIXSocket + + if HAS_SSL + require 'puma/minissl' + else + module MiniSSL + # this class is defined so that it exists when Puma is compiled + # without ssl support, as Server and Reactor use it in rescue statements. + class SSLError < StandardError ; end + end + end + + def self.ssl? + HAS_SSL + end + + def self.abstract_unix_socket? + @abstract_unix ||= + if HAS_UNIX_SOCKET + begin + ::UNIXServer.new("\0puma.temp.unix").close + true + rescue ArgumentError # darwin + false + end + else + false + end + end + + # @!attribute [rw] stats_object= + def self.stats_object=(val) + @get_stats = val + end + + # @!attribute [rw] stats_object + def self.stats + Puma::JSONSerialization.generate @get_stats.stats + end + + # @!attribute [r] stats_hash + # @version 5.0.0 + def self.stats_hash + @get_stats.stats + end + + # Thread name is new in Ruby 2.3 + def self.set_thread_name(name) + return unless Thread.current.respond_to?(:name=) + Thread.current.name = "puma #{name}" + end +end diff --git a/lib/puma/app/status.rb b/lib/puma/app/status.rb new file mode 100644 index 0000000..5cfb2b0 --- /dev/null +++ b/lib/puma/app/status.rb @@ -0,0 +1,93 @@ +# frozen_string_literal: true +require 'puma/json_serialization' + +module Puma + module App + # Check out {#call}'s source code to see what actions this web application + # can respond to. + class Status + OK_STATUS = '{ "status": "ok" }'.freeze + + # @param launcher [::Puma::Launcher] + # @param token [String, nil] the token used for authentication + # + def initialize(launcher, token = nil) + @launcher = launcher + @auth_token = token + end + + # most commands call methods in `::Puma::Launcher` based on command in + # `env['PATH_INFO']` + def call(env) + unless authenticate(env) + return rack_response(403, 'Invalid auth token', 'text/plain') + end + + # resp_type is processed by following case statement, return + # is a number (status) or a string used as the body of a 200 response + resp_type = + case env['PATH_INFO'][/\/([^\/]+)$/, 1] + when 'stop' + @launcher.stop ; 200 + + when 'halt' + @launcher.halt ; 200 + + when 'restart' + @launcher.restart ; 200 + + when 'phased-restart' + @launcher.phased_restart ? 200 : 404 + + when 'reload-worker-directory' + @launcher.send(:reload_worker_directory) ? 200 : 404 + + when 'gc' + GC.start ; 200 + + when 'gc-stats' + Puma::JSONSerialization.generate GC.stat + + when 'stats' + Puma::JSONSerialization.generate @launcher.stats + + when 'thread-backtraces' + backtraces = [] + @launcher.thread_status do |name, backtrace| + backtraces << { name: name, backtrace: backtrace } + end + Puma::JSONSerialization.generate backtraces + + else + return rack_response(404, "Unsupported action", 'text/plain') + end + + case resp_type + when String + rack_response 200, resp_type + when 200 + rack_response 200, OK_STATUS + when 404 + str = env['PATH_INFO'][/\/(\S+)/, 1].tr '-', '_' + rack_response 404, "{ \"error\": \"#{str} not available\" }" + end + end + + private + + def authenticate(env) + return true unless @auth_token + env['QUERY_STRING'].to_s.split(/&;/).include?("token=#{@auth_token}") + end + + def rack_response(status, body, content_type='application/json') + headers = { + 'Content-Type' => content_type, + 'Content-Length' => body.bytesize.to_s + } + + [status, headers, [body]] + end + end + end +end diff --git a/lib/puma/binder.rb b/lib/puma/binder.rb new file mode 100644 index 0000000..300ece1 --- /dev/null +++ b/lib/puma/binder.rb @@ -0,0 +1,504 @@ +# frozen_string_literal: true + +require 'uri' +require 'socket' + +require 'puma/const' +require 'puma/util' +require 'puma/configuration' + +module Puma + + if HAS_SSL + require 'puma/minissl' + require 'puma/minissl/context_builder' + + # Odd bug in 'pure Ruby' nio4r version 2.5.2, which installs with Ruby 2.3. + # NIO doesn't create any OpenSSL objects, but it rescues an OpenSSL error. + # The bug was that it did not require openssl. + # @todo remove when Ruby 2.3 support is dropped + # + if windows? && RbConfig::CONFIG['ruby_version'] == '2.3.0' + require 'openssl' + end + end + + class Binder + include Puma::Const + + RACK_VERSION = [1,6].freeze + + def initialize(events, conf = Configuration.new) + @events = events + @conf = conf + @listeners = [] + @inherited_fds = {} + @activated_sockets = {} + @unix_paths = [] + + @proto_env = { + "rack.version".freeze => RACK_VERSION, + "rack.errors".freeze => events.stderr, + "rack.multithread".freeze => conf.options[:max_threads] > 1, + "rack.multiprocess".freeze => conf.options[:workers] >= 1, + "rack.run_once".freeze => false, + RACK_URL_SCHEME => conf.options[:rack_url_scheme], + "SCRIPT_NAME".freeze => ENV['SCRIPT_NAME'] || "", + + # I'd like to set a default CONTENT_TYPE here but some things + # depend on their not being a default set and inferring + # it from the content. And so if i set it here, it won't + # infer properly. + + "QUERY_STRING".freeze => "", + SERVER_PROTOCOL => HTTP_11, + SERVER_SOFTWARE => PUMA_SERVER_STRING, + GATEWAY_INTERFACE => CGI_VER + } + + @envs = {} + @ios = [] + localhost_authority + end + + attr_reader :ios + + # @version 5.0.0 + attr_reader :activated_sockets, :envs, :inherited_fds, :listeners, :proto_env, :unix_paths + + # @version 5.0.0 + attr_writer :ios, :listeners + + def env(sock) + @envs.fetch(sock, @proto_env) + end + + def close + @ios.each { |i| i.close } + end + + # @!attribute [r] connected_ports + # @version 5.0.0 + def connected_ports + ios.map { |io| io.addr[1] }.uniq + end + + # @version 5.0.0 + def create_inherited_fds(env_hash) + env_hash.select {|k,v| k =~ /PUMA_INHERIT_\d+/}.each do |_k, v| + fd, url = v.split(":", 2) + @inherited_fds[url] = fd.to_i + end.keys # pass keys back for removal + end + + # systemd socket activation. + # LISTEN_FDS = number of listening sockets. e.g. 2 means accept on 2 sockets w/descriptors 3 and 4. + # LISTEN_PID = PID of the service process, aka us + # @see https://www.freedesktop.org/software/systemd/man/systemd-socket-activate.html + # @version 5.0.0 + # + def create_activated_fds(env_hash) + @events.debug "ENV['LISTEN_FDS'] #{ENV['LISTEN_FDS'].inspect} env_hash['LISTEN_PID'] #{env_hash['LISTEN_PID'].inspect}" + return [] unless env_hash['LISTEN_FDS'] && env_hash['LISTEN_PID'].to_i == $$ + env_hash['LISTEN_FDS'].to_i.times do |index| + sock = TCPServer.for_fd(socket_activation_fd(index)) + key = begin # Try to parse as a path + [:unix, Socket.unpack_sockaddr_un(sock.getsockname)] + rescue ArgumentError # Try to parse as a port/ip + port, addr = Socket.unpack_sockaddr_in(sock.getsockname) + addr = "[#{addr}]" if addr =~ /\:/ + [:tcp, addr, port] + end + @activated_sockets[key] = sock + @events.debug "Registered #{key.join ':'} for activation from LISTEN_FDS" + end + ["LISTEN_FDS", "LISTEN_PID"] # Signal to remove these keys from ENV + end + + # Synthesize binds from systemd socket activation + # + # When systemd socket activation is enabled, it can be tedious to keep the + # binds in sync. This method can synthesize any binds based on the received + # activated sockets. Any existing matching binds will be respected. + # + # When only_matching is true in, all binds that do not match an activated + # socket is removed in place. + # + # It's a noop if no activated sockets were received. + def synthesize_binds_from_activated_fs(binds, only_matching) + return binds unless activated_sockets.any? + + activated_binds = [] + + activated_sockets.keys.each do |proto, addr, port| + if port + tcp_url = "#{proto}://#{addr}:#{port}" + ssl_url = "ssl://#{addr}:#{port}" + ssl_url_prefix = "#{ssl_url}?" + + existing = binds.find { |bind| bind == tcp_url || bind == ssl_url || bind.start_with?(ssl_url_prefix) } + + activated_binds << (existing || tcp_url) + else + # TODO: can there be a SSL bind without a port? + activated_binds << "#{proto}://#{addr}" + end + end + + if only_matching + activated_binds + else + binds | activated_binds + end + end + + def parse(binds, logger, log_msg = 'Listening') + binds.each do |str| + uri = URI.parse str + case uri.scheme + when "tcp" + if fd = @inherited_fds.delete(str) + io = inherit_tcp_listener uri.host, uri.port, fd + logger.log "* Inherited #{str}" + elsif sock = @activated_sockets.delete([ :tcp, uri.host, uri.port ]) + io = inherit_tcp_listener uri.host, uri.port, sock + logger.log "* Activated #{str}" + else + ios_len = @ios.length + params = Util.parse_query uri.query + + opt = params.key?('low_latency') && params['low_latency'] != 'false' + backlog = params.fetch('backlog', 1024).to_i + + io = add_tcp_listener uri.host, uri.port, opt, backlog + + @ios[ios_len..-1].each do |i| + addr = loc_addr_str i + logger.log "* #{log_msg} on http://#{addr}" + end + end + + @listeners << [str, io] if io + when "unix" + path = "#{uri.host}#{uri.path}".gsub("%20", " ") + abstract = false + if str.start_with? 'unix://@' + raise "OS does not support abstract UNIXSockets" unless Puma.abstract_unix_socket? + abstract = true + path = "@#{path}" + end + + if fd = @inherited_fds.delete(str) + @unix_paths << path unless abstract + io = inherit_unix_listener path, fd + logger.log "* Inherited #{str}" + elsif sock = @activated_sockets.delete([ :unix, path ]) || + @activated_sockets.delete([ :unix, File.realdirpath(path) ]) + @unix_paths << path unless abstract || File.exist?(path) + io = inherit_unix_listener path, sock + logger.log "* Activated #{str}" + else + umask = nil + mode = nil + backlog = 1024 + + if uri.query + params = Util.parse_query uri.query + if u = params['umask'] + # Use Integer() to respect the 0 prefix as octal + umask = Integer(u) + end + + if u = params['mode'] + mode = Integer('0'+u) + end + + if u = params['backlog'] + backlog = Integer(u) + end + end + + @unix_paths << path unless abstract || File.exist?(path) + io = add_unix_listener path, umask, mode, backlog + logger.log "* #{log_msg} on #{str}" + end + + @listeners << [str, io] + when "ssl" + + raise "Puma compiled without SSL support" unless HAS_SSL + + params = Util.parse_query uri.query + + # If key and certs are not defined and localhost gem is required. + # localhost gem will be used for self signed + # Load localhost authority if not loaded. + if params.values_at('cert', 'key').all? { |v| v.to_s.empty? } + ctx = localhost_authority && localhost_authority_context + end + + ctx ||= + begin + # Extract cert_pem and key_pem from options[:store] if present + ['cert', 'key'].each do |v| + if params[v] && params[v].start_with?('store:') + index = Integer(params.delete(v).split('store:').last) + params["#{v}_pem"] = @conf.options[:store][index] + end + end + MiniSSL::ContextBuilder.new(params, @events).context + end + + if fd = @inherited_fds.delete(str) + logger.log "* Inherited #{str}" + io = inherit_ssl_listener fd, ctx + elsif sock = @activated_sockets.delete([ :tcp, uri.host, uri.port ]) + io = inherit_ssl_listener sock, ctx + logger.log "* Activated #{str}" + else + ios_len = @ios.length + backlog = params.fetch('backlog', 1024).to_i + io = add_ssl_listener uri.host, uri.port, ctx, optimize_for_latency = true, backlog + + @ios[ios_len..-1].each do |i| + addr = loc_addr_str i + logger.log "* #{log_msg} on ssl://#{addr}?#{uri.query}" + end + end + + @listeners << [str, io] if io + else + logger.error "Invalid URI: #{str}" + end + end + + # If we inherited fds but didn't use them (because of a + # configuration change), then be sure to close them. + @inherited_fds.each do |str, fd| + logger.log "* Closing unused inherited connection: #{str}" + + begin + IO.for_fd(fd).close + rescue SystemCallError + end + + # We have to unlink a unix socket path that's not being used + uri = URI.parse str + if uri.scheme == "unix" + path = "#{uri.host}#{uri.path}" + File.unlink path + end + end + + # Also close any unused activated sockets + unless @activated_sockets.empty? + fds = @ios.map(&:to_i) + @activated_sockets.each do |key, sock| + next if fds.include? sock.to_i + logger.log "* Closing unused activated socket: #{key.first}://#{key[1..-1].join ':'}" + begin + sock.close + rescue SystemCallError + end + # We have to unlink a unix socket path that's not being used + File.unlink key[1] if key.first == :unix + end + end + end + + def localhost_authority + @localhost_authority ||= Localhost::Authority.fetch if defined?(Localhost::Authority) && !Puma::IS_JRUBY + end + + def localhost_authority_context + return unless localhost_authority + + key_path, crt_path = if [:key_path, :certificate_path].all? { |m| localhost_authority.respond_to?(m) } + [localhost_authority.key_path, localhost_authority.certificate_path] + else + local_certificates_path = File.expand_path("~/.localhost") + [File.join(local_certificates_path, "localhost.key"), File.join(local_certificates_path, "localhost.crt")] + end + MiniSSL::ContextBuilder.new({ "key" => key_path, "cert" => crt_path }, @events).context + end + + # Tell the server to listen on host +host+, port +port+. + # If +optimize_for_latency+ is true (the default) then clients connecting + # will be optimized for latency over throughput. + # + # +backlog+ indicates how many unaccepted connections the kernel should + # allow to accumulate before returning connection refused. + # + def add_tcp_listener(host, port, optimize_for_latency=true, backlog=1024) + if host == "localhost" + loopback_addresses.each do |addr| + add_tcp_listener addr, port, optimize_for_latency, backlog + end + return + end + + host = host[1..-2] if host and host[0..0] == '[' + tcp_server = TCPServer.new(host, port) + + if optimize_for_latency + tcp_server.setsockopt(Socket::IPPROTO_TCP, Socket::TCP_NODELAY, 1) + end + tcp_server.setsockopt(Socket::SOL_SOCKET,Socket::SO_REUSEADDR, true) + tcp_server.listen backlog + + @ios << tcp_server + tcp_server + end + + def inherit_tcp_listener(host, port, fd) + s = fd.kind_of?(::TCPServer) ? fd : ::TCPServer.for_fd(fd) + + @ios << s + s + end + + def add_ssl_listener(host, port, ctx, + optimize_for_latency=true, backlog=1024) + + raise "Puma compiled without SSL support" unless HAS_SSL + # Puma will try to use local authority context if context is supplied nil + ctx ||= localhost_authority_context + + if host == "localhost" + loopback_addresses.each do |addr| + add_ssl_listener addr, port, ctx, optimize_for_latency, backlog + end + return + end + + host = host[1..-2] if host[0..0] == '[' + s = TCPServer.new(host, port) + if optimize_for_latency + s.setsockopt(Socket::IPPROTO_TCP, Socket::TCP_NODELAY, 1) + end + s.setsockopt(Socket::SOL_SOCKET,Socket::SO_REUSEADDR, true) + s.listen backlog + + ssl = MiniSSL::Server.new s, ctx + env = @proto_env.dup + env[HTTPS_KEY] = HTTPS + @envs[ssl] = env + + @ios << ssl + s + end + + def inherit_ssl_listener(fd, ctx) + raise "Puma compiled without SSL support" unless HAS_SSL + # Puma will try to use local authority context if context is supplied nil + ctx ||= localhost_authority_context + + s = fd.kind_of?(::TCPServer) ? fd : ::TCPServer.for_fd(fd) + + ssl = MiniSSL::Server.new(s, ctx) + + env = @proto_env.dup + env[HTTPS_KEY] = HTTPS + @envs[ssl] = env + + @ios << ssl + + s + end + + # Tell the server to listen on +path+ as a UNIX domain socket. + # + def add_unix_listener(path, umask=nil, mode=nil, backlog=1024) + # Let anyone connect by default + umask ||= 0 + + begin + old_mask = File.umask(umask) + + if File.exist? path + begin + old = UNIXSocket.new path + rescue SystemCallError, IOError + File.unlink path + else + old.close + raise "There is already a server bound to: #{path}" + end + end + s = UNIXServer.new path.sub(/\A@/, "\0") # check for abstract UNIXSocket + s.listen backlog + @ios << s + ensure + File.umask old_mask + end + + if mode + File.chmod mode, path + end + + env = @proto_env.dup + env[REMOTE_ADDR] = "127.0.0.1" + @envs[s] = env + + s + end + + def inherit_unix_listener(path, fd) + s = fd.kind_of?(::TCPServer) ? fd : ::UNIXServer.for_fd(fd) + + @ios << s + + env = @proto_env.dup + env[REMOTE_ADDR] = "127.0.0.1" + @envs[s] = env + + s + end + + def close_listeners + @listeners.each do |l, io| + io.close unless io.closed? + uri = URI.parse l + next unless uri.scheme == 'unix' + unix_path = "#{uri.host}#{uri.path}" + File.unlink unix_path if @unix_paths.include?(unix_path) && File.exist?(unix_path) + end + end + + def redirects_for_restart + redirects = @listeners.map { |a| [a[1].to_i, a[1].to_i] }.to_h + redirects[:close_others] = true + redirects + end + + # @version 5.0.0 + def redirects_for_restart_env + @listeners.each_with_object({}).with_index do |(listen, memo), i| + memo["PUMA_INHERIT_#{i}"] = "#{listen[1].to_i}:#{listen[0]}" + end + end + + private + + # @!attribute [r] loopback_addresses + def loopback_addresses + Socket.ip_address_list.select do |addrinfo| + addrinfo.ipv6_loopback? || addrinfo.ipv4_loopback? + end.map { |addrinfo| addrinfo.ip_address }.uniq + end + + def loc_addr_str(io) + loc_addr = io.to_io.local_address + if loc_addr.ipv6? + "[#{loc_addr.ip_unpack[0]}]:#{loc_addr.ip_unpack[1]}" + else + loc_addr.ip_unpack.join(':') + end + end + + # @version 5.0.0 + def socket_activation_fd(int) + int + 3 # 3 is the magic number you add to follow the SA protocol + end + end +end diff --git a/lib/puma/cli.rb b/lib/puma/cli.rb new file mode 100644 index 0000000..16e0ace --- /dev/null +++ b/lib/puma/cli.rb @@ -0,0 +1,245 @@ +# frozen_string_literal: true + +require 'optparse' +require 'uri' + +require 'puma' +require 'puma/configuration' +require 'puma/launcher' +require 'puma/const' +require 'puma/events' + +module Puma + class << self + # The CLI exports a Puma::Configuration instance here to allow + # apps to pick it up. An app must load this object conditionally + # because it is not set if the app is launched via any mechanism + # other than the CLI class. + attr_accessor :cli_config + end + + # Handles invoke a Puma::Server in a command line style. + # + class CLI + # @deprecated 6.0.0 + KEYS_NOT_TO_PERSIST_IN_STATE = Launcher::KEYS_NOT_TO_PERSIST_IN_STATE + + # Create a new CLI object using +argv+ as the command line + # arguments. + # + # +stdout+ and +stderr+ can be set to IO-like objects which + # this object will report status on. + # + def initialize(argv, events=Events.stdio) + @debug = false + @argv = argv.dup + + @events = events + + @conf = nil + + @stdout = nil + @stderr = nil + @append = false + + @control_url = nil + @control_options = {} + + setup_options + + begin + @parser.parse! @argv + + if file = @argv.shift + @conf.configure do |user_config, file_config| + file_config.rackup file + end + end + rescue UnsupportedOption + exit 1 + end + + @conf.configure do |user_config, file_config| + if @stdout || @stderr + user_config.stdout_redirect @stdout, @stderr, @append + end + + if @control_url + user_config.activate_control_app @control_url, @control_options + end + end + + @launcher = Puma::Launcher.new(@conf, :events => @events, :argv => argv) + end + + attr_reader :launcher + + # Parse the options, load the rackup, start the server and wait + # for it to finish. + # + def run + @launcher.run + end + + private + def unsupported(str) + @events.error(str) + raise UnsupportedOption + end + + def configure_control_url(command_line_arg) + if command_line_arg + @control_url = command_line_arg + elsif Puma.jruby? + unsupported "No default url available on JRuby" + end + end + + # Build the OptionParser object to handle the available options. + # + + def setup_options + @conf = Configuration.new do |user_config, file_config| + @parser = OptionParser.new do |o| + o.on "-b", "--bind URI", "URI to bind to (tcp://, unix://, ssl://)" do |arg| + user_config.bind arg + end + + o.on "--bind-to-activated-sockets [only]", "Bind to all activated sockets" do |arg| + user_config.bind_to_activated_sockets(arg || true) + end + + o.on "-C", "--config PATH", "Load PATH as a config file" do |arg| + file_config.load arg + end + + # Identical to supplying --config "-", but more semantic + o.on "--no-config", "Prevent Puma from searching for a config file" do |arg| + file_config.load "-" + end + + o.on "--control-url URL", "The bind url to use for the control server. Use 'auto' to use temp unix server" do |arg| + configure_control_url(arg) + end + + o.on "--control-token TOKEN", + "The token to use as authentication for the control server" do |arg| + @control_options[:auth_token] = arg + end + + o.on "--debug", "Log lowlevel debugging information" do + user_config.debug + end + + o.on "--dir DIR", "Change to DIR before starting" do |d| + user_config.directory d + end + + o.on "-e", "--environment ENVIRONMENT", + "The environment to run the Rack app on (default development)" do |arg| + user_config.environment arg + end + + o.on "-f", "--fork-worker=[REQUESTS]", OptionParser::DecimalInteger, + "Fork new workers from existing worker. Cluster mode only", + "Auto-refork after REQUESTS (default 1000)" do |*args| + user_config.fork_worker(*args.compact) + end + + o.on "-I", "--include PATH", "Specify $LOAD_PATH directories" do |arg| + $LOAD_PATH.unshift(*arg.split(':')) + end + + o.on "-p", "--port PORT", "Define the TCP port to bind to", + "Use -b for more advanced options" do |arg| + user_config.bind "tcp://#{Configuration::DefaultTCPHost}:#{arg}" + end + + o.on "--pidfile PATH", "Use PATH as a pidfile" do |arg| + user_config.pidfile arg + end + + o.on "--preload", "Preload the app. Cluster mode only" do + user_config.preload_app! + end + + o.on "--prune-bundler", "Prune out the bundler env if possible" do + user_config.prune_bundler + end + + o.on "--extra-runtime-dependencies GEM1,GEM2", "Defines any extra needed gems when using --prune-bundler" do |arg| + user_config.extra_runtime_dependencies arg.split(',') + end + + o.on "-q", "--quiet", "Do not log requests internally (default true)" do + user_config.quiet + end + + o.on "-v", "--log-requests", "Log requests as they occur" do + user_config.log_requests + end + + o.on "-R", "--restart-cmd CMD", + "The puma command to run during a hot restart", + "Default: inferred" do |cmd| + user_config.restart_command cmd + end + + o.on "-s", "--silent", "Do not log prompt messages other than errors" do + @events = Events.new NullIO.new, $stderr + end + + o.on "-S", "--state PATH", "Where to store the state details" do |arg| + user_config.state_path arg + end + + o.on '-t', '--threads INT', "min:max threads to use (default 0:16)" do |arg| + min, max = arg.split(":") + if max + user_config.threads min, max + else + user_config.threads min, min + end + end + + o.on "--early-hints", "Enable early hints support" do + user_config.early_hints + end + + o.on "-V", "--version", "Print the version information" do + puts "puma version #{Puma::Const::VERSION}" + exit 0 + end + + o.on "-w", "--workers COUNT", + "Activate cluster mode: How many worker processes to create" do |arg| + user_config.workers arg + end + + o.on "--tag NAME", "Additional text to display in process listing" do |arg| + user_config.tag arg + end + + o.on "--redirect-stdout FILE", "Redirect STDOUT to a specific file" do |arg| + @stdout = arg.to_s + end + + o.on "--redirect-stderr FILE", "Redirect STDERR to a specific file" do |arg| + @stderr = arg.to_s + end + + o.on "--[no-]redirect-append", "Append to redirected files" do |val| + @append = val + end + + o.banner = "puma " + + o.on_tail "-h", "--help", "Show help" do + $stdout.puts o + exit 0 + end + end + end + end + end +end diff --git a/lib/puma/client.rb b/lib/puma/client.rb new file mode 100644 index 0000000..e966f99 --- /dev/null +++ b/lib/puma/client.rb @@ -0,0 +1,585 @@ +# frozen_string_literal: true + +class IO + # We need to use this for a jruby work around on both 1.8 and 1.9. + # So this either creates the constant (on 1.8), or harmlessly + # reopens it (on 1.9). + module WaitReadable + end +end + +require 'puma/detect' +require 'tempfile' +require 'forwardable' + +if Puma::IS_JRUBY + # We have to work around some OpenSSL buffer/io-readiness bugs + # so we pull it in regardless of if the user is binding + # to an SSL socket + require 'openssl' +end + +module Puma + + class ConnectionError < RuntimeError; end + + class HttpParserError501 < IOError; end + + # An instance of this class represents a unique request from a client. + # For example, this could be a web request from a browser or from CURL. + # + # An instance of `Puma::Client` can be used as if it were an IO object + # by the reactor. The reactor is expected to call `#to_io` + # on any non-IO objects it polls. For example, nio4r internally calls + # `IO::try_convert` (which may call `#to_io`) when a new socket is + # registered. + # + # Instances of this class are responsible for knowing if + # the header and body are fully buffered via the `try_to_finish` method. + # They can be used to "time out" a response via the `timeout_at` reader. + # + class Client + + # this tests all values but the last, which must be chunked + ALLOWED_TRANSFER_ENCODING = %w[compress deflate gzip].freeze + + # chunked body validation + CHUNK_SIZE_INVALID = /[^\h]/.freeze + CHUNK_VALID_ENDING = "\r\n".freeze + + # Content-Length header value validation + CONTENT_LENGTH_VALUE_INVALID = /[^\d]/.freeze + + TE_ERR_MSG = 'Invalid Transfer-Encoding' + + # The object used for a request with no body. All requests with + # no body share this one object since it has no state. + EmptyBody = NullIO.new + + include Puma::Const + extend Forwardable + + def initialize(io, env=nil) + @io = io + @to_io = io.to_io + @proto_env = env + if !env + @env = nil + else + @env = env.dup + end + + @parser = HttpParser.new + @parsed_bytes = 0 + @read_header = true + @read_proxy = false + @ready = false + + @body = nil + @body_read_start = nil + @buffer = nil + @tempfile = nil + + @timeout_at = nil + + @requests_served = 0 + @hijacked = false + + @peerip = nil + @listener = nil + @remote_addr_header = nil + @expect_proxy_proto = false + + @body_remain = 0 + + @in_last_chunk = false + end + + attr_reader :env, :to_io, :body, :io, :timeout_at, :ready, :hijacked, + :tempfile + + attr_writer :peerip + + attr_accessor :remote_addr_header, :listener + + def_delegators :@io, :closed? + + # Test to see if io meets a bare minimum of functioning, @to_io needs to be + # used for MiniSSL::Socket + def io_ok? + @to_io.is_a?(::BasicSocket) && !closed? + end + + # @!attribute [r] inspect + def inspect + "#" + end + + # For the hijack protocol (allows us to just put the Client object + # into the env) + def call + @hijacked = true + env[HIJACK_IO] ||= @io + end + + # @!attribute [r] in_data_phase + def in_data_phase + !(@read_header || @read_proxy) + end + + def set_timeout(val) + @timeout_at = Process.clock_gettime(Process::CLOCK_MONOTONIC) + val + end + + # Number of seconds until the timeout elapses. + def timeout + [@timeout_at - Process.clock_gettime(Process::CLOCK_MONOTONIC), 0].max + end + + def reset(fast_check=true) + @parser.reset + @read_header = true + @read_proxy = !!@expect_proxy_proto + @env = @proto_env.dup + @body = nil + @tempfile = nil + @parsed_bytes = 0 + @ready = false + @body_remain = 0 + @peerip = nil if @remote_addr_header + @in_last_chunk = false + + if @buffer + return false unless try_to_parse_proxy_protocol + + @parsed_bytes = @parser.execute(@env, @buffer, @parsed_bytes) + + if @parser.finished? + return setup_body + elsif @parsed_bytes >= MAX_HEADER + raise HttpParserError, + "HEADER is longer than allowed, aborting client early." + end + + return false + else + begin + if fast_check && @to_io.wait_readable(FAST_TRACK_KA_TIMEOUT) + return try_to_finish + end + rescue IOError + # swallow it + end + + end + end + + def close + begin + @io.close + rescue IOError, Errno::EBADF + Puma::Util.purge_interrupt_queue + end + end + + # If necessary, read the PROXY protocol from the buffer. Returns + # false if more data is needed. + def try_to_parse_proxy_protocol + if @read_proxy + if @expect_proxy_proto == :v1 + if @buffer.include? "\r\n" + if md = PROXY_PROTOCOL_V1_REGEX.match(@buffer) + if md[1] + @peerip = md[1].split(" ")[0] + end + @buffer = md.post_match + end + # if the buffer has a \r\n but doesn't have a PROXY protocol + # request, this is just HTTP from a non-PROXY client; move on + @read_proxy = false + return @buffer.size > 0 + else + return false + end + end + end + true + end + + def try_to_finish + return read_body if in_data_phase + + begin + data = @io.read_nonblock(CHUNK_SIZE) + rescue IO::WaitReadable + return false + rescue EOFError + # Swallow error, don't log + rescue SystemCallError, IOError + raise ConnectionError, "Connection error detected during read" + end + + # No data means a closed socket + unless data + @buffer = nil + set_ready + raise EOFError + end + + if @buffer + @buffer << data + else + @buffer = data + end + + return false unless try_to_parse_proxy_protocol + + @parsed_bytes = @parser.execute(@env, @buffer, @parsed_bytes) + + if @parser.finished? + return setup_body + elsif @parsed_bytes >= MAX_HEADER + raise HttpParserError, + "HEADER is longer than allowed, aborting client early." + end + + false + end + + def eagerly_finish + return true if @ready + return false unless @to_io.wait_readable(0) + try_to_finish + end + + def finish(timeout) + return if @ready + @to_io.wait_readable(timeout) || timeout! until try_to_finish + end + + def timeout! + write_error(408) if in_data_phase + raise ConnectionError + end + + def write_error(status_code) + begin + @io << ERROR_RESPONSE[status_code] + rescue StandardError + end + end + + def peerip + return @peerip if @peerip + + if @remote_addr_header + hdr = (@env[@remote_addr_header] || LOCALHOST_IP).split(/[\s,]/).first + @peerip = hdr + return hdr + end + + @peerip ||= @io.peeraddr.last + end + + # Returns true if the persistent connection can be closed immediately + # without waiting for the configured idle/shutdown timeout. + # @version 5.0.0 + # + def can_close? + # Allow connection to close if we're not in the middle of parsing a request. + @parsed_bytes == 0 + end + + def expect_proxy_proto=(val) + if val + if @read_header + @read_proxy = true + end + else + @read_proxy = false + end + @expect_proxy_proto = val + end + + private + + def setup_body + @body_read_start = Process.clock_gettime(Process::CLOCK_MONOTONIC, :millisecond) + + if @env[HTTP_EXPECT] == CONTINUE + # TODO allow a hook here to check the headers before + # going forward + @io << HTTP_11_100 + @io.flush + end + + @read_header = false + + body = @parser.body + + te = @env[TRANSFER_ENCODING2] + if te + te_lwr = te.downcase + if te.include? ',' + te_ary = te_lwr.split ',' + te_count = te_ary.count CHUNKED + te_valid = te_ary[0..-2].all? { |e| ALLOWED_TRANSFER_ENCODING.include? e } + if te_ary.last == CHUNKED && te_count == 1 && te_valid + @env.delete TRANSFER_ENCODING2 + return setup_chunked_body body + elsif te_count >= 1 + raise HttpParserError , "#{TE_ERR_MSG}, multiple chunked: '#{te}'" + elsif !te_valid + raise HttpParserError501, "#{TE_ERR_MSG}, unknown value: '#{te}'" + end + elsif te_lwr == CHUNKED + @env.delete TRANSFER_ENCODING2 + return setup_chunked_body body + elsif ALLOWED_TRANSFER_ENCODING.include? te_lwr + raise HttpParserError , "#{TE_ERR_MSG}, single value must be chunked: '#{te}'" + else + raise HttpParserError501 , "#{TE_ERR_MSG}, unknown value: '#{te}'" + end + end + + @chunked_body = false + + cl = @env[CONTENT_LENGTH] + + if cl + # cannot contain characters that are not \d + if cl =~ CONTENT_LENGTH_VALUE_INVALID + raise HttpParserError, "Invalid Content-Length: #{cl.inspect}" + end + else + @buffer = body.empty? ? nil : body + @body = EmptyBody + set_ready + return true + end + + remain = cl.to_i - body.bytesize + + if remain <= 0 + @body = StringIO.new(body) + @buffer = nil + set_ready + return true + end + + if remain > MAX_BODY + @body = Tempfile.new(Const::PUMA_TMP_BASE) + @body.unlink + @body.binmode + @tempfile = @body + else + # The body[0,0] trick is to get an empty string in the same + # encoding as body. + @body = StringIO.new body[0,0] + end + + @body.write body + + @body_remain = remain + + false + end + + def read_body + if @chunked_body + return read_chunked_body + end + + # Read an odd sized chunk so we can read even sized ones + # after this + remain = @body_remain + + if remain > CHUNK_SIZE + want = CHUNK_SIZE + else + want = remain + end + + begin + chunk = @io.read_nonblock(want) + rescue IO::WaitReadable + return false + rescue SystemCallError, IOError + raise ConnectionError, "Connection error detected during read" + end + + # No chunk means a closed socket + unless chunk + @body.close + @buffer = nil + set_ready + raise EOFError + end + + remain -= @body.write(chunk) + + if remain <= 0 + @body.rewind + @buffer = nil + set_ready + return true + end + + @body_remain = remain + + false + end + + def read_chunked_body + while true + begin + chunk = @io.read_nonblock(4096) + rescue IO::WaitReadable + return false + rescue SystemCallError, IOError + raise ConnectionError, "Connection error detected during read" + end + + # No chunk means a closed socket + unless chunk + @body.close + @buffer = nil + set_ready + raise EOFError + end + + if decode_chunk(chunk) + @env[CONTENT_LENGTH] = @chunked_content_length.to_s + return true + end + end + end + + def setup_chunked_body(body) + @chunked_body = true + @partial_part_left = 0 + @prev_chunk = "" + + @body = Tempfile.new(Const::PUMA_TMP_BASE) + @body.unlink + @body.binmode + @tempfile = @body + @chunked_content_length = 0 + + if decode_chunk(body) + @env[CONTENT_LENGTH] = @chunked_content_length.to_s + return true + end + end + + # @version 5.0.0 + def write_chunk(str) + @chunked_content_length += @body.write(str) + end + + def decode_chunk(chunk) + if @partial_part_left > 0 + if @partial_part_left <= chunk.size + if @partial_part_left > 2 + write_chunk(chunk[0..(@partial_part_left-3)]) # skip the \r\n + end + chunk = chunk[@partial_part_left..-1] + @partial_part_left = 0 + else + if @partial_part_left > 2 + if @partial_part_left == chunk.size + 1 + # Don't include the last \r + write_chunk(chunk[0..(@partial_part_left-3)]) + else + # don't include the last \r\n + write_chunk(chunk) + end + end + @partial_part_left -= chunk.size + return false + end + end + + if @prev_chunk.empty? + io = StringIO.new(chunk) + else + io = StringIO.new(@prev_chunk+chunk) + @prev_chunk = "" + end + + while !io.eof? + line = io.gets + if line.end_with?("\r\n") + # Puma doesn't process chunk extensions, but should parse if they're + # present, which is the reason for the semicolon regex + chunk_hex = line.strip[/\A[^;]+/] + if chunk_hex =~ CHUNK_SIZE_INVALID + raise HttpParserError, "Invalid chunk size: '#{chunk_hex}'" + end + len = chunk_hex.to_i(16) + if len == 0 + @in_last_chunk = true + @body.rewind + rest = io.read + last_crlf_size = "\r\n".bytesize + if rest.bytesize < last_crlf_size + @buffer = nil + @partial_part_left = last_crlf_size - rest.bytesize + return false + else + @buffer = rest[last_crlf_size..-1] + @buffer = nil if @buffer.empty? + set_ready + return true + end + end + + len += 2 + + part = io.read(len) + + unless part + @partial_part_left = len + next + end + + got = part.size + + case + when got == len + # proper chunked segment must end with "\r\n" + if part.end_with? CHUNK_VALID_ENDING + write_chunk(part[0..-3]) # to skip the ending \r\n + else + raise HttpParserError, "Chunk size mismatch" + end + when got <= len - 2 + write_chunk(part) + @partial_part_left = len - part.size + when got == len - 1 # edge where we get just \r but not \n + write_chunk(part[0..-2]) + @partial_part_left = len - part.size + end + else + @prev_chunk = line + return false + end + end + + if @in_last_chunk + set_ready + true + else + false + end + end + + def set_ready + if @body_read_start + @env['puma.request_body_wait'] = Process.clock_gettime(Process::CLOCK_MONOTONIC, :millisecond) - @body_read_start + end + @requests_served += 1 + @ready = true + end + end +end diff --git a/lib/puma/cluster.rb b/lib/puma/cluster.rb new file mode 100644 index 0000000..67aca9c --- /dev/null +++ b/lib/puma/cluster.rb @@ -0,0 +1,546 @@ +# frozen_string_literal: true + +require 'puma/runner' +require 'puma/util' +require 'puma/plugin' +require 'puma/cluster/worker_handle' +require 'puma/cluster/worker' + +require 'time' + +module Puma + # This class is instantiated by the `Puma::Launcher` and used + # to boot and serve a Ruby application when puma "workers" are needed + # i.e. when using multi-processes. For example `$ puma -w 5` + # + # An instance of this class will spawn the number of processes passed in + # via the `spawn_workers` method call. Each worker will have it's own + # instance of a `Puma::Server`. + class Cluster < Runner + def initialize(cli, events) + super cli, events + + @phase = 0 + @workers = [] + @next_check = Time.now + + @phased_restart = false + end + + def stop_workers + log "- Gracefully shutting down workers..." + @workers.each { |x| x.term } + + begin + loop do + wait_workers + break if @workers.reject {|w| w.pid.nil?}.empty? + sleep 0.2 + end + rescue Interrupt + log "! Cancelled waiting for workers" + end + end + + def start_phased_restart + @events.fire_on_restart! + @phase += 1 + log "- Starting phased worker restart, phase: #{@phase}" + + # Be sure to change the directory again before loading + # the app. This way we can pick up new code. + dir = @launcher.restart_dir + log "+ Changing to #{dir}" + Dir.chdir dir + end + + def redirect_io + super + + @workers.each { |x| x.hup } + end + + def spawn_workers + diff = @options[:workers] - @workers.size + return if diff < 1 + + master = Process.pid + if @options[:fork_worker] + @fork_writer << "-1\n" + end + + diff.times do + idx = next_worker_index + + if @options[:fork_worker] && idx != 0 + @fork_writer << "#{idx}\n" + pid = nil + else + pid = spawn_worker(idx, master) + end + + debug "Spawned worker: #{pid}" + @workers << WorkerHandle.new(idx, pid, @phase, @options) + end + + if @options[:fork_worker] && + @workers.all? {|x| x.phase == @phase} + + @fork_writer << "0\n" + end + end + + # @version 5.0.0 + def spawn_worker(idx, master) + @launcher.config.run_hooks :before_worker_fork, idx, @launcher.events + + pid = fork { worker(idx, master) } + if !pid + log "! Complete inability to spawn new workers detected" + log "! Seppuku is the only choice." + exit! 1 + end + + @launcher.config.run_hooks :after_worker_fork, idx, @launcher.events + pid + end + + def cull_workers + diff = @workers.size - @options[:workers] + return if diff < 1 + debug "Culling #{diff} workers" + + workers = workers_to_cull(diff) + debug "Workers to cull: #{workers.inspect}" + + workers.each do |worker| + log "- Worker #{worker.index} (PID: #{worker.pid}) terminating" + worker.term + end + end + + def workers_to_cull(diff) + workers = @workers.sort_by(&:started_at) + + # In fork_worker mode, worker 0 acts as our master process. + # We should avoid culling it to preserve copy-on-write memory gains. + workers.reject! { |w| w.index == 0 } if @options[:fork_worker] + + workers[cull_start_index(diff), diff] + end + + def cull_start_index(diff) + case @options[:worker_culling_strategy] + when :oldest + 0 + else # :youngest + -diff + end + end + + # @!attribute [r] next_worker_index + def next_worker_index + occupied_positions = @workers.map(&:index) + idx = 0 + idx += 1 until !occupied_positions.include?(idx) + idx + end + + def all_workers_booted? + @workers.count { |w| !w.booted? } == 0 + end + + def check_workers + return if @next_check >= Time.now + + @next_check = Time.now + @options[:worker_check_interval] + + timeout_workers + wait_workers + cull_workers + spawn_workers + + if all_workers_booted? + # If we're running at proper capacity, check to see if + # we need to phase any workers out (which will restart + # in the right phase). + # + w = @workers.find { |x| x.phase != @phase } + + if w + log "- Stopping #{w.pid} for phased upgrade..." + unless w.term? + w.term + log "- #{w.signal} sent to #{w.pid}..." + end + end + end + + @next_check = [ + @workers.reject(&:term?).map(&:ping_timeout).min, + @next_check + ].compact.min + end + + def worker(index, master) + @workers = [] + + @master_read.close + @suicide_pipe.close + @fork_writer.close + + pipes = { check_pipe: @check_pipe, worker_write: @worker_write } + if @options[:fork_worker] + pipes[:fork_pipe] = @fork_pipe + pipes[:wakeup] = @wakeup + end + + server = start_server if preload? + new_worker = Worker.new index: index, + master: master, + launcher: @launcher, + pipes: pipes, + server: server + new_worker.run + end + + def restart + @restart = true + stop + end + + def phased_restart + return false if @options[:preload_app] + + @phased_restart = true + wakeup! + + true + end + + def stop + @status = :stop + wakeup! + end + + def stop_blocked + @status = :stop if @status == :run + wakeup! + @control.stop(true) if @control + Process.waitall + end + + def halt + @status = :halt + wakeup! + end + + def reload_worker_directory + dir = @launcher.restart_dir + log "+ Changing to #{dir}" + Dir.chdir dir + end + + # Inside of a child process, this will return all zeroes, as @workers is only populated in + # the master process. + # @!attribute [r] stats + def stats + old_worker_count = @workers.count { |w| w.phase != @phase } + worker_status = @workers.map do |w| + { + started_at: w.started_at.utc.iso8601, + pid: w.pid, + index: w.index, + phase: w.phase, + booted: w.booted?, + last_checkin: w.last_checkin.utc.iso8601, + last_status: w.last_status, + } + end + + { + started_at: @started_at.utc.iso8601, + workers: @workers.size, + phase: @phase, + booted_workers: worker_status.count { |w| w[:booted] }, + old_workers: old_worker_count, + worker_status: worker_status, + } + end + + def preload? + @options[:preload_app] + end + + # @version 5.0.0 + def fork_worker! + if (worker = @workers.find { |w| w.index == 0 }) + worker.phase += 1 + end + phased_restart + end + + # We do this in a separate method to keep the lambda scope + # of the signals handlers as small as possible. + def setup_signals + if @options[:fork_worker] + Signal.trap "SIGURG" do + fork_worker! + end + + # Auto-fork after the specified number of requests. + if (fork_requests = @options[:fork_worker].to_i) > 0 + @launcher.events.register(:ping!) do |w| + fork_worker! if w.index == 0 && + w.phase == 0 && + w.last_status[:requests_count] >= fork_requests + end + end + end + + Signal.trap "SIGCHLD" do + wakeup! + end + + Signal.trap "TTIN" do + @options[:workers] += 1 + wakeup! + end + + Signal.trap "TTOU" do + @options[:workers] -= 1 if @options[:workers] >= 2 + wakeup! + end + + master_pid = Process.pid + + Signal.trap "SIGTERM" do + # The worker installs their own SIGTERM when booted. + # Until then, this is run by the worker and the worker + # should just exit if they get it. + if Process.pid != master_pid + log "Early termination of worker" + exit! 0 + else + @launcher.close_binder_listeners + + stop_workers + stop + @events.fire_on_stopped! + raise(SignalException, "SIGTERM") if @options[:raise_exception_on_sigterm] + exit 0 # Clean exit, workers were stopped + end + end + end + + def run + @status = :run + + output_header "cluster" + + # This is aligned with the output from Runner, see Runner#output_header + log "* Workers: #{@options[:workers]}" + + if preload? + # Threads explicitly marked as fork safe will be ignored. Used in Rails, + # but may be used by anyone. Note that we need to explicit + # Process::Waiter check here because there's a bug in Ruby 2.6 and below + # where calling thread_variable_get on a Process::Waiter will segfault. + # We can drop that clause once those versions of Ruby are no longer + # supported. + fork_safe = ->(t) { !t.is_a?(Process::Waiter) && t.thread_variable_get(:fork_safe) } + + before = Thread.list.reject(&fork_safe) + + log "* Restarts: (\u2714) hot (\u2716) phased" + log "* Preloading application" + load_and_bind + + after = Thread.list.reject(&fork_safe) + + if after.size > before.size + threads = (after - before) + if threads.first.respond_to? :backtrace + log "! WARNING: Detected #{after.size-before.size} Thread(s) started in app boot:" + threads.each do |t| + log "! #{t.inspect} - #{t.backtrace ? t.backtrace.first : ''}" + end + else + log "! WARNING: Detected #{after.size-before.size} Thread(s) started in app boot" + end + end + else + log "* Restarts: (\u2714) hot (\u2714) phased" + + unless @launcher.config.app_configured? + error "No application configured, nothing to run" + exit 1 + end + + @launcher.binder.parse @options[:binds], self + end + + read, @wakeup = Puma::Util.pipe + + setup_signals + + # Used by the workers to detect if the master process dies. + # If select says that @check_pipe is ready, it's because the + # master has exited and @suicide_pipe has been automatically + # closed. + # + @check_pipe, @suicide_pipe = Puma::Util.pipe + + # Separate pipe used by worker 0 to receive commands to + # fork new worker processes. + @fork_pipe, @fork_writer = Puma::Util.pipe + + log "Use Ctrl-C to stop" + + single_worker_warning + + redirect_io + + Plugins.fire_background + + @launcher.write_state + + start_control + + @master_read, @worker_write = read, @wakeup + + @launcher.config.run_hooks :before_fork, nil, @launcher.events + Puma::Util.nakayoshi_gc @events if @options[:nakayoshi_fork] + + spawn_workers + + Signal.trap "SIGINT" do + stop + end + + begin + booted = false + in_phased_restart = false + workers_not_booted = @options[:workers] + + while @status == :run + begin + if @phased_restart + start_phased_restart + @phased_restart = false + in_phased_restart = true + workers_not_booted = @options[:workers] + end + + check_workers + + if read.wait_readable([0, @next_check - Time.now].max) + req = read.read_nonblock(1) + + @next_check = Time.now if req == "!" + next if !req || req == "!" + + result = read.gets + pid = result.to_i + + if req == "b" || req == "f" + pid, idx = result.split(':').map(&:to_i) + w = @workers.find {|x| x.index == idx} + w.pid = pid if w.pid.nil? + end + + if w = @workers.find { |x| x.pid == pid } + case req + when "b" + w.boot! + log "- Worker #{w.index} (PID: #{pid}) booted in #{w.uptime.round(2)}s, phase: #{w.phase}" + @next_check = Time.now + workers_not_booted -= 1 + when "e" + # external term, see worker method, Signal.trap "SIGTERM" + w.term! + when "t" + w.term unless w.term? + when "p" + w.ping!(result.sub(/^\d+/,'').chomp) + @launcher.events.fire(:ping!, w) + if !booted && @workers.none? {|worker| worker.last_status.empty?} + @launcher.events.fire_on_booted! + booted = true + end + end + else + log "! Out-of-sync worker list, no #{pid} worker" + end + end + if in_phased_restart && workers_not_booted.zero? + @events.fire_on_booted! + in_phased_restart = false + end + + rescue Interrupt + @status = :stop + end + end + + stop_workers unless @status == :halt + ensure + @check_pipe.close + @suicide_pipe.close + read.close + @wakeup.close + end + end + + private + + def single_worker_warning + return if @options[:workers] != 1 || @options[:silence_single_worker_warning] + + log "! WARNING: Detected running cluster mode with 1 worker." + log "! Running Puma in cluster mode with a single worker is often a misconfiguration." + log "! Consider running Puma in single-mode (workers = 0) in order to reduce memory overhead." + log "! Set the `silence_single_worker_warning` option to silence this warning message." + end + + # loops thru @workers, removing workers that exited, and calling + # `#term` if needed + def wait_workers + @workers.reject! do |w| + next false if w.pid.nil? + begin + if Process.wait(w.pid, Process::WNOHANG) + true + else + w.term if w.term? + nil + end + rescue Errno::ECHILD + begin + Process.kill(0, w.pid) + # child still alive but has another parent (e.g., using fork_worker) + w.term if w.term? + false + rescue Errno::ESRCH, Errno::EPERM + true # child is already terminated + end + end + end + end + + # @version 5.0.0 + def timeout_workers + @workers.each do |w| + if !w.term? && w.ping_timeout <= Time.now + details = if w.booted? + "(worker failed to check in within #{@options[:worker_timeout]} seconds)" + else + "(worker failed to boot within #{@options[:worker_boot_timeout]} seconds)" + end + log "! Terminating timed out worker #{details}: #{w.pid}" + w.kill + end + end + end + end +end diff --git a/lib/puma/cluster/worker.rb b/lib/puma/cluster/worker.rb new file mode 100644 index 0000000..5cc4889 --- /dev/null +++ b/lib/puma/cluster/worker.rb @@ -0,0 +1,173 @@ +# frozen_string_literal: true + +module Puma + class Cluster < Puma::Runner + # This class is instantiated by the `Puma::Cluster` and represents a single + # worker process. + # + # At the core of this class is running an instance of `Puma::Server` which + # gets created via the `start_server` method from the `Puma::Runner` class + # that this inherits from. + class Worker < Puma::Runner + attr_reader :index, :master + + def initialize(index:, master:, launcher:, pipes:, server: nil) + super launcher, launcher.events + + @index = index + @master = master + @launcher = launcher + @options = launcher.options + @check_pipe = pipes[:check_pipe] + @worker_write = pipes[:worker_write] + @fork_pipe = pipes[:fork_pipe] + @wakeup = pipes[:wakeup] + @server = server + end + + def run + title = "puma: cluster worker #{index}: #{master}" + title += " [#{@options[:tag]}]" if @options[:tag] && !@options[:tag].empty? + $0 = title + + Signal.trap "SIGINT", "IGNORE" + Signal.trap "SIGCHLD", "DEFAULT" + + Thread.new do + Puma.set_thread_name "wrkr check" + @check_pipe.wait_readable + log "! Detected parent died, dying" + exit! 1 + end + + # If we're not running under a Bundler context, then + # report the info about the context we will be using + if !ENV['BUNDLE_GEMFILE'] + if File.exist?("Gemfile") + log "+ Gemfile in context: #{File.expand_path("Gemfile")}" + elsif File.exist?("gems.rb") + log "+ Gemfile in context: #{File.expand_path("gems.rb")}" + end + end + + # Invoke any worker boot hooks so they can get + # things in shape before booting the app. + @launcher.config.run_hooks :before_worker_boot, index, @launcher.events + + begin + server = @server ||= start_server + rescue Exception => e + log "! Unable to start worker" + log e.backtrace[0] + exit 1 + end + + restart_server = Queue.new << true << false + + fork_worker = @options[:fork_worker] && index == 0 + + if fork_worker + restart_server.clear + worker_pids = [] + Signal.trap "SIGCHLD" do + wakeup! if worker_pids.reject! do |p| + Process.wait(p, Process::WNOHANG) rescue true + end + end + + Thread.new do + Puma.set_thread_name "wrkr fork" + while (idx = @fork_pipe.gets) + idx = idx.to_i + if idx == -1 # stop server + if restart_server.length > 0 + restart_server.clear + server.begin_restart(true) + @launcher.config.run_hooks :before_refork, nil, @launcher.events + Puma::Util.nakayoshi_gc @events if @options[:nakayoshi_fork] + end + elsif idx == 0 # restart server + restart_server << true << false + else # fork worker + worker_pids << pid = spawn_worker(idx) + @worker_write << "f#{pid}:#{idx}\n" rescue nil + end + end + end + end + + Signal.trap "SIGTERM" do + @worker_write << "e#{Process.pid}\n" rescue nil + restart_server.clear + server.stop + restart_server << false + end + + begin + @worker_write << "b#{Process.pid}:#{index}\n" + rescue SystemCallError, IOError + Puma::Util.purge_interrupt_queue + STDERR.puts "Master seems to have exited, exiting." + return + end + + while restart_server.pop + server_thread = server.run + stat_thread ||= Thread.new(@worker_write) do |io| + Puma.set_thread_name "stat pld" + base_payload = "p#{Process.pid}" + + while true + begin + b = server.backlog || 0 + r = server.running || 0 + t = server.pool_capacity || 0 + m = server.max_threads || 0 + rc = server.requests_count || 0 + payload = %Q!#{base_payload}{ "backlog":#{b}, "running":#{r}, "pool_capacity":#{t}, "max_threads": #{m}, "requests_count": #{rc} }\n! + io << payload + rescue IOError + Puma::Util.purge_interrupt_queue + break + end + sleep @options[:worker_check_interval] + end + end + server_thread.join + end + + # Invoke any worker shutdown hooks so they can prevent the worker + # exiting until any background operations are completed + @launcher.config.run_hooks :before_worker_shutdown, index, @launcher.events + ensure + @worker_write << "t#{Process.pid}\n" rescue nil + @worker_write.close + end + + private + + def spawn_worker(idx) + @launcher.config.run_hooks :before_worker_fork, idx, @launcher.events + + pid = fork do + new_worker = Worker.new index: idx, + master: master, + launcher: @launcher, + pipes: { check_pipe: @check_pipe, + worker_write: @worker_write }, + server: @server + new_worker.run + end + + if !pid + log "! Complete inability to spawn new workers detected" + log "! Seppuku is the only choice." + exit! 1 + end + + @launcher.config.run_hooks :after_worker_fork, idx, @launcher.events + pid + end + end + end +end diff --git a/lib/puma/cluster/worker_handle.rb b/lib/puma/cluster/worker_handle.rb new file mode 100644 index 0000000..7b1c9fd --- /dev/null +++ b/lib/puma/cluster/worker_handle.rb @@ -0,0 +1,94 @@ +# frozen_string_literal: true + +module Puma + class Cluster < Runner + # This class represents a worker process from the perspective of the puma + # master process. It contains information about the process and its health + # and it exposes methods to control the process via IPC. It does not + # include the actual logic executed by the worker process itself. For that, + # see Puma::Cluster::Worker. + class WorkerHandle + def initialize(idx, pid, phase, options) + @index = idx + @pid = pid + @phase = phase + @stage = :started + @signal = "TERM" + @options = options + @first_term_sent = nil + @started_at = Time.now + @last_checkin = Time.now + @last_status = {} + @term = false + end + + attr_reader :index, :pid, :phase, :signal, :last_checkin, :last_status, :started_at + + # @version 5.0.0 + attr_writer :pid, :phase + + def booted? + @stage == :booted + end + + def uptime + Time.now - started_at + end + + def boot! + @last_checkin = Time.now + @stage = :booted + end + + def term! + @term = true + end + + def term? + @term + end + + def ping!(status) + @last_checkin = Time.now + captures = status.match(/{ "backlog":(?\d*), "running":(?\d*), "pool_capacity":(?\d*), "max_threads": (?\d*), "requests_count": (?\d*) }/) + @last_status = captures.names.inject({}) do |hash, key| + hash[key.to_sym] = captures[key].to_i + hash + end + end + + # @see Puma::Cluster#check_workers + # @version 5.0.0 + def ping_timeout + @last_checkin + + (booted? ? + @options[:worker_timeout] : + @options[:worker_boot_timeout] + ) + end + + def term + begin + if @first_term_sent && (Time.now - @first_term_sent) > @options[:worker_shutdown_timeout] + @signal = "KILL" + else + @term ||= true + @first_term_sent ||= Time.now + end + Process.kill @signal, @pid if @pid + rescue Errno::ESRCH + end + end + + def kill + @signal = 'KILL' + term + end + + def hup + Process.kill "HUP", @pid + rescue Errno::ESRCH + end + end + end +end diff --git a/lib/puma/commonlogger.rb b/lib/puma/commonlogger.rb new file mode 100644 index 0000000..4762be3 --- /dev/null +++ b/lib/puma/commonlogger.rb @@ -0,0 +1,108 @@ +# frozen_string_literal: true + +module Puma + # Rack::CommonLogger forwards every request to the given +app+, and + # logs a line in the + # {Apache common log format}[https://httpd.apache.org/docs/1.3/logs.html#common] + # to the +logger+. + # + # If +logger+ is nil, CommonLogger will fall back +rack.errors+, which is + # an instance of Rack::NullLogger. + # + # +logger+ can be any class, including the standard library Logger, and is + # expected to have either +write+ or +<<+ method, which accepts the CommonLogger::FORMAT. + # According to the SPEC, the error stream must also respond to +puts+ + # (which takes a single argument that responds to +to_s+), and +flush+ + # (which is called without arguments in order to make the error appear for + # sure) + class CommonLogger + # Common Log Format: https://httpd.apache.org/docs/1.3/logs.html#common + # + # lilith.local - - [07/Aug/2006 23:58:02 -0400] "GET / HTTP/1.1" 500 - + # + # %{%s - %s [%s] "%s %s%s %s" %d %s\n} % + FORMAT = %{%s - %s [%s] "%s %s%s %s" %d %s %0.4f\n} + + HIJACK_FORMAT = %{%s - %s [%s] "%s %s%s %s" HIJACKED -1 %0.4f\n} + + CONTENT_LENGTH = 'Content-Length'.freeze + PATH_INFO = 'PATH_INFO'.freeze + QUERY_STRING = 'QUERY_STRING'.freeze + REQUEST_METHOD = 'REQUEST_METHOD'.freeze + + def initialize(app, logger=nil) + @app = app + @logger = logger + end + + def call(env) + began_at = Time.now + status, header, body = @app.call(env) + header = Util::HeaderHash.new(header) + + # If we've been hijacked, then output a special line + if env['rack.hijack_io'] + log_hijacking(env, 'HIJACK', header, began_at) + else + ary = env['rack.after_reply'] + ary << lambda { log(env, status, header, began_at) } + end + + [status, header, body] + end + + private + + def log_hijacking(env, status, header, began_at) + now = Time.now + + msg = HIJACK_FORMAT % [ + env['HTTP_X_FORWARDED_FOR'] || env["REMOTE_ADDR"] || "-", + env["REMOTE_USER"] || "-", + now.strftime("%d/%b/%Y %H:%M:%S"), + env[REQUEST_METHOD], + env[PATH_INFO], + env[QUERY_STRING].empty? ? "" : "?#{env[QUERY_STRING]}", + env["HTTP_VERSION"], + now - began_at ] + + write(msg) + end + + def log(env, status, header, began_at) + now = Time.now + length = extract_content_length(header) + + msg = FORMAT % [ + env['HTTP_X_FORWARDED_FOR'] || env["REMOTE_ADDR"] || "-", + env["REMOTE_USER"] || "-", + now.strftime("%d/%b/%Y:%H:%M:%S %z"), + env[REQUEST_METHOD], + env[PATH_INFO], + env[QUERY_STRING].empty? ? "" : "?#{env[QUERY_STRING]}", + env["HTTP_VERSION"], + status.to_s[0..3], + length, + now - began_at ] + + write(msg) + end + + def write(msg) + logger = @logger || env['rack.errors'] + + # Standard library logger doesn't support write but it supports << which actually + # calls to write on the log device without formatting + if logger.respond_to?(:write) + logger.write(msg) + else + logger << msg + end + end + + def extract_content_length(headers) + value = headers[CONTENT_LENGTH] or return '-' + value.to_s == '0' ? '-' : value + end + end +end diff --git a/lib/puma/configuration.rb b/lib/puma/configuration.rb new file mode 100644 index 0000000..9871ff0 --- /dev/null +++ b/lib/puma/configuration.rb @@ -0,0 +1,371 @@ +# frozen_string_literal: true + +require 'puma/rack/builder' +require 'puma/plugin' +require 'puma/const' + +module Puma + + module ConfigDefault + DefaultRackup = "config.ru" + + DefaultTCPHost = "0.0.0.0" + DefaultTCPPort = 9292 + DefaultWorkerCheckInterval = 5 + DefaultWorkerTimeout = 60 + DefaultWorkerShutdownTimeout = 30 + end + + # A class used for storing "leveled" configuration options. + # + # In this class any "user" specified options take precedence over any + # "file" specified options, take precedence over any "default" options. + # + # User input is preferred over "defaults": + # user_options = { foo: "bar" } + # default_options = { foo: "zoo" } + # options = UserFileDefaultOptions.new(user_options, default_options) + # puts options[:foo] + # # => "bar" + # + # All values can be accessed via `all_of` + # + # puts options.all_of(:foo) + # # => ["bar", "zoo"] + # + # A "file" option can be set. This config will be preferred over "default" options + # but will defer to any available "user" specified options. + # + # user_options = { foo: "bar" } + # default_options = { rackup: "zoo.rb" } + # options = UserFileDefaultOptions.new(user_options, default_options) + # options.file_options[:rackup] = "sup.rb" + # puts options[:rackup] + # # => "sup.rb" + # + # The "default" options can be set via procs. These are resolved during runtime + # via calls to `finalize_values` + class UserFileDefaultOptions + def initialize(user_options, default_options) + @user_options = user_options + @file_options = {} + @default_options = default_options + end + + attr_reader :user_options, :file_options, :default_options + + def [](key) + fetch(key) + end + + def []=(key, value) + user_options[key] = value + end + + def fetch(key, default_value = nil) + return user_options[key] if user_options.key?(key) + return file_options[key] if file_options.key?(key) + return default_options[key] if default_options.key?(key) + + default_value + end + + def all_of(key) + user = user_options[key] + file = file_options[key] + default = default_options[key] + + user = [user] unless user.is_a?(Array) + file = [file] unless file.is_a?(Array) + default = [default] unless default.is_a?(Array) + + user.compact! + file.compact! + default.compact! + + user + file + default + end + + def finalize_values + @default_options.each do |k,v| + if v.respond_to? :call + @default_options[k] = v.call + end + end + end + + def final_options + default_options + .merge(file_options) + .merge(user_options) + end + end + + # The main configuration class of Puma. + # + # It can be initialized with a set of "user" options and "default" options. + # Defaults will be merged with `Configuration.puma_default_options`. + # + # This class works together with 2 main other classes the `UserFileDefaultOptions` + # which stores configuration options in order so the precedence is that user + # set configuration wins over "file" based configuration wins over "default" + # configuration. These configurations are set via the `DSL` class. This + # class powers the Puma config file syntax and does double duty as a configuration + # DSL used by the `Puma::CLI` and Puma rack handler. + # + # It also handles loading plugins. + # + # [Note:] + # `:port` and `:host` are not valid keys. By the time they make it to the + # configuration options they are expected to be incorporated into a `:binds` key. + # Under the hood the DSL maps `port` and `host` calls to `:binds` + # + # config = Configuration.new({}) do |user_config, file_config, default_config| + # user_config.port 3003 + # end + # config.load + # puts config.options[:port] + # # => 3003 + # + # It is expected that `load` is called on the configuration instance after setting + # config. This method expands any values in `config_file` and puts them into the + # correct configuration option hash. + # + # Once all configuration is complete it is expected that `clamp` will be called + # on the instance. This will expand any procs stored under "default" values. This + # is done because an environment variable may have been modified while loading + # configuration files. + class Configuration + include ConfigDefault + + def initialize(user_options={}, default_options = {}, &block) + default_options = self.puma_default_options.merge(default_options) + + @options = UserFileDefaultOptions.new(user_options, default_options) + @plugins = PluginLoader.new + @user_dsl = DSL.new(@options.user_options, self) + @file_dsl = DSL.new(@options.file_options, self) + @default_dsl = DSL.new(@options.default_options, self) + + if !@options[:prune_bundler] + default_options[:preload_app] = (@options[:workers] > 1) && Puma.forkable? + end + + if block + configure(&block) + end + end + + attr_reader :options, :plugins + + def configure + yield @user_dsl, @file_dsl, @default_dsl + ensure + @user_dsl._offer_plugins + @file_dsl._offer_plugins + @default_dsl._offer_plugins + end + + def initialize_copy(other) + @conf = nil + @cli_options = nil + @options = @options.dup + end + + def flatten + dup.flatten! + end + + def flatten! + @options = @options.flatten + self + end + + # @version 5.0.0 + def default_max_threads + Puma.mri? ? 5 : 16 + end + + def puma_default_options + { + :min_threads => Integer(ENV['PUMA_MIN_THREADS'] || ENV['MIN_THREADS'] || 0), + :max_threads => Integer(ENV['PUMA_MAX_THREADS'] || ENV['MAX_THREADS'] || default_max_threads), + :log_requests => false, + :debug => false, + :binds => ["tcp://#{DefaultTCPHost}:#{DefaultTCPPort}"], + :workers => Integer(ENV['WEB_CONCURRENCY'] || 0), + :silence_single_worker_warning => false, + :mode => :http, + :worker_check_interval => DefaultWorkerCheckInterval, + :worker_timeout => DefaultWorkerTimeout, + :worker_boot_timeout => DefaultWorkerTimeout, + :worker_shutdown_timeout => DefaultWorkerShutdownTimeout, + :worker_culling_strategy => :youngest, + :remote_address => :socket, + :tag => method(:infer_tag), + :environment => -> { ENV['APP_ENV'] || ENV['RACK_ENV'] || ENV['RAILS_ENV'] || 'development' }, + :rackup => DefaultRackup, + :logger => STDOUT, + :persistent_timeout => Const::PERSISTENT_TIMEOUT, + :first_data_timeout => Const::FIRST_DATA_TIMEOUT, + :raise_exception_on_sigterm => true, + :max_fast_inline => Const::MAX_FAST_INLINE, + :io_selector_backend => :auto, + :mutate_stdout_and_stderr_to_sync_on_write => true, + } + end + + def load + config_files.each { |config_file| @file_dsl._load_from(config_file) } + + @options + end + + def config_files + files = @options.all_of(:config_files) + + return [] if files == ['-'] + return files if files.any? + + first_default_file = %W(config/puma/#{environment_str}.rb config/puma.rb).find do |f| + File.exist?(f) + end + + [first_default_file] + end + + # Call once all configuration (included from rackup files) + # is loaded to flesh out any defaults + def clamp + @options.finalize_values + end + + # Injects the Configuration object into the env + class ConfigMiddleware + def initialize(config, app) + @config = config + @app = app + end + + def call(env) + env[Const::PUMA_CONFIG] = @config + @app.call(env) + end + end + + # Indicate if there is a properly configured app + # + def app_configured? + @options[:app] || File.exist?(rackup) + end + + def rackup + @options[:rackup] + end + + # Load the specified rackup file, pull options from + # the rackup file, and set @app. + # + def app + found = options[:app] || load_rackup + + if @options[:log_requests] + require 'puma/commonlogger' + logger = @options[:logger] + found = CommonLogger.new(found, logger) + end + + ConfigMiddleware.new(self, found) + end + + # Return which environment we're running in + def environment + @options[:environment] + end + + def environment_str + environment.respond_to?(:call) ? environment.call : environment + end + + def load_plugin(name) + @plugins.create name + end + + def run_hooks(key, arg, events) + @options.all_of(key).each do |b| + begin + b.call arg + rescue => e + events.log "WARNING hook #{key} failed with exception (#{e.class}) #{e.message}" + events.debug e.backtrace.join("\n") + end + end + end + + def final_options + @options.final_options + end + + def self.temp_path + require 'tmpdir' + + t = (Time.now.to_f * 1000).to_i + "#{Dir.tmpdir}/puma-status-#{t}-#{$$}" + end + + private + + def infer_tag + File.basename(Dir.getwd) + end + + # Load and use the normal Rack builder if we can, otherwise + # fallback to our minimal version. + def rack_builder + # Load bundler now if we can so that we can pickup rack from + # a Gemfile + if ENV.key? 'PUMA_BUNDLER_PRUNED' + begin + require 'bundler/setup' + rescue LoadError + end + end + + begin + require 'rack' + require 'rack/builder' + rescue LoadError + # ok, use builtin version + return Puma::Rack::Builder + else + return ::Rack::Builder + end + end + + def load_rackup + raise "Missing rackup file '#{rackup}'" unless File.exist?(rackup) + + rack_app, rack_options = rack_builder.parse_file(rackup) + rack_options = rack_options || {} + + @options.file_options.merge!(rack_options) + + config_ru_binds = [] + rack_options.each do |k, v| + config_ru_binds << v if k.to_s.start_with?("bind") + end + + @options.file_options[:binds] = config_ru_binds unless config_ru_binds.empty? + + rack_app + end + + def self.random_token + require 'securerandom' unless defined?(SecureRandom) + + SecureRandom.hex(16) + end + end +end + +require 'puma/dsl' diff --git a/lib/puma/const.rb b/lib/puma/const.rb new file mode 100644 index 0000000..a8eb1be --- /dev/null +++ b/lib/puma/const.rb @@ -0,0 +1,252 @@ +#encoding: utf-8 +# frozen_string_literal: true + +module Puma + class UnsupportedOption < RuntimeError + end + + + # Every standard HTTP code mapped to the appropriate message. These are + # used so frequently that they are placed directly in Puma for easy + # access rather than Puma::Const itself. + + # Every standard HTTP code mapped to the appropriate message. + # Generated with: + # curl -s https://www.iana.org/assignments/http-status-codes/http-status-codes-1.csv | \ + # ruby -ne 'm = /^(\d{3}),(?!Unassigned|\(Unused\))([^,]+)/.match($_) and \ + # puts "#{m[1]} => \x27#{m[2].strip}\x27,"' + HTTP_STATUS_CODES = { + 100 => 'Continue', + 101 => 'Switching Protocols', + 102 => 'Processing', + 200 => 'OK', + 201 => 'Created', + 202 => 'Accepted', + 203 => 'Non-Authoritative Information', + 204 => 'No Content', + 205 => 'Reset Content', + 206 => 'Partial Content', + 207 => 'Multi-Status', + 208 => 'Already Reported', + 226 => 'IM Used', + 300 => 'Multiple Choices', + 301 => 'Moved Permanently', + 302 => 'Found', + 303 => 'See Other', + 304 => 'Not Modified', + 305 => 'Use Proxy', + 307 => 'Temporary Redirect', + 308 => 'Permanent Redirect', + 400 => 'Bad Request', + 401 => 'Unauthorized', + 402 => 'Payment Required', + 403 => 'Forbidden', + 404 => 'Not Found', + 405 => 'Method Not Allowed', + 406 => 'Not Acceptable', + 407 => 'Proxy Authentication Required', + 408 => 'Request Timeout', + 409 => 'Conflict', + 410 => 'Gone', + 411 => 'Length Required', + 412 => 'Precondition Failed', + 413 => 'Payload Too Large', + 414 => 'URI Too Long', + 415 => 'Unsupported Media Type', + 416 => 'Range Not Satisfiable', + 417 => 'Expectation Failed', + 418 => 'I\'m A Teapot', + 421 => 'Misdirected Request', + 422 => 'Unprocessable Entity', + 423 => 'Locked', + 424 => 'Failed Dependency', + 426 => 'Upgrade Required', + 428 => 'Precondition Required', + 429 => 'Too Many Requests', + 431 => 'Request Header Fields Too Large', + 451 => 'Unavailable For Legal Reasons', + 500 => 'Internal Server Error', + 501 => 'Not Implemented', + 502 => 'Bad Gateway', + 503 => 'Service Unavailable', + 504 => 'Gateway Timeout', + 505 => 'HTTP Version Not Supported', + 506 => 'Variant Also Negotiates', + 507 => 'Insufficient Storage', + 508 => 'Loop Detected', + 510 => 'Not Extended', + 511 => 'Network Authentication Required' + }.freeze + + # For some HTTP status codes the client only expects headers. + # + + STATUS_WITH_NO_ENTITY_BODY = { + 204 => true, + 205 => true, + 304 => true + }.freeze + + # Frequently used constants when constructing requests or responses. Many times + # the constant just refers to a string with the same contents. Using these constants + # gave about a 3% to 10% performance improvement over using the strings directly. + # + # The constants are frozen because Hash#[]= when called with a String key dups + # the String UNLESS the String is frozen. This saves us therefore 2 object + # allocations when creating the env hash later. + # + # While Puma does try to emulate the CGI/1.2 protocol, it does not use the REMOTE_IDENT, + # REMOTE_USER, or REMOTE_HOST parameters since those are either a security problem or + # too taxing on performance. + module Const + + PUMA_VERSION = VERSION = "5.6.4".freeze + CODE_NAME = "Birdie's Version".freeze + + PUMA_SERVER_STRING = ['puma', PUMA_VERSION, CODE_NAME].join(' ').freeze + + FAST_TRACK_KA_TIMEOUT = 0.2 + + # The default number of seconds for another request within a persistent + # session. + PERSISTENT_TIMEOUT = 20 + + # The default number of seconds to wait until we get the first data + # for the request + FIRST_DATA_TIMEOUT = 30 + + # How long to wait when getting some write blocking on the socket when + # sending data back + WRITE_TIMEOUT = 10 + + # How many requests to attempt inline before sending a client back to + # the reactor to be subject to normal ordering. The idea here is that + # we amortize the cost of going back to the reactor for a well behaved + # but very "greedy" client across 10 requests. This prevents a not + # well behaved client from monopolizing the thread forever. + MAX_FAST_INLINE = 10 + + # The original URI requested by the client. + REQUEST_URI= 'REQUEST_URI'.freeze + REQUEST_PATH = 'REQUEST_PATH'.freeze + QUERY_STRING = 'QUERY_STRING'.freeze + CONTENT_LENGTH = "CONTENT_LENGTH".freeze + + PATH_INFO = 'PATH_INFO'.freeze + + PUMA_TMP_BASE = "puma".freeze + + ERROR_RESPONSE = { + # Indicate that we couldn't parse the request + 400 => "HTTP/1.1 400 Bad Request\r\n\r\n".freeze, + # The standard empty 404 response for bad requests. Use Error4040Handler for custom stuff. + 404 => "HTTP/1.1 404 Not Found\r\nConnection: close\r\nServer: Puma #{PUMA_VERSION}\r\n\r\nNOT FOUND".freeze, + # The standard empty 408 response for requests that timed out. + 408 => "HTTP/1.1 408 Request Timeout\r\nConnection: close\r\nServer: Puma #{PUMA_VERSION}\r\n\r\n".freeze, + # Indicate that there was an internal error, obviously. + 500 => "HTTP/1.1 500 Internal Server Error\r\n\r\n".freeze, + # Incorrect or invalid header value + 501 => "HTTP/1.1 501 Not Implemented\r\n\r\n".freeze, + # A common header for indicating the server is too busy. Not used yet. + 503 => "HTTP/1.1 503 Service Unavailable\r\n\r\nBUSY".freeze + }.freeze + + # The basic max request size we'll try to read. + CHUNK_SIZE = 16 * 1024 + + # This is the maximum header that is allowed before a client is booted. The parser detects + # this, but we'd also like to do this as well. + MAX_HEADER = 1024 * (80 + 32) + + # Maximum request body size before it is moved out of memory and into a tempfile for reading. + MAX_BODY = MAX_HEADER + + REQUEST_METHOD = "REQUEST_METHOD".freeze + HEAD = "HEAD".freeze + # ETag is based on the apache standard of hex mtime-size-inode (inode is 0 on win32) + LINE_END = "\r\n".freeze + REMOTE_ADDR = "REMOTE_ADDR".freeze + HTTP_X_FORWARDED_FOR = "HTTP_X_FORWARDED_FOR".freeze + HTTP_X_FORWARDED_SSL = "HTTP_X_FORWARDED_SSL".freeze + HTTP_X_FORWARDED_SCHEME = "HTTP_X_FORWARDED_SCHEME".freeze + HTTP_X_FORWARDED_PROTO = "HTTP_X_FORWARDED_PROTO".freeze + + SERVER_NAME = "SERVER_NAME".freeze + SERVER_PORT = "SERVER_PORT".freeze + HTTP_HOST = "HTTP_HOST".freeze + PORT_80 = "80".freeze + PORT_443 = "443".freeze + LOCALHOST = "localhost".freeze + LOCALHOST_IP = "127.0.0.1".freeze + + SERVER_PROTOCOL = "SERVER_PROTOCOL".freeze + HTTP_11 = "HTTP/1.1".freeze + + SERVER_SOFTWARE = "SERVER_SOFTWARE".freeze + GATEWAY_INTERFACE = "GATEWAY_INTERFACE".freeze + CGI_VER = "CGI/1.2".freeze + + STOP_COMMAND = "?".freeze + HALT_COMMAND = "!".freeze + RESTART_COMMAND = "R".freeze + + RACK_INPUT = "rack.input".freeze + RACK_URL_SCHEME = "rack.url_scheme".freeze + RACK_AFTER_REPLY = "rack.after_reply".freeze + PUMA_SOCKET = "puma.socket".freeze + PUMA_CONFIG = "puma.config".freeze + PUMA_PEERCERT = "puma.peercert".freeze + + HTTP = "http".freeze + HTTPS = "https".freeze + + HTTPS_KEY = "HTTPS".freeze + + HTTP_VERSION = "HTTP_VERSION".freeze + HTTP_CONNECTION = "HTTP_CONNECTION".freeze + HTTP_EXPECT = "HTTP_EXPECT".freeze + CONTINUE = "100-continue".freeze + + HTTP_11_100 = "HTTP/1.1 100 Continue\r\n\r\n".freeze + HTTP_11_200 = "HTTP/1.1 200 OK\r\n".freeze + HTTP_10_200 = "HTTP/1.0 200 OK\r\n".freeze + + CLOSE = "close".freeze + KEEP_ALIVE = "keep-alive".freeze + + CONTENT_LENGTH2 = "content-length".freeze + CONTENT_LENGTH_S = "Content-Length: ".freeze + TRANSFER_ENCODING = "transfer-encoding".freeze + TRANSFER_ENCODING2 = "HTTP_TRANSFER_ENCODING".freeze + + CONNECTION_CLOSE = "Connection: close\r\n".freeze + CONNECTION_KEEP_ALIVE = "Connection: Keep-Alive\r\n".freeze + + TRANSFER_ENCODING_CHUNKED = "Transfer-Encoding: chunked\r\n".freeze + CLOSE_CHUNKED = "0\r\n\r\n".freeze + + CHUNKED = "chunked".freeze + + COLON = ": ".freeze + + NEWLINE = "\n".freeze + + HIJACK_P = "rack.hijack?".freeze + HIJACK = "rack.hijack".freeze + HIJACK_IO = "rack.hijack_io".freeze + + EARLY_HINTS = "rack.early_hints".freeze + + # Illegal character in the key or value of response header + DQUOTE = "\"".freeze + HTTP_HEADER_DELIMITER = Regexp.escape("(),/:;<=>?@[]{}\\").freeze + ILLEGAL_HEADER_KEY_REGEX = /[\x00-\x20#{DQUOTE}#{HTTP_HEADER_DELIMITER}]/.freeze + # header values can contain HTAB? + ILLEGAL_HEADER_VALUE_REGEX = /[\x00-\x08\x0A-\x1F]/.freeze + + # Banned keys of response header + BANNED_HEADER_KEY = /\A(rack\.|status\z)/.freeze + + PROXY_PROTOCOL_V1_REGEX = /^PROXY (?:TCP4|TCP6|UNKNOWN) ([^\r]+)\r\n/.freeze + end +end diff --git a/lib/puma/control_cli.rb b/lib/puma/control_cli.rb new file mode 100644 index 0000000..efa3a86 --- /dev/null +++ b/lib/puma/control_cli.rb @@ -0,0 +1,306 @@ +# frozen_string_literal: true + +require 'optparse' +require_relative 'state_file' +require_relative 'const' +require_relative 'detect' +require_relative 'configuration' +require 'uri' +require 'socket' + +module Puma + class ControlCLI + + # values must be string or nil + # value of `nil` means command cannot be processed via signal + # @version 5.0.3 + CMD_PATH_SIG_MAP = { + 'gc' => nil, + 'gc-stats' => nil, + 'halt' => 'SIGQUIT', + 'phased-restart' => 'SIGUSR1', + 'refork' => 'SIGURG', + 'reload-worker-directory' => nil, + 'restart' => 'SIGUSR2', + 'start' => nil, + 'stats' => nil, + 'status' => '', + 'stop' => 'SIGTERM', + 'thread-backtraces' => nil + }.freeze + + # @deprecated 6.0.0 + COMMANDS = CMD_PATH_SIG_MAP.keys.freeze + + # commands that cannot be used in a request + NO_REQ_COMMANDS = %w{refork}.freeze + + # @version 5.0.0 + PRINTABLE_COMMANDS = %w{gc-stats stats thread-backtraces}.freeze + + def initialize(argv, stdout=STDOUT, stderr=STDERR) + @state = nil + @quiet = false + @pidfile = nil + @pid = nil + @control_url = nil + @control_auth_token = nil + @config_file = nil + @command = nil + @environment = ENV['APP_ENV'] || ENV['RACK_ENV'] || ENV['RAILS_ENV'] + + @argv = argv.dup + @stdout = stdout + @stderr = stderr + @cli_options = {} + + opts = OptionParser.new do |o| + o.banner = "Usage: pumactl (-p PID | -P pidfile | -S status_file | -C url -T token | -F config.rb) (#{CMD_PATH_SIG_MAP.keys.join("|")})" + + o.on "-S", "--state PATH", "Where the state file to use is" do |arg| + @state = arg + end + + o.on "-Q", "--quiet", "Not display messages" do |arg| + @quiet = true + end + + o.on "-P", "--pidfile PATH", "Pid file" do |arg| + @pidfile = arg + end + + o.on "-p", "--pid PID", "Pid" do |arg| + @pid = arg.to_i + end + + o.on "-C", "--control-url URL", "The bind url to use for the control server" do |arg| + @control_url = arg + end + + o.on "-T", "--control-token TOKEN", "The token to use as authentication for the control server" do |arg| + @control_auth_token = arg + end + + o.on "-F", "--config-file PATH", "Puma config script" do |arg| + @config_file = arg + end + + o.on "-e", "--environment ENVIRONMENT", + "The environment to run the Rack app on (default development)" do |arg| + @environment = arg + end + + o.on_tail("-H", "--help", "Show this message") do + @stdout.puts o + exit + end + + o.on_tail("-V", "--version", "Show version") do + @stdout.puts Const::PUMA_VERSION + exit + end + end + + opts.order!(argv) { |a| opts.terminate a } + opts.parse! + + @command = argv.shift + + # check presence of command + unless @command + raise "Available commands: #{CMD_PATH_SIG_MAP.keys.join(", ")}" + end + + unless CMD_PATH_SIG_MAP.key? @command + raise "Invalid command: #{@command}" + end + + unless @config_file == '-' + environment = @environment || 'development' + + if @config_file.nil? + @config_file = %W(config/puma/#{environment}.rb config/puma.rb).find do |f| + File.exist?(f) + end + end + + if @config_file + config = Puma::Configuration.new({ config_files: [@config_file] }, {}) + config.load + @state ||= config.options[:state] + @control_url ||= config.options[:control_url] + @control_auth_token ||= config.options[:control_auth_token] + @pidfile ||= config.options[:pidfile] + end + end + rescue => e + @stdout.puts e.message + exit 1 + end + + def message(msg) + @stdout.puts msg unless @quiet + end + + def prepare_configuration + if @state + unless File.exist? @state + raise "State file not found: #{@state}" + end + + sf = Puma::StateFile.new + sf.load @state + + @control_url = sf.control_url + @control_auth_token = sf.control_auth_token + @pid = sf.pid + elsif @pidfile + # get pid from pid_file + @pid = File.read(@pidfile, mode: 'rb:UTF-8').to_i + end + end + + def send_request + uri = URI.parse @control_url + + # create server object by scheme + server = + case uri.scheme + when 'ssl' + require 'openssl' + OpenSSL::SSL::SSLSocket.new( + TCPSocket.new(uri.host, uri.port), + OpenSSL::SSL::SSLContext.new) + .tap { |ssl| ssl.sync_close = true } # default is false + .tap(&:connect) + when 'tcp' + TCPSocket.new uri.host, uri.port + when 'unix' + # check for abstract UNIXSocket + UNIXSocket.new(@control_url.start_with?('unix://@') ? + "\0#{uri.host}#{uri.path}" : "#{uri.host}#{uri.path}") + else + raise "Invalid scheme: #{uri.scheme}" + end + + if @command == 'status' + message 'Puma is started' + elsif NO_REQ_COMMANDS.include? @command + raise "Invalid request command: #{@command}" + else + url = "/#{@command}" + + if @control_auth_token + url = url + "?token=#{@control_auth_token}" + end + + server.syswrite "GET #{url} HTTP/1.0\r\n\r\n" + + unless data = server.read + raise 'Server closed connection before responding' + end + + response = data.split("\r\n") + + if response.empty? + raise "Server sent empty response" + end + + @http, @code, @message = response.first.split(' ',3) + + if @code == '403' + raise 'Unauthorized access to server (wrong auth token)' + elsif @code == '404' + raise "Command error: #{response.last}" + elsif @code != '200' + raise "Bad response from server: #{@code}" + end + + message "Command #{@command} sent success" + message response.last if PRINTABLE_COMMANDS.include?(@command) + end + ensure + if server + if uri.scheme == 'ssl' + server.sysclose + else + server.close unless server.closed? + end + end + end + + def send_signal + unless @pid + raise 'Neither pid nor control url available' + end + + begin + sig = CMD_PATH_SIG_MAP[@command] + + if sig.nil? + @stdout.puts "'#{@command}' not available via pid only" + @stdout.flush unless @stdout.sync + return + elsif sig.start_with? 'SIG' + Process.kill sig, @pid + elsif @command == 'status' + begin + Process.kill 0, @pid + @stdout.puts 'Puma is started' + @stdout.flush unless @stdout.sync + rescue Errno::ESRCH + raise 'Puma is not running' + end + return + end + rescue SystemCallError + if @command == 'restart' + start + else + raise "No pid '#{@pid}' found" + end + end + + message "Command #{@command} sent success" + end + + def run + return start if @command == 'start' + prepare_configuration + + if Puma.windows? || @control_url + send_request + else + send_signal + end + + rescue => e + message e.message + exit 1 + end + + private + def start + require 'puma/cli' + + run_args = [] + + run_args += ["-S", @state] if @state + run_args += ["-q"] if @quiet + run_args += ["--pidfile", @pidfile] if @pidfile + run_args += ["--control-url", @control_url] if @control_url + run_args += ["--control-token", @control_auth_token] if @control_auth_token + run_args += ["-C", @config_file] if @config_file + run_args += ["-e", @environment] if @environment + + events = Puma::Events.new @stdout, @stderr + + # replace $0 because puma use it to generate restart command + puma_cmd = $0.gsub(/pumactl$/, 'puma') + $0 = puma_cmd if File.exist?(puma_cmd) + + cli = Puma::CLI.new run_args, events + cli.run + end + end +end diff --git a/lib/puma/detect.rb b/lib/puma/detect.rb new file mode 100644 index 0000000..565ed00 --- /dev/null +++ b/lib/puma/detect.rb @@ -0,0 +1,42 @@ +# frozen_string_literal: true + +# This file can be loaded independently of puma.rb, so it cannot have any code +# that assumes puma.rb is loaded. + + +module Puma + # @version 5.2.1 + HAS_FORK = ::Process.respond_to? :fork + + IS_JRUBY = Object.const_defined? :JRUBY_VERSION + + IS_OSX = RUBY_PLATFORM.include? 'darwin' + + IS_WINDOWS = !!(RUBY_PLATFORM =~ /mswin|ming|cygwin/) || + IS_JRUBY && RUBY_DESCRIPTION.include?('mswin') + + # @version 5.2.0 + IS_MRI = (RUBY_ENGINE == 'ruby' || RUBY_ENGINE.nil?) + + def self.jruby? + IS_JRUBY + end + + def self.osx? + IS_OSX + end + + def self.windows? + IS_WINDOWS + end + + # @version 5.0.0 + def self.mri? + IS_MRI + end + + # @version 5.0.0 + def self.forkable? + HAS_FORK + end +end diff --git a/lib/puma/dsl.rb b/lib/puma/dsl.rb new file mode 100644 index 0000000..3140e54 --- /dev/null +++ b/lib/puma/dsl.rb @@ -0,0 +1,1008 @@ +# frozen_string_literal: true + +require 'puma/const' + +module Puma + # The methods that are available for use inside the configuration file. + # These same methods are used in Puma cli and the rack handler + # internally. + # + # Used manually (via CLI class): + # + # config = Configuration.new({}) do |user_config| + # user_config.port 3001 + # end + # config.load + # + # puts config.options[:binds] # => "tcp://127.0.0.1:3001" + # + # Used to load file: + # + # $ cat puma_config.rb + # port 3002 + # + # Resulting configuration: + # + # config = Configuration.new(config_file: "puma_config.rb") + # config.load + # + # puts config.options[:binds] # => "tcp://127.0.0.1:3002" + # + # You can also find many examples being used by the test suite in + # +test/config+. + # + class DSL + include ConfigDefault + + # convenience method so logic can be used in CI + # @see ssl_bind + # + def self.ssl_bind_str(host, port, opts) + verify = opts.fetch(:verify_mode, 'none').to_s + + tls_str = + if opts[:no_tlsv1_1] then '&no_tlsv1_1=true' + elsif opts[:no_tlsv1] then '&no_tlsv1=true' + else '' + end + + ca_additions = "&ca=#{opts[:ca]}" if ['peer', 'force_peer'].include?(verify) + + backlog_str = opts[:backlog] ? "&backlog=#{Integer(opts[:backlog])}" : '' + + if defined?(JRUBY_VERSION) + ssl_cipher_list = opts[:ssl_cipher_list] ? + "&ssl_cipher_list=#{opts[:ssl_cipher_list]}" : nil + + keystore_additions = "keystore=#{opts[:keystore]}&keystore-pass=#{opts[:keystore_pass]}" + + "ssl://#{host}:#{port}?#{keystore_additions}#{ssl_cipher_list}" \ + "&verify_mode=#{verify}#{tls_str}#{ca_additions}#{backlog_str}" + else + ssl_cipher_filter = opts[:ssl_cipher_filter] ? + "&ssl_cipher_filter=#{opts[:ssl_cipher_filter]}" : nil + + v_flags = (ary = opts[:verification_flags]) ? + "&verification_flags=#{Array(ary).join ','}" : nil + + "ssl://#{host}:#{port}?cert=#{opts[:cert]}&key=#{opts[:key]}" \ + "#{ssl_cipher_filter}&verify_mode=#{verify}#{tls_str}#{ca_additions}#{v_flags}#{backlog_str}" + end + end + + def initialize(options, config) + @config = config + @options = options + + @plugins = [] + end + + def _load_from(path) + if path + @path = path + instance_eval(File.read(path), path, 1) + end + ensure + _offer_plugins + end + + def _offer_plugins + @plugins.each do |o| + if o.respond_to? :config + @options.shift + o.config self + end + end + + @plugins.clear + end + + def set_default_host(host) + @options[:default_host] = host + end + + def default_host + @options[:default_host] || Configuration::DefaultTCPHost + end + + def inject(&blk) + instance_eval(&blk) + end + + def get(key,default=nil) + @options[key.to_sym] || default + end + + # Load the named plugin for use by this configuration + # + def plugin(name) + @plugins << @config.load_plugin(name) + end + + # Use an object or block as the rack application. This allows the + # configuration file to be the application itself. + # + # @example + # app do |env| + # body = 'Hello, World!' + # + # [ + # 200, + # { + # 'Content-Type' => 'text/plain', + # 'Content-Length' => body.length.to_s + # }, + # [body] + # ] + # end + # + # @see Puma::Configuration#app + # + def app(obj=nil, &block) + obj ||= block + + raise "Provide either a #call'able or a block" unless obj + + @options[:app] = obj + end + + # Start the Puma control rack application on +url+. This application can + # be communicated with to control the main server. Additionally, you can + # provide an authentication token, so all requests to the control server + # will need to include that token as a query parameter. This allows for + # simple authentication. + # + # Check out {Puma::App::Status} to see what the app has available. + # + # @example + # activate_control_app 'unix:///var/run/pumactl.sock' + # @example + # activate_control_app 'unix:///var/run/pumactl.sock', { auth_token: '12345' } + # @example + # activate_control_app 'unix:///var/run/pumactl.sock', { no_token: true } + def activate_control_app(url="auto", opts={}) + if url == "auto" + path = Configuration.temp_path + @options[:control_url] = "unix://#{path}" + @options[:control_url_temp] = path + else + @options[:control_url] = url + end + + if opts[:no_token] + # We need to use 'none' rather than :none because this value will be + # passed on to an instance of OptionParser, which doesn't support + # symbols as option values. + # + # See: https://github.com/puma/puma/issues/1193#issuecomment-305995488 + auth_token = 'none' + else + auth_token = opts[:auth_token] + auth_token ||= Configuration.random_token + end + + @options[:control_auth_token] = auth_token + @options[:control_url_umask] = opts[:umask] if opts[:umask] + end + + # Load additional configuration from a file + # Files get loaded later via Configuration#load + def load(file) + @options[:config_files] ||= [] + @options[:config_files] << file + end + + # Bind the server to +url+. "tcp://", "unix://" and "ssl://" are the only + # accepted protocols. Multiple urls can be bound to, calling +bind+ does + # not overwrite previous bindings. + # + # The default is "tcp://0.0.0.0:9292". + # + # You can use query parameters within the url to specify options: + # + # * Set the socket backlog depth with +backlog+, default is 1024. + # * Set up an SSL certificate with +key+ & +cert+. + # * Set whether to optimize for low latency instead of throughput with + # +low_latency+, default is to not optimize for low latency. This is done + # via +Socket::TCP_NODELAY+. + # * Set socket permissions with +umask+. + # + # @example Backlog depth + # bind 'unix:///var/run/puma.sock?backlog=512' + # @example SSL cert + # bind 'ssl://127.0.0.1:9292?key=key.key&cert=cert.pem' + # @example Disable optimization for low latency + # bind 'tcp://0.0.0.0:9292?low_latency=false' + # @example Socket permissions + # bind 'unix:///var/run/puma.sock?umask=0111' + # @see Puma::Runner#load_and_bind + # @see Puma::Cluster#run + # + def bind(url) + @options[:binds] ||= [] + @options[:binds] << url + end + + def clear_binds! + @options[:binds] = [] + end + + # Bind to (systemd) activated sockets, regardless of configured binds. + # + # Systemd can present sockets as file descriptors that are already opened. + # By default Puma will use these but only if it was explicitly told to bind + # to the socket. If not, it will close the activated sockets. This means + # all configuration is duplicated. + # + # Binds can contain additional configuration, but only SSL config is really + # relevant since the unix and TCP socket options are ignored. + # + # This means there is a lot of duplicated configuration for no additional + # value in most setups. This method tells the launcher to bind to all + # activated sockets, regardless of existing bind. + # + # To clear configured binds, the value only can be passed. This will clear + # out any binds that may have been configured. + # + # @example Use any systemd activated sockets as well as configured binds + # bind_to_activated_sockets + # + # @example Only bind to systemd activated sockets, ignoring other binds + # bind_to_activated_sockets 'only' + def bind_to_activated_sockets(bind=true) + @options[:bind_to_activated_sockets] = bind + end + + # Define the TCP port to bind to. Use +bind+ for more advanced options. + # + # @example + # port 9292 + def port(port, host=nil) + host ||= default_host + bind URI::Generic.build(scheme: 'tcp', host: host, port: Integer(port)).to_s + end + + # Define how long persistent connections can be idle before Puma closes them. + # @see Puma::Server.new + def persistent_timeout(seconds) + @options[:persistent_timeout] = Integer(seconds) + end + + # Define how long the tcp socket stays open, if no data has been received. + # @see Puma::Server.new + def first_data_timeout(seconds) + @options[:first_data_timeout] = Integer(seconds) + end + + # Work around leaky apps that leave garbage in Thread locals + # across requests. + def clean_thread_locals(which=true) + @options[:clean_thread_locals] = which + end + + # When shutting down, drain the accept socket of pending connections and + # process them. This loops over the accept socket until there are no more + # read events and then stops looking and waits for the requests to finish. + # @see Puma::Server#graceful_shutdown + # + def drain_on_shutdown(which=true) + @options[:drain_on_shutdown] = which + end + + # Set the environment in which the rack's app will run. The value must be + # a string. + # + # The default is "development". + # + # @example + # environment 'production' + def environment(environment) + @options[:environment] = environment + end + + # How long to wait for threads to stop when shutting them + # down. Defaults to :forever. Specifying :immediately will cause + # Puma to kill the threads immediately. Otherwise the value + # is the number of seconds to wait. + # + # Puma always waits a few seconds after killing a thread for it to try + # to finish up it's work, even in :immediately mode. + # @see Puma::Server#graceful_shutdown + def force_shutdown_after(val=:forever) + i = case val + when :forever + -1 + when :immediately + 0 + else + Float(val) + end + + @options[:force_shutdown_after] = i + end + + # Code to run before doing a restart. This code should + # close log files, database connections, etc. + # + # This can be called multiple times to add code each time. + # + # @example + # on_restart do + # puts 'On restart...' + # end + def on_restart(&block) + @options[:on_restart] ||= [] + @options[:on_restart] << block + end + + # Command to use to restart Puma. This should be just how to + # load Puma itself (ie. 'ruby -Ilib bin/puma'), not the arguments + # to Puma, as those are the same as the original process. + # + # @example + # restart_command '/u/app/lolcat/bin/restart_puma' + def restart_command(cmd) + @options[:restart_cmd] = cmd.to_s + end + + # Store the pid of the server in the file at "path". + # + # @example + # pidfile '/u/apps/lolcat/tmp/pids/puma.pid' + def pidfile(path) + @options[:pidfile] = path.to_s + end + + # Disable request logging, if this isn't used it'll be enabled by default. + # + # @example + # quiet + def quiet(which=true) + @options[:log_requests] = !which + end + + # Enable request logging + # + def log_requests(which=true) + @options[:log_requests] = which + end + + # Show debugging info + # + def debug + @options[:debug] = true + end + + # Load +path+ as a rackup file. + # + # The default is "config.ru". + # + # @example + # rackup '/u/apps/lolcat/config.ru' + def rackup(path) + @options[:rackup] ||= path.to_s + end + + # Allows setting `env['rack.url_scheme']`. + # Only necessary if X-Forwarded-Proto is not being set by your proxy + # Normal values are 'http' or 'https'. + def rack_url_scheme(scheme=nil) + @options[:rack_url_scheme] = scheme + end + + def early_hints(answer=true) + @options[:early_hints] = answer + end + + # Redirect +STDOUT+ and +STDERR+ to files specified. The +append+ parameter + # specifies whether the output is appended, the default is +false+. + # + # @example + # stdout_redirect '/app/lolcat/log/stdout', '/app/lolcat/log/stderr' + # @example + # stdout_redirect '/app/lolcat/log/stdout', '/app/lolcat/log/stderr', true + def stdout_redirect(stdout=nil, stderr=nil, append=false) + @options[:redirect_stdout] = stdout + @options[:redirect_stderr] = stderr + @options[:redirect_append] = append + end + + def log_formatter(&block) + @options[:log_formatter] = block + end + + # Configure +min+ to be the minimum number of threads to use to answer + # requests and +max+ the maximum. + # + # The default is the environment variables +PUMA_MIN_THREADS+ / +PUMA_MAX_THREADS+ + # (or +MIN_THREADS+ / +MAX_THREADS+ if the +PUMA_+ variables aren't set). + # + # If these environment variables aren't set, the default is "0, 5" in MRI or "0, 16" for other interpreters. + # + # @example + # threads 0, 16 + # @example + # threads 5, 5 + def threads(min, max) + min = Integer(min) + max = Integer(max) + if min > max + raise "The minimum (#{min}) number of threads must be less than or equal to the max (#{max})" + end + + if max < 1 + raise "The maximum number of threads (#{max}) must be greater than 0" + end + + @options[:min_threads] = min + @options[:max_threads] = max + end + + # Instead of using +bind+ and manually constructing a URI like: + # + # bind 'ssl://127.0.0.1:9292?key=key_path&cert=cert_path' + # + # you can use the this method. + # + # When binding on localhost you don't need to specify +cert+ and +key+, + # Puma will assume you are using the +localhost+ gem and try to load the + # appropriate files. + # + # @example + # ssl_bind '127.0.0.1', '9292', { + # cert: path_to_cert, + # key: path_to_key, + # ssl_cipher_filter: cipher_filter, # optional + # verify_mode: verify_mode, # default 'none' + # verification_flags: flags, # optional, not supported by JRuby + # } + # + # @example Using self-signed certificate with the +localhost+ gem: + # ssl_bind '127.0.0.1', '9292' + # + # @example Alternatively, you can provide +cert_pem+ and +key_pem+: + # ssl_bind '127.0.0.1', '9292', { + # cert_pem: File.read(path_to_cert), + # key_pem: File.read(path_to_key), + # } + # + # @example For JRuby, two keys are required: +keystore+ & +keystore_pass+ + # ssl_bind '127.0.0.1', '9292', { + # keystore: path_to_keystore, + # keystore_pass: password, + # ssl_cipher_list: cipher_list, # optional + # verify_mode: verify_mode # default 'none' + # } + def ssl_bind(host, port, opts = {}) + add_pem_values_to_options_store(opts) + bind self.class.ssl_bind_str(host, port, opts) + end + + # Use +path+ as the file to store the server info state. This is + # used by +pumactl+ to query and control the server. + # + # @example + # state_path '/u/apps/lolcat/tmp/pids/puma.state' + def state_path(path) + @options[:state] = path.to_s + end + + # Use +permission+ to restrict permissions for the state file. + # + # @example + # state_permission 0600 + # @version 5.0.0 + # + def state_permission(permission) + @options[:state_permission] = permission + end + + # How many worker processes to run. Typically this is set to + # the number of available cores. + # + # The default is the value of the environment variable +WEB_CONCURRENCY+ if + # set, otherwise 0. + # + # @note Cluster mode only. + # @see Puma::Cluster + def workers(count) + @options[:workers] = count.to_i + end + + # Disable warning message when running in cluster mode with a single worker. + # + # Cluster mode has some overhead of running an additional 'control' process + # in order to manage the cluster. If only running a single worker it is + # likely not worth paying that overhead vs running in single mode with + # additional threads instead. + # + # There are some scenarios where running cluster mode with a single worker + # may still be warranted and valid under certain deployment scenarios, see + # https://github.com/puma/puma/issues/2534 + # + # Moving from workers = 1 to workers = 0 will save 10-30% of memory use. + # + # @note Cluster mode only. + def silence_single_worker_warning + @options[:silence_single_worker_warning] = true + end + + # Code to run immediately before master process + # forks workers (once on boot). These hooks can block if necessary + # to wait for background operations unknown to Puma to finish before + # the process terminates. + # This can be used to close any connections to remote servers (database, + # Redis, ...) that were opened when preloading the code. + # + # This can be called multiple times to add several hooks. + # + # @note Cluster mode only. + # @example + # before_fork do + # puts "Starting workers..." + # end + def before_fork(&block) + @options[:before_fork] ||= [] + @options[:before_fork] << block + end + + # Code to run in a worker when it boots to setup + # the process before booting the app. + # + # This can be called multiple times to add several hooks. + # + # @note Cluster mode only. + # @example + # on_worker_boot do + # puts 'Before worker boot...' + # end + def on_worker_boot(&block) + @options[:before_worker_boot] ||= [] + @options[:before_worker_boot] << block + end + + # Code to run immediately before a worker shuts + # down (after it has finished processing HTTP requests). These hooks + # can block if necessary to wait for background operations unknown + # to Puma to finish before the process terminates. + # + # This can be called multiple times to add several hooks. + # + # @note Cluster mode only. + # @example + # on_worker_shutdown do + # puts 'On worker shutdown...' + # end + def on_worker_shutdown(&block) + @options[:before_worker_shutdown] ||= [] + @options[:before_worker_shutdown] << block + end + + # Code to run in the master right before a worker is started. The worker's + # index is passed as an argument. + # + # This can be called multiple times to add several hooks. + # + # @note Cluster mode only. + # @example + # on_worker_fork do + # puts 'Before worker fork...' + # end + def on_worker_fork(&block) + @options[:before_worker_fork] ||= [] + @options[:before_worker_fork] << block + end + + # Code to run in the master after a worker has been started. The worker's + # index is passed as an argument. + # + # This is called everytime a worker is to be started. + # + # @note Cluster mode only. + # @example + # after_worker_fork do + # puts 'After worker fork...' + # end + def after_worker_fork(&block) + @options[:after_worker_fork] ||= [] + @options[:after_worker_fork] << block + end + + alias_method :after_worker_boot, :after_worker_fork + + # When `fork_worker` is enabled, code to run in Worker 0 + # before all other workers are re-forked from this process, + # after the server has temporarily stopped serving requests + # (once per complete refork cycle). + # + # This can be used to trigger extra garbage-collection to maximize + # copy-on-write efficiency, or close any connections to remote servers + # (database, Redis, ...) that were opened while the server was running. + # + # This can be called multiple times to add several hooks. + # + # @note Cluster mode with `fork_worker` enabled only. + # @example + # on_refork do + # 3.times {GC.start} + # end + # @version 5.0.0 + # + def on_refork(&block) + @options[:before_refork] ||= [] + @options[:before_refork] << block + end + + # Code to run out-of-band when the worker is idle. + # These hooks run immediately after a request has finished + # processing and there are no busy threads on the worker. + # The worker doesn't accept new requests until this code finishes. + # + # This hook is useful for running out-of-band garbage collection + # or scheduling asynchronous tasks to execute after a response. + # + # This can be called multiple times to add several hooks. + def out_of_band(&block) + @options[:out_of_band] ||= [] + @options[:out_of_band] << block + end + + # The directory to operate out of. + # + # The default is the current directory. + # + # @example + # directory '/u/apps/lolcat' + def directory(dir) + @options[:directory] = dir.to_s + end + + # Preload the application before starting the workers; this conflicts with + # phased restart feature. On by default if your app uses more than 1 worker. + # + # @note Cluster mode only. + # @example + # preload_app! + def preload_app!(answer=true) + @options[:preload_app] = answer + end + + # Use +obj+ or +block+ as the low level error handler. This allows the + # configuration file to change the default error on the server. + # + # @example + # lowlevel_error_handler do |err| + # [200, {}, ["error page"]] + # end + def lowlevel_error_handler(obj=nil, &block) + obj ||= block + raise "Provide either a #call'able or a block" unless obj + @options[:lowlevel_error_handler] = obj + end + + # This option is used to allow your app and its gems to be + # properly reloaded when not using preload. + # + # When set, if Puma detects that it's been invoked in the + # context of Bundler, it will cleanup the environment and + # re-run itself outside the Bundler environment, but directly + # using the files that Bundler has setup. + # + # This means that Puma is now decoupled from your Bundler + # context and when each worker loads, it will be loading a + # new Bundler context and thus can float around as the release + # dictates. + # + # @see extra_runtime_dependencies + # + # @note This is incompatible with +preload_app!+. + # @note This is only supported for RubyGems 2.2+ + def prune_bundler(answer=true) + @options[:prune_bundler] = answer + end + + # By default, Puma will raise SignalException when SIGTERM is received. In + # environments where SIGTERM is something expected, you can suppress these + # with this option. + # + # This can be useful for example in Kubernetes, where rolling restart is + # guaranteed usually on infrastructure level. + # + # @example + # raise_exception_on_sigterm false + # @see Puma::Launcher#setup_signals + # @see Puma::Cluster#setup_signals + # + def raise_exception_on_sigterm(answer=true) + @options[:raise_exception_on_sigterm] = answer + end + + # When using prune_bundler, if extra runtime dependencies need to be loaded to + # initialize your app, then this setting can be used. This includes any Puma plugins. + # + # Before bundler is pruned, the gem names supplied will be looked up in the bundler + # context and then loaded again after bundler is pruned. + # Only applies if prune_bundler is used. + # + # @example + # extra_runtime_dependencies ['gem_name_1', 'gem_name_2'] + # @example + # extra_runtime_dependencies ['puma_worker_killer', 'puma-heroku'] + # @see Puma::Launcher#extra_runtime_deps_directories + # + def extra_runtime_dependencies(answer = []) + @options[:extra_runtime_dependencies] = Array(answer) + end + + # Additional text to display in process listing. + # + # If you do not specify a tag, Puma will infer it. If you do not want Puma + # to add a tag, use an empty string. + # + # @example + # tag 'app name' + # @example + # tag '' + def tag(string) + @options[:tag] = string.to_s + end + + # Change the default interval for checking workers. + # + # The default value is 5 seconds. + # + # @note Cluster mode only. + # @example + # worker_check_interval 5 + # @see Puma::Cluster#check_workers + # + def worker_check_interval(interval) + @options[:worker_check_interval] = Integer(interval) + end + + # Verifies that all workers have checked in to the master process within + # the given timeout. If not the worker process will be restarted. This is + # not a request timeout, it is to protect against a hung or dead process. + # Setting this value will not protect against slow requests. + # + # The minimum value is 6 seconds, the default value is 60 seconds. + # + # @note Cluster mode only. + # @example + # worker_timeout 60 + # @see Puma::Cluster::Worker#ping_timeout + # + def worker_timeout(timeout) + timeout = Integer(timeout) + min = @options.fetch(:worker_check_interval, Puma::ConfigDefault::DefaultWorkerCheckInterval) + + if timeout <= min + raise "The minimum worker_timeout must be greater than the worker reporting interval (#{min})" + end + + @options[:worker_timeout] = timeout + end + + # Change the default worker timeout for booting. + # + # If unspecified, this defaults to the value of worker_timeout. + # + # @note Cluster mode only. + # + # @example + # worker_boot_timeout 60 + # @see Puma::Cluster::Worker#ping_timeout + # + def worker_boot_timeout(timeout) + @options[:worker_boot_timeout] = Integer(timeout) + end + + # Set the timeout for worker shutdown. + # + # @note Cluster mode only. + # @see Puma::Cluster::Worker#term + # + def worker_shutdown_timeout(timeout) + @options[:worker_shutdown_timeout] = Integer(timeout) + end + + # Set the strategy for worker culling. + # + # There are two possible values: + # + # 1. **:youngest** - the youngest workers (i.e. the workers that were + # the most recently started) will be culled. + # 2. **:oldest** - the oldest workers (i.e. the workers that were started + # the longest time ago) will be culled. + # + # @note Cluster mode only. + # @example + # worker_culling_strategy :oldest + # @see Puma::Cluster#cull_workers + # + def worker_culling_strategy(strategy) + stategy = strategy.to_sym + + if ![:youngest, :oldest].include?(strategy) + raise "Invalid value for worker_culling_strategy - #{stategy}" + end + + @options[:worker_culling_strategy] = strategy + end + + # When set to true (the default), workers accept all requests + # and queue them before passing them to the handlers. + # When set to false, each worker process accepts exactly as + # many requests as it is configured to simultaneously handle. + # + # Queueing requests generally improves performance. In some + # cases, such as a single threaded application, it may be + # better to ensure requests get balanced across workers. + # + # Note that setting this to false disables HTTP keepalive and + # slow clients will occupy a handler thread while the request + # is being sent. A reverse proxy, such as nginx, can handle + # slow clients and queue requests before they reach Puma. + # @see Puma::Server + def queue_requests(answer=true) + @options[:queue_requests] = answer + end + + # When a shutdown is requested, the backtraces of all the + # threads will be written to $stdout. This can help figure + # out why shutdown is hanging. + # + def shutdown_debug(val=true) + @options[:shutdown_debug] = val + end + + + # Attempts to route traffic to less-busy workers by causing them to delay + # listening on the socket, allowing workers which are not processing any + # requests to pick up new requests first. + # + # Only works on MRI. For all other interpreters, this setting does nothing. + # @see Puma::Server#handle_servers + # @see Puma::ThreadPool#wait_for_less_busy_worker + # @version 5.0.0 + # + def wait_for_less_busy_worker(val=0.005) + @options[:wait_for_less_busy_worker] = val.to_f + end + + # Control how the remote address of the connection is set. This + # is configurable because to calculate the true socket peer address + # a kernel syscall is required which for very fast rack handlers + # slows down the handling significantly. + # + # There are 5 possible values: + # + # 1. **:socket** (the default) - read the peername from the socket using the + # syscall. This is the normal behavior. + # 2. **:localhost** - set the remote address to "127.0.0.1" + # 3. **header: **- set the remote address to the value of the + # provided http header. For instance: + # `set_remote_address header: "X-Real-IP"`. + # Only the first word (as separated by spaces or comma) is used, allowing + # headers such as X-Forwarded-For to be used as well. + # 4. **proxy_protocol: :v1**- set the remote address to the value read from the + # HAproxy PROXY protocol, version 1. If the request does not have the PROXY + # protocol attached to it, will fall back to :socket + # 5. **\** - this allows you to hardcode remote address to any value + # you wish. Because Puma never uses this field anyway, it's format is + # entirely in your hands. + # + def set_remote_address(val=:socket) + case val + when :socket + @options[:remote_address] = val + when :localhost + @options[:remote_address] = :value + @options[:remote_address_value] = "127.0.0.1".freeze + when String + @options[:remote_address] = :value + @options[:remote_address_value] = val + when Hash + if hdr = val[:header] + @options[:remote_address] = :header + @options[:remote_address_header] = "HTTP_" + hdr.upcase.tr("-", "_") + elsif protocol_version = val[:proxy_protocol] + @options[:remote_address] = :proxy_protocol + protocol_version = protocol_version.downcase.to_sym + unless [:v1].include?(protocol_version) + raise "Invalid value for proxy_protocol - #{protocol_version.inspect}" + end + @options[:remote_address_proxy_protocol] = protocol_version + else + raise "Invalid value for set_remote_address - #{val.inspect}" + end + else + raise "Invalid value for set_remote_address - #{val}" + end + end + + # When enabled, workers will be forked from worker 0 instead of from the master process. + # This option is similar to `preload_app` because the app is preloaded before forking, + # but it is compatible with phased restart. + # + # This option also enables the `refork` command (SIGURG), which optimizes copy-on-write performance + # in a running app. + # + # A refork will automatically trigger once after the specified number of requests + # (default 1000), or pass 0 to disable auto refork. + # + # @note Cluster mode only. + # @version 5.0.0 + # + def fork_worker(after_requests=1000) + @options[:fork_worker] = Integer(after_requests) + end + + # When enabled, Puma will GC 4 times before forking workers. + # If available (Ruby 2.7+), we will also call GC.compact. + # Not recommended for non-MRI Rubies. + # + # Based on the work of Koichi Sasada and Aaron Patterson, this option may + # decrease memory utilization of preload-enabled cluster-mode Pumas. It will + # also increase time to boot and fork. See your logs for details on how much + # time this adds to your boot process. For most apps, it will be less than one + # second. + # + # @see Puma::Cluster#nakayoshi_gc + # @version 5.0.0 + # + def nakayoshi_fork(enabled=true) + @options[:nakayoshi_fork] = enabled + end + + # The number of requests to attempt inline before sending a client back to + # the reactor to be subject to normal ordering. + # + def max_fast_inline(num_of_requests) + @options[:max_fast_inline] = Float(num_of_requests) + end + + # Specify the backend for the IO selector. + # + # Provided values will be passed directly to +NIO::Selector.new+, with the + # exception of +:auto+ which will let nio4r choose the backend. + # + # Check the documentation of +NIO::Selector.backends+ for the list of valid + # options. Note that the available options on your system will depend on the + # operating system. If you want to use the pure Ruby backend (not + # recommended due to its comparatively low performance), set environment + # variable +NIO4R_PURE+ to +true+. + # + # The default is +:auto+. + # + # @see https://github.com/socketry/nio4r/blob/master/lib/nio/selector.rb + # + def io_selector_backend(backend) + @options[:io_selector_backend] = backend.to_sym + end + + def mutate_stdout_and_stderr_to_sync_on_write(enabled=true) + @options[:mutate_stdout_and_stderr_to_sync_on_write] = enabled + end + + private + + # To avoid adding cert_pem and key_pem as URI params, we store them on the + # options[:store] from where Puma binder knows how to find and extract them. + def add_pem_values_to_options_store(opts) + return if defined?(JRUBY_VERSION) + + @options[:store] ||= [] + + # Store cert_pem and key_pem to options[:store] if present + [:cert, :key].each do |v| + opt_key = :"#{v}_pem" + if opts[opt_key] + index = @options[:store].length + @options[:store] << opts[opt_key] + opts[v] = "store:#{index}" + end + end + end + end +end diff --git a/lib/puma/error_logger.rb b/lib/puma/error_logger.rb new file mode 100644 index 0000000..51610d2 --- /dev/null +++ b/lib/puma/error_logger.rb @@ -0,0 +1,104 @@ +# frozen_string_literal: true + +require 'puma/const' + +module Puma + # The implementation of a detailed error logging. + # @version 5.0.0 + # + class ErrorLogger + include Const + + attr_reader :ioerr + + REQUEST_FORMAT = %{"%s %s%s" - (%s)} + + def initialize(ioerr) + @ioerr = ioerr + + @debug = ENV.key? 'PUMA_DEBUG' + end + + def self.stdio + new $stderr + end + + # Print occurred error details. + # +options+ hash with additional options: + # - +error+ is an exception object + # - +req+ the http request + # - +text+ (default nil) custom string to print in title + # and before all remaining info. + # + def info(options={}) + log title(options) + end + + # Print occurred error details only if + # environment variable PUMA_DEBUG is defined. + # +options+ hash with additional options: + # - +error+ is an exception object + # - +req+ the http request + # - +text+ (default nil) custom string to print in title + # and before all remaining info. + # + def debug(options={}) + return unless @debug + + error = options[:error] + req = options[:req] + + string_block = [] + string_block << title(options) + string_block << request_dump(req) if request_parsed?(req) + string_block << error.backtrace if error + + log string_block.join("\n") + end + + def title(options={}) + text = options[:text] + req = options[:req] + error = options[:error] + + string_block = ["#{Time.now}"] + string_block << " #{text}" if text + string_block << " (#{request_title(req)})" if request_parsed?(req) + string_block << ": #{error.inspect}" if error + string_block.join('') + end + + def request_dump(req) + "Headers: #{request_headers(req)}\n" \ + "Body: #{req.body}" + end + + def request_title(req) + env = req.env + + REQUEST_FORMAT % [ + env[REQUEST_METHOD], + env[REQUEST_PATH] || env[PATH_INFO], + env[QUERY_STRING] || "", + env[HTTP_X_FORWARDED_FOR] || env[REMOTE_ADDR] || "-" + ] + end + + def request_headers(req) + headers = req.env.select { |key, _| key.start_with?('HTTP_') } + headers.map { |key, value| [key[5..-1], value] }.to_h.inspect + end + + def request_parsed?(req) + req && req.env[REQUEST_METHOD] + end + + private + + def log(str) + ioerr.puts str + + ioerr.flush unless ioerr.sync + end + end +end diff --git a/lib/puma/events.rb b/lib/puma/events.rb new file mode 100644 index 0000000..f96d553 --- /dev/null +++ b/lib/puma/events.rb @@ -0,0 +1,177 @@ +# frozen_string_literal: true + +require "puma/null_io" +require 'puma/error_logger' +require 'stringio' + +module Puma + # The default implement of an event sink object used by Server + # for when certain kinds of events occur in the life of the server. + # + # The methods available are the events that the Server fires. + # + class Events + class DefaultFormatter + def call(str) + str + end + end + + class PidFormatter + def call(str) + "[#{$$}] #{str}" + end + end + + # Create an Events object that prints to +stdout+ and +stderr+. + # + def initialize(stdout, stderr) + @formatter = DefaultFormatter.new + @stdout = stdout + @stderr = stderr + + @debug = ENV.key? 'PUMA_DEBUG' + @error_logger = ErrorLogger.new(@stderr) + + @hooks = Hash.new { |h,k| h[k] = [] } + end + + attr_reader :stdout, :stderr + attr_accessor :formatter + + # Fire callbacks for the named hook + # + def fire(hook, *args) + @hooks[hook].each { |t| t.call(*args) } + end + + # Register a callback for a given hook + # + def register(hook, obj=nil, &blk) + if obj and blk + raise "Specify either an object or a block, not both" + end + + h = obj || blk + + @hooks[hook] << h + + h + end + + # Write +str+ to +@stdout+ + # + def log(str) + @stdout.puts format(str) if @stdout.respond_to? :puts + + @stdout.flush unless @stdout.sync + rescue Errno::EPIPE + end + + def write(str) + @stdout.write format(str) + end + + def debug(str) + log("% #{str}") if @debug + end + + # Write +str+ to +@stderr+ + # + def error(str) + @error_logger.info(text: format("ERROR: #{str}")) + exit 1 + end + + def format(str) + formatter.call(str) + end + + # An HTTP connection error has occurred. + # +error+ a connection exception, +req+ the request, + # and +text+ additional info + # @version 5.0.0 + # + def connection_error(error, req, text="HTTP connection error") + @error_logger.info(error: error, req: req, text: text) + end + + # An HTTP parse error has occurred. + # +error+ a parsing exception, + # and +req+ the request. + # + def parse_error(error, req) + @error_logger.info(error: error, req: req, text: 'HTTP parse error, malformed request') + end + + # An SSL error has occurred. + # @param error + # @param ssl_socket + # + def ssl_error(error, ssl_socket) + peeraddr = ssl_socket.peeraddr.last rescue "" + peercert = ssl_socket.peercert + subject = peercert ? peercert.subject : nil + @error_logger.info(error: error, text: "SSL error, peer: #{peeraddr}, peer cert: #{subject}") + end + + # An unknown error has occurred. + # +error+ an exception object, +req+ the request, + # and +text+ additional info + # + def unknown_error(error, req=nil, text="Unknown error") + @error_logger.info(error: error, req: req, text: text) + end + + # Log occurred error debug dump. + # +error+ an exception object, +req+ the request, + # and +text+ additional info + # @version 5.0.0 + # + def debug_error(error, req=nil, text="") + @error_logger.debug(error: error, req: req, text: text) + end + + def on_booted(&block) + register(:on_booted, &block) + end + + def on_restart(&block) + register(:on_restart, &block) + end + + def on_stopped(&block) + register(:on_stopped, &block) + end + + def fire_on_booted! + fire(:on_booted) + end + + def fire_on_restart! + fire(:on_restart) + end + + def fire_on_stopped! + fire(:on_stopped) + end + + DEFAULT = new(STDOUT, STDERR) + + # Returns an Events object which writes its status to 2 StringIO + # objects. + # + def self.strings + Events.new StringIO.new, StringIO.new + end + + def self.stdio + Events.new $stdout, $stderr + end + + def self.null + n = NullIO.new + Events.new n, n + end + end +end diff --git a/lib/puma/io_buffer.rb b/lib/puma/io_buffer.rb new file mode 100644 index 0000000..4814671 --- /dev/null +++ b/lib/puma/io_buffer.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +module Puma + class IOBuffer < String + def append(*args) + args.each { |a| concat(a) } + end + + alias reset clear + end +end diff --git a/lib/puma/jruby_restart.rb b/lib/puma/jruby_restart.rb new file mode 100644 index 0000000..af16d5b --- /dev/null +++ b/lib/puma/jruby_restart.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +require 'ffi' + +module Puma + module JRubyRestart + extend FFI::Library + ffi_lib 'c' + + attach_function :execlp, [:string, :varargs], :int + attach_function :chdir, [:string], :int + attach_function :fork, [], :int + attach_function :exit, [:int], :void + attach_function :setsid, [], :int + + def self.chdir_exec(dir, argv) + chdir(dir) + cmd = argv.first + argv = ([:string] * argv.size).zip(argv).flatten + argv << :string + argv << nil + execlp(cmd, *argv) + raise SystemCallError.new(FFI.errno) + end + end +end diff --git a/lib/puma/json_serialization.rb b/lib/puma/json_serialization.rb new file mode 100644 index 0000000..94cad5c --- /dev/null +++ b/lib/puma/json_serialization.rb @@ -0,0 +1,96 @@ +# frozen_string_literal: true +require 'stringio' + +module Puma + + # Puma deliberately avoids the use of the json gem and instead performs JSON + # serialization without any external dependencies. In a puma cluster, loading + # any gem into the puma master process means that operators cannot use a + # phased restart to upgrade their application if the new version of that + # application uses a different version of that gem. The json gem in + # particular is additionally problematic because it leverages native + # extensions. If the puma master process relies on a gem with native + # extensions and operators remove gems from disk related to old releases, + # subsequent phased restarts can fail. + # + # The implementation of JSON serialization in this module is not designed to + # be particularly full-featured or fast. It just has to handle the few places + # where Puma relies on JSON serialization internally. + + module JSONSerialization + QUOTE = /"/ + BACKSLASH = /\\/ + CONTROL_CHAR_TO_ESCAPE = /[\x00-\x1F]/ # As required by ECMA-404 + CHAR_TO_ESCAPE = Regexp.union QUOTE, BACKSLASH, CONTROL_CHAR_TO_ESCAPE + + class SerializationError < StandardError; end + + class << self + def generate(value) + StringIO.open do |io| + serialize_value io, value + io.string + end + end + + private + + def serialize_value(output, value) + case value + when Hash + output << '{' + value.each_with_index do |(k, v), index| + output << ',' if index != 0 + serialize_object_key output, k + output << ':' + serialize_value output, v + end + output << '}' + when Array + output << '[' + value.each_with_index do |member, index| + output << ',' if index != 0 + serialize_value output, member + end + output << ']' + when Integer, Float + output << value.to_s + when String + serialize_string output, value + when true + output << 'true' + when false + output << 'false' + when nil + output << 'null' + else + raise SerializationError, "Unexpected value of type #{value.class}" + end + end + + def serialize_string(output, value) + output << '"' + output << value.gsub(CHAR_TO_ESCAPE) do |character| + case character + when BACKSLASH + '\\\\' + when QUOTE + '\\"' + when CONTROL_CHAR_TO_ESCAPE + '\u%.4X' % character.ord + end + end + output << '"' + end + + def serialize_object_key(output, value) + case value + when Symbol, String + serialize_string output, value.to_s + else + raise SerializationError, "Could not serialize object of type #{value.class} as object key" + end + end + end + end +end diff --git a/lib/puma/launcher.rb b/lib/puma/launcher.rb new file mode 100644 index 0000000..a3cc5f8 --- /dev/null +++ b/lib/puma/launcher.rb @@ -0,0 +1,546 @@ +# frozen_string_literal: true + +require 'puma/events' +require 'puma/detect' +require 'puma/cluster' +require 'puma/single' +require 'puma/const' +require 'puma/binder' + +module Puma + # Puma::Launcher is the single entry point for starting a Puma server based on user + # configuration. It is responsible for taking user supplied arguments and resolving them + # with configuration in `config/puma.rb` or `config/puma/.rb`. + # + # It is responsible for either launching a cluster of Puma workers or a single + # puma server. + class Launcher + # @deprecated 6.0.0 + KEYS_NOT_TO_PERSIST_IN_STATE = [ + :logger, :lowlevel_error_handler, + :before_worker_shutdown, :before_worker_boot, :before_worker_fork, + :after_worker_boot, :before_fork, :on_restart + ] + # Returns an instance of Launcher + # + # +conf+ A Puma::Configuration object indicating how to run the server. + # + # +launcher_args+ A Hash that currently has one required key `:events`, + # this is expected to hold an object similar to an `Puma::Events.stdio`, + # this object will be responsible for broadcasting Puma's internal state + # to a logging destination. An optional key `:argv` can be supplied, + # this should be an array of strings, these arguments are re-used when + # restarting the puma server. + # + # Examples: + # + # conf = Puma::Configuration.new do |user_config| + # user_config.threads 1, 10 + # user_config.app do |env| + # [200, {}, ["hello world"]] + # end + # end + # Puma::Launcher.new(conf, events: Puma::Events.stdio).run + def initialize(conf, launcher_args={}) + @runner = nil + @events = launcher_args[:events] || Events::DEFAULT + @argv = launcher_args[:argv] || [] + @original_argv = @argv.dup + @config = conf + + @binder = Binder.new(@events, conf) + @binder.create_inherited_fds(ENV).each { |k| ENV.delete k } + @binder.create_activated_fds(ENV).each { |k| ENV.delete k } + + @environment = conf.environment + + # Advertise the Configuration + Puma.cli_config = @config if defined?(Puma.cli_config) + + @config.load + + if @config.options[:bind_to_activated_sockets] + @config.options[:binds] = @binder.synthesize_binds_from_activated_fs( + @config.options[:binds], + @config.options[:bind_to_activated_sockets] == 'only' + ) + end + + @options = @config.options + @config.clamp + + @events.formatter = Events::PidFormatter.new if clustered? + @events.formatter = options[:log_formatter] if @options[:log_formatter] + + generate_restart_data + + if clustered? && !Puma.forkable? + unsupported "worker mode not supported on #{RUBY_ENGINE} on this platform" + end + + Dir.chdir(@restart_dir) + + prune_bundler if prune_bundler? + + @environment = @options[:environment] if @options[:environment] + set_rack_environment + + if clustered? + @options[:logger] = @events + + @runner = Cluster.new(self, @events) + else + @runner = Single.new(self, @events) + end + Puma.stats_object = @runner + + @status = :run + + log_config if ENV['PUMA_LOG_CONFIG'] + end + + attr_reader :binder, :events, :config, :options, :restart_dir + + # Return stats about the server + def stats + @runner.stats + end + + # Write a state file that can be used by pumactl to control + # the server + def write_state + write_pid + + path = @options[:state] + permission = @options[:state_permission] + return unless path + + require 'puma/state_file' + + sf = StateFile.new + sf.pid = Process.pid + sf.control_url = @options[:control_url] + sf.control_auth_token = @options[:control_auth_token] + sf.running_from = File.expand_path('.') + + sf.save path, permission + end + + # Delete the configured pidfile + def delete_pidfile + path = @options[:pidfile] + File.unlink(path) if path && File.exist?(path) + end + + # Begin async shutdown of the server + def halt + @status = :halt + @runner.halt + end + + # Begin async shutdown of the server gracefully + def stop + @status = :stop + @runner.stop + end + + # Begin async restart of the server + def restart + @status = :restart + @runner.restart + end + + # Begin a phased restart if supported + def phased_restart + unless @runner.respond_to?(:phased_restart) and @runner.phased_restart + log "* phased-restart called but not available, restarting normally." + return restart + end + true + end + + # Run the server. This blocks until the server is stopped + def run + previous_env = + if defined?(Bundler) + env = Bundler::ORIGINAL_ENV.dup + # add -rbundler/setup so we load from Gemfile when restarting + bundle = "-rbundler/setup" + env["RUBYOPT"] = [env["RUBYOPT"], bundle].join(" ").lstrip unless env["RUBYOPT"].to_s.include?(bundle) + env + else + ENV.to_h + end + + @config.clamp + + @config.plugins.fire_starts self + + setup_signals + set_process_title + integrate_with_systemd + @runner.run + + case @status + when :halt + log "* Stopping immediately!" + @runner.stop_control + when :run, :stop + graceful_stop + when :restart + log "* Restarting..." + ENV.replace(previous_env) + @runner.stop_control + restart! + when :exit + # nothing + end + close_binder_listeners unless @status == :restart + end + + # Return all tcp ports the launcher may be using, TCP or SSL + # @!attribute [r] connected_ports + # @version 5.0.0 + def connected_ports + @binder.connected_ports + end + + # @!attribute [r] restart_args + def restart_args + cmd = @options[:restart_cmd] + if cmd + cmd.split(' ') + @original_argv + else + @restart_argv + end + end + + def close_binder_listeners + @runner.close_control_listeners + @binder.close_listeners + unless @status == :restart + log "=== puma shutdown: #{Time.now} ===" + log "- Goodbye!" + end + end + + # @!attribute [r] thread_status + # @version 5.0.0 + def thread_status + Thread.list.each do |thread| + name = "Thread: TID-#{thread.object_id.to_s(36)}" + name += " #{thread['label']}" if thread['label'] + name += " #{thread.name}" if thread.respond_to?(:name) && thread.name + backtrace = thread.backtrace || [""] + + yield name, backtrace + end + end + + private + + # If configured, write the pid of the current process out + # to a file. + def write_pid + path = @options[:pidfile] + return unless path + cur_pid = Process.pid + File.write path, cur_pid, mode: 'wb:UTF-8' + at_exit do + delete_pidfile if cur_pid == Process.pid + end + end + + def reload_worker_directory + @runner.reload_worker_directory if @runner.respond_to?(:reload_worker_directory) + end + + def restart! + @events.fire_on_restart! + @config.run_hooks :on_restart, self, @events + + if Puma.jruby? + close_binder_listeners + + require 'puma/jruby_restart' + JRubyRestart.chdir_exec(@restart_dir, restart_args) + elsif Puma.windows? + close_binder_listeners + + argv = restart_args + Dir.chdir(@restart_dir) + Kernel.exec(*argv) + else + argv = restart_args + Dir.chdir(@restart_dir) + ENV.update(@binder.redirects_for_restart_env) + argv += [@binder.redirects_for_restart] + Kernel.exec(*argv) + end + end + + # @!attribute [r] files_to_require_after_prune + def files_to_require_after_prune + puma = spec_for_gem("puma") + + require_paths_for_gem(puma) + extra_runtime_deps_directories + end + + # @!attribute [r] extra_runtime_deps_directories + def extra_runtime_deps_directories + Array(@options[:extra_runtime_dependencies]).map do |d_name| + if (spec = spec_for_gem(d_name)) + require_paths_for_gem(spec) + else + log "* Could not load extra dependency: #{d_name}" + nil + end + end.flatten.compact + end + + # @!attribute [r] puma_wild_location + def puma_wild_location + puma = spec_for_gem("puma") + dirs = require_paths_for_gem(puma) + puma_lib_dir = dirs.detect { |x| File.exist? File.join(x, '../bin/puma-wild') } + File.expand_path(File.join(puma_lib_dir, "../bin/puma-wild")) + end + + def prune_bundler + return if ENV['PUMA_BUNDLER_PRUNED'] + return unless defined?(Bundler) + require_rubygems_min_version!(Gem::Version.new("2.2"), "prune_bundler") + unless puma_wild_location + log "! Unable to prune Bundler environment, continuing" + return + end + + dirs = files_to_require_after_prune + + log '* Pruning Bundler environment' + home = ENV['GEM_HOME'] + bundle_gemfile = Bundler.original_env['BUNDLE_GEMFILE'] + bundle_app_config = Bundler.original_env['BUNDLE_APP_CONFIG'] + with_unbundled_env do + ENV['GEM_HOME'] = home + ENV['BUNDLE_GEMFILE'] = bundle_gemfile + ENV['PUMA_BUNDLER_PRUNED'] = '1' + ENV["BUNDLE_APP_CONFIG"] = bundle_app_config + args = [Gem.ruby, puma_wild_location, '-I', dirs.join(':')] + @original_argv + # Ruby 2.0+ defaults to true which breaks socket activation + args += [{:close_others => false}] + Kernel.exec(*args) + end + end + + # + # Puma's systemd integration allows Puma to inform systemd: + # 1. when it has successfully started + # 2. when it is starting shutdown + # 3. periodically for a liveness check with a watchdog thread + # + + def integrate_with_systemd + return unless ENV["NOTIFY_SOCKET"] + + begin + require 'puma/systemd' + rescue LoadError + log "Systemd integration failed. It looks like you're trying to use systemd notify but don't have sd_notify gem installed" + return + end + + log "* Enabling systemd notification integration" + + systemd = Systemd.new(@events) + systemd.hook_events + systemd.start_watchdog + end + + def spec_for_gem(gem_name) + Bundler.rubygems.loaded_specs(gem_name) + end + + def require_paths_for_gem(gem_spec) + gem_spec.full_require_paths + end + + def log(str) + @events.log str + end + + def clustered? + (@options[:workers] || 0) > 0 + end + + def unsupported(str) + @events.error(str) + raise UnsupportedOption + end + + def graceful_stop + @events.fire_on_stopped! + @runner.stop_blocked + end + + def set_process_title + Process.respond_to?(:setproctitle) ? Process.setproctitle(title) : $0 = title + end + + # @!attribute [r] title + def title + buffer = "puma #{Puma::Const::VERSION} (#{@options[:binds].join(',')})" + buffer += " [#{@options[:tag]}]" if @options[:tag] && !@options[:tag].empty? + buffer + end + + def set_rack_environment + @options[:environment] = environment + ENV['RACK_ENV'] = environment + end + + # @!attribute [r] environment + def environment + @environment + end + + def prune_bundler? + @options[:prune_bundler] && clustered? && !@options[:preload_app] + end + + def generate_restart_data + if dir = @options[:directory] + @restart_dir = dir + + elsif Puma.windows? + # I guess the value of PWD is garbage on windows so don't bother + # using it. + @restart_dir = Dir.pwd + + # Use the same trick as unicorn, namely favor PWD because + # it will contain an unresolved symlink, useful for when + # the pwd is /data/releases/current. + elsif dir = ENV['PWD'] + s_env = File.stat(dir) + s_pwd = File.stat(Dir.pwd) + + if s_env.ino == s_pwd.ino and (Puma.jruby? or s_env.dev == s_pwd.dev) + @restart_dir = dir + end + end + + @restart_dir ||= Dir.pwd + + # if $0 is a file in the current directory, then restart + # it the same, otherwise add -S on there because it was + # picked up in PATH. + # + if File.exist?($0) + arg0 = [Gem.ruby, $0] + else + arg0 = [Gem.ruby, "-S", $0] + end + + # Detect and reinject -Ilib from the command line, used for testing without bundler + # cruby has an expanded path, jruby has just "lib" + lib = File.expand_path "lib" + arg0[1,0] = ["-I", lib] if [lib, "lib"].include?($LOAD_PATH[0]) + + if defined? Puma::WILD_ARGS + @restart_argv = arg0 + Puma::WILD_ARGS + @original_argv + else + @restart_argv = arg0 + @original_argv + end + end + + def setup_signals + begin + Signal.trap "SIGUSR2" do + restart + end + rescue Exception + log "*** SIGUSR2 not implemented, signal based restart unavailable!" + end + + unless Puma.jruby? + begin + Signal.trap "SIGUSR1" do + phased_restart + end + rescue Exception + log "*** SIGUSR1 not implemented, signal based restart unavailable!" + end + end + + begin + Signal.trap "SIGTERM" do + graceful_stop + + raise(SignalException, "SIGTERM") if @options[:raise_exception_on_sigterm] + end + rescue Exception + log "*** SIGTERM not implemented, signal based gracefully stopping unavailable!" + end + + begin + Signal.trap "SIGINT" do + stop + end + rescue Exception + log "*** SIGINT not implemented, signal based gracefully stopping unavailable!" + end + + begin + Signal.trap "SIGHUP" do + if @runner.redirected_io? + @runner.redirect_io + else + stop + end + end + rescue Exception + log "*** SIGHUP not implemented, signal based logs reopening unavailable!" + end + + begin + unless Puma.jruby? # INFO in use by JVM already + Signal.trap "SIGINFO" do + thread_status do |name, backtrace| + @events.log name + @events.log backtrace.map { |bt| " #{bt}" } + end + end + end + rescue Exception + # Not going to log this one, as SIGINFO is *BSD only and would be pretty annoying + # to see this constantly on Linux. + end + end + + def require_rubygems_min_version!(min_version, feature) + return if min_version <= Gem::Version.new(Gem::VERSION) + + raise "#{feature} is not supported on your version of RubyGems. " \ + "You must have RubyGems #{min_version}+ to use this feature." + end + + # @version 5.0.0 + def with_unbundled_env + bundler_ver = Gem::Version.new(Bundler::VERSION) + if bundler_ver < Gem::Version.new('2.1.0') + Bundler.with_clean_env { yield } + else + Bundler.with_unbundled_env { yield } + end + end + + def log_config + log "Configuration:" + + @config.final_options + .each { |config_key, value| log "- #{config_key}: #{value}" } + + log "\n" + end + end +end diff --git a/lib/puma/minissl.rb b/lib/puma/minissl.rb new file mode 100644 index 0000000..f9161af --- /dev/null +++ b/lib/puma/minissl.rb @@ -0,0 +1,360 @@ +# frozen_string_literal: true + +begin + require 'io/wait' +rescue LoadError +end + +# need for Puma::MiniSSL::OPENSSL constants used in `HAS_TLS1_3` +require 'puma/puma_http11' + +module Puma + module MiniSSL + # Define constant at runtime, as it's easy to determine at built time, + # but Puma could (it shouldn't) be loaded with an older OpenSSL version + # @version 5.0.0 + HAS_TLS1_3 = !IS_JRUBY && + (OPENSSL_VERSION[/ \d+\.\d+\.\d+/].split('.').map(&:to_i) <=> [1,1,1]) != -1 && + (OPENSSL_LIBRARY_VERSION[/ \d+\.\d+\.\d+/].split('.').map(&:to_i) <=> [1,1,1]) !=-1 + + class Socket + def initialize(socket, engine) + @socket = socket + @engine = engine + @peercert = nil + end + + # @!attribute [r] to_io + def to_io + @socket + end + + def closed? + @socket.closed? + end + + # Returns a two element array, + # first is protocol version (SSL_get_version), + # second is 'handshake' state (SSL_state_string) + # + # Used for dropping tcp connections to ssl. + # See OpenSSL ssl/ssl_stat.c SSL_state_string for info + # @!attribute [r] ssl_version_state + # @version 5.0.0 + # + def ssl_version_state + IS_JRUBY ? [nil, nil] : @engine.ssl_vers_st + end + + # Used to check the handshake status, in particular when a TCP connection + # is made with TLSv1.3 as an available protocol + # @version 5.0.0 + def bad_tlsv1_3? + HAS_TLS1_3 && @engine.ssl_vers_st == ['TLSv1.3', 'SSLERR'] + end + private :bad_tlsv1_3? + + def readpartial(size) + while true + output = @engine.read + return output if output + + data = @socket.readpartial(size) + @engine.inject(data) + output = @engine.read + + return output if output + + while neg_data = @engine.extract + @socket.write neg_data + end + end + end + + def engine_read_all + output = @engine.read + while output and additional_output = @engine.read + output << additional_output + end + output + end + + def read_nonblock(size, *_) + # *_ is to deal with keyword args that were added + # at some point (and being used in the wild) + while true + output = engine_read_all + return output if output + + data = @socket.read_nonblock(size, exception: false) + if data == :wait_readable || data == :wait_writable + # It would make more sense to let @socket.read_nonblock raise + # EAGAIN if necessary but it seems like it'll misbehave on Windows. + # I don't have a Windows machine to debug this so I can't explain + # exactly whats happening in that OS. Please let me know if you + # find out! + # + # In the meantime, we can emulate the correct behavior by + # capturing :wait_readable & :wait_writable and raising EAGAIN + # ourselves. + raise IO::EAGAINWaitReadable + elsif data.nil? + raise SSLError.exception "HTTP connection?" if bad_tlsv1_3? + return nil + end + + @engine.inject(data) + output = engine_read_all + + return output if output + + while neg_data = @engine.extract + @socket.write neg_data + end + end + end + + def write(data) + return 0 if data.empty? + + data_size = data.bytesize + need = data_size + + while true + wrote = @engine.write data + + enc_wr = ''.dup + while (enc = @engine.extract) + enc_wr << enc + end + @socket.write enc_wr unless enc_wr.empty? + + need -= wrote + + return data_size if need == 0 + + data = data.byteslice(wrote..-1) + end + end + + alias_method :syswrite, :write + alias_method :<<, :write + + # This is a temporary fix to deal with websockets code using + # write_nonblock. + + # The problem with implementing it properly + # is that it means we'd have to have the ability to rewind + # an engine because after we write+extract, the socket + # write_nonblock call might raise an exception and later + # code would pass the same data in, but the engine would think + # it had already written the data in. + # + # So for the time being (and since write blocking is quite rare), + # go ahead and actually block in write_nonblock. + # + def write_nonblock(data, *_) + write data + end + + def flush + @socket.flush + end + + def close + begin + unless @engine.shutdown + while alert_data = @engine.extract + @socket.write alert_data + end + end + rescue IOError, SystemCallError + Puma::Util.purge_interrupt_queue + # nothing + ensure + @socket.close + end + end + + # @!attribute [r] peeraddr + def peeraddr + @socket.peeraddr + end + + # @!attribute [r] peercert + def peercert + return @peercert if @peercert + + raw = @engine.peercert + return nil unless raw + + @peercert = OpenSSL::X509::Certificate.new raw + end + end + + if IS_JRUBY + OPENSSL_NO_SSL3 = false + OPENSSL_NO_TLS1 = false + + class SSLError < StandardError + # Define this for jruby even though it isn't used. + end + end + + class Context + attr_accessor :verify_mode + attr_reader :no_tlsv1, :no_tlsv1_1 + + def initialize + @no_tlsv1 = false + @no_tlsv1_1 = false + @key = nil + @cert = nil + @key_pem = nil + @cert_pem = nil + end + + if IS_JRUBY + # jruby-specific Context properties: java uses a keystore and password pair rather than a cert/key pair + attr_reader :keystore + attr_accessor :keystore_pass + attr_accessor :ssl_cipher_list + + def keystore=(keystore) + raise ArgumentError, "No such keystore file '#{keystore}'" unless File.exist? keystore + @keystore = keystore + end + + def check + raise "Keystore not configured" unless @keystore + end + + else + # non-jruby Context properties + attr_reader :key + attr_reader :cert + attr_reader :ca + attr_reader :cert_pem + attr_reader :key_pem + attr_accessor :ssl_cipher_filter + attr_accessor :verification_flags + + def key=(key) + raise ArgumentError, "No such key file '#{key}'" unless File.exist? key + @key = key + end + + def cert=(cert) + raise ArgumentError, "No such cert file '#{cert}'" unless File.exist? cert + @cert = cert + end + + def ca=(ca) + raise ArgumentError, "No such ca file '#{ca}'" unless File.exist? ca + @ca = ca + end + + def cert_pem=(cert_pem) + raise ArgumentError, "'cert_pem' is not a String" unless cert_pem.is_a? String + @cert_pem = cert_pem + end + + def key_pem=(key_pem) + raise ArgumentError, "'key_pem' is not a String" unless key_pem.is_a? String + @key_pem = key_pem + end + + def check + raise "Key not configured" if @key.nil? && @key_pem.nil? + raise "Cert not configured" if @cert.nil? && @cert_pem.nil? + end + end + + # disables TLSv1 + # @!attribute [w] no_tlsv1= + def no_tlsv1=(tlsv1) + raise ArgumentError, "Invalid value of no_tlsv1=" unless ['true', 'false', true, false].include?(tlsv1) + @no_tlsv1 = tlsv1 + end + + # disables TLSv1 and TLSv1.1. Overrides `#no_tlsv1=` + # @!attribute [w] no_tlsv1_1= + def no_tlsv1_1=(tlsv1_1) + raise ArgumentError, "Invalid value of no_tlsv1_1=" unless ['true', 'false', true, false].include?(tlsv1_1) + @no_tlsv1_1 = tlsv1_1 + end + + end + + VERIFY_NONE = 0 + VERIFY_PEER = 1 + VERIFY_FAIL_IF_NO_PEER_CERT = 2 + + # https://github.com/openssl/openssl/blob/master/include/openssl/x509_vfy.h.in + # /* Certificate verify flags */ + VERIFICATION_FLAGS = { + "USE_CHECK_TIME" => 0x2, + "CRL_CHECK" => 0x4, + "CRL_CHECK_ALL" => 0x8, + "IGNORE_CRITICAL" => 0x10, + "X509_STRICT" => 0x20, + "ALLOW_PROXY_CERTS" => 0x40, + "POLICY_CHECK" => 0x80, + "EXPLICIT_POLICY" => 0x100, + "INHIBIT_ANY" => 0x200, + "INHIBIT_MAP" => 0x400, + "NOTIFY_POLICY" => 0x800, + "EXTENDED_CRL_SUPPORT" => 0x1000, + "USE_DELTAS" => 0x2000, + "CHECK_SS_SIGNATURE" => 0x4000, + "TRUSTED_FIRST" => 0x8000, + "SUITEB_128_LOS_ONLY" => 0x10000, + "SUITEB_192_LOS" => 0x20000, + "SUITEB_128_LOS" => 0x30000, + "PARTIAL_CHAIN" => 0x80000, + "NO_ALT_CHAINS" => 0x100000, + "NO_CHECK_TIME" => 0x200000 + }.freeze + + class Server + def initialize(socket, ctx) + @socket = socket + @ctx = ctx + @eng_ctx = IS_JRUBY ? @ctx : SSLContext.new(ctx) + end + + def accept + @ctx.check + io = @socket.accept + engine = Engine.server @eng_ctx + Socket.new io, engine + end + + def accept_nonblock + @ctx.check + io = @socket.accept_nonblock + engine = Engine.server @eng_ctx + Socket.new io, engine + end + + # @!attribute [r] to_io + def to_io + @socket + end + + # @!attribute [r] addr + # @version 5.0.0 + def addr + @socket.addr + end + + def close + @socket.close unless @socket.closed? # closed? call is for Windows + end + + def closed? + @socket.closed? + end + end + end +end diff --git a/lib/puma/minissl/context_builder.rb b/lib/puma/minissl/context_builder.rb new file mode 100644 index 0000000..8cf16bb --- /dev/null +++ b/lib/puma/minissl/context_builder.rb @@ -0,0 +1,81 @@ +module Puma + module MiniSSL + class ContextBuilder + def initialize(params, events) + @params = params + @events = events + end + + def context + ctx = MiniSSL::Context.new + + if defined?(JRUBY_VERSION) + unless params['keystore'] + events.error "Please specify the Java keystore via 'keystore='" + end + + ctx.keystore = params['keystore'] + + unless params['keystore-pass'] + events.error "Please specify the Java keystore password via 'keystore-pass='" + end + + ctx.keystore_pass = params['keystore-pass'] + ctx.ssl_cipher_list = params['ssl_cipher_list'] if params['ssl_cipher_list'] + else + if params['key'].nil? && params['key_pem'].nil? + events.error "Please specify the SSL key via 'key=' or 'key_pem='" + end + + ctx.key = params['key'] if params['key'] + ctx.key_pem = params['key_pem'] if params['key_pem'] + + if params['cert'].nil? && params['cert_pem'].nil? + events.error "Please specify the SSL cert via 'cert=' or 'cert_pem='" + end + + ctx.cert = params['cert'] if params['cert'] + ctx.cert_pem = params['cert_pem'] if params['cert_pem'] + + if ['peer', 'force_peer'].include?(params['verify_mode']) + unless params['ca'] + events.error "Please specify the SSL ca via 'ca='" + end + end + + ctx.ca = params['ca'] if params['ca'] + ctx.ssl_cipher_filter = params['ssl_cipher_filter'] if params['ssl_cipher_filter'] + end + + ctx.no_tlsv1 = true if params['no_tlsv1'] == 'true' + ctx.no_tlsv1_1 = true if params['no_tlsv1_1'] == 'true' + + if params['verify_mode'] + ctx.verify_mode = case params['verify_mode'] + when "peer" + MiniSSL::VERIFY_PEER + when "force_peer" + MiniSSL::VERIFY_PEER | MiniSSL::VERIFY_FAIL_IF_NO_PEER_CERT + when "none" + MiniSSL::VERIFY_NONE + else + events.error "Please specify a valid verify_mode=" + MiniSSL::VERIFY_NONE + end + end + + if params['verification_flags'] + ctx.verification_flags = params['verification_flags'].split(','). + map { |flag| MiniSSL::VERIFICATION_FLAGS.fetch(flag) }. + inject { |sum, flag| sum ? sum | flag : flag } + end + + ctx + end + + private + + attr_reader :params, :events + end + end +end diff --git a/lib/puma/null_io.rb b/lib/puma/null_io.rb new file mode 100644 index 0000000..71d410c --- /dev/null +++ b/lib/puma/null_io.rb @@ -0,0 +1,56 @@ +# frozen_string_literal: true + +module Puma + # Provides an IO-like object that always appears to contain no data. + # Used as the value for rack.input when the request has no body. + # + class NullIO + def gets + nil + end + + def string + "" + end + + def each + end + + # Mimics IO#read with no data. + # + def read(count = nil, _buffer = nil) + count && count > 0 ? nil : "" + end + + def rewind + end + + def close + end + + def size + 0 + end + + def eof? + true + end + + def sync + true + end + + def sync=(v) + end + + def puts(*ary) + end + + def write(*ary) + end + + def flush + self + end + end +end diff --git a/lib/puma/plugin.rb b/lib/puma/plugin.rb new file mode 100644 index 0000000..8a943b5 --- /dev/null +++ b/lib/puma/plugin.rb @@ -0,0 +1,111 @@ +# frozen_string_literal: true + +module Puma + class UnknownPlugin < RuntimeError; end + + class PluginLoader + def initialize + @instances = [] + end + + def create(name) + if cls = Plugins.find(name) + plugin = cls.new + @instances << plugin + return plugin + end + + raise UnknownPlugin, "File failed to register properly named plugin" + end + + def fire_starts(launcher) + @instances.each do |i| + if i.respond_to? :start + i.start(launcher) + end + end + end + end + + class PluginRegistry + def initialize + @plugins = {} + @background = [] + end + + def register(name, cls) + @plugins[name] = cls + end + + def find(name) + name = name.to_s + + if cls = @plugins[name] + return cls + end + + begin + require "puma/plugin/#{name}" + rescue LoadError + raise UnknownPlugin, "Unable to find plugin: #{name}" + end + + if cls = @plugins[name] + return cls + end + + raise UnknownPlugin, "file failed to register a plugin" + end + + def add_background(blk) + @background << blk + end + + def fire_background + @background.each_with_index do |b, i| + Thread.new do + Puma.set_thread_name "plgn bg #{i}" + b.call + end + end + end + end + + Plugins = PluginRegistry.new + + class Plugin + # Matches + # "C:/Ruby22/lib/ruby/gems/2.2.0/gems/puma-3.0.1/lib/puma/plugin/tmp_restart.rb:3:in `'" + # AS + # C:/Ruby22/lib/ruby/gems/2.2.0/gems/puma-3.0.1/lib/puma/plugin/tmp_restart.rb + CALLER_FILE = / + \A # start of string + .+ # file path (one or more characters) + (?= # stop previous match when + :\d+ # a colon is followed by one or more digits + :in # followed by a colon followed by in + ) + /x + + def self.extract_name(ary) + path = ary.first[CALLER_FILE] + + m = %r!puma/plugin/([^/]*)\.rb$!.match(path) + m[1] + end + + def self.create(&blk) + name = extract_name(caller) + + cls = Class.new(self) + + cls.class_eval(&blk) + + Plugins.register name, cls + end + + def in_background(&blk) + Plugins.add_background blk + end + end +end diff --git a/lib/puma/plugin/tmp_restart.rb b/lib/puma/plugin/tmp_restart.rb new file mode 100644 index 0000000..5e326bf --- /dev/null +++ b/lib/puma/plugin/tmp_restart.rb @@ -0,0 +1,36 @@ +# frozen_string_literal: true + +require 'puma/plugin' + +Puma::Plugin.create do + def start(launcher) + path = File.join("tmp", "restart.txt") + + orig = nil + + # If we can't write to the path, then just don't bother with this plugin + begin + File.write(path, "") unless File.exist?(path) + orig = File.stat(path).mtime + rescue SystemCallError + return + end + + in_background do + while true + sleep 2 + + begin + mtime = File.stat(path).mtime + rescue SystemCallError + # If the file has disappeared, assume that means don't restart + else + if mtime > orig + launcher.restart + break + end + end + end + end + end +end diff --git a/lib/puma/queue_close.rb b/lib/puma/queue_close.rb new file mode 100644 index 0000000..d9a94dc --- /dev/null +++ b/lib/puma/queue_close.rb @@ -0,0 +1,26 @@ +class ClosedQueueError < StandardError; end +module Puma + + # Queue#close was added in Ruby 2.3. + # Add a simple implementation for earlier Ruby versions. + # + module QueueClose + def close + num_waiting.times {push nil} + @closed = true + end + def closed? + @closed ||= false + end + def push(object) + raise ClosedQueueError if closed? + super + end + alias << push + def pop(non_block=false) + return nil if !non_block && closed? && empty? + super + end + end + ::Queue.prepend QueueClose +end diff --git a/lib/puma/rack/builder.rb b/lib/puma/rack/builder.rb new file mode 100644 index 0000000..2c46cb1 --- /dev/null +++ b/lib/puma/rack/builder.rb @@ -0,0 +1,297 @@ +# frozen_string_literal: true + +module Puma +end + +module Puma::Rack + class Options + def parse!(args) + options = {} + opt_parser = OptionParser.new("", 24, ' ') do |opts| + opts.banner = "Usage: rackup [ruby options] [rack options] [rackup config]" + + opts.separator "" + opts.separator "Ruby options:" + + lineno = 1 + opts.on("-e", "--eval LINE", "evaluate a LINE of code") { |line| + eval line, TOPLEVEL_BINDING, "-e", lineno + lineno += 1 + } + + opts.on("-b", "--builder BUILDER_LINE", "evaluate a BUILDER_LINE of code as a builder script") { |line| + options[:builder] = line + } + + opts.on("-d", "--debug", "set debugging flags (set $DEBUG to true)") { + options[:debug] = true + } + opts.on("-w", "--warn", "turn warnings on for your script") { + options[:warn] = true + } + opts.on("-q", "--quiet", "turn off logging") { + options[:quiet] = true + } + + opts.on("-I", "--include PATH", + "specify $LOAD_PATH (may be used more than once)") { |path| + (options[:include] ||= []).concat(path.split(":")) + } + + opts.on("-r", "--require LIBRARY", + "require the library, before executing your script") { |library| + options[:require] = library + } + + opts.separator "" + opts.separator "Rack options:" + opts.on("-s", "--server SERVER", "serve using SERVER (thin/puma/webrick/mongrel)") { |s| + options[:server] = s + } + + opts.on("-o", "--host HOST", "listen on HOST (default: localhost)") { |host| + options[:Host] = host + } + + opts.on("-p", "--port PORT", "use PORT (default: 9292)") { |port| + options[:Port] = port + } + + opts.on("-O", "--option NAME[=VALUE]", "pass VALUE to the server as option NAME. If no VALUE, sets it to true. Run '#{$0} -s SERVER -h' to get a list of options for SERVER") { |name| + name, value = name.split('=', 2) + value = true if value.nil? + options[name.to_sym] = value + } + + opts.on("-E", "--env ENVIRONMENT", "use ENVIRONMENT for defaults (default: development)") { |e| + options[:environment] = e + } + + opts.on("-P", "--pid FILE", "file to store PID") { |f| + options[:pid] = ::File.expand_path(f) + } + + opts.separator "" + opts.separator "Common options:" + + opts.on_tail("-h", "-?", "--help", "Show this message") do + puts opts + puts handler_opts(options) + + exit + end + + opts.on_tail("--version", "Show version") do + puts "Rack #{Rack.version} (Release: #{Rack.release})" + exit + end + end + + begin + opt_parser.parse! args + rescue OptionParser::InvalidOption => e + warn e.message + abort opt_parser.to_s + end + + options[:config] = args.last if args.last + options + end + + def handler_opts(options) + begin + info = [] + server = Rack::Handler.get(options[:server]) || Rack::Handler.default(options) + if server && server.respond_to?(:valid_options) + info << "" + info << "Server-specific options for #{server.name}:" + + has_options = false + server.valid_options.each do |name, description| + next if name.to_s =~ /^(Host|Port)[^a-zA-Z]/ # ignore handler's host and port options, we do our own. + + info << " -O %-21s %s" % [name, description] + has_options = true + end + return "" if !has_options + end + info.join("\n") + rescue NameError + return "Warning: Could not find handler specified (#{options[:server] || 'default'}) to determine handler-specific options" + end + end + end + + # Rack::Builder implements a small DSL to iteratively construct Rack + # applications. + # + # Example: + # + # require 'rack/lobster' + # app = Rack::Builder.new do + # use Rack::CommonLogger + # use Rack::ShowExceptions + # map "/lobster" do + # use Rack::Lint + # run Rack::Lobster.new + # end + # end + # + # run app + # + # Or + # + # app = Rack::Builder.app do + # use Rack::CommonLogger + # run lambda { |env| [200, {'Content-Type' => 'text/plain'}, ['OK']] } + # end + # + # run app + # + # +use+ adds middleware to the stack, +run+ dispatches to an application. + # You can use +map+ to construct a Rack::URLMap in a convenient way. + + class Builder + def self.parse_file(config, opts = Options.new) + options = {} + if config =~ /\.ru$/ + cfgfile = ::File.read(config) + if cfgfile[/^#\\(.*)/] && opts + options = opts.parse! $1.split(/\s+/) + end + cfgfile.sub!(/^__END__\n.*\Z/m, '') + app = new_from_string cfgfile, config + else + require config + app = Object.const_get(::File.basename(config, '.rb').capitalize) + end + [app, options] + end + + def self.new_from_string(builder_script, file="(rackup)") + eval "Puma::Rack::Builder.new {\n" + builder_script + "\n}.to_app", + TOPLEVEL_BINDING, file, 0 + end + + def initialize(default_app = nil,&block) + @use, @map, @run, @warmup = [], nil, default_app, nil + + # Conditionally load rack now, so that any rack middlewares, + # etc are available. + begin + require 'rack' + rescue LoadError + end + + instance_eval(&block) if block_given? + end + + def self.app(default_app = nil, &block) + self.new(default_app, &block).to_app + end + + # Specifies middleware to use in a stack. + # + # class Middleware + # def initialize(app) + # @app = app + # end + # + # def call(env) + # env["rack.some_header"] = "setting an example" + # @app.call(env) + # end + # end + # + # use Middleware + # run lambda { |env| [200, { "Content-Type" => "text/plain" }, ["OK"]] } + # + # All requests through to this application will first be processed by the middleware class. + # The +call+ method in this example sets an additional environment key which then can be + # referenced in the application if required. + def use(middleware, *args, &block) + if @map + mapping, @map = @map, nil + @use << proc { |app| generate_map app, mapping } + end + @use << proc { |app| middleware.new(app, *args, &block) } + end + + # Takes an argument that is an object that responds to #call and returns a Rack response. + # The simplest form of this is a lambda object: + # + # run lambda { |env| [200, { "Content-Type" => "text/plain" }, ["OK"]] } + # + # However this could also be a class: + # + # class Heartbeat + # def self.call(env) + # [200, { "Content-Type" => "text/plain" }, ["OK"]] + # end + # end + # + # run Heartbeat + def run(app) + @run = app + end + + # Takes a lambda or block that is used to warm-up the application. + # + # warmup do |app| + # client = Rack::MockRequest.new(app) + # client.get('/') + # end + # + # use SomeMiddleware + # run MyApp + def warmup(prc=nil, &block) + @warmup = prc || block + end + + # Creates a route within the application. + # + # Rack::Builder.app do + # map '/' do + # run Heartbeat + # end + # end + # + # The +use+ method can also be used here to specify middleware to run under a specific path: + # + # Rack::Builder.app do + # map '/' do + # use Middleware + # run Heartbeat + # end + # end + # + # This example includes a piece of middleware which will run before requests hit +Heartbeat+. + # + def map(path, &block) + @map ||= {} + @map[path] = block + end + + def to_app + app = @map ? generate_map(@run, @map) : @run + fail "missing run or map statement" unless app + app = @use.reverse.inject(app) { |a,e| e[a] } + @warmup.call(app) if @warmup + app + end + + def call(env) + to_app.call(env) + end + + private + + def generate_map(default_app, mapping) + require 'puma/rack/urlmap' + + mapped = default_app ? {'/' => default_app} : {} + mapping.each { |r,b| mapped[r] = self.class.new(default_app, &b).to_app } + URLMap.new(mapped) + end + end +end diff --git a/lib/puma/rack/urlmap.rb b/lib/puma/rack/urlmap.rb new file mode 100644 index 0000000..0d0a514 --- /dev/null +++ b/lib/puma/rack/urlmap.rb @@ -0,0 +1,93 @@ +# frozen_string_literal: true + +module Puma::Rack + # Rack::URLMap takes a hash mapping urls or paths to apps, and + # dispatches accordingly. Support for HTTP/1.1 host names exists if + # the URLs start with http:// or https://. + # + # URLMap modifies the SCRIPT_NAME and PATH_INFO such that the part + # relevant for dispatch is in the SCRIPT_NAME, and the rest in the + # PATH_INFO. This should be taken care of when you need to + # reconstruct the URL in order to create links. + # + # URLMap dispatches in such a way that the longest paths are tried + # first, since they are most specific. + + class URLMap + NEGATIVE_INFINITY = -1.0 / 0.0 + INFINITY = 1.0 / 0.0 + + def initialize(map = {}) + remap(map) + end + + def remap(map) + @mapping = map.map { |location, app| + if location =~ %r{\Ahttps?://(.*?)(/.*)} + host, location = $1, $2 + else + host = nil + end + + unless location[0] == ?/ + raise ArgumentError, "paths need to start with /" + end + + location = location.chomp('/') + match = Regexp.new("^#{Regexp.quote(location).gsub('/', '/+')}(.*)", nil, 'n') + + [host, location, match, app] + }.sort_by do |(host, location, _, _)| + [host ? -host.size : INFINITY, -location.size] + end + end + + def call(env) + path = env['PATH_INFO'] + script_name = env['SCRIPT_NAME'] + http_host = env['HTTP_HOST'] + server_name = env['SERVER_NAME'] + server_port = env['SERVER_PORT'] + + is_same_server = casecmp?(http_host, server_name) || + casecmp?(http_host, "#{server_name}:#{server_port}") + + @mapping.each do |host, location, match, app| + unless casecmp?(http_host, host) \ + || casecmp?(server_name, host) \ + || (!host && is_same_server) + next + end + + next unless m = match.match(path.to_s) + + rest = m[1] + next unless !rest || rest.empty? || rest[0] == ?/ + + env['SCRIPT_NAME'] = (script_name + location) + env['PATH_INFO'] = rest + + return app.call(env) + end + + [404, {'Content-Type' => "text/plain", "X-Cascade" => "pass"}, ["Not Found: #{path}"]] + + ensure + env['PATH_INFO'] = path + env['SCRIPT_NAME'] = script_name + end + + private + def casecmp?(v1, v2) + # if both nil, or they're the same string + return true if v1 == v2 + + # if either are nil... (but they're not the same) + return false if v1.nil? + return false if v2.nil? + + # otherwise check they're not case-insensitive the same + v1.casecmp(v2).zero? + end + end +end diff --git a/lib/puma/rack_default.rb b/lib/puma/rack_default.rb new file mode 100644 index 0000000..016f54d --- /dev/null +++ b/lib/puma/rack_default.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +require 'rack/handler/puma' + +module Rack::Handler + def self.default(options = {}) + Rack::Handler::Puma + end +end diff --git a/lib/puma/reactor.rb b/lib/puma/reactor.rb new file mode 100644 index 0000000..463d304 --- /dev/null +++ b/lib/puma/reactor.rb @@ -0,0 +1,116 @@ +# frozen_string_literal: true + +require 'puma/queue_close' unless ::Queue.instance_methods.include? :close + +module Puma + class UnsupportedBackend < StandardError; end + + # Monitors a collection of IO objects, calling a block whenever + # any monitored object either receives data or times out, or when the Reactor shuts down. + # + # The waiting/wake up is performed with nio4r, which will use the appropriate backend (libev, + # Java NIO or just plain IO#select). The call to `NIO::Selector#select` will + # 'wakeup' any IO object that receives data. + # + # This class additionally tracks a timeout for every added object, + # and wakes up any object when its timeout elapses. + # + # The implementation uses a Queue to synchronize adding new objects from the internal select loop. + class Reactor + # Create a new Reactor to monitor IO objects added by #add. + # The provided block will be invoked when an IO has data available to read, + # its timeout elapses, or when the Reactor shuts down. + def initialize(backend, &block) + require 'nio' + unless backend == :auto || NIO::Selector.backends.include?(backend) + raise "unsupported IO selector backend: #{backend} (available backends: #{NIO::Selector.backends.join(', ')})" + end + @selector = backend == :auto ? NIO::Selector.new : NIO::Selector.new(backend) + @input = Queue.new + @timeouts = [] + @block = block + end + + # Run the internal select loop, using a background thread by default. + def run(background=true) + if background + @thread = Thread.new do + Puma.set_thread_name "reactor" + select_loop + end + else + select_loop + end + end + + # Add a new client to monitor. + # The object must respond to #timeout and #timeout_at. + # Returns false if the reactor is already shut down. + def add(client) + @input << client + @selector.wakeup + true + rescue ClosedQueueError + false + end + + # Shutdown the reactor, blocking until the background thread is finished. + def shutdown + @input.close + begin + @selector.wakeup + rescue IOError # Ignore if selector is already closed + end + @thread.join if @thread + end + + private + + def select_loop + begin + until @input.closed? && @input.empty? + # Wakeup any registered object that receives incoming data. + # Block until the earliest timeout or Selector#wakeup is called. + timeout = (earliest = @timeouts.first) && earliest.timeout + @selector.select(timeout) {|mon| wakeup!(mon.value)} + + # Wakeup all objects that timed out. + timed_out = @timeouts.take_while {|t| t.timeout == 0} + timed_out.each(&method(:wakeup!)) + + unless @input.empty? + until @input.empty? + client = @input.pop + register(client) if client.io_ok? + end + @timeouts.sort_by!(&:timeout_at) + end + end + rescue StandardError => e + STDERR.puts "Error in reactor loop escaped: #{e.message} (#{e.class})" + STDERR.puts e.backtrace + retry + end + # Wakeup all remaining objects on shutdown. + @timeouts.each(&@block) + @selector.close + end + + # Start monitoring the object. + def register(client) + @selector.register(client.to_io, :r).value = client + @timeouts << client + rescue ArgumentError + # unreadable clients raise error when processed by NIO + end + + # 'Wake up' a monitored object by calling the provided block. + # Stop monitoring the object if the block returns `true`. + def wakeup!(client) + if @block.call client + @selector.deregister client.to_io + @timeouts.delete client + end + end + end +end diff --git a/lib/puma/request.rb b/lib/puma/request.rb new file mode 100644 index 0000000..698cea6 --- /dev/null +++ b/lib/puma/request.rb @@ -0,0 +1,472 @@ +# frozen_string_literal: true + +module Puma + + # The methods here are included in Server, but are separated into this file. + # All the methods here pertain to passing the request to the app, then + # writing the response back to the client. + # + # None of the methods here are called externally, with the exception of + # #handle_request, which is called in Server#process_client. + # @version 5.0.3 + # + module Request + + include Puma::Const + + # Takes the request contained in +client+, invokes the Rack application to construct + # the response and writes it back to +client.io+. + # + # It'll return +false+ when the connection is closed, this doesn't mean + # that the response wasn't successful. + # + # It'll return +:async+ if the connection remains open but will be handled + # elsewhere, i.e. the connection has been hijacked by the Rack application. + # + # Finally, it'll return +true+ on keep-alive connections. + # @param client [Puma::Client] + # @param lines [Puma::IOBuffer] + # @param requests [Integer] + # @return [Boolean,:async] + # + def handle_request(client, lines, requests) + env = client.env + io = client.io # io may be a MiniSSL::Socket + + return false if closed_socket?(io) + + normalize_env env, client + + env[PUMA_SOCKET] = io + + if env[HTTPS_KEY] && io.peercert + env[PUMA_PEERCERT] = io.peercert + end + + env[HIJACK_P] = true + env[HIJACK] = client + + body = client.body + + head = env[REQUEST_METHOD] == HEAD + + env[RACK_INPUT] = body + env[RACK_URL_SCHEME] ||= default_server_port(env) == PORT_443 ? HTTPS : HTTP + + if @early_hints + env[EARLY_HINTS] = lambda { |headers| + begin + fast_write io, str_early_hints(headers) + rescue ConnectionError => e + @events.debug_error e + # noop, if we lost the socket we just won't send the early hints + end + } + end + + req_env_post_parse env + + # A rack extension. If the app writes #call'ables to this + # array, we will invoke them when the request is done. + # + after_reply = env[RACK_AFTER_REPLY] = [] + + begin + begin + status, headers, res_body = @thread_pool.with_force_shutdown do + @app.call(env) + end + + return :async if client.hijacked + + status = status.to_i + + if status == -1 + unless headers.empty? and res_body == [] + raise "async response must have empty headers and body" + end + + return :async + end + rescue ThreadPool::ForceShutdown => e + @events.unknown_error e, client, "Rack app" + @events.log "Detected force shutdown of a thread" + + status, headers, res_body = lowlevel_error(e, env, 503) + rescue Exception => e + @events.unknown_error e, client, "Rack app" + + status, headers, res_body = lowlevel_error(e, env, 500) + end + + res_info = {} + res_info[:content_length] = nil + res_info[:no_body] = head + + res_info[:content_length] = if res_body.kind_of? Array and res_body.size == 1 + res_body[0].bytesize + else + nil + end + + cork_socket io + + str_headers(env, status, headers, res_info, lines, requests, client) + + line_ending = LINE_END + + content_length = res_info[:content_length] + response_hijack = res_info[:response_hijack] + + if res_info[:no_body] + if content_length and status != 204 + lines.append CONTENT_LENGTH_S, content_length.to_s, line_ending + end + + lines << LINE_END + fast_write io, lines.to_s + return res_info[:keep_alive] + end + + if content_length + lines.append CONTENT_LENGTH_S, content_length.to_s, line_ending + chunked = false + elsif !response_hijack and res_info[:allow_chunked] + lines << TRANSFER_ENCODING_CHUNKED + chunked = true + end + + lines << line_ending + + fast_write io, lines.to_s + + if response_hijack + response_hijack.call io + return :async + end + + begin + res_body.each do |part| + next if part.bytesize.zero? + if chunked + fast_write io, (part.bytesize.to_s(16) << line_ending) + fast_write io, part # part may have different encoding + fast_write io, line_ending + else + fast_write io, part + end + io.flush + end + + if chunked + fast_write io, CLOSE_CHUNKED + io.flush + end + rescue SystemCallError, IOError + raise ConnectionError, "Connection error detected during write" + end + + ensure + begin + uncork_socket io + + body.close + client.tempfile.unlink if client.tempfile + ensure + # Whatever happens, we MUST call `close` on the response body. + # Otherwise Rack::BodyProxy callbacks may not fire and lead to various state leaks + res_body.close if res_body.respond_to? :close + end + + after_reply.each { |o| o.call } + end + + res_info[:keep_alive] + end + + # @param env [Hash] see Puma::Client#env, from request + # @return [Puma::Const::PORT_443,Puma::Const::PORT_80] + # + def default_server_port(env) + if ['on', HTTPS].include?(env[HTTPS_KEY]) || env[HTTP_X_FORWARDED_PROTO].to_s[0...5] == HTTPS || env[HTTP_X_FORWARDED_SCHEME] == HTTPS || env[HTTP_X_FORWARDED_SSL] == "on" + PORT_443 + else + PORT_80 + end + end + + # Writes to an io (normally Client#io) using #syswrite + # @param io [#syswrite] the io to write to + # @param str [String] the string written to the io + # @raise [ConnectionError] + # + def fast_write(io, str) + n = 0 + while true + begin + n = io.syswrite str + rescue Errno::EAGAIN, Errno::EWOULDBLOCK + unless io.wait_writable WRITE_TIMEOUT + raise ConnectionError, "Socket timeout writing data" + end + + retry + rescue Errno::EPIPE, SystemCallError, IOError + raise ConnectionError, "Socket timeout writing data" + end + + return if n == str.bytesize + str = str.byteslice(n..-1) + end + end + private :fast_write + + # @param status [Integer] status from the app + # @return [String] the text description from Puma::HTTP_STATUS_CODES + # + def fetch_status_code(status) + HTTP_STATUS_CODES.fetch(status) { 'CUSTOM' } + end + private :fetch_status_code + + # Given a Hash +env+ for the request read from +client+, add + # and fixup keys to comply with Rack's env guidelines. + # @param env [Hash] see Puma::Client#env, from request + # @param client [Puma::Client] only needed for Client#peerip + # @todo make private in 6.0.0 + # + def normalize_env(env, client) + if host = env[HTTP_HOST] + # host can be a hostname, ipv4 or bracketed ipv6. Followed by an optional port. + if colon = host.rindex("]:") # IPV6 with port + env[SERVER_NAME] = host[0, colon+1] + env[SERVER_PORT] = host[colon+2, host.bytesize] + elsif !host.start_with?("[") && colon = host.index(":") # not hostname or IPV4 with port + env[SERVER_NAME] = host[0, colon] + env[SERVER_PORT] = host[colon+1, host.bytesize] + else + env[SERVER_NAME] = host + env[SERVER_PORT] = default_server_port(env) + end + else + env[SERVER_NAME] = LOCALHOST + env[SERVER_PORT] = default_server_port(env) + end + + unless env[REQUEST_PATH] + # it might be a dumbass full host request header + uri = URI.parse(env[REQUEST_URI]) + env[REQUEST_PATH] = uri.path + + raise "No REQUEST PATH" unless env[REQUEST_PATH] + + # A nil env value will cause a LintError (and fatal errors elsewhere), + # so only set the env value if there actually is a value. + env[QUERY_STRING] = uri.query if uri.query + end + + env[PATH_INFO] = env[REQUEST_PATH] + + # From https://www.ietf.org/rfc/rfc3875 : + # "Script authors should be aware that the REMOTE_ADDR and + # REMOTE_HOST meta-variables (see sections 4.1.8 and 4.1.9) + # may not identify the ultimate source of the request. + # They identify the client for the immediate request to the + # server; that client may be a proxy, gateway, or other + # intermediary acting on behalf of the actual source client." + # + + unless env.key?(REMOTE_ADDR) + begin + addr = client.peerip + rescue Errno::ENOTCONN + # Client disconnects can result in an inability to get the + # peeraddr from the socket; default to localhost. + addr = LOCALHOST_IP + end + + # Set unix socket addrs to localhost + addr = LOCALHOST_IP if addr.empty? + + env[REMOTE_ADDR] = addr + end + end + # private :normalize_env + + # @param header_key [#to_s] + # @return [Boolean] + # + def illegal_header_key?(header_key) + !!(ILLEGAL_HEADER_KEY_REGEX =~ header_key.to_s) + end + + # @param header_value [#to_s] + # @return [Boolean] + # + def illegal_header_value?(header_value) + !!(ILLEGAL_HEADER_VALUE_REGEX =~ header_value.to_s) + end + private :illegal_header_key?, :illegal_header_value? + + # Fixup any headers with `,` in the name to have `_` now. We emit + # headers with `,` in them during the parse phase to avoid ambiguity + # with the `-` to `_` conversion for critical headers. But here for + # compatibility, we'll convert them back. This code is written to + # avoid allocation in the common case (ie there are no headers + # with `,` in their names), that's why it has the extra conditionals. + # @param env [Hash] see Puma::Client#env, from request, modifies in place + # @version 5.0.3 + # + def req_env_post_parse(env) + to_delete = nil + to_add = nil + + env.each do |k,v| + if k.start_with?("HTTP_") and k.include?(",") and k != "HTTP_TRANSFER,ENCODING" + if to_delete + to_delete << k + else + to_delete = [k] + end + + unless to_add + to_add = {} + end + + to_add[k.tr(",", "_")] = v + end + end + + if to_delete + to_delete.each { |k| env.delete(k) } + env.merge! to_add + end + end + private :req_env_post_parse + + # Used in the lambda for env[ `Puma::Const::EARLY_HINTS` ] + # @param headers [Hash] the headers returned by the Rack application + # @return [String] + # @version 5.0.3 + # + def str_early_hints(headers) + eh_str = "HTTP/1.1 103 Early Hints\r\n".dup + headers.each_pair do |k, vs| + next if illegal_header_key?(k) + + if vs.respond_to?(:to_s) && !vs.to_s.empty? + vs.to_s.split(NEWLINE).each do |v| + next if illegal_header_value?(v) + eh_str << "#{k}: #{v}\r\n" + end + else + eh_str << "#{k}: #{vs}\r\n" + end + end + "#{eh_str}\r\n".freeze + end + private :str_early_hints + + # Processes and write headers to the IOBuffer. + # @param env [Hash] see Puma::Client#env, from request + # @param status [Integer] the status returned by the Rack application + # @param headers [Hash] the headers returned by the Rack application + # @param res_info [Hash] used to pass info between this method and #handle_request + # @param lines [Puma::IOBuffer] modified inn place + # @param requests [Integer] number of inline requests handled + # @param client [Puma::Client] + # @version 5.0.3 + # + def str_headers(env, status, headers, res_info, lines, requests, client) + line_ending = LINE_END + colon = COLON + + http_11 = env[HTTP_VERSION] == HTTP_11 + if http_11 + res_info[:allow_chunked] = true + res_info[:keep_alive] = env.fetch(HTTP_CONNECTION, "").downcase != CLOSE + + # An optimization. The most common response is 200, so we can + # reply with the proper 200 status without having to compute + # the response header. + # + if status == 200 + lines << HTTP_11_200 + else + lines.append "HTTP/1.1 ", status.to_s, " ", + fetch_status_code(status), line_ending + + res_info[:no_body] ||= status < 200 || STATUS_WITH_NO_ENTITY_BODY[status] + end + else + res_info[:allow_chunked] = false + res_info[:keep_alive] = env.fetch(HTTP_CONNECTION, "").downcase == KEEP_ALIVE + + # Same optimization as above for HTTP/1.1 + # + if status == 200 + lines << HTTP_10_200 + else + lines.append "HTTP/1.0 ", status.to_s, " ", + fetch_status_code(status), line_ending + + res_info[:no_body] ||= status < 200 || STATUS_WITH_NO_ENTITY_BODY[status] + end + end + + # regardless of what the client wants, we always close the connection + # if running without request queueing + res_info[:keep_alive] &&= @queue_requests + + # Close the connection after a reasonable number of inline requests + # if the server is at capacity and the listener has a new connection ready. + # This allows Puma to service connections fairly when the number + # of concurrent connections exceeds the size of the threadpool. + res_info[:keep_alive] &&= requests < @max_fast_inline || + @thread_pool.busy_threads < @max_threads || + !client.listener.to_io.wait_readable(0) + + res_info[:response_hijack] = nil + + headers.each do |k, vs| + next if illegal_header_key?(k) + + case k.downcase + when CONTENT_LENGTH2 + next if illegal_header_value?(vs) + res_info[:content_length] = vs + next + when TRANSFER_ENCODING + res_info[:allow_chunked] = false + res_info[:content_length] = nil + when HIJACK + res_info[:response_hijack] = vs + next + when BANNED_HEADER_KEY + next + end + + if vs.respond_to?(:to_s) && !vs.to_s.empty? + vs.to_s.split(NEWLINE).each do |v| + next if illegal_header_value?(v) + lines.append k, colon, v, line_ending + end + else + lines.append k, colon, line_ending + end + end + + # HTTP/1.1 & 1.0 assume different defaults: + # - HTTP 1.0 assumes the connection will be closed if not specified + # - HTTP 1.1 assumes the connection will be kept alive if not specified. + # Only set the header if we're doing something which is not the default + # for this protocol version + if http_11 + lines << CONNECTION_CLOSE if !res_info[:keep_alive] + else + lines << CONNECTION_KEEP_ALIVE if res_info[:keep_alive] + end + end + private :str_headers + end +end diff --git a/lib/puma/runner.rb b/lib/puma/runner.rb new file mode 100644 index 0000000..ed75a52 --- /dev/null +++ b/lib/puma/runner.rb @@ -0,0 +1,177 @@ +# frozen_string_literal: true + +require 'puma/server' +require 'puma/const' + +module Puma + # Generic class that is used by `Puma::Cluster` and `Puma::Single` to + # serve requests. This class spawns a new instance of `Puma::Server` via + # a call to `start_server`. + class Runner + def initialize(cli, events) + @launcher = cli + @events = events + @options = cli.options + @app = nil + @control = nil + @started_at = Time.now + @wakeup = nil + end + + def wakeup! + return unless @wakeup + + @wakeup.write "!" unless @wakeup.closed? + + rescue SystemCallError, IOError + Puma::Util.purge_interrupt_queue + end + + def development? + @options[:environment] == "development" + end + + def test? + @options[:environment] == "test" + end + + def log(str) + @events.log str + end + + # @version 5.0.0 + def stop_control + @control.stop(true) if @control + end + + def error(str) + @events.error str + end + + def debug(str) + @events.log "- #{str}" if @options[:debug] + end + + def start_control + str = @options[:control_url] + return unless str + + require 'puma/app/status' + + if token = @options[:control_auth_token] + token = nil if token.empty? || token == 'none' + end + + app = Puma::App::Status.new @launcher, token + + control = Puma::Server.new app, @launcher.events, + { min_threads: 0, max_threads: 1, queue_requests: false } + + control.binder.parse [str], self, 'Starting control server' + + control.run thread_name: 'ctl' + @control = control + end + + # @version 5.0.0 + def close_control_listeners + @control.binder.close_listeners if @control + end + + # @!attribute [r] ruby_engine + def ruby_engine + if !defined?(RUBY_ENGINE) || RUBY_ENGINE == "ruby" + "ruby #{RUBY_VERSION}-p#{RUBY_PATCHLEVEL}" + else + if defined?(RUBY_ENGINE_VERSION) + "#{RUBY_ENGINE} #{RUBY_ENGINE_VERSION} - ruby #{RUBY_VERSION}" + else + "#{RUBY_ENGINE} #{RUBY_VERSION}" + end + end + end + + def output_header(mode) + min_t = @options[:min_threads] + max_t = @options[:max_threads] + environment = @options[:environment] + + log "Puma starting in #{mode} mode..." + log "* Puma version: #{Puma::Const::PUMA_VERSION} (#{ruby_engine}) (\"#{Puma::Const::CODE_NAME}\")" + log "* Min threads: #{min_t}" + log "* Max threads: #{max_t}" + log "* Environment: #{environment}" + + if mode == "cluster" + log "* Master PID: #{Process.pid}" + else + log "* PID: #{Process.pid}" + end + end + + def redirected_io? + @options[:redirect_stdout] || @options[:redirect_stderr] + end + + def redirect_io + stdout = @options[:redirect_stdout] + stderr = @options[:redirect_stderr] + append = @options[:redirect_append] + + if stdout + ensure_output_directory_exists(stdout, 'STDOUT') + + STDOUT.reopen stdout, (append ? "a" : "w") + STDOUT.puts "=== puma startup: #{Time.now} ===" + STDOUT.flush unless STDOUT.sync + end + + if stderr + ensure_output_directory_exists(stderr, 'STDERR') + + STDERR.reopen stderr, (append ? "a" : "w") + STDERR.puts "=== puma startup: #{Time.now} ===" + STDERR.flush unless STDERR.sync + end + + if @options[:mutate_stdout_and_stderr_to_sync_on_write] + STDOUT.sync = true + STDERR.sync = true + end + end + + def load_and_bind + unless @launcher.config.app_configured? + error "No application configured, nothing to run" + exit 1 + end + + begin + @app = @launcher.config.app + rescue Exception => e + log "! Unable to load application: #{e.class}: #{e.message}" + raise e + end + + @launcher.binder.parse @options[:binds], self + end + + # @!attribute [r] app + def app + @app ||= @launcher.config.app + end + + def start_server + server = Puma::Server.new app, @launcher.events, @options + server.inherit_binder @launcher.binder + server + end + + private + def ensure_output_directory_exists(path, io_name) + unless Dir.exist?(File.dirname(path)) + raise "Cannot redirect #{io_name} to #{path}" + end + end + end +end diff --git a/lib/puma/server.rb b/lib/puma/server.rb new file mode 100644 index 0000000..9323d1b --- /dev/null +++ b/lib/puma/server.rb @@ -0,0 +1,627 @@ +# frozen_string_literal: true + +require 'stringio' + +require 'puma/thread_pool' +require 'puma/const' +require 'puma/events' +require 'puma/null_io' +require 'puma/reactor' +require 'puma/client' +require 'puma/binder' +require 'puma/util' +require 'puma/io_buffer' +require 'puma/request' + +require 'socket' +require 'io/wait' +require 'forwardable' + +module Puma + + # The HTTP Server itself. Serves out a single Rack app. + # + # This class is used by the `Puma::Single` and `Puma::Cluster` classes + # to generate one or more `Puma::Server` instances capable of handling requests. + # Each Puma process will contain one `Puma::Server` instance. + # + # The `Puma::Server` instance pulls requests from the socket, adds them to a + # `Puma::Reactor` where they get eventually passed to a `Puma::ThreadPool`. + # + # Each `Puma::Server` will have one reactor and one thread pool. + class Server + + include Puma::Const + include Request + extend Forwardable + + attr_reader :thread + attr_reader :events + attr_reader :min_threads, :max_threads # for #stats + attr_reader :requests_count # @version 5.0.0 + + # @todo the following may be deprecated in the future + attr_reader :auto_trim_time, :early_hints, :first_data_timeout, + :leak_stack_on_error, + :persistent_timeout, :reaping_time + + # @deprecated v6.0.0 + attr_writer :auto_trim_time, :early_hints, :first_data_timeout, + :leak_stack_on_error, :min_threads, :max_threads, + :persistent_timeout, :reaping_time + + attr_accessor :app + attr_accessor :binder + + def_delegators :@binder, :add_tcp_listener, :add_ssl_listener, + :add_unix_listener, :connected_ports + + ThreadLocalKey = :puma_server + + # Create a server for the rack app +app+. + # + # +events+ is an object which will be called when certain error events occur + # to be handled. See Puma::Events for the list of current methods to implement. + # + # Server#run returns a thread that you can join on to wait for the server + # to do its work. + # + # @note Several instance variables exist so they are available for testing, + # and have default values set via +fetch+. Normally the values are set via + # `::Puma::Configuration.puma_default_options`. + # + def initialize(app, events=Events.stdio, options={}) + @app = app + @events = events + + @check, @notify = nil + @status = :stop + + @auto_trim_time = 30 + @reaping_time = 1 + + @thread = nil + @thread_pool = nil + + @options = options + + @early_hints = options.fetch :early_hints, nil + @first_data_timeout = options.fetch :first_data_timeout, FIRST_DATA_TIMEOUT + @min_threads = options.fetch :min_threads, 0 + @max_threads = options.fetch :max_threads , (Puma.mri? ? 5 : 16) + @persistent_timeout = options.fetch :persistent_timeout, PERSISTENT_TIMEOUT + @queue_requests = options.fetch :queue_requests, true + @max_fast_inline = options.fetch :max_fast_inline, MAX_FAST_INLINE + @io_selector_backend = options.fetch :io_selector_backend, :auto + + temp = !!(@options[:environment] =~ /\A(development|test)\z/) + @leak_stack_on_error = @options[:environment] ? temp : true + + @binder = Binder.new(events) + + ENV['RACK_ENV'] ||= "development" + + @mode = :http + + @precheck_closing = true + + @requests_count = 0 + end + + def inherit_binder(bind) + @binder = bind + end + + class << self + # @!attribute [r] current + def current + Thread.current[ThreadLocalKey] + end + + # :nodoc: + # @version 5.0.0 + def tcp_cork_supported? + Socket.const_defined?(:TCP_CORK) && Socket.const_defined?(:IPPROTO_TCP) + end + + # :nodoc: + # @version 5.0.0 + def closed_socket_supported? + Socket.const_defined?(:TCP_INFO) && Socket.const_defined?(:IPPROTO_TCP) + end + private :tcp_cork_supported? + private :closed_socket_supported? + end + + # On Linux, use TCP_CORK to better control how the TCP stack + # packetizes our stream. This improves both latency and throughput. + # socket parameter may be an MiniSSL::Socket, so use to_io + # + if tcp_cork_supported? + # 6 == Socket::IPPROTO_TCP + # 3 == TCP_CORK + # 1/0 == turn on/off + def cork_socket(socket) + skt = socket.to_io + begin + skt.setsockopt(Socket::IPPROTO_TCP, Socket::TCP_CORK, 1) if skt.kind_of? TCPSocket + rescue IOError, SystemCallError + Puma::Util.purge_interrupt_queue + end + end + + def uncork_socket(socket) + skt = socket.to_io + begin + skt.setsockopt(Socket::IPPROTO_TCP, Socket::TCP_CORK, 0) if skt.kind_of? TCPSocket + rescue IOError, SystemCallError + Puma::Util.purge_interrupt_queue + end + end + else + def cork_socket(socket) + end + + def uncork_socket(socket) + end + end + + if closed_socket_supported? + UNPACK_TCP_STATE_FROM_TCP_INFO = "C".freeze + + def closed_socket?(socket) + skt = socket.to_io + return false unless skt.kind_of?(TCPSocket) && @precheck_closing + + begin + tcp_info = skt.getsockopt(Socket::IPPROTO_TCP, Socket::TCP_INFO) + rescue IOError, SystemCallError + Puma::Util.purge_interrupt_queue + @precheck_closing = false + false + else + state = tcp_info.unpack(UNPACK_TCP_STATE_FROM_TCP_INFO)[0] + # TIME_WAIT: 6, CLOSE: 7, CLOSE_WAIT: 8, LAST_ACK: 9, CLOSING: 11 + (state >= 6 && state <= 9) || state == 11 + end + end + else + def closed_socket?(socket) + false + end + end + + # @!attribute [r] backlog + def backlog + @thread_pool and @thread_pool.backlog + end + + # @!attribute [r] running + def running + @thread_pool and @thread_pool.spawned + end + + + # This number represents the number of requests that + # the server is capable of taking right now. + # + # For example if the number is 5 then it means + # there are 5 threads sitting idle ready to take + # a request. If one request comes in, then the + # value would be 4 until it finishes processing. + # @!attribute [r] pool_capacity + def pool_capacity + @thread_pool and @thread_pool.pool_capacity + end + + # Runs the server. + # + # If +background+ is true (the default) then a thread is spun + # up in the background to handle requests. Otherwise requests + # are handled synchronously. + # + def run(background=true, thread_name: 'srv') + BasicSocket.do_not_reverse_lookup = true + + @events.fire :state, :booting + + @status = :run + + @thread_pool = ThreadPool.new( + thread_name, + @min_threads, + @max_threads, + ::Puma::IOBuffer, + &method(:process_client) + ) + + @thread_pool.out_of_band_hook = @options[:out_of_band] + @thread_pool.clean_thread_locals = @options[:clean_thread_locals] + + if @queue_requests + @reactor = Reactor.new(@io_selector_backend, &method(:reactor_wakeup)) + @reactor.run + end + + if @reaping_time + @thread_pool.auto_reap!(@reaping_time) + end + + if @auto_trim_time + @thread_pool.auto_trim!(@auto_trim_time) + end + + @check, @notify = Puma::Util.pipe unless @notify + + @events.fire :state, :running + + if background + @thread = Thread.new do + Puma.set_thread_name thread_name + handle_servers + end + return @thread + else + handle_servers + end + end + + # This method is called from the Reactor thread when a queued Client receives data, + # times out, or when the Reactor is shutting down. + # + # It is responsible for ensuring that a request has been completely received + # before it starts to be processed by the ThreadPool. This may be known as read buffering. + # If read buffering is not done, and no other read buffering is performed (such as by an application server + # such as nginx) then the application would be subject to a slow client attack. + # + # For a graphical representation of how the request buffer works see [architecture.md](https://github.com/puma/puma/blob/master/docs/architecture.md#connection-pipeline). + # + # The method checks to see if it has the full header and body with + # the `Puma::Client#try_to_finish` method. If the full request has been sent, + # then the request is passed to the ThreadPool (`@thread_pool << client`) + # so that a "worker thread" can pick up the request and begin to execute application logic. + # The Client is then removed from the reactor (return `true`). + # + # If a client object times out, a 408 response is written, its connection is closed, + # and the object is removed from the reactor (return `true`). + # + # If the Reactor is shutting down, all Clients are either timed out or passed to the + # ThreadPool, depending on their current state (#can_close?). + # + # Otherwise, if the full request is not ready then the client will remain in the reactor + # (return `false`). When the client sends more data to the socket the `Puma::Client` object + # will wake up and again be checked to see if it's ready to be passed to the thread pool. + def reactor_wakeup(client) + shutdown = !@queue_requests + if client.try_to_finish || (shutdown && !client.can_close?) + @thread_pool << client + elsif shutdown || client.timeout == 0 + client.timeout! + else + client.set_timeout(@first_data_timeout) + false + end + rescue StandardError => e + client_error(e, client) + client.close + true + end + + def handle_servers + begin + check = @check + sockets = [check] + @binder.ios + pool = @thread_pool + queue_requests = @queue_requests + drain = @options[:drain_on_shutdown] ? 0 : nil + + addr_send_name, addr_value = case @options[:remote_address] + when :value + [:peerip=, @options[:remote_address_value]] + when :header + [:remote_addr_header=, @options[:remote_address_header]] + when :proxy_protocol + [:expect_proxy_proto=, @options[:remote_address_proxy_protocol]] + else + [nil, nil] + end + + while @status == :run || (drain && shutting_down?) + begin + ios = IO.select sockets, nil, nil, (shutting_down? ? 0 : nil) + break unless ios + ios.first.each do |sock| + if sock == check + break if handle_check + else + pool.wait_until_not_full + pool.wait_for_less_busy_worker(@options[:wait_for_less_busy_worker]) + + io = begin + sock.accept_nonblock + rescue IO::WaitReadable + next + end + drain += 1 if shutting_down? + pool << Client.new(io, @binder.env(sock)).tap { |c| + c.listener = sock + c.send(addr_send_name, addr_value) if addr_value + } + end + end + rescue IOError, Errno::EBADF + # In the case that any of the sockets are unexpectedly close. + raise + rescue StandardError => e + @events.unknown_error e, nil, "Listen loop" + end + end + + @events.debug "Drained #{drain} additional connections." if drain + @events.fire :state, @status + + if queue_requests + @queue_requests = false + @reactor.shutdown + end + graceful_shutdown if @status == :stop || @status == :restart + rescue Exception => e + @events.unknown_error e, nil, "Exception handling servers" + ensure + # RuntimeError is Ruby 2.2 issue, can't modify frozen IOError + # Errno::EBADF is infrequently raised + [@check, @notify].each do |io| + begin + io.close unless io.closed? + rescue Errno::EBADF, RuntimeError + end + end + @notify = nil + @check = nil + end + + @events.fire :state, :done + end + + # :nodoc: + def handle_check + cmd = @check.read(1) + + case cmd + when STOP_COMMAND + @status = :stop + return true + when HALT_COMMAND + @status = :halt + return true + when RESTART_COMMAND + @status = :restart + return true + end + + false + end + + # Given a connection on +client+, handle the incoming requests, + # or queue the connection in the Reactor if no request is available. + # + # This method is called from a ThreadPool worker thread. + # + # This method supports HTTP Keep-Alive so it may, depending on if the client + # indicates that it supports keep alive, wait for another request before + # returning. + # + # Return true if one or more requests were processed. + def process_client(client, buffer) + # Advertise this server into the thread + Thread.current[ThreadLocalKey] = self + + clean_thread_locals = @options[:clean_thread_locals] + close_socket = true + + requests = 0 + + begin + if @queue_requests && + !client.eagerly_finish + + client.set_timeout(@first_data_timeout) + if @reactor.add client + close_socket = false + return false + end + end + + with_force_shutdown(client) do + client.finish(@first_data_timeout) + end + + while true + @requests_count += 1 + case handle_request(client, buffer, requests + 1) + when false + break + when :async + close_socket = false + break + when true + buffer.reset + + ThreadPool.clean_thread_locals if clean_thread_locals + + requests += 1 + + # As an optimization, try to read the next request from the + # socket for a short time before returning to the reactor. + fast_check = @status == :run + + # Always pass the client back to the reactor after a reasonable + # number of inline requests if there are other requests pending. + fast_check = false if requests >= @max_fast_inline && + @thread_pool.backlog > 0 + + next_request_ready = with_force_shutdown(client) do + client.reset(fast_check) + end + + unless next_request_ready + break unless @queue_requests + client.set_timeout @persistent_timeout + if @reactor.add client + close_socket = false + break + end + end + end + end + true + rescue StandardError => e + client_error(e, client) + # The ensure tries to close +client+ down + requests > 0 + ensure + buffer.reset + + begin + client.close if close_socket + rescue IOError, SystemCallError + Puma::Util.purge_interrupt_queue + # Already closed + rescue StandardError => e + @events.unknown_error e, nil, "Client" + end + end + end + + # Triggers a client timeout if the thread-pool shuts down + # during execution of the provided block. + def with_force_shutdown(client, &block) + @thread_pool.with_force_shutdown(&block) + rescue ThreadPool::ForceShutdown + client.timeout! + end + + # :nocov: + + # Handle various error types thrown by Client I/O operations. + def client_error(e, client) + # Swallow, do not log + return if [ConnectionError, EOFError].include?(e.class) + + lowlevel_error(e, client.env) + case e + when MiniSSL::SSLError + @events.ssl_error e, client.io + when HttpParserError + client.write_error(400) + @events.parse_error e, client + when HttpParserError501 + client.write_error(501) + @events.parse_error e, client + else + client.write_error(500) + @events.unknown_error e, nil, "Read" + end + end + + # A fallback rack response if +@app+ raises as exception. + # + def lowlevel_error(e, env, status=500) + if handler = @options[:lowlevel_error_handler] + if handler.arity == 1 + return handler.call(e) + elsif handler.arity == 2 + return handler.call(e, env) + else + return handler.call(e, env, status) + end + end + + if @leak_stack_on_error + backtrace = e.backtrace.nil? ? '' : e.backtrace.join("\n") + [status, {}, ["Puma caught this error: #{e.message} (#{e.class})\n#{backtrace}"]] + else + [status, {}, ["An unhandled lowlevel error occurred. The application logs may have details.\n"]] + end + end + + # Wait for all outstanding requests to finish. + # + def graceful_shutdown + if @options[:shutdown_debug] + threads = Thread.list + total = threads.size + + pid = Process.pid + + $stdout.syswrite "#{pid}: === Begin thread backtrace dump ===\n" + + threads.each_with_index do |t,i| + $stdout.syswrite "#{pid}: Thread #{i+1}/#{total}: #{t.inspect}\n" + $stdout.syswrite "#{pid}: #{t.backtrace.join("\n#{pid}: ")}\n\n" + end + $stdout.syswrite "#{pid}: === End thread backtrace dump ===\n" + end + + if @status != :restart + @binder.close + end + + if @thread_pool + if timeout = @options[:force_shutdown_after] + @thread_pool.shutdown timeout.to_f + else + @thread_pool.shutdown + end + end + end + + def notify_safely(message) + @notify << message + rescue IOError, NoMethodError, Errno::EPIPE + # The server, in another thread, is shutting down + Puma::Util.purge_interrupt_queue + rescue RuntimeError => e + # Temporary workaround for https://bugs.ruby-lang.org/issues/13239 + if e.message.include?('IOError') + Puma::Util.purge_interrupt_queue + else + raise e + end + end + private :notify_safely + + # Stops the acceptor thread and then causes the worker threads to finish + # off the request queue before finally exiting. + + def stop(sync=false) + notify_safely(STOP_COMMAND) + @thread.join if @thread && sync + end + + def halt(sync=false) + notify_safely(HALT_COMMAND) + @thread.join if @thread && sync + end + + def begin_restart(sync=false) + notify_safely(RESTART_COMMAND) + @thread.join if @thread && sync + end + + def shutting_down? + @status == :stop || @status == :restart + end + + # List of methods invoked by #stats. + # @version 5.0.0 + STAT_METHODS = [:backlog, :running, :pool_capacity, :max_threads, :requests_count].freeze + + # Returns a hash of stats about the running server for reporting purposes. + # @version 5.0.0 + # @!attribute [r] stats + def stats + STAT_METHODS.map {|name| [name, send(name) || 0]}.to_h + end + end +end diff --git a/lib/puma/single.rb b/lib/puma/single.rb new file mode 100644 index 0000000..c34e014 --- /dev/null +++ b/lib/puma/single.rb @@ -0,0 +1,67 @@ +# frozen_string_literal: true + +require 'puma/runner' +require 'puma/detect' +require 'puma/plugin' + +module Puma + # This class is instantiated by the `Puma::Launcher` and used + # to boot and serve a Ruby application when no puma "workers" are needed + # i.e. only using "threaded" mode. For example `$ puma -t 1:5` + # + # At the core of this class is running an instance of `Puma::Server` which + # gets created via the `start_server` method from the `Puma::Runner` class + # that this inherits from. + class Single < Runner + # @!attribute [r] stats + def stats + { + started_at: @started_at.utc.iso8601 + }.merge(@server.stats) + end + + def restart + @server.begin_restart + end + + def stop + @server.stop(false) if @server + end + + def halt + @server.halt + end + + def stop_blocked + log "- Gracefully stopping, waiting for requests to finish" + @control.stop(true) if @control + @server.stop(true) if @server + end + + def run + output_header "single" + + load_and_bind + + Plugins.fire_background + + @launcher.write_state + + start_control + + @server = server = start_server + server_thread = server.run + + log "Use Ctrl-C to stop" + redirect_io + + @launcher.events.fire_on_booted! + + begin + server_thread.join + rescue Interrupt + # Swallow it + end + end + end +end diff --git a/lib/puma/state_file.rb b/lib/puma/state_file.rb new file mode 100644 index 0000000..dd63628 --- /dev/null +++ b/lib/puma/state_file.rb @@ -0,0 +1,70 @@ +# frozen_string_literal: true + +module Puma + + # Puma::Launcher uses StateFile to write a yaml file for use with Puma::ControlCLI. + # + # In previous versions of Puma, YAML was used to read/write the state file. + # Since Puma is similar to Bundler/RubyGems in that it may load before one's app + # does, minimizing the dependencies that may be shared with the app is desired. + # + # At present, it only works with numeric and string values. It is still a valid + # yaml file, and the CI tests parse it with Psych. + # + class StateFile + + ALLOWED_FIELDS = %w!control_url control_auth_token pid running_from! + + # @deprecated 6.0.0 + FIELDS = ALLOWED_FIELDS + + def initialize + @options = {} + end + + def save(path, permission = nil) + contents = "---\n".dup + @options.each do |k,v| + next unless ALLOWED_FIELDS.include? k + case v + when Numeric + contents << "#{k}: #{v}\n" + when String + next if v.strip.empty? + contents << (k == 'running_from' || v.to_s.include?(' ') ? + "#{k}: \"#{v}\"\n" : "#{k}: #{v}\n") + end + end + if permission + File.write path, contents, mode: 'wb:UTF-8' + else + File.write path, contents, mode: 'wb:UTF-8', perm: permission + end + end + + def load(path) + File.read(path).lines.each do |line| + next if line.start_with? '#' + k,v = line.split ':', 2 + next unless v && ALLOWED_FIELDS.include?(k) + v = v.strip + @options[k] = + case v + when /\A\d+\z/ then v.to_i + when /\A\d+\.\d+\z/ then v.to_f + else v.gsub(/\A"|"\z/, '') + end + end + end + + ALLOWED_FIELDS.each do |f| + define_method f do + @options[f] + end + + define_method "#{f}=" do |v| + @options[f] = v + end + end + end +end diff --git a/lib/puma/systemd.rb b/lib/puma/systemd.rb new file mode 100644 index 0000000..037e738 --- /dev/null +++ b/lib/puma/systemd.rb @@ -0,0 +1,46 @@ +# frozen_string_literal: true + +require 'sd_notify' + +module Puma + class Systemd + def initialize(events) + @events = events + end + + def hook_events + @events.on_booted { SdNotify.ready } + @events.on_stopped { SdNotify.stopping } + @events.on_restart { SdNotify.reloading } + end + + def start_watchdog + return unless SdNotify.watchdog? + + ping_f = watchdog_sleep_time + + log "Pinging systemd watchdog every #{ping_f.round(1)} sec" + Thread.new do + loop do + sleep ping_f + SdNotify.watchdog + end + end + end + + private + + def watchdog_sleep_time + usec = Integer(ENV["WATCHDOG_USEC"]) + + sec_f = usec / 1_000_000.0 + # "It is recommended that a daemon sends a keep-alive notification message + # to the service manager every half of the time returned here." + sec_f / 2 + end + + def log(str) + @events.log str + end + end +end diff --git a/lib/puma/thread_pool.rb b/lib/puma/thread_pool.rb new file mode 100644 index 0000000..e3e1915 --- /dev/null +++ b/lib/puma/thread_pool.rb @@ -0,0 +1,396 @@ +# frozen_string_literal: true + +require 'thread' + +module Puma + # Internal Docs for A simple thread pool management object. + # + # Each Puma "worker" has a thread pool to process requests. + # + # First a connection to a client is made in `Puma::Server`. It is wrapped in a + # `Puma::Client` instance and then passed to the `Puma::Reactor` to ensure + # the whole request is buffered into memory. Once the request is ready, it is passed into + # a thread pool via the `Puma::ThreadPool#<<` operator where it is stored in a `@todo` array. + # + # Each thread in the pool has an internal loop where it pulls a request from the `@todo` array + # and processes it. + class ThreadPool + class ForceShutdown < RuntimeError + end + + # How long, after raising the ForceShutdown of a thread during + # forced shutdown mode, to wait for the thread to try and finish + # up its work before leaving the thread to die on the vine. + SHUTDOWN_GRACE_TIME = 5 # seconds + + # Maintain a minimum of +min+ and maximum of +max+ threads + # in the pool. + # + # The block passed is the work that will be performed in each + # thread. + # + def initialize(name, min, max, *extra, &block) + @not_empty = ConditionVariable.new + @not_full = ConditionVariable.new + @mutex = Mutex.new + + @todo = [] + + @spawned = 0 + @waiting = 0 + + @name = name + @min = Integer(min) + @max = Integer(max) + @block = block + @extra = extra + + @shutdown = false + + @trim_requested = 0 + @out_of_band_pending = false + + @workers = [] + + @auto_trim = nil + @reaper = nil + + @mutex.synchronize do + @min.times do + spawn_thread + @not_full.wait(@mutex) + end + end + + @clean_thread_locals = false + @force_shutdown = false + @shutdown_mutex = Mutex.new + end + + attr_reader :spawned, :trim_requested, :waiting + attr_accessor :clean_thread_locals + attr_accessor :out_of_band_hook # @version 5.0.0 + + def self.clean_thread_locals + Thread.current.keys.each do |key| # rubocop: disable Style/HashEachMethods + Thread.current[key] = nil unless key == :__recursive_key__ + end + end + + # How many objects have yet to be processed by the pool? + # + def backlog + with_mutex { @todo.size } + end + + # @!attribute [r] pool_capacity + def pool_capacity + waiting + (@max - spawned) + end + + # @!attribute [r] busy_threads + # @version 5.0.0 + def busy_threads + with_mutex { @spawned - @waiting + @todo.size } + end + + # :nodoc: + # + # Must be called with @mutex held! + # + def spawn_thread + @spawned += 1 + + th = Thread.new(@spawned) do |spawned| + Puma.set_thread_name '%s tp %03i' % [@name, spawned] + todo = @todo + block = @block + mutex = @mutex + not_empty = @not_empty + not_full = @not_full + + extra = @extra.map { |i| i.new } + + while true + work = nil + + mutex.synchronize do + while todo.empty? + if @trim_requested > 0 + @trim_requested -= 1 + @spawned -= 1 + @workers.delete th + not_full.signal + Thread.exit + end + + @waiting += 1 + if @out_of_band_pending && trigger_out_of_band_hook + @out_of_band_pending = false + end + not_full.signal + begin + not_empty.wait mutex + ensure + @waiting -= 1 + end + end + + work = todo.shift + end + + if @clean_thread_locals + ThreadPool.clean_thread_locals + end + + begin + @out_of_band_pending = true if block.call(work, *extra) + rescue Exception => e + STDERR.puts "Error reached top of thread-pool: #{e.message} (#{e.class})" + end + end + end + + @workers << th + + th + end + + private :spawn_thread + + # @version 5.0.0 + def trigger_out_of_band_hook + return false unless out_of_band_hook && out_of_band_hook.any? + + # we execute on idle hook when all threads are free + return false unless @spawned == @waiting + + out_of_band_hook.each(&:call) + true + rescue Exception => e + STDERR.puts "Exception calling out_of_band_hook: #{e.message} (#{e.class})" + true + end + + private :trigger_out_of_band_hook + + # @version 5.0.0 + def with_mutex(&block) + @mutex.owned? ? + yield : + @mutex.synchronize(&block) + end + + # Add +work+ to the todo list for a Thread to pickup and process. + def <<(work) + with_mutex do + if @shutdown + raise "Unable to add work while shutting down" + end + + @todo << work + + if @waiting < @todo.size and @spawned < @max + spawn_thread + end + + @not_empty.signal + end + end + + # This method is used by `Puma::Server` to let the server know when + # the thread pool can pull more requests from the socket and + # pass to the reactor. + # + # The general idea is that the thread pool can only work on a fixed + # number of requests at the same time. If it is already processing that + # number of requests then it is at capacity. If another Puma process has + # spare capacity, then the request can be left on the socket so the other + # worker can pick it up and process it. + # + # For example: if there are 5 threads, but only 4 working on + # requests, this method will not wait and the `Puma::Server` + # can pull a request right away. + # + # If there are 5 threads and all 5 of them are busy, then it will + # pause here, and wait until the `not_full` condition variable is + # signaled, usually this indicates that a request has been processed. + # + # It's important to note that even though the server might accept another + # request, it might not be added to the `@todo` array right away. + # For example if a slow client has only sent a header, but not a body + # then the `@todo` array would stay the same size as the reactor works + # to try to buffer the request. In that scenario the next call to this + # method would not block and another request would be added into the reactor + # by the server. This would continue until a fully buffered request + # makes it through the reactor and can then be processed by the thread pool. + def wait_until_not_full + with_mutex do + while true + return if @shutdown + + # If we can still spin up new threads and there + # is work queued that cannot be handled by waiting + # threads, then accept more work until we would + # spin up the max number of threads. + return if busy_threads < @max + + @not_full.wait @mutex + end + end + end + + # @version 5.0.0 + def wait_for_less_busy_worker(delay_s) + return unless delay_s && delay_s > 0 + + # Ruby MRI does GVL, this can result + # in processing contention when multiple threads + # (requests) are running concurrently + return unless Puma.mri? + + with_mutex do + return if @shutdown + + # do not delay, if we are not busy + return unless busy_threads > 0 + + # this will be signaled once a request finishes, + # which can happen earlier than delay + @not_full.wait @mutex, delay_s + end + end + + # If there are any free threads in the pool, tell one to go ahead + # and exit. If +force+ is true, then a trim request is requested + # even if all threads are being utilized. + # + def trim(force=false) + with_mutex do + free = @waiting - @todo.size + if (force or free > 0) and @spawned - @trim_requested > @min + @trim_requested += 1 + @not_empty.signal + end + end + end + + # If there are dead threads in the pool make them go away while decreasing + # spawned counter so that new healthy threads could be created again. + def reap + with_mutex do + dead_workers = @workers.reject(&:alive?) + + dead_workers.each do |worker| + worker.kill + @spawned -= 1 + end + + @workers.delete_if do |w| + dead_workers.include?(w) + end + end + end + + class Automaton + def initialize(pool, timeout, thread_name, message) + @pool = pool + @timeout = timeout + @thread_name = thread_name + @message = message + @running = false + end + + def start! + @running = true + + @thread = Thread.new do + Puma.set_thread_name @thread_name + while @running + @pool.public_send(@message) + sleep @timeout + end + end + end + + def stop + @running = false + @thread.wakeup + end + end + + def auto_trim!(timeout=30) + @auto_trim = Automaton.new(self, timeout, "#{@name} threadpool trimmer", :trim) + @auto_trim.start! + end + + def auto_reap!(timeout=5) + @reaper = Automaton.new(self, timeout, "#{@name} threadpool reaper", :reap) + @reaper.start! + end + + # Allows ThreadPool::ForceShutdown to be raised within the + # provided block if the thread is forced to shutdown during execution. + def with_force_shutdown + t = Thread.current + @shutdown_mutex.synchronize do + raise ForceShutdown if @force_shutdown + t[:with_force_shutdown] = true + end + yield + ensure + t[:with_force_shutdown] = false + end + + # Tell all threads in the pool to exit and wait for them to finish. + # Wait +timeout+ seconds then raise +ForceShutdown+ in remaining threads. + # Next, wait an extra +grace+ seconds then force-kill remaining threads. + # Finally, wait +kill_grace+ seconds for remaining threads to exit. + # + def shutdown(timeout=-1) + threads = with_mutex do + @shutdown = true + @trim_requested = @spawned + @not_empty.broadcast + @not_full.broadcast + + @auto_trim.stop if @auto_trim + @reaper.stop if @reaper + # dup workers so that we join them all safely + @workers.dup + end + + if timeout == -1 + # Wait for threads to finish without force shutdown. + threads.each(&:join) + else + join = ->(inner_timeout) do + start = Process.clock_gettime(Process::CLOCK_MONOTONIC) + threads.reject! do |t| + elapsed = Process.clock_gettime(Process::CLOCK_MONOTONIC) - start + t.join inner_timeout - elapsed + end + end + + # Wait +timeout+ seconds for threads to finish. + join.call(timeout) + + # If threads are still running, raise ForceShutdown and wait to finish. + @shutdown_mutex.synchronize do + @force_shutdown = true + threads.each do |t| + t.raise ForceShutdown if t[:with_force_shutdown] + end + end + join.call(SHUTDOWN_GRACE_TIME) + + # If threads are _still_ running, forcefully kill them and wait to finish. + threads.each(&:kill) + join.call(1) + end + + @spawned = 0 + @workers = [] + end + end +end diff --git a/lib/puma/util.rb b/lib/puma/util.rb new file mode 100644 index 0000000..bf1cabc --- /dev/null +++ b/lib/puma/util.rb @@ -0,0 +1,143 @@ +# frozen_string_literal: true + +require 'uri/common' + +module Puma + module Util + module_function + + def pipe + IO.pipe + end + + # An instance method on Thread has been provided to address https://bugs.ruby-lang.org/issues/13632, + # which currently effects some older versions of Ruby: 2.2.7 2.2.8 2.2.9 2.2.10 2.3.4 2.4.1 + # Additional context: https://github.com/puma/puma/pull/1345 + def purge_interrupt_queue + Thread.current.purge_interrupt_queue if Thread.current.respond_to? :purge_interrupt_queue + end + + # Unescapes a URI escaped string with +encoding+. +encoding+ will be the + # target encoding of the string returned, and it defaults to UTF-8 + if defined?(::Encoding) + def unescape(s, encoding = Encoding::UTF_8) + URI.decode_www_form_component(s, encoding) + end + else + def unescape(s, encoding = nil) + URI.decode_www_form_component(s, encoding) + end + end + module_function :unescape + + # @version 5.0.0 + def nakayoshi_gc(events) + events.log "! Promoting existing objects to old generation..." + 4.times { GC.start(full_mark: false) } + if GC.respond_to?(:compact) + events.log "! Compacting..." + GC.compact + end + events.log "! Friendly fork preparation complete." + end + + DEFAULT_SEP = /[&;] */n + + # Stolen from Mongrel, with some small modifications: + # Parses a query string by breaking it up at the '&' + # and ';' characters. You can also use this to parse + # cookies by changing the characters used in the second + # parameter (which defaults to '&;'). + def parse_query(qs, d = nil, &unescaper) + unescaper ||= method(:unescape) + + params = {} + + (qs || '').split(d ? /[#{d}] */n : DEFAULT_SEP).each do |p| + next if p.empty? + k, v = p.split('=', 2).map(&unescaper) + + if cur = params[k] + if cur.class == Array + params[k] << v + else + params[k] = [cur, v] + end + else + params[k] = v + end + end + + params + end + + # A case-insensitive Hash that preserves the original case of a + # header when set. + class HeaderHash < Hash + def self.new(hash={}) + HeaderHash === hash ? hash : super(hash) + end + + def initialize(hash={}) + super() + @names = {} + hash.each { |k, v| self[k] = v } + end + + def each + super do |k, v| + yield(k, v.respond_to?(:to_ary) ? v.to_ary.join("\n") : v) + end + end + + # @!attribute [r] to_hash + def to_hash + hash = {} + each { |k,v| hash[k] = v } + hash + end + + def [](k) + super(k) || super(@names[k.downcase]) + end + + def []=(k, v) + canonical = k.downcase + delete k if @names[canonical] && @names[canonical] != k # .delete is expensive, don't invoke it unless necessary + @names[k] = @names[canonical] = k + super k, v + end + + def delete(k) + canonical = k.downcase + result = super @names.delete(canonical) + @names.delete_if { |name,| name.downcase == canonical } + result + end + + def include?(k) + @names.include?(k) || @names.include?(k.downcase) + end + + alias_method :has_key?, :include? + alias_method :member?, :include? + alias_method :key?, :include? + + def merge!(other) + other.each { |k, v| self[k] = v } + self + end + + def merge(other) + hash = dup + hash.merge! other + end + + def replace(other) + clear + other.each { |k, v| self[k] = v } + self + end + end + end +end diff --git a/lib/rack/handler/puma.rb b/lib/rack/handler/puma.rb new file mode 100644 index 0000000..d00f230 --- /dev/null +++ b/lib/rack/handler/puma.rb @@ -0,0 +1,114 @@ +# frozen_string_literal: true + +require 'rack/handler' + +module Rack + module Handler + module Puma + DEFAULT_OPTIONS = { + :Verbose => false, + :Silent => false + } + + def self.config(app, options = {}) + require 'puma' + require 'puma/configuration' + require 'puma/events' + require 'puma/launcher' + + default_options = DEFAULT_OPTIONS.dup + + # Libraries pass in values such as :Port and there is no way to determine + # if it is a default provided by the library or a special value provided + # by the user. A special key `user_supplied_options` can be passed. This + # contains an array of all explicitly defined user options. We then + # know that all other values are defaults + if user_supplied_options = options.delete(:user_supplied_options) + (options.keys - user_supplied_options).each do |k| + default_options[k] = options.delete(k) + end + end + + conf = ::Puma::Configuration.new(options, default_options) do |user_config, file_config, default_config| + if options.delete(:Verbose) + require 'rack/common_logger' + app = Rack::CommonLogger.new(app, STDOUT) + end + + if options[:environment] + user_config.environment options[:environment] + end + + if options[:Threads] + min, max = options.delete(:Threads).split(':', 2) + user_config.threads min, max + end + + if options[:Host] || options[:Port] + host = options[:Host] || default_options[:Host] + port = options[:Port] || default_options[:Port] + self.set_host_port_to_config(host, port, user_config) + end + + if default_options[:Host] + file_config.set_default_host(default_options[:Host]) + end + self.set_host_port_to_config(default_options[:Host], default_options[:Port], default_config) + + user_config.app app + end + conf + end + + def self.run(app, **options) + conf = self.config(app, options) + + events = options.delete(:Silent) ? ::Puma::Events.strings : ::Puma::Events.stdio + + launcher = ::Puma::Launcher.new(conf, :events => events) + + yield launcher if block_given? + begin + launcher.run + rescue Interrupt + puts "* Gracefully stopping, waiting for requests to finish" + launcher.stop + puts "* Goodbye!" + end + end + + def self.valid_options + { + "Host=HOST" => "Hostname to listen on (default: localhost)", + "Port=PORT" => "Port to listen on (default: 8080)", + "Threads=MIN:MAX" => "min:max threads to use (default 0:16)", + "Verbose" => "Don't report each request (default: false)" + } + end + + def self.set_host_port_to_config(host, port, config) + config.clear_binds! if host || port + + if host && (host[0,1] == '.' || host[0,1] == '/') + config.bind "unix://#{host}" + elsif host && host =~ /^ssl:\/\// + uri = URI.parse(host) + uri.port ||= port || ::Puma::Configuration::DefaultTCPPort + config.bind uri.to_s + else + + if host + port ||= ::Puma::Configuration::DefaultTCPPort + end + + if port + host ||= ::Puma::Configuration::DefaultTCPHost + config.port port, host + end + end + end + end + + register :puma, Puma + end +end diff --git a/puma.gemspec b/puma.gemspec new file mode 100644 index 0000000..e89a786 --- /dev/null +++ b/puma.gemspec @@ -0,0 +1,31 @@ +require_relative "lib/puma/const" + +Gem::Specification.new do |s| + s.name = "puma" + s.version = Puma::Const::PUMA_VERSION + s.authors = ["Evan Phoenix"] + s.description = "Puma is a simple, fast, threaded, and highly parallel HTTP 1.1 server for Ruby/Rack applications. Puma is intended for use in both development and production environments. It's great for highly parallel Ruby implementations such as Rubinius and JRuby as well as as providing process worker support to support CRuby well." + s.summary = "Puma is a simple, fast, threaded, and highly parallel HTTP 1.1 server for Ruby/Rack applications" + s.email = ["evan@phx.io"] + s.executables = ["puma", "pumactl"] + s.extensions = ["ext/puma_http11/extconf.rb"] + s.add_runtime_dependency "nio4r", "~> 2.0" + if RbConfig::CONFIG['ruby_version'] >= '2.5' + s.metadata["msys2_mingw_dependencies"] = "openssl" + end + s.files = `git ls-files -- bin docs ext lib tools`.split("\n") + + %w[History.md LICENSE README.md] + s.homepage = "https://puma.io" + + if s.respond_to?(:metadata=) + s.metadata = { + "bug_tracker_uri" => "https://github.com/puma/puma/issues", + "changelog_uri" => "https://github.com/puma/puma/blob/master/History.md", + "homepage_uri" => "https://puma.io", + "source_code_uri" => "https://github.com/puma/puma" + } + end + + s.license = "BSD-3-Clause" + s.required_ruby_version = Gem::Requirement.new(">= 2.2") +end diff --git a/test/bundle_app_config_test/.bundle/config b/test/bundle_app_config_test/.bundle/config new file mode 100644 index 0000000..2369228 --- /dev/null +++ b/test/bundle_app_config_test/.bundle/config @@ -0,0 +1,2 @@ +--- +BUNDLE_PATH: "vendor/bundle" diff --git a/test/bundle_app_config_test/Gemfile b/test/bundle_app_config_test/Gemfile new file mode 100644 index 0000000..e0695ee --- /dev/null +++ b/test/bundle_app_config_test/Gemfile @@ -0,0 +1 @@ +gem 'puma', path: '../..' diff --git a/test/bundle_app_config_test/config.ru b/test/bundle_app_config_test/config.ru new file mode 100644 index 0000000..deb8fc8 --- /dev/null +++ b/test/bundle_app_config_test/config.ru @@ -0,0 +1 @@ +run lambda { |env| [200, {"Content-Type" => "text/plain"}, ["Hello World"]] } diff --git a/test/bundle_preservation_test/.gitignore b/test/bundle_preservation_test/.gitignore new file mode 100644 index 0000000..53c6471 --- /dev/null +++ b/test/bundle_preservation_test/.gitignore @@ -0,0 +1 @@ +Gemfile.bundle_env_preservation_test.lock diff --git a/test/bundle_preservation_test/Gemfile.bundle_env_preservation_test b/test/bundle_preservation_test/Gemfile.bundle_env_preservation_test new file mode 100644 index 0000000..e0695ee --- /dev/null +++ b/test/bundle_preservation_test/Gemfile.bundle_env_preservation_test @@ -0,0 +1 @@ +gem 'puma', path: '../..' diff --git a/test/bundle_preservation_test/config.ru b/test/bundle_preservation_test/config.ru new file mode 100644 index 0000000..1f0f2cc --- /dev/null +++ b/test/bundle_preservation_test/config.ru @@ -0,0 +1 @@ +run lambda { |env| [200, {'Content-Type'=>'text/plain'}, [ENV['BUNDLE_GEMFILE'].inspect]] } diff --git a/test/bundle_preservation_test/version1/Gemfile b/test/bundle_preservation_test/version1/Gemfile new file mode 100644 index 0000000..e1437de --- /dev/null +++ b/test/bundle_preservation_test/version1/Gemfile @@ -0,0 +1 @@ +gem 'puma', path: '../../..' diff --git a/test/bundle_preservation_test/version1/config.ru b/test/bundle_preservation_test/version1/config.ru new file mode 100644 index 0000000..1f0f2cc --- /dev/null +++ b/test/bundle_preservation_test/version1/config.ru @@ -0,0 +1 @@ +run lambda { |env| [200, {'Content-Type'=>'text/plain'}, [ENV['BUNDLE_GEMFILE'].inspect]] } diff --git a/test/bundle_preservation_test/version1/config/puma.rb b/test/bundle_preservation_test/version1/config/puma.rb new file mode 100644 index 0000000..ab019ff --- /dev/null +++ b/test/bundle_preservation_test/version1/config/puma.rb @@ -0,0 +1 @@ +directory File.expand_path("../../current", __dir__) diff --git a/test/bundle_preservation_test/version2/Gemfile b/test/bundle_preservation_test/version2/Gemfile new file mode 100644 index 0000000..e1437de --- /dev/null +++ b/test/bundle_preservation_test/version2/Gemfile @@ -0,0 +1 @@ +gem 'puma', path: '../../..' diff --git a/test/bundle_preservation_test/version2/config.ru b/test/bundle_preservation_test/version2/config.ru new file mode 100644 index 0000000..1f0f2cc --- /dev/null +++ b/test/bundle_preservation_test/version2/config.ru @@ -0,0 +1 @@ +run lambda { |env| [200, {'Content-Type'=>'text/plain'}, [ENV['BUNDLE_GEMFILE'].inspect]] } diff --git a/test/bundle_preservation_test/version2/config/puma.rb b/test/bundle_preservation_test/version2/config/puma.rb new file mode 100644 index 0000000..ab019ff --- /dev/null +++ b/test/bundle_preservation_test/version2/config/puma.rb @@ -0,0 +1 @@ +directory File.expand_path("../../current", __dir__) diff --git a/test/config/ab_rs.rb b/test/config/ab_rs.rb new file mode 100644 index 0000000..8ba59e6 --- /dev/null +++ b/test/config/ab_rs.rb @@ -0,0 +1,22 @@ +url = ARGV.shift +count = (ARGV.shift || 1000).to_i + +STDOUT.sync = true + +1.upto(5) do |i| + print "#{i}: " + str = `ab -n #{count} -c #{i} #{url} 2>/dev/null` + + rs = /Requests per second:\s+([\d.]+)\s/.match(str) + puts rs[1] +end + +puts "Keep Alive:" + +1.upto(5) do |i| + print "#{i}: " + str = `ab -n #{count} -k -c #{i} #{url} 2>/dev/null` + + rs = /Requests per second:\s+([\d.]+)\s/.match(str) + puts rs[1] +end diff --git a/test/config/app.rb b/test/config/app.rb new file mode 100644 index 0000000..7d78fd1 --- /dev/null +++ b/test/config/app.rb @@ -0,0 +1,9 @@ +port ENV['PORT'] if ENV['PORT'] + +app do |env| + [200, {}, ["embedded app"]] +end + +lowlevel_error_handler do |err| + [200, {}, ["error page"]] +end diff --git a/test/config/control_no_token.rb b/test/config/control_no_token.rb new file mode 100644 index 0000000..eb63d87 --- /dev/null +++ b/test/config/control_no_token.rb @@ -0,0 +1,5 @@ +activate_control_app 'unix:///tmp/pumactl.sock', { no_token: true } + +app do |env| + [200, {}, ["embedded app"]] +end diff --git a/test/config/cpu_spin.rb b/test/config/cpu_spin.rb new file mode 100644 index 0000000..6d1996b --- /dev/null +++ b/test/config/cpu_spin.rb @@ -0,0 +1,17 @@ +# call with "GET /cpu/ HTTP/1.1\r\n\r\n", +# where is the number of iterations + +require 'benchmark' + +# configure `wait_for_less_busy_workers` based on ENV, default `true` +wait_for_less_busy_worker ENV.fetch('WAIT_FOR_LESS_BUSY_WORKERS', '0.005').to_f + +app do |env| + iterations = (env['REQUEST_PATH'][/\/cpu\/(\d.*)/,1] || '1000').to_i + + duration = Benchmark.measure do + iterations.times { rand } + end + + [200, {"Content-Type" => "text/plain"}, ["Run for #{duration.total} #{Process.pid}"]] +end diff --git a/test/config/custom_log_formatter.rb b/test/config/custom_log_formatter.rb new file mode 100644 index 0000000..234748b --- /dev/null +++ b/test/config/custom_log_formatter.rb @@ -0,0 +1,3 @@ +log_formatter do |str| + "[#{Process.pid}] [#{Socket.gethostname}] #{Time.now}: #{str}" +end diff --git a/test/config/plugin1.rb b/test/config/plugin1.rb new file mode 100644 index 0000000..007ae63 --- /dev/null +++ b/test/config/plugin1.rb @@ -0,0 +1 @@ +plugin 'tmp_restart' diff --git a/test/config/prune_bundler_print_json_defined.rb b/test/config/prune_bundler_print_json_defined.rb new file mode 100644 index 0000000..dcef22c --- /dev/null +++ b/test/config/prune_bundler_print_json_defined.rb @@ -0,0 +1,4 @@ +prune_bundler true +before_fork do + puts "defined?(::JSON): #{defined?(::JSON).inspect}" +end diff --git a/test/config/prune_bundler_print_nio_defined.rb b/test/config/prune_bundler_print_nio_defined.rb new file mode 100644 index 0000000..8e2b65d --- /dev/null +++ b/test/config/prune_bundler_print_nio_defined.rb @@ -0,0 +1,4 @@ +prune_bundler true +before_fork do + puts "defined?(::NIO): #{defined?(::NIO).inspect}" +end diff --git a/test/config/prune_bundler_with_deps.rb b/test/config/prune_bundler_with_deps.rb new file mode 100644 index 0000000..5c5675b --- /dev/null +++ b/test/config/prune_bundler_with_deps.rb @@ -0,0 +1,7 @@ +prune_bundler true +extra_runtime_dependencies ["rdoc"] +before_fork do + $LOAD_PATH.each do |path| + puts "LOAD_PATH: #{path}" + end +end diff --git a/test/config/prune_bundler_with_multiple_workers.rb b/test/config/prune_bundler_with_multiple_workers.rb new file mode 100644 index 0000000..a93043f --- /dev/null +++ b/test/config/prune_bundler_with_multiple_workers.rb @@ -0,0 +1,14 @@ +require 'bundler/setup' +Bundler.setup + +prune_bundler true + +workers 2 + +app do |env| + [200, {}, ["embedded app"]] +end + +lowlevel_error_handler do |err| + [200, {}, ["error page"]] +end diff --git a/test/config/settings.rb b/test/config/settings.rb new file mode 100644 index 0000000..4c2b8f2 --- /dev/null +++ b/test/config/settings.rb @@ -0,0 +1,2 @@ +port 3000 +threads 3, 5 diff --git a/test/config/ssl_config.rb b/test/config/ssl_config.rb new file mode 100644 index 0000000..8721c42 --- /dev/null +++ b/test/config/ssl_config.rb @@ -0,0 +1,13 @@ +key = File.expand_path "../../../examples/puma/puma_keypair.pem", __FILE__ +cert = File.expand_path "../../../examples/puma/cert_puma.pem", __FILE__ +ca = File.expand_path "../../../examples/puma/client-certs/ca.crt", __FILE__ + +ssl_bind "0.0.0.0", 9292, :cert => cert, :key => key, :verify_mode => "peer", :ca => ca + +app do |env| + [200, {}, ["embedded app"]] +end + +lowlevel_error_handler do |err| + [200, {}, ["error page"]] +end diff --git a/test/config/ssl_self_signed_config.rb b/test/config/ssl_self_signed_config.rb new file mode 100644 index 0000000..d70b51d --- /dev/null +++ b/test/config/ssl_self_signed_config.rb @@ -0,0 +1,7 @@ +require "localhost" + +ssl_bind "0.0.0.0", 9292 + +app do |env| + [200, {}, ["self-signed certificate app"]] +end diff --git a/test/config/state_file_testing_config.rb b/test/config/state_file_testing_config.rb new file mode 100644 index 0000000..a920fb0 --- /dev/null +++ b/test/config/state_file_testing_config.rb @@ -0,0 +1,13 @@ +pidfile "t3-pid" +workers 3 +on_worker_boot do |index| + File.open("t3-worker-#{index}-pid", "w") { |f| f.puts Process.pid } +end + +before_fork { 1 } +on_worker_shutdown { 1 } +on_worker_boot { 1 } +on_worker_fork { 1 } +on_restart { 1 } +after_worker_boot { 1 } +lowlevel_error_handler { 1 } diff --git a/test/config/suppress_exception.rb b/test/config/suppress_exception.rb new file mode 100644 index 0000000..28caf50 --- /dev/null +++ b/test/config/suppress_exception.rb @@ -0,0 +1 @@ +raise_exception_on_sigterm false diff --git a/test/config/t1_conf.rb b/test/config/t1_conf.rb new file mode 100644 index 0000000..cae5662 --- /dev/null +++ b/test/config/t1_conf.rb @@ -0,0 +1,3 @@ +log_requests +stdout_redirect "t1-stdout" +pidfile "t1-pid" diff --git a/test/config/t2_conf.rb b/test/config/t2_conf.rb new file mode 100644 index 0000000..3553339 --- /dev/null +++ b/test/config/t2_conf.rb @@ -0,0 +1,3 @@ +log_requests +stdout_redirect "t2-stdout" +pidfile "t2-pid" diff --git a/test/config/t3_conf.rb b/test/config/t3_conf.rb new file mode 100644 index 0000000..1be95ce --- /dev/null +++ b/test/config/t3_conf.rb @@ -0,0 +1,5 @@ +pidfile "t3-pid" +workers 3 +on_worker_boot do |index| + File.open("t3-worker-#{index}-pid", "w") { |f| f.puts Process.pid } +end diff --git a/test/config/with_float_convert.rb b/test/config/with_float_convert.rb new file mode 100644 index 0000000..c1be13f --- /dev/null +++ b/test/config/with_float_convert.rb @@ -0,0 +1 @@ +max_fast_inline Float::INFINITY diff --git a/test/config/with_integer_convert.rb b/test/config/with_integer_convert.rb new file mode 100644 index 0000000..2ad83bc --- /dev/null +++ b/test/config/with_integer_convert.rb @@ -0,0 +1,9 @@ +persistent_timeout "6" +first_data_timeout "3" + +workers "2" +threads "4", "8" + +worker_timeout "90" +worker_boot_timeout "120" +worker_shutdown_timeout "150" diff --git a/test/config/with_rackup_from_dsl.rb b/test/config/with_rackup_from_dsl.rb new file mode 100644 index 0000000..3c310d8 --- /dev/null +++ b/test/config/with_rackup_from_dsl.rb @@ -0,0 +1 @@ +rackup "test/rackup/hello-env.ru" diff --git a/test/config/with_symbol_convert.rb b/test/config/with_symbol_convert.rb new file mode 100644 index 0000000..9162a5c --- /dev/null +++ b/test/config/with_symbol_convert.rb @@ -0,0 +1 @@ +io_selector_backend :ruby diff --git a/test/config/worker_shutdown_timeout_2.rb b/test/config/worker_shutdown_timeout_2.rb new file mode 100644 index 0000000..ddb8c64 --- /dev/null +++ b/test/config/worker_shutdown_timeout_2.rb @@ -0,0 +1 @@ +worker_shutdown_timeout 2 diff --git a/test/helper.rb b/test/helper.rb new file mode 100644 index 0000000..b8cf5d7 --- /dev/null +++ b/test/helper.rb @@ -0,0 +1,251 @@ +# frozen_string_literal: true +# Copyright (c) 2011 Evan Phoenix +# Copyright (c) 2005 Zed A. Shaw + +if %w(2.2.7 2.2.8 2.2.9 2.2.10 2.3.4 2.4.1).include? RUBY_VERSION + begin + require 'stopgap_13632' + rescue LoadError + puts "For test stability, you must install the stopgap_13632 gem." + exit(1) + end +end + +require_relative "minitest/verbose" +require "minitest/autorun" +require "minitest/pride" +require "minitest/proveit" +require "minitest/stub_const" +require "net/http" +require_relative "helpers/apps" + +Thread.abort_on_exception = true + +$debugging_info = ''.dup +$debugging_hold = false # needed for TestCLI#test_control_clustered +$test_case_timeout = ENV.fetch("TEST_CASE_TIMEOUT") do + RUBY_ENGINE == "ruby" ? 45 : 60 +end.to_i + +require "puma" +require "puma/detect" + +# used in various ssl test files, see test_puma_server_ssl.rb and +# test_puma_localhost_authority.rb +if Puma::HAS_SSL + require "puma/events" + class SSLEventsHelper < ::Puma::Events + attr_accessor :addr, :cert, :error + + def ssl_error(error, ssl_socket) + self.error = error + self.addr = ssl_socket.peeraddr.last rescue "" + self.cert = ssl_socket.peercert + end + end +end + +# Either takes a string to do a get request against, or a tuple of [URI, HTTP] where +# HTTP is some kind of Net::HTTP request object (POST, HEAD, etc.) +def hit(uris) + uris.map do |u| + response = + if u.kind_of? String + Net::HTTP.get(URI.parse(u)) + else + url = URI.parse(u[0]) + Net::HTTP.new(url.host, url.port).start {|h| h.request(u[1]) } + end + + assert response, "Didn't get a response: #{u}" + response + end +end + +module UniquePort + def self.call + TCPServer.open('127.0.0.1', 0) do |server| + server.connect_address.ip_port + end + end +end + +require "timeout" +module TimeoutEveryTestCase + # our own subclass so we never confused different timeouts + class TestTookTooLong < Timeout::Error + end + + def run + with_info_handler do + time_it do + capture_exceptions do + ::Timeout.timeout($test_case_timeout, TestTookTooLong) do + before_setup; setup; after_setup + self.send self.name + end + end + + capture_exceptions do + ::Timeout.timeout($test_case_timeout, TestTookTooLong) do + Minitest::Test::TEARDOWN_METHODS.each { |hook| self.send hook } + end + end + if respond_to? :clean_tmp_paths + clean_tmp_paths + end + end + end + + Minitest::Result.from self # per contract + end +end + +Minitest::Test.prepend TimeoutEveryTestCase +if ENV['CI'] + require 'minitest/retry' + Minitest::Retry.use! +end + +module TestSkips + + HAS_FORK = ::Process.respond_to? :fork + UNIX_SKT_EXIST = Object.const_defined? :UNIXSocket + + MSG_FORK = "Kernel.fork isn't available on #{RUBY_ENGINE} on #{RUBY_PLATFORM}" + MSG_UNIX = "UNIXSockets aren't available on the #{RUBY_PLATFORM} platform" + MSG_AUNIX = "Abstract UNIXSockets aren't available on the #{RUBY_PLATFORM} platform" + + SIGNAL_LIST = Signal.list.keys.map(&:to_sym) - (Puma.windows? ? [:INT, :TERM] : []) + + JRUBY_HEAD = Puma::IS_JRUBY && RUBY_DESCRIPTION =~ /SNAPSHOT/ + + DARWIN = RUBY_PLATFORM.include? 'darwin' + + TRUFFLE = RUBY_ENGINE == 'truffleruby' + + # usage: skip_unless_signal_exist? :USR2 + def skip_unless_signal_exist?(sig, bt: caller) + signal = sig.to_s.sub(/\ASIG/, '').to_sym + unless SIGNAL_LIST.include? signal + skip "Signal #{signal} isn't available on the #{RUBY_PLATFORM} platform", bt + end + end + + # called with one or more params, like skip_if :jruby, :windows + # optional suffix kwarg is appended to the skip message + # optional suffix bt should generally not used + def skip_if(*engs, suffix: '', bt: caller) + engs.each do |eng| + skip_msg = case eng + when :darwin then "Skipped if darwin#{suffix}" if Puma::IS_OSX + when :jruby then "Skipped if JRuby#{suffix}" if Puma::IS_JRUBY + when :truffleruby then "Skipped if TruffleRuby#{suffix}" if TRUFFLE + when :windows then "Skipped if Windows#{suffix}" if Puma::IS_WINDOWS + when :ci then "Skipped if ENV['CI']#{suffix}" if ENV['CI'] + when :no_bundler then "Skipped w/o Bundler#{suffix}" if !defined?(Bundler) + when :ssl then "Skipped if SSL is supported" if Puma::HAS_SSL + when :fork then "Skipped if Kernel.fork exists" if HAS_FORK + when :unix then "Skipped if UNIXSocket exists" if Puma::HAS_UNIX_SOCKET + when :aunix then "Skipped if abstract UNIXSocket" if Puma.abstract_unix_socket? + else false + end + skip skip_msg, bt if skip_msg + end + end + + # called with only one param + def skip_unless(eng, bt: caller) + skip_msg = case eng + when :darwin then "Skip unless darwin" unless Puma::IS_OSX + when :jruby then "Skip unless JRuby" unless Puma::IS_JRUBY + when :windows then "Skip unless Windows" unless Puma::IS_WINDOWS + when :mri then "Skip unless MRI" unless Puma::IS_MRI + when :ssl then "Skip unless SSL is supported" unless Puma::HAS_SSL + when :fork then MSG_FORK unless HAS_FORK + when :unix then MSG_UNIX unless Puma::HAS_UNIX_SOCKET + when :aunix then MSG_AUNIX unless Puma.abstract_unix_socket? + else false + end + skip skip_msg, bt if skip_msg + end +end + +Minitest::Test.include TestSkips + +class Minitest::Test + + REPO_NAME = ENV['GITHUB_REPOSITORY'] ? ENV['GITHUB_REPOSITORY'][/[^\/]+\z/] : 'puma' + + def self.run(reporter, options = {}) # :nodoc: + prove_it! + super + end + + def full_name + "#{self.class.name}##{name}" + end +end + +Minitest.after_run do + # needed for TestCLI#test_control_clustered + unless $debugging_hold + out = $debugging_info.strip + unless out.empty? + dash = "\u2500" + wid = ENV['GITHUB_ACTIONS'] ? 88 : 90 + txt = " Debugging Info #{dash * 2}".rjust wid, dash + if ENV['GITHUB_ACTIONS'] + puts "", "##[group]#{txt}", out, dash * wid, '', '::[endgroup]' + else + puts "", txt, out, dash * wid, '' + end + end + end +end + +module AggregatedResults + def aggregated_results(io) + filtered_results = results.dup + + if options[:verbose] + skips = filtered_results.select(&:skipped?) + unless skips.empty? + dash = "\u2500" + io.puts '', "Skips:" + hsh = skips.group_by { |f| f.failures.first.error.message } + hsh_s = {} + hsh.each { |k, ary| + hsh_s[k] = ary.map { |s| + [s.source_location, s.klass, s.name] + }.sort_by(&:first) + } + num = 0 + hsh_s = hsh_s.sort.to_h + hsh_s.each { |k,v| + io.puts " #{k} #{dash * 2}".rjust 90, dash + hsh_1 = v.group_by { |i| i.first.first } + hsh_1.each { |k1,v1| + io.puts " #{k1[/\/test\/(.*)/,1]}" + v1.each { |item| + num += 1 + io.puts format(" %3s %-5s #{item[1]} #{item[2]}", "#{num})", ":#{item[0][1]}") + } + puts '' + } + } + end + end + + filtered_results.reject!(&:skipped?) + + io.puts "Errors & Failures:" unless filtered_results.empty? + + filtered_results.each_with_index { |result, i| + io.puts "\n%3d) %s" % [i+1, result] + } + io.puts + io + end +end +Minitest::SummaryReporter.prepend AggregatedResults diff --git a/test/helpers/apps.rb b/test/helpers/apps.rb new file mode 100644 index 0000000..f74e3eb --- /dev/null +++ b/test/helpers/apps.rb @@ -0,0 +1,12 @@ +module TestApps + + # call with "GET /sleep HTTP/1.1\r\n\r\n", where is the number of + # seconds to sleep + # same as rackup/sleep.ru + SLEEP = -> (env) do + dly = (env['REQUEST_PATH'][/\/sleep(\d+)/,1] || '0').to_i + sleep dly + [200, {"Content-Type" => "text/plain"}, ["Slept #{dly}"]] + end + +end diff --git a/test/helpers/config_file.rb b/test/helpers/config_file.rb new file mode 100644 index 0000000..d4b2e23 --- /dev/null +++ b/test/helpers/config_file.rb @@ -0,0 +1,16 @@ +class TestConfigFileBase < Minitest::Test + private + + def with_env(env = {}) + original_env = {} + env.each do |k, v| + original_env[k] = ENV[k] + ENV[k] = v + end + yield + ensure + original_env.each do |k, v| + ENV[k] = v + end + end +end diff --git a/test/helpers/integration.rb b/test/helpers/integration.rb new file mode 100644 index 0000000..0326750 --- /dev/null +++ b/test/helpers/integration.rb @@ -0,0 +1,393 @@ +# frozen_string_literal: true + +require "puma/control_cli" +require "json" +require "open3" +require "io/wait" +require_relative 'tmp_path' + +# Only single mode tests go here. Cluster and pumactl tests +# have their own files, use those instead +class TestIntegration < Minitest::Test + include TmpPath + DARWIN = RUBY_PLATFORM.include? 'darwin' + HOST = "127.0.0.1" + TOKEN = "xxyyzz" + RESP_READ_LEN = 65_536 + RESP_READ_TIMEOUT = 10 + RESP_SPLIT = "\r\n\r\n" + + BASE = defined?(Bundler) ? "bundle exec #{Gem.ruby} -Ilib" : + "#{Gem.ruby} -Ilib" + + def setup + @server = nil + @ios_to_close = [] + @bind_path = tmp_path('.sock') + end + + def teardown + if @server && defined?(@control_tcp_port) && Puma.windows? + cli_pumactl 'stop' + elsif @server && @pid && !Puma.windows? + stop_server @pid, signal: :INT + end + + if @ios_to_close + @ios_to_close.each do |io| + io.close if io.is_a?(IO) && !io.closed? + io = nil + end + end + + if @bind_path + refute File.exist?(@bind_path), "Bind path must be removed after stop" + File.unlink(@bind_path) rescue nil + end + + # wait until the end for OS buffering? + if @server + @server.close unless @server.closed? + @server = nil + end + end + + private + + def silent_and_checked_system_command(*args) + assert(system(*args, out: File::NULL, err: File::NULL)) + end + + def cli_server(argv, unix: false, config: nil, merge_err: false) + if config + config_file = Tempfile.new(%w(config .rb)) + config_file.write config + config_file.close + config = "-C #{config_file.path}" + end + puma_path = File.expand_path '../../../bin/puma', __FILE__ + if unix + cmd = "#{BASE} #{puma_path} #{config} -b unix://#{@bind_path} #{argv}" + else + @tcp_port = UniquePort.call + cmd = "#{BASE} #{puma_path} #{config} -b tcp://#{HOST}:#{@tcp_port} #{argv}" + end + if merge_err + @server = IO.popen(cmd, "r", :err=>[:child, :out]) + else + @server = IO.popen(cmd, "r") + end + wait_for_server_to_boot + @pid = @server.pid + @server + end + + # rescue statements are just in case method is called with a server + # that is already stopped/killed, especially since Process.wait2 is + # blocking + def stop_server(pid = @pid, signal: :TERM) + begin + Process.kill signal, pid + rescue Errno::ESRCH + end + begin + Process.wait2 pid + rescue Errno::ECHILD + end + end + + def restart_server_and_listen(argv) + cli_server argv + connection = connect + initial_reply = read_body(connection) + restart_server connection + [initial_reply, read_body(connect)] + end + + # reuses an existing connection to make sure that works + def restart_server(connection, log: false) + Process.kill :USR2, @pid + connection.write "GET / HTTP/1.1\r\n\r\n" # trigger it to start by sending a new request + wait_for_server_to_boot(log: log) + end + + # wait for server to say it booted + # @server and/or @server.gets may be nil on slow CI systems + def wait_for_server_to_boot(log: false) + if log + puts "Waiting for server to boot..." + begin + line = @server && @server.gets + puts line if line && line.strip != '' + end until line && line.include?('Ctrl-C') + puts "Server booted!" + else + sleep 0.1 until @server.is_a?(IO) + true until (@server.gets || '').include?('Ctrl-C') + end + end + + def connect(path = nil, unix: false) + s = unix ? UNIXSocket.new(@bind_path) : TCPSocket.new(HOST, @tcp_port) + @ios_to_close << s + s << "GET /#{path} HTTP/1.1\r\n\r\n" + s + end + + # use only if all socket writes are fast + # does not wait for a read + def fast_connect(path = nil, unix: false) + s = unix ? UNIXSocket.new(@bind_path) : TCPSocket.new(HOST, @tcp_port) + @ios_to_close << s + fast_write s, "GET /#{path} HTTP/1.1\r\n\r\n" + s + end + + def fast_write(io, str) + n = 0 + while true + begin + n = io.syswrite str + rescue Errno::EAGAIN, Errno::EWOULDBLOCK => e + if !IO.select(nil, [io], nil, 5) + raise e + end + + retry + rescue Errno::EPIPE, SystemCallError, IOError => e + raise e + end + + return if n == str.bytesize + str = str.byteslice(n..-1) + end + end + + def read_body(connection, timeout = nil) + read_response(connection, timeout).last + end + + def read_response(connection, timeout = nil) + timeout ||= RESP_READ_TIMEOUT + content_length = nil + chunked = nil + response = ''.dup + t_st = Process.clock_gettime Process::CLOCK_MONOTONIC + if connection.to_io.wait_readable timeout + loop do + begin + part = connection.read_nonblock(RESP_READ_LEN, exception: false) + case part + when String + unless content_length || chunked + chunked ||= part.include? "\r\nTransfer-Encoding: chunked\r\n" + content_length = (t = part[/^Content-Length: (\d+)/i , 1]) ? t.to_i : nil + end + + response << part + hdrs, body = response.split RESP_SPLIT, 2 + unless body.nil? + # below could be simplified, but allows for debugging... + ret = + if content_length + body.bytesize == content_length + elsif chunked + body.end_with? "\r\n0\r\n\r\n" + elsif !hdrs.empty? && !body.empty? + true + else + false + end + if ret + return [hdrs, body] + end + end + sleep 0.000_1 + when :wait_readable, :wait_writable # :wait_writable for ssl + sleep 0.000_2 + when nil + raise EOFError + end + if timeout < Process.clock_gettime(Process::CLOCK_MONOTONIC) - t_st + raise Timeout::Error, 'Client Read Timeout' + end + end + end + else + raise Timeout::Error, 'Client Read Timeout' + end + end + + # gets worker pids from @server output + def get_worker_pids(phase = 0, size = workers) + pids = [] + re = /PID: (\d+)\) booted in [.0-9]+s, phase: #{phase}/ + while pids.size < size + if pid = @server.gets[re, 1] + pids << pid + end + end + pids.map(&:to_i) + end + + # used to define correct 'refused' errors + def thread_run_refused(unix: false) + if unix + DARWIN ? [Errno::ENOENT, Errno::EPIPE, IOError] : + [IOError, Errno::ENOENT] + else + # Errno::ECONNABORTED is thrown intermittently on TCPSocket.new + DARWIN ? [Errno::EBADF, Errno::ECONNREFUSED, Errno::EPIPE, EOFError, Errno::ECONNABORTED] : + [IOError, Errno::ECONNREFUSED] + end + end + + def cli_pumactl(argv, unix: false) + arg = + if unix + %W[-C unix://#{@control_path} -T #{TOKEN} #{argv}] + else + %W[-C tcp://#{HOST}:#{@control_tcp_port} -T #{TOKEN} #{argv}] + end + r, w = IO.pipe + Thread.new { Puma::ControlCLI.new(arg, w, w).run }.join + w.close + @ios_to_close << r + r + end + + def get_stats + read_pipe = cli_pumactl "stats" + JSON.parse(read_pipe.readlines.last) + end + + def hot_restart_does_not_drop_connections(num_threads: 1, total_requests: 500) + skipped = true + skip_if :jruby, suffix: <<-MSG + - file descriptors are not preserved on exec on JRuby; connection reset errors are expected during restarts + MSG + skip_if :truffleruby, suffix: ' - Undiagnosed failures on TruffleRuby' + skip "Undiagnosed failures on Ruby 2.2" if RUBY_VERSION < '2.3' + + args = "-w #{workers} -t 0:5 -q test/rackup/hello_with_delay.ru" + if Puma.windows? + @control_tcp_port = UniquePort.call + cli_server "--control-url tcp://#{HOST}:#{@control_tcp_port} --control-token #{TOKEN} #{args}" + else + cli_server args + end + + skipped = false + replies = Hash.new 0 + refused = thread_run_refused unix: false + message = 'A' * 16_256 # 2^14 - 128 + + mutex = Mutex.new + restart_count = 0 + client_threads = [] + + num_requests = (total_requests/num_threads).to_i + + num_threads.times do |thread| + client_threads << Thread.new do + num_requests.times do |req_num| + begin + socket = TCPSocket.new HOST, @tcp_port + fast_write socket, "POST / HTTP/1.1\r\nContent-Length: #{message.bytesize}\r\n\r\n#{message}" + body = read_body(socket, 10) + if body == "Hello World" + mutex.synchronize { + replies[:success] += 1 + replies[:restart] += 1 if restart_count > 0 + } + else + mutex.synchronize { replies[:unexpected_response] += 1 } + end + rescue Errno::ECONNRESET, Errno::EBADF, Errno::ENOTCONN + # connection was accepted but then closed + # client would see an empty response + # Errno::EBADF Windows may not be able to make a connection + mutex.synchronize { replies[:reset] += 1 } + rescue *refused, IOError + # IOError intermittently thrown by Ubuntu, add to allow retry + mutex.synchronize { replies[:refused] += 1 } + rescue ::Timeout::Error + mutex.synchronize { replies[:read_timeout] += 1 } + ensure + if socket.is_a?(IO) && !socket.closed? + begin + socket.close + rescue Errno::EBADF + end + end + end + end + # STDOUT.puts "#{thread} #{replies[:success]}" + end + end + + run = true + + restart_thread = Thread.new do + sleep 0.30 # let some connections in before 1st restart + while run + if Puma.windows? + cli_pumactl 'restart' + else + Process.kill :USR2, @pid + end + sleep 0.5 + wait_for_server_to_boot + restart_count += 1 + sleep(Puma.windows? ? 3.0 : 1.0) + end + end + + client_threads.each(&:join) + run = false + restart_thread.join + if Puma.windows? + cli_pumactl 'stop' + Process.wait @server.pid + @server = nil + end + + msg = (" %4d unexpected_response\n" % replies.fetch(:unexpected_response,0)).dup + msg << " %4d refused\n" % replies.fetch(:refused,0) + msg << " %4d read timeout\n" % replies.fetch(:read_timeout,0) + msg << " %4d reset\n" % replies.fetch(:reset,0) + msg << " %4d success\n" % replies.fetch(:success,0) + msg << " %4d success after restart\n" % replies.fetch(:restart,0) + msg << " %4d restart count\n" % restart_count + + reset = replies[:reset] + + if Puma.windows? + # 5 is default thread count in Puma? + reset_max = num_threads * restart_count + assert_operator reset_max, :>=, reset, "#{msg}Expected reset_max >= reset errors" + assert_operator 40, :>=, replies[:refused], "#{msg}Too many refused connections" + else + assert_equal 0, reset, "#{msg}Expected no reset errors" + assert_equal 0, replies[:refused], "#{msg}Expected no refused connections" + end + assert_equal 0, replies[:unexpected_response], "#{msg}Unexpected response" + assert_equal 0, replies[:read_timeout], "#{msg}Expected no read timeouts" + + if Puma.windows? + assert_equal (num_threads * num_requests) - reset - replies[:refused], replies[:success] + else + assert_equal (num_threads * num_requests), replies[:success] + end + + ensure + return if skipped + if passed? + msg = " restart_count #{restart_count}, reset #{reset}, success after restart #{replies[:restart]}" + $debugging_info << "#{full_name}\n#{msg}\n" + else + client_threads.each { |thr| thr.kill if thr.is_a? Thread } + $debugging_info << "#{full_name}\n#{msg}\n" + end + end +end diff --git a/test/helpers/ssl.rb b/test/helpers/ssl.rb new file mode 100644 index 0000000..b3ad4e9 --- /dev/null +++ b/test/helpers/ssl.rb @@ -0,0 +1,27 @@ +module SSLHelper + def ssl_query + @ssl_query ||= if Puma.jruby? + @keystore = File.expand_path "../../examples/puma/keystore.jks", __dir__ + @ssl_cipher_list = "TLS_DHE_RSA_WITH_AES_128_CBC_SHA,TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256" + "keystore=#{@keystore}&keystore-pass=jruby_puma&ssl_cipher_list=#{@ssl_cipher_list}" + else + @cert = File.expand_path "../../examples/puma/cert_puma.pem", __dir__ + @key = File.expand_path "../../examples/puma/puma_keypair.pem", __dir__ + "key=#{@key}&cert=#{@cert}" + end + end + + # sets and returns an opts hash for use with Puma::DSL.ssl_bind_str + def ssl_opts + @ssl_opts ||= if Puma.jruby? + @ssl_opts = {} + @ssl_opts[:keystore] = File.expand_path '../../examples/puma/keystore.jks', __dir__ + @ssl_opts[:keystore_pass] = 'jruby_puma' + @ssl_opts[:ssl_cipher_list] = 'TLS_DHE_RSA_WITH_AES_128_CBC_SHA,TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256' + else + @ssl_opts = {} + @ssl_opts[:cert] = File.expand_path '../../examples/puma/cert_puma.pem', __dir__ + @ssl_opts[:key] = File.expand_path '../../examples/puma/puma_keypair.pem', __dir__ + end + end +end diff --git a/test/helpers/tmp_path.rb b/test/helpers/tmp_path.rb new file mode 100644 index 0000000..bbd192d --- /dev/null +++ b/test/helpers/tmp_path.rb @@ -0,0 +1,24 @@ +module TmpPath + def clean_tmp_paths + while path = tmp_paths.pop + delete_tmp_path(path) + end + end + + private + + def tmp_path(extension=nil) + path = Tempfile.create(['', extension]) { |f| f.path } + tmp_paths << path + path + end + + def tmp_paths + @tmp_paths ||= [] + end + + def delete_tmp_path(path) + File.unlink(path) + rescue Errno::ENOENT + end +end diff --git a/test/minitest/verbose.rb b/test/minitest/verbose.rb new file mode 100644 index 0000000..0f4747f --- /dev/null +++ b/test/minitest/verbose.rb @@ -0,0 +1,5 @@ +require "minitest" +require_relative "verbose_progress_plugin" + +Minitest.load_plugins +Minitest.extensions << 'verbose_progress' unless Minitest.extensions.include?('verbose_progress') diff --git a/test/minitest/verbose_progress_plugin.rb b/test/minitest/verbose_progress_plugin.rb new file mode 100644 index 0000000..4c905e6 --- /dev/null +++ b/test/minitest/verbose_progress_plugin.rb @@ -0,0 +1,34 @@ +module Minitest + # Adds minimal support for parallel tests to the default verbose progress reporter. + def self.plugin_verbose_progress_init(options) + if options[:verbose] + self.reporter.reporters. + delete_if {|r| r.is_a?(ProgressReporter)}. + push(VerboseProgressReporter.new(options[:io], options)) + end + end + + # Verbose progress reporter that supports parallel test execution. + class VerboseProgressReporter < Reporter + def prerecord(klass, name) + @current ||= nil + @current = [klass.name, name].tap(&method(:print_start)) + end + + def record(result) + print_start [result.klass, result.name] + @current = nil + io.print "%.2f s = " % [result.time] + io.print result.result_code + io.puts + end + + def print_start(test) + unless @current == test + io.puts '…' if @current + io.print "%s#%s = " % test + io.flush + end + end + end +end diff --git a/test/rackup/big_response.ru b/test/rackup/big_response.ru new file mode 100644 index 0000000..8d5dee6 --- /dev/null +++ b/test/rackup/big_response.ru @@ -0,0 +1 @@ +run lambda { |env| [200, {"Content-Type" => "text/plain"}, ["Hello World" * 100_000]] } diff --git a/test/rackup/close_listeners.ru b/test/rackup/close_listeners.ru new file mode 100644 index 0000000..d7d3722 --- /dev/null +++ b/test/rackup/close_listeners.ru @@ -0,0 +1,6 @@ +require 'objspace' + +run lambda { |env| + ios = ObjectSpace.each_object(::TCPServer).to_a.tap { |a| a.each(&:close) } + [200, [], ["#{ios.inspect}\n"]] +} diff --git a/test/rackup/hello-bind.ru b/test/rackup/hello-bind.ru new file mode 100644 index 0000000..483eb9d --- /dev/null +++ b/test/rackup/hello-bind.ru @@ -0,0 +1,2 @@ +#\ -O bind=tcp://127.0.0.1:9292 +run lambda { |env| [200, {"Content-Type" => "text/plain"}, ["Hello World"]] } diff --git a/test/rackup/hello-env.ru b/test/rackup/hello-env.ru new file mode 100644 index 0000000..4b5c8d3 --- /dev/null +++ b/test/rackup/hello-env.ru @@ -0,0 +1,2 @@ +ENV["RAND"] ||= rand.to_s +run lambda { |env| [200, {"Content-Type" => "text/plain"}, ["Hello RAND #{ENV["RAND"]}"]] } diff --git a/test/rackup/hello.ru b/test/rackup/hello.ru new file mode 100644 index 0000000..deb8fc8 --- /dev/null +++ b/test/rackup/hello.ru @@ -0,0 +1 @@ +run lambda { |env| [200, {"Content-Type" => "text/plain"}, ["Hello World"]] } diff --git a/test/rackup/hello_with_delay.ru b/test/rackup/hello_with_delay.ru new file mode 100644 index 0000000..2417bad --- /dev/null +++ b/test/rackup/hello_with_delay.ru @@ -0,0 +1,4 @@ +run lambda { |env| + sleep 0.001 + [200, {"Content-Type" => "text/plain"}, ["Hello World"]] +} diff --git a/test/rackup/lobster.ru b/test/rackup/lobster.ru new file mode 100644 index 0000000..cc7ffca --- /dev/null +++ b/test/rackup/lobster.ru @@ -0,0 +1,4 @@ +require 'rack/lobster' + +use Rack::ShowExceptions +run Rack::Lobster.new diff --git a/test/rackup/many_long_headers.ru b/test/rackup/many_long_headers.ru new file mode 100644 index 0000000..f9c5f53 --- /dev/null +++ b/test/rackup/many_long_headers.ru @@ -0,0 +1,9 @@ +require 'securerandom' + +long_header_hash = {} + +30.times do |i| + long_header_hash["X-My-Header-#{i}"] = SecureRandom.hex(1000) +end + +run lambda { |env| [200, long_header_hash, ["Hello World"]] } diff --git a/test/rackup/realistic_response.ru b/test/rackup/realistic_response.ru new file mode 100644 index 0000000..8b6ede2 --- /dev/null +++ b/test/rackup/realistic_response.ru @@ -0,0 +1,11 @@ +require 'securerandom' + +long_header_hash = {} + +25.times do |i| + long_header_hash["X-My-Header-#{i}"] = SecureRandom.hex(25) +end + +response = SecureRandom.hex(100_000) # A 100kb document + +run lambda { |env| [200, long_header_hash.dup, [response.dup]] } diff --git a/test/rackup/sleep.ru b/test/rackup/sleep.ru new file mode 100644 index 0000000..a924dc3 --- /dev/null +++ b/test/rackup/sleep.ru @@ -0,0 +1,9 @@ +# call with "GET /sleep HTTP/1.1\r\n\r\n", where is the number of +# seconds to sleep +# same as TestApps::SLEEP + +run lambda { |env| + dly = (env['REQUEST_PATH'][/\/sleep(\d+)/,1] || '0').to_i + sleep dly + [200, {"Content-Type" => "text/plain"}, ["Slept #{dly}"]] +} diff --git a/test/rackup/sleep_pid.ru b/test/rackup/sleep_pid.ru new file mode 100644 index 0000000..0f1811c --- /dev/null +++ b/test/rackup/sleep_pid.ru @@ -0,0 +1,8 @@ +# call with "GET /sleep HTTP/1.1\r\n\r\n", where is the number of +# seconds to sleep, returns process pid + +run lambda { |env| + dly = (env['REQUEST_PATH'][/\/sleep(\d+)/,1] || '0').to_i + sleep dly + [200, {"Content-Type" => "text/plain"}, ["Slept #{dly} #{Process.pid}"]] +} diff --git a/test/rackup/sleep_step.ru b/test/rackup/sleep_step.ru new file mode 100644 index 0000000..e5633cd --- /dev/null +++ b/test/rackup/sleep_step.ru @@ -0,0 +1,10 @@ +# call with "GET /sleep- HTTP/1.1\r\n\r\n", where is the number of +# seconds to sleep and is the step + +run lambda { |env| + p = env['REQUEST_PATH'] + dly = (p[/\/sleep(\d+)/,1] || '0').to_i + step = p[/(\d+)\z/,1].to_i + sleep dly + [200, {"Content-Type" => "text/plain"}, ["Slept #{dly} #{step}"]] +} diff --git a/test/rackup/write_to_stdout.ru b/test/rackup/write_to_stdout.ru new file mode 100644 index 0000000..880d3d2 --- /dev/null +++ b/test/rackup/write_to_stdout.ru @@ -0,0 +1,6 @@ +app = lambda do |env| + $stdout.write "hello\n" + [200, {"Content-Type" => "text/plain"}, ["Hello World"]] +end + +run app diff --git a/test/rackup/write_to_stdout_on_boot.ru b/test/rackup/write_to_stdout_on_boot.ru new file mode 100644 index 0000000..faf29d5 --- /dev/null +++ b/test/rackup/write_to_stdout_on_boot.ru @@ -0,0 +1,2 @@ +puts "Loading app" +run lambda { |env| [200, {"Content-Type" => "text/plain"}, ["Hello World"]] } diff --git a/test/test_app_status.rb b/test/test_app_status.rb new file mode 100644 index 0000000..2c4b7f1 --- /dev/null +++ b/test/test_app_status.rb @@ -0,0 +1,91 @@ +# frozen_string_literal: true + +require_relative "helper" + +require "puma/app/status" +require "rack" + +class TestAppStatus < Minitest::Test + parallelize_me! + + class FakeServer + def initialize + @status = :running + end + + attr_reader :status + + def stop + @status = :stop + end + + def halt + @status = :halt + end + + def stats + {} + end + end + + def setup + @server = FakeServer.new + @app = Puma::App::Status.new(@server) + end + + def lint(uri) + app = Rack::Lint.new @app + mock_env = Rack::MockRequest.env_for uri + app.call mock_env + end + + def test_bad_token + @app.instance_variable_set(:@auth_token, "abcdef") + + status, _, _ = lint('/whatever') + + assert_equal 403, status + end + + def test_good_token + @app.instance_variable_set(:@auth_token, "abcdef") + + status, _, _ = lint('/whatever?token=abcdef') + + assert_equal 404, status + end + + def test_unsupported + status, _, _ = lint('/not-real') + + assert_equal 404, status + end + + def test_stop + status, _ , app = lint('/stop') + + assert_equal :stop, @server.status + assert_equal 200, status + assert_equal ['{ "status": "ok" }'], app.enum_for.to_a + end + + def test_halt + status, _ , app = lint('/halt') + + assert_equal :halt, @server.status + assert_equal 200, status + assert_equal ['{ "status": "ok" }'], app.enum_for.to_a + end + + def test_stats + status, _ , app = lint('/stats') + + assert_equal 200, status + assert_equal ['{}'], app.enum_for.to_a + end + + def test_alternate_location + status, _ , _ = lint('__alternatE_location_/stats') + assert_equal 200, status + end +end diff --git a/test/test_binder.rb b/test/test_binder.rb new file mode 100644 index 0000000..c2fa42b --- /dev/null +++ b/test/test_binder.rb @@ -0,0 +1,519 @@ +# frozen_string_literal: true + +require_relative "helper" +require_relative "helpers/ssl" if ::Puma::HAS_SSL +require_relative "helpers/tmp_path" + +require "puma/binder" +require "puma/events" +require "puma/configuration" + +class TestBinderBase < Minitest::Test + include SSLHelper if ::Puma::HAS_SSL + include TmpPath + + def setup + @events = Puma::Events.strings + @binder = Puma::Binder.new(@events) + end + + def teardown + @binder.ios.reject! { |io| Minitest::Mock === io || io.to_io.closed? } + @binder.close + @binder.unix_paths.select! { |path| File.exist? path } + @binder.close_listeners + end + + private + + def ssl_context_for_binder(binder = @binder) + binder.ios[0].instance_variable_get(:@ctx) + end +end + +class TestBinder < TestBinderBase + parallelize_me! + + def test_synthesize_binds_from_activated_fds_no_sockets + binds = ['tcp://0.0.0.0:3000'] + result = @binder.synthesize_binds_from_activated_fs(binds, true) + + assert_equal ['tcp://0.0.0.0:3000'], result + end + + def test_synthesize_binds_from_activated_fds_non_matching_together + binds = ['tcp://0.0.0.0:3000'] + sockets = {['tcp', '0.0.0.0', '5000'] => nil} + @binder.instance_variable_set(:@activated_sockets, sockets) + result = @binder.synthesize_binds_from_activated_fs(binds, false) + + assert_equal ['tcp://0.0.0.0:3000', 'tcp://0.0.0.0:5000'], result + end + + def test_synthesize_binds_from_activated_fds_non_matching_only + binds = ['tcp://0.0.0.0:3000'] + sockets = {['tcp', '0.0.0.0', '5000'] => nil} + @binder.instance_variable_set(:@activated_sockets, sockets) + result = @binder.synthesize_binds_from_activated_fs(binds, true) + + assert_equal ['tcp://0.0.0.0:5000'], result + end + + def test_synthesize_binds_from_activated_fds_complex_binds + binds = [ + 'tcp://0.0.0.0:3000', + 'ssl://192.0.2.100:5000', + 'ssl://192.0.2.101:5000?no_tlsv1=true', + 'unix:///run/puma.sock' + ] + sockets = { + ['tcp', '0.0.0.0', '5000'] => nil, + ['tcp', '192.0.2.100', '5000'] => nil, + ['tcp', '192.0.2.101', '5000'] => nil, + ['unix', '/run/puma.sock'] => nil + } + @binder.instance_variable_set(:@activated_sockets, sockets) + result = @binder.synthesize_binds_from_activated_fs(binds, false) + + expected = ['tcp://0.0.0.0:3000', 'ssl://192.0.2.100:5000', 'ssl://192.0.2.101:5000?no_tlsv1=true', 'unix:///run/puma.sock', 'tcp://0.0.0.0:5000'] + assert_equal expected, result + end + + def test_localhost_addresses_dont_alter_listeners_for_tcp_addresses + @binder.parse ["tcp://localhost:0"], @events + + assert_empty @binder.listeners + end + + def test_home_alters_listeners_for_tcp_addresses + port = UniquePort.call + @binder.parse ["tcp://127.0.0.1:#{port}"], @events + + assert_equal "tcp://127.0.0.1:#{port}", @binder.listeners[0][0] + assert_kind_of TCPServer, @binder.listeners[0][1] + end + + def test_connected_ports + ports = (1..3).map { |_| UniquePort.call } + + @binder.parse(ports.map { |p| "tcp://localhost:#{p}" }, @events) + + assert_equal ports, @binder.connected_ports + end + + def test_localhost_addresses_dont_alter_listeners_for_ssl_addresses + skip_unless :ssl + @binder.parse ["ssl://localhost:0?#{ssl_query}"], @events + + assert_empty @binder.listeners + end + + def test_home_alters_listeners_for_ssl_addresses + skip_unless :ssl + port = UniquePort.call + @binder.parse ["ssl://127.0.0.1:#{port}?#{ssl_query}"], @events + + assert_equal "ssl://127.0.0.1:#{port}?#{ssl_query}", @binder.listeners[0][0] + assert_kind_of TCPServer, @binder.listeners[0][1] + end + + def test_correct_zero_port + @binder.parse ["tcp://localhost:0"], @events + + m = %r!http://127.0.0.1:(\d+)!.match(@events.stdout.string) + port = m[1].to_i + + refute_equal 0, port + end + + def test_correct_zero_port_ssl + skip_unless :ssl + + ssl_regex = %r!ssl://127.0.0.1:(\d+)! + + @binder.parse ["ssl://localhost:0?#{ssl_query}"], @events + + port = ssl_regex.match(@events.stdout.string)[1].to_i + + refute_equal 0, port + end + + def test_logs_all_localhost_bindings + @binder.parse ["tcp://localhost:0"], @events + + assert_match %r!http://127.0.0.1:(\d+)!, @events.stdout.string + if Socket.ip_address_list.any? {|i| i.ipv6_loopback? } + assert_match %r!http://\[::1\]:(\d+)!, @events.stdout.string + end + end + + def test_logs_all_localhost_bindings_ssl + skip_unless :ssl + + @binder.parse ["ssl://localhost:0?#{ssl_query}"], @events + + assert_match %r!ssl://127.0.0.1:(\d+)!, @events.stdout.string + if Socket.ip_address_list.any? {|i| i.ipv6_loopback? } + assert_match %r!ssl://\[::1\]:(\d+)!, @events.stdout.string + end + end + + def test_allows_both_ssl_and_tcp + assert_parsing_logs_uri [:ssl, :tcp] + end + + def test_allows_both_unix_and_tcp + skip_if :jruby # Undiagnosed thread race. TODO fix + assert_parsing_logs_uri [:unix, :tcp] + end + + def test_allows_both_tcp_and_unix + assert_parsing_logs_uri [:tcp, :unix] + end + + def test_pre_existing_unix + skip_unless :unix + + unix_path = tmp_path('.sock') + File.open(unix_path, mode: 'wb') { |f| f.puts 'pre existing' } + @binder.parse ["unix://#{unix_path}"], @events + + assert_match %r!unix://#{unix_path}!, @events.stdout.string + + refute_includes @binder.unix_paths, unix_path + + @binder.close_listeners + + assert File.exist?(unix_path) + + ensure + if UNIX_SKT_EXIST + File.unlink unix_path if File.exist? unix_path + end + end + + def test_binder_parses_nil_low_latency + skip_if :jruby + @binder.parse ["tcp://0.0.0.0:0?low_latency"], @events + + socket = @binder.listeners.first.last + + assert socket.getsockopt(Socket::IPPROTO_TCP, Socket::TCP_NODELAY).bool + end + + def test_binder_parses_true_low_latency + skip_if :jruby + @binder.parse ["tcp://0.0.0.0:0?low_latency=true"], @events + + socket = @binder.listeners.first.last + + assert socket.getsockopt(Socket::IPPROTO_TCP, Socket::TCP_NODELAY).bool + end + + def test_binder_parses_false_low_latency + skip_if :jruby + @binder.parse ["tcp://0.0.0.0:0?low_latency=false"], @events + + socket = @binder.listeners.first.last + + refute socket.getsockopt(Socket::IPPROTO_TCP, Socket::TCP_NODELAY).bool + end + + def test_binder_parses_tlsv1_disabled + skip_unless :ssl + @binder.parse ["ssl://0.0.0.0:0?#{ssl_query}&no_tlsv1=true"], @events + + assert ssl_context_for_binder.no_tlsv1 + end + + def test_binder_parses_tlsv1_enabled + skip_unless :ssl + @binder.parse ["ssl://0.0.0.0:0?#{ssl_query}&no_tlsv1=false"], @events + + refute ssl_context_for_binder.no_tlsv1 + end + + def test_binder_parses_tlsv1_tlsv1_1_unspecified_defaults_to_enabled + skip_unless :ssl + @binder.parse ["ssl://0.0.0.0:0?#{ssl_query}"], @events + + refute ssl_context_for_binder.no_tlsv1 + refute ssl_context_for_binder.no_tlsv1_1 + end + + def test_binder_parses_tlsv1_1_disabled + skip_unless :ssl + @binder.parse ["ssl://0.0.0.0:0?#{ssl_query}&no_tlsv1_1=true"], @events + + assert ssl_context_for_binder.no_tlsv1_1 + end + + def test_binder_parses_tlsv1_1_enabled + skip_unless :ssl + @binder.parse ["ssl://0.0.0.0:0?#{ssl_query}&no_tlsv1_1=false"], @events + + refute ssl_context_for_binder.no_tlsv1_1 + end + + def test_env_contains_protoenv + skip_unless :ssl + @binder.parse ["ssl://localhost:0?#{ssl_query}"], @events + + env_hash = @binder.envs[@binder.ios.first] + + @binder.proto_env.each do |k,v| + assert env_hash[k] == v + end + end + + def test_env_contains_stderr + skip_unless :ssl + @binder.parse ["ssl://localhost:0?#{ssl_query}"], @events + + env_hash = @binder.envs[@binder.ios.first] + + assert_equal @events.stderr, env_hash["rack.errors"] + end + + def test_ssl_binder_sets_backlog + skip_unless :ssl + + host = '127.0.0.1' + port = UniquePort.call + tcp_server = TCPServer.new(host, port) + tcp_server.define_singleton_method(:listen) do |backlog| + Thread.current[:backlog] = backlog + super(backlog) + end + + TCPServer.stub(:new, tcp_server) do + @binder.parse ["ssl://#{host}:#{port}?#{ssl_query}&backlog=2048"], @events + end + + assert_equal 2048, Thread.current[:backlog] + end + + def test_close_calls_close_on_ios + @mocked_ios = [Minitest::Mock.new, Minitest::Mock.new] + @mocked_ios.each { |m| m.expect(:close, true) } + @binder.ios = @mocked_ios + + @binder.close + + assert @mocked_ios.map(&:verify).all? + end + + def test_redirects_for_restart_creates_a_hash + @binder.parse ["tcp://127.0.0.1:0"], @events + + result = @binder.redirects_for_restart + ios = @binder.listeners.map { |_l, io| io.to_i } + + ios.each { |int| assert_equal int, result[int] } + assert result[:close_others] + end + + def test_redirects_for_restart_env + @binder.parse ["tcp://127.0.0.1:0"], @events + + result = @binder.redirects_for_restart_env + + @binder.listeners.each_with_index do |l, i| + assert_equal "#{l[1].to_i}:#{l[0]}", result["PUMA_INHERIT_#{i}"] + end + end + + def test_close_listeners_closes_ios + @binder.parse ["tcp://127.0.0.1:#{UniquePort.call}"], @events + + refute @binder.listeners.any? { |_l, io| io.closed? } + + @binder.close_listeners + + assert @binder.listeners.all? { |_l, io| io.closed? } + end + + def test_close_listeners_closes_ios_unless_closed? + @binder.parse ["tcp://127.0.0.1:0"], @events + + bomb = @binder.listeners.first[1] + bomb.close + def bomb.close; raise "Boom!"; end # the bomb has been planted + + assert @binder.listeners.any? { |_l, io| io.closed? } + + @binder.close_listeners + + assert @binder.listeners.all? { |_l, io| io.closed? } + end + + def test_listeners_file_unlink_if_unix_listener + skip_unless :unix + + unix_path = tmp_path('.sock') + @binder.parse ["unix://#{unix_path}"], @events + assert File.socket?(unix_path) + + @binder.close_listeners + refute File.socket?(unix_path) + end + + def test_import_from_env_listen_inherit + @binder.parse ["tcp://127.0.0.1:0"], @events + removals = @binder.create_inherited_fds(@binder.redirects_for_restart_env) + + @binder.listeners.each do |l, io| + assert_equal io.to_i, @binder.inherited_fds[l] + end + assert_includes removals, "PUMA_INHERIT_0" + end + + # Socket activation tests. We have to skip all of these on non-UNIX platforms + # because the check that we do in the code only works if you support UNIX sockets. + # This is OK, because systemd obviously only works on Linux. + def test_socket_activation_tcp + skip_unless :unix + url = "127.0.0.1" + port = UniquePort.call + sock = Addrinfo.tcp(url, port).listen + assert_activates_sockets(url: url, port: port, sock: sock) + end + + def test_socket_activation_tcp_ipv6 + skip_unless :unix + url = "::" + port = UniquePort.call + sock = Addrinfo.tcp(url, port).listen + assert_activates_sockets(url: url, port: port, sock: sock) + end + + def test_socket_activation_unix + skip_if :jruby # Failing with what I think is a JRuby bug + skip_unless :unix + + state_path = tmp_path('.state') + sock = Addrinfo.unix(state_path).listen + assert_activates_sockets(path: state_path, sock: sock) + ensure + File.unlink(state_path) rescue nil # JRuby race? + end + + def test_rack_multithread_default_configuration + binder = Puma::Binder.new(@events) + + assert binder.proto_env["rack.multithread"] + end + + def test_rack_multithread_custom_configuration + conf = Puma::Configuration.new(max_threads: 1) + + binder = Puma::Binder.new(@events, conf) + + refute binder.proto_env["rack.multithread"] + end + + def test_rack_multiprocess_default_configuration + binder = Puma::Binder.new(@events) + + refute binder.proto_env["rack.multiprocess"] + end + + def test_rack_multiprocess_custom_configuration + conf = Puma::Configuration.new(workers: 1) + + binder = Puma::Binder.new(@events, conf) + + assert binder.proto_env["rack.multiprocess"] + end + + private + + def assert_activates_sockets(path: nil, port: nil, url: nil, sock: nil) + hash = { "LISTEN_FDS" => 1, "LISTEN_PID" => $$ } + @events.instance_variable_set(:@debug, true) + + @binder.instance_variable_set(:@sock_fd, sock.fileno) + def @binder.socket_activation_fd(int); @sock_fd; end + @result = @binder.create_activated_fds(hash) + + url = "[::]" if url == "::" + ary = path ? [:unix, path] : [:tcp, url, port] + + assert_kind_of TCPServer, @binder.activated_sockets[ary] + assert_match "Registered #{ary.join(":")} for activation from LISTEN_FDS", @events.stdout.string + assert_equal ["LISTEN_FDS", "LISTEN_PID"], @result + end + + def assert_parsing_logs_uri(order = [:unix, :tcp]) + skip MSG_UNIX if order.include?(:unix) && !UNIX_SKT_EXIST + skip_unless :ssl + + unix_path = tmp_path('.sock') + prepared_paths = { + ssl: "ssl://127.0.0.1:#{UniquePort.call}?#{ssl_query}", + tcp: "tcp://127.0.0.1:#{UniquePort.call}", + unix: "unix://#{unix_path}" + } + + expected_logs = prepared_paths.dup.tap do |logs| + logs[:tcp] = logs[:tcp].gsub('tcp://', 'http://') + end + + tested_paths = [prepared_paths[order[0]], prepared_paths[order[1]]] + + @binder.parse tested_paths, @events + stdout = @events.stdout.string + + order.each do |prot| + assert_match expected_logs[prot], stdout + end + ensure + @binder.close_listeners if order.include?(:unix) && UNIX_SKT_EXIST + end +end + +class TestBinderJRuby < TestBinderBase + def test_binder_parses_jruby_ssl_options + skip_unless :ssl + + keystore = File.expand_path "../../examples/puma/keystore.jks", __FILE__ + ssl_cipher_list = "TLS_DHE_RSA_WITH_AES_128_CBC_SHA,TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256" + + @binder.parse ["ssl://0.0.0.0:8080?#{ssl_query}"], @events + + assert_equal keystore, ssl_context_for_binder.keystore + assert_equal ssl_cipher_list, ssl_context_for_binder.ssl_cipher_list + end +end if ::Puma::IS_JRUBY + +class TestBinderMRI < TestBinderBase + def test_binder_parses_ssl_cipher_filter + skip_unless :ssl + + ssl_cipher_filter = "AES@STRENGTH" + + @binder.parse ["ssl://0.0.0.0?#{ssl_query}&ssl_cipher_filter=#{ssl_cipher_filter}"], @events + + assert_equal ssl_cipher_filter, ssl_context_for_binder.ssl_cipher_filter + end + + def test_binder_parses_ssl_verification_flags_one + skip_unless :ssl + + input = "&verification_flags=TRUSTED_FIRST" + + @binder.parse ["ssl://0.0.0.0?#{ssl_query}#{input}"], @events + + assert_equal 0x8000, ssl_context_for_binder.verification_flags + end + + def test_binder_parses_ssl_verification_flags_multiple + skip_unless :ssl + + input = "&verification_flags=TRUSTED_FIRST,NO_CHECK_TIME" + + @binder.parse ["ssl://0.0.0.0?#{ssl_query}#{input}"], @events + + assert_equal 0x8000 | 0x200000, ssl_context_for_binder.verification_flags + end +end unless ::Puma::IS_JRUBY diff --git a/test/test_busy_worker.rb b/test/test_busy_worker.rb new file mode 100644 index 0000000..e532136 --- /dev/null +++ b/test/test_busy_worker.rb @@ -0,0 +1,102 @@ +require_relative "helper" +require "puma/events" + +class TestBusyWorker < Minitest::Test + def setup + skip_unless :mri # This feature only makes sense on MRI + @ios = [] + @server = nil + end + + def teardown + return if skipped? + @server.stop(true) if @server + @ios.each {|i| i.close unless i.closed?} + end + + def new_connection + TCPSocket.new('127.0.0.1', @port).tap {|s| @ios << s} + rescue IOError + Puma::Util.purge_interrupt_queue + retry + end + + def send_http(req) + new_connection << req + end + + def send_http_and_read(req) + send_http(req).read + end + + def with_server(**options, &app) + @requests_count = 0 # number of requests processed + @requests_running = 0 # current number of requests running + @requests_max_running = 0 # max number of requests running in parallel + @mutex = Mutex.new + + request_handler = ->(env) do + @mutex.synchronize do + @requests_count += 1 + @requests_running += 1 + if @requests_running > @requests_max_running + @requests_max_running = @requests_running + end + end + + begin + yield(env) + ensure + @mutex.synchronize do + @requests_running -= 1 + end + end + end + + @server = Puma::Server.new request_handler, Puma::Events.strings, **options + @server.min_threads = options[:min_threads] || 0 + @server.max_threads = options[:max_threads] || 10 + @port = (@server.add_tcp_listener '127.0.0.1', 0).addr[1] + @server.run + end + + # Multiple concurrent requests are not processed + # sequentially as a small delay is introduced + def test_multiple_requests_waiting_on_less_busy_worker + with_server(wait_for_less_busy_worker: 1.0) do |_| + sleep(0.1) + + [200, {}, [""]] + end + + n = 2 + + Array.new(n) do + Thread.new { send_http_and_read "GET / HTTP/1.0\r\n\r\n" } + end.each(&:join) + + assert_equal n, @requests_count, "number of requests needs to match" + assert_equal 0, @requests_running, "none of requests needs to be running" + assert_equal 1, @requests_max_running, "maximum number of concurrent requests needs to be 1" + end + + # Multiple concurrent requests are processed + # in parallel as a delay is disabled + def test_multiple_requests_processing_in_parallel + with_server(wait_for_less_busy_worker: 0.0) do |_| + sleep(0.1) + + [200, {}, [""]] + end + + n = 4 + + Array.new(n) do + Thread.new { send_http_and_read "GET / HTTP/1.0\r\n\r\n" } + end.each(&:join) + + assert_equal n, @requests_count, "number of requests needs to match" + assert_equal 0, @requests_running, "none of requests needs to be running" + assert_equal n, @requests_max_running, "maximum number of concurrent requests needs to match" + end +end diff --git a/test/test_cli.rb b/test/test_cli.rb new file mode 100644 index 0000000..0967070 --- /dev/null +++ b/test/test_cli.rb @@ -0,0 +1,489 @@ +require_relative "helper" +require_relative "helpers/ssl" if ::Puma::HAS_SSL +require_relative "helpers/tmp_path" + +require "puma/cli" +require "json" +require "psych" + +class TestCLI < Minitest::Test + include SSLHelper if ::Puma::HAS_SSL + include TmpPath + + def setup + @environment = 'production' + + @tmp_path = tmp_path('puma-test') + @tmp_path2 = "#{@tmp_path}2" + + File.unlink @tmp_path if File.exist? @tmp_path + File.unlink @tmp_path2 if File.exist? @tmp_path2 + + @wait, @ready = IO.pipe + + @events = Puma::Events.strings + @events.on_booted { @ready << "!" } + end + + def wait_booted + @wait.sysread 1 + rescue Errno::EAGAIN + sleep 0.001 + retry + end + + def teardown + File.unlink @tmp_path if File.exist? @tmp_path + File.unlink @tmp_path2 if File.exist? @tmp_path2 + + @wait.close + @ready.close + end + + def test_control_for_tcp + cntl = UniquePort.call + url = "tcp://127.0.0.1:#{cntl}/" + + cli = Puma::CLI.new ["-b", "tcp://127.0.0.1:0", + "--control-url", url, + "--control-token", "", + "test/rackup/lobster.ru"], @events + + t = Thread.new do + cli.run + end + + wait_booted + + s = TCPSocket.new "127.0.0.1", cntl + s << "GET /stats HTTP/1.0\r\n\r\n" + body = s.read + s.close + + assert_equal Puma.stats_hash, JSON.parse(Puma.stats, symbolize_names: true) + + dmt = Puma::Configuration.new.default_max_threads + assert_match(/{"started_at":"\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z","backlog":0,"running":0,"pool_capacity":#{dmt},"max_threads":#{dmt},"requests_count":0}/, body.split(/\r?\n/).last) + + ensure + cli.launcher.stop + t.join + end + + def test_control_for_ssl + skip_unless :ssl + + require "net/http" + control_port = UniquePort.call + control_host = "127.0.0.1" + control_url = "ssl://#{control_host}:#{control_port}?#{ssl_query}" + token = "token" + + cli = Puma::CLI.new ["-b", "tcp://127.0.0.1:0", + "--control-url", control_url, + "--control-token", token, + "test/rackup/lobster.ru"], @events + + t = Thread.new do + cli.run + end + + wait_booted + + body = "" + http = Net::HTTP.new control_host, control_port + http.use_ssl = true + http.verify_mode = OpenSSL::SSL::VERIFY_NONE + http.start do + req = Net::HTTP::Get.new "/stats?token=#{token}", {} + body = http.request(req).body + end + + dmt = Puma::Configuration.new.default_max_threads + expected_stats = /{"started_at":"\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z","backlog":0,"running":0,"pool_capacity":#{dmt},"max_threads":#{dmt}/ + assert_match(expected_stats, body.split(/\r?\n/).last) + + ensure + cli.launcher.stop if cli + t.join if t + end + + def test_control_clustered + skip_unless :fork + skip_unless :unix + url = "unix://#{@tmp_path}" + + cli = Puma::CLI.new ["-b", "unix://#{@tmp_path2}", + "-t", "2:2", + "-w", "2", + "--control-url", url, + "--control-token", "", + "test/rackup/lobster.ru"], @events + + # without this, Minitest.after_run will trigger on this test ? + $debugging_hold = true + + t = Thread.new { cli.run } + + wait_booted + + s = UNIXSocket.new @tmp_path + s << "GET /stats HTTP/1.0\r\n\r\n" + body = s.read + s.close + + require 'json' + status = JSON.parse(body.split("\n").last) + + assert_equal 2, status["workers"] + + s = UNIXSocket.new @tmp_path + s << "GET /stats HTTP/1.0\r\n\r\n" + body = s.read + s.close + + assert_match(/\{"started_at":"\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z","workers":2,"phase":0,"booted_workers":2,"old_workers":0,"worker_status":\[\{"started_at":"\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z","pid":\d+,"index":0,"phase":0,"booted":true,"last_checkin":"[^"]+","last_status":\{"backlog":0,"running":2,"pool_capacity":2,"max_threads":2,"requests_count":0\}\},\{"started_at":"\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z","pid":\d+,"index":1,"phase":0,"booted":true,"last_checkin":"[^"]+","last_status":\{"backlog":0,"running":2,"pool_capacity":2,"max_threads":2,"requests_count":0\}\}\]\}/, body.split("\r\n").last) + ensure + if UNIX_SKT_EXIST && HAS_FORK + cli.launcher.stop + t.join + + done = nil + until done + @events.stdout.rewind + log = @events.stdout.readlines.join '' + done = log[/ - Goodbye!/] + end + + $debugging_hold = false + end + end + + def test_control + skip_unless :unix + url = "unix://#{@tmp_path}" + + cli = Puma::CLI.new ["-b", "unix://#{@tmp_path2}", + "--control-url", url, + "--control-token", "", + "test/rackup/lobster.ru"], @events + + t = Thread.new { cli.run } + + wait_booted + + s = UNIXSocket.new @tmp_path + s << "GET /stats HTTP/1.0\r\n\r\n" + body = s.read + s.close + + dmt = Puma::Configuration.new.default_max_threads + assert_match(/{"started_at":"\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z","backlog":0,"running":0,"pool_capacity":#{dmt},"max_threads":#{dmt},"requests_count":0}/, body.split("\r\n").last) + ensure + if UNIX_SKT_EXIST + cli.launcher.stop + t.join + end + end + + def test_control_stop + skip_unless :unix + url = "unix://#{@tmp_path}" + + cli = Puma::CLI.new ["-b", "unix://#{@tmp_path2}", + "--control-url", url, + "--control-token", "", + "test/rackup/lobster.ru"], @events + + t = Thread.new { cli.run } + + wait_booted + + s = UNIXSocket.new @tmp_path + s << "GET /stop HTTP/1.0\r\n\r\n" + body = s.read + s.close + + assert_equal '{ "status": "ok" }', body.split("\r\n").last + ensure + t.join if UNIX_SKT_EXIST + end + + def test_control_requests_count + tcp = UniquePort.call + cntl = UniquePort.call + url = "tcp://127.0.0.1:#{cntl}/" + + cli = Puma::CLI.new ["-b", "tcp://127.0.0.1:#{tcp}", + "--control-url", url, + "--control-token", "", + "test/rackup/lobster.ru"], @events + + t = Thread.new do + cli.run + end + + wait_booted + + s = TCPSocket.new "127.0.0.1", cntl + s << "GET /stats HTTP/1.0\r\n\r\n" + body = s.read + s.close + + assert_equal 0, JSON.parse(body.split(/\r?\n/).last)['requests_count'] + + # send real requests to server + 3.times do + s = TCPSocket.new "127.0.0.1", tcp + s << "GET / HTTP/1.0\r\n\r\n" + body = s.read + s.close + end + + s = TCPSocket.new "127.0.0.1", cntl + s << "GET /stats HTTP/1.0\r\n\r\n" + body = s.read + s.close + + assert_equal 3, JSON.parse(body.split(/\r?\n/).last)['requests_count'] + ensure + cli.launcher.stop + t.join + end + + def test_control_thread_backtraces + skip_unless :unix + url = "unix://#{@tmp_path}" + + cli = Puma::CLI.new ["-b", "unix://#{@tmp_path2}", + "--control-url", url, + "--control-token", "", + "test/rackup/lobster.ru"], @events + + t = Thread.new { cli.run } + + wait_booted + + s = UNIXSocket.new @tmp_path + s << "GET /thread-backtraces HTTP/1.0\r\n\r\n" + body = s.read + s.close + + assert_match %r{Thread: TID-}, body.split("\r\n").last + ensure + cli.launcher.stop if cli + t.join if UNIX_SKT_EXIST + end + + def control_gc_stats(uri, cntl) + cli = Puma::CLI.new ["-b", uri, + "--control-url", cntl, + "--control-token", "", + "test/rackup/lobster.ru"], @events + + t = Thread.new do + cli.run + end + + wait_booted + + s = yield + s << "GET /gc-stats HTTP/1.0\r\n\r\n" + body = s.read + s.close + + lines = body.split("\r\n") + json_line = lines.detect { |l| l[0] == "{" } + pairs = json_line.scan(/\"[^\"]+\": [^,]+/) + gc_stats = {} + pairs.each do |p| + p =~ /\"([^\"]+)\": ([^,]+)/ || raise("Can't parse #{p.inspect}!") + gc_stats[$1] = $2 + end + gc_count_before = gc_stats["count"].to_i + + s = yield + s << "GET /gc HTTP/1.0\r\n\r\n" + body = s.read # Ignored + s.close + + s = yield + s << "GET /gc-stats HTTP/1.0\r\n\r\n" + body = s.read + s.close + + lines = body.split("\r\n") + json_line = lines.detect { |l| l[0] == "{" } + gc_stats = JSON.parse(json_line) + gc_count_after = gc_stats["count"].to_i + + # Hitting the /gc route should increment the count by 1 + assert(gc_count_before < gc_count_after, "make sure a gc has happened") + + ensure + cli.launcher.stop if cli + t.join + end + + def test_control_gc_stats_tcp + uri = "tcp://127.0.0.1:#{UniquePort.call}/" + cntl_port = UniquePort.call + cntl = "tcp://127.0.0.1:#{cntl_port}/" + + control_gc_stats(uri, cntl) { TCPSocket.new "127.0.0.1", cntl_port } + end + + def test_control_gc_stats_unix + skip_unless :unix + + uri = "unix://#{@tmp_path2}" + cntl = "unix://#{@tmp_path}" + + control_gc_stats(uri, cntl) { UNIXSocket.new @tmp_path } + end + + def test_tmp_control + skip_if :jruby, suffix: " - Unknown issue" + + cli = Puma::CLI.new ["--state", @tmp_path, "--control-url", "auto"] + cli.launcher.write_state + + opts = cli.launcher.instance_variable_get(:@options) + + data = Psych.load_file @tmp_path + + Puma::StateFile::ALLOWED_FIELDS.each do |key| + val = + case key + when 'pid' then Process.pid + when 'running_from' then File.expand_path('.') # same as Launcher + else opts[key.to_sym] + end + assert_equal val, data[key] + end + + assert_equal (Puma::StateFile::ALLOWED_FIELDS & data.keys).sort, data.keys.sort + + url = data["control_url"] + + m = %r!unix://(.*)!.match(url) + + assert m, "'#{url}' is not a URL" + end + + def test_state_file_callback_filtering + skip_unless :fork + cli = Puma::CLI.new [ "--config", "test/config/state_file_testing_config.rb", + "--state", @tmp_path ] + cli.launcher.write_state + + data = Psych.load_file @tmp_path + + assert_equal (Puma::StateFile::ALLOWED_FIELDS & data.keys).sort, data.keys.sort + end + + def test_log_formatter_default_single + cli = Puma::CLI.new [ ] + assert_instance_of Puma::Events::DefaultFormatter, cli.launcher.events.formatter + end + + def test_log_formatter_default_clustered + skip_unless :fork + + cli = Puma::CLI.new [ "-w 2" ] + assert_instance_of Puma::Events::PidFormatter, cli.launcher.events.formatter + end + + def test_log_formatter_custom_single + cli = Puma::CLI.new [ "--config", "test/config/custom_log_formatter.rb" ] + assert_instance_of Proc, cli.launcher.events.formatter + assert_match(/^\[.*\] \[.*\] .*: test$/, cli.launcher.events.format('test')) + end + + def test_log_formatter_custom_clustered + skip_unless :fork + + cli = Puma::CLI.new [ "--config", "test/config/custom_log_formatter.rb", "-w 2" ] + assert_instance_of Proc, cli.launcher.events.formatter + assert_match(/^\[.*\] \[.*\] .*: test$/, cli.launcher.events.format('test')) + end + + def test_state + url = "tcp://127.0.0.1:#{UniquePort.call}" + cli = Puma::CLI.new ["--state", @tmp_path, "--control-url", url] + cli.launcher.write_state + + data = Psych.load_file @tmp_path + + assert_equal Process.pid, data["pid"] + assert_equal url, data["control_url"] + end + + def test_load_path + Puma::CLI.new ["--include", 'foo/bar'] + + assert_equal 'foo/bar', $LOAD_PATH[0] + $LOAD_PATH.shift + + Puma::CLI.new ["--include", 'foo/bar:baz/qux'] + + assert_equal 'foo/bar', $LOAD_PATH[0] + $LOAD_PATH.shift + assert_equal 'baz/qux', $LOAD_PATH[0] + $LOAD_PATH.shift + end + + def test_extra_runtime_dependencies + cli = Puma::CLI.new ['--extra-runtime-dependencies', 'a,b'] + extra_dependencies = cli.instance_variable_get(:@conf) + .instance_variable_get(:@options)[:extra_runtime_dependencies] + + assert_equal %w[a b], extra_dependencies + end + + def test_environment_app_env + ENV['RACK_ENV'] = @environment + ENV['RAILS_ENV'] = @environment + ENV['APP_ENV'] = 'test' + + cli = Puma::CLI.new [] + cli.send(:setup_options) + + assert_equal 'test', cli.instance_variable_get(:@conf).environment.call + ensure + ENV.delete 'APP_ENV' + ENV.delete 'RAILS_ENV' + end + + def test_environment_rack_env + ENV['RACK_ENV'] = @environment + + cli = Puma::CLI.new [] + cli.send(:setup_options) + + assert_equal @environment, cli.instance_variable_get(:@conf).environment.call + end + + def test_environment_rails_env + ENV.delete 'RACK_ENV' + ENV['RAILS_ENV'] = @environment + + cli = Puma::CLI.new [] + cli.send(:setup_options) + + assert_equal @environment, cli.instance_variable_get(:@conf).environment.call + ensure + ENV.delete 'RAILS_ENV' + end + + def test_silent + cli = Puma::CLI.new ['--silent'] + cli.send(:setup_options) + + events = cli.instance_variable_get(:@events) + + assert_equal events.class, Puma::Events.null.class + assert_equal events.stdout.class, Puma::NullIO + assert_equal events.stderr, $stderr + end +end diff --git a/test/test_config.rb b/test/test_config.rb new file mode 100644 index 0000000..d2ced63 --- /dev/null +++ b/test/test_config.rb @@ -0,0 +1,557 @@ +# frozen_string_literal: true + +require_relative "helper" +require_relative "helpers/config_file" + +require "puma/configuration" +require 'puma/events' + +class TestConfigFile < TestConfigFileBase + parallelize_me! + + def test_default_max_threads + max_threads = 16 + max_threads = 5 if RUBY_ENGINE.nil? || RUBY_ENGINE == 'ruby' + assert_equal max_threads, Puma::Configuration.new.default_max_threads + end + + def test_app_from_rackup + conf = Puma::Configuration.new do |c| + c.rackup "test/rackup/hello-bind.ru" + end + conf.load + + # suppress deprecation warning of Rack (>= 2.2.0) + # > Parsing options from the first comment line is deprecated!\n + assert_output(nil, nil) do + conf.app + end + + assert_equal ["tcp://127.0.0.1:9292"], conf.options[:binds] + end + + def test_app_from_app_DSL + conf = Puma::Configuration.new do |c| + c.load "test/config/app.rb" + end + conf.load + + app = conf.app + + assert_equal [200, {}, ["embedded app"]], app.call({}) + end + + def test_ssl_configuration_from_DSL + skip_unless :ssl + conf = Puma::Configuration.new do |config| + config.load "test/config/ssl_config.rb" + end + + conf.load + + bind_configuration = conf.options.file_options[:binds].first + app = conf.app + + assert bind_configuration =~ %r{ca=.*ca.crt} + assert bind_configuration =~ /verify_mode=peer/ + + assert_equal [200, {}, ["embedded app"]], app.call({}) + end + + def test_ssl_self_signed_configuration_from_DSL + skip_if :jruby + skip_unless :ssl + conf = Puma::Configuration.new do |config| + config.load "test/config/ssl_self_signed_config.rb" + end + + conf.load + + bind_configuration = conf.options.file_options[:binds].first + app = conf.app + + ssl_binding = "ssl://0.0.0.0:9292?cert=&key=&verify_mode=none" + assert_equal [ssl_binding], conf.options[:binds] + end + + def test_ssl_bind + skip_if :jruby + skip_unless :ssl + + conf = Puma::Configuration.new do |c| + c.ssl_bind "0.0.0.0", "9292", { + cert: "/path/to/cert", + key: "/path/to/key", + verify_mode: "the_verify_mode", + } + end + + conf.load + + ssl_binding = "ssl://0.0.0.0:9292?cert=/path/to/cert&key=/path/to/key&verify_mode=the_verify_mode" + assert_equal [ssl_binding], conf.options[:binds] + end + + def test_ssl_bind_with_cert_and_key_pem + skip_if :jruby + skip_unless :ssl + + cert_path = File.expand_path "../examples/puma/client-certs", __dir__ + cert_pem = File.read("#{cert_path}/server.crt") + key_pem = File.read("#{cert_path}/server.key") + + conf = Puma::Configuration.new do |c| + c.ssl_bind "0.0.0.0", "9292", { + cert_pem: cert_pem, + key_pem: key_pem, + verify_mode: "the_verify_mode", + } + end + + conf.load + + ssl_binding = "ssl://0.0.0.0:9292?cert=store:0&key=store:1&verify_mode=the_verify_mode" + assert_equal [ssl_binding], conf.options[:binds] + end + + def test_ssl_bind_with_backlog + skip_unless :ssl + + conf = Puma::Configuration.new do |c| + c.ssl_bind "0.0.0.0", "9292", { + backlog: "2048", + } + end + + conf.load + + ssl_binding = conf.options[:binds].first + assert ssl_binding.include?('&backlog=2048') + end + + def test_ssl_bind_jruby + skip_unless :jruby + skip_unless :ssl + + cipher_list = "TLS_DHE_RSA_WITH_AES_128_CBC_SHA,TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256" + + conf = Puma::Configuration.new do |c| + c.ssl_bind "0.0.0.0", "9292", { + keystore: "/path/to/keystore", + keystore_pass: "password", + ssl_cipher_list: cipher_list, + verify_mode: "the_verify_mode" + } + end + + conf.load + + ssl_binding = "ssl://0.0.0.0:9292?keystore=/path/to/keystore" \ + "&keystore-pass=password&ssl_cipher_list=#{cipher_list}" \ + "&verify_mode=the_verify_mode" + assert_equal [ssl_binding], conf.options[:binds] + end + + def test_ssl_bind_no_tlsv1_1 + skip_if :jruby + skip_unless :ssl + + conf = Puma::Configuration.new do |c| + c.ssl_bind "0.0.0.0", "9292", { + cert: "/path/to/cert", + key: "/path/to/key", + verify_mode: "the_verify_mode", + no_tlsv1_1: true + } + end + + conf.load + + ssl_binding = "ssl://0.0.0.0:9292?cert=/path/to/cert&key=/path/to/key&verify_mode=the_verify_mode&no_tlsv1_1=true" + assert_equal [ssl_binding], conf.options[:binds] + end + + def test_ssl_bind_with_cipher_filter + skip_if :jruby + skip_unless :ssl + + cipher_filter = "!aNULL:AES+SHA" + conf = Puma::Configuration.new do |c| + c.ssl_bind "0.0.0.0", "9292", { + cert: "cert", + key: "key", + ssl_cipher_filter: cipher_filter, + } + end + + conf.load + + ssl_binding = conf.options[:binds].first + assert ssl_binding.include?("&ssl_cipher_filter=#{cipher_filter}") + end + + def test_ssl_bind_with_verification_flags + skip_if :jruby + skip_unless :ssl + + conf = Puma::Configuration.new do |c| + c.ssl_bind "0.0.0.0", "9292", { + cert: "cert", + key: "key", + verification_flags: ["TRUSTED_FIRST", "NO_CHECK_TIME"] + } + end + + conf.load + + ssl_binding = conf.options[:binds].first + assert ssl_binding.include?("&verification_flags=TRUSTED_FIRST,NO_CHECK_TIME") + end + + def test_ssl_bind_with_ca + skip_unless :ssl + conf = Puma::Configuration.new do |c| + c.ssl_bind "0.0.0.0", "9292", { + cert: "/path/to/cert", + ca: "/path/to/ca", + key: "/path/to/key", + verify_mode: :peer, + } + end + + conf.load + + ssl_binding = conf.options[:binds].first + assert_match "ca=/path/to/ca", ssl_binding + assert_match "verify_mode=peer", ssl_binding + end + + def test_lowlevel_error_handler_DSL + conf = Puma::Configuration.new do |c| + c.load "test/config/app.rb" + end + conf.load + + app = conf.options[:lowlevel_error_handler] + + assert_equal [200, {}, ["error page"]], app.call({}) + end + + def test_allow_users_to_override_default_options + conf = Puma::Configuration.new(restart_cmd: 'bin/rails server') + + assert_equal 'bin/rails server', conf.options[:restart_cmd] + end + + def test_overwrite_options + conf = Puma::Configuration.new do |c| + c.workers 3 + end + conf.load + + assert_equal conf.options[:workers], 3 + conf.options[:workers] += 1 + assert_equal conf.options[:workers], 4 + end + + def test_explicit_config_files + conf = Puma::Configuration.new(config_files: ['test/config/settings.rb']) do |c| + end + conf.load + assert_match(/:3000$/, conf.options[:binds].first) + end + + def test_parameters_overwrite_files + conf = Puma::Configuration.new(config_files: ['test/config/settings.rb']) do |c| + c.port 3030 + end + conf.load + + assert_match(/:3030$/, conf.options[:binds].first) + assert_equal 3, conf.options[:min_threads] + assert_equal 5, conf.options[:max_threads] + end + + def test_config_files_default + conf = Puma::Configuration.new do + end + + assert_equal [nil], conf.config_files + end + + def test_config_files_with_dash + conf = Puma::Configuration.new(config_files: ['-']) do + end + + assert_equal [], conf.config_files + end + + def test_config_files_with_existing_path + conf = Puma::Configuration.new(config_files: ['test/config/settings.rb']) do + end + + assert_equal ['test/config/settings.rb'], conf.config_files + end + + def test_config_files_with_non_existing_path + conf = Puma::Configuration.new(config_files: ['test/config/typo/settings.rb']) do + end + + assert_equal ['test/config/typo/settings.rb'], conf.config_files + end + + def test_config_files_with_integer_convert + conf = Puma::Configuration.new(config_files: ['test/config/with_integer_convert.rb']) do + end + conf.load + + assert_equal 6, conf.options[:persistent_timeout] + assert_equal 3, conf.options[:first_data_timeout] + assert_equal 2, conf.options[:workers] + assert_equal 4, conf.options[:min_threads] + assert_equal 8, conf.options[:max_threads] + assert_equal 90, conf.options[:worker_timeout] + assert_equal 120, conf.options[:worker_boot_timeout] + assert_equal 150, conf.options[:worker_shutdown_timeout] + end + + def test_config_files_with_float_convert + conf = Puma::Configuration.new(config_files: ['test/config/with_float_convert.rb']) do + end + conf.load + + assert_equal Float::INFINITY, conf.options[:max_fast_inline] + end + + def test_config_files_with_symbol_convert + conf = Puma::Configuration.new(config_files: ['test/config/with_symbol_convert.rb']) do + end + conf.load + + assert_equal :ruby, conf.options[:io_selector_backend] + end + + def test_config_raise_exception_on_sigterm + conf = Puma::Configuration.new do |c| + c.raise_exception_on_sigterm false + end + conf.load + + assert_equal conf.options[:raise_exception_on_sigterm], false + conf.options[:raise_exception_on_sigterm] = true + assert_equal conf.options[:raise_exception_on_sigterm], true + end + + def test_run_hooks_on_restart_hook + assert_run_hooks :on_restart + end + + def test_run_hooks_before_worker_fork + assert_run_hooks :before_worker_fork, configured_with: :on_worker_fork + end + + def test_run_hooks_after_worker_fork + assert_run_hooks :after_worker_fork + end + + def test_run_hooks_before_worker_boot + assert_run_hooks :before_worker_boot, configured_with: :on_worker_boot + end + + def test_run_hooks_before_worker_shutdown + assert_run_hooks :before_worker_shutdown, configured_with: :on_worker_shutdown + end + + def test_run_hooks_before_fork + assert_run_hooks :before_fork + end + + def test_run_hooks_and_exception + conf = Puma::Configuration.new do |c| + c.on_restart do |a| + raise RuntimeError, 'Error from hook' + end + end + conf.load + events = Puma::Events.strings + + conf.run_hooks :on_restart, 'ARG', events + expected = /WARNING hook on_restart failed with exception \(RuntimeError\) Error from hook/ + assert_match expected, events.stdout.string + end + + def test_config_does_not_load_workers_by_default + assert_equal 0, Puma::Configuration.new.options.default_options[:workers] + end + + def test_final_options_returns_merged_options + conf = Puma::Configuration.new({ min_threads: 1, max_threads: 2 }, { min_threads: 2 }) + + assert_equal 1, conf.final_options[:min_threads] + assert_equal 2, conf.final_options[:max_threads] + end + + def test_silence_single_worker_warning_default + conf = Puma::Configuration.new + conf.load + + assert_equal false, conf.options[:silence_single_worker_warning] + end + + def test_silence_single_worker_warning_overwrite + conf = Puma::Configuration.new do |c| + c.silence_single_worker_warning + end + conf.load + + assert_equal true, conf.options[:silence_single_worker_warning] + end + + private + + def assert_run_hooks(hook_name, options = {}) + configured_with = options[:configured_with] || hook_name + + # test single, not an array + messages = [] + conf = Puma::Configuration.new + conf.options[hook_name] = -> (a) { + messages << "#{hook_name} is called with #{a}" + } + + conf.run_hooks hook_name, 'ARG', Puma::Events.strings + assert_equal messages, ["#{hook_name} is called with ARG"] + + # test multiple + messages = [] + conf = Puma::Configuration.new do |c| + c.send(configured_with) do |a| + messages << "#{hook_name} is called with #{a} one time" + end + + c.send(configured_with) do |a| + messages << "#{hook_name} is called with #{a} a second time" + end + end + conf.load + + conf.run_hooks hook_name, 'ARG', Puma::Events.strings + assert_equal messages, ["#{hook_name} is called with ARG one time", "#{hook_name} is called with ARG a second time"] + end +end + +# Thread unsafe modification of ENV +class TestEnvModifificationConfig < TestConfigFileBase + def test_double_bind_port + port = (rand(10_000) + 30_000).to_s + with_env("PORT" => port) do + conf = Puma::Configuration.new do |user_config, file_config, default_config| + user_config.bind "tcp://#{Puma::Configuration::DefaultTCPHost}:#{port}" + file_config.load "test/config/app.rb" + end + + conf.load + assert_equal ["tcp://0.0.0.0:#{port}"], conf.options[:binds] + end + end +end + +class TestConfigEnvVariables < TestConfigFileBase + def test_config_loads_correct_min_threads + assert_equal 0, Puma::Configuration.new.options.default_options[:min_threads] + + with_env("MIN_THREADS" => "7") do + conf = Puma::Configuration.new + assert_equal 7, conf.options.default_options[:min_threads] + end + + with_env("PUMA_MIN_THREADS" => "8") do + conf = Puma::Configuration.new + assert_equal 8, conf.options.default_options[:min_threads] + end + end + + def test_config_loads_correct_max_threads + conf = Puma::Configuration.new + assert_equal conf.default_max_threads, conf.options.default_options[:max_threads] + + with_env("MAX_THREADS" => "7") do + conf = Puma::Configuration.new + assert_equal 7, conf.options.default_options[:max_threads] + end + + with_env("PUMA_MAX_THREADS" => "8") do + conf = Puma::Configuration.new + assert_equal 8, conf.options.default_options[:max_threads] + end + end + + def test_config_loads_workers_from_env + with_env("WEB_CONCURRENCY" => "9") do + conf = Puma::Configuration.new + assert_equal 9, conf.options.default_options[:workers] + end + end + + def test_config_does_not_preload_app_if_not_using_workers + with_env("WEB_CONCURRENCY" => "0") do + conf = Puma::Configuration.new + assert_equal false, conf.options.default_options[:preload_app] + end + end + + def test_config_preloads_app_if_using_workers + with_env("WEB_CONCURRENCY" => "2") do + preload = Puma.forkable? + conf = Puma::Configuration.new + assert_equal preload, conf.options.default_options[:preload_app] + end + end +end + +class TestConfigFileWithFakeEnv < TestConfigFileBase + def setup + FileUtils.mkpath("config/puma") + File.write("config/puma/fake-env.rb", "") + end + + def test_config_files_with_app_env + with_env('APP_ENV' => 'fake-env') do + conf = Puma::Configuration.new do + end + + assert_equal ['config/puma/fake-env.rb'], conf.config_files + end + end + + def test_config_files_with_rack_env + with_env('RACK_ENV' => 'fake-env') do + conf = Puma::Configuration.new do + end + + assert_equal ['config/puma/fake-env.rb'], conf.config_files + end + end + + def test_config_files_with_rails_env + with_env('RAILS_ENV' => 'fake-env', 'RACK_ENV' => nil) do + conf = Puma::Configuration.new do + end + + assert_equal ['config/puma/fake-env.rb'], conf.config_files + end + end + + def test_config_files_with_specified_environment + conf = Puma::Configuration.new do + end + + conf.options[:environment] = 'fake-env' + + assert_equal ['config/puma/fake-env.rb'], conf.config_files + end + + def teardown + FileUtils.rm_r("config/puma") + end +end diff --git a/test/test_error_logger.rb b/test/test_error_logger.rb new file mode 100644 index 0000000..08be915 --- /dev/null +++ b/test/test_error_logger.rb @@ -0,0 +1,95 @@ +require 'puma/error_logger' +require_relative "helper" + +class TestErrorLogger < Minitest::Test + Req = Struct.new(:env, :body) + + def test_stdio + error_logger = Puma::ErrorLogger.stdio + + assert_equal STDERR, error_logger.ioerr + end + + + def test_stdio_respects_sync + error_logger = Puma::ErrorLogger.stdio + + assert_equal STDERR.sync, error_logger.ioerr.sync + assert_equal STDERR, error_logger.ioerr + end + + def test_info_with_only_error + _, err = capture_io do + Puma::ErrorLogger.stdio.info(error: StandardError.new('ready')) + end + + assert_match %r!#!, err + end + + def test_info_with_request + env = { + 'REQUEST_METHOD' => 'GET', + 'PATH_INFO' => '/debug', + 'HTTP_X_FORWARDED_FOR' => '8.8.8.8' + } + req = Req.new(env, '{"hello":"world"}') + + _, err = capture_io do + Puma::ErrorLogger.stdio.info(error: StandardError.new, req: req) + end + + assert_match %r!\("GET /debug" - \(8\.8\.8\.8\)\)!, err + end + + def test_info_with_text + _, err = capture_io do + Puma::ErrorLogger.stdio.info(text: 'The client disconnected while we were reading data') + end + + assert_match %r!The client disconnected while we were reading data!, err + end + + def test_debug_without_debug_mode + _, err = capture_io do + Puma::ErrorLogger.stdio.debug(text: 'blank') + end + + assert_empty err + end + + def test_debug_with_debug_mode + with_debug_mode do + _, err = capture_io do + Puma::ErrorLogger.stdio.debug(text: 'non-blank') + end + + assert_match %r!non-blank!, err + end + end + + def test_debug_backtrace_logging + with_debug_mode do + def dummy_error + raise StandardError.new('non-blank') + rescue => e + Puma::ErrorLogger.stdio.debug(error: e) + end + + _, err = capture_io do + dummy_error + end + + assert_match %r!non-blank!, err + assert_match %r!:in `dummy_error'!, err + end + end + + private + + def with_debug_mode + original_debug, ENV["PUMA_DEBUG"] = ENV["PUMA_DEBUG"], "1" + yield + ensure + ENV["PUMA_DEBUG"] = original_debug + end +end diff --git a/test/test_events.rb b/test/test_events.rb new file mode 100644 index 0000000..f4df5fb --- /dev/null +++ b/test/test_events.rb @@ -0,0 +1,238 @@ +require 'puma/events' +require_relative "helper" + +class TestEvents < Minitest::Test + def test_null + events = Puma::Events.null + + assert_instance_of Puma::NullIO, events.stdout + assert_instance_of Puma::NullIO, events.stderr + assert_equal events.stdout, events.stderr + end + + def test_strings + events = Puma::Events.strings + + assert_instance_of StringIO, events.stdout + assert_instance_of StringIO, events.stderr + end + + def test_stdio + events = Puma::Events.stdio + + assert_equal STDOUT, events.stdout + assert_equal STDERR, events.stderr + end + + def test_stdio_respects_sync + events = Puma::Events.stdio + + assert_equal STDOUT.sync, events.stdout.sync + assert_equal STDERR.sync, events.stderr.sync + assert_equal STDOUT, events.stdout + assert_equal STDERR, events.stderr + end + + def test_register_callback_with_block + res = false + + events = Puma::Events.null + + events.register(:exec) { res = true } + + events.fire(:exec) + + assert_equal true, res + end + + def test_register_callback_with_object + obj = Object.new + + def obj.res + @res || false + end + + def obj.call + @res = true + end + + events = Puma::Events.null + + events.register(:exec, obj) + + events.fire(:exec) + + assert_equal true, obj.res + end + + def test_fire_callback_with_multiple_arguments + res = [] + + events = Puma::Events.null + + events.register(:exec) { |*args| res.concat(args) } + + events.fire(:exec, :foo, :bar, :baz) + + assert_equal [:foo, :bar, :baz], res + end + + def test_on_booted_callback + res = false + + events = Puma::Events.null + + events.on_booted { res = true } + + events.fire_on_booted! + + assert res + end + + def test_log_writes_to_stdout + out, _ = capture_io do + Puma::Events.stdio.log("ready") + end + + assert_equal "ready\n", out + end + + def test_null_log_does_nothing + out, _ = capture_io do + Puma::Events.null.log("ready") + end + + assert_equal "", out + end + + def test_write_writes_to_stdout + out, _ = capture_io do + Puma::Events.stdio.write("ready") + end + + assert_equal "ready", out + end + + def test_debug_writes_to_stdout_if_env_is_present + original_debug, ENV["PUMA_DEBUG"] = ENV["PUMA_DEBUG"], "1" + + out, _ = capture_io do + Puma::Events.stdio.debug("ready") + end + + assert_equal "% ready\n", out + ensure + ENV["PUMA_DEBUG"] = original_debug + end + + def test_debug_not_write_to_stdout_if_env_is_not_present + out, _ = capture_io do + Puma::Events.stdio.debug("ready") + end + + assert_empty out + end + + def test_error_writes_to_stderr_and_exits + did_exit = false + + _, err = capture_io do + begin + Puma::Events.stdio.error("interrupted") + rescue SystemExit + did_exit = true + ensure + assert did_exit + end + end + + assert_match %r!ERROR: interrupted!, err + end + + def test_pid_formatter + pid = Process.pid + + out, _ = capture_io do + events = Puma::Events.stdio + + events.formatter = Puma::Events::PidFormatter.new + + events.write("ready") + end + + assert_equal "[#{ pid }] ready", out + end + + def test_custom_log_formatter + custom_formatter = proc { |str| "-> #{ str }" } + + out, _ = capture_io do + events = Puma::Events.stdio + + events.formatter = custom_formatter + + events.write("ready") + end + + assert_equal "-> ready", out + end + + def test_parse_error + port = 0 + host = "127.0.0.1" + app = proc { |env| [200, {"Content-Type" => "plain/text"}, ["hello\n"]] } + events = Puma::Events.strings + server = Puma::Server.new app, events + + port = (server.add_tcp_listener host, 0).addr[1] + server.run + + sock = TCPSocket.new host, port + path = "/" + params = "a"*1024*10 + + sock << "GET #{path}?a=#{params} HTTP/1.1\r\nConnection: close\r\n\r\n" + sock.read + sleep 0.1 # important so that the previous data is sent as a packet + assert_match %r!HTTP parse error, malformed request!, events.stderr.string + assert_match %r!\("GET #{path}" - \(-\)\)!, events.stderr.string + ensure + sock.close if sock && !sock.closed? + server.stop true + end + + # test_puma_server_ssl.rb checks that ssl errors are raised correctly, + # but it mocks the actual error code. This test the code, but it will + # break if the logged message changes + def test_ssl_error + events = Puma::Events.strings + + ssl_mock = -> (addr, subj) { + obj = Object.new + obj.define_singleton_method(:peeraddr) { addr } + if subj + cert = Object.new + cert.define_singleton_method(:subject) { subj } + obj.define_singleton_method(:peercert) { cert } + else + obj.define_singleton_method(:peercert) { nil } + end + obj + } + + events.ssl_error OpenSSL::SSL::SSLError, ssl_mock.call(['127.0.0.1'], 'test_cert') + error = events.stderr.string + assert_includes error, "SSL error" + assert_includes error, "peer: 127.0.0.1" + assert_includes error, "cert: test_cert" + + events.stderr.string = '' + + events.ssl_error OpenSSL::SSL::SSLError, ssl_mock.call(nil, nil) + error = events.stderr.string + assert_includes error, "SSL error" + assert_includes error, "peer: " + assert_includes error, "cert: :" + + end if ::Puma::HAS_SSL +end diff --git a/test/test_http10.rb b/test/test_http10.rb new file mode 100644 index 0000000..cba001b --- /dev/null +++ b/test/test_http10.rb @@ -0,0 +1,27 @@ +require_relative "helper" + +require "puma/puma_http11" + +class Http10ParserTest < Minitest::Test + def test_parse_simple + parser = Puma::HttpParser.new + req = {} + http = "GET / HTTP/1.0\r\n\r\n" + nread = parser.execute(req, http, 0) + + assert nread == http.length, "Failed to parse the full HTTP request" + assert parser.finished?, "Parser didn't finish" + assert !parser.error?, "Parser had error" + assert nread == parser.nread, "Number read returned from execute does not match" + + assert_equal '/', req['REQUEST_PATH'] + assert_equal 'HTTP/1.0', req['HTTP_VERSION'] + assert_equal '/', req['REQUEST_URI'] + assert_equal 'GET', req['REQUEST_METHOD'] + assert_nil req['FRAGMENT'] + assert_nil req['QUERY_STRING'] + + parser.reset + assert parser.nread == 0, "Number read after reset should be 0" + end +end diff --git a/test/test_http11.rb b/test/test_http11.rb new file mode 100644 index 0000000..7ef606b --- /dev/null +++ b/test/test_http11.rb @@ -0,0 +1,241 @@ +# Copyright (c) 2011 Evan Phoenix +# Copyright (c) 2005 Zed A. Shaw + +require_relative "helper" +require "digest" + +require "puma/puma_http11" + +class Http11ParserTest < Minitest::Test + + parallelize_me! + + def test_parse_simple + parser = Puma::HttpParser.new + req = {} + http = "GET /?a=1 HTTP/1.1\r\n\r\n" + nread = parser.execute(req, http, 0) + + assert nread == http.length, "Failed to parse the full HTTP request" + assert parser.finished?, "Parser didn't finish" + assert !parser.error?, "Parser had error" + assert nread == parser.nread, "Number read returned from execute does not match" + + assert_equal '/', req['REQUEST_PATH'] + assert_equal 'HTTP/1.1', req['HTTP_VERSION'] + assert_equal '/?a=1', req['REQUEST_URI'] + assert_equal 'GET', req['REQUEST_METHOD'] + assert_nil req['FRAGMENT'] + assert_equal "a=1", req['QUERY_STRING'] + + parser.reset + assert parser.nread == 0, "Number read after reset should be 0" + end + + def test_parse_escaping_in_query + parser = Puma::HttpParser.new + req = {} + http = "GET /admin/users?search=%27%%27 HTTP/1.1\r\n\r\n" + nread = parser.execute(req, http, 0) + + assert nread == http.length, "Failed to parse the full HTTP request" + assert parser.finished?, "Parser didn't finish" + assert !parser.error?, "Parser had error" + assert nread == parser.nread, "Number read returned from execute does not match" + + assert_equal '/admin/users?search=%27%%27', req['REQUEST_URI'] + assert_equal "search=%27%%27", req['QUERY_STRING'] + + parser.reset + assert parser.nread == 0, "Number read after reset should be 0" + end + + def test_parse_absolute_uri + parser = Puma::HttpParser.new + req = {} + http = "GET http://192.168.1.96:3000/api/v1/matches/test?1=1 HTTP/1.1\r\n\r\n" + nread = parser.execute(req, http, 0) + + assert nread == http.length, "Failed to parse the full HTTP request" + assert parser.finished?, "Parser didn't finish" + assert !parser.error?, "Parser had error" + assert nread == parser.nread, "Number read returned from execute does not match" + + assert_equal "GET", req['REQUEST_METHOD'] + assert_equal 'http://192.168.1.96:3000/api/v1/matches/test?1=1', req['REQUEST_URI'] + assert_equal 'HTTP/1.1', req['HTTP_VERSION'] + + assert_nil req['REQUEST_PATH'] + assert_nil req['FRAGMENT'] + assert_nil req['QUERY_STRING'] + + parser.reset + assert parser.nread == 0, "Number read after reset should be 0" + + end + + def test_parse_dumbfuck_headers + parser = Puma::HttpParser.new + req = {} + should_be_good = "GET / HTTP/1.1\r\naaaaaaaaaaaaa:++++++++++\r\n\r\n" + nread = parser.execute(req, should_be_good, 0) + assert_equal should_be_good.length, nread + assert parser.finished? + assert !parser.error? + end + + def test_parse_error + parser = Puma::HttpParser.new + req = {} + bad_http = "GET / SsUTF/1.1" + + error = false + begin + parser.execute(req, bad_http, 0) + rescue + error = true + end + + assert error, "failed to throw exception" + assert !parser.finished?, "Parser shouldn't be finished" + assert parser.error?, "Parser SHOULD have error" + end + + def test_fragment_in_uri + parser = Puma::HttpParser.new + req = {} + get = "GET /forums/1/topics/2375?page=1#posts-17408 HTTP/1.1\r\n\r\n" + + parser.execute(req, get, 0) + + assert parser.finished? + assert_equal '/forums/1/topics/2375?page=1', req['REQUEST_URI'] + assert_equal 'posts-17408', req['FRAGMENT'] + end + + def test_semicolon_in_path + skip_if :jruby # Not yet supported on JRuby, see https://github.com/puma/puma/issues/1978 + parser = Puma::HttpParser.new + req = {} + get = "GET /forums/1/path;stillpath/2375?page=1 HTTP/1.1\r\n\r\n" + + parser.execute(req, get, 0) + + assert parser.finished? + assert_equal '/forums/1/path;stillpath/2375?page=1', req['REQUEST_URI'] + assert_equal '/forums/1/path;stillpath/2375', req['REQUEST_PATH'] + end + + # lame random garbage maker + def rand_data(min, max, readable=true) + count = min + ((rand(max)+1) *10).to_i + res = count.to_s + "/" + + if readable + res << Digest(:SHA1).hexdigest(rand(count * 100).to_s) * (count / 40) + else + res << Digest(:SHA1).digest(rand(count * 100).to_s) * (count / 20) + end + + res + end + + def test_max_uri_path_length + parser = Puma::HttpParser.new + req = {} + + # Support URI path length to a max of 8192 + path = "/" + rand_data(7000, 100) + http = "GET #{path} HTTP/1.1\r\n\r\n" + parser.execute(req, http, 0) + assert_equal path, req['REQUEST_PATH'] + parser.reset + + # Raise exception if URI path length > 8192 + path = "/" + rand_data(9000, 100) + http = "GET #{path} HTTP/1.1\r\n\r\n" + assert_raises Puma::HttpParserError do + parser.execute(req, http, 0) + parser.reset + end + end + + def test_horrible_queries + parser = Puma::HttpParser.new + + # then that large header names are caught + 10.times do |c| + get = "GET /#{rand_data(10,120)} HTTP/1.1\r\nX-#{rand_data(1024, 1024+(c*1024))}: Test\r\n\r\n" + assert_raises Puma::HttpParserError do + parser.execute({}, get, 0) + parser.reset + end + end + + # then that large mangled field values are caught + 10.times do |c| + get = "GET /#{rand_data(10,120)} HTTP/1.1\r\nX-Test: #{rand_data(1024, 1024+(c*1024), false)}\r\n\r\n" + assert_raises Puma::HttpParserError do + parser.execute({}, get, 0) + parser.reset + end + end + + # then large headers are rejected too + get = "GET /#{rand_data(10,120)} HTTP/1.1\r\n" + get += "X-Test: test\r\n" * (80 * 1024) + assert_raises Puma::HttpParserError do + parser.execute({}, get, 0) + parser.reset + end + + # finally just that random garbage gets blocked all the time + 10.times do |c| + get = "GET #{rand_data(1024, 1024+(c*1024), false)} #{rand_data(1024, 1024+(c*1024), false)}\r\n\r\n" + assert_raises Puma::HttpParserError do + parser.execute({}, get, 0) + parser.reset + end + end + end + + def test_trims_whitespace_from_headers + parser = Puma::HttpParser.new + req = {} + http = "GET / HTTP/1.1\r\nX-Strip-Me: Strip This \r\n\r\n" + + parser.execute(req, http, 0) + + assert_equal "Strip This", req["HTTP_X_STRIP_ME"] + end + + def test_newline_smuggler + parser = Puma::HttpParser.new + req = {} + http = "GET / HTTP/1.1\r\nHost: localhost:8080\r\nDummy: x\nDummy2: y\r\n\r\n" + + parser.execute(req, http, 0) rescue nil # We test the raise elsewhere. + + assert parser.error?, "Parser SHOULD have error" + end + + def test_newline_smuggler_two + parser = Puma::HttpParser.new + req = {} + http = "GET / HTTP/1.1\r\nHost: localhost:8080\r\nDummy: x\r\nDummy: y\nDummy2: z\r\n\r\n" + + parser.execute(req, http, 0) rescue nil + + assert parser.error?, "Parser SHOULD have error" + end + + def test_htab_in_header_val + parser = Puma::HttpParser.new + req = {} + http = "GET / HTTP/1.1\r\nHost: localhost:8080\r\nDummy: Valid\tValue\r\n\r\n" + + parser.execute(req, http, 0) + + assert_equal "Valid\tValue", req['HTTP_DUMMY'] + end +end diff --git a/test/test_integration_cluster.rb b/test/test_integration_cluster.rb new file mode 100644 index 0000000..979765f --- /dev/null +++ b/test/test_integration_cluster.rb @@ -0,0 +1,653 @@ +require_relative "helper" +require_relative "helpers/integration" + +require "time" + +class TestIntegrationCluster < TestIntegration + parallelize_me! if ::Puma.mri? + + def workers ; 2 ; end + + def setup + skip_unless :fork + super + end + + def teardown + return if skipped? + super + end + + def test_hot_restart_does_not_drop_connections_threads + hot_restart_does_not_drop_connections num_threads: 10, total_requests: 3_000 + end + + def test_hot_restart_does_not_drop_connections + hot_restart_does_not_drop_connections num_threads: 1, total_requests: 1_000 + end + + def test_pre_existing_unix + skip_unless :unix + + File.open(@bind_path, mode: 'wb') { |f| f.puts 'pre existing' } + + cli_server "-w #{workers} -q test/rackup/sleep_step.ru", unix: :unix + + stop_server + + assert File.exist?(@bind_path) + + ensure + if UNIX_SKT_EXIST + File.unlink @bind_path if File.exist? @bind_path + end + end + + def test_siginfo_thread_print + skip_unless_signal_exist? :INFO + + cli_server "-w #{workers} -q test/rackup/hello.ru" + worker_pids = get_worker_pids + output = [] + t = Thread.new { output << @server.readlines } + Process.kill :INFO, worker_pids.first + Process.kill :INT , @pid + t.join + + assert_match "Thread: TID", output.join + end + + def test_usr2_restart + _, new_reply = restart_server_and_listen("-q -w #{workers} test/rackup/hello.ru") + assert_equal "Hello World", new_reply + end + + # Next two tests, one tcp, one unix + # Send requests 10 per second. Send 10, then :TERM server, then send another 30. + # No more than 10 should throw Errno::ECONNRESET. + + def test_term_closes_listeners_tcp + skip_unless_signal_exist? :TERM + skip "Intermittent failure on Ruby 2.2" if RUBY_VERSION < '2.3' + term_closes_listeners unix: false + end + + def test_term_closes_listeners_unix + skip_unless_signal_exist? :TERM + term_closes_listeners unix: true + end + + # Next two tests, one tcp, one unix + # Send requests 1 per second. Send 1, then :USR1 server, then send another 24. + # All should be responded to, and at least three workers should be used + + def test_usr1_all_respond_tcp + skip_unless_signal_exist? :USR1 + usr1_all_respond unix: false + end + + def test_usr1_fork_worker + skip_unless_signal_exist? :USR1 + usr1_all_respond config: '--fork-worker' + end + + def test_usr1_all_respond_unix + skip_unless_signal_exist? :USR1 + usr1_all_respond unix: true + end + + def test_term_exit_code + cli_server "-w #{workers} test/rackup/hello.ru" + _, status = stop_server + + assert_equal 15, status + end + + def test_term_suppress + cli_server "-w #{workers} -C test/config/suppress_exception.rb test/rackup/hello.ru" + + _, status = stop_server + + assert_equal 0, status + end + + def test_term_worker_clean_exit + skip "Intermittent failure on Ruby 2.2" if RUBY_VERSION < '2.3' + + cli_server "-w #{workers} test/rackup/hello.ru" + + # Get the PIDs of the child workers. + worker_pids = get_worker_pids + + # Signal the workers to terminate, and wait for them to die. + Process.kill :TERM, @pid + Process.wait @pid + + zombies = bad_exit_pids worker_pids + + assert_empty zombies, "Process ids #{zombies} became zombies" + end + + # mimicking stuck workers, test respawn with external TERM + def test_stuck_external_term_spawn + skip_unless_signal_exist? :TERM + + worker_respawn(0) do |phase0_worker_pids| + last = phase0_worker_pids.last + # test is tricky if only one worker is TERM'd, so kill all but + # spread out, so all aren't killed at once + phase0_worker_pids.each do |pid| + Process.kill :TERM, pid + sleep 4 unless pid == last + end + end + end + + # mimicking stuck workers, test restart + def test_stuck_phased_restart + skip_unless_signal_exist? :USR1 + worker_respawn { |phase0_worker_pids| Process.kill :USR1, @pid } + end + + def test_worker_check_interval + @control_tcp_port = UniquePort.call + worker_check_interval = 1 + + cli_server "-w 1 -t 1:1 --control-url tcp://#{HOST}:#{@control_tcp_port} --control-token #{TOKEN} test/rackup/hello.ru", config: "worker_check_interval #{worker_check_interval}" + + sleep worker_check_interval + 1 + last_checkin_1 = Time.parse(get_stats["worker_status"].first["last_checkin"]) + + sleep worker_check_interval + 1 + last_checkin_2 = Time.parse(get_stats["worker_status"].first["last_checkin"]) + + assert(last_checkin_2 > last_checkin_1) + end + + def test_worker_boot_timeout + timeout = 1 + worker_timeout(timeout, 2, "worker failed to boot within \\\d+ seconds", "worker_boot_timeout #{timeout}; on_worker_boot { sleep #{timeout + 1} }") + end + + def test_worker_timeout + skip 'Thread#name not available' unless Thread.current.respond_to?(:name) + timeout = Puma::ConfigDefault::DefaultWorkerCheckInterval + 1 + worker_timeout(timeout, 1, "worker failed to check in within \\\d+ seconds", <'/dev/null') + sleep 0.01 + exitstatus = Process.detach(pid).value.exitstatus + [200, {}, [exitstatus.to_s]] +end +RUBY + assert_equal '0', read_body(connect) + end + + def test_nakayoshi + cli_server "-w #{workers} test/rackup/hello.ru", config: <=, resets , msg + + assert_operator 20, :<=, refused , msg + + # Interleaved asserts + # UNIX binders do not generate :reset items + if l_reset + assert_operator r_success, :<, l_reset , "Interleaved success and reset" + assert_operator r_reset , :<, l_refused, "Interleaved reset and refused" + else + assert_operator r_success, :<, l_refused, "Interleaved success and refused" + end + + ensure + if passed? + $debugging_info << "#{full_name}\n #{msg}\n" + else + $debugging_info << "#{full_name}\n #{msg}\n#{replies.inspect}\n" + end + end + + # Send requests 1 per second. Send 1, then :USR1 server, then send another 24. + # All should be responded to, and at least three workers should be used + def usr1_all_respond(unix: false, config: '') + cli_server "-w #{workers} -t 0:5 -q test/rackup/sleep_pid.ru #{config}", unix: unix + threads = [] + replies = [] + mutex = Mutex.new + + s = connect "sleep1", unix: unix + replies << read_body(s) + + Process.kill :USR1, @pid + + refused = thread_run_refused unix: unix + + 24.times do |delay| + threads << Thread.new do + thread_run_pid replies, delay, 1, mutex, refused, unix: unix + end + end + + threads.each(&:join) + + responses = replies.count { |r| r[/\ASlept 1/] } + resets = replies.count { |r| r == :reset } + refused = replies.count { |r| r == :refused } + read_timeouts = replies.count { |r| r == :read_timeout } + + # get pids from replies, generate uniq array + qty_pids = replies.map { |body| body[/\d+\z/] }.uniq.compact.length + + msg = "#{responses} responses, #{qty_pids} uniq pids" + + assert_equal 25, responses, msg + assert_operator qty_pids, :>, 2, msg + + msg = "#{responses} responses, #{resets} resets, #{refused} refused, #{read_timeouts} read timeouts" + + assert_equal 0, refused, msg + + assert_equal 0, resets, msg + + assert_equal 0, read_timeouts, msg + ensure + unless passed? + $debugging_info << "#{full_name}\n #{msg}\n#{replies.inspect}\n" + end + end + + def worker_respawn(phase = 1, size = workers) + threads = [] + + cli_server "-w #{workers} -t 1:1 -C test/config/worker_shutdown_timeout_2.rb test/rackup/sleep_pid.ru" + + # make sure two workers have booted + phase0_worker_pids = get_worker_pids + + [35, 40].each do |sleep_time| + threads << Thread.new do + begin + connect "sleep#{sleep_time}" + # stuck connections will raise IOError or Errno::ECONNRESET + # when shutdown + rescue IOError, Errno::ECONNRESET + end + end + end + + @start_time = Time.now.to_f + + # below should 'cancel' the phase 0 workers, either via phased_restart or + # externally TERM'ing them + yield phase0_worker_pids + + # wait for new workers to boot + phase1_worker_pids = get_worker_pids phase + + # should be empty if all phase 0 workers cleanly exited + phase0_exited = bad_exit_pids phase0_worker_pids + + # Since 35 is the shorter of the two requests, server should restart + # and cancel both requests + assert_operator (Time.now.to_f - @start_time).round(2), :<, 35 + + msg = "phase0_worker_pids #{phase0_worker_pids.inspect} phase1_worker_pids #{phase1_worker_pids.inspect} phase0_exited #{phase0_exited.inspect}" + assert_equal workers, phase0_worker_pids.length, msg + + assert_equal workers, phase1_worker_pids.length, msg + assert_empty phase0_worker_pids & phase1_worker_pids, "#{msg}\nBoth workers should be replaced with new" + + assert_empty phase0_exited, msg + + threads.each { |th| Thread.kill th } + end + + # Returns an array of pids still in the process table, so it should + # be empty for a clean exit. + # Process.kill should raise the Errno::ESRCH exception, indicating the + # process is dead and has been reaped. + def bad_exit_pids(pids) + pids.map do |pid| + begin + pid if Process.kill 0, pid + rescue Errno::ESRCH + nil + end + end.compact + end + + # used in loop to create several 'requests' + def thread_run_pid(replies, delay, sleep_time, mutex, refused, unix: false) + begin + sleep delay + s = fast_connect "sleep#{sleep_time}", unix: unix + body = read_body(s, 20) + mutex.synchronize { replies << body } + rescue Errno::ECONNRESET + # connection was accepted but then closed + # client would see an empty response + mutex.synchronize { replies << :reset } + rescue *refused + mutex.synchronize { replies << :refused } + rescue Timeout::Error + mutex.synchronize { replies << :read_timeout } + end + end + + # used in loop to create several 'requests' + def thread_run_step(replies, delay, sleep_time, step, mutex, refused, unix: false) + begin + sleep delay + s = connect "sleep#{sleep_time}-#{step}", unix: unix + body = read_body(s, 20) + if body[/\ASlept /] + mutex.synchronize { replies[step] = :success } + else + mutex.synchronize { replies[step] = :failure } + end + rescue Errno::ECONNRESET + # connection was accepted but then closed + # client would see an empty response + mutex.synchronize { replies[step] = :reset } + rescue *refused + mutex.synchronize { replies[step] = :refused } + rescue Timeout::Error + mutex.synchronize { replies[step] = :read_timeout } + end + end +end if ::Process.respond_to?(:fork) diff --git a/test/test_integration_pumactl.rb b/test/test_integration_pumactl.rb new file mode 100644 index 0000000..fa1a830 --- /dev/null +++ b/test/test_integration_pumactl.rb @@ -0,0 +1,135 @@ +require_relative "helper" +require_relative "helpers/integration" + +class TestIntegrationPumactl < TestIntegration + include TmpPath + parallelize_me! if ::Puma.mri? + + def workers ; 2 ; end + + def setup + super + + @state_path = tmp_path('.state') + @control_path = tmp_path('.sock') + end + + def teardown + super + + refute File.exist?(@control_path), "Control path must be removed after stop" + ensure + [@state_path, @control_path].each { |p| File.unlink(p) rescue nil } + end + + def test_stop_tcp + skip_if :jruby, :truffleruby # Undiagnose thread race. TODO fix + @control_tcp_port = UniquePort.call + cli_server "-q test/rackup/sleep.ru --control-url tcp://#{HOST}:#{@control_tcp_port} --control-token #{TOKEN} -S #{@state_path}" + + cli_pumactl "stop" + + _, status = Process.wait2(@pid) + assert_equal 0, status + + @server = nil + end + + def test_stop_unix + ctl_unix + end + + def test_halt_unix + ctl_unix 'halt' + end + + def ctl_unix(signal='stop') + skip_unless :unix + stderr = Tempfile.new(%w(stderr .log)) + cli_server "-q test/rackup/sleep.ru --control-url unix://#{@control_path} --control-token #{TOKEN} -S #{@state_path}", + config: "stdout_redirect nil, '#{stderr.path}'", + unix: true + + cli_pumactl signal, unix: true + + _, status = Process.wait2(@pid) + assert_equal 0, status + refute_match 'error', File.read(stderr.path) + @server = nil + end + + def test_phased_restart_cluster + skip_unless :fork + cli_server "-q -w #{workers} test/rackup/sleep.ru --control-url unix://#{@control_path} --control-token #{TOKEN} -S #{@state_path}", unix: true + + start = Time.now + + s = UNIXSocket.new @bind_path + @ios_to_close << s + s << "GET /sleep1 HTTP/1.0\r\n\r\n" + + # Get the PIDs of the phase 0 workers. + phase0_worker_pids = get_worker_pids 0 + assert File.exist? @bind_path + + # Phased restart + cli_pumactl "phased-restart", unix: true + + # Get the PIDs of the phase 1 workers. + phase1_worker_pids = get_worker_pids 1 + + msg = "phase 0 pids #{phase0_worker_pids.inspect} phase 1 pids #{phase1_worker_pids.inspect}" + + assert_equal workers, phase0_worker_pids.length, msg + assert_equal workers, phase1_worker_pids.length, msg + assert_empty phase0_worker_pids & phase1_worker_pids, "#{msg}\nBoth workers should be replaced with new" + assert File.exist?(@bind_path), "Bind path must exist after phased restart" + + cli_pumactl "stop", unix: true + + _, status = Process.wait2(@pid) + assert_equal 0, status + assert_operator Time.now - start, :<, (DARWIN ? 8 : 6) + @server = nil + end + + def test_prune_bundler_with_multiple_workers + skip_unless :fork + + cli_server "-q -C test/config/prune_bundler_with_multiple_workers.rb --control-url unix://#{@control_path} --control-token #{TOKEN} -S #{@state_path}", unix: true + + s = UNIXSocket.new @bind_path + @ios_to_close << s + s << "GET / HTTP/1.0\r\n\r\n" + + body = s.read + + assert_match "200 OK", body + assert_match "embedded app", body + + cli_pumactl "stop", unix: true + + _, _ = Process.wait2(@pid) + @server = nil + end + + def test_kill_unknown + skip_if :jruby + + # we run ls to get a 'safe' pid to pass off as puma in cli stop + # do not want to accidentally kill a valid other process + io = IO.popen(windows? ? "dir" : "ls") + safe_pid = io.pid + Process.wait safe_pid + + sout = StringIO.new + + e = assert_raises SystemExit do + Puma::ControlCLI.new(%W!-p #{safe_pid} stop!, sout).run + end + sout.rewind + # windows bad URI(is not URI?) + assert_match(/No pid '\d+' found|bad URI\(is not URI\?\)/, sout.readlines.join("")) + assert_equal(1, e.status) + end +end diff --git a/test/test_integration_single.rb b/test/test_integration_single.rb new file mode 100644 index 0000000..fd930fe --- /dev/null +++ b/test/test_integration_single.rb @@ -0,0 +1,215 @@ +require_relative "helper" +require_relative "helpers/integration" + +class TestIntegrationSingle < TestIntegration + parallelize_me! if ::Puma.mri? + + def workers ; 0 ; end + + def test_hot_restart_does_not_drop_connections_threads + hot_restart_does_not_drop_connections num_threads: 5, total_requests: 1_000 + end + + def test_hot_restart_does_not_drop_connections + hot_restart_does_not_drop_connections + end + + def test_usr2_restart + skip_unless_signal_exist? :USR2 + _, new_reply = restart_server_and_listen("-q test/rackup/hello.ru") + assert_equal "Hello World", new_reply + end + + # It does not share environments between multiple generations, which would break Dotenv + def test_usr2_restart_restores_environment + # jruby has a bug where setting `nil` into the ENV or `delete` do not change the + # next workers ENV + skip_if :jruby + skip_unless_signal_exist? :USR2 + + initial_reply, new_reply = restart_server_and_listen("-q test/rackup/hello-env.ru") + + assert_includes initial_reply, "Hello RAND" + assert_includes new_reply, "Hello RAND" + refute_equal initial_reply, new_reply + end + + def test_term_exit_code + skip_unless_signal_exist? :TERM + skip_if :jruby # JVM does not return correct exit code for TERM + + cli_server "test/rackup/hello.ru" + _, status = stop_server + + assert_equal 15, status + end + + def test_term_suppress + skip_unless_signal_exist? :TERM + + cli_server "-C test/config/suppress_exception.rb test/rackup/hello.ru" + _, status = stop_server + + assert_equal 0, status + end + + def test_prefer_rackup_file_specified_by_cli + skip_unless_signal_exist? :TERM + + cli_server "-C test/config/with_rackup_from_dsl.rb test/rackup/hello.ru" + reply = read_body(connect) + stop_server + + assert_match("Hello World", reply) + end + + def test_term_not_accepts_new_connections + skip_unless_signal_exist? :TERM + skip_if :jruby + + cli_server 'test/rackup/sleep.ru' + + _stdin, curl_stdout, _stderr, curl_wait_thread = Open3.popen3({ 'LC_ALL' => 'C' }, "curl http://#{HOST}:#{@tcp_port}/sleep10") + sleep 1 # ensure curl send a request + + Process.kill :TERM, @pid + true while @server.gets !~ /Gracefully stopping/ # wait for server to begin graceful shutdown + + # Invoke a request which must be rejected + _stdin, _stdout, rejected_curl_stderr, rejected_curl_wait_thread = Open3.popen3("curl #{HOST}:#{@tcp_port}") + + assert nil != Process.getpgid(@server.pid) # ensure server is still running + assert nil != Process.getpgid(curl_wait_thread[:pid]) # ensure first curl invocation still in progress + + curl_wait_thread.join + rejected_curl_wait_thread.join + + assert_match(/Slept 10/, curl_stdout.read) + assert_match(/Connection refused/, rejected_curl_stderr.read) + + Process.wait(@server.pid) + @server.close unless @server.closed? + @server = nil # prevent `#teardown` from killing already killed server + end + + def test_int_refuse + skip_unless_signal_exist? :INT + skip_if :jruby # seems to intermittently lockup JRuby CI + + cli_server 'test/rackup/hello.ru' + begin + sock = TCPSocket.new(HOST, @tcp_port) + sock.close + rescue => ex + fail("Port didn't open properly: #{ex.message}") + end + + Process.kill :INT, @pid + Process.wait @pid + + assert_raises(Errno::ECONNREFUSED) { TCPSocket.new(HOST, @tcp_port) } + end + + def test_siginfo_thread_print + skip_unless_signal_exist? :INFO + + cli_server 'test/rackup/hello.ru' + output = [] + t = Thread.new { output << @server.readlines } + Process.kill :INFO, @pid + Process.kill :INT , @pid + t.join + + assert_match "Thread: TID", output.join + end + + def test_write_to_log + skip_unless_signal_exist? :TERM + + suppress_output = '> /dev/null 2>&1' + + cli_server '-C test/config/t1_conf.rb test/rackup/hello.ru' + + system "curl http://localhost:#{@tcp_port}/ #{suppress_output}" + + stop_server + + log = File.read('t1-stdout') + + File.unlink 't1-stdout' if File.file? 't1-stdout' + File.unlink 't1-pid' if File.file? 't1-pid' + + assert_match(%r!GET / HTTP/1\.1!, log) + end + + def test_puma_started_log_writing + skip_unless_signal_exist? :TERM + + suppress_output = '> /dev/null 2>&1' + + cli_server '-C test/config/t2_conf.rb test/rackup/hello.ru' + + system "curl http://localhost:#{@tcp_port}/ #{suppress_output}" + + out=`#{BASE} bin/pumactl -F test/config/t2_conf.rb status` + + stop_server + + log = File.read('t2-stdout') + + File.unlink 't2-stdout' if File.file? 't2-stdout' + + assert_match(%r!GET / HTTP/1\.1!, log) + assert(!File.file?("t2-pid")) + assert_equal("Puma is started\n", out) + end + + def test_application_logs_are_flushed_on_write + @control_tcp_port = UniquePort.call + cli_server "--control-url tcp://#{HOST}:#{@control_tcp_port} --control-token #{TOKEN} test/rackup/write_to_stdout.ru" + + read_body connect + + cli_pumactl 'stop' + + assert_equal "hello\n", @server.gets + assert_includes @server.read, 'Goodbye!' + + @server.close unless @server.closed? + @server = nil + end + + # listener is closed 'externally' while Puma is in the IO.select statement + def test_closed_listener + skip_unless_signal_exist? :TERM + + cli_server "test/rackup/close_listeners.ru", merge_err: true + connection = fast_connect + + if DARWIN && RUBY_VERSION < '2.5' + begin + read_body connection + rescue EOFError + end + else + read_body connection + end + + begin + Timeout.timeout(5) do + begin + Process.kill :SIGTERM, @pid + rescue Errno::ESRCH + end + begin + Process.wait2 @pid + rescue Errno::ECHILD + end + end + rescue Timeout::Error + Process.kill :SIGKILL, @pid + assert false, "Process froze" + end + assert true + end +end diff --git a/test/test_integration_ssl.rb b/test/test_integration_ssl.rb new file mode 100644 index 0000000..07dc097 --- /dev/null +++ b/test/test_integration_ssl.rb @@ -0,0 +1,149 @@ +require_relative 'helper' +require_relative "helpers/integration" + +# These tests are used to verify that Puma works with SSL sockets. Only +# integration tests isolate the server from the test environment, so there +# should be a few SSL tests. +# +# For instance, since other tests make use of 'client' SSLSockets created by +# net/http, OpenSSL is loaded in the CI process. By shelling out with IO.popen, +# the server process isn't affected by whatever is loaded in the CI process. + +class TestIntegrationSSL < TestIntegration + parallelize_me! if ::Puma.mri? + + require "net/http" + require "openssl" + + def teardown + @server.close unless @server.closed? + @server = nil + super + end + + def bind_port + @bind_port ||= UniquePort.call + end + + def control_tcp_port + @control_tcp_port ||= UniquePort.call + end + + def with_server(config) + config_file = Tempfile.new %w(config .rb) + config_file.write config + config_file.close + config_file.path + + # start server + cmd = "#{BASE} bin/puma -C #{config_file.path}" + @server = IO.popen cmd, 'r' + wait_for_server_to_boot + @pid = @server.pid + + http = Net::HTTP.new HOST, bind_port + http.use_ssl = true + http.verify_mode = OpenSSL::SSL::VERIFY_NONE + + yield http + + # stop server + sock = TCPSocket.new HOST, control_tcp_port + @ios_to_close << sock + sock.syswrite "GET /stop?token=#{TOKEN} HTTP/1.1\r\n\r\n" + sock.read + assert_match 'Goodbye!', @server.read + end + + def test_ssl_run + config = < "value" } + assert_puma_json_generates_string '{"key":"value"}', value + end + + def test_json_generates_string_for_hash_with_symbol_keys + value = { key: 'value' } + assert_puma_json_generates_string '{"key":"value"}', value, expected_roundtrip: { "key" => "value" } + end + + def test_generate_raises_error_for_unexpected_key_type + value = { [1] => 'b' } + ex = assert_raises Puma::JSONSerialization::SerializationError do + Puma::JSONSerialization.generate value + end + assert_equal 'Could not serialize object of type Array as object key', ex.message + end + + def test_json_generates_string_for_array_of_integers + value = [1, 2, 3] + assert_puma_json_generates_string '[1,2,3]', value + end + + def test_json_generates_string_for_array_of_strings + value = ["a", "b", "c"] + assert_puma_json_generates_string '["a","b","c"]', value + end + + def test_json_generates_string_for_nested_arrays + value = [1, [2, [3]]] + assert_puma_json_generates_string '[1,[2,[3]]]', value + end + + def test_json_generates_string_for_integer + value = 42 + assert_puma_json_generates_string '42', value + end + + def test_json_generates_string_for_float + value = 1.23 + assert_puma_json_generates_string '1.23', value + end + + def test_json_escapes_strings_with_quotes + value = 'a"' + assert_puma_json_generates_string '"a\""', value + end + + def test_json_escapes_strings_with_backslashes + value = 'a\\' + assert_puma_json_generates_string '"a\\\\"', value + end + + def test_json_escapes_strings_with_null_byte + value = "\x00" + assert_puma_json_generates_string '"\u0000"', value + end + + def test_json_escapes_strings_with_unicode_information_separator_one + value = "\x1f" + assert_puma_json_generates_string '"\u001F"', value + end + + def test_json_generates_string_for_true + value = true + assert_puma_json_generates_string 'true', value + end + + def test_json_generates_string_for_false + value = false + assert_puma_json_generates_string 'false', value + end + + def test_json_generates_string_for_nil + value = nil + assert_puma_json_generates_string 'null', value + end + + def test_generate_raises_error_for_unexpected_value_type + value = /abc/ + ex = assert_raises Puma::JSONSerialization::SerializationError do + Puma::JSONSerialization.generate value + end + assert_equal 'Unexpected value of type Regexp', ex.message + end + + private + + def assert_puma_json_generates_string(expected_output, value_to_serialize, expected_roundtrip: nil) + actual_output = Puma::JSONSerialization.generate(value_to_serialize) + assert_equal expected_output, actual_output + + if value_to_serialize.nil? + assert_nil ::JSON.parse(actual_output) + else + expected_roundtrip ||= value_to_serialize + assert_equal expected_roundtrip, ::JSON.parse(actual_output) + end + end +end diff --git a/test/test_launcher.rb b/test/test_launcher.rb new file mode 100644 index 0000000..370e0c3 --- /dev/null +++ b/test/test_launcher.rb @@ -0,0 +1,206 @@ +require_relative "helper" +require_relative "helpers/tmp_path" + +require "puma/configuration" +require 'puma/events' + +class TestLauncher < Minitest::Test + include TmpPath + + def test_files_to_require_after_prune_is_correctly_built_for_no_extra_deps + skip_if :no_bundler + + dirs = launcher.send(:files_to_require_after_prune) + + assert_equal(2, dirs.length) + assert_match(%r{puma/lib$}, dirs[0]) # lib dir + assert_match(%r{puma-#{Puma::Const::PUMA_VERSION}$}, dirs[1]) # native extension dir + refute_match(%r{gems/rdoc-[\d.]+/lib$}, dirs[2]) + end + + def test_files_to_require_after_prune_is_correctly_built_with_extra_deps + skip_if :no_bundler + conf = Puma::Configuration.new do |c| + c.extra_runtime_dependencies ['rdoc'] + end + + dirs = launcher(conf).send(:files_to_require_after_prune) + + assert_equal(3, dirs.length) + assert_match(%r{puma/lib$}, dirs[0]) # lib dir + assert_match(%r{puma-#{Puma::Const::PUMA_VERSION}$}, dirs[1]) # native extension dir + assert_match(%r{gems/rdoc-[\d.]+/lib$}, dirs[2]) # rdoc dir + end + + def test_extra_runtime_deps_directories_is_empty_for_no_config + assert_equal([], launcher.send(:extra_runtime_deps_directories)) + end + + def test_extra_runtime_deps_directories_is_correctly_built + skip_if :no_bundler + conf = Puma::Configuration.new do |c| + c.extra_runtime_dependencies ['rdoc'] + end + dep_dirs = launcher(conf).send(:extra_runtime_deps_directories) + + assert_equal(1, dep_dirs.length) + assert_match(%r{gems/rdoc-[\d.]+/lib$}, dep_dirs.first) + end + + def test_puma_wild_location_is_an_absolute_path + skip_if :no_bundler + puma_wild_location = launcher.send(:puma_wild_location) + + assert_match(%r{bin/puma-wild$}, puma_wild_location) + # assert no "/../" in path + refute_match(%r{/\.\./}, puma_wild_location) + end + + def test_prints_thread_traces + launcher.thread_status do |name, _backtrace| + assert_match "Thread: TID", name + end + end + + def test_pid_file + pid_path = tmp_path('.pid') + + conf = Puma::Configuration.new do |c| + c.pidfile pid_path + end + + launcher(conf).write_state + + assert_equal File.read(pid_path).strip.to_i, Process.pid + + File.unlink pid_path + end + + def test_state_permission_0640 + state_path = tmp_path('.state') + state_permission = 0640 + + conf = Puma::Configuration.new do |c| + c.state_path state_path + c.state_permission state_permission + end + + launcher(conf).write_state + + assert File.stat(state_path).mode.to_s(8)[-4..-1], state_permission + ensure + File.unlink state_path + end + + def test_state_permission_nil + state_path = tmp_path('.state') + + conf = Puma::Configuration.new do |c| + c.state_path state_path + c.state_permission nil + end + + launcher(conf).write_state + + assert File.exist?(state_path) + ensure + File.unlink state_path + end + + def test_no_state_permission + state_path = tmp_path('.state') + + conf = Puma::Configuration.new do |c| + c.state_path state_path + end + + launcher(conf).write_state + + assert File.exist?(state_path) + ensure + File.unlink state_path + end + + def test_puma_stats + conf = Puma::Configuration.new do |c| + c.app -> {[200, {}, ['']]} + c.clear_binds! + end + launcher = launcher(conf) + launcher.events.on_booted { + sleep 1.1 unless Puma.mri? + launcher.stop + } + launcher.run + sleep 1 unless Puma.mri? + Puma::Server::STAT_METHODS.each do |stat| + assert_includes Puma.stats_hash, stat + end + end + + def test_puma_stats_clustered + skip_unless :fork + + conf = Puma::Configuration.new do |c| + c.app -> {[200, {}, ['']]} + c.workers 1 + c.clear_binds! + end + launcher = launcher(conf) + Thread.new do + sleep Puma::ConfigDefault::DefaultWorkerCheckInterval + 1 + status = Puma.stats_hash[:worker_status].first[:last_status] + Puma::Server::STAT_METHODS.each do |stat| + assert_includes status, stat + end + launcher.stop + end + launcher.run + end + + def test_log_config_enabled + ENV['PUMA_LOG_CONFIG'] = "1" + + assert_match(/Configuration:/, launcher.events.stdout.string) + + launcher.config.final_options.each do |config_key, _value| + assert_match(/#{config_key}/, launcher.events.stdout.string) + end + + ENV.delete('PUMA_LOG_CONFIG') + end + + def test_log_config_disabled + refute_match(/Configuration:/, launcher.events.stdout.string) + end + + def test_fire_on_stopped + conf = Puma::Configuration.new do |c| + c.app -> {[200, {}, ['']]} + c.port UniquePort.call + end + + launcher = launcher(conf) + launcher.events.on_booted { + sleep 1.1 unless Puma.mri? + launcher.stop + } + launcher.events.on_stopped { puts 'on_stopped called' } + + out, = capture_io do + launcher.run + end + sleep 0.2 unless Puma.mri? + assert_equal 'on_stopped called', out.strip + end + + private + + def events + @events ||= Puma::Events.strings + end + + def launcher(config = Puma::Configuration.new, evts = events) + @launcher ||= Puma::Launcher.new(config, events: evts) + end +end diff --git a/test/test_minissl.rb b/test/test_minissl.rb new file mode 100644 index 0000000..f3b0dbe --- /dev/null +++ b/test/test_minissl.rb @@ -0,0 +1,43 @@ +require_relative "helper" + +require "puma/minissl" if ::Puma::HAS_SSL + +class TestMiniSSL < Minitest::Test + + if Puma.jruby? + def test_raises_with_invalid_keystore_file + ctx = Puma::MiniSSL::Context.new + + exception = assert_raises(ArgumentError) { ctx.keystore = "/no/such/keystore" } + assert_equal("No such keystore file '/no/such/keystore'", exception.message) + end + else + def test_raises_with_invalid_key_file + ctx = Puma::MiniSSL::Context.new + + exception = assert_raises(ArgumentError) { ctx.key = "/no/such/key" } + assert_equal("No such key file '/no/such/key'", exception.message) + end + + def test_raises_with_invalid_cert_file + ctx = Puma::MiniSSL::Context.new + + exception = assert_raises(ArgumentError) { ctx.cert = "/no/such/cert" } + assert_equal("No such cert file '/no/such/cert'", exception.message) + end + + def test_raises_with_invalid_key_pem + ctx = Puma::MiniSSL::Context.new + + exception = assert_raises(ArgumentError) { ctx.key_pem = nil } + assert_equal("'key_pem' is not a String", exception.message) + end + + def test_raises_with_invalid_cert_pem + ctx = Puma::MiniSSL::Context.new + + exception = assert_raises(ArgumentError) { ctx.cert_pem = nil } + assert_equal("'cert_pem' is not a String", exception.message) + end + end +end if ::Puma::HAS_SSL diff --git a/test/test_null_io.rb b/test/test_null_io.rb new file mode 100644 index 0000000..8a8f034 --- /dev/null +++ b/test/test_null_io.rb @@ -0,0 +1,67 @@ +# frozen_string_literal: true + +require_relative "helper" + +require "puma/null_io" + +class TestNullIO < Minitest::Test + parallelize_me! + + attr_accessor :nio + + def setup + self.nio = Puma::NullIO.new + end + + def test_eof_returns_true + assert nio.eof? + end + + def test_gets_returns_nil + assert_nil nio.gets + end + + def test_string_returns_empty_string + assert_equal "", nio.string + end + + def test_each_never_yields + nio.instance_variable_set(:@foo, :baz) + nio.each { @foo = :bar } + assert_equal :baz, nio.instance_variable_get(:@foo) + end + + def test_read_with_no_arguments + assert_equal "", nio.read + end + + def test_read_with_nil_length + assert_equal "", nio.read(nil) + end + + def test_read_with_zero_length + assert_equal "", nio.read(0) + end + + def test_read_with_positive_integer_length + assert_nil nio.read(1) + end + + def test_read_with_length_and_buffer + buf = "" + assert_nil nio.read(1, buf) + assert_equal "", buf + end + + def test_size + assert_equal 0, nio.size + end + + def test_sync_returns_true + assert_equal true, nio.sync + end + + def test_flush_returns_self + assert_equal nio, nio.flush + end +end diff --git a/test/test_out_of_band_server.rb b/test/test_out_of_band_server.rb new file mode 100644 index 0000000..27eec9b --- /dev/null +++ b/test/test_out_of_band_server.rb @@ -0,0 +1,160 @@ +require_relative "helper" +require "puma/events" + +class TestOutOfBandServer < Minitest::Test + parallelize_me! + + def setup + @ios = [] + @server = nil + @oob_finished = ConditionVariable.new + @app_finished = ConditionVariable.new + end + + def teardown + @oob_finished.broadcast + @app_finished.broadcast + @server.stop(true) if @server + @ios.each {|i| i.close unless i.closed?} + end + + def new_connection + TCPSocket.new('127.0.0.1', @port).tap {|s| @ios << s} + rescue IOError + Puma::Util.purge_interrupt_queue + retry + end + + def send_http(req) + new_connection << req + end + + def send_http_and_read(req) + send_http(req).read + end + + def oob_server(**options) + @request_count = 0 + @oob_count = 0 + in_oob = Mutex.new + @mutex = Mutex.new + oob_wait = options.delete(:oob_wait) + oob = -> do + in_oob.synchronize do + @mutex.synchronize do + @oob_count += 1 + @oob_finished.signal + @oob_finished.wait(@mutex, 1) if oob_wait + end + end + end + app_wait = options.delete(:app_wait) + app = ->(_) do + raise 'OOB conflict' if in_oob.locked? + @mutex.synchronize do + @request_count += 1 + @app_finished.signal + @app_finished.wait(@mutex, 1) if app_wait + end + [200, {}, [""]] + end + + @server = Puma::Server.new app, Puma::Events.strings, out_of_band: [oob], **options + @server.min_threads = options[:min_threads] || 1 + @server.max_threads = options[:max_threads] || 1 + @port = (@server.add_tcp_listener '127.0.0.1', 0).addr[1] + @server.run + sleep 0.15 if Puma.jruby? + end + + # Sequential requests should trigger out_of_band after every request. + def test_sequential + n = 100 + oob_server + n.times do + @mutex.synchronize do + send_http "GET / HTTP/1.0\r\n\r\n" + @oob_finished.wait(@mutex, 1) + end + end + assert_equal n, @request_count + assert_equal n, @oob_count + end + + # Stream of requests on concurrent connections should trigger + # out_of_band hooks only once after the final request. + def test_stream + oob_server app_wait: true, max_threads: 2 + n = 100 + Array.new(n) {send_http("GET / HTTP/1.0\r\n\r\n")} + Thread.pass until @request_count == n + @mutex.synchronize do + @app_finished.signal + @oob_finished.wait(@mutex, 1) + end + assert_equal n, @request_count + assert_equal 1, @oob_count + end + + # New requests should not get processed while OOB is running. + def test_request_overlapping_hook + oob_server oob_wait: true, max_threads: 2 + + # Establish connection for Req2 before OOB + req2 = new_connection + sleep 0.01 + + @mutex.synchronize do + send_http "GET / HTTP/1.0\r\n\r\n" + @oob_finished.wait(@mutex) # enter OOB + + # Send Req2 + req2 << "GET / HTTP/1.0\r\n\r\n" + # If Req2 is processed now it raises 'OOB Conflict' in the response. + sleep 0.01 + + @oob_finished.signal # exit OOB + # Req2 should be processed now. + @oob_finished.wait(@mutex, 1) # enter OOB + @oob_finished.signal # exit OOB + end + + refute_match(/OOB conflict/, req2.read) + end + + # Partial requests should not trigger OOB. + def test_partial_request + oob_server + new_connection.close + sleep 0.01 + assert_equal 0, @oob_count + end + + # OOB should be triggered following a completed request + # concurrent with other partial requests. + def test_partial_concurrent + oob_server max_threads: 2 + @mutex.synchronize do + send_http("GET / HTTP/1.0\r\n\r\n") + 100.times {new_connection.close} + @oob_finished.wait(@mutex, 1) + end + assert_equal 1, @oob_count + end + + # OOB should block new connections from being accepted. + def test_blocks_new_connection + oob_server oob_wait: true, max_threads: 2 + @mutex.synchronize do + send_http("GET / HTTP/1.0\r\n\r\n") + @oob_finished.wait(@mutex) + end + accepted = false + io = @server.binder.ios.last + io.stub(:accept_nonblock, -> {accepted = true; new_connection}) do + new_connection.close + sleep 0.01 + end + refute accepted, 'New connection accepted during out of band' + end +end diff --git a/test/test_persistent.rb b/test/test_persistent.rb new file mode 100644 index 0000000..baa8beb --- /dev/null +++ b/test/test_persistent.rb @@ -0,0 +1,247 @@ +require_relative "helper" + +class TestPersistent < Minitest::Test + + HOST = "127.0.0.1" + + def setup + @valid_request = "GET / HTTP/1.1\r\nHost: test.com\r\nContent-Type: text/plain\r\n\r\n" + @close_request = "GET / HTTP/1.1\r\nHost: test.com\r\nContent-Type: text/plain\r\nConnection: close\r\n\r\n" + @http10_request = "GET / HTTP/1.0\r\nHost: test.com\r\nContent-Type: text/plain\r\n\r\n" + @keep_request = "GET / HTTP/1.0\r\nHost: test.com\r\nContent-Type: text/plain\r\nConnection: Keep-Alive\r\n\r\n" + + @valid_post = "POST / HTTP/1.1\r\nHost: test.com\r\nContent-Type: text/plain\r\nContent-Length: 5\r\n\r\nhello" + @valid_no_body = "GET / HTTP/1.1\r\nHost: test.com\r\nX-Status: 204\r\nContent-Type: text/plain\r\n\r\n" + + @headers = { "X-Header" => "Works" } + @body = ["Hello"] + @inputs = [] + + @simple = lambda do |env| + @inputs << env['rack.input'] + status = Integer(env['HTTP_X_STATUS'] || 200) + [status, @headers, @body] + end + + @server = Puma::Server.new @simple + @port = (@server.add_tcp_listener HOST, 0).addr[1] + @server.max_threads = 1 + @server.run + sleep 0.15 if Puma.jruby? + @client = TCPSocket.new HOST, @port + end + + def teardown + @client.close + @server.stop(true) + end + + def lines(count, s=@client) + str = "".dup + Timeout.timeout(5) do + count.times { str << (s.gets || "") } + end + str + end + + def test_one_with_content_length + @client << @valid_request + sz = @body[0].size.to_s + + assert_equal "HTTP/1.1 200 OK\r\nX-Header: Works\r\nContent-Length: #{sz}\r\n\r\n", lines(4) + assert_equal "Hello", @client.read(5) + end + + def test_two_back_to_back + @client << @valid_request + sz = @body[0].size.to_s + + assert_equal "HTTP/1.1 200 OK\r\nX-Header: Works\r\nContent-Length: #{sz}\r\n\r\n", lines(4) + assert_equal "Hello", @client.read(5) + + @client << @valid_request + sz = @body[0].size.to_s + + assert_equal "HTTP/1.1 200 OK\r\nX-Header: Works\r\nContent-Length: #{sz}\r\n\r\n", lines(4) + assert_equal "Hello", @client.read(5) + end + + def test_post_then_get + @client << @valid_post + sz = @body[0].size.to_s + + assert_equal "HTTP/1.1 200 OK\r\nX-Header: Works\r\nContent-Length: #{sz}\r\n\r\n", lines(4) + assert_equal "Hello", @client.read(5) + + @client << @valid_request + sz = @body[0].size.to_s + + assert_equal "HTTP/1.1 200 OK\r\nX-Header: Works\r\nContent-Length: #{sz}\r\n\r\n", lines(4) + assert_equal "Hello", @client.read(5) + end + + def test_no_body_then_get + @client << @valid_no_body + assert_equal "HTTP/1.1 204 No Content\r\nX-Header: Works\r\n\r\n", lines(3) + + @client << @valid_request + sz = @body[0].size.to_s + + assert_equal "HTTP/1.1 200 OK\r\nX-Header: Works\r\nContent-Length: #{sz}\r\n\r\n", lines(4) + assert_equal "Hello", @client.read(5) + end + + def test_chunked + @body << "Chunked" + + @client << @valid_request + + assert_equal "HTTP/1.1 200 OK\r\nX-Header: Works\r\nTransfer-Encoding: chunked\r\n\r\n5\r\nHello\r\n7\r\nChunked\r\n0\r\n\r\n", lines(10) + end + + def test_chunked_with_empty_part + @body << "" + @body << "Chunked" + + @client << @valid_request + + assert_equal "HTTP/1.1 200 OK\r\nX-Header: Works\r\nTransfer-Encoding: chunked\r\n\r\n5\r\nHello\r\n7\r\nChunked\r\n0\r\n\r\n", lines(10) + end + + def test_no_chunked_in_http10 + @body << "Chunked" + + @client << @http10_request + + assert_equal "HTTP/1.0 200 OK\r\nX-Header: Works\r\n\r\n", lines(3) + assert_equal "HelloChunked", @client.read + end + + def test_hex + str = "This is longer and will be in hex" + @body << str + + @client << @valid_request + + assert_equal "HTTP/1.1 200 OK\r\nX-Header: Works\r\nTransfer-Encoding: chunked\r\n\r\n5\r\nHello\r\n#{str.size.to_s(16)}\r\n#{str}\r\n0\r\n\r\n", lines(10) + + end + + def test_client11_close + @client << @close_request + sz = @body[0].size.to_s + + assert_equal "HTTP/1.1 200 OK\r\nX-Header: Works\r\nConnection: close\r\nContent-Length: #{sz}\r\n\r\n", lines(5) + assert_equal "Hello", @client.read(5) + end + + def test_client10_close + @client << @http10_request + sz = @body[0].size.to_s + + assert_equal "HTTP/1.0 200 OK\r\nX-Header: Works\r\nContent-Length: #{sz}\r\n\r\n", lines(4) + assert_equal "Hello", @client.read(5) + end + + def test_one_with_keep_alive_header + @client << @keep_request + sz = @body[0].size.to_s + + assert_equal "HTTP/1.0 200 OK\r\nX-Header: Works\r\nConnection: Keep-Alive\r\nContent-Length: #{sz}\r\n\r\n", lines(5) + assert_equal "Hello", @client.read(5) + end + + def test_persistent_timeout + @server.persistent_timeout = 1 + @client << @valid_request + sz = @body[0].size.to_s + + assert_equal "HTTP/1.1 200 OK\r\nX-Header: Works\r\nContent-Length: #{sz}\r\n\r\n", lines(4) + assert_equal "Hello", @client.read(5) + + sleep 2 + + assert_raises EOFError do + @client.read_nonblock(1) + end + end + + def test_app_sets_content_length + @body = ["hello", " world"] + @headers['Content-Length'] = "11" + + @client << @valid_request + + assert_equal "HTTP/1.1 200 OK\r\nX-Header: Works\r\nContent-Length: 11\r\n\r\n", + lines(4) + assert_equal "hello world", @client.read(11) + end + + def test_allow_app_to_chunk_itself + @headers = {'Transfer-Encoding' => "chunked" } + + @body = ["5\r\nhello\r\n0\r\n\r\n"] + + @client << @valid_request + + assert_equal "HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\n\r\n5\r\nhello\r\n0\r\n\r\n", lines(7) + end + + + def test_two_requests_in_one_chunk + @server.persistent_timeout = 3 + + req = @valid_request.to_s + req += "GET /second HTTP/1.1\r\nHost: test.com\r\nContent-Type: text/plain\r\n\r\n" + + @client << req + + sz = @body[0].size.to_s + + assert_equal "HTTP/1.1 200 OK\r\nX-Header: Works\r\nContent-Length: #{sz}\r\n\r\n", lines(4) + assert_equal "Hello", @client.read(5) + + assert_equal "HTTP/1.1 200 OK\r\nX-Header: Works\r\nContent-Length: #{sz}\r\n\r\n", lines(4) + assert_equal "Hello", @client.read(5) + end + + def test_second_request_not_in_first_req_body + @server.persistent_timeout = 3 + + req = @valid_request.to_s + req += "GET /second HTTP/1.1\r\nHost: test.com\r\nContent-Type: text/plain\r\n\r\n" + + @client << req + + sz = @body[0].size.to_s + + assert_equal "HTTP/1.1 200 OK\r\nX-Header: Works\r\nContent-Length: #{sz}\r\n\r\n", lines(4) + assert_equal "Hello", @client.read(5) + + assert_equal "HTTP/1.1 200 OK\r\nX-Header: Works\r\nContent-Length: #{sz}\r\n\r\n", lines(4) + assert_equal "Hello", @client.read(5) + + assert_kind_of Puma::NullIO, @inputs[0] + assert_kind_of Puma::NullIO, @inputs[1] + end + + def test_keepalive_doesnt_starve_clients + sz = @body[0].size.to_s + + @client << @valid_request + + c2 = TCPSocket.new HOST, @port + c2 << @valid_request + + out = IO.select([c2], nil, nil, 1) + + assert out, "select returned nil" + assert_equal c2, out.first.first + + assert_equal "HTTP/1.1 200 OK\r\nX-Header: Works\r\nContent-Length: #{sz}\r\n\r\n", lines(4, c2) + assert_equal "Hello", c2.read(5) + ensure + c2.close + end + +end diff --git a/test/test_plugin.rb b/test/test_plugin.rb new file mode 100644 index 0000000..ba79689 --- /dev/null +++ b/test/test_plugin.rb @@ -0,0 +1,39 @@ +require_relative "helper" +require_relative "helpers/integration" + +class TestPlugin < TestIntegration + def test_plugin + skip "Skipped on Windows Ruby < 2.5.0, Ruby bug" if windows? && RUBY_VERSION < '2.5.0' + @tcp_bind = UniquePort.call + @tcp_ctrl = UniquePort.call + + Dir.mkdir("tmp") unless Dir.exist?("tmp") + + cli_server "-b tcp://#{HOST}:#{@tcp_bind} --control-url tcp://#{HOST}:#{@tcp_ctrl} --control-token #{TOKEN} -C test/config/plugin1.rb test/rackup/hello.ru" + File.open('tmp/restart.txt', mode: 'wb') { |f| f.puts "Restart #{Time.now}" } + + true while (l = @server.gets) !~ /Restarting\.\.\./ + assert_match(/Restarting\.\.\./, l) + + true while (l = @server.gets) !~ /Ctrl-C/ + assert_match(/Ctrl-C/, l) + + out = StringIO.new + + cli_pumactl "-C tcp://#{HOST}:#{@tcp_ctrl} -T #{TOKEN} stop" + true while (l = @server.gets) !~ /Goodbye/ + + @server.close + @server = nil + out.close + end + + private + + def cli_pumactl(argv) + pumactl = IO.popen("#{BASE} bin/pumactl #{argv}", "r") + @ios_to_close << pumactl + Process.wait pumactl.pid + pumactl + end +end diff --git a/test/test_preserve_bundler_env.rb b/test/test_preserve_bundler_env.rb new file mode 100644 index 0000000..2a2692e --- /dev/null +++ b/test/test_preserve_bundler_env.rb @@ -0,0 +1,110 @@ +require_relative "helper" +require_relative "helpers/integration" + +class TestPreserveBundlerEnv < TestIntegration + def setup + skip_unless :fork + super + end + + def teardown + return if skipped? + FileUtils.rm current_release_symlink, force: true + super + end + + # It does not wipe out BUNDLE_GEMFILE et al + def test_usr2_restart_preserves_bundler_environment + skip_unless_signal_exist? :USR2 + + @tcp_port = UniquePort.call + env = { + # Intentionally set this to something we wish to keep intact on restarts + "BUNDLE_GEMFILE" => "Gemfile.bundle_env_preservation_test", + # Don't allow our (rake test's) original env to interfere with the child process + "BUNDLER_ORIG_BUNDLE_GEMFILE" => nil + } + # Must use `bundle exec puma` here, because otherwise Bundler may not be defined, which is required to trigger the bug + cmd = "bundle exec puma -q -w 1 --prune-bundler -b tcp://#{HOST}:#{@tcp_port}" + Dir.chdir(File.expand_path("bundle_preservation_test", __dir__)) do + @server = IO.popen(env, cmd.split, "r") + end + wait_for_server_to_boot + @pid = @server.pid + connection = connect + initial_reply = read_body(connection) + assert_match("Gemfile.bundle_env_preservation_test", initial_reply) + restart_server connection + new_reply = read_body(connection) + assert_match("Gemfile.bundle_env_preservation_test", new_reply) + end + + def test_worker_forking_preserves_bundler_config_path + skip_unless_signal_exist? :TERM + + @tcp_port = UniquePort.call + env = { + # Disable the .bundle/config file in the bundle_app_config_test directory + "BUNDLE_APP_CONFIG" => "/dev/null", + # Don't allow our (rake test's) original env to interfere with the child process + "BUNDLE_GEMFILE" => nil, + "BUNDLER_ORIG_BUNDLE_GEMFILE" => nil + } + cmd = "bundle exec puma -q -w 1 --prune-bundler -b tcp://#{HOST}:#{@tcp_port}" + Dir.chdir File.expand_path("bundle_app_config_test", __dir__) do + @server = IO.popen(env, cmd.split, "r") + end + wait_for_server_to_boot + @pid = @server.pid + reply = read_body(connect) + assert_equal("Hello World", reply) + end + + def test_phased_restart_preserves_unspecified_bundle_gemfile + skip_unless_signal_exist? :USR1 + + @tcp_port = UniquePort.call + env = { + "BUNDLE_GEMFILE" => nil, + "BUNDLER_ORIG_BUNDLE_GEMFILE" => nil + } + set_release_symlink File.expand_path("bundle_preservation_test/version1", __dir__) + cmd = "bundle exec puma -q -w 1 --prune-bundler -b tcp://#{HOST}:#{@tcp_port}" + Dir.chdir(current_release_symlink) do + @server = IO.popen(env, cmd.split, "r") + end + wait_for_server_to_boot + @pid = @server.pid + connection = connect + + # Bundler itself sets ENV['BUNDLE_GEMFILE'] to the Gemfile it finds if ENV['BUNDLE_GEMFILE'] was unspecified + initial_reply = read_body(connection) + expected_gemfile = File.expand_path("bundle_preservation_test/version1/Gemfile", __dir__).inspect + assert_equal(expected_gemfile, initial_reply) + + set_release_symlink File.expand_path("bundle_preservation_test/version2", __dir__) + start_phased_restart + + connection = connect + new_reply = read_body(connection) + expected_gemfile = File.expand_path("bundle_preservation_test/version2/Gemfile", __dir__).inspect + assert_equal(expected_gemfile, new_reply) + end + + private + + def current_release_symlink + File.expand_path "bundle_preservation_test/current", __dir__ + end + + def set_release_symlink(target_dir) + FileUtils.rm current_release_symlink, force: true + FileUtils.symlink target_dir, current_release_symlink, force: true + end + + def start_phased_restart + Process.kill :USR1, @pid + + true while @server.gets !~ /booted in [.0-9]+s, phase: 1/ + end +end diff --git a/test/test_puma_localhost_authority.rb b/test/test_puma_localhost_authority.rb new file mode 100644 index 0000000..8599980 --- /dev/null +++ b/test/test_puma_localhost_authority.rb @@ -0,0 +1,95 @@ +# Nothing in this file runs if Puma isn't compiled with ssl support +# +# helper is required first since it loads Puma, which needs to be +# loaded so HAS_SSL is defined +require_relative "helper" +require "localhost/authority" + +if ::Puma::HAS_SSL && !Puma::IS_JRUBY + require "puma/minissl" + require "net/http" + + # net/http (loaded in helper) does not necessarily load OpenSSL + require "openssl" unless Object.const_defined? :OpenSSL +end + +class TestPumaLocalhostAuthority < Minitest::Test + parallelize_me! + def setup + @http = nil + @server = nil + end + + def teardown + @http.finish if @http && @http.started? + @server.stop(true) if @server + end + + # yields ctx to block, use for ctx setup & configuration + def start_server + @host = "localhost" + app = lambda { |env| [200, {}, [env['rack.url_scheme']]] } + + @events = SSLEventsHelper.new STDOUT, STDERR + @server = Puma::Server.new app, @events + @server.app = app + @server.add_ssl_listener @host, 0,nil + @http = Net::HTTP.new @host, @server.connected_ports[0] + + @http.use_ssl = true + # Disabling verification since its self signed + @http.verify_mode = OpenSSL::SSL::VERIFY_NONE + # @http.verify_mode = OpenSSL::SSL::VERIFY_NONE + + @server.run + end + + def test_localhost_authority_file_generated + # Initiate server to create localhost authority + unless File.exist?(File.join(Localhost::Authority.path,"localhost.key")) + start_server + end + assert_equal(File.exist?(File.join(Localhost::Authority.path,"localhost.key")), true) + assert_equal(File.exist?(File.join(Localhost::Authority.path,"localhost.crt")), true) + end + +end if ::Puma::HAS_SSL && !Puma::IS_JRUBY + +class TestPumaSSLLocalhostAuthority < Minitest::Test + def test_self_signed_by_localhost_authority + @host = "localhost" + + app = lambda { |env| [200, {}, [env['rack.url_scheme']]] } + + @events = SSLEventsHelper.new STDOUT, STDERR + + @server = Puma::Server.new app, @events + @server.app = app + + @server.add_ssl_listener @host, 0,nil + + @http = Net::HTTP.new @host, @server.connected_ports[0] + @http.use_ssl = true + + OpenSSL::PKey::RSA.new File.read(File.join(Localhost::Authority.path,"localhost.key")) + local_authority_crt = OpenSSL::X509::Certificate.new File.read(File.join(Localhost::Authority.path,"localhost.crt")) + + @http.verify_mode = OpenSSL::SSL::VERIFY_NONE + @server.run + @cert = nil + begin + @http.start do + req = Net::HTTP::Get.new "/", {} + @http.request(req) + @cert = @http.peer_cert + end + rescue OpenSSL::SSL::SSLError, EOFError, Errno::ECONNRESET + # Errno::ECONNRESET TruffleRuby + # closes socket if open, may not close on error + @http.send :do_finish + end + sleep 0.1 + + assert_equal(@cert.to_pem, local_authority_crt.to_pem) + end +end if ::Puma::HAS_SSL && !Puma::IS_JRUBY diff --git a/test/test_puma_server.rb b/test/test_puma_server.rb new file mode 100644 index 0000000..298e44b --- /dev/null +++ b/test/test_puma_server.rb @@ -0,0 +1,1368 @@ +require_relative "helper" +require "puma/events" +require "net/http" +require "nio" +require "ipaddr" + +class TestPumaServer < Minitest::Test + parallelize_me! unless JRUBY_HEAD + + def setup + @host = "127.0.0.1" + + @ios = [] + + @app = ->(env) { [200, {}, [env['rack.url_scheme']]] } + + @events = Puma::Events.strings + @server = Puma::Server.new @app, @events + end + + def teardown + @server.stop(true) + @ios.each { |io| io.close if io && !io.closed? } + end + + def server_run(**options, &block) + @server = Puma::Server.new block || @app, @events, options + @port = (@server.add_tcp_listener @host, 0).addr[1] + @server.run + sleep 0.15 if Puma.jruby? + end + + def header(sock) + header = [] + while true + line = sock.gets + break if line == "\r\n" + header << line.strip + end + + header + end + + def send_http_and_read(req) + send_http(req).read + end + + def send_http(req) + new_connection << req + end + + def send_proxy_v1_http(req, remote_ip, multisend = false) + addr = IPAddr.new(remote_ip) + family = addr.ipv4? ? "TCP4" : "TCP6" + target = addr.ipv4? ? "127.0.0.1" : "::1" + conn = new_connection + if multisend + conn << "PROXY #{family} #{remote_ip} #{target} 10000 80\r\n" + sleep 0.15 + conn << req + else + conn << ("PROXY #{family} #{remote_ip} #{target} 10000 80\r\n" + req) + end + end + + + def new_connection + TCPSocket.new(@host, @port).tap {|sock| @ios << sock} + end + + def test_normalize_host_header_missing + server_run do |env| + [200, {}, [env["SERVER_NAME"], "\n", env["SERVER_PORT"]]] + end + + data = send_http_and_read "GET / HTTP/1.0\r\n\r\n" + assert_equal "localhost\n80", data.split("\r\n").last + end + + def test_normalize_host_header_hostname + server_run do |env| + [200, {}, [env["SERVER_NAME"], "\n", env["SERVER_PORT"]]] + end + + data = send_http_and_read "GET / HTTP/1.0\r\nHost: example.com:456\r\n\r\n" + assert_equal "example.com\n456", data.split("\r\n").last + + data = send_http_and_read "GET / HTTP/1.0\r\nHost: example.com\r\n\r\n" + assert_equal "example.com\n80", data.split("\r\n").last + end + + def test_normalize_host_header_ipv4 + server_run do |env| + [200, {}, [env["SERVER_NAME"], "\n", env["SERVER_PORT"]]] + end + + data = send_http_and_read "GET / HTTP/1.0\r\nHost: 123.123.123.123:456\r\n\r\n" + assert_equal "123.123.123.123\n456", data.split("\r\n").last + + data = send_http_and_read "GET / HTTP/1.0\r\nHost: 123.123.123.123\r\n\r\n" + assert_equal "123.123.123.123\n80", data.split("\r\n").last + end + + def test_normalize_host_header_ipv6 + server_run do |env| + [200, {}, [env["SERVER_NAME"], "\n", env["SERVER_PORT"]]] + end + + data = send_http_and_read "GET / HTTP/1.0\r\nHost: [::ffff:127.0.0.1]:9292\r\n\r\n" + assert_equal "[::ffff:127.0.0.1]\n9292", data.split("\r\n").last + + data = send_http_and_read "GET / HTTP/1.0\r\nHost: [::1]:9292\r\n\r\n" + assert_equal "[::1]\n9292", data.split("\r\n").last + + data = send_http_and_read "GET / HTTP/1.0\r\nHost: [::1]\r\n\r\n" + assert_equal "[::1]\n80", data.split("\r\n").last + end + + def test_proper_stringio_body + data = nil + + server_run do |env| + data = env['rack.input'].read + [200, {}, ["ok"]] + end + + fifteen = "1" * 15 + + sock = send_http "PUT / HTTP/1.0\r\nContent-Length: 30\r\n\r\n#{fifteen}" + + sleep 0.1 # important so that the previous data is sent as a packet + sock << fifteen + + sock.read + + assert_equal "#{fifteen}#{fifteen}", data + end + + def test_puma_socket + body = "HTTP/1.1 750 Upgraded to Awesome\r\nDone: Yep!\r\n" + server_run do |env| + io = env['puma.socket'] + io.write body + io.close + [-1, {}, []] + end + + data = send_http_and_read "PUT / HTTP/1.0\r\n\r\nHello" + + assert_equal body, data + end + + def test_very_large_return + giant = "x" * 2056610 + + server_run do + [200, {}, [giant]] + end + + sock = send_http "GET / HTTP/1.0\r\n\r\n" + + while true + line = sock.gets + break if line == "\r\n" + end + + out = sock.read + + assert_equal giant.bytesize, out.bytesize + end + + def test_respect_x_forwarded_proto + env = {} + env['HOST'] = "example.com" + env['HTTP_X_FORWARDED_PROTO'] = "https,http" + + assert_equal "443", @server.default_server_port(env) + end + + def test_respect_x_forwarded_ssl_on + env = {} + env['HOST'] = 'example.com' + env['HTTP_X_FORWARDED_SSL'] = 'on' + + assert_equal "443", @server.default_server_port(env) + end + + def test_respect_x_forwarded_scheme + env = {} + env['HOST'] = 'example.com' + env['HTTP_X_FORWARDED_SCHEME'] = 'https' + + assert_equal '443', @server.default_server_port(env) + end + + def test_default_server_port + server_run do |env| + [200, {}, [env['SERVER_PORT']]] + end + + req = Net::HTTP::Get.new '/' + req['HOST'] = 'example.com' + + res = Net::HTTP.start @host, @port do |http| + http.request(req) + end + + assert_equal "80", res.body + end + + def test_default_server_port_respects_x_forwarded_proto + server_run do |env| + [200, {}, [env['SERVER_PORT']]] + end + + req = Net::HTTP::Get.new("/") + req['HOST'] = "example.com" + req['X-FORWARDED-PROTO'] = "https,http" + + res = Net::HTTP.start @host, @port do |http| + http.request(req) + end + + assert_equal "443", res.body + end + + def test_HEAD_has_no_body + server_run { [200, {"Foo" => "Bar"}, ["hello"]] } + + data = send_http_and_read "HEAD / HTTP/1.0\r\n\r\n" + + assert_equal "HTTP/1.0 200 OK\r\nFoo: Bar\r\nContent-Length: 5\r\n\r\n", data + end + + def test_GET_with_empty_body_has_sane_chunking + server_run { [200, {}, [""]] } + + data = send_http_and_read "HEAD / HTTP/1.0\r\n\r\n" + + assert_equal "HTTP/1.0 200 OK\r\nContent-Length: 0\r\n\r\n", data + end + + def test_early_hints_works + server_run(early_hints: true) do |env| + env['rack.early_hints'].call("Link" => "; rel=preload; as=style\n; rel=preload") + [200, { "X-Hello" => "World" }, ["Hello world!"]] + end + + data = send_http_and_read "HEAD / HTTP/1.0\r\n\r\n" + + expected_data = (<; rel=preload; as=style +Link: ; rel=preload + +HTTP/1.0 200 OK +X-Hello: World +Content-Length: 12 +EOF +).split("\n").join("\r\n") + "\r\n\r\n" + + assert_equal true, @server.early_hints + assert_equal expected_data, data + end + + def test_early_hints_are_ignored_if_connection_lost + + server_run(early_hints: true) do |env| + env['rack.early_hints'].call("Link" => "; rel=preload") + [200, { "X-Hello" => "World" }, ["Hello world!"]] + end + + def @server.fast_write(*args) + raise Puma::ConnectionError + end + + # This request will cause the server to try and send early hints + _ = send_http "HEAD / HTTP/1.0\r\n\r\n" + + # Give the server some time to try to write (and fail) + sleep 0.1 + + # Expect no errors in stderr + assert @events.stderr.pos.zero?, "Server didn't swallow the connection error" + end + + def test_early_hints_is_off_by_default + server_run do |env| + assert_nil env['rack.early_hints'] + [200, { "X-Hello" => "World" }, ["Hello world!"]] + end + + data = send_http_and_read "HEAD / HTTP/1.0\r\n\r\n" + + expected_data = (< "application/json"}, ["{}\n"]]} + server_run(lowlevel_error_handler: handler, force_shutdown_after: 2) do + @server.stop + sleep 5 + end + + data = send_http_and_read "GET / HTTP/1.0\r\n\r\n" + + assert_match(/HTTP\/1.0 500 Internal Server Error/, data) + assert_match(/Content-Type: application\/json/, data) + assert_match(/{}\n$/, data) + end + + def test_lowlevel_error_message + skip_if :windows + @server = Puma::Server.new @app, @events, {:force_shutdown_after => 2} + + server_run do + require 'json' + + # will raise fatal: machine stack overflow in critical region + obj = {} + obj['cycle'] = obj + ::JSON.dump(obj) + end + + data = send_http_and_read "GET / HTTP/1.0\r\n\r\n" + + assert_match(/HTTP\/1.0 500 Internal Server Error/, data) + assert (data.size > 0), "Expected response message to be not empty" + end + + def test_force_shutdown_error_default + server_run(force_shutdown_after: 2) do + @server.stop + sleep 5 + end + + data = send_http_and_read "GET / HTTP/1.0\r\n\r\n" + + assert_match(/HTTP\/1.0 503 Service Unavailable/, data) + assert_match(/Puma caught this error.+Puma::ThreadPool::ForceShutdown/, data) + end + + def test_prints_custom_error + re = lambda { |err| [302, {'Content-Type' => 'text', 'Location' => 'foo.html'}, ['302 found']] } + server_run(lowlevel_error_handler: re) { raise "don't leak me bro" } + + data = send_http_and_read "GET / HTTP/1.0\r\n\r\n" + + assert_match(/HTTP\/1.0 302 Found/, data) + end + + def test_leh_gets_env_as_well + re = lambda { |err,env| + env['REQUEST_PATH'] || raise('where is env?') + [302, {'Content-Type' => 'text', 'Location' => 'foo.html'}, ['302 found']] + } + + server_run(lowlevel_error_handler: re) { raise "don't leak me bro" } + + data = send_http_and_read "GET / HTTP/1.0\r\n\r\n" + + assert_match(/HTTP\/1.0 302 Found/, data) + end + + def test_leh_has_status + re = lambda { |err, env, status| + raise "Cannot find status" unless status + [302, {'Content-Type' => 'text', 'Location' => 'foo.html'}, ['302 found']] + } + + server_run(lowlevel_error_handler: re) { raise "don't leak me bro" } + + data = send_http_and_read "GET / HTTP/1.0\r\n\r\n" + + assert_match(/HTTP\/1.0 302 Found/, data) + end + + def test_custom_http_codes_10 + server_run { [449, {}, [""]] } + + data = send_http_and_read "GET / HTTP/1.0\r\n\r\n" + + assert_equal "HTTP/1.0 449 CUSTOM\r\nContent-Length: 0\r\n\r\n", data + end + + def test_custom_http_codes_11 + server_run { [449, {}, [""]] } + + data = send_http_and_read "GET / HTTP/1.1\r\nConnection: close\r\n\r\n" + + assert_equal "HTTP/1.1 449 CUSTOM\r\nConnection: close\r\nContent-Length: 0\r\n\r\n", data + end + + def test_HEAD_returns_content_headers + server_run { [200, {"Content-Type" => "application/pdf", + "Content-Length" => "4242"}, []] } + + data = send_http_and_read "HEAD / HTTP/1.0\r\n\r\n" + + assert_equal "HTTP/1.0 200 OK\r\nContent-Type: application/pdf\r\nContent-Length: 4242\r\n\r\n", data + end + + def test_status_hook_fires_when_server_changes_states + + states = [] + + @events.register(:state) { |s| states << s } + + server_run { [200, {}, [""]] } + + _ = send_http_and_read "HEAD / HTTP/1.0\r\n\r\n" + + assert_equal [:booting, :running], states + + @server.stop(true) + + assert_equal [:booting, :running, :stop, :done], states + end + + def test_timeout_in_data_phase(**options) + server_run(first_data_timeout: 1, **options) + + sock = send_http "POST / HTTP/1.1\r\nHost: test.com\r\nContent-Type: text/plain\r\nContent-Length: 5\r\n\r\n" + + sock << "Hello" unless IO.select([sock], nil, nil, 1.15) + + data = sock.gets + + assert_equal "HTTP/1.1 408 Request Timeout\r\n", data + end + + def test_timeout_data_no_queue + test_timeout_in_data_phase(queue_requests: false) + end + + # https://github.com/puma/puma/issues/2574 + def test_no_timeout_after_data_received + @server.first_data_timeout = 1 + server_run + + sock = send_http "POST / HTTP/1.1\r\nHost: test.com\r\nContent-Type: text/plain\r\nContent-Length: 11\r\n\r\n" + sleep 0.5 + + sock << "hello" + sleep 0.5 + sock << "world" + sleep 0.5 + sock << "!" + + data = sock.gets + + assert_equal "HTTP/1.1 200 OK\r\n", data + end + + def test_no_timeout_after_data_received_no_queue + @server = Puma::Server.new @app, @events, queue_requests: false + test_no_timeout_after_data_received + end + + def test_http_11_keep_alive_with_body + server_run { [200, {"Content-Type" => "plain/text"}, ["hello\n"]] } + + sock = send_http "GET / HTTP/1.1\r\nConnection: Keep-Alive\r\n\r\n" + + h = header sock + + body = sock.gets + + assert_equal ["HTTP/1.1 200 OK", "Content-Type: plain/text", "Content-Length: 6"], h + assert_equal "hello\n", body + + sock.close + end + + def test_http_11_close_with_body + server_run { [200, {"Content-Type" => "plain/text"}, ["hello"]] } + + data = send_http_and_read "GET / HTTP/1.1\r\nConnection: close\r\n\r\n" + + assert_equal "HTTP/1.1 200 OK\r\nContent-Type: plain/text\r\nConnection: close\r\nContent-Length: 5\r\n\r\nhello", data + end + + def test_http_11_keep_alive_without_body + server_run { [204, {}, []] } + + sock = send_http "GET / HTTP/1.1\r\nConnection: Keep-Alive\r\n\r\n" + + h = header sock + + assert_equal ["HTTP/1.1 204 No Content"], h + end + + def test_http_11_close_without_body + server_run { [204, {}, []] } + + sock = send_http "GET / HTTP/1.1\r\nConnection: close\r\n\r\n" + + h = header sock + + assert_equal ["HTTP/1.1 204 No Content", "Connection: close"], h + end + + def test_http_10_keep_alive_with_body + server_run { [200, {"Content-Type" => "plain/text"}, ["hello\n"]] } + + sock = send_http "GET / HTTP/1.0\r\nConnection: Keep-Alive\r\n\r\n" + + h = header sock + + body = sock.gets + + assert_equal ["HTTP/1.0 200 OK", "Content-Type: plain/text", "Connection: Keep-Alive", "Content-Length: 6"], h + assert_equal "hello\n", body + end + + def test_http_10_close_with_body + server_run { [200, {"Content-Type" => "plain/text"}, ["hello"]] } + + data = send_http_and_read "GET / HTTP/1.0\r\nConnection: close\r\n\r\n" + + assert_equal "HTTP/1.0 200 OK\r\nContent-Type: plain/text\r\nContent-Length: 5\r\n\r\nhello", data + end + + def test_http_10_partial_hijack_with_content_length + body_parts = ['abc', 'de'] + + server_run do + hijack_lambda = proc do | io | + io.write(body_parts[0]) + io.write(body_parts[1]) + io.close + end + [200, {"Content-Length" => "5", 'rack.hijack' => hijack_lambda}, nil] + end + + data = send_http_and_read "GET / HTTP/1.0\r\nConnection: close\r\n\r\n" + + assert_equal "HTTP/1.0 200 OK\r\nContent-Length: 5\r\n\r\nabcde", data + end + + def test_http_10_keep_alive_without_body + server_run { [204, {}, []] } + + sock = send_http "GET / HTTP/1.0\r\nConnection: Keep-Alive\r\n\r\n" + + h = header sock + + assert_equal ["HTTP/1.0 204 No Content", "Connection: Keep-Alive"], h + end + + def test_http_10_close_without_body + server_run { [204, {}, []] } + + data = send_http_and_read "GET / HTTP/1.0\r\nConnection: close\r\n\r\n" + + assert_equal "HTTP/1.0 204 No Content\r\n\r\n", data + end + + def test_Expect_100 + server_run { [200, {}, [""]] } + + data = send_http_and_read "GET / HTTP/1.1\r\nConnection: close\r\nExpect: 100-continue\r\n\r\n" + + assert_equal "HTTP/1.1 100 Continue\r\n\r\nHTTP/1.1 200 OK\r\nConnection: close\r\nContent-Length: 0\r\n\r\n", data + end + + def test_chunked_request + body = nil + content_length = nil + transfer_encoding = nil + server_run { |env| + body = env['rack.input'].read + content_length = env['CONTENT_LENGTH'] + transfer_encoding = env['HTTP_TRANSFER_ENCODING'] + [200, {}, [""]] + } + + data = send_http_and_read "GET / HTTP/1.1\r\nConnection: close\r\nTransfer-Encoding: gzip,chunked\r\n\r\n1\r\nh\r\n4\r\nello\r\n0\r\n\r\n" + + assert_equal "HTTP/1.1 200 OK\r\nConnection: close\r\nContent-Length: 0\r\n\r\n", data + assert_equal "hello", body + assert_equal "5", content_length + assert_nil transfer_encoding + end + + def test_large_chunked_request + body = nil + content_length = nil + server_run { |env| + body = env['rack.input'].read + content_length = env['CONTENT_LENGTH'] + [200, {}, [""]] + } + + header = "GET / HTTP/1.1\r\nConnection: close\r\nTransfer-Encoding: chunked\r\n\r\n" + + chunk_header_size = 6 # 4fb8\r\n + # Current implementation reads one chunk of CHUNK_SIZE, then more chunks of size 4096. + # We want a chunk to split exactly after "#{request_body}\r", before the "\n". + edge_case_size = Puma::Const::CHUNK_SIZE + 4096 - header.size - chunk_header_size - 1 + + margin = 0 # 0 for only testing this specific case, increase to test more surrounding sizes + (-margin..margin).each do |i| + size = edge_case_size + i + request_body = '.' * size + request = "#{header}#{size.to_s(16)}\r\n#{request_body}\r\n0\r\n\r\n" + + data = send_http_and_read request + + assert_equal "HTTP/1.1 200 OK\r\nConnection: close\r\nContent-Length: 0\r\n\r\n", data + assert_equal size, Integer(content_length) + assert_equal request_body, body + end + end + + def test_chunked_request_pause_before_value + body = nil + content_length = nil + server_run { |env| + body = env['rack.input'].read + content_length = env['CONTENT_LENGTH'] + [200, {}, [""]] + } + + sock = send_http "GET / HTTP/1.1\r\nConnection: close\r\nTransfer-Encoding: chunked\r\n\r\n1\r\n" + sleep 1 + + sock << "h\r\n4\r\nello\r\n0\r\n\r\n" + + data = sock.read + + assert_equal "HTTP/1.1 200 OK\r\nConnection: close\r\nContent-Length: 0\r\n\r\n", data + assert_equal "hello", body + assert_equal "5", content_length + end + + def test_chunked_request_pause_between_chunks + body = nil + content_length = nil + server_run { |env| + body = env['rack.input'].read + content_length = env['CONTENT_LENGTH'] + [200, {}, [""]] + } + + sock = send_http "GET / HTTP/1.1\r\nConnection: close\r\nTransfer-Encoding: chunked\r\n\r\n1\r\nh\r\n" + sleep 1 + + sock << "4\r\nello\r\n0\r\n\r\n" + + data = sock.read + + assert_equal "HTTP/1.1 200 OK\r\nConnection: close\r\nContent-Length: 0\r\n\r\n", data + assert_equal "hello", body + assert_equal "5", content_length + end + + def test_chunked_request_pause_mid_count + body = nil + content_length = nil + server_run { |env| + body = env['rack.input'].read + content_length = env['CONTENT_LENGTH'] + [200, {}, [""]] + } + + sock = send_http "GET / HTTP/1.1\r\nConnection: close\r\nTransfer-Encoding: chunked\r\n\r\n1\r" + sleep 1 + + sock << "\nh\r\n4\r\nello\r\n0\r\n\r\n" + + data = sock.read + + assert_equal "HTTP/1.1 200 OK\r\nConnection: close\r\nContent-Length: 0\r\n\r\n", data + assert_equal "hello", body + assert_equal "5", content_length + end + + def test_chunked_request_pause_before_count_newline + body = nil + content_length = nil + server_run { |env| + body = env['rack.input'].read + content_length = env['CONTENT_LENGTH'] + [200, {}, [""]] + } + + sock = send_http "GET / HTTP/1.1\r\nConnection: close\r\nTransfer-Encoding: chunked\r\n\r\n1" + sleep 1 + + sock << "\r\nh\r\n4\r\nello\r\n0\r\n\r\n" + + data = sock.read + + assert_equal "HTTP/1.1 200 OK\r\nConnection: close\r\nContent-Length: 0\r\n\r\n", data + assert_equal "hello", body + assert_equal "5", content_length + end + + def test_chunked_request_pause_mid_value + body = nil + content_length = nil + server_run { |env| + body = env['rack.input'].read + content_length = env['CONTENT_LENGTH'] + [200, {}, [""]] + } + + sock = send_http "GET / HTTP/1.1\r\nConnection: close\r\nTransfer-Encoding: chunked\r\n\r\n1\r\nh\r\n4\r\ne" + sleep 1 + + sock << "llo\r\n0\r\n\r\n" + + data = sock.read + + assert_equal "HTTP/1.1 200 OK\r\nConnection: close\r\nContent-Length: 0\r\n\r\n", data + assert_equal "hello", body + assert_equal "5", content_length + end + + def test_chunked_request_pause_between_cr_lf_after_size_of_second_chunk + body = nil + content_length = nil + server_run { |env| + body = env['rack.input'].read + content_length = env['CONTENT_LENGTH'] + [200, {}, [""]] + } + + part1 = 'a' * 4200 + + chunked_body = "#{part1.size.to_s(16)}\r\n#{part1}\r\n1\r\nb\r\n0\r\n\r\n" + + sock = send_http "PUT /path HTTP/1.1\r\nConnection: close\r\nTransfer-Encoding: chunked\r\n\r\n" + + sleep 0.1 + + sock << chunked_body[0..-10] + + sleep 0.1 + + sock << chunked_body[-9..-1] + + data = sock.read + + assert_equal "HTTP/1.1 200 OK\r\nConnection: close\r\nContent-Length: 0\r\n\r\n", data + assert_equal (part1 + 'b'), body + assert_equal "4201", content_length + end + + def test_chunked_request_pause_between_closing_cr_lf + body = nil + content_length = nil + server_run { |env| + body = env['rack.input'].read + content_length = env['CONTENT_LENGTH'] + [200, {}, [""]] + } + + sock = send_http "PUT /path HTTP/1.1\r\nConnection: close\r\nTransfer-Encoding: chunked\r\n\r\n5\r\nhello\r" + + sleep 1 + + sock << "\n0\r\n\r\n" + + data = sock.read + + assert_equal "HTTP/1.1 200 OK\r\nConnection: close\r\nContent-Length: 0\r\n\r\n", data + assert_equal 'hello', body + assert_equal "5", content_length + end + + def test_chunked_request_pause_before_closing_cr_lf + body = nil + content_length = nil + server_run { |env| + body = env['rack.input'].read + content_length = env['CONTENT_LENGTH'] + [200, {}, [""]] + } + + sock = send_http "PUT /path HTTP/1.1\r\nConnection: close\r\nTransfer-Encoding: chunked\r\n\r\n5\r\nhello" + + sleep 1 + + sock << "\r\n0\r\n\r\n" + + data = sock.read + + assert_equal "HTTP/1.1 200 OK\r\nConnection: close\r\nContent-Length: 0\r\n\r\n", data + assert_equal 'hello', body + assert_equal "5", content_length + end + + def test_chunked_request_header_case + body = nil + content_length = nil + server_run { |env| + body = env['rack.input'].read + content_length = env['CONTENT_LENGTH'] + [200, {}, [""]] + } + + data = send_http_and_read "GET / HTTP/1.1\r\nConnection: close\r\nTransfer-Encoding: Chunked\r\n\r\n1\r\nh\r\n4\r\nello\r\n0\r\n\r\n" + + assert_equal "HTTP/1.1 200 OK\r\nConnection: close\r\nContent-Length: 0\r\n\r\n", data + assert_equal "hello", body + assert_equal "5", content_length + end + + def test_chunked_keep_alive + body = nil + content_length = nil + server_run { |env| + body = env['rack.input'].read + content_length = env['CONTENT_LENGTH'] + [200, {}, [""]] + } + + sock = send_http "GET / HTTP/1.1\r\nConnection: Keep-Alive\r\nTransfer-Encoding: chunked\r\n\r\n1\r\nh\r\n4\r\nello\r\n0\r\n\r\n" + + h = header sock + + assert_equal ["HTTP/1.1 200 OK", "Content-Length: 0"], h + assert_equal "hello", body + assert_equal "5", content_length + + sock.close + end + + def test_chunked_keep_alive_two_back_to_back + body = nil + content_length = nil + server_run { |env| + body = env['rack.input'].read + content_length = env['CONTENT_LENGTH'] + [200, {}, [""]] + } + + sock = send_http "GET / HTTP/1.1\r\nConnection: Keep-Alive\r\nTransfer-Encoding: chunked\r\n\r\n1\r\nh\r\n4\r\nello\r\n0\r\n" + + last_crlf_written = false + last_crlf_writer = Thread.new do + sleep 0.1 + sock << "\r" + sleep 0.1 + sock << "\n" + last_crlf_written = true + end + + h = header(sock) + assert_equal ["HTTP/1.1 200 OK", "Content-Length: 0"], h + assert_equal "hello", body + assert_equal "5", content_length + assert_equal true, last_crlf_written + + last_crlf_writer.join + + sock << "GET / HTTP/1.1\r\nConnection: Keep-Alive\r\nTransfer-Encoding: chunked\r\n\r\n4\r\ngood\r\n3\r\nbye\r\n0\r\n\r\n" + sleep 0.1 + + h = header(sock) + + assert_equal ["HTTP/1.1 200 OK", "Content-Length: 0"], h + assert_equal "goodbye", body + assert_equal "7", content_length + + sock.close + end + + def test_chunked_keep_alive_two_back_to_back_with_set_remote_address + body = nil + content_length = nil + remote_addr =nil + server_run(remote_address: :header, remote_address_header: 'HTTP_X_FORWARDED_FOR') { |env| + body = env['rack.input'].read + content_length = env['CONTENT_LENGTH'] + remote_addr = env['REMOTE_ADDR'] + [200, {}, [""]] + } + + sock = send_http "GET / HTTP/1.1\r\nX-Forwarded-For: 127.0.0.1\r\nConnection: Keep-Alive\r\nTransfer-Encoding: chunked\r\n\r\n1\r\nh\r\n4\r\nello\r\n0\r\n\r\n" + + h = header sock + assert_equal ["HTTP/1.1 200 OK", "Content-Length: 0"], h + assert_equal "hello", body + assert_equal "5", content_length + assert_equal "127.0.0.1", remote_addr + + sock << "GET / HTTP/1.1\r\nX-Forwarded-For: 127.0.0.2\r\nConnection: Keep-Alive\r\nTransfer-Encoding: chunked\r\n\r\n4\r\ngood\r\n3\r\nbye\r\n0\r\n\r\n" + sleep 0.1 + + h = header(sock) + + assert_equal ["HTTP/1.1 200 OK", "Content-Length: 0"], h + assert_equal "goodbye", body + assert_equal "7", content_length + assert_equal "127.0.0.2", remote_addr + + sock.close + end + + def test_chunked_encoding + enc = Encoding::UTF_16LE + str = "──иї_テスト──\n".encode enc + + server_run { + hdrs = {} + hdrs['Content-Type'] = "text; charset=#{enc.to_s.downcase}" + + body = Enumerator.new do |yielder| + 100.times do |entry| + yielder << str + end + yielder << "\nHello World\n".encode(enc) + end + + [200, hdrs, body] + } + + body = Net::HTTP.start @host, @port do |http| + http.request(Net::HTTP::Get.new '/').body.force_encoding(enc) + end + assert_includes body, str + assert_equal enc, body.encoding + end + + def test_empty_header_values + server_run { [200, {"X-Empty-Header" => ""}, []] } + + data = send_http_and_read "HEAD / HTTP/1.0\r\n\r\n" + + assert_equal "HTTP/1.0 200 OK\r\nX-Empty-Header: \r\n\r\n", data + end + + def test_request_body_wait + request_body_wait = nil + server_run { |env| + request_body_wait = env['puma.request_body_wait'] + [204, {}, []] + } + + sock = send_http "POST / HTTP/1.1\r\nHost: test.com\r\nContent-Type: text/plain\r\nContent-Length: 5\r\n\r\nh" + sleep 1 + sock << "ello" + + sock.gets + + # Could be 1000 but the tests get flaky. We don't care if it's extremely precise so much as that + # it is set to a reasonable number. + assert_operator request_body_wait, :>=, 900 + end + + def test_request_body_wait_chunked + request_body_wait = nil + server_run { |env| + request_body_wait = env['puma.request_body_wait'] + [204, {}, []] + } + + sock = send_http "GET / HTTP/1.1\r\nConnection: close\r\nTransfer-Encoding: chunked\r\n\r\n1\r\nh\r\n" + sleep 3 + sock << "4\r\nello\r\n0\r\n\r\n" + + sock.gets + + # Could be 1000 but the tests get flaky. We don't care if it's extremely precise so much as that + # it is set to a reasonable number. + assert_operator request_body_wait, :>=, 900 + end + + def test_open_connection_wait(**options) + server_run(**options) { [200, {}, ["Hello"]] } + s = send_http nil + sleep 0.1 + s << "GET / HTTP/1.0\r\n\r\n" + assert_equal 'Hello', s.readlines.last + end + + def test_open_connection_wait_no_queue + test_open_connection_wait(queue_requests: false) + end + + # Rack may pass a newline in a header expecting us to split it. + def test_newline_splits + server_run { [200, {'X-header' => "first line\nsecond line"}, ["Hello"]] } + + data = send_http_and_read "HEAD / HTTP/1.0\r\n\r\n" + + assert_match "X-header: first line\r\nX-header: second line\r\n", data + end + + def test_newline_splits_in_early_hint + server_run(early_hints: true) do |env| + env['rack.early_hints'].call({'X-header' => "first line\nsecond line"}) + [200, {}, ["Hello world!"]] + end + + data = send_http_and_read "HEAD / HTTP/1.0\r\n\r\n" + + assert_match "X-header: first line\r\nX-header: second line\r\n", data + end + + def test_proxy_protocol + server_run(remote_address: :proxy_protocol, remote_address_proxy_protocol: :v1) do |env| + [200, {}, [env["REMOTE_ADDR"]]] + end + + remote_addr = send_proxy_v1_http("GET / HTTP/1.0\r\n\r\n", "1.2.3.4").read.split("\r\n").last + assert_equal '1.2.3.4', remote_addr + + remote_addr = send_proxy_v1_http("GET / HTTP/1.0\r\n\r\n", "fd00::1").read.split("\r\n").last + assert_equal 'fd00::1', remote_addr + + remote_addr = send_proxy_v1_http("GET / HTTP/1.0\r\n\r\n", "fd00::1", true).read.split("\r\n").last + assert_equal 'fd00::1', remote_addr + end + + # To comply with the Rack spec, we have to split header field values + # containing newlines into multiple headers. + def assert_does_not_allow_http_injection(app, opts = {}) + server_run(early_hints: opts[:early_hints], &app) + + data = send_http_and_read "HEAD / HTTP/1.0\r\n\r\n" + + refute_match(/[\r\n]Cookie: hack[\r\n]/, data) + end + + # HTTP Injection Tests + # + # Puma should prevent injection of CR and LF characters into headers, either as + # CRLF or CR or LF, because browsers may interpret it at as a line end and + # allow untrusted input in the header to split the header or start the + # response body. While it's not documented anywhere and they shouldn't be doing + # it, Chrome and curl recognize a lone CR as a line end. According to RFC, + # clients SHOULD interpret LF as a line end for robustness, and CRLF is the + # specced line end. + # + # There are three different tests because there are three ways to set header + # content in Puma. Regular (rack env), early hints, and a special case for + # overriding content-length. + {"cr" => "\r", "lf" => "\n", "crlf" => "\r\n"}.each do |suffix, line_ending| + # The cr-only case for the following test was CVE-2020-5247 + define_method("test_prevent_response_splitting_headers_#{suffix}") do + app = ->(_) { [200, {'X-header' => "untrusted input#{line_ending}Cookie: hack"}, ["Hello"]] } + assert_does_not_allow_http_injection(app) + end + + define_method("test_prevent_response_splitting_headers_early_hint_#{suffix}") do + app = ->(env) do + env['rack.early_hints'].call("X-header" => "untrusted input#{line_ending}Cookie: hack") + [200, {}, ["Hello"]] + end + assert_does_not_allow_http_injection(app, early_hints: true) + end + + define_method("test_prevent_content_length_injection_#{suffix}") do + app = ->(_) { [200, {'content-length' => "untrusted input#{line_ending}Cookie: hack"}, ["Hello"]] } + assert_does_not_allow_http_injection(app) + end + end + + # Perform a server shutdown while requests are pending (one in app-server response, one still sending client request). + def shutdown_requests(s1_complete: true, s1_response: nil, post: false, s2_response: nil, **options) + mutex = Mutex.new + app_finished = ConditionVariable.new + server_run(**options) { |env| + path = env['REQUEST_PATH'] + mutex.synchronize do + app_finished.signal + app_finished.wait(mutex) if path == '/s1' + end + [204, {}, []] + } + + pool = @server.instance_variable_get(:@thread_pool) + + # Trigger potential race condition by pausing Reactor#add until shutdown begins. + if options.fetch(:queue_requests, true) + reactor = @server.instance_variable_get(:@reactor) + reactor.instance_variable_set(:@pool, pool) + reactor.extend(Module.new do + def add(client) + if client.env['REQUEST_PATH'] == '/s2' + Thread.pass until @pool.instance_variable_get(:@shutdown) + end + super + end + end) + end + + s1 = nil + s2 = send_http post ? + "POST /s2 HTTP/1.1\r\nHost: test.com\r\nContent-Type: text/plain\r\nContent-Length: 5\r\n\r\nhi!" : + "GET /s2 HTTP/1.1\r\n" + mutex.synchronize do + s1 = send_http "GET /s1 HTTP/1.1\r\n\r\n" + app_finished.wait(mutex) + app_finished.signal if s1_complete + end + @server.stop + Thread.pass until pool.instance_variable_get(:@shutdown) + + assert_match(s1_response, s1.gets) if s1_response + + # Send s2 after shutdown begins + s2 << "\r\n" unless IO.select([s2], nil, nil, 0.2) + + assert IO.select([s2], nil, nil, 10), 'timeout waiting for response' + s2_result = begin + s2.gets + rescue Errno::ECONNABORTED, Errno::ECONNRESET + # Some platforms raise errors instead of returning a response/EOF when a TCP connection is aborted. + post ? '408' : nil + end + + if s2_response + assert_match s2_response, s2_result + else + assert_nil s2_result + end + end + + # Shutdown should allow pending requests and app-responses to complete. + def test_shutdown_requests + opts = {s1_response: /204/, s2_response: /204/} + shutdown_requests(**opts) + shutdown_requests(**opts, queue_requests: false) + end + + # Requests still pending after `force_shutdown_after` should have connection closed (408 w/pending POST body). + # App-responses still pending should return 503 (uncaught Puma::ThreadPool::ForceShutdown exception). + def test_force_shutdown + opts = {s1_complete: false, s1_response: /503/, s2_response: nil, force_shutdown_after: 0} + shutdown_requests(**opts) + shutdown_requests(**opts, queue_requests: false) + shutdown_requests(**opts, post: true, s2_response: /408/) + end + + def test_http11_connection_header_queue + server_run { [200, {}, [""]] } + + sock = send_http "GET / HTTP/1.1\r\n\r\n" + assert_equal ["HTTP/1.1 200 OK", "Content-Length: 0"], header(sock) + + sock << "GET / HTTP/1.1\r\nConnection: close\r\n\r\n" + assert_equal ["HTTP/1.1 200 OK", "Connection: close", "Content-Length: 0"], header(sock) + + sock.close + end + + def test_http10_connection_header_queue + server_run { [200, {}, [""]] } + + sock = send_http "GET / HTTP/1.0\r\nConnection: keep-alive\r\n\r\n" + assert_equal ["HTTP/1.0 200 OK", "Connection: Keep-Alive", "Content-Length: 0"], header(sock) + + sock << "GET / HTTP/1.0\r\n\r\n" + assert_equal ["HTTP/1.0 200 OK", "Content-Length: 0"], header(sock) + sock.close + end + + def test_http11_connection_header_no_queue + server_run(queue_requests: false) { [200, {}, [""]] } + sock = send_http "GET / HTTP/1.1\r\n\r\n" + assert_equal ["HTTP/1.1 200 OK", "Connection: close", "Content-Length: 0"], header(sock) + sock.close + end + + def test_http10_connection_header_no_queue + server_run(queue_requests: false) { [200, {}, [""]] } + sock = send_http "GET / HTTP/1.0\r\n\r\n" + assert_equal ["HTTP/1.0 200 OK", "Content-Length: 0"], header(sock) + sock.close + end + + def stub_accept_nonblock(error) + @port = (@server.add_tcp_listener @host, 0).addr[1] + io = @server.binder.ios.last + accept_old = io.method(:accept_nonblock) + accept_stub = -> do + accept_old.call.close + raise error + end + io.stub(:accept_nonblock, accept_stub) do + @server.run + new_connection + sleep 0.01 + end + end + + # System-resource errors such as EMFILE should not be silently swallowed by accept loop. + def test_accept_emfile + stub_accept_nonblock Errno::EMFILE.new('accept(2)') + refute_empty @events.stderr.string, "Expected EMFILE error not logged" + end + + # Retryable errors such as ECONNABORTED should be silently swallowed by accept loop. + def test_accept_econnaborted + # Match Ruby #accept_nonblock implementation, ECONNABORTED error is extended by IO::WaitReadable. + error = Errno::ECONNABORTED.new('accept(2) would block').tap {|e| e.extend IO::WaitReadable} + stub_accept_nonblock(error) + assert_empty @events.stderr.string + end + + # see https://github.com/puma/puma/issues/2390 + # fixed by https://github.com/puma/puma/pull/2279 + # + def test_client_quick_close_no_lowlevel_error_handler_call + handler = ->(err, env, status) { + @events.stdout.write "LLEH #{err.message}" + [500, {"Content-Type" => "application/json"}, ["{}\n"]] + } + + server_run(lowlevel_error_handler: handler) { [200, {}, ['Hello World']] } + + # valid req & read, close + sock = TCPSocket.new @host, @port + sock.syswrite "GET / HTTP/1.0\r\n\r\n" + sleep 0.05 # macOS TruffleRuby may not get the body without + resp = sock.sysread 256 + sock.close + assert_match 'Hello World', resp + sleep 0.5 + assert_empty @events.stdout.string + + # valid req, close + sock = TCPSocket.new @host, @port + sock.syswrite "GET / HTTP/1.0\r\n\r\n" + sock.close + sleep 0.5 + assert_empty @events.stdout.string + + # invalid req, close + sock = TCPSocket.new @host, @port + sock.syswrite "GET / HTTP" + sock.close + sleep 0.5 + assert_empty @events.stdout.string + end + + def test_idle_connections_closed_immediately_on_shutdown + server_run + sock = new_connection + sleep 0.5 # give enough time for new connection to enter reactor + @server.stop false + + assert IO.select([sock], nil, nil, 1), 'Unexpected timeout' + assert_raises EOFError do + sock.read_nonblock(256) + end + end + + def test_run_stop_thread_safety + 100.times do + thread = @server.run + @server.stop + assert thread.join(1) + end + end + + def test_command_ignored_before_run + @server.stop # ignored + @server.run + @server.halt + done = Queue.new + @server.events.register(:state) do |state| + done << @server.instance_variable_get(:@status) if state == :done + end + assert_equal :halt, done.pop + end + + def test_custom_io_selector + backend = NIO::Selector.backends.first + + @server = Puma::Server.new @app, @events, {:io_selector_backend => backend} + @server.run + + selector = @server.instance_variable_get(:@reactor).instance_variable_get(:@selector) + + assert_equal selector.backend, backend + end + + def test_drain_on_shutdown(drain=true) + num_connections = 10 + + wait = Queue.new + server_run(drain_on_shutdown: drain, max_threads: 1) do + wait.pop + [200, {}, ["DONE"]] + end + connections = Array.new(num_connections) {send_http "GET / HTTP/1.0\r\n\r\n"} + @server.stop + wait.close + bad = 0 + connections.each do |s| + begin + assert_match 'DONE', s.read + rescue Errno::ECONNRESET + bad += 1 + end + end + if drain + assert_equal 0, bad + else + refute_equal 0, bad + end + end + + def test_not_drain_on_shutdown + test_drain_on_shutdown false + end + + def test_rack_url_scheme_dflt + server_run + + data = send_http_and_read "GET / HTTP/1.0\r\n\r\n" + assert_equal "http", data.split("\r\n").last + end + + def test_rack_url_scheme_user + @port = UniquePort.call + opts = { rack_url_scheme: 'user', binds: ["tcp://#{@host}:#{@port}"] } + conf = Puma::Configuration.new(opts).tap(&:clamp) + @server = Puma::Server.new @app, @events, conf.options + @server.inherit_binder Puma::Binder.new(@events, conf) + @server.binder.parse conf.options[:binds], @events + @server.run + + data = send_http_and_read "GET / HTTP/1.0\r\n\r\n" + assert_equal "user", data.split("\r\n").last + end +end diff --git a/test/test_puma_server_ssl.rb b/test/test_puma_server_ssl.rb new file mode 100644 index 0000000..58fd650 --- /dev/null +++ b/test/test_puma_server_ssl.rb @@ -0,0 +1,375 @@ +# Nothing in this file runs if Puma isn't compiled with ssl support +# +# helper is required first since it loads Puma, which needs to be +# loaded so HAS_SSL is defined +require_relative "helper" + +if ::Puma::HAS_SSL + require "puma/minissl" + require "net/http" + + # net/http (loaded in helper) does not necessarily load OpenSSL + require "openssl" unless Object.const_defined? :OpenSSL + if Puma::IS_JRUBY + puts "", RUBY_DESCRIPTION, "RUBYOPT: #{ENV['RUBYOPT']}", + " OpenSSL", + "OPENSSL_LIBRARY_VERSION: #{OpenSSL::OPENSSL_LIBRARY_VERSION}", + " OPENSSL_VERSION: #{OpenSSL::OPENSSL_VERSION}", "" + else + puts "", RUBY_DESCRIPTION, "RUBYOPT: #{ENV['RUBYOPT']}", + " Puma::MiniSSL OpenSSL", + "OPENSSL_LIBRARY_VERSION: #{Puma::MiniSSL::OPENSSL_LIBRARY_VERSION.ljust 32}#{OpenSSL::OPENSSL_LIBRARY_VERSION}", + " OPENSSL_VERSION: #{Puma::MiniSSL::OPENSSL_VERSION.ljust 32}#{OpenSSL::OPENSSL_VERSION}", "" + end +end + +class TestPumaServerSSL < Minitest::Test + parallelize_me! + def setup + @http = nil + @server = nil + end + + def teardown + @http.finish if @http && @http.started? + @server.stop(true) if @server + end + + # yields ctx to block, use for ctx setup & configuration + def start_server + @host = "127.0.0.1" + + app = lambda { |env| [200, {}, [env['rack.url_scheme']]] } + + ctx = Puma::MiniSSL::Context.new + + if Puma.jruby? + ctx.keystore = File.expand_path "../examples/puma/keystore.jks", __dir__ + ctx.keystore_pass = 'jruby_puma' + else + ctx.key = File.expand_path "../examples/puma/puma_keypair.pem", __dir__ + ctx.cert = File.expand_path "../examples/puma/cert_puma.pem", __dir__ + end + + ctx.verify_mode = Puma::MiniSSL::VERIFY_NONE + + yield ctx if block_given? + + @events = SSLEventsHelper.new STDOUT, STDERR + @server = Puma::Server.new app, @events + @port = (@server.add_ssl_listener @host, 0, ctx).addr[1] + @server.run + + @http = Net::HTTP.new @host, @port + @http.use_ssl = true + @http.verify_mode = OpenSSL::SSL::VERIFY_NONE + end + + def test_url_scheme_for_https + start_server + body = nil + @http.start do + req = Net::HTTP::Get.new "/", {} + + @http.request(req) do |rep| + body = rep.body + end + end + + assert_equal "https", body + end + + def test_request_wont_block_thread + start_server + # Open a connection and give enough data to trigger a read, then wait + ctx = OpenSSL::SSL::SSLContext.new + ctx.verify_mode = OpenSSL::SSL::VERIFY_NONE + port = @server.connected_ports[0] + socket = OpenSSL::SSL::SSLSocket.new TCPSocket.new(@host, port), ctx + socket.connect + socket.write "HEAD" + sleep 0.1 + + # Capture the amount of threads being used after connecting and being idle + thread_pool = @server.instance_variable_get(:@thread_pool) + busy_threads = thread_pool.spawned - thread_pool.waiting + + socket.close + + # The thread pool should be empty since the request would block on read + # and our request should have been moved to the reactor. + assert busy_threads.zero?, "Our connection is monopolizing a thread" + end + + def test_very_large_return + start_server + giant = "x" * 2056610 + + @server.app = proc do + [200, {}, [giant]] + end + + body = nil + @http.start do + req = Net::HTTP::Get.new "/" + @http.request(req) do |rep| + body = rep.body + end + end + + assert_equal giant.bytesize, body.bytesize + end + + def test_form_submit + start_server + body = nil + @http.start do + req = Net::HTTP::Post.new '/' + req.set_form_data('a' => '1', 'b' => '2') + + @http.request(req) do |rep| + body = rep.body + end + + end + + assert_equal "https", body + end + + def test_ssl_v3_rejection + skip("SSLv3 protocol is unavailable") if Puma::MiniSSL::OPENSSL_NO_SSL3 + start_server + @http.ssl_version= :SSLv3 + # Ruby 2.4.5 on Travis raises ArgumentError + assert_raises(OpenSSL::SSL::SSLError, ArgumentError) do + @http.start do + Net::HTTP::Get.new '/' + end + end + unless Puma.jruby? + msg = /wrong version number|no protocols available|version too low|unknown SSL method/ + assert_match(msg, @events.error.message) if @events.error + end + end + + def test_tls_v1_rejection + skip("TLSv1 protocol is unavailable") if Puma::MiniSSL::OPENSSL_NO_TLS1 + start_server { |ctx| ctx.no_tlsv1 = true } + + if OpenSSL::SSL::SSLContext.private_instance_methods(false).include?(:set_minmax_proto_version) + @http.max_version = :TLS1 + else + @http.ssl_version = :TLSv1 + end + # Ruby 2.4.5 on Travis raises ArgumentError + assert_raises(OpenSSL::SSL::SSLError, ArgumentError) do + @http.start do + Net::HTTP::Get.new '/' + end + end + unless Puma.jruby? + msg = /wrong version number|(unknown|unsupported) protocol|no protocols available|version too low|unknown SSL method/ + assert_match(msg, @events.error.message) if @events.error + end + end + + def test_tls_v1_1_rejection + start_server { |ctx| ctx.no_tlsv1_1 = true } + + if OpenSSL::SSL::SSLContext.private_instance_methods(false).include?(:set_minmax_proto_version) + @http.max_version = :TLS1_1 + else + @http.ssl_version = :TLSv1_1 + end + # Ruby 2.4.5 on Travis raises ArgumentError + assert_raises(OpenSSL::SSL::SSLError, ArgumentError) do + @http.start do + Net::HTTP::Get.new '/' + end + end + unless Puma.jruby? + msg = /wrong version number|(unknown|unsupported) protocol|no protocols available|version too low|unknown SSL method/ + assert_match(msg, @events.error.message) if @events.error + end + end + + def test_http_rejection + body_http = nil + body_https = nil + + start_server + + http = Net::HTTP.new @host, @server.connected_ports[0] + http.use_ssl = false + http.read_timeout = 6 + + tcp = Thread.new do + req_http = Net::HTTP::Get.new "/", {} + # Net::ReadTimeout - TruffleRuby + assert_raises(Errno::ECONNREFUSED, EOFError, Net::ReadTimeout, Net::OpenTimeout) do + http.start.request(req_http) { |rep| body_http = rep.body } + end + end + + ssl = Thread.new do + @http.start do + req_https = Net::HTTP::Get.new "/", {} + @http.request(req_https) { |rep_https| body_https = rep_https.body } + end + end + + tcp.join + ssl.join + http.finish + sleep 1.0 + + assert_nil body_http + assert_equal "https", body_https + + thread_pool = @server.instance_variable_get(:@thread_pool) + busy_threads = thread_pool.spawned - thread_pool.waiting + + assert busy_threads.zero?, "Our connection is wasn't dropped" + end +end if ::Puma::HAS_SSL + +# client-side TLS authentication tests +class TestPumaServerSSLClient < Minitest::Test + parallelize_me! unless ::Puma.jruby? + + CERT_PATH = File.expand_path "../examples/puma/client-certs", __dir__ + + # Context can be shared, may help with JRuby + CTX = Puma::MiniSSL::Context.new.tap { |ctx| + if Puma.jruby? + ctx.keystore = "#{CERT_PATH}/keystore.jks" + ctx.keystore_pass = 'jruby_puma' + else + ctx.key = "#{CERT_PATH}/server.key" + ctx.cert = "#{CERT_PATH}/server.crt" + ctx.ca = "#{CERT_PATH}/ca.crt" + end + ctx.verify_mode = Puma::MiniSSL::VERIFY_PEER | Puma::MiniSSL::VERIFY_FAIL_IF_NO_PEER_CERT + } + + def assert_ssl_client_error_match(error, subject=nil, &blk) + host = "localhost" + port = 0 + + app = lambda { |env| [200, {}, [env['rack.url_scheme']]] } + + events = SSLEventsHelper.new STDOUT, STDERR + server = Puma::Server.new app, events + server.add_ssl_listener host, port, CTX + host_addrs = server.binder.ios.map { |io| io.to_io.addr[2] } + server.run + + http = Net::HTTP.new host, server.connected_ports[0] + http.use_ssl = true + http.verify_mode = OpenSSL::SSL::VERIFY_NONE + + yield http + + client_error = false + begin + http.start do + req = Net::HTTP::Get.new "/", {} + http.request(req) + end + rescue OpenSSL::SSL::SSLError, EOFError, Errno::ECONNRESET + # Errno::ECONNRESET TruffleRuby + client_error = true + # closes socket if open, may not close on error + http.send :do_finish + end + + sleep 0.1 + assert_equal !!error, client_error + # The JRuby MiniSSL implementation lacks error capturing currently, + # so we can't inspect the messages here + unless Puma.jruby? + assert_match error, events.error.message if error + assert_includes host_addrs, events.addr if error + assert_equal subject, events.cert.subject.to_s if subject + end + ensure + server.stop(true) if server + end + + def test_verify_fail_if_no_client_cert + assert_ssl_client_error_match 'peer did not return a certificate' do |http| + # nothing + end + end + + def test_verify_fail_if_client_unknown_ca + assert_ssl_client_error_match(/self[- ]signed certificate in certificate chain/, '/DC=net/DC=puma/CN=CAU') do |http| + key = "#{CERT_PATH}/client_unknown.key" + crt = "#{CERT_PATH}/client_unknown.crt" + http.key = OpenSSL::PKey::RSA.new File.read(key) + http.cert = OpenSSL::X509::Certificate.new File.read(crt) + http.ca_file = "#{CERT_PATH}/unknown_ca.crt" + end + end + + def test_verify_fail_if_client_expired_cert + assert_ssl_client_error_match('certificate has expired', '/DC=net/DC=puma/CN=localhost') do |http| + key = "#{CERT_PATH}/client_expired.key" + crt = "#{CERT_PATH}/client_expired.crt" + http.key = OpenSSL::PKey::RSA.new File.read(key) + http.cert = OpenSSL::X509::Certificate.new File.read(crt) + http.ca_file = "#{CERT_PATH}/ca.crt" + end + end + + def test_verify_client_cert + assert_ssl_client_error_match(nil) do |http| + key = "#{CERT_PATH}/client.key" + crt = "#{CERT_PATH}/client.crt" + http.key = OpenSSL::PKey::RSA.new File.read(key) + http.cert = OpenSSL::X509::Certificate.new File.read(crt) + http.ca_file = "#{CERT_PATH}/ca.crt" + http.verify_mode = OpenSSL::SSL::VERIFY_PEER + end + end +end if ::Puma::HAS_SSL + +class TestPumaServerSSLWithCertPemAndKeyPem < Minitest::Test + CERT_PATH = File.expand_path "../examples/puma/client-certs", __dir__ + + def test_server_ssl_with_cert_pem_and_key_pem + host = "localhost" + port = 0 + ctx = Puma::MiniSSL::Context.new.tap { |ctx| + ctx.cert_pem = File.read("#{CERT_PATH}/server.crt") + ctx.key_pem = File.read("#{CERT_PATH}/server.key") + } + + app = lambda { |env| [200, {}, [env['rack.url_scheme']]] } + events = SSLEventsHelper.new STDOUT, STDERR + server = Puma::Server.new app, events + server.add_ssl_listener host, port, ctx + server.run + + http = Net::HTTP.new host, server.connected_ports[0] + http.use_ssl = true + http.ca_file = "#{CERT_PATH}/ca.crt" + + client_error = nil + begin + http.start do + req = Net::HTTP::Get.new "/", {} + http.request(req) + end + rescue OpenSSL::SSL::SSLError, EOFError, Errno::ECONNRESET => e + # Errno::ECONNRESET TruffleRuby + client_error = e + # closes socket if open, may not close on error + http.send :do_finish + end + + assert_nil client_error + ensure + server.stop(true) if server + end +end if ::Puma::HAS_SSL && !Puma::IS_JRUBY diff --git a/test/test_pumactl.rb b/test/test_pumactl.rb new file mode 100644 index 0000000..156b647 --- /dev/null +++ b/test/test_pumactl.rb @@ -0,0 +1,264 @@ +require_relative "helper" +require_relative "helpers/config_file" +require_relative "helpers/ssl" + +require 'pathname' +require 'puma/control_cli' + +class TestPumaControlCli < TestConfigFileBase + include SSLHelper + + def setup + # use a pipe to get info across thread boundary + @wait, @ready = IO.pipe + end + + def wait_booted + line = @wait.gets until line =~ /Use Ctrl-C to stop/ + end + + def teardown + @wait.close + @ready.close + end + + def with_config_file(path_to_config, port) + path = Pathname.new(path_to_config) + Dir.mktmpdir do |tmp_dir| + Dir.chdir(tmp_dir) do + FileUtils.mkdir_p(path.dirname) + File.open(path, "w") { |f| f << "port #{port}" } + yield + end + end + end + + def test_blank_command + assert_system_exit_with_cli_output [], "Available commands: #{Puma::ControlCLI::CMD_PATH_SIG_MAP.keys.join(", ")}" + end + + def test_invalid_command + assert_system_exit_with_cli_output ['an-invalid-command'], 'Invalid command: an-invalid-command' + end + + def test_config_file + control_cli = Puma::ControlCLI.new ["--config-file", "test/config/state_file_testing_config.rb", "halt"] + assert_equal "t3-pid", control_cli.instance_variable_get("@pidfile") + File.unlink "t3-pid" if File.file? "t3-pid" + end + + def test_app_env_without_environment + with_env('APP_ENV' => 'test') do + control_cli = Puma::ControlCLI.new ['halt'] + assert_equal 'test', control_cli.instance_variable_get('@environment') + end + end + + def test_rack_env_without_environment + with_env("RACK_ENV" => "test") do + control_cli = Puma::ControlCLI.new ["halt"] + assert_equal "test", control_cli.instance_variable_get("@environment") + end + end + + def test_app_env_precedence + with_env('APP_ENV' => nil, 'RACK_ENV' => nil, 'RAILS_ENV' => 'production') do + control_cli = Puma::ControlCLI.new ['halt'] + assert_equal 'production', control_cli.instance_variable_get('@environment') + end + + with_env('APP_ENV' => nil, 'RACK_ENV' => 'test', 'RAILS_ENV' => 'production') do + control_cli = Puma::ControlCLI.new ['halt'] + assert_equal 'test', control_cli.instance_variable_get('@environment') + end + + with_env('APP_ENV' => 'development', 'RACK_ENV' => 'test', 'RAILS_ENV' => 'production') do + control_cli = Puma::ControlCLI.new ['halt'] + assert_equal 'development', control_cli.instance_variable_get('@environment') + + control_cli = Puma::ControlCLI.new ['-e', 'test', 'halt'] + assert_equal 'test', control_cli.instance_variable_get('@environment') + end + end + + def test_environment_without_app_env + with_env('APP_ENV' => nil, 'RACK_ENV' => nil, 'RAILS_ENV' => nil) do + control_cli = Puma::ControlCLI.new ['halt'] + assert_nil control_cli.instance_variable_get('@environment') + + control_cli = Puma::ControlCLI.new ['-e', 'test', 'halt'] + assert_equal 'test', control_cli.instance_variable_get('@environment') + end + end + + def test_environment_without_rack_env + with_env("RACK_ENV" => nil, 'RAILS_ENV' => nil) do + control_cli = Puma::ControlCLI.new ["halt"] + assert_nil control_cli.instance_variable_get("@environment") + + control_cli = Puma::ControlCLI.new ["-e", "test", "halt"] + assert_equal "test", control_cli.instance_variable_get("@environment") + end + end + + def test_environment_with_rack_env + with_env("RACK_ENV" => "production") do + control_cli = Puma::ControlCLI.new ["halt"] + assert_equal "production", control_cli.instance_variable_get("@environment") + + control_cli = Puma::ControlCLI.new ["-e", "test", "halt"] + assert_equal "test", control_cli.instance_variable_get("@environment") + end + end + + def test_environment_specific_config_file_exist + port = 6002 + puma_config_file = "config/puma.rb" + production_config_file = "config/puma/production.rb" + + with_env("RACK_ENV" => nil) do + with_config_file(puma_config_file, port) do + control_cli = Puma::ControlCLI.new ["-e", "production", "halt"] + assert_equal puma_config_file, control_cli.instance_variable_get("@config_file") + end + + with_config_file(production_config_file, port) do + control_cli = Puma::ControlCLI.new ["-e", "production", "halt"] + assert_equal production_config_file, control_cli.instance_variable_get("@config_file") + end + end + end + + def test_default_config_file_exist + port = 6001 + puma_config_file = "config/puma.rb" + development_config_file = "config/puma/development.rb" + + with_env("RACK_ENV" => nil, 'RAILS_ENV' => nil) do + with_config_file(puma_config_file, port) do + control_cli = Puma::ControlCLI.new ["halt"] + assert_equal puma_config_file, control_cli.instance_variable_get("@config_file") + end + + with_config_file(development_config_file, port) do + control_cli = Puma::ControlCLI.new ["halt"] + assert_equal development_config_file, control_cli.instance_variable_get("@config_file") + end + end + end + + def test_control_no_token + opts = [ + "--config-file", "test/config/control_no_token.rb", + "start" + ] + + control_cli = Puma::ControlCLI.new opts, @ready, @ready + assert_equal 'none', control_cli.instance_variable_get("@control_auth_token") + end + + def test_control_url_and_status + host = "127.0.0.1" + port = UniquePort.call + url = "tcp://#{host}:#{port}/" + + opts = [ + "--control-url", url, + "--control-token", "ctrl", + "--config-file", "test/config/app.rb", + ] + + control_cli = Puma::ControlCLI.new (opts + ["start"]), @ready, @ready + t = Thread.new do + control_cli.run + end + + wait_booted + + s = TCPSocket.new host, 9292 + s << "GET / HTTP/1.0\r\n\r\n" + body = s.read + assert_match "200 OK", body + assert_match "embedded app", body + + assert_command_cli_output opts + ["status"], "Puma is started" + assert_command_cli_output opts + ["stop"], "Command stop sent success" + + assert_kind_of Thread, t.join, "server didn't stop" + end + + def test_control_ssl + skip_unless :ssl + + host = "127.0.0.1" + port = UniquePort.call + url = "ssl://#{host}:#{port}?#{ssl_query}" + + opts = [ + "--control-url", url, + "--control-token", "ctrl", + "--config-file", "test/config/app.rb", + ] + + control_cli = Puma::ControlCLI.new (opts + ["start"]), @ready, @ready + t = Thread.new do + control_cli.run + end + + wait_booted + + assert_command_cli_output opts + ["status"], "Puma is started" + assert_command_cli_output opts + ["stop"], "Command stop sent success" + + assert_kind_of Thread, t.join, "server didn't stop" + end + + def test_control_aunix + skip_unless :aunix + + url = "unix://@test_control_aunix.unix" + + opts = [ + "--control-url", url, + "--control-token", "ctrl", + "--config-file", "test/config/app.rb", + ] + + control_cli = Puma::ControlCLI.new (opts + ["start"]), @ready, @ready + t = Thread.new do + control_cli.run + end + + wait_booted + + assert_command_cli_output opts + ["status"], "Puma is started" + assert_command_cli_output opts + ["stop"], "Command stop sent success" + + assert_kind_of Thread, t.join, "server didn't stop" + end + + private + + def assert_command_cli_output(options, expected_out) + cmd = Puma::ControlCLI.new(options) + out, _ = capture_subprocess_io do + begin + cmd.run + rescue SystemExit + end + end + assert_match expected_out, out + end + + def assert_system_exit_with_cli_output(options, expected_out) + out, _ = capture_subprocess_io do + response = assert_raises(SystemExit) do + Puma::ControlCLI.new(options).run + end + + assert_equal(response.status, 1) + end + + assert_match expected_out, out + end +end diff --git a/test/test_rack_handler.rb b/test/test_rack_handler.rb new file mode 100644 index 0000000..12c98bd --- /dev/null +++ b/test/test_rack_handler.rb @@ -0,0 +1,270 @@ +require_relative "helper" + +require "rack/handler/puma" + +class TestHandlerGetStrSym < Minitest::Test + def test_handler + handler = Rack::Handler.get(:puma) + assert_equal Rack::Handler::Puma, handler + handler = Rack::Handler.get('Puma') + assert_equal Rack::Handler::Puma, handler + end +end + +class TestPathHandler < Minitest::Test + def app + Proc.new {|env| @input = env; [200, {}, ["hello world"]]} + end + + def setup + @input = nil + end + + def in_handler(app, options = {}) + options[:Port] ||= 0 + options[:Silent] = true + + @launcher = nil + thread = Thread.new do + Rack::Handler::Puma.run(app, **options) do |s, p| + @launcher = s + end + end + + # Wait for launcher to boot + Timeout.timeout(10) do + sleep 0.5 until @launcher + end + sleep 1.5 unless Puma::IS_MRI + + yield @launcher + ensure + @launcher.stop if @launcher + thread.join if thread + end + + def test_handler_boots + host = '127.0.0.1' + port = UniquePort.call + opts = { Host: host, Port: port } + in_handler(app, opts) do |launcher| + hit(["http://#{host}:#{port}/test"]) + assert_equal("/test", @input["PATH_INFO"]) + end + end +end + +class TestUserSuppliedOptionsPortIsSet < Minitest::Test + def setup + @options = {} + @options[:user_supplied_options] = [:Port] + end + + def test_port_wins_over_config + user_port = 5001 + file_port = 6001 + + Dir.mktmpdir do |d| + Dir.chdir(d) do + FileUtils.mkdir("config") + File.open("config/puma.rb", "w") { |f| f << "port #{file_port}" } + + @options[:Port] = user_port + conf = Rack::Handler::Puma.config(->{}, @options) + conf.load + + assert_equal ["tcp://0.0.0.0:#{user_port}"], conf.options[:binds] + end + end + end +end + +class TestUserSuppliedOptionsHostIsSet < Minitest::Test + def setup + @options = {} + @options[:user_supplied_options] = [:Host] + end + + def test_host_uses_supplied_port_default + user_port = rand(1000..9999) + user_host = "123.456.789" + + @options[:Host] = user_host + @options[:Port] = user_port + conf = Rack::Handler::Puma.config(->{}, @options) + conf.load + + assert_equal ["tcp://#{user_host}:#{user_port}"], conf.options[:binds] + end + + def test_ipv6_host_supplied_port_default + @options[:Host] = "::1" + conf = Rack::Handler::Puma.config(->{}, @options) + conf.load + + assert_equal ["tcp://[::1]:9292"], conf.options[:binds] + end +end + +class TestUserSuppliedOptionsIsEmpty < Minitest::Test + def setup + @options = {} + @options[:user_supplied_options] = [] + end + + def test_config_file_wins_over_port + user_port = 5001 + file_port = 6001 + + Dir.mktmpdir do |d| + Dir.chdir(d) do + FileUtils.mkdir("config") + File.open("config/puma.rb", "w") { |f| f << "port #{file_port}" } + + @options[:Port] = user_port + conf = Rack::Handler::Puma.config(->{}, @options) + conf.load + + assert_equal ["tcp://0.0.0.0:#{file_port}"], conf.options[:binds] + end + end + end + + def test_default_host_when_using_config_file + user_port = 5001 + file_port = 6001 + + Dir.mktmpdir do |d| + Dir.chdir(d) do + FileUtils.mkdir("config") + File.open("config/puma.rb", "w") { |f| f << "port #{file_port}" } + + @options[:Host] = "localhost" + @options[:Port] = user_port + conf = Rack::Handler::Puma.config(->{}, @options) + conf.load + + assert_equal ["tcp://localhost:#{file_port}"], conf.options[:binds] + end + end + end + + def test_default_host_when_using_config_file_with_explicit_host + user_port = 5001 + file_port = 6001 + + Dir.mktmpdir do |d| + Dir.chdir(d) do + FileUtils.mkdir("config") + File.open("config/puma.rb", "w") { |f| f << "port #{file_port}, '1.2.3.4'" } + + @options[:Host] = "localhost" + @options[:Port] = user_port + conf = Rack::Handler::Puma.config(->{}, @options) + conf.load + + assert_equal ["tcp://1.2.3.4:#{file_port}"], conf.options[:binds] + end + end + end +end + +class TestUserSuppliedOptionsIsNotPresent < Minitest::Test + def setup + @options = {} + end + + def test_default_port_when_no_config_file + conf = Rack::Handler::Puma.config(->{}, @options) + conf.load + + assert_equal ["tcp://0.0.0.0:9292"], conf.options[:binds] + end + + def test_config_wins_over_default + file_port = 6001 + + Dir.mktmpdir do |d| + Dir.chdir(d) do + FileUtils.mkdir("config") + File.open("config/puma.rb", "w") { |f| f << "port #{file_port}" } + + conf = Rack::Handler::Puma.config(->{}, @options) + conf.load + + assert_equal ["tcp://0.0.0.0:#{file_port}"], conf.options[:binds] + end + end + end + + def test_user_port_wins_over_default_when_user_supplied_is_blank + user_port = 5001 + @options[:user_supplied_options] = [] + @options[:Port] = user_port + conf = Rack::Handler::Puma.config(->{}, @options) + conf.load + + assert_equal ["tcp://0.0.0.0:#{user_port}"], conf.options[:binds] + end + + def test_user_port_wins_over_default + user_port = 5001 + @options[:Port] = user_port + conf = Rack::Handler::Puma.config(->{}, @options) + conf.load + + assert_equal ["tcp://0.0.0.0:#{user_port}"], conf.options[:binds] + end + + def test_user_port_wins_over_config + user_port = 5001 + file_port = 6001 + + Dir.mktmpdir do |d| + Dir.chdir(d) do + FileUtils.mkdir("config") + File.open("config/puma.rb", "w") { |f| f << "port #{file_port}" } + + @options[:Port] = user_port + conf = Rack::Handler::Puma.config(->{}, @options) + conf.load + + assert_equal ["tcp://0.0.0.0:#{user_port}"], conf.options[:binds] + end + end + end + + def test_default_log_request_when_no_config_file + conf = Rack::Handler::Puma.config(->{}, @options) + conf.load + + assert_equal false, conf.options[:log_requests] + end + + def test_file_log_requests_wins_over_default_config + file_log_requests_config = true + + @options[:config_files] = [ + 'test/config/t1_conf.rb' + ] + + conf = Rack::Handler::Puma.config(->{}, @options) + conf.load + + assert_equal file_log_requests_config, conf.options[:log_requests] + end + + def test_user_log_requests_wins_over_file_config + user_log_requests_config = false + + @options[:log_requests] = user_log_requests_config + @options[:config_files] = [ + 'test/config/t1_conf.rb' + ] + + conf = Rack::Handler::Puma.config(->{}, @options) + conf.load + + assert_equal user_log_requests_config, conf.options[:log_requests] + end +end diff --git a/test/test_rack_server.rb b/test/test_rack_server.rb new file mode 100644 index 0000000..0d2e5a3 --- /dev/null +++ b/test/test_rack_server.rb @@ -0,0 +1,126 @@ +# frozen_string_literal: true +require_relative "helper" +require "net/http" + +require "rack" + +class TestRackServer < Minitest::Test + parallelize_me! + + class ErrorChecker + def initialize(app) + @app = app + @exception = nil + end + + attr_reader :exception, :env + + def call(env) + begin + @app.call(env) + rescue Exception => e + @exception = e + [ 500, {}, ["Error detected"] ] + end + end + end + + class ServerLint < Rack::Lint + def call(env) + check_env env + + @app.call(env) + end + end + + def setup + @simple = lambda { |env| [200, { "X-Header" => "Works" }, ["Hello"]] } + @server = Puma::Server.new @simple + port = (@server.add_tcp_listener "127.0.0.1", 0).addr[1] + @tcp = "http://127.0.0.1:#{port}" + @stopped = false + end + + def stop + @server.stop(true) + @stopped = true + end + + def teardown + @server.stop(true) unless @stopped + end + + def test_lint + @checker = ErrorChecker.new ServerLint.new(@simple) + @server.app = @checker + + @server.run + + hit(["#{@tcp}/test"]) + + stop + + refute @checker.exception, "Checker raised exception" + end + + def test_large_post_body + @checker = ErrorChecker.new ServerLint.new(@simple) + @server.app = @checker + + @server.run + + big = "x" * (1024 * 16) + + Net::HTTP.post_form URI.parse("#{@tcp}/test"), + { "big" => big } + + stop + + refute @checker.exception, "Checker raised exception" + end + + def test_path_info + input = nil + @server.app = lambda { |env| input = env; @simple.call(env) } + @server.run + + hit(["#{@tcp}/test/a/b/c"]) + + stop + + assert_equal "/test/a/b/c", input['PATH_INFO'] + end + + def test_after_reply + closed = false + + @server.app = lambda do |env| + env['rack.after_reply'] << lambda { closed = true } + @simple.call(env) + end + + @server.run + + hit(["#{@tcp}/test"]) + + stop + + assert_equal true, closed + end + + def test_common_logger + log = StringIO.new + + logger = Rack::CommonLogger.new(@simple, log) + + @server.app = logger + + @server.run + + hit(["#{@tcp}/test"]) + + stop + + assert_match %r!GET /test HTTP/1\.1!, log.string + end +end diff --git a/test/test_redirect_io.rb b/test/test_redirect_io.rb new file mode 100644 index 0000000..2172fe1 --- /dev/null +++ b/test/test_redirect_io.rb @@ -0,0 +1,109 @@ +require_relative "helper" +require_relative "helpers/integration" + +class TestRedirectIO < TestIntegration + parallelize_me! + + def setup + skip_unless_signal_exist? :HUP + super + + # Keep the Tempfile instances alive to avoid being GC'd + @out_file = Tempfile.new('puma-out') + @err_file = Tempfile.new('puma-err') + @out_file_path = @out_file.path + @err_file_path = @err_file.path + end + + def teardown + return if skipped? + super + + paths = (skipped? ? [@out_file_path, @err_file_path] : + [@out_file_path, @err_file_path, @old_out_file_path, @old_err_file_path]).compact + + File.unlink(*paths) + @out_file = nil + @err_file = nil + end + + def test_sighup_redirects_io_single + skip_if :jruby # Server isn't coming up in CI, TODO Fix + + cli_args = [ + '--redirect-stdout', @out_file_path, + '--redirect-stderr', @err_file_path, + 'test/rackup/hello.ru' + ] + cli_server cli_args.join ' ' + + wait_until_file_has_content @out_file_path + assert_match 'puma startup', File.read(@out_file_path) + + wait_until_file_has_content @err_file_path + assert_match 'puma startup', File.read(@err_file_path) + + log_rotate_output_files + + Process.kill :HUP, @server.pid + + wait_until_file_has_content @out_file_path + assert_match 'puma startup', File.read(@out_file_path) + + wait_until_file_has_content @err_file_path + assert_match 'puma startup', File.read(@err_file_path) + end + + def test_sighup_redirects_io_cluster + skip_unless :fork + + cli_args = [ + '-w', '1', + '--redirect-stdout', @out_file_path, + '--redirect-stderr', @err_file_path, + 'test/rackup/hello.ru' + ] + cli_server cli_args.join ' ' + + wait_until_file_has_content @out_file_path + assert_match 'puma startup', File.read(@out_file_path) + + wait_until_file_has_content @err_file_path + assert_match 'puma startup', File.read(@err_file_path) + + log_rotate_output_files + + Process.kill :HUP, @server.pid + + wait_until_file_has_content @out_file_path + assert_match 'puma startup', File.read(@out_file_path) + + wait_until_file_has_content @err_file_path + assert_match 'puma startup', File.read(@err_file_path) + end + + private + + def log_rotate_output_files + # rename both files to .old + @old_out_file_path = "#{@out_file_path}.old" + @old_err_file_path = "#{@err_file_path}.old" + File.rename @out_file_path, @old_out_file_path + File.rename @err_file_path, @old_err_file_path + + File.new(@out_file_path, File::CREAT).close + File.new(@err_file_path, File::CREAT).close + end + + def wait_until_file_has_content(path) + File.open(path) do |file| + begin + file.read_nonblock 1 + file.seek 0 + rescue EOFError + sleep 0.1 + retry + end + end + end +end diff --git a/test/test_request_invalid.rb b/test/test_request_invalid.rb new file mode 100644 index 0000000..8f6c2ec --- /dev/null +++ b/test/test_request_invalid.rb @@ -0,0 +1,220 @@ +require_relative "helper" +require "puma/events" + +# These tests check for invalid request headers and metadata. +# Content-Length, Transfer-Encoding, and chunked body size +# values are checked for validity +# +# See https://datatracker.ietf.org/doc/html/rfc7230 +# +# https://datatracker.ietf.org/doc/html/rfc7230#section-3.3.2 Content-Length +# https://datatracker.ietf.org/doc/html/rfc7230#section-3.3.1 Transfer-Encoding +# https://datatracker.ietf.org/doc/html/rfc7230#section-4.1 chunked body size +# +class TestRequestInvalid < Minitest::Test + # running parallel seems to take longer... + # parallelize_me! unless JRUBY_HEAD + + GET_PREFIX = "GET / HTTP/1.1\r\nConnection: close\r\n" + CHUNKED = "1\r\nH\r\n4\r\nello\r\n5\r\nWorld\r\n0\r\n\r\n" + + def setup + @host = '127.0.0.1' + + @ios = [] + + # this app should never be called, used for debugging + app = ->(env) { + body = ''.dup + env.each do |k,v| + body << "#{k} = #{v}\n" + if k == 'rack.input' + body << "#{v.read}\n" + end + end + [200, {}, [body]] + } + + @log_writer = Puma::LogWriter.strings + events = Puma::Events.new + @server = Puma::Server.new app, @log_writer, events + @port = (@server.add_tcp_listener @host, 0).addr[1] + @server.run + sleep 0.15 if Puma.jruby? + end + + def teardown + @server.stop(true) + @ios.each { |io| io.close if io && !io.closed? } + end + + def send_http_and_read(req) + send_http(req).read + end + + def send_http(req) + new_connection << req + end + + def new_connection + TCPSocket.new(@host, @port).tap {|sock| @ios << sock} + end + + def assert_status(str, status = 400) + assert str.start_with?("HTTP/1.1 #{status}"), "'#{str[/[^\r]+/]}' should be #{status}" + end + + # ──────────────────────────────────── below are invalid Content-Length + + def test_content_length_multiple + te = [ + 'Content-Length: 5', + 'Content-Length: 5' + ].join "\r\n" + + data = send_http_and_read "#{GET_PREFIX}#{te}\r\n\r\nHello\r\n\r\n" + + assert_status data + end + + def test_content_length_bad_characters_1 + te = 'Content-Length: 5.01' + + data = send_http_and_read "#{GET_PREFIX}#{te}\r\n\r\nHello\r\n\r\n" + + assert_status data + end + + def test_content_length_bad_characters_2 + te = 'Content-Length: +5' + + data = send_http_and_read "#{GET_PREFIX}#{te}\r\n\r\nHello\r\n\r\n" + + assert_status data + end + + def test_content_length_bad_characters_3 + te = 'Content-Length: 5 test' + + data = send_http_and_read "#{GET_PREFIX}#{te}\r\n\r\nHello\r\n\r\n" + + assert_status data + end + + # ──────────────────────────────────── below are invalid Transfer-Encoding + + def test_transfer_encoding_chunked_not_last + te = [ + 'Transfer-Encoding: chunked', + 'Transfer-Encoding: gzip' + ].join "\r\n" + + data = send_http_and_read "#{GET_PREFIX}#{te}\r\n\r\n#{CHUNKED}" + + assert_status data + end + + def test_transfer_encoding_chunked_multiple + te = [ + 'Transfer-Encoding: chunked', + 'Transfer-Encoding: gzip', + 'Transfer-Encoding: chunked' + ].join "\r\n" + + data = send_http_and_read "#{GET_PREFIX}#{te}\r\n\r\n#{CHUNKED}" + + assert_status data + end + + def test_transfer_encoding_invalid_single + te = 'Transfer-Encoding: xchunked' + + data = send_http_and_read "#{GET_PREFIX}#{te}\r\n\r\n#{CHUNKED}" + + assert_status data, 501 + end + + def test_transfer_encoding_invalid_multiple + te = [ + 'Transfer-Encoding: x_gzip', + 'Transfer-Encoding: gzip', + 'Transfer-Encoding: chunked' + ].join "\r\n" + + data = send_http_and_read "#{GET_PREFIX}#{te}\r\n\r\n#{CHUNKED}" + + assert_status data, 501 + end + + def test_transfer_encoding_single_not_chunked + te = 'Transfer-Encoding: gzip' + + data = send_http_and_read "#{GET_PREFIX}#{te}\r\n\r\n#{CHUNKED}" + + assert_status data + end + + # ──────────────────────────────────── below are invalid chunked size + + def test_chunked_size_bad_characters_1 + te = 'Transfer-Encoding: chunked' + chunked ='5.01' + + data = send_http_and_read "#{GET_PREFIX}#{te}\r\n\r\n1\r\nh\r\n#{chunked}\r\nHello\r\n0\r\n\r\n" + + assert_status data + end + + def test_chunked_size_bad_characters_2 + te = 'Transfer-Encoding: chunked' + chunked ='+5' + + data = send_http_and_read "#{GET_PREFIX}#{te}\r\n\r\n1\r\nh\r\n#{chunked}\r\nHello\r\n0\r\n\r\n" + + assert_status data + end + + def test_chunked_size_bad_characters_3 + te = 'Transfer-Encoding: chunked' + chunked ='5 bad' + + data = send_http_and_read "#{GET_PREFIX}#{te}\r\n\r\n1\r\nh\r\n#{chunked}\r\nHello\r\n0\r\n\r\n" + + assert_status data + end + + def test_chunked_size_bad_characters_4 + te = 'Transfer-Encoding: chunked' + chunked ='0xA' + + data = send_http_and_read "#{GET_PREFIX}#{te}\r\n\r\n1\r\nh\r\n#{chunked}\r\nHelloHello\r\n0\r\n\r\n" + + assert_status data + end + + # size is less than bytesize + def test_chunked_size_mismatch_1 + te = 'Transfer-Encoding: chunked' + chunked = + "5\r\nHello\r\n" \ + "4\r\nWorld\r\n" \ + "0" + + data = send_http_and_read "#{GET_PREFIX}#{te}\r\n\r\n#{chunked}\r\n\r\n" + + assert_status data + end + + # size is greater than bytesize + def test_chunked_size_mismatch_2 + te = 'Transfer-Encoding: chunked' + chunked = + "5\r\nHello\r\n" \ + "6\r\nWorld\r\n" \ + "0" + + data = send_http_and_read "#{GET_PREFIX}#{te}\r\n\r\n#{chunked}\r\n\r\n" + + assert_status data + end +end diff --git a/test/test_response_header.rb b/test/test_response_header.rb new file mode 100644 index 0000000..508bbcd --- /dev/null +++ b/test/test_response_header.rb @@ -0,0 +1,144 @@ +require_relative "helper" +require "puma/events" +require "net/http" + +class TestResponseHeader < Minitest::Test + parallelize_me! + + def setup + @host = "127.0.0.1" + + @ios = [] + + @app = ->(env) { [200, {}, [env['rack.url_scheme']]] } + + @events = Puma::Events.strings + @server = Puma::Server.new @app, @events + end + + def teardown + @server.stop(true) + @ios.each { |io| io.close if io && !io.closed? } + end + + def server_run(app: @app, early_hints: false) + @server.app = app + @port = (@server.add_tcp_listener @host, 0).addr[1] + @server.early_hints = true if early_hints + @server.run + end + + def send_http_and_read(req) + send_http(req).read + end + + def send_http(req) + new_connection << req + end + + def new_connection + TCPSocket.new(@host, @port).tap {|sock| @ios << sock} + end + + # The header keys must be Strings + def test_integer_key + server_run app: ->(env) { [200, { 1 => 'Boo'}, []] } + data = send_http_and_read "GET / HTTP/1.0\r\n\r\n" + + assert_match(/HTTP\/1.1 500 Internal Server Error/, data) + end + + # The header must respond to each + def test_nil_header + server_run app: ->(env) { [200, nil, []] } + data = send_http_and_read "GET / HTTP/1.0\r\n\r\n" + + assert_match(/HTTP\/1.1 500 Internal Server Error/, data) + end + + # The values of the header must be Strings + def test_integer_value + server_run app: ->(env) { [200, {'Content-Length' => 500}, []] } + data = send_http_and_read "GET / HTTP/1.0\r\n\r\n" + + assert_match(/HTTP\/1.0 200 OK\r\nContent-Length: 500\r\n\r\n/, data) + end + + def assert_ignore_header(name, value, opts={}) + header = { name => value } + + if opts[:early_hints] + app = ->(env) do + env['rack.early_hints'].call(header) + [200, {}, ['Hello']] + end + else + app = -> (env) { [200, header, ['hello']]} + end + + server_run(app: app, early_hints: opts[:early_hints]) + data = send_http_and_read "GET / HTTP/1.0\r\n\r\n" + + refute_match("#{name}: #{value}", data) + end + + # The header must not contain a Status key. + def test_status_key + assert_ignore_header("Status", "500") + end + + # The header key can contain the word status. + def test_key_containing_status + server_run app: ->(env) { [200, {'Teapot-Status' => 'Boiling'}, []] } + data = send_http_and_read "GET / HTTP/1.0\r\n\r\n" + + assert_match(/HTTP\/1.0 200 OK\r\nTeapot-Status: Boiling\r\n\r\n/, data) + end + + # Special headers starting “rack.” are for communicating with the server, and must not be sent back to the client. + def test_rack_key + assert_ignore_header("rack.command_to_server_only", "work") + end + + # The header key can still start with the word rack + def test_racket_key + server_run app: ->(env) { [200, {'Racket' => 'Bouncy'}, []] } + data = send_http_and_read "GET / HTTP/1.0\r\n\r\n" + + assert_match(/HTTP\/1.0 200 OK\r\nRacket: Bouncy\r\n\r\n/, data) + end + + # testing header key must conform rfc token specification + # i.e. cannot contain non-printable ASCII, DQUOTE or “(),/:;<=>?@[]{}”. + # Header keys will be set through two ways: Regular and early hints. + + def test_illegal_character_in_key + assert_ignore_header("\"F\u0000o\u0025(@o}", "Boo") + end + + def test_illegal_character_in_key_when_early_hints + assert_ignore_header("\"F\u0000o\u0025(@o}", "Boo", early_hints: true) + end + + # testing header value can be separated by \n into line, and each line must not contain characters below 037 + # Header values can be set through three ways: Regular, early hints and a special case for overriding content-length + + def test_illegal_character_in_value + assert_ignore_header("X-header", "First \000Lin\037e") + end + + def test_illegal_character_in_value_when_early_hints + assert_ignore_header("X-header", "First \000Lin\037e", early_hints: true) + end + + def test_illegal_character_in_value_when_override_content_length + assert_ignore_header("Content-Length", "\037") + end + + def test_illegal_character_in_value_when_newline + server_run app: ->(env) { [200, {'X-header' => "First\000 line\nSecond Lin\037e"}, ["Hello"]] } + data = send_http_and_read "GET / HTTP/1.0\r\n\r\n" + + refute_match("X-header: First\000 line\r\nX-header: Second Lin\037e\r\n", data) + end +end diff --git a/test/test_thread_pool.rb b/test/test_thread_pool.rb new file mode 100644 index 0000000..8021e7b --- /dev/null +++ b/test/test_thread_pool.rb @@ -0,0 +1,311 @@ +require_relative "helper" + +require "puma/thread_pool" + +class TestThreadPool < Minitest::Test + + def teardown + @pool.shutdown(1) if defined?(@pool) + end + + def new_pool(min, max, &block) + block = proc { } unless block + @pool = Puma::ThreadPool.new('tst', min, max, &block) + end + + def mutex_pool(min, max, &block) + block = proc { } unless block + @pool = MutexPool.new('tst', min, max, &block) + end + + # Wraps ThreadPool work in mutex for better concurrency control. + class MutexPool < Puma::ThreadPool + # Wait until the added work is completed before returning. + # Array argument is treated as a batch of work items to be added. + # Block will run after work is added but before it is executed on a worker thread. + def <<(work, &block) + work = [work] unless work.is_a?(Array) + with_mutex do + work.each {|arg| super arg} + yield if block_given? + @not_full.wait(@mutex) + end + end + + def signal + @not_full.signal + end + + # If +wait+ is true, wait until the trim request is completed before returning. + def trim(force=false, wait: true) + super(force) + Thread.pass until @trim_requested == 0 if wait + end + end + + def test_append_spawns + saw = [] + pool = mutex_pool(0, 1) do |work| + saw << work + end + + pool << 1 + assert_equal 1, pool.spawned + assert_equal [1], saw + end + + def test_thread_name + skip 'Thread.name not supported' unless Thread.current.respond_to?(:name) + thread_name = nil + pool = mutex_pool(0, 1) {thread_name = Thread.current.name} + pool << 1 + assert_equal('puma tst tp 001', thread_name) + end + + def test_thread_name_linux + skip 'Thread.name not supported' unless Thread.current.respond_to?(:name) + + task_dir = File.join('', 'proc', Process.pid.to_s, 'task') + skip 'This test only works under Linux and MRI Ruby with appropriate permissions' if !(File.directory?(task_dir) && File.readable?(task_dir) && Puma::IS_MRI) + + expected_thread_name = 'puma tst tp 001' + found_thread = false + pool = mutex_pool(0, 1) do + # Read every /proc//task//comm file to find the thread name + Dir.entries(task_dir).select {|tid| File.directory?(File.join(task_dir, tid))}.each do |tid| + comm_file = File.join(task_dir, tid, 'comm') + next unless File.file?(comm_file) && File.readable?(comm_file) + + if File.read(comm_file).strip == expected_thread_name + found_thread = true + break + end + end + end + pool << 1 + + assert(found_thread, "Did not find thread with name '#{expected_thread_name}'") + end + + def test_converts_pool_sizes + pool = new_pool('0', '1') + + assert_equal 0, pool.spawned + + pool << 1 + + assert_equal 1, pool.spawned + end + + def test_append_queues_on_max + pool = new_pool(0, 0) do + "Hello World!" + end + + pool << 1 + pool << 2 + pool << 3 + + assert_equal 3, pool.backlog + end + + def test_trim + pool = mutex_pool(0, 1) + + pool << 1 + + assert_equal 1, pool.spawned + + pool.trim + assert_equal 0, pool.spawned + end + + def test_trim_leaves_min + pool = mutex_pool(1, 2) + + pool << [1, 2] + + assert_equal 2, pool.spawned + + pool.trim + assert_equal 1, pool.spawned + + pool.trim + assert_equal 1, pool.spawned + end + + def test_force_trim_doesnt_overtrim + pool = mutex_pool(1, 2) + + pool.<< [1, 2] do + assert_equal 2, pool.spawned + pool.trim true, wait: false + pool.trim true, wait: false + end + + assert_equal 1, pool.spawned + end + + def test_trim_is_ignored_if_no_waiting_threads + pool = mutex_pool(1, 2) + + pool.<< [1, 2] do + assert_equal 2, pool.spawned + pool.trim + pool.trim + end + + assert_equal 2, pool.spawned + assert_equal 0, pool.trim_requested + end + + def test_autotrim + pool = mutex_pool(1, 2) + + timeout = 0 + pool.auto_trim! timeout + + pool.<< [1, 2] do + assert_equal 2, pool.spawned + end + + start = Time.now + Thread.pass until pool.spawned == 1 || Time.now - start > 1 + + assert_equal 1, pool.spawned + end + + def test_cleanliness + values = [] + n = 100 + + pool = mutex_pool(1,1) { + values.push Thread.current[:foo] + Thread.current[:foo] = :hai + } + + pool.clean_thread_locals = true + + pool << [1] * n + + assert_equal n, values.length + + assert_equal [], values.compact + end + + def test_reap_only_dead_threads + pool = mutex_pool(2,2) do + th = Thread.current + Thread.new {th.join; pool.signal} + th.kill + end + + assert_equal 2, pool.spawned + + pool << 1 + + assert_equal 2, pool.spawned + + pool.reap + + assert_equal 1, pool.spawned + + pool << 2 + + assert_equal 1, pool.spawned + + pool.reap + + assert_equal 0, pool.spawned + end + + def test_auto_reap_dead_threads + pool = mutex_pool(2,2) do + th = Thread.current + Thread.new {th.join; pool.signal} + th.kill + end + + timeout = 0 + pool.auto_reap! timeout + + assert_equal 2, pool.spawned + + pool << 1 + pool << 2 + + start = Time.now + Thread.pass until pool.spawned == 0 || Time.now - start > 1 + + assert_equal 0, pool.spawned + end + + def test_force_shutdown_immediately + rescued = false + + pool = mutex_pool(0, 1) do + begin + pool.with_force_shutdown do + pool.signal + sleep + end + rescue Puma::ThreadPool::ForceShutdown + rescued = true + end + end + + pool << 1 + pool.shutdown(0) + + assert_equal 0, pool.spawned + assert rescued + end + + def test_waiting_on_startup + pool = new_pool(1, 2) + assert_equal 1, pool.waiting + end + + def test_shutdown_with_grace + timeout = 0.01 + grace = 0.01 + + rescued = [] + pool = mutex_pool(2, 2) do + begin + pool.with_force_shutdown do + pool.signal + sleep + end + rescue Puma::ThreadPool::ForceShutdown + rescued << Thread.current + sleep + end + end + + pool << 1 + pool << 2 + + Puma::ThreadPool.stub_const(:SHUTDOWN_GRACE_TIME, grace) do + pool.shutdown(timeout) + end + assert_equal 0, pool.spawned + assert_equal 2, rescued.length + refute rescued.compact.any?(&:alive?) + end + + def test_correct_waiting_count_for_killed_threads + pool = new_pool(1, 1) { |_| } + sleep 1 + + # simulate our waiting worker thread getting killed for whatever reason + pool.instance_eval { @workers[0].kill } + sleep 1 + pool.reap + sleep 1 + + pool << 0 + sleep 1 + assert_equal 0, pool.backlog + end +end diff --git a/test/test_unix_socket.rb b/test/test_unix_socket.rb new file mode 100644 index 0000000..854d44c --- /dev/null +++ b/test/test_unix_socket.rb @@ -0,0 +1,46 @@ +# frozen_string_literal: true + +require_relative "helper" +require_relative "helpers/tmp_path" + +class TestPumaUnixSocket < Minitest::Test + include TmpPath + + App = lambda { |env| [200, {}, ["Works"]] } + + def teardown + return if skipped? + @server.stop(true) + end + + def server_unix(type) + @tmp_socket_path = type == :unix ? tmp_path('.sock') : "@TestPumaUnixSocket" + @server = Puma::Server.new App + @server.add_unix_listener @tmp_socket_path + @server.run + end + + def test_server_unix + skip_unless :unix + server_unix :unix + sock = UNIXSocket.new @tmp_socket_path + + sock << "GET / HTTP/1.0\r\nHost: blah.com\r\n\r\n" + + expected = "HTTP/1.0 200 OK\r\nContent-Length: 5\r\n\r\nWorks" + + assert_equal expected, sock.read(expected.size) + end + + def test_server_aunix + skip_unless :aunix + server_unix :aunix + sock = UNIXSocket.new @tmp_socket_path.sub(/\A@/, "\0") + + sock << "GET / HTTP/1.0\r\nHost: blah.com\r\n\r\n" + + expected = "HTTP/1.0 200 OK\r\nContent-Length: 5\r\n\r\nWorks" + + assert_equal expected, sock.read(expected.size) + end +end diff --git a/test/test_web_server.rb b/test/test_web_server.rb new file mode 100644 index 0000000..9dc1093 --- /dev/null +++ b/test/test_web_server.rb @@ -0,0 +1,115 @@ +# frozen_string_literal: true +# Copyright (c) 2011 Evan Phoenix +# Copyright (c) 2005 Zed A. Shaw + +require_relative "helper" + +require "puma/server" + +class TestHandler + attr_reader :ran_test + + def call(env) + @ran_test = true + + [200, {"Content-Type" => "text/plain"}, ["hello!"]] + end +end + +class WebServerTest < Minitest::Test + parallelize_me! + + VALID_REQUEST = "GET / HTTP/1.1\r\nHost: www.zedshaw.com\r\nContent-Type: text/plain\r\nConnection: close\r\n\r\n" + + def setup + @tester = TestHandler.new + @server = Puma::Server.new @tester, Puma::Events.strings + @port = (@server.add_tcp_listener "127.0.0.1", 0).addr[1] + @tcp = "http://127.0.0.1:#{@port}" + @server.run + end + + def teardown + @server.stop(true) + end + + def test_simple_server + hit(["#{@tcp}/test"]) + assert @tester.ran_test, "Handler didn't really run" + end + + def test_requests_count + assert_equal @server.requests_count, 0 + 3.times do + hit(["#{@tcp}/test"]) + end + assert_equal @server.requests_count, 3 + end + + def test_trickle_attack + socket = do_test(VALID_REQUEST, 3) + assert_match "hello", socket.read + socket.close + end + + def test_close_client + assert_raises IOError do + do_test_raise(VALID_REQUEST, 10, 20) + end + end + + def test_bad_client + socket = do_test("GET /test HTTP/BAD", 3) + assert_match "Bad Request", socket.read + socket.close + end + + def test_header_is_too_long + long = "GET /test HTTP/1.1\r\n" + ("X-Big: stuff\r\n" * 15000) + "\r\n" + assert_raises Errno::ECONNRESET, Errno::EPIPE, Errno::ECONNABORTED, Errno::EINVAL, IOError do + do_test_raise(long, long.length/2, 10) + end + end + + def test_file_streamed_request + body = "a" * (Puma::Const::MAX_BODY * 2) + long = "GET /test HTTP/1.1\r\nContent-length: #{body.length}\r\nConnection: close\r\n\r\n" + body + socket = do_test(long, (Puma::Const::CHUNK_SIZE * 2) - 400) + assert_match "hello", socket.read + socket.close + end + + private + + def do_test(string, chunk) + # Do not use instance variables here, because it needs to be thread safe + socket = TCPSocket.new("127.0.0.1", @port); + request = StringIO.new(string) + chunks_out = 0 + + while data = request.read(chunk) + chunks_out += socket.write(data) + socket.flush + end + socket + end + + def do_test_raise(string, chunk, close_after = nil) + # Do not use instance variables here, because it needs to be thread safe + socket = TCPSocket.new("127.0.0.1", @port); + request = StringIO.new(string) + chunks_out = 0 + + while data = request.read(chunk) + chunks_out += socket.write(data) + socket.flush + socket.close if close_after && chunks_out > close_after + end + + socket.write(" ") # Some platforms only raise the exception on attempted write + socket.flush + socket + ensure + socket.close unless socket.closed? + end +end diff --git a/test/test_worker_gem_independence.rb b/test/test_worker_gem_independence.rb new file mode 100644 index 0000000..085a022 --- /dev/null +++ b/test/test_worker_gem_independence.rb @@ -0,0 +1,145 @@ +require_relative "helper" +require_relative "helpers/integration" + +class TestWorkerGemIndependence < TestIntegration + def setup + skip_unless :fork + super + end + + def teardown + return if skipped? + FileUtils.rm current_release_symlink, force: true + super + end + + def test_changing_nio4r_version_during_phased_restart + change_gem_version_during_phased_restart old_app_dir: 'worker_gem_independence_test/old_nio4r', + old_version: '2.3.0', + new_app_dir: 'worker_gem_independence_test/new_nio4r', + new_version: '2.3.1' + end + + def test_changing_json_version_during_phased_restart + change_gem_version_during_phased_restart old_app_dir: 'worker_gem_independence_test/old_json', + old_version: '2.3.1', + new_app_dir: 'worker_gem_independence_test/new_json', + new_version: '2.3.0' + end + + def test_changing_json_version_during_phased_restart_after_querying_stats_from_status_server + @control_tcp_port = UniquePort.call + server_opts = "--control-url tcp://#{HOST}:#{@control_tcp_port} --control-token #{TOKEN}" + before_restart = ->() do + cli_pumactl "stats" + end + + change_gem_version_during_phased_restart server_opts: server_opts, + before_restart: before_restart, + old_app_dir: 'worker_gem_independence_test/old_json', + old_version: '2.3.1', + new_app_dir: 'worker_gem_independence_test/new_json', + new_version: '2.3.0' + end + + def test_changing_json_version_during_phased_restart_after_querying_gc_stats_from_status_server + @control_tcp_port = UniquePort.call + server_opts = "--control-url tcp://#{HOST}:#{@control_tcp_port} --control-token #{TOKEN}" + before_restart = ->() do + cli_pumactl "gc-stats" + end + + change_gem_version_during_phased_restart server_opts: server_opts, + before_restart: before_restart, + old_app_dir: 'worker_gem_independence_test/old_json', + old_version: '2.3.1', + new_app_dir: 'worker_gem_independence_test/new_json', + new_version: '2.3.0' + end + + def test_changing_json_version_during_phased_restart_after_querying_thread_backtraces_from_status_server + @control_tcp_port = UniquePort.call + server_opts = "--control-url tcp://#{HOST}:#{@control_tcp_port} --control-token #{TOKEN}" + before_restart = ->() do + cli_pumactl "thread-backtraces" + end + + change_gem_version_during_phased_restart server_opts: server_opts, + before_restart: before_restart, + old_app_dir: 'worker_gem_independence_test/old_json', + old_version: '2.3.1', + new_app_dir: 'worker_gem_independence_test/new_json', + new_version: '2.3.0' + end + + def test_changing_json_version_during_phased_restart_after_accessing_puma_stats_directly + change_gem_version_during_phased_restart old_app_dir: 'worker_gem_independence_test/old_json_with_puma_stats_after_fork', + old_version: '2.3.1', + new_app_dir: 'worker_gem_independence_test/new_json_with_puma_stats_after_fork', + new_version: '2.3.0' + end + + private + + def change_gem_version_during_phased_restart(old_app_dir:, + new_app_dir:, + old_version:, + new_version:, + server_opts: '', + before_restart: nil) + skip_unless_signal_exist? :USR1 + + set_release_symlink File.expand_path(old_app_dir, __dir__) + + Dir.chdir(current_release_symlink) do + with_unbundled_env do + silent_and_checked_system_command("bundle config --local path vendor/bundle") + silent_and_checked_system_command("bundle install") + cli_server "--prune-bundler -w 1 #{server_opts}" + end + end + + connection = connect + initial_reply = read_body(connection) + assert_equal old_version, initial_reply + + before_restart.call if before_restart + + set_release_symlink File.expand_path(new_app_dir, __dir__) + Dir.chdir(current_release_symlink) do + with_unbundled_env do + silent_and_checked_system_command("bundle config --local path vendor/bundle") + silent_and_checked_system_command("bundle install") + end + end + start_phased_restart + + connection = connect + new_reply = read_body(connection) + assert_equal new_version, new_reply + end + + def current_release_symlink + File.expand_path "worker_gem_independence_test/current", __dir__ + end + + def set_release_symlink(target_dir) + FileUtils.rm current_release_symlink, force: true + FileUtils.symlink target_dir, current_release_symlink, force: true + end + + def start_phased_restart + Process.kill :USR1, @pid + + true while @server.gets !~ /booted in [.0-9]+s, phase: 1/ + end + + def with_unbundled_env + bundler_ver = Gem::Version.new(Bundler::VERSION) + if bundler_ver < Gem::Version.new('2.1.0') + Bundler.with_clean_env { yield } + else + Bundler.with_unbundled_env { yield } + end + end +end diff --git a/test/worker_gem_independence_test/new_json/Gemfile b/test/worker_gem_independence_test/new_json/Gemfile new file mode 100644 index 0000000..be0f804 --- /dev/null +++ b/test/worker_gem_independence_test/new_json/Gemfile @@ -0,0 +1,4 @@ +source "https://rubygems.org" + +gem 'puma', path: '../../..' +gem 'json', '= 2.3.0' diff --git a/test/worker_gem_independence_test/new_json/config.ru b/test/worker_gem_independence_test/new_json/config.ru new file mode 100644 index 0000000..a2d096f --- /dev/null +++ b/test/worker_gem_independence_test/new_json/config.ru @@ -0,0 +1,2 @@ +require 'json' +run lambda { |env| [200, {'Content-Type'=>'text/plain'}, [JSON::VERSION]] } diff --git a/test/worker_gem_independence_test/new_json/config/puma.rb b/test/worker_gem_independence_test/new_json/config/puma.rb new file mode 100644 index 0000000..ab019ff --- /dev/null +++ b/test/worker_gem_independence_test/new_json/config/puma.rb @@ -0,0 +1 @@ +directory File.expand_path("../../current", __dir__) diff --git a/test/worker_gem_independence_test/new_json_with_puma_stats_after_fork/Gemfile b/test/worker_gem_independence_test/new_json_with_puma_stats_after_fork/Gemfile new file mode 100644 index 0000000..be0f804 --- /dev/null +++ b/test/worker_gem_independence_test/new_json_with_puma_stats_after_fork/Gemfile @@ -0,0 +1,4 @@ +source "https://rubygems.org" + +gem 'puma', path: '../../..' +gem 'json', '= 2.3.0' diff --git a/test/worker_gem_independence_test/new_json_with_puma_stats_after_fork/config.ru b/test/worker_gem_independence_test/new_json_with_puma_stats_after_fork/config.ru new file mode 100644 index 0000000..a2d096f --- /dev/null +++ b/test/worker_gem_independence_test/new_json_with_puma_stats_after_fork/config.ru @@ -0,0 +1,2 @@ +require 'json' +run lambda { |env| [200, {'Content-Type'=>'text/plain'}, [JSON::VERSION]] } diff --git a/test/worker_gem_independence_test/new_json_with_puma_stats_after_fork/config/puma.rb b/test/worker_gem_independence_test/new_json_with_puma_stats_after_fork/config/puma.rb new file mode 100644 index 0000000..98bf445 --- /dev/null +++ b/test/worker_gem_independence_test/new_json_with_puma_stats_after_fork/config/puma.rb @@ -0,0 +1,2 @@ +directory File.expand_path("../../current", __dir__) +after_worker_fork { Puma.stats } diff --git a/test/worker_gem_independence_test/new_nio4r/Gemfile b/test/worker_gem_independence_test/new_nio4r/Gemfile new file mode 100644 index 0000000..11a8fb0 --- /dev/null +++ b/test/worker_gem_independence_test/new_nio4r/Gemfile @@ -0,0 +1,4 @@ +source "https://rubygems.org" + +gem 'puma', path: '../../..' +gem 'nio4r', '= 2.3.1' diff --git a/test/worker_gem_independence_test/new_nio4r/config.ru b/test/worker_gem_independence_test/new_nio4r/config.ru new file mode 100644 index 0000000..773d1ee --- /dev/null +++ b/test/worker_gem_independence_test/new_nio4r/config.ru @@ -0,0 +1 @@ +run lambda { |env| [200, {'Content-Type'=>'text/plain'}, [NIO::VERSION]] } diff --git a/test/worker_gem_independence_test/new_nio4r/config/puma.rb b/test/worker_gem_independence_test/new_nio4r/config/puma.rb new file mode 100644 index 0000000..ab019ff --- /dev/null +++ b/test/worker_gem_independence_test/new_nio4r/config/puma.rb @@ -0,0 +1 @@ +directory File.expand_path("../../current", __dir__) diff --git a/test/worker_gem_independence_test/old_json/Gemfile b/test/worker_gem_independence_test/old_json/Gemfile new file mode 100644 index 0000000..c91f721 --- /dev/null +++ b/test/worker_gem_independence_test/old_json/Gemfile @@ -0,0 +1,4 @@ +source "https://rubygems.org" + +gem 'puma', path: '../../..' +gem 'json', '= 2.3.1' diff --git a/test/worker_gem_independence_test/old_json/config.ru b/test/worker_gem_independence_test/old_json/config.ru new file mode 100644 index 0000000..a2d096f --- /dev/null +++ b/test/worker_gem_independence_test/old_json/config.ru @@ -0,0 +1,2 @@ +require 'json' +run lambda { |env| [200, {'Content-Type'=>'text/plain'}, [JSON::VERSION]] } diff --git a/test/worker_gem_independence_test/old_json/config/puma.rb b/test/worker_gem_independence_test/old_json/config/puma.rb new file mode 100644 index 0000000..ab019ff --- /dev/null +++ b/test/worker_gem_independence_test/old_json/config/puma.rb @@ -0,0 +1 @@ +directory File.expand_path("../../current", __dir__) diff --git a/test/worker_gem_independence_test/old_json_with_puma_stats_after_fork/Gemfile b/test/worker_gem_independence_test/old_json_with_puma_stats_after_fork/Gemfile new file mode 100644 index 0000000..c91f721 --- /dev/null +++ b/test/worker_gem_independence_test/old_json_with_puma_stats_after_fork/Gemfile @@ -0,0 +1,4 @@ +source "https://rubygems.org" + +gem 'puma', path: '../../..' +gem 'json', '= 2.3.1' diff --git a/test/worker_gem_independence_test/old_json_with_puma_stats_after_fork/config.ru b/test/worker_gem_independence_test/old_json_with_puma_stats_after_fork/config.ru new file mode 100644 index 0000000..a2d096f --- /dev/null +++ b/test/worker_gem_independence_test/old_json_with_puma_stats_after_fork/config.ru @@ -0,0 +1,2 @@ +require 'json' +run lambda { |env| [200, {'Content-Type'=>'text/plain'}, [JSON::VERSION]] } diff --git a/test/worker_gem_independence_test/old_json_with_puma_stats_after_fork/config/puma.rb b/test/worker_gem_independence_test/old_json_with_puma_stats_after_fork/config/puma.rb new file mode 100644 index 0000000..98bf445 --- /dev/null +++ b/test/worker_gem_independence_test/old_json_with_puma_stats_after_fork/config/puma.rb @@ -0,0 +1,2 @@ +directory File.expand_path("../../current", __dir__) +after_worker_fork { Puma.stats } diff --git a/test/worker_gem_independence_test/old_nio4r/Gemfile b/test/worker_gem_independence_test/old_nio4r/Gemfile new file mode 100644 index 0000000..cdeab2b --- /dev/null +++ b/test/worker_gem_independence_test/old_nio4r/Gemfile @@ -0,0 +1,4 @@ +source "https://rubygems.org" + +gem 'puma', path: '../../..' +gem 'nio4r', '= 2.3.0' diff --git a/test/worker_gem_independence_test/old_nio4r/config.ru b/test/worker_gem_independence_test/old_nio4r/config.ru new file mode 100644 index 0000000..773d1ee --- /dev/null +++ b/test/worker_gem_independence_test/old_nio4r/config.ru @@ -0,0 +1 @@ +run lambda { |env| [200, {'Content-Type'=>'text/plain'}, [NIO::VERSION]] } diff --git a/test/worker_gem_independence_test/old_nio4r/config/puma.rb b/test/worker_gem_independence_test/old_nio4r/config/puma.rb new file mode 100644 index 0000000..ab019ff --- /dev/null +++ b/test/worker_gem_independence_test/old_nio4r/config/puma.rb @@ -0,0 +1 @@ +directory File.expand_path("../../current", __dir__) diff --git a/tools/Dockerfile b/tools/Dockerfile new file mode 100644 index 0000000..bf61657 --- /dev/null +++ b/tools/Dockerfile @@ -0,0 +1,16 @@ +# Use this Dockerfile to create minimal reproductions of issues + +FROM ruby:3.1 + +# throw errors if Gemfile has been modified since Gemfile.lock +RUN bundle config --global frozen 1 + +WORKDIR /usr/src/app + +COPY . . +RUN gem install bundler +RUN bundle install +RUN bundle exec rake compile + +EXPOSE 9292 +CMD bundle exec bin/puma test/rackup/hello.ru diff --git a/tools/trickletest.rb b/tools/trickletest.rb new file mode 100644 index 0000000..fa54380 --- /dev/null +++ b/tools/trickletest.rb @@ -0,0 +1,44 @@ +require 'socket' +require 'stringio' + +def do_test(st, chunk) + s = TCPSocket.new('127.0.0.1',ARGV[0].to_i); + req = StringIO.new(st) + nout = 0 + randstop = rand(st.length / 10) + STDERR.puts "stopping after: #{randstop}" + + begin + while data = req.read(chunk) + nout += s.write(data) + s.flush + sleep 0.1 + if nout > randstop + STDERR.puts "BANG! after #{nout} bytes." + break + end + end + rescue Object => e + STDERR.puts "ERROR: #{e}" + ensure + s.close + end +end + +content = "-" * (1024 * 240) +st = "GET / HTTP/1.1\r\nHost: www.zedshaw.com\r\nContent-Type: text/plain\r\nContent-Length: #{content.length}\r\n\r\n#{content}" + +puts "length: #{content.length}" + +threads = [] +ARGV[1].to_i.times do + t = Thread.new do + size = 100 + puts ">>>> #{size} sized chunks" + do_test(st, size) + end + + threads << t +end + +threads.each {|t| t.join} diff --git a/win_gem_test/Rakefile_wintest b/win_gem_test/Rakefile_wintest new file mode 100644 index 0000000..42b3038 --- /dev/null +++ b/win_gem_test/Rakefile_wintest @@ -0,0 +1,11 @@ +# rake -f Rakefile_wintest -N -R norakelib + +require "rake/testtask" + +Rake::TestTask.new(:win_test) do |t| + t.libs << "test" + t.warning = false + t.options = '--verbose' +end + +task :default => [:win_test] diff --git a/win_gem_test/package_gem.rb b/win_gem_test/package_gem.rb new file mode 100644 index 0000000..fc3b3c7 --- /dev/null +++ b/win_gem_test/package_gem.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +require 'rubygems' +require 'rubygems/package' + +spec = Gem::Specification.load("./puma.gemspec") + +spec.files.concat ['Rakefile_wintest', 'lib/puma/puma_http11.rb'] +spec.files.concat Dir['lib/**/*.so'] +spec.test_files = Dir['{examples,test}/**/*.*'] + +# below lines are required and not gem specific +spec.platform = ARGV[0] +spec.required_ruby_version = [">= #{ARGV[1]}", "< #{ARGV[2]}"] +spec.extensions = [] +if spec.respond_to?(:metadata=) + spec.metadata.delete("msys2_mingw_dependencies") + spec.metadata['commit'] = ENV['commit_info'] +end + +Gem::Package.build(spec) diff --git a/win_gem_test/puma.ps1 b/win_gem_test/puma.ps1 new file mode 100644 index 0000000..01dea20 --- /dev/null +++ b/win_gem_test/puma.ps1 @@ -0,0 +1,67 @@ +# PowerShell script for building & testing SQLite3-Ruby fat binary gem +# Code by MSP-Greg, see https://github.com/MSP-Greg/av-gem-build-test + +# load utility functions, pass 64 or 32 +. $PSScriptRoot\shared\appveyor_setup.ps1 $args[0] +if ($LastExitCode) { exit } + +# above is required code +#———————————————————————————————————————————————————————————————— above for all repos + +Make-Const gem_name 'puma' +Make-Const repo_name 'puma' +Make-Const url_repo 'https://github.com/puma/puma.git' + +#———————————————————————————————————————————————————————————————— lowest ruby version +Make-Const ruby_vers_low 22 +# null = don't compile; false = compile, ignore test (allow failure); +# true = compile & test +Make-Const trunk $false ; Make-Const trunk_x64 $false +Make-Const trunk_JIT $null ; Make-Const trunk_x64_JIT $null + +#———————————————————————————————————————————————————————————————— make info +Make-Const dest_so 'lib\puma' +Make-Const exts @( + @{ 'conf' = 'ext/puma_http11/extconf.rb' ; 'so' = 'puma_http11' } +) +Make-Const write_so_require $true + +#———————————————————————————————————————————————————————————————— Pre-Compile +# runs before compiling starts on every ruby version +function Pre-Compile { + # load the correct OpenSSL version in the build system + Check-OpenSSL + Write-Host Compiling With $env:SSL_VERS +} + +#———————————————————————————————————————————————————————————————— Pre-Gem-Install +function Pre-Gem-Install { + if ($ruby -lt '23') { + gem install -N --no-user-install nio4r:2.3.1 + } else { + gem install -N --no-user-install nio4r + } +} + +#———————————————————————————————————————————————————————————————— Run-Tests +function Run-Tests { + # call with comma separated list of gems to install or update + Update-Gems minitest, minitest-retry, minitest-proveit, rack, rake + $env:CI = 1 + rake -f Rakefile_wintest -N -R norakelib | Set-Content -Path $log_name -PassThru -Encoding UTF8 + # add info after test results + $(ruby -ropenssl -e "STDOUT.puts $/ + OpenSSL::OPENSSL_LIBRARY_VERSION") | + Add-Content -Path $log_name -PassThru -Encoding UTF8 + minitest # collects test results +} + +#———————————————————————————————————————————————————————————————— below for all repos +# below is required code +Make-Const dir_gem $(Convert-Path $PSScriptRoot\..) +Make-Const dir_ps $PSScriptRoot + +Push-Location $PSScriptRoot +.\shared\make.ps1 +.\shared\test.ps1 +Pop-Location +exit $ttl_errors_fails + $exit_code -- 2.30.2