diff -Nru puma-5.6.5/.devcontainer/Dockerfile puma-6.4.2/.devcontainer/Dockerfile --- puma-5.6.5/.devcontainer/Dockerfile 1970-01-01 00:00:00.000000000 +0000 +++ puma-6.4.2/.devcontainer/Dockerfile 2024-01-08 05:53:42.000000000 +0000 @@ -0,0 +1,19 @@ +# See here for image contents: https://github.com/microsoft/vscode-dev-containers/tree/v0.245.2/containers/ruby/.devcontainer/base.Dockerfile + +# [Choice] Ruby version (use -bullseye variants on local arm64/Apple Silicon): 3, 3.1, 3.0, 2, 2.7, 3-bullseye, 3.1-bullseye, 3.0-bullseye, 2-bullseye, 2.7-bullseye, 3-buster, 3.1-buster, 3.0-buster, 2-buster, 2.7-buster +ARG VARIANT="3.1-bullseye" +FROM mcr.microsoft.com/vscode/devcontainers/ruby:0-${VARIANT} + +# [Choice] Node.js version: none, lts/*, 16, 14, 12, 10 +ARG NODE_VERSION="none" +RUN if [ "${NODE_VERSION}" != "none" ]; then su vscode -c "umask 0002 && . /usr/local/share/nvm/nvm.sh && nvm install ${NODE_VERSION} 2>&1"; fi + +# [Optional] Uncomment this section to install additional OS packages. +RUN apt-get update && export DEBIAN_FRONTEND=noninteractive \ + && apt-get -y install --no-install-recommends ragel + +# [Optional] Uncomment this line to install additional gems. +# RUN gem install + +# [Optional] Uncomment this line to install global node packages. +# RUN su vscode -c "source /usr/local/share/nvm/nvm.sh && npm install -g " 2>&1 diff -Nru puma-5.6.5/.devcontainer/devcontainer.json puma-6.4.2/.devcontainer/devcontainer.json --- puma-5.6.5/.devcontainer/devcontainer.json 1970-01-01 00:00:00.000000000 +0000 +++ puma-6.4.2/.devcontainer/devcontainer.json 2024-01-08 05:53:42.000000000 +0000 @@ -0,0 +1,37 @@ +// For format details, see https://aka.ms/devcontainer.json. For config options, see the README at: +// https://github.com/microsoft/vscode-dev-containers/tree/v0.245.2/containers/ruby +{ + "name": "Ruby", + "build": { + "dockerfile": "Dockerfile", + "args": { + // Update 'VARIANT' to pick a Ruby version: 3, 3.1, 3.0, 2, 2.7 + // Append -bullseye or -buster to pin to an OS version. + // Use -bullseye variants on local on arm64/Apple Silicon. + "VARIANT": "3.1-bullseye", + // Options + "NODE_VERSION": "none" + } + }, + + // Configure tool-specific properties. + "customizations": { + // Configure properties specific to VS Code. + "vscode": { + // Add the IDs of extensions you want installed when the container is created. + "extensions": [ + "rebornix.Ruby" + ] + } + }, + + // Use 'forwardPorts' to make a list of ports inside the container available locally. + // "forwardPorts": [], + + // Use 'postCreateCommand' to run commands after the container is created. + "postCreateCommand": "bundle install && bundle exec rake compile", + + // Comment out to connect as root instead. More info: https://aka.ms/vscode-remote/containers/non-root. + "remoteUser": "vscode" + +} diff -Nru puma-5.6.5/.github/dependabot.yml puma-6.4.2/.github/dependabot.yml --- puma-5.6.5/.github/dependabot.yml 1970-01-01 00:00:00.000000000 +0000 +++ puma-6.4.2/.github/dependabot.yml 2024-01-08 05:53:42.000000000 +0000 @@ -0,0 +1,6 @@ +version: 2 +updates: + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "weekly" diff -Nru puma-5.6.5/.github/workflows/github_actions_info.rb puma-6.4.2/.github/workflows/github_actions_info.rb --- puma-5.6.5/.github/workflows/github_actions_info.rb 1970-01-01 00:00:00.000000000 +0000 +++ puma-6.4.2/.github/workflows/github_actions_info.rb 2024-01-08 05:53:42.000000000 +0000 @@ -0,0 +1,8 @@ +# logs repo/commit info + +puts "ENV['GITHUB_WORKFLOW_REF'] #{ENV['GITHUB_WORKFLOW_REF']}\n" \ + "ENV['GITHUB_WORKFLOW_SHA'] #{ENV['GITHUB_WORKFLOW_SHA']}\n" \ + "ENV['GITHUB_REPOSITORY'] #{ENV['GITHUB_REPOSITORY']}\n" \ + "ENV['GITHUB_REF_TYPE'] #{ENV['GITHUB_REF_TYPE']}\n" \ + "ENV['GITHUB_REF'] #{ENV['GITHUB_REF']}\n" \ + "ENV['GITHUB_REF_NAME'] #{ENV['GITHUB_REF_NAME']}" diff -Nru puma-5.6.5/.github/workflows/rack_conform.yml puma-6.4.2/.github/workflows/rack_conform.yml --- puma-5.6.5/.github/workflows/rack_conform.yml 1970-01-01 00:00:00.000000000 +0000 +++ puma-6.4.2/.github/workflows/rack_conform.yml 2024-01-08 05:53:42.000000000 +0000 @@ -0,0 +1,68 @@ +name: rack-conform + +on: [push, pull_request, workflow_dispatch] + +permissions: + contents: read # to fetch code (actions/checkout) + +jobs: + skip_duplicate_runs: + uses: ./.github/workflows/skip_duplicate_workflow_runs.yml + + rack-conform: + name: >- + ${{ matrix.os }} Ruby ${{ matrix.ruby }} rack-conform + needs: skip_duplicate_runs + runs-on: ${{ matrix.os }} + if: | + !( contains(github.event.pull_request.title, '[ci skip]') + || contains(github.event.pull_request.title, '[skip ci]') + || (needs.skip_duplicate_runs.outputs.should_skip == 'true')) + strategy: + fail-fast: false + matrix: + include: + - { os: ubuntu-20.04 , ruby: '2.7' } + - { os: ubuntu-20.04 , ruby: '3.2' } + - { os: ubuntu-22.04 , ruby: '3.3' } + - { os: ubuntu-22.04 , ruby: head } + + env: + BUNDLE_GEMFILE: gems/puma-head-rack-v3.rb + RACK_CONFORM_SERVER: puma + RACK_CONFORM_ENDPOINT: http://localhost:9292 + + steps: + - name: checkout rack-conform + uses: actions/checkout@v4 + with: + repository: socketry/rack-conform + + - name: Update gems/puma-head-rack-v3.rb + run: | + # use Puma from current repo (may be a fork) & sha + SRC="gem ['\"]puma['\"].*" + DST="gem 'puma', git: 'https://github.com/$GITHUB_REPOSITORY.git', ref: '$GITHUB_SHA'" + sed -i "s#$SRC#$DST#" gems/puma-head-rack-v3.rb + + - name: load ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: ${{ matrix.ruby }} + bundler-cache: true + timeout-minutes: 10 + + - name: cat gems/puma-head-rack-v3.rb.lock + run: cat gems/puma-head-rack-v3.rb.lock + + - name: rack-conform test + id: test + timeout-minutes: 10 + run: bundle exec bake test + continue-on-error: true + if: success() + + - name: >- + Test outcome: ${{ steps.test.outcome }} + # every step must define a `uses` or `run` key + run: cat server.log diff -Nru puma-5.6.5/.github/workflows/ragel.yml puma-6.4.2/.github/workflows/ragel.yml --- puma-5.6.5/.github/workflows/ragel.yml 2022-08-22 23:33:14.000000000 +0000 +++ puma-6.4.2/.github/workflows/ragel.yml 2024-01-08 05:53:42.000000000 +0000 @@ -1,22 +1,23 @@ name: ragel -on: - push: - paths: - - 'ext/**' - - '.github/workflows/ragel.yml' - pull_request: - paths: - - 'ext/**' - - '.github/workflows/ragel.yml' - workflow_dispatch: +on: [push, pull_request, workflow_dispatch] + +permissions: + contents: read # to fetch code (actions/checkout) jobs: + skip_duplicate_runs: + uses: ./.github/workflows/skip_duplicate_workflow_runs.yml + with: + paths: '["ext/**", ".github/workflows/ragel.yml"]' + ragel: name: >- ragel ${{ matrix.os }} ${{ matrix.ruby }} + needs: skip_duplicate_runs env: PUMA_NO_RUBOCOP: true + PUMA_TEST_DEBUG: true runs-on: ${{ matrix.os }} if: | @@ -28,20 +29,26 @@ include: - { os: ubuntu-20.04 , ruby: head } - { os: macos-11 , ruby: head } - - { os: windows-2022 , ruby: ucrt } + # Dec-2023 - incorrect line directives with Windows + # occurs with both MSYS2 and MSFT/vpkg versions of ragel + # - { os: windows-2022 , ruby: ucrt } steps: # windows git will convert \n to \r\n - name: git config - if: startsWith(matrix.os, 'windows') + if: | + startsWith(matrix.os, 'windows') && + (needs.skip_duplicate_runs.outputs.should_skip != 'true') run: | git config --global core.autocrlf false git config --global core.eol lf - name: repo checkout - uses: actions/checkout@v3 + if: ${{ needs.skip_duplicate_runs.outputs.should_skip != 'true' }} + uses: actions/checkout@v4 - name: load ruby + if: ${{ needs.skip_duplicate_runs.outputs.should_skip != 'true' }} uses: ruby/setup-ruby-pkgs@v1 with: ruby-version: ${{ matrix.ruby }} @@ -51,12 +58,13 @@ timeout-minutes: 10 - name: check ragel generation + if: ${{ needs.skip_duplicate_runs.outputs.should_skip != 'true' }} shell: pwsh run: | ragel --version Remove-Item -Path ext/puma_http11/http11_parser.c Remove-Item -Path ext/puma_http11/org/jruby/puma/Http11Parser.java - rake ragel + bundle exec rake ragel if ($IsWindows) { dos2unix ext/puma_http11/http11_parser.c dos2unix ext/puma_http11/org/jruby/puma/Http11Parser.java @@ -70,7 +78,7 @@ } - name: save ragel generated files on fail - uses: actions/upload-artifact@v3 + uses: actions/upload-artifact@v4 if: ${{ failure() }} with: name: ${{ matrix.os }}-ragel-generated-files diff -Nru puma-5.6.5/.github/workflows/skip_duplicate_workflow_runs.yml puma-6.4.2/.github/workflows/skip_duplicate_workflow_runs.yml --- puma-5.6.5/.github/workflows/skip_duplicate_workflow_runs.yml 1970-01-01 00:00:00.000000000 +0000 +++ puma-6.4.2/.github/workflows/skip_duplicate_workflow_runs.yml 2024-01-08 05:53:42.000000000 +0000 @@ -0,0 +1,32 @@ +name: Skip Duplicate Workflow Runs + +on: + workflow_call: + inputs: + paths: + description: 'A JSON-array with path patterns' + default: '[]' + required: false + type: string + outputs: + should_skip: + description: "The output from the skip_duplicate_runs job" + value: ${{ jobs.skip_duplicate_runs.outputs.should_skip }} + +permissions: + contents: read + +jobs: + skip_duplicate_runs: + name: 'Skip Duplicate Runs' + runs-on: ubuntu-latest + outputs: + should_skip: ${{ steps.skip_check.outputs.should_skip }} + steps: + - id: skip_check + uses: fkirc/skip-duplicate-actions@v5.3.1 + with: + paths_ignore: '["**.md"]' + paths: ${{ inputs.paths }} + concurrent_skipping: 'same_content_newer' # skip newer runs with same content + skip_after_successful_duplicate: 'true' diff -Nru puma-5.6.5/.github/workflows/tests.yaml puma-6.4.2/.github/workflows/tests.yaml --- puma-5.6.5/.github/workflows/tests.yaml 2022-08-22 23:33:14.000000000 +0000 +++ puma-6.4.2/.github/workflows/tests.yaml 1970-01-01 00:00:00.000000000 +0000 @@ -1,184 +0,0 @@ -name: Tests - -on: - push: - paths-ignore: - - '**.md' - pull_request: - paths-ignore: - - '**.md' - workflow_dispatch: - -jobs: - rubocop: - name: 'Rubocop linting' - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v3 - - uses: ruby/setup-ruby@v1 - with: - ruby-version: 2.6 - bundler-cache: true # `bundle install` and cache - - name: rubocop - run: bundle exec rake rubocop - - test_mri: - name: >- - MRI: ${{ matrix.os }} ${{ matrix.ruby }}${{ matrix.no-ssl }}${{ matrix.yjit }} - needs: rubocop - env: - CI: true - TESTOPTS: -v - PUMA_NO_RUBOCOP: true - - 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: mswin } - - { 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' } - - { os: ubuntu-22.04 , ruby: 3.1 } - - { os: ubuntu-22.04 , ruby: head } - - 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@v3 - - - name: load ruby - uses: ruby/setup-ruby-pkgs@v1 - with: - ruby-version: ${{ matrix.ruby }} - apt-get: ragel - brew: ragel - # below is only needed for Rubies 2.4 and earlier - mingw: openssl ragel - bundler-cache: true - 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 - - # fixes 'has a bug that prevents `required_ruby_version`' - - name: update rubygems for Ruby 2.3 - 2.5 - if: contains('2.3 2.4 2.5', matrix.ruby) - run: gem update --system 3.3.14 --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: 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 - test_non_mri: - name: >- - NON-MRI: ${{ matrix.os }} ${{ matrix.ruby }}${{ matrix.no-ssl }} - needs: rubocop - env: - CI: true - TESTOPTS: -v - PUMA_NO_RUBOCOP: true - - 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@v3 - - - 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: ruby/setup-ruby-pkgs@v1 - with: - ruby-version: ${{ matrix.ruby }} - apt-get: ragel - brew: ragel - bundler: none - 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 -Nru puma-5.6.5/.github/workflows/tests.yml puma-6.4.2/.github/workflows/tests.yml --- puma-5.6.5/.github/workflows/tests.yml 1970-01-01 00:00:00.000000000 +0000 +++ puma-6.4.2/.github/workflows/tests.yml 2024-01-08 05:53:42.000000000 +0000 @@ -0,0 +1,206 @@ +name: Tests + +on: [push, pull_request, workflow_dispatch] + +permissions: + contents: read # to fetch code (actions/checkout) + +jobs: + skip_duplicate_runs: + uses: ./.github/workflows/skip_duplicate_workflow_runs.yml + + rubocop: + name: RuboCop linting + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: ruby/setup-ruby@v1 + with: + ruby-version: 3.1 + bundler-cache: true # `bundle install` and cache + - name: rubocop + run: bundle exec rake rubocop + + test_mri: + name: >- + MRI: ${{ matrix.os }} ${{ matrix.ruby }}${{ matrix.no-ssl }}${{ matrix.yjit }}${{ matrix.rack-v }} + needs: [rubocop, skip_duplicate_runs] + env: + CI: true + PUMA_TEST_DEBUG: true + TESTOPTS: -v + PUMA_NO_RUBOCOP: true + + 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-22.04, macos-11, macos-12, macos-13, windows-2022 ] + ruby: [ 2.4, 2.5, 2.6, 2.7, '3.0', 3.1, 3.2, 3.3, head ] + no-ssl: [''] + rack-v: [''] + yjit: [''] + include: + - { os: windows-2022 , ruby: ucrt } + - { os: windows-2022 , ruby: mswin } + - { os: windows-2022 , ruby: 2.7 , no-ssl: ' no SSL' } + - { os: ubuntu-20.04 , ruby: 2.7 , no-ssl: ' no SSL' } + - { os: ubuntu-22.04 , ruby: head , yjit: ' yjit' } + - { os: ubuntu-22.04 , ruby: 2.4 , rack-v: ' rack2' } + - { os: ubuntu-22.04 , ruby: 3.2 , rack-v: ' rack2' } + - { os: ubuntu-22.04 , ruby: 2.4 , rack-v: ' rack1' } + + exclude: + - { os: ubuntu-22.04 , ruby: 2.4 } + - { os: ubuntu-22.04 , ruby: 2.5 } + - { os: ubuntu-22.04 , ruby: 2.6 } + - { os: ubuntu-22.04 , ruby: 2.7 } + - { os: ubuntu-22.04 , ruby: 3.0 } + - { os: macos-11 , ruby: 2.5 } + - { os: macos-11 , ruby: 2.6 } + - { os: macos-11 , ruby: '3.0' } + - { os: macos-11 , ruby: 3.1 } + - { os: macos-11 , ruby: head } + - { os: macos-13 , ruby: 2.5 } + - { os: macos-13 , ruby: 2.6 } + - { os: macos-13 , ruby: '3.0' } + - { os: macos-13 , ruby: 3.1 } + - { os: macos-13 , ruby: 2.6 } + - { os: macos-13 , ruby: head } + - { os: windows-2022 , ruby: head } + + steps: + - name: repo checkout + if: ${{ needs.skip_duplicate_runs.outputs.should_skip != 'true' }} + uses: actions/checkout@v4 + + - name: Compile Puma without SSL support + if: | + (matrix.no-ssl == ' no SSL') && + (needs.skip_duplicate_runs.outputs.should_skip != 'true') + shell: bash + run: echo 'PUMA_DISABLE_SSL=true' >> $GITHUB_ENV + + - name: Set Rack version, see Gemfile + shell: bash + run: echo 'PUMA_CI_RACK=${{ matrix.rack-v }}' >> $GITHUB_ENV + + - name: load ruby + if: ${{ needs.skip_duplicate_runs.outputs.should_skip != 'true' }} + uses: ruby/setup-ruby-pkgs@v1 + with: + ruby-version: ${{ matrix.ruby }} + apt-get: ragel + brew: ragel + # below is only needed for Ruby 2.4 + mingw: openssl + rubygems: latest + bundler-cache: true + timeout-minutes: 10 + + - name: Repo & Commit Info + if: ${{ needs.skip_duplicate_runs.outputs.should_skip != 'true' }} + run: ruby .github/workflows/github_actions_info.rb + + - name: set WERRORFLAG + if: ${{ needs.skip_duplicate_runs.outputs.should_skip != 'true' }} + shell: bash + run: echo 'PUMA_MAKE_WARNINGS_INTO_ERRORS=true' >> $GITHUB_ENV + + - name: compile + if: ${{ needs.skip_duplicate_runs.outputs.should_skip != 'true' }} + run: bundle exec rake compile + + - name: Use yjit + if: | + (matrix.yjit == ' yjit') && + (needs.skip_duplicate_runs.outputs.should_skip != 'true') + shell: bash + run: echo 'RUBYOPT=--yjit' >> $GITHUB_ENV + + - name: test + if: ${{ needs.skip_duplicate_runs.outputs.should_skip != 'true' }} + timeout-minutes: 6 + run: test/runner --verbose + + test_non_mri: + name: >- + NON-MRI: ${{ matrix.os }} ${{ matrix.ruby }}${{ matrix.no-ssl }} + needs: [rubocop, skip_duplicate_runs] + env: + CI: true + PUMA_TEST_DEBUG: true + TESTOPTS: -v + PUMA_NO_RUBOCOP: true + + 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: + # tto - test timeout + - { tto: 8 , os: ubuntu-22.04 , ruby: jruby } + - { tto: 8 , os: ubuntu-22.04 , ruby: jruby, no-ssl: ' no SSL' } + - { tto: 8 , os: ubuntu-22.04 , ruby: jruby-head, allow-failure: true } + - { tto: 8 , os: ubuntu-20.04 , ruby: truffleruby, allow-failure: true } # Until https://github.com/oracle/truffleruby/issues/2700 is solved + - { tto: 8 , os: ubuntu-20.04 , ruby: truffleruby-head, allow-failure: true } + - { tto: 8 , os: ubuntu-22.04 , ruby: truffleruby, allow-failure: true } # Until https://github.com/oracle/truffleruby/issues/2700 is solved + - { tto: 8 , os: ubuntu-22.04 , ruby: truffleruby-head, allow-failure: true } + - { tto: 8 , os: macos-12 , ruby: jruby } + - { tto: 8 , os: macos-12 , ruby: truffleruby, allow-failure: true } + + steps: + - name: repo checkout + if: ${{ needs.skip_duplicate_runs.outputs.should_skip != 'true' }} + uses: actions/checkout@v4 + + - name: set JAVA_HOME to JDK 17 + if: (needs.skip_duplicate_runs.outputs.should_skip != 'true') + run: echo JAVA_HOME=$JAVA_HOME_17_X64 >> $GITHUB_ENV + + - name: load ruby, ragel + if: ${{ needs.skip_duplicate_runs.outputs.should_skip != 'true' }} + uses: ruby/setup-ruby-pkgs@v1 + with: + ruby-version: ${{ matrix.ruby }} + apt-get: ragel + brew: ragel + bundler: none + bundler-cache: true + timeout-minutes: 10 + + - name: Compile Puma without SSL support + if: | + (matrix.no-ssl == ' no SSL') && + (needs.skip_duplicate_runs.outputs.should_skip != 'true') + shell: bash + run: echo 'PUMA_DISABLE_SSL=true' >> $GITHUB_ENV + + - name: set WERRORFLAG + if: ${{ needs.skip_duplicate_runs.outputs.should_skip != 'true' }} + shell: bash + run: echo 'PUMA_MAKE_WARNINGS_INTO_ERRORS=true' >> $GITHUB_ENV + + - name: compile + if: ${{ needs.skip_duplicate_runs.outputs.should_skip != 'true' }} + run: bundle exec rake compile + + - name: test + id: test + timeout-minutes: ${{ matrix.tto }} + continue-on-error: ${{ matrix.allow-failure || false }} + if: | # only run if previous steps have succeeded + success() && + (needs.skip_duplicate_runs.outputs.should_skip != 'true') + run: test/runner --verbose + + - name: >- + Test outcome: ${{ steps.test.outcome }} + # every step must define a `uses` or `run` key + run: echo NOOP diff -Nru puma-5.6.5/.github/workflows/turbo-rails.yml puma-6.4.2/.github/workflows/turbo-rails.yml --- puma-5.6.5/.github/workflows/turbo-rails.yml 1970-01-01 00:00:00.000000000 +0000 +++ puma-6.4.2/.github/workflows/turbo-rails.yml 2024-01-08 05:53:42.000000000 +0000 @@ -0,0 +1,75 @@ +name: turbo-rails + +# Note: turbo-rails often returns an ActionDispatch::Response::RackBody for the +# body. Also, Rack::BodyProxy or Sprockets::Asset + +on: [push, pull_request, workflow_dispatch] + +permissions: + contents: read # to fetch code (actions/checkout) + +jobs: + skip_duplicate_runs: + uses: ./.github/workflows/skip_duplicate_workflow_runs.yml + + turbo-rails: + name: >- + ${{ matrix.os }} Ruby ${{ matrix.ruby }} Rails ${{ matrix.rails }} + needs: skip_duplicate_runs + runs-on: ${{ matrix.os }} + if: | + !( contains(github.event.pull_request.title, '[ci skip]') + || contains(github.event.pull_request.title, '[skip ci]') + || (needs.skip_duplicate_runs.outputs.should_skip == 'true')) + strategy: + fail-fast: false + matrix: + include: + - { os: ubuntu-20.04 , ruby: '2.7', rails: '6.1' } + - { os: ubuntu-20.04 , ruby: '3.1', rails: '7.0' } + - { os: ubuntu-20.04 , ruby: '3.2', rails: '7.0' } + - { os: ubuntu-22.04 , ruby: '3.3', rails: '7.0' } + - { os: ubuntu-22.04 , ruby: head , rails: '7.0' } + env: + CI: true + RAILS_VERSION: "${{ matrix.rails }}" + + steps: + - name: checkout hotwired/turbo-rails + uses: actions/checkout@v4 + with: + repository: hotwired/turbo-rails + ref: main + + - name: turbo-rails updates + run: | + # use repo & commit being tested, $GITHUB_REPOSITORY allows forks to work + SRC="gem ['\"]puma['\"].*" + DST="gem 'puma', git: 'https://github.com/$GITHUB_REPOSITORY.git', ref: '$GITHUB_SHA'" + sed -i "s#$SRC#$DST#" Gemfile + # + # allow using capybara from the repo, either a branch or a commit + # comment out if CI works with current release + # SRC="gem ['\"]capybara['\"].*" + # DST="kw =\n if RUBY_VERSION.start_with? '3'\n {git: 'https://github.com/teamcapybara/capybara.git', ref: '43e32a8495'}\n else\n {}\n end\n gem 'capybara', **kw" + # sed -i "s#$SRC#$DST#" Gemfile + # + # use `stdio` for log_writer, always have one thread existing + SRC="Silent: true" + DST="Silent: false, Threads: '1:4'" + sed -i "s/$SRC/$DST/" test/application_system_test_case.rb + + - name: load ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: ${{ matrix.ruby }} + rubygems: latest + bundler-cache: true + timeout-minutes: 10 + + - name: turbo-rails Gemfile.lock + run: cat Gemfile.lock + + - name: turbo-rails test + id: test + run: bin/test test/**/*_test.rb -vd diff -Nru puma-5.6.5/.rubocop.yml puma-6.4.2/.rubocop.yml --- puma-5.6.5/.rubocop.yml 2022-08-22 23:33:14.000000000 +0000 +++ puma-6.4.2/.rubocop.yml 2024-01-08 05:53:42.000000000 +0000 @@ -1,6 +1,9 @@ +require: + - rubocop-performance + AllCops: DisabledByDefault: true - TargetRubyVersion: 2.2 + TargetRubyVersion: 2.4 DisplayCopNames: true StyleGuideCopsOnly: false Exclude: @@ -9,6 +12,21 @@ - 'examples/**/*' - 'pkg/**/*' - 'Rakefile' + SuggestExtensions: false + NewCops: enable + +# enable all Performance cops +Performance: + Enabled: true + +# ————————————————————————————————————————— disabled cops + +# ————————————————————————————————————————— enabled cops +Layout/AccessModifierIndentation: + EnforcedStyle: indent + +Layout/IndentationStyle: + Enabled: true Layout/SpaceAfterColon: Enabled: true @@ -26,10 +44,7 @@ Layout/SpaceInsideParens: Enabled: true -Layout/Tab: - Enabled: true - -Layout/TrailingBlankLines: +Layout/TrailingEmptyLines: Enabled: true Layout/TrailingWhitespace: @@ -38,6 +53,12 @@ Lint/Debugger: Enabled: true +Metrics/ParameterLists: + Max: 7 + +Naming/ConstantName: + Enabled: true + Naming/MethodName: Enabled: true EnforcedStyle: snake_case @@ -50,32 +71,14 @@ Style/MethodDefParentheses: Enabled: true -Style/TrailingCommaInArguments: - Enabled: true - -Performance: +Style/SafeNavigation: Enabled: true -Metrics/ParameterLists: - Max: 7 - -Performance/RedundantMatch: - Enabled: true - -Performance/RedundantBlockCall: +Style/TernaryParentheses: Enabled: true -Performance/StringReplacement: +Style/TrailingCommaInArguments: Enabled: true -Layout/AccessModifierIndentation: - EnforcedStyle: indent - Style/WhileUntilModifier: Enabled: true - -Style/TernaryParentheses: - Enabled: true - -Style/RedundantReturn: - Enabled: true diff -Nru puma-5.6.5/6.0-Upgrade.md puma-6.4.2/6.0-Upgrade.md --- puma-5.6.5/6.0-Upgrade.md 1970-01-01 00:00:00.000000000 +0000 +++ puma-6.4.2/6.0-Upgrade.md 2024-01-08 05:53:42.000000000 +0000 @@ -0,0 +1,56 @@ +# Welcome to Puma 6: Sunflower. + +![Image by Todd Trapani, Unsplash](https://user-images.githubusercontent.com/845662/192706685-774d3d0d-f4a9-4b93-b27b-5a3b7f44ff31.jpg) + +Puma 6 brings performance improvements for most applications, experimental Rack 3 support, support for Sidekiq 7 Capsules, and more. + +Here's what you should do: + +1. Review the Upgrade section below to look for breaking changes that could affect you. +2. Upgrade to version 6.0 in your Gemfile and deploy. +3. Open up a new bug issue if you find any problems. +4. Join us in building Puma! We welcome first-timers. See [CONTRIBUTING.md](./CONTRIBUTING.md). + +For a complete list of changes, see [History.md](./History.md). + +## What's New + +Puma 6 is mostly about a few nice-to-have performance changes, and then a few breaking API changes we've been putting off for a while. + +### Improved Performance + +We've improved throughput and latency in Puma 6 in a few areas. + +1. **Large chunked response body throughput 3-10x higher** Chunked response bodies >100kb should be 3 to 10 times faster than in Puma 5. String response bodies should be ~10% faster. +2. **File response throughput is 3x higher.** File responses (e.g. assets) should be about 3x faster. +3. **wait_for_less_busy_worker is now default, meaning lower latencies for high-utilization servers** `wait_for_less_busy_worker` was an experimental feature in Puma 5 and it's now the default in Puma 6. This feature makes each Puma child worker in cluster mode wait before listening on the socket, and that wait time is proportional to N * `number_of_threads_responding_to_requests`. This means that it's more likely that a request is picked up by the least-loaded Puma child worker listening on the socket. Many users reported back that this option was stable and decreased average latency, particularly in environments with high load and utilization. + +### Experimental Rack 3 Support + +[Rack 3 is now out](https://github.com/rack/rack/blob/main/UPGRADE-GUIDE.md) and we've started on Rack 3 support. Please open a bug if you find any incompatibilites. + +### Sidekiq 7 Capsules + +Sidekiq 7 (releasing soon) introduces Capsules, which allows you to run a Sidekiq server inside your Puma server (or any other Ruby process for that matter). We've added support by allowing you to pass data into `run_hooks`, see [issue #2915](https://github.com/puma/puma/issues/2915). + +## Upgrade + +Check the following list to see if you're depending on any of these behaviors: + +1. Configuration constants like `DefaultRackup` removed, see [#2928](https://github.com/puma/puma/pull/2928/files#diff-2dc4e3e83be7fd97cebc482ae07d6a8216944003de82458783fb00b5ae9524c8) for the full list. +1. We have changed the names of the following environment variables: `DISABLE_SSL` is now `PUMA_DISABLE_SSL`, `MAKE_WARNINGS_INTO_ERRORS` is now `PUMA_MAKE_WARNINGS_INTO_ERRORS`, and `WAIT_FOR_LESS_BUSY_WORKERS` is now `PUMA_WAIT_FOR_LESS_BUSY_WORKERS`. +1. Nakayoshi GC (`nakayoshi_fork` option in config) has been removed without replacement. +1. `wait_for_less_busy_worker` is now on by default. If you don't want to use this feature, you must add `wait_for_less_busy_worker false` in your config. +1. We've removed the following public methods on Puma::Server: `Puma::Server#min_threads`, `Puma::Server#max_threads`. Instead, you can pass in configuration as an option to Puma::Server#new. This might make certain gems break (`capybara` for example). +1. We've removed the following constants: `Puma::StateFile::FIELDS`, `Puma::CLI::KEYS_NOT_TO_PERSIST_IN_STATE` and `Puma::Launcher::KEYS_NOT_TO_PERSIST_IN_STATE`, and `Puma::ControlCLI::COMMANDS`. +1. We no longer support Ruby 2.2, 2.3, or JRuby on Java 1.7 or below. +1. The behavior of `remote_addr` has changed. When using the set_remote_address header: "header_name" functionality, if the header is not passed, REMOTE_ADDR is now set to the physical peeraddr instead of always being set to 127.0.0.1. When an error occurs preventing the physical peeraddr from being fetched, REMOTE_ADDR is now set to the unspecified source address ('0.0.0.0') instead of to '127.0.0.1' +1. Previously, Puma supported anything as an HTTP method and passed it to the app. We now only accept the following 8 HTTP methods, based on [RFC 9110, section 9.1](https://www.rfc-editor.org/rfc/rfc9110.html#section-9.1). The [IANA HTTP Method Registry](https://www.iana.org/assignments/http-methods/http-methods.xhtml) contains a full list of HTTP methods. + ``` + HEAD GET POST PUT DELETE OPTIONS TRACE PATCH + ``` + As of Puma 6.2, these can be overridden by `supported_http_methods` in your config file, see `Puma::DSL#supported_http_methods`. + +Then, update your Gemfile: + +`gem 'puma', '< 7'` diff -Nru puma-5.6.5/CONTRIBUTING.md puma-6.4.2/CONTRIBUTING.md --- puma-5.6.5/CONTRIBUTING.md 2022-08-22 23:33:14.000000000 +0000 +++ puma-6.4.2/CONTRIBUTING.md 2024-01-08 05:53:42.000000000 +0000 @@ -25,8 +25,11 @@ ## Setup -First step: join us on Matrix at [#puma-contrib:matrix.org](https://matrix.to/#/!blREBEDhVeXTYdjTVT:matrix.org?via=matrix.org) +Any questions about contributing may be asked in our [Discussions](https://github.com/puma/puma/discussions). +**If you're nervous, get stuck, need help, or want to know where to start and where you can help**, please don't hesitate to [book 30 minutes with maintainer @nateberkopec here](https://calendly.com/nateberkopec/30min). He is happy to help! + +Nate also [gave a 40 minute conference talk in 2022](https://www.youtube.com/watch?v=w4X_oBuPmTM) detailing how Puma works, a brief overview of its internals, and a quick guide on how to contribute. #### Clone the repo @@ -98,9 +101,15 @@ ## Running tests -To run the entire test suite: +To run rubocop + tests: + ```sh -bundle exec rake test:all +bundle exec rake +``` + +To run the test suite only: +```sh +bundle exec rake test ``` To run a single test file: @@ -123,14 +132,22 @@ TEST_CASE_TIMEOUT=5 bundle exec m test/test_binder.rb:37 ``` +If you would like more information about extension building, SSL versions, your local Ruby version, and more, use the PUMA_TEST_DEBUG env variable: + +```sh +PUMA_TEST_DEBUG=1 bundle exec rake test +``` + #### 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 +# check your file limit +ulimit -S -n + +# change file limit for the current session +ulimit -S -n ``` 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/) @@ -139,7 +156,11 @@ Puma could use your help in several areas! -**The [contrib-wanted] label indicates that an issue might approachable to first-time contributors.**\ +**Don't worry about "claiming an issue". No issues are "claimed" in Puma.** Just start working on it. The issue tracker is almost always kept updated, so if there is an open issue, it is ready for you to contribute (unless you have questions about how to close issue - then please ask!). Once you have a few lines of code, post a draft PR. We are more than happy to help once you have a draft PR up. + +**New to systems programming? That's ok!** Puma deals with concepts you may not have been familiar with before, like sockets, TCP, UDP, SSL, and Threads. That's ok! You can learn by contributing. Also, see the "Bibliography" section at the end of this document. + +**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. @@ -188,6 +209,8 @@ ## Pull requests +Please open draft PRs as soon as you are ready for feedback from the community. + 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. @@ -204,7 +227,7 @@ ## 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). +If you're looking to contribute to Puma, please join us in [Discussions](https://github.com/puma/puma/discussions). ## Bibliography/Reading @@ -212,5 +235,6 @@ * [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) +* [Working with...](https://workingwithruby.com/) "Working With" is a excellent (and now free) Ruby book series about working with Threads, TCP and Unix Sockets. * The Ruby docs for IO.pipe, TCPServer/Socket. * [nio4r documentation](https://github.com/socketry/nio4r/wiki/Getting-Started) diff -Nru puma-5.6.5/Gemfile puma-6.4.2/Gemfile --- puma-5.6.5/Gemfile 2022-08-22 23:33:14.000000000 +0000 +++ puma-6.4.2/Gemfile 2024-01-08 05:53:42.000000000 +0000 @@ -2,23 +2,38 @@ gemspec -gem "rdoc" -gem "rake-compiler", "~> 1.1.1" +gem "rake-compiler", "~> 1.1.9" 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" + +use_rackup = false +rack_vers = + case ENV['PUMA_CI_RACK']&.strip + when 'rack2' + '~> 2.2' + when 'rack1' + '~> 1.6' + else + use_rackup = true + '>= 2.2' + end + +gem "rack", rack_vers +gem "rackup" if use_rackup gem "jruby-openssl", :platform => "jruby" -gem "rubocop", "~> 0.64.0" +unless ENV['PUMA_NO_RUBOCOP'] || RUBY_PLATFORM.include?('mswin') + gem "rubocop" + gem 'rubocop-performance', require: false +end -if %w(2.2.7 2.2.8 2.2.9 2.2.10 2.3.4 2.4.1).include? RUBY_VERSION +if RUBY_VERSION == '2.4.1' gem "stopgap_13632", "~> 1.0", :platforms => ["mri", "mingw", "x64_mingw"] end diff -Nru puma-5.6.5/History.md puma-6.4.2/History.md --- puma-5.6.5/History.md 2022-08-22 23:33:14.000000000 +0000 +++ puma-6.4.2/History.md 2024-01-08 05:53:42.000000000 +0000 @@ -1,8 +1,200 @@ +## 6.4.2 / 2024-01-08 + +* Security + * Limit the size of chunk extensions. Without this limit, an attacker could cause unbounded resource (CPU, network bandwidth) consumption. ([GHSA-c2f4-cvqm-65w2](https://github.com/puma/puma/security/advisories/GHSA-c2f4-cvqm-65w2)) + +## 6.4.1 / 2024-01-03 + +* Bugfixes + * DSL#warn_if_in_single_mode - fixup when workers set via CLI ([#3256]) + * Fix `idle-timeout` not working in cluster mode ([#3235], [#3228], [#3282], [#3283]) + * Fix worker 0 timing out during phased restart ([#3225], [#2786]) + * context_builder.rb - require openssl if verify_mode != 'none' ([#3179]) + * Make puma cluster process suitable as PID 1 ([#3255]) + * Improve Puma::NullIO consistency with real IO ([#3276]) + * extconf.rb - fixup to detect openssl info in Ruby build ([#3271], [#3266]) + * MiniSSL.java - set serialVersionUID, fix RaiseException deprecation ([#3270]) + * dsl.rb - fix warn_if_in_single_mode when WEB_CONCURRENCY is set ([#3265], [#3264]) + +* Maintenance + * LOTS of test refactoring to make tests more stable and easier to write - thanks to @MSP-Greg! + * Fix bug in tests re: TestPuma::HOST4 ([#3254]) + * Dockerfile for minimal repros: use Ruby 3.2, expect bundler installed ([#3245]) + * fix define_method calls, use Symbol parameter instead of String ([#3293]) + +* Docs + * README.md - add the puma-acme plugin ([#3301]) + * Remove `--keep-file-descriptors` flag from systemd docs ([#3248]) + * Note symlink mechanism in restart documentation for hot restart ([#3298]) + +## 6.4.0 / 2023-09-21 + +* Features + * on_thread_exit hook ([#2920]) + * on_thread_start_hook ([#3195]) + * Shutdown on idle ([#3209], [#2580]) + * New error message when control server port taken ([#3204]) + +* Refactor + * Remove `Forwardable` dependency ([#3191], #3190) + * Update URLMap Regexp usage for Ruby v3.3 ([#3165]) + +* Bugfixes + * Bring the cert_pem: parameter into parity with the cert: parameter to ssl_bind. ([#3174]) + * Fix using control server with IPv6 host ([#3181]) + * control_cli.rb - add require_relative 'log_writer' ([#3187]) + * Fix cases where fallback Rack response wasn't sent to the client ([#3094]) + +## 6.3.1 / 2023-08-18 + +* Security + * Address HTTP request smuggling vulnerabilities with zero-length Content Length header and trailer fields ([GHSA-68xg-gqqm-vgj8](https://github.com/puma/puma/security/advisories/GHSA-68xg-gqqm-vgj8)) + +## 6.3.0 / 2023-05-31 + +* Features + * Add dsl method `supported_http_methods` ([#3106], [#3014]) + * Puma error responses no longer have any fingerprints to indicate Puma ([#3161], [#3037]) + * Support decryption of SSL key ([#3133], [#3132]) + +* Bugfixes + * Don't send 103 early hints response when only invalid headers are used ([#3163]) + * Handle malformed request path ([#3155], [#3148]) + * Misc lib file fixes - trapping additional errors, CI helper ([#3129]) + * Fixup req form data file upload with "r\n" line endings ([#3137]) + * Restore rack 1.6 compatibility ([#3156]) + +* Refactor + * const.rb - Update Puma::HTTP_STATUS_CODES ([#3162]) + * Clarify Reactor#initialize ([#3151]) + +## 6.2.2 / 2023-04-17 + +* Bugfixes + * Fix Rack-related NameError by adding :: operator ([#3118], [#3117]) + +## 6.2.1 / 2023-03-31 + +* Bugfixes + * Fix java 8 compatibility ([#3109], [#3108]) + * Always write io_buffer when in "enum bodies" branch. ([#3113], [#3112]) + * Fix warn_if_in_single_mode incorrect message ([#3111]) + +## 6.2.0 / 2023-03-29 + +* Features + * Ability to supply a custom logger ([#2770], [#2511]) + * Warn when clustered-only hooks are defined in single mode ([#3089]) + * Adds the on_booted event ([#2709]) + +* Bugfixes + * Loggers - internal_write - catch Errno::EINVAL ([#3091]) + * commonlogger.rb - fix HIJACK time format, use constants, not strings ([#3074]) + * Fixed some edge cases regarding request hijacking ([#3072]) + +## 6.1.1 / 2023-02-28 + +* Bugfixes + * We no longer try to use the systemd plugin for JRuby ([#3079]) + * Allow ::Rack::Handler::Puma.run to work regardless of whether Rack/Rackup are loaded ([#3080]) + +## 6.1.0 / 2023-02-12 + +* Features + * WebSocket support via partial hijack ([#3058], [#3007]) + * Add built-in systemd notify support ([#3011]) + * Periodically send status to systemd ([#3006], [#2604]) + * Introduce the ability to return 413: payload too large for requests ([#3040]) + * Log loaded extensions when `PUMA_DEBUG` is set ([#3036], [#3020]) + +* Bugfixes + * Fix issue with rack 3 compatibility re: rackup ([#3061], [#3057]) + * Allow setting TCP low_latency with SSL listener ([#3065]) + +* Performance + * Reduce memory usage for large file uploads ([#3062]) + +## 6.0.2 / 2023-01-01 + +* Refactor + * Remove use of etc and time gems in Puma ([#3035], [#3033]) + * Refactor const.rb - freeze ([#3016]) + +## 6.0.1 / 2022-12-20 + +* Bugfixes + * Handle waking up a closed selector in Reactor#add ([#3005]) + * Fixup response processing, enumerable bodies ([#3004], [#3000]) + * Correctly close app body for all code paths ([#3002], [#2999]) +* Refactor + * Add IOBuffer to Client, remove from ThreadPool thread instances ([#3013]) + +## 6.0.0 / 2022-10-14 + +* Breaking Changes + * Dropping Ruby 2.2 and 2.3 support (now 2.4+) ([#2919]) + * Remote_addr functionality has changed ([#2652], [#2653]) + * No longer supporting Java 1.7 or below (JRuby 9.1 was the last release to support this) ([#2849]) + * Remove nakayoshi GC ([#2933], [#2925]) + * wait_for_less_busy_worker is now default on ([#2940]) + * Prefix all environment variables with `PUMA_` ([#2924], [#2853]) + * Removed some constants ([#2957], [#2958], [#2959], [#2960]) + * The following classes are now part of Puma's private API: `Client`, `Cluster::Worker`, `Cluster::Worker`, `HandleRequest`. ([#2988]) + * Configuration constants like `DefaultRackup` removed ([#2928]) + * Extracted `LogWriter` from `Events` ([#2798]) + * Only accept the standard 8 HTTP methods, others rejected with 501. ([#2932]) + +* Features + * Increase throughput on large (100kb+) response bodies by 3-10x ([#2896], [#2892]) + * Increase throughput on file responses ([#2923]) + * Add support for streaming bodies in Rack. ([#2740]) + * Allow OpenSSL session reuse via a 'reuse' ssl_bind method or bind string query parameter ([#2845]) + * Allow `run_hooks` to pass a hash to blocks for use later ([#2917], [#2915]) + * Allow using `preload_app!` with `fork_worker` ([#2907]) + * Support request_body_wait metric with higher precision ([#2953]) + * Allow header values to be arrays (Rack 3) ([#2936], [#2931]) + * Export Puma/Ruby versions in /stats ([#2875]) + * Allow configuring request uri max length & request path max length ([#2840]) + * Add a couple of public accessors ([#2774]) + * Log entire backtrace when worker start fails ([#2891]) + * [jruby] Enable TLSv1.3 support ([#2886]) + * [jruby] support setting TLS protocols + rename ssl_cipher_list ([#2899]) + * [jruby] Support a truststore option ([#2849], [#2904], [#2884]) + +* Bugfixes + * Load the configuration before passing it to the binder ([#2897]) + * Do not raise error raised on HTTP methods we don't recognize or support, like CONNECT ([#2932], [#1441]) + * Fixed a memory leak when creating a new SSL listener ([#2956]) + +* Refactor + * log_writer.rb - add internal_write method ([#2888]) + * Extract prune_bundler code into it's own class. ([#2797]) + * Refactor Launcher#run to increase readability (no logic change) ([#2795]) + * Ruby 3.2 will have native IO#wait_* methods, don't require io/wait ([#2903]) + * Various internal API refactorings ([#2942], [#2921], [#2922], [#2955]) + +## 5.6.8 / 2024-01-08 + +* Security + * Limit the size of chunk extensions. Without this limit, an attacker could cause unbounded resource (CPU, network bandwidth) consumption. ([GHSA-c2f4-cvqm-65w2](https://github.com/puma/puma/security/advisories/GHSA-c2f4-cvqm-65w2)) + +## 5.6.7 / 2023-08-18 + +* Security + * Address HTTP request smuggling vulnerabilities with zero-length Content Length header and trailer fields ([GHSA-68xg-gqqm-vgj8](https://github.com/puma/puma/security/advisories/GHSA-68xg-gqqm-vgj8)) + +## 5.6.6 / 2023-06-21 + +* Bugfix + * Prevent loading with rack 3 ([#3166]) + ## 5.6.5 / 2022-08-23 +* Feature + * Puma::ControlCLI - allow refork command to be sent as a request ([#2868], [#2866]) + * Bugfixes * NullIO#closed should return false ([#2883]) - * Puma::ControlCLI - allow refork command to be sent as a request ([#2868], [#2866]) * [jruby] Fix TLS verification hang ([#2890], [#2729]) * extconf.rb - don't use pkg_config('openssl') if '--with-openssl-dir' is used ([#2885], [#2839]) * MiniSSL - detect SSL_CTX_set_dh_auto ([#2864], [#2863]) @@ -318,6 +510,16 @@ * Support parallel tests in verbose progress reporting ([#2223]) * Refactor error handling in server accept loop ([#2239]) +## 4.3.12 / 2022-03-30 + +* Security + * Close several HTTP Request Smuggling exploits (CVE-2022-24790) + +## 4.3.11 / 2022-02-11 + +* Security + * Always close the response body (GHSA-rmj8-8hhh-gv5h) + ## 4.3.10 / 2021-10-12 * Bugfixes @@ -1861,15 +2063,144 @@ * Bugfixes * Your bugfix goes here (#Github Number) -[#2883]:https://github.com/puma/puma/pull/2883 "PR by @MSP-Greg, merged 2022-06-02" +[#3256]:https://github.com/puma/puma/pull/3256 "PR by @MSP-Greg, merged 2023-10-16" +[#3235]:https://github.com/puma/puma/pull/3235 "PR by @joshuay03, merged 2023-10-03" +[#3228]:https://github.com/puma/puma/issues/3228 "Issue by @davidalejandroaguilar, closed 2023-10-03" +[#3282]:https://github.com/puma/puma/issues/3282 "Issue by @bensheldon, closed 2024-01-02" +[#3283]:https://github.com/puma/puma/pull/3283 "PR by @joshuay03, merged 2024-01-02" +[#3225]:https://github.com/puma/puma/pull/3225 "PR by @joshuay03, merged 2023-09-27" +[#2786]:https://github.com/puma/puma/issues/2786 "Issue by @vitiokss, closed 2023-09-27" +[#3179]:https://github.com/puma/puma/pull/3179 "PR by @MSP-Greg, merged 2023-09-26" +[#3255]:https://github.com/puma/puma/pull/3255 "PR by @casperisfine, merged 2023-10-19" +[#3276]:https://github.com/puma/puma/pull/3276 "PR by @casperisfine, merged 2023-11-16" +[#3271]:https://github.com/puma/puma/pull/3271 "PR by @MSP-Greg, merged 2023-10-30" +[#3266]:https://github.com/puma/puma/issues/3266 "Issue by @Dragonicity, closed 2023-10-30" +[#3270]:https://github.com/puma/puma/pull/3270 "PR by @MSP-Greg, merged 2023-10-30" +[#3265]:https://github.com/puma/puma/pull/3265 "PR by @MSP-Greg, merged 2023-10-25" +[#3264]:https://github.com/puma/puma/issues/3264 "Issue by @dentarg, closed 2023-10-25" +[#3254]:https://github.com/puma/puma/pull/3254 "PR by @casperisfine, merged 2023-10-11" +[#3245]:https://github.com/puma/puma/pull/3245 "PR by @olleolleolle, merged 2023-10-02" +[#3293]:https://github.com/puma/puma/pull/3293 "PR by @MSP-Greg, merged 2023-12-21" +[#3301]:https://github.com/puma/puma/pull/3301 "PR by @benburkert, merged 2023-12-29" +[#3248]:https://github.com/puma/puma/pull/3248 "PR by @dentarg, merged 2023-10-04" +[#3298]:https://github.com/puma/puma/pull/3298 "PR by @til, merged 2023-12-26" +[#2920]:https://github.com/puma/puma/pull/2920 "PR by @biinari, merged 2023-07-11" +[#3195]:https://github.com/puma/puma/pull/3195 "PR by @binarygit, merged 2023-08-15" +[#3209]:https://github.com/puma/puma/pull/3209 "PR by @joshuay03, merged 2023-09-04" +[#2580]:https://github.com/puma/puma/issues/2580 "Issue by @schuetzm, closed 2023-09-04" +[#3204]:https://github.com/puma/puma/pull/3204 "PR by @dhavalsingh, merged 2023-08-25" +[#3191]:https://github.com/puma/puma/pull/3191 "PR by @MSP-Greg, merged 2023-08-31" +[#3165]:https://github.com/puma/puma/pull/3165 "PR by @fallwith, merged 2023-06-06" +[#3174]:https://github.com/puma/puma/pull/3174 "PR by @copiousfreetime, merged 2023-06-11" +[#3181]:https://github.com/puma/puma/pull/3181 "PR by @MSP-Greg, merged 2023-06-23" +[#3187]:https://github.com/puma/puma/pull/3187 "PR by @MSP-Greg, merged 2023-06-30" +[#3094]:https://github.com/puma/puma/pull/3094 "PR by @Vuta, merged 2023-07-23" +[#3106]:https://github.com/puma/puma/pull/3106 "PR by @MSP-Greg, merged 2023-05-29" +[#3014]:https://github.com/puma/puma/issues/3014 "Issue by @kyledrake, closed 2023-05-29" +[#3161]:https://github.com/puma/puma/pull/3161 "PR by @MSP-Greg, merged 2023-05-27" +[#3037]:https://github.com/puma/puma/issues/3037 "Issue by @daisy1754, closed 2023-05-27" +[#3133]:https://github.com/puma/puma/pull/3133 "PR by @stanhu, merged 2023-04-30" +[#3132]:https://github.com/puma/puma/issues/3132 "Issue by @stanhu, closed 2023-04-30" +[#3163]:https://github.com/puma/puma/pull/3163 "PR by @MSP-Greg, merged 2023-05-27" +[#3155]:https://github.com/puma/puma/pull/3155 "PR by @dentarg, merged 2023-05-14" +[#3148]:https://github.com/puma/puma/issues/3148 "Issue by @dentarg, closed 2023-05-14" +[#3129]:https://github.com/puma/puma/pull/3129 "PR by @MSP-Greg, merged 2023-05-02" +[#3137]:https://github.com/puma/puma/pull/3137 "PR by @MSP-Greg, merged 2023-04-30" +[#3156]:https://github.com/puma/puma/pull/3156 "PR by @severin, merged 2023-05-16" +[#3162]:https://github.com/puma/puma/pull/3162 "PR by @MSP-Greg, merged 2023-05-23" +[#3151]:https://github.com/puma/puma/pull/3151 "PR by @nateberkopec, merged 2023-05-12" +[#3118]:https://github.com/puma/puma/pull/3118 "PR by @ninoseki, merged 2023-04-01" +[#3117]:https://github.com/puma/puma/issues/3117 "Issue by @ninoseki, closed 2023-04-01" +[#3109]:https://github.com/puma/puma/pull/3109 "PR by @ahorek, merged 2023-03-31" +[#3108]:https://github.com/puma/puma/issues/3108 "Issue by @treviateo, closed 2023-03-31" +[#3113]:https://github.com/puma/puma/pull/3113 "PR by @collinsauve, merged 2023-03-31" +[#3112]:https://github.com/puma/puma/issues/3112 "Issue by @dmke, closed 2023-03-31" +[#3111]:https://github.com/puma/puma/pull/3111 "PR by @adzap, merged 2023-03-30" +[#2770]:https://github.com/puma/puma/pull/2770 "PR by @vzajkov, merged 2023-03-29" +[#2511]:https://github.com/puma/puma/issues/2511 "Issue by @jchristie55332, closed 2021-12-12" +[#3089]:https://github.com/puma/puma/pull/3089 "PR by @Vuta, merged 2023-03-06" +[#2709]:https://github.com/puma/puma/pull/2709 "PR by @rodzyn, merged 2023-02-20" +[#3091]:https://github.com/puma/puma/pull/3091 "PR by @MSP-Greg, merged 2023-03-28" +[#3074]:https://github.com/puma/puma/pull/3074 "PR by @MSP-Greg, merged 2023-03-14" +[#3072]:https://github.com/puma/puma/pull/3072 "PR by @MSP-Greg, merged 2023-02-17" +[#3079]:https://github.com/puma/puma/pull/3079 "PR by @mohamedhafez, merged 2023-02-24" +[#3080]:https://github.com/puma/puma/pull/3080 "PR by @MSP-Greg, merged 2023-02-16" +[#3058]:https://github.com/puma/puma/pull/3058 "PR by @dentarg, merged 2023-01-29" +[#3007]:https://github.com/puma/puma/issues/3007 "Issue by @MSP-Greg, closed 2023-01-29" +[#3011]:https://github.com/puma/puma/pull/3011 "PR by @joaomarcos96, merged 2023-01-03" +[#3006]:https://github.com/puma/puma/pull/3006 "PR by @QWYNG, merged 2023-02-09" +[#2604]:https://github.com/puma/puma/issues/2604 "Issue by @dgoetz, closed 2023-02-09" +[#3040]:https://github.com/puma/puma/pull/3040 "PR by @shayonj, merged 2023-01-02" +[#3036]:https://github.com/puma/puma/pull/3036 "PR by @MSP-Greg, merged 2023-01-13" +[#3020]:https://github.com/puma/puma/issues/3020 "Issue by @dentarg, closed 2023-01-13" +[#3061]:https://github.com/puma/puma/pull/3061 "PR by @MSP-Greg, merged 2023-02-12" +[#3057]:https://github.com/puma/puma/issues/3057 "Issue by @mmarvb8h, closed 2023-02-12" +[#3065]:https://github.com/puma/puma/pull/3065 "PR by @MSP-Greg, merged 2023-02-11" +[#3062]:https://github.com/puma/puma/pull/3062 "PR by @willkoehler, merged 2023-01-29" +[#3035]:https://github.com/puma/puma/pull/3035 "PR by @MSP-Greg, merged 2022-12-24" +[#3033]:https://github.com/puma/puma/issues/3033 "Issue by @jules-w2, closed 2022-12-24" +[#3016]:https://github.com/puma/puma/pull/3016 "PR by @MSP-Greg, merged 2022-12-24" +[#3005]:https://github.com/puma/puma/pull/3005 "PR by @JuanitoFatas, merged 2022-11-04" +[#3004]:https://github.com/puma/puma/pull/3004 "PR by @MSP-Greg, merged 2022-11-24" +[#3000]:https://github.com/puma/puma/issues/3000 "Issue by @dentarg, closed 2022-11-24" +[#3002]:https://github.com/puma/puma/pull/3002 "PR by @MSP-Greg, merged 2022-11-03" +[#2999]:https://github.com/puma/puma/issues/2999 "Issue by @aymeric-ledorze, closed 2022-11-03" +[#3013]:https://github.com/puma/puma/pull/3013 "PR by @MSP-Greg, merged 2022-11-13" +[#2919]:https://github.com/puma/puma/pull/2919 "PR by @MSP-Greg, merged 2022-08-30" +[#2652]:https://github.com/puma/puma/issues/2652 "Issue by @Roguelazer, closed 2022-09-04" +[#2653]:https://github.com/puma/puma/pull/2653 "PR by @Roguelazer, closed 2022-03-07" +[#2849]:https://github.com/puma/puma/pull/2849 "PR by @kares, merged 2022-04-09" +[#2933]:https://github.com/puma/puma/pull/2933 "PR by @cafedomancer, merged 2022-09-09" +[#2925]:https://github.com/puma/puma/issues/2925 "Issue by @nateberkopec, closed 2022-09-09" +[#2940]:https://github.com/puma/puma/pull/2940 "PR by @cafedomancer, merged 2022-09-10" +[#2924]:https://github.com/puma/puma/pull/2924 "PR by @cafedomancer, merged 2022-09-07" +[#2853]:https://github.com/puma/puma/issues/2853 "Issue by @nateberkopec, closed 2022-09-07" +[#2957]:https://github.com/puma/puma/pull/2957 "PR by @JuanitoFatas, merged 2022-09-16" +[#2958]:https://github.com/puma/puma/pull/2958 "PR by @JuanitoFatas, merged 2022-09-16" +[#2959]:https://github.com/puma/puma/pull/2959 "PR by @JuanitoFatas, merged 2022-09-16" +[#2960]:https://github.com/puma/puma/pull/2960 "PR by @JuanitoFatas, merged 2022-09-16" +[#2988]:https://github.com/puma/puma/pull/2988 "PR by @MSP-Greg, merged 2022-10-12" +[#2928]:https://github.com/puma/puma/pull/2928 "PR by @nateberkopec, merged 2022-09-10" +[#2798]:https://github.com/puma/puma/pull/2798 "PR by @johnnyshields, merged 2022-02-05" +[#2932]:https://github.com/puma/puma/pull/2932 "PR by @mrzasa, merged 2022-09-12" +[#2896]:https://github.com/puma/puma/pull/2896 "PR by @MSP-Greg, merged 2022-09-13" +[#2892]:https://github.com/puma/puma/pull/2892 "PR by @guilleiguaran, closed 2022-09-13" +[#2923]:https://github.com/puma/puma/pull/2923 "PR by @nateberkopec, merged 2022-09-09" +[#2740]:https://github.com/puma/puma/pull/2740 "PR by @ioquatix, merged 2022-01-29" +[#2845]:https://github.com/puma/puma/issues/2845 "Issue by @donv, closed 2022-03-22" +[#2917]:https://github.com/puma/puma/pull/2917 "PR by @MSP-Greg, merged 2022-09-19" +[#2915]:https://github.com/puma/puma/issues/2915 "Issue by @mperham, closed 2022-09-19" +[#2907]:https://github.com/puma/puma/pull/2907 "PR by @casperisfine, merged 2022-09-15" +[#2953]:https://github.com/puma/puma/pull/2953 "PR by @JuanitoFatas, merged 2022-09-14" +[#2936]:https://github.com/puma/puma/pull/2936 "PR by @MSP-Greg, merged 2022-09-09" +[#2931]:https://github.com/puma/puma/issues/2931 "Issue by @dentarg, closed 2022-09-09" +[#2875]:https://github.com/puma/puma/pull/2875 "PR by @ylecuyer, merged 2022-05-19" +[#2840]:https://github.com/puma/puma/pull/2840 "PR by @LukaszMaslej, merged 2022-04-13" +[#2774]:https://github.com/puma/puma/pull/2774 "PR by @ob-stripe, merged 2022-01-31" +[#2891]:https://github.com/puma/puma/pull/2891 "PR by @gingerlime, merged 2022-06-02" +[#2886]:https://github.com/puma/puma/pull/2886 "PR by @kares, merged 2022-05-30" +[#2899]:https://github.com/puma/puma/pull/2899 "PR by @kares, merged 2022-07-04" +[#2904]:https://github.com/puma/puma/pull/2904 "PR by @kares, merged 2022-08-27" +[#2884]:https://github.com/puma/puma/pull/2884 "PR by @kares, merged 2022-05-30" +[#2897]:https://github.com/puma/puma/pull/2897 "PR by @Edouard-chin, merged 2022-08-27" +[#1441]:https://github.com/puma/puma/issues/1441 "Issue by @nirvdrum, closed 2022-09-12" +[#2956]:https://github.com/puma/puma/pull/2956 "PR by @MSP-Greg, merged 2022-09-15" +[#2888]:https://github.com/puma/puma/pull/2888 "PR by @MSP-Greg, merged 2022-06-01" +[#2797]:https://github.com/puma/puma/pull/2797 "PR by @johnnyshields, merged 2022-02-01" +[#2795]:https://github.com/puma/puma/pull/2795 "PR by @johnnyshields, merged 2022-01-31" +[#2903]:https://github.com/puma/puma/pull/2903 "PR by @MSP-Greg, merged 2022-08-27" +[#2942]:https://github.com/puma/puma/pull/2942 "PR by @nateberkopec, merged 2022-09-15" +[#2921]:https://github.com/puma/puma/issues/2921 "Issue by @MSP-Greg, closed 2022-09-15" +[#2922]:https://github.com/puma/puma/issues/2922 "Issue by @MSP-Greg, closed 2022-09-10" +[#2955]:https://github.com/puma/puma/pull/2955 "PR by @cafedomancer, merged 2022-09-15" +[#3166]:https://github.com/puma/puma/pull/3166 "PR by @JoeDupuis, merged 2023-06-08" [#2868]:https://github.com/puma/puma/pull/2868 "PR by @MSP-Greg, merged 2022-06-02" [#2866]:https://github.com/puma/puma/issues/2866 "Issue by @slondr, closed 2022-06-02" -[#2888]:https://github.com/puma/puma/pull/2888 "PR by @MSP-Greg, merged 2022-06-01" +[#2883]:https://github.com/puma/puma/pull/2883 "PR by @MSP-Greg, merged 2022-06-02" [#2890]:https://github.com/puma/puma/pull/2890 "PR by @kares, merged 2022-06-01" [#2729]:https://github.com/puma/puma/issues/2729 "Issue by @kares, closed 2022-06-01" [#2885]:https://github.com/puma/puma/pull/2885 "PR by @MSP-Greg, merged 2022-05-30" [#2839]:https://github.com/puma/puma/issues/2839 "Issue by @wlipa, closed 2022-05-30" -[#2882]:https://github.com/puma/puma/pull/2882 "PR by @MSP-Greg, merged 2022-05-19" [#2864]:https://github.com/puma/puma/pull/2864 "PR by @MSP-Greg, merged 2022-04-26" [#2863]:https://github.com/puma/puma/issues/2863 "Issue by @eradman, closed 2022-04-26" [#2861]:https://github.com/puma/puma/pull/2861 "PR by @BlakeWilliams, merged 2022-04-17" @@ -1880,13 +2211,6 @@ [#2838]:https://github.com/puma/puma/pull/2838 "PR by @epsilon-0, merged 2022-03-03" [#2817]:https://github.com/puma/puma/pull/2817 "PR by @khustochka, merged 2022-02-20" [#2810]:https://github.com/puma/puma/pull/2810 "PR by @kzkn, merged 2022-01-27" -[#2899]:https://github.com/puma/puma/pull/2899 "PR by @kares, merged 2022-07-04" -[#2891]:https://github.com/puma/puma/pull/2891 "PR by @gingerlime, merged 2022-06-02" -[#2886]:https://github.com/puma/puma/pull/2886 "PR by @kares, merged 2022-05-30" -[#2884]:https://github.com/puma/puma/pull/2884 "PR by @kares, merged 2022-05-30" -[#2875]:https://github.com/puma/puma/pull/2875 "PR by @ylecuyer, merged 2022-05-19" -[#2840]:https://github.com/puma/puma/pull/2840 "PR by @LukaszMaslej, merged 2022-04-13" -[#2849]:https://github.com/puma/puma/pull/2849 "PR by @kares, merged 2022-04-09" [#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" @@ -1896,7 +2220,7 @@ [#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" +[#2341]:https://github.com/puma/puma/issues/2341 "Issue by @cjlarose, closed 2023-07-23" [#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" @@ -1944,7 +2268,7 @@ [#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" +[#2572]:https://github.com/puma/puma/issues/2572 "Issue by @josef-krabath, 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" @@ -2260,7 +2584,7 @@ [#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" +[#1138]:https://github.com/puma/puma/pull/1138 "PR by @skull-squadron, 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" diff -Nru puma-5.6.5/README.md puma-6.4.2/README.md --- puma-5.6.5/README.md 2022-08-22 23:33:14.000000000 +0000 +++ puma-6.4.2/README.md 2024-01-08 05:53:42.000000000 +0000 @@ -4,21 +4,23 @@ # 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) +[![Actions](https://github.com/puma/puma/workflows/Tests/badge.svg?branch=master)](https://github.com/puma/puma/actions?query=workflow%3ATests) [![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. +Puma is a server for [Rack](https://github.com/rack/rack)-powered HTTP applications written in Ruby. It is: +* **Multi-threaded**. Each request is served in a separate thread. This helps you serve more requests per second with less memory use. +* **Multi-process**. "Pre-forks" in cluster mode, using less memory per-process thanks to copy-on-write memory. +* **Standalone**. With SSL support, zero-downtime rolling restarts and a built-in request bufferer, you can deploy Puma without any reverse proxy. +* **Battle-tested**. Our HTTP parser is inherited from Mongrel and has over 15 years of production use. Puma is currently the most popular Ruby webserver, and is the default server for Ruby on Rails. 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. +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. Truly parallel Ruby implementations (TruffleRuby, JRuby) don't have this limitation. ## Quick Start @@ -108,15 +110,25 @@ $ puma -t 8:32 -w 3 ``` +Or with the `WEB_CONCURRENCY` environment variable: + +``` +$ WEB_CONCURRENCY=3 puma -t 8:32 +``` + 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: +For an in-depth discussion of the tradeoffs of thread and process count settings, [see our docs](https://github.com/puma/puma/blob/9282a8efa5a0c48e39c60d22ca70051a25df9f55/docs/kubernetes.md#workers-per-pod-and-other-config-issues). + +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). + +If the `WEB_CONCURRENCY` environment variable is set to a value > 1 (and `--prune-bundler` has not been specified), preloading will be enabled by default. Otherwise, you can use the `--preload` flag from the command line: ``` $ puma -w 3 --preload ``` -If you're using a configuration file, use the `preload_app!` method: +Or, if you're using a configuration file, you can use the `preload_app!` method: ```ruby # config/puma.rb @@ -124,7 +136,9 @@ preload_app! ``` -Additionally, you can specify a block in your configuration file that will be run on boot of each worker: +Preloading can’t be used with phased restart, since phased restart kills and restarts workers one-by-one, and preloading copies the code of master into the workers. + +When using clustered mode, you can specify a block in your configuration file that will be run on boot of each worker: ```ruby # config/puma.rb @@ -137,12 +151,10 @@ 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. +Constants loaded by your application (such as `Rails`) will not be available in `on_worker_boot` +unless preloading is enabled. -`before_fork` specifies a block to be run before workers are forked: +You can also specify a block to be run before workers are forked, using `before_fork`: ```ruby # config/puma.rb @@ -151,7 +163,14 @@ 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. +You can also specify a block to be run after puma is booted using `on_booted`: + +```ruby +# config/puma.rb +on_booted do + # configuration here +end +``` ### Error handling @@ -192,35 +211,38 @@ ``` $ 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): +#### 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 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: +Add the gem to your Gemfile: ```ruby -# Add the gem to your Gemfile -group(:development) do +group(:development) do gem 'localhost' end +``` -# And require it implicitly using bundler +And require it implicitly using bundler: +```ruby require "bundler" Bundler.require(:default, ENV["RACK_ENV"].to_sym) +``` -# Alternatively, you can require the gem in config.ru: -require './app' +Alternatively, you can require the gem in your configuration file, either `config/puma/development.rb`, `config/puma.rb`, or set via the `-C` cli option: +```ruby require 'localhost' -run Sinatra::Application +# configuration methods (from Puma::DSL) as needed ``` Additionally, Puma must be listening to an SSL socket: ```shell -$ puma -b 'ssl://localhost:9292' config.ru +$ puma -b 'ssl://localhost:9292' -C config/use_local_host.rb # The following options allow you to reach Puma over HTTP as well: -$ puma -b ssl://localhost:9292 -b tcp://localhost:9393 config.ru +$ puma -b ssl://localhost:9292 -b tcp://localhost:9393 -C config/use_local_host.rb ``` [`localhost`]: https://github.com/socketry/localhost @@ -266,6 +288,30 @@ 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). +#### Controlling OpenSSL Password Decryption + +To enable runtime decryption of an encrypted SSL key (not available for JRuby), use `key_password_command`: + +``` +$ puma -b 'ssl://127.0.0.1:9292?key=path_to_key&cert=path_to_cert&key_password_command=/path/to/command.sh' +``` + +`key_password_command` must: + +1. Be executable by Puma. +2. Print the decryption password to stdout. + + For example: + +```shell +#!/bin/sh + +echo "this is my password" +``` + +`key_password_command` can be used with `key` or `key_pem`. If the key +is not encrypted, the executable will not be called. + ### Control/Status Server Puma has a built-in status and control app that can be used to query and control Puma. @@ -341,16 +387,18 @@ ## Deployment -Puma has support for Capistrano with an [external gem](https://github.com/seuros/capistrano-puma). + * Puma has support for Capistrano with an [external gem](https://github.com/seuros/capistrano-puma). + + * Additionally, Puma has support for built-in daemonization via the [puma-daemon](https://github.com/kigster/puma-daemon) ruby gem. The gem restores the `daemonize` option that was removed from Puma starting version 5, but only for MRI Ruby. + 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: +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: +Community guides: * [Deploying Puma on OpenBSD using relayd and httpd](https://gist.github.com/anon987654321/4532cf8d6c59c1f43ec8973faa031103) @@ -360,7 +408,9 @@ * [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 +* [puma-plugin-systemd](https://github.com/sj26/puma-plugin-systemd) — deeper integration with systemd for notify, status and watchdog. Puma 5.1.0 integrated notify and watchdog, which probably conflicts with this plugin. Puma 6.1.0 added status support which obsoletes the plugin entirely. +* [puma-plugin-telemetry](https://github.com/babbel/puma-plugin-telemetry) - telemetry plugin for Puma offering various targets to publish +* [puma-acme](https://github.com/anchordotdev/puma-acme) - automatic SSL/HTTPS certificate provisioning and setup ### Monitoring diff -Nru puma-5.6.5/Rakefile puma-6.4.2/Rakefile --- puma-5.6.5/Rakefile 2022-08-22 23:33:14.000000000 +0000 +++ puma-6.4.2/Rakefile 2024-01-08 05:53:42.000000000 +0000 @@ -2,17 +2,21 @@ 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' +begin + # Add rubocop task + require "rubocop/rake_task" + RuboCop::RakeTask.new +rescue LoadError +end + gemspec = Gem::Specification.load("puma.gemspec") Gem::PackageTask.new(gemspec).define -# Add rubocop task -RuboCop::RakeTask.new - +Rake::FileUtilsExt.verbose_flag = !!ENV['PUMA_TEST_DEBUG'] # generate extension code using Ragel (C and Java) desc "Generate extension code (C and Java) using Ragel" task :ragel @@ -52,7 +56,7 @@ # override it so we can select the files class ::Rake::JavaExtensionTask def source_files - if ENV["DISABLE_SSL"] + if ENV["PUMA_DISABLE_SSL"] # uses no_ssl/PumaHttp11Service.java, removes MiniSSL.java FileList[ File.join(@ext_dir, "no_ssl/PumaHttp11Service.java"), @@ -72,6 +76,7 @@ Rake::JavaExtensionTask.new("puma_http11", gemspec) do |ext| ext.lib_dir = "lib/puma" + ext.release = '8' end end diff -Nru puma-5.6.5/Release.md puma-6.4.2/Release.md --- puma-5.6.5/Release.md 2022-08-22 23:33:14.000000000 +0000 +++ puma-6.4.2/Release.md 2024-01-08 05:53:42.000000000 +0000 @@ -11,7 +11,7 @@ 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` +1. Switch to latest JRuby version +1. `rake java gem` +1. `gem push pkg/puma-VERSION-java.gem` +1. Add release on Github at https://github.com/puma/puma/releases/new diff -Nru puma-5.6.5/SECURITY.md puma-6.4.2/SECURITY.md --- puma-5.6.5/SECURITY.md 2022-08-22 23:33:14.000000000 +0000 +++ puma-6.4.2/SECURITY.md 2024-01-08 05:53:42.000000000 +0000 @@ -4,8 +4,8 @@ | Version | Supported | | :------------ | :--------: | +| Latest release in 6.x | ✅ | | Latest release in 5.x | ✅ | -| Latest release in 4.x | ✅ | | All other releases | ❌ | ## Reporting a Vulnerability diff -Nru puma-5.6.5/benchmarks/local/bench_base.rb puma-6.4.2/benchmarks/local/bench_base.rb --- puma-5.6.5/benchmarks/local/bench_base.rb 1970-01-01 00:00:00.000000000 +0000 +++ puma-6.4.2/benchmarks/local/bench_base.rb 2024-01-08 05:53:42.000000000 +0000 @@ -0,0 +1,394 @@ +# frozen_string_literal: true + +require 'optparse' + +module TestPuma + + HOST4 = ENV.fetch('PUMA_TEST_HOST4', '127.0.0.1') + HOST6 = ENV.fetch('PUMA_TEST_HOST6', '::1') + PORT = ENV.fetch('PUMA_TEST_PORT', 40001).to_i + + # Array of response body sizes. If specified, set by ENV['PUMA_TEST_SIZES'] + # + SIZES = if (t = ENV['PUMA_TEST_SIZES']) + t.split(',').map(&:to_i).freeze + else + [1, 10, 100, 256, 512, 1024, 2048].freeze + end + + TYPES = [[:a, 'array'].freeze, [:c, 'chunk'].freeze, + [:s, 'string'].freeze, [:i, 'io'].freeze].freeze + + # Creates files used by 'i' (File/IO) responses. Placed in + # "#{Dir.tmpdir}/.puma_response_body_io" + # @param sizes [Array ] Array of sizes + # + def self.create_io_files(sizes = SIZES) + require 'tmpdir' + tmp_folder = "#{Dir.tmpdir}/.puma_response_body_io" + Dir.mkdir(tmp_folder) unless Dir.exist? tmp_folder + fn_format = "#{tmp_folder}/body_io_%04d.txt" + str = ("── Puma Hello World! ── " * 31) + "── Puma Hello World! ──\n" # 1 KB + sizes.each do |len| + suf = format "%04d", len + fn = format fn_format, len + unless File.exist? fn + body = "Hello World\n#{str}".byteslice(0,1023) + "\n" + (str * (len-1)) + File.write fn, body + end + end + end + + # Base class for generating client request streams + # + class BenchBase + # We're running under GitHub Actions + IS_GHA = ENV['GITHUB_ACTIONS'] == 'true' + + WRK_PERCENTILE = [0.50, 0.75, 0.9, 0.99, 1.0].freeze + + HDR_BODY_CONF = "Body-Conf: " + + # extracts 'type' string from `-b` argument + TYPES_RE = /\A[acis]+/.freeze + + # extracts 'size' string from `-b` argument + SIZES_RE = /\d[\d,]*\z/.freeze + + def initialize + sleep 5 # wait for server to boot + + @thread_loops = nil + @clients_per_thread = nil + @req_per_client = nil + @body_sizes = SIZES + @body_types = TYPES + @dly_app = nil + @bind_type = :tcp + + @ios_to_close = [] + + setup_options + + unless File.exist? @state_file + puts "Can't find state file '#{@state_file}'" + exit 1 + end + + mstr_pid = File.binread(@state_file)[/^pid: +(\d+)/, 1].to_i + begin + Process.kill 0, mstr_pid + rescue Errno::ESRCH + puts 'Puma server stopped?' + exit 1 + rescue Errno::EPERM + end + + case @bind_type + when :ssl, :ssl4, :tcp, :tcp4 + @bind_host = HOST4 + @bind_port = PORT + when :ssl6, :tcp6 + @bind_host = HOST6 + @bind_port = PORT + when :unix + @bind_path = 'tmp/benchmark_skt.unix' + when :aunix + @bind_path = '@benchmark_skt.aunix' + else + exit 1 + end + end + + def setup_options + OptionParser.new do |o| + o.on "-T", "--stream-threads THREADS", OptionParser::DecimalInteger, "request_stream: loops/threads" do |arg| + @stream_threads = arg.to_i + end + + o.on "-c", "--wrk-connections CONNECTIONS", OptionParser::DecimalInteger, "request_stream: clients_per_thread" do |arg| + @wrk_connections = arg.to_i + end + + o.on "-R", "--requests REQUESTS", OptionParser::DecimalInteger, "request_stream: requests per socket" do |arg| + @req_per_socket = arg.to_i + end + + o.on "-D", "--duration DURATION", OptionParser::DecimalInteger, "wrk/stream: duration" do |arg| + @duration = arg.to_i + end + + o.on "-b", "--body_conf BODY_CONF", String, "CI RackUp: type and size of response body in kB" do |arg| + if (types = arg[TYPES_RE]) + @body_types = TYPES.select { |a| types.include? a[0].to_s } + end + + if (sizes = arg[SIZES_RE]) + @body_sizes = sizes.split(',') + @body_sizes.map!(&:to_i) + @body_sizes.sort! + end + end + + o.on "-d", "--dly_app DELAYAPP", Float, "CI RackUp: app response delay" do |arg| + @dly_app = arg.to_f + end + + o.on "-s", "--socket SOCKETTYPE", String, "Bind type: tcp, ssl, tcp6, ssl6, unix, aunix" do |arg| + @bind_type = arg.to_sym + end + + o.on "-S", "--state PUMA_STATEFILE", String, "Puma Server: state file" do |arg| + @state_file = arg + end + + o.on "-t", "--threads PUMA_THREADS", String, "Puma Server: threads" do |arg| + @threads = arg + end + + o.on "-w", "--workers PUMA_WORKERS", OptionParser::DecimalInteger, "Puma Server: workers" do |arg| + @workers = arg.to_i + end + + o.on "-W", "--wrk_bind WRK_STR", String, "wrk: bind string" do |arg| + @wrk_bind_str = arg + end + + o.on("-h", "--help", "Prints this help") do + puts o + exit + end + end.parse! ARGV + end + + def close_clients + closed = 0 + @ios_to_close.each do |socket| + if socket && socket.to_io.is_a?(IO) && !socket.closed? + begin + if @bind_type == :ssl + socket.sysclose + else + socket.close + end + closed += 1 + rescue Errno::EBADF + end + end + end + puts "Closed #{closed} sockets" unless closed.zero? + end + + # Runs wrk and returns data from its output. + # @param cmd [String] The wrk command string, with arguments + # @return [Hash] The wrk data + # + def run_wrk_parse(cmd, log: false) + STDOUT.syswrite cmd.ljust 55 + + if @dly_app + cmd.sub! ' -H ', " -H 'Dly: #{@dly_app.round 4}' -H " + end + + wrk_output = %x[#{cmd}] + if log + puts '', wrk_output, '' + end + + wrk_data = "#{wrk_output[/\A.+ connections/m]}\n#{wrk_output[/ Thread Stats.+\z/m]}" + + ary = wrk_data[/^ +\d+ +requests.+/].strip.split ' ' + + fmt = " | %6s %s %s %7s %8s %s\n" + + STDOUT.syswrite format(fmt, *ary) + + hsh = {} + + rps = wrk_data[/^Requests\/sec: +([\d.]+)/, 1].to_f + requests = wrk_data[/^ +(\d+) +requests/, 1].to_i + + transfer = wrk_data[/^Transfer\/sec: +([\d.]+)/, 1].to_f + transfer_unit = wrk_data[/^Transfer\/sec: +[\d.]+(GB|KB|MB)/, 1] + transfer_mult = mult_for_unit transfer_unit + + read = wrk_data[/ +([\d.]+)(GB|KB|MB) +read$/, 1].to_f + read_unit = wrk_data[/ +[\d.]+(GB|KB|MB) +read$/, 1] + read_mult = mult_for_unit read_unit + + resp_transfer = (transfer * transfer_mult)/rps + resp_read = (read * read_mult)/requests.to_f + + mult = transfer/read + + hsh[:resp_size] = ((resp_transfer * mult + resp_read)/(mult + 1)).round + + hsh[:resp_size] = hsh[:resp_size] - 1770 - hsh[:resp_size].to_s.length + + hsh[:rps] = rps.round + hsh[:requests] = requests + + if (t = wrk_data[/^ +Socket errors: +(.+)/, 1]) + hsh[:errors] = t + end + + read = wrk_data[/ +([\d.]+)(GB|KB|MB) +read$/, 1].to_f + unit = wrk_data[/ +[\d.]+(GB|KB|MB) +read$/, 1] + + mult = mult_for_unit unit + + hsh[:read] = (mult * read).round + + if hsh[:errors] + t = hsh[:errors] + hsh[:errors] = t.sub('connect ', 'c').sub('read ', 'r') + .sub('write ', 'w').sub('timeout ', 't') + end + + t_re = ' +([\d.ums]+)' + + latency = + wrk_data.match(/^ +50%#{t_re}\s+75%#{t_re}\s+90%#{t_re}\s+99%#{t_re}/).captures + # add up max time + latency.push wrk_data[/^ +Latency.+/].split(' ')[-2] + + hsh[:times_summary] = WRK_PERCENTILE.zip(latency.map do |t| + if t.end_with?('ms') + t.to_f + elsif t.end_with?('us') + t.to_f/1000 + elsif t.end_with?('s') + t.to_f * 1000 + else + 0 + end + end).to_h + hsh + end + + def mult_for_unit(unit) + case unit + when 'KB' then 1_024 + when 'MB' then 1_024**2 + when 'GB' then 1_024**3 + end + end + + # Outputs info about the run. Example output: + # + # benchmarks/local/response_time_wrk.sh -w2 -t5:5 -s tcp6 + # Server cluster mode -w2 -t5:5, bind: tcp6 + # Puma repo branch 00-response-refactor + # ruby 3.2.0dev (2022-06-11T12:26:03Z master 28e27ee76e) +YJIT [x86_64-linux] + # + def env_log + puts "#{ENV['PUMA_BENCH_CMD']} #{ENV['PUMA_BENCH_ARGS']}" + puts @workers ? + "Server cluster mode -w#{@workers} -t#{@threads}, bind: #{@bind_type}" : + "Server single mode -t#{@threads}, bind: #{@bind_type}" + + branch = %x[git branch][/^\* (.*)/, 1] + if branch + puts "Puma repo branch #{branch.strip}", RUBY_DESCRIPTION + else + const = File.read File.expand_path('../../lib/puma/const.rb', __dir__) + puma_version = const[/^ +PUMA_VERSION[^'"]+['"]([^\s'"]+)/, 1] + puts "Puma version #{puma_version}", RUBY_DESCRIPTION + end + end + + # Parses data returned by `PumaInfo.run stats` + # @return [Hash] The data from Puma stats + # + def parse_stats + stats = {} + + obj = @puma_info.run 'stats' + + worker_status = obj[:worker_status] + + worker_status.each do |w| + pid = w[:pid] + req_cnt = w[:last_status][:requests_count] + id = format 'worker-%01d-%02d', w[:phase], w[:index] + hsh = { + pid: pid, + requests: req_cnt - @worker_req_ttl[pid], + backlog: w[:last_status][:backlog] + } + @pids[pid] = id + @worker_req_ttl[pid] = req_cnt + stats[id] = hsh + end + + stats + end + + # Runs gc in the server, then parses data from + # `smem -c 'pid rss pss uss command'` + # @return [Hash] The data from smem + # + def parse_smem + @puma_info.run 'gc' + sleep 1 + + hsh_smem = Hash.new [] + pids = @pids.keys + + smem_info = %x[smem -c 'pid rss pss uss command'] + + smem_info.lines.each do |l| + ary = l.strip.split ' ', 5 + if pids.include? ary[0].to_i + hsh_smem[@pids[ary[0].to_i]] = { + pid: ary[0].to_i, + rss: ary[1].to_i, + pss: ary[2].to_i, + uss: ary[3].to_i + } + end + end + hsh_smem.sort.to_h + end + end + + class ResponseTimeBase < BenchBase + def run + @puma_info = PumaInfo.new ['-S', @state_file] + end + + # Prints summarized data. Example: + # ``` + # Body ────────── req/sec ────────── ─────── req 50% times ─────── + # KB array chunk string io array chunk string io + # 1 13760 13492 13817 9610 0.744 0.759 0.740 1.160 + # 10 13536 13077 13492 9269 0.759 0.785 0.760 1.190 + # ``` + # + # @param summaries [Hash] generated in subclasses + # + def overall_summary(summaries) + names = +'' + @body_types.each { |_, t_desc| names << t_desc.rjust(8) } + + puts "\nBody ────────── req/sec ────────── ─────── req 50% times ───────" \ + "\n KB #{names.ljust 32}#{names}" + + len = @body_types.length + digits = [4 - Math.log10(@max_050_time).to_i, 3].min + + fmt_rps = ('%6d ' * len).strip + fmt_times = (digits < 0 ? " %6d" : " %6.#{digits}f") * len + + @body_sizes.each do |size| + line = format '%-5d ', size + resp = '' + line << format(fmt_rps , *@body_types.map { |_, t_desc| summaries[size][t_desc][:rps] }).ljust(30) + line << format(fmt_times, *@body_types.map { |_, t_desc| summaries[size][t_desc][:times_summary][0.5] }) + puts line + end + puts '─' * 69 + end + end + +end diff -Nru puma-5.6.5/benchmarks/local/bench_base.sh puma-6.4.2/benchmarks/local/bench_base.sh --- puma-5.6.5/benchmarks/local/bench_base.sh 1970-01-01 00:00:00.000000000 +0000 +++ puma-6.4.2/benchmarks/local/bench_base.sh 2024-01-08 05:53:42.000000000 +0000 @@ -0,0 +1,175 @@ +#!/bin/bash + +# -T client threads (wrk -t) +# -c connections per client thread +# -R requests per client +# +# Total connections/requests = l * c * r +# +# -b response body size kB +# -d app delay +# +# -s Puma bind socket type, default ssl, also tcp or unix +# -t Puma threads +# -w Puma workers +# -r Puma rackup file + +if [[ "$@" =~ ^[^-].* ]]; then + echo "Error: Invalid option was specified $1" + exit +fi + +PUMA_BENCH_CMD=$0 +PUMA_BENCH_ARGS=$@ + +export PUMA_BENCH_CMD +export PUMA_BENCH_ARGS + +if [ -z "$PUMA_TEST_HOST4" ]; then export PUMA_TEST_HOST4=127.0.0.1; fi +if [ -z "$PUMA_TEST_HOST6" ]; then export PUMA_TEST_HOST6=::1; fi +if [ -z "$PUMA_TEST_PORT" ]; then export PUMA_TEST_PORT=40001; fi +if [ -z "$PUMA_TEST_CTRL" ]; then export PUMA_TEST_CTRL=40010; fi +if [ -z "$PUMA_TEST_STATE" ]; then export PUMA_TEST_STATE=tmp/bench_test_puma.state; fi + +export PUMA_CTRL=$PUMA_TEST_HOST4:$PUMA_TEST_CTRL + +while getopts :b:C:c:D:d:R:r:s:T:t:w:Y option +do +case "${option}" in +#———————————————————— RUBY options +Y) export RUBYOPT=--yjit;; +#———————————————————— Puma options +C) conf=${OPTARG};; +t) threads=${OPTARG};; +w) workers=${OPTARG};; +r) rackup_file=${OPTARG};; +#———————————————————— app/common options +b) body_conf=${OPTARG};; +s) skt_type=${OPTARG};; +d) dly_app=${OPTARG};; +#———————————————————— request_stream options +T) stream_threads=${OPTARG};; +D) duration=${OPTARG};; +R) req_per_socket=${OPTARG};; +#———————————————————— wrk options +c) connections=${OPTARG};; +# T) stream_threads=${OPTARG};; +# D) duration=${OPTARG};; +?) echo "Error: Invalid option was specified -$OPTARG"; exit;; +esac +done + +# -n not empty, -z is empty + +ruby_args="-S $PUMA_TEST_STATE" + +if [ -n "$connections" ]; then + ruby_args="$ruby_args -c$connections" +fi + +if [ -n "$stream_threads" ]; then + ruby_args="$ruby_args -T$stream_threads" +fi + +if [ -n "$duration" ] ; then + ruby_args="$ruby_args -D$duration" +fi + +if [ -n "$req_per_socket" ]; then + ruby_args="$ruby_args -R$req_per_socket" +fi + +if [ -n "$dly_app" ]; then + ruby_args="$ruby_args -d$dly_app" +fi + +if [ -n "$body_conf" ]; then + ruby_args="$ruby_args -b $body_conf" + export CI_BODY_CONF=$body_conf +fi + +if [ -z "$skt_type" ]; then + skt_type=tcp +fi + +ruby_args="$ruby_args -s $skt_type" + +puma_args="-S $PUMA_TEST_STATE" + +if [ -n "$workers" ]; then + puma_args="$puma_args -w$workers" + ruby_args="$ruby_args -w$workers" +fi + +if [ -z "$threads" ]; then + threads=0:5 +fi + +puma_args="$puma_args -t$threads" +ruby_args="$ruby_args -t$threads" + +if [ -n "$conf" ]; then + puma_args="$puma_args -C $conf" +fi + +if [ -z "$rackup_file" ]; then + rackup_file="test/rackup/ci_select.ru" +fi + +ip4=$PUMA_TEST_HOST4:$PUMA_TEST_PORT +ip6=[$PUMA_TEST_HOST6]:$PUMA_TEST_PORT + +case $skt_type in + ssl4) + bind="ssl://$PUMA_TEST_HOST4:$PUMA_TEST_PORT?cert=examples/puma/cert_puma.pem&key=examples/puma/puma_keypair.pem&verify_mode=none" + curl_str=https://$ip4 + wrk_str=https://$ip4 + ;; + ssl) + bind="ssl://$ip4?cert=examples/puma/cert_puma.pem&key=examples/puma/puma_keypair.pem&verify_mode=none" + curl_str=https://$ip4 + wrk_str=https://$ip4 + ;; + ssl6) + bind="ssl://$ip6?cert=examples/puma/cert_puma.pem&key=examples/puma/puma_keypair.pem&verify_mode=none" + curl_str=https://$ip6 + wrk_str=https://$ip6 + ;; + tcp4) + bind=tcp://$ip4 + curl_str=http://$ip4 + wrk_str=http://$ip4 + ;; + tcp) + bind=tcp://$ip4 + curl_str=http://$ip4 + wrk_str=http://$ip4 + ;; + tcp6) + bind=tcp://$ip6 + curl_str=http://$ip6 + wrk_str=http://$ip6 + ;; + unix) + bind=unix://tmp/benchmark_skt.unix + curl_str="--unix-socket tmp/benchmark_skt.unix http:/n" + ;; + aunix) + bind=unix://@benchmark_skt.aunix + curl_str="--abstract-unix-socket benchmark_skt.aunix http:/n" + ;; + *) + echo "Error: Invalid socket type option was specified '$skt_type'" + exit + ;; +esac + +StartPuma() +{ + if [ -n "$1" ]; then + rackup_file=$1 + fi + printf "\nbundle exec bin/puma -q -b $bind $puma_args --control-url=tcp://$PUMA_CTRL --control-token=test $rackup_file\n\n" + bundle exec bin/puma -q -b $bind $puma_args --control-url=tcp://$PUMA_CTRL --control-token=test $rackup_file & + sleep 6s +} diff -Nru puma-5.6.5/benchmarks/local/puma_info.rb puma-6.4.2/benchmarks/local/puma_info.rb --- puma-5.6.5/benchmarks/local/puma_info.rb 1970-01-01 00:00:00.000000000 +0000 +++ puma-6.4.2/benchmarks/local/puma_info.rb 2024-01-08 05:53:42.000000000 +0000 @@ -0,0 +1,201 @@ +# frozen_string_literal: true + +require 'optparse' +require_relative '../../lib/puma/state_file' +require_relative '../../lib/puma/const' +require_relative '../../lib/puma/detect' +require_relative '../../lib/puma/configuration' +require 'uri' +require 'socket' +require 'json' + +module TestPuma + + # Similar to puma_ctl.rb, but returns objects. Command list is minimal. + # + class PumaInfo + # @version 5.0.0 + PRINTABLE_COMMANDS = %w{gc-stats stats stop thread-backtraces}.freeze + + COMMANDS = (PRINTABLE_COMMANDS + %w{gc}).freeze + + attr_reader :master_pid + + 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['RACK_ENV'] || ENV['RAILS_ENV'] + + @argv = argv + @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) (#{PRINTABLE_COMMANDS.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 "-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! + + 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 + + @master_pid = File.binread(@state)[/^pid: +(\d+)/, 1].to_i + + 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 + 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 + + 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 == '500' && @command == 'stop-sigterm' + # expected with stop-sigterm + elsif @code != '200' + raise "Bad response from server: #{@code}" + end + return unless PRINTABLE_COMMANDS.include? @command + JSON.parse response.last, {symbolize_names: true} + ensure + if server + if uri.scheme == 'ssl' + server.sysclose + else + server.close unless server.closed? + end + end + end + + def run(cmd) + return unless COMMANDS.include?(cmd) + @command = cmd + prepare_configuration + send_request + rescue => e + message e.message + exit 1 + end + end +end diff -Nru puma-5.6.5/benchmarks/local/response_time_wrk.rb puma-6.4.2/benchmarks/local/response_time_wrk.rb --- puma-5.6.5/benchmarks/local/response_time_wrk.rb 1970-01-01 00:00:00.000000000 +0000 +++ puma-6.4.2/benchmarks/local/response_time_wrk.rb 2024-01-08 05:53:42.000000000 +0000 @@ -0,0 +1,240 @@ +# frozen_string_literal: true + +require_relative 'bench_base' +require_relative 'puma_info' + +module TestPuma + + # This file is called from `response_time_wrk.sh`. It requires `wrk`. + # We suggest using https://github.com/ioquatix/wrk + # + # It starts a `Puma` server, then collects data from one or more runs of wrk. + # It logs the wrk data as each wrk runs is done, then summarizes + # the data in two tables. + # + # The default runs a matrix of the following, and takes a bit over 5 minutes, + # with 28 (4x7) wrk runs: + # + # bodies - array, chunk, string, io
+ # + # sizes - 1k, 10k, 100k, 256k, 512k, 1024k, 2048k + # + # See the file 'Testing - benchmark/local files' for sample output and information + # on arguments for the shell script. + # + # Examples: + # + # * `benchmarks/local/response_time_wrk.sh -w2 -t5:5 -s tcp6 -Y`
+ # 2 Puma workers, Puma threads 5:5, IPv6 http, 28 wrk runs with matrix above + # + # * `benchmarks/local/response_time_wrk.sh -t6:6 -s tcp -Y -b ac10,50,100`
+ # Puma single mode (0 workers), Puma threads 6:6, IPv4 http, six wrk runs, + # [array, chunk] * [10kb, 50kb, 100kb] + # + class ResponseTimeWrk < ResponseTimeBase + + def run + time_start = Process.clock_gettime(Process::CLOCK_MONOTONIC) + super + # default values + @duration ||= 10 + max_threads = (@threads[/\d+\z/] || 5).to_i + @stream_threads ||= (0.8 * (@workers || 1) * max_threads).to_i + connections = @stream_threads * (@wrk_connections || 2) + + warm_up + + @max_100_time = 0 + @max_050_time = 0 + @errors = false + + summaries = Hash.new { |h,k| h[k] = {} } + + @single_size = @body_sizes.length == 1 + @single_type = @body_types.length == 1 + + @body_sizes.each do |size| + @body_types.each do |pre, desc| + header = @single_size ? "-H '#{HDR_BODY_CONF}#{pre}#{size}'" : + "-H '#{HDR_BODY_CONF}#{pre}#{size}'".ljust(21) + + # warmup? + if pre == :i + wrk_cmd = %Q[wrk -t#{@stream_threads} -c#{connections} -d1s --latency #{header} #{@wrk_bind_str}] + %x[#{wrk_cmd}] + end + + wrk_cmd = %Q[wrk -t#{@stream_threads} -c#{connections} -d#{@duration}s --latency #{header} #{@wrk_bind_str}] + hsh = run_wrk_parse wrk_cmd + + @errors ||= hsh.key? :errors + + times = hsh[:times_summary] + @max_100_time = times[1.0] if times[1.0] > @max_100_time + @max_050_time = times[0.5] if times[0.5] > @max_050_time + summaries[size][desc] = hsh + end + sleep 0.5 + @puma_info.run 'gc' + sleep 2.0 + end + + run_summaries summaries + + if @single_size || @single_type + puts '' + else + overall_summary(summaries) unless @single_size || @single_type + end + + puts "wrk -t#{@stream_threads} -c#{connections} -d#{@duration}s" + + env_log + + rescue => e + puts e.class, e.message, e.backtrace + ensure + puts '' + @puma_info.run 'stop' + sleep 2 + running_time = Process.clock_gettime(Process::CLOCK_MONOTONIC) - time_start + puts format("\n%2d:%d Total Time", (running_time/60).to_i, running_time % 60) + end + + # Prints parsed data of each wrk run. Similar to: + # ``` + # Type req/sec 50% 75% 90% 99% 100% Resp Size + # ───────────────────────────────────────────────────────────────── 1kB + # array 13760 0.74 2.51 5.22 7.76 11.18 2797 + # ``` + # + # @param summaries [Hash] + # + def run_summaries(summaries) + digits = [4 - Math.log10(@max_100_time).to_i, 3].min + + fmt_vals = +'%-6s %6d' + fmt_vals << (digits < 0 ? " %6d" : " %6.#{digits}f")*5 + fmt_vals << ' %8d' + + label = @single_type ? 'Size' : 'Type' + + if @errors + puts "\n#{label} req/sec 50% 75% 90% 99% 100% Resp Size Errors" + desc_width = 83 + else + puts "\n#{label} req/sec 50% 75% 90% 99% 100% Resp Size" + desc_width = 65 + end + + puts format("#{'─' * desc_width} %s", @body_types[0][1]) if @single_type + + @body_sizes.each do |size| + puts format("#{'─' * desc_width}%5dkB", size) unless @single_type + @body_types.each do |_, t_desc| + hsh = summaries[size][t_desc] + times = hsh[:times_summary].values + desc = @single_type ? size : t_desc +# puts format(fmt_vals, desc, hsh[:rps], *times, hsh[:read]/hsh[:requests]) + puts format(fmt_vals, desc, hsh[:rps], *times, hsh[:resp_size]) + end + end + + end + + # Checks if any body files need to be created, reads all the body files, + # then runs a quick 'wrk warmup' command for each body type + # + def warm_up + puts "\nwarm-up" + if @body_types.map(&:first).include? :i + TestPuma.create_io_files @body_sizes + + # get size files cached + if @body_types.include? :i + 2.times do + @body_sizes.each do |size| + fn = format "#{Dir.tmpdir}/.puma_response_body_io/body_io_%04d.txt", size + t = File.read fn, mode: 'rb' + end + end + end + end + + size = @body_sizes.length == 1 ? @body_sizes.first : 10 + + @body_types.each do |pre, _| + header = "-H '#{HDR_BODY_CONF}#{pre}#{size}'".ljust(21) + warm_up_cmd = %Q[wrk -t2 -c4 -d1s --latency #{header} #{@wrk_bind_str}] + run_wrk_parse warm_up_cmd + end + puts '' + end + + # Experimental - try to see how busy a CI system is. + def ci_test_rps + host = ENV['HOST'] + port = ENV['PORT'].to_i + + str = 'a' * 65_500 + + server = TCPServer.new host, port + + svr_th = Thread.new do + loop do + begin + Thread.new(server.accept) do |client| + client.sysread 65_536 + client.syswrite str + client.close + end + rescue => e + break + end + end + end + + threads = [] + + t_st = Process.clock_gettime(Process::CLOCK_MONOTONIC) + + 100.times do + threads << Thread.new do + 100.times { + s = TCPSocket.new host, port + s.syswrite str + s.sysread 65_536 + s = nil + } + end + end + + threads.each(&:join) + loops_time = (1_000*(Process.clock_gettime(Process::CLOCK_MONOTONIC) - t_st)).to_i + + threads.clear + threads = nil + + server.close + svr_th.join + + req_limit = + if loops_time > 3_050 then 13_000 + elsif loops_time > 2_900 then 13_500 + elsif loops_time > 2_500 then 14_000 + elsif loops_time > 2_200 then 18_000 + elsif loops_time > 2_100 then 19_000 + elsif loops_time > 1_900 then 20_000 + elsif loops_time > 1_800 then 21_000 + elsif loops_time > 1_600 then 22_500 + else 23_000 + end + [req_limit, loops_time] + end + + def puts(*ary) + ary.each { |s| STDOUT.syswrite "#{s}\n" } + end + end +end +TestPuma::ResponseTimeWrk.new.run diff -Nru puma-5.6.5/benchmarks/local/response_time_wrk.sh puma-6.4.2/benchmarks/local/response_time_wrk.sh --- puma-5.6.5/benchmarks/local/response_time_wrk.sh 1970-01-01 00:00:00.000000000 +0000 +++ puma-6.4.2/benchmarks/local/response_time_wrk.sh 2024-01-08 05:53:42.000000000 +0000 @@ -0,0 +1,18 @@ +#!/bin/bash + +# see comments in response_time_wrk.rb + +source benchmarks/local/bench_base.sh + +if [ "$skt_type" == "unix" ] || [ "$skt_type" == "aunix" ]; then + printf "\nwrk doesn't support UNIXSockets...\n\n" + exit +fi + +StartPuma + +ruby -I./lib benchmarks/local/response_time_wrk.rb $ruby_args -W $wrk_str +wrk_exit=$? + +printf "\n" +exit $wrk_exit diff -Nru puma-5.6.5/benchmarks/local/sinatra/Gemfile puma-6.4.2/benchmarks/local/sinatra/Gemfile --- puma-5.6.5/benchmarks/local/sinatra/Gemfile 1970-01-01 00:00:00.000000000 +0000 +++ puma-6.4.2/benchmarks/local/sinatra/Gemfile 2024-01-08 05:53:42.000000000 +0000 @@ -0,0 +1,13 @@ +source "http://rubygems.org" +git_source(:github) { |repo| "https://github.com/#{repo}.git" } + +ruby "3.2.0" + +gem "sinatra" +gem "puma_worker_killer" + +# current puma release +gem "puma" + +# PR to reduce memory of large file uploads +# gem "puma", github: "willkoehler/puma", branch: "reduce_read_body_memory" diff -Nru puma-5.6.5/benchmarks/local/sinatra/README.md puma-6.4.2/benchmarks/local/sinatra/README.md --- puma-5.6.5/benchmarks/local/sinatra/README.md 1970-01-01 00:00:00.000000000 +0000 +++ puma-6.4.2/benchmarks/local/sinatra/README.md 2024-01-08 05:53:42.000000000 +0000 @@ -0,0 +1,92 @@ +# Large file upload demo + +This is a simple app to demonstrate memory used by Puma for large file uploads and +compare it to proposed changes in PR https://github.com/puma/puma/pull/3062 + +### Steps to test memory improvements in https://github.com/puma/puma/pull/3062 + +- Run the app with puma_worker_killer: `bundle exec puma -p 9090 --config puma.rb` +- Make a POST request with curl: `curl --form "data=@some_large_file.mp4" --limit-rate 10M http://localhost:9090/` +- Puma will log memory usage in the console + +Below is example of the results uploading a 115MB video. + +### Puma 6.0.2 + +``` +[11820] Puma starting in cluster mode... +[11820] * Puma version: 6.0.2 (ruby 3.2.0-p0) ("Sunflower") +[11820] * Min threads: 0 +[11820] * Max threads: 5 +[11820] * Environment: development +[11820] * Master PID: 11820 +[11820] * Workers: 1 +[11820] * Restarts: (✔) hot (✔) phased +[11820] * Listening on http://0.0.0.0:3000 +[11820] Use Ctrl-C to stop +[11820] - Worker 0 (PID: 11949) booted in 0.06s, phase: 0 +[11820] PumaWorkerKiller: Consuming 70.984375 mb with master and 1 workers. +[11820] PumaWorkerKiller: Consuming 70.984375 mb with master and 1 workers. + +...curl request made - memory increases as file is received + +[11820] PumaWorkerKiller: Consuming 72.796875 mb with master and 1 workers. +[11820] PumaWorkerKiller: Consuming 75.921875 mb with master and 1 workers. +[11820] PumaWorkerKiller: Consuming 78.953125 mb with master and 1 workers. +[11820] PumaWorkerKiller: Consuming 82.15625 mb with master and 1 workers. +[11820] PumaWorkerKiller: Consuming 85.265625 mb with master and 1 workers. +[11820] PumaWorkerKiller: Consuming 88.046875 mb with master and 1 workers. + +...(clipped out lines) memory keeps increasing while request is received + +[11820] PumaWorkerKiller: Consuming 121.53125 mb with master and 1 workers. +[11820] PumaWorkerKiller: Consuming 122.75 mb with master and 1 workers. +[11820] PumaWorkerKiller: Consuming 125.40625 mb with master and 1 workers. + +...request handed off from Puma to Rack/Sinatra + +[11820] PumaWorkerKiller: Consuming 220.6875 mb with master and 1 workers. +127.0.0.1 - - [26/Jan/2023:20:09:56 -0500] "POST /upload HTTP/1.1" 200 162 0.0553 +[11820] PumaWorkerKiller: Consuming 228.96875 mb with master and 1 workers. +[11820] PumaWorkerKiller: Consuming 228.96875 mb with master and 1 workers. +``` + +### With PR https://github.com/puma/puma/pull/3062 + +``` +[20815] Puma starting in cluster mode... +[20815] * Puma version: 6.0.2 (ruby 3.2.0-p0) ("Sunflower") +[20815] * Min threads: 0 +[20815] * Max threads: 5 +[20815] * Environment: development +[20815] * Master PID: 20815 +[20815] * Workers: 1 +[20815] * Restarts: (✔) hot (✔) phased +[20815] * Listening on http://0.0.0.0:3000 +[20815] Use Ctrl-C to stop +[20815] - Worker 0 (PID: 20944) booted in 0.1s, phase: 0 +[20815] PumaWorkerKiller: Consuming 73.25 mb with master and 1 workers. +[20815] PumaWorkerKiller: Consuming 73.25 mb with master and 1 workers. + +...curl request made - memory stays level as file is received + +[20815] PumaWorkerKiller: Consuming 73.28125 mb with master and 1 workers. +[20815] PumaWorkerKiller: Consuming 73.296875 mb with master and 1 workers. +[20815] PumaWorkerKiller: Consuming 73.34375 mb with master and 1 workers. +[20815] PumaWorkerKiller: Consuming 73.359375 mb with master and 1 workers. +[20815] PumaWorkerKiller: Consuming 73.359375 mb with master and 1 workers. +[20815] PumaWorkerKiller: Consuming 73.359375 mb with master and 1 workers. + +...(clipped out lines) memory continues to stay level + +[20815] PumaWorkerKiller: Consuming 73.703125 mb with master and 1 workers. +[20815] PumaWorkerKiller: Consuming 73.703125 mb with master and 1 workers. +[20815] PumaWorkerKiller: Consuming 73.703125 mb with master and 1 workers. + +...request handed off from Puma to Rack/Sinatra + +[20815] PumaWorkerKiller: Consuming 181.96875 mb with master and 1 workers. +127.0.0.1 - - [26/Jan/2023:20:27:16 -0500] "POST /upload HTTP/1.1" 200 162 0.0585 +[20815] PumaWorkerKiller: Consuming 183.78125 mb with master and 1 workers. +[20815] PumaWorkerKiller: Consuming 183.78125 mb with master and 1 workers. +``` diff -Nru puma-5.6.5/benchmarks/local/sinatra/config.ru puma-6.4.2/benchmarks/local/sinatra/config.ru --- puma-5.6.5/benchmarks/local/sinatra/config.ru 1970-01-01 00:00:00.000000000 +0000 +++ puma-6.4.2/benchmarks/local/sinatra/config.ru 2024-01-08 05:53:42.000000000 +0000 @@ -0,0 +1,7 @@ +require "sinatra" + +post "/" do + 204 +end + +run Sinatra::Application diff -Nru puma-5.6.5/benchmarks/local/sinatra/puma.rb puma-6.4.2/benchmarks/local/sinatra/puma.rb --- puma-5.6.5/benchmarks/local/sinatra/puma.rb 1970-01-01 00:00:00.000000000 +0000 +++ puma-6.4.2/benchmarks/local/sinatra/puma.rb 2024-01-08 05:53:42.000000000 +0000 @@ -0,0 +1,15 @@ +silence_single_worker_warning + +workers 1 + +before_fork do + require "puma_worker_killer" + + PumaWorkerKiller.config do |config| + config.ram = 1024 # mb + config.frequency = 0.3 # seconds + config.reaper_status_logs = true # Log memory: PumaWorkerKiller: Consuming 54.34765625 mb with master and 1 workers. + end + + PumaWorkerKiller.start +end diff -Nru puma-5.6.5/benchmarks/wrk/big_file.sh puma-6.4.2/benchmarks/wrk/big_file.sh --- puma-5.6.5/benchmarks/wrk/big_file.sh 1970-01-01 00:00:00.000000000 +0000 +++ puma-6.4.2/benchmarks/wrk/big_file.sh 2024-01-08 05:53:42.000000000 +0000 @@ -0,0 +1,6 @@ +bundle exec bin/puma -t 4 test/rackup/big_file.ru & +PID1=$! +sleep 5 +wrk -c 4 -d 60 --latency http://localhost:9292 + +kill $PID1 diff -Nru puma-5.6.5/bin/puma-wild puma-6.4.2/bin/puma-wild --- puma-5.6.5/bin/puma-wild 2022-08-22 23:33:14.000000000 +0000 +++ puma-6.4.2/bin/puma-wild 2024-01-08 05:53:42.000000000 +0000 @@ -16,7 +16,7 @@ module Puma; end -Puma.const_set("WILD_ARGS", ["-I", inc]) +Puma.const_set(:WILD_ARGS, ["-I", inc]) require 'puma/cli' diff -Nru puma-5.6.5/debian/changelog puma-6.4.2/debian/changelog --- puma-5.6.5/debian/changelog 2024-01-23 17:50:14.000000000 +0000 +++ puma-6.4.2/debian/changelog 2024-02-07 22:16:00.000000000 +0000 @@ -1,80 +1,79 @@ -puma (5.6.5-4ubuntu3) noble; urgency=medium +puma (6.4.2-4ubuntu1~ppa1) noble; urgency=medium - * SECURITY UPDATE: DoS via chunked transfer encoding body parsing - - debian/patches/CVE-2024-21647.patch: limit the size of chunk - extensions in lib/puma/client.rb, test/test_puma_server.rb. - - CVE-2024-21647 - - -- Marc Deslauriers Tue, 23 Jan 2024 12:50:14 -0500 - -puma (5.6.5-4ubuntu2) mantic; urgency=medium - - * SECURITY UPDATE: HTTP request smuggling issues - - debian/patches/CVE-2023-40175.patch: fix parsing in - lib/puma/client.rb, test/test_puma_server.rb. - - CVE-2023-40175 - - -- Marc Deslauriers Fri, 22 Sep 2023 13:01:34 -0400 - -puma (5.6.5-4ubuntu1) mantic; urgency=medium - - * Merge with Debian unstable (LP: #2018102). Remaining changes: - - Fix autopkgtest regressions on multiple architectures - (LP #1916954, #1906307) - + d/p/skip-tests-hanging-on-different-arches.patch: this is a - workaround for now. The discussion with the Debian maintainer is - ongoing. - - d/p/skip-integration-tests-failing-in-ubuntu-autopkgtest-env.patch: - some tests are failing only in autopkgtest and need further - investigation. - - d/ruby-tests.rake: skip flaky tests in Ubuntu. - Some of them are executed in parallel and they try to start and stop - the puma server multiple times which is causing a race condition. - * Added: - - d/ruby-tests.rake: skip test_chunked_keep_alive_two_back_to_back - failing on s390x. - - -- Lucas Kanashiro Fri, 21 Jul 2023 16:27:57 -0300 - -puma (5.6.5-4) unstable; urgency=medium - - * d/control: add dependency on ruby-sd-notify (LP: #2027958). - It is required when used puma integration with systemd (Type=notify). - * Declare compliance with Debian Policy 4.6.2 - * Run wrap-and-sort: - - d/control: sort dependencies by name. - - d/puma.manpages: delete blank line. - * Add myself to the Uploaders list. - - -- Lucas Kanashiro Wed, 19 Jul 2023 16:35:12 -0300 - -puma (5.6.5-3ubuntu1) lunar; urgency=medium - - * Merge with Debian unstable. Remaining changes: - - Fix autopkgtest regressions on multiple architectures - (LP #1916954, #1906307) - + d/p/skip-tests-hanging-on-different-arches.patch: this is a - workaround for now. The discussion with the Debian maintainer is - ongoing. - - d/p/skip-integration-tests-failing-in-ubuntu-autopkgtest-env.patch: - some tests are failing only in autopkgtest and need further - investigation. - * Dropped: - - d/p/fix-ssl-test.patch: Fix FTBFS against OpenSSL 3. - [Applied by upstream in version 5.6.0] - * Added: - - d/ruby-tests.rake: skip flaky tests in Ubuntu. - Some of them are executed in parallel and they try to start and stop - the puma server multiple times which is causing a race condition. + * d/p/0018-disable-test-failing-with-ruby3.2.patch: some tests are failing + because they take too long, they do not seem real regressions. - -- Lucas Kanashiro Fri, 17 Feb 2023 09:45:23 -0300 + -- Lucas Kanashiro Wed, 07 Feb 2024 19:16:00 -0300 -puma (5.6.5-3) unstable; urgency=medium +puma (6.4.2-4build1) noble; urgency=medium + + * No changes rebuild in Ubuntu. + + -- Lucas Kanashiro Wed, 07 Feb 2024 18:52:50 -0300 + +puma (6.4.2-4) unstable; urgency=medium + + * Disable test failing on armhf + + -- Pirate Praveen Tue, 06 Feb 2024 18:13:41 +0530 + +puma (6.4.2-3) unstable; urgency=medium + + * Add Breaks: rails (<< 2:6.1.7.3+dfsg-3~) + + -- Pirate Praveen Mon, 05 Feb 2024 15:12:00 +0530 + +puma (6.4.2-2) unstable; urgency=medium + + * Disable test failing on arm64 buildd + + -- Pirate Praveen Mon, 05 Feb 2024 14:11:14 +0530 + +puma (6.4.2-1) unstable; urgency=medium + + * Switch to github tags from releases (6.4.2 is only available from tags) + * New upstream version 6.4.2 (Fixes: CVE-2024-21647) + + -- Pirate Praveen Mon, 05 Feb 2024 01:08:22 +0530 + +puma (6.4.0-4) unstable; urgency=medium + + * Remove minitest/retry as well (copy from 5.x branch and fixes salsa ci) + * Export LC_ALL also to C.UTF-8 (hoping this would fix some test failures) + + -- Pirate Praveen Mon, 05 Feb 2024 00:52:13 +0530 + +puma (6.4.0-3) unstable; urgency=medium + + * Reupload to unstable + + -- Pirate Praveen Sun, 04 Feb 2024 01:16:15 +0530 + +puma (6.4.0-2) experimental; urgency=medium + + * Set TEST_CASE_TIMEOUT = 300 (one test is timing out on amd64 buildd) + + -- Pirate Praveen Mon, 04 Dec 2023 23:59:02 +0530 + +puma (6.4.0-1) experimental; urgency=medium + + * New upstream version 6.4.0 + * Disable more tests and refresh patches + * Add procps to build depends for kill command (used in tests) + + -- Pirate Praveen Sun, 03 Dec 2023 13:58:23 +0530 + +puma (6.0.2-1) experimental; urgency=medium * Team upload. - * d/control (Vcs-Git): Fix URL. + * New upstream release. + * d/control (Standards-Version): Bump to 4.6.2. + (Build-Depends): Add ruby-sd-notify. + * d/copyright (Copyright): Update years. + * d/ruby-tests.rake: Re-enable multiple tests (closes: #984713). + * d/upstream/metadata: Adjust a few URLs. - -- Daniel Leidert Thu, 09 Feb 2023 16:24:05 +0100 + -- Daniel Leidert Thu, 09 Feb 2023 16:12:20 +0100 puma (5.6.5-2) unstable; urgency=medium @@ -96,64 +95,12 @@ * New upstream version 5.6.4 * Refresh patches - * Disable some tests that fail with + * Disable some tests that fail with NameError: uninitialized constant Puma::LogWriter * Remove tmp/restart.txt in clean -- Pirate Praveen Mon, 04 Apr 2022 13:24:10 +0530 -puma (5.5.2-2ubuntu4) lunar; urgency=medium - - * No-change upload to remove support for ruby3.0. - - -- Lucas Kanashiro Fri, 03 Feb 2023 12:43:47 -0300 - -puma (5.5.2-2ubuntu3) lunar; urgency=medium - - * No-change upload to add support for ruby3.1. - - -- Lucas Kanashiro Tue, 24 Jan 2023 12:11:39 -0300 - -puma (5.5.2-2ubuntu2) jammy; urgency=medium - - * No-change upload due to ruby3.0 transition, remove ruby2.7 support. - - -- Lucas Kanashiro Fri, 03 Dec 2021 18:17:16 -0300 - -puma (5.5.2-2ubuntu1) jammy; urgency=medium - - * Merge with Debian unstable. Remaining changes: - - Fix autopkgtest regressions on multiple architectures - (LP #1916954, #1906307) - + d/p/skip-tests-hanging-on-different-arches.patch: this is a - workaround for now. The discussion with the Debian maintainer is - ongoing. - * Dropped: - - Disable a test that fails on Ubuntu builder farm, but not locally - (LP #1866881). - - d/t/control: do not run SSL tests with autopkgtest. Due to OpenSSL - differences between Ubuntu and Debian some tests are failing with - autopkgtest only. - - d/t/control: removed. The test defined runs the same command than the - one defined by autodep8. In Debian this test is manually defined to run - OpenSSL related tests, but in Ubuntu we disabled them. - - d/t/autopkgtest-pkg-ruby.conf: add restrictions to the autodep8 test - definition. - - d/ruby-tests.rake: - + Set CI environment variable to 300. This will make tests time out - after 5 minutes and not hang there forever, which can helps us identify - problems more easily. - + Do not run test/test_cli.rb tests on arm64, different test cases from - this test file have been failing for a while. Some investigation with - upstream maintainers is ongoing. - * Added: - - d/p/fix-ssl-test.patch: Fix FTBFS against OpenSSL 3. - - d/p/skip-integration-tests-failing-in-ubuntu-autopkgtest-env.patch: - some tests are failing only in autopkgtest and need further - investigation. - - -- Lucas Kanashiro Wed, 24 Nov 2021 11:30:05 -0300 - puma (5.5.2-2) unstable; urgency=medium * Team upload @@ -217,51 +164,6 @@ -- Pirate Praveen Sun, 07 Mar 2021 21:03:52 +0530 -puma (4.3.6-1ubuntu4) hirsute; urgency=medium - - * Another attempt to fix autopkgtest regressions (LP: #1916954, #1906307) - - d/p/skip-test-hanging-on-s390x.patch: renamed to - skip-test-hanging-on-different-arches.patch. Also skipped another test - which is timing out on s390x, arm64 and ppc64el so far. - - d/ruby-tests.rake: - + Set CI environment variable to 300. This will make tests time out - after 5 minutes and not hang there forever, which can helps us identify - problems more easily. - + Do not run test/test_cli.rb tests on arm64, different test cases from - this test file have been failing for a while. Some investigation with - upstream maintainers is ongoing. - - -- Lucas Kanashiro Mon, 01 Mar 2021 10:53:51 -0300 - -puma (4.3.6-1ubuntu3) hirsute; urgency=medium - - * Fix autopkgtest regressions on s390x and arm64 (LP: #1916954) - - d/p/skip-test-hanging-on-s390x.patch: this is a workaround for now. The - discussion with the Debian maintainer is ongoing. - - d/t/autopkgtest-pkg-ruby.conf: add restrictions to the autodep8 test - definition. - - d/t/control: removed. The test defined runs the same command than the - one defined by autodep8. In Debian this test is manually defined to run - OpenSSL related tests, but in Ubuntu we disabled them. - - -- Lucas Kanashiro Thu, 25 Feb 2021 17:57:53 -0300 - -puma (4.3.6-1ubuntu2) hirsute; urgency=medium - - * d/t/control: do not run SSL tests with autopkgtest. Due to OpenSSL - differences between Ubuntu and Debian some tests are failing with - autopkgtest only. - - -- Lucas Kanashiro Fri, 19 Feb 2021 16:52:12 -0300 - -puma (4.3.6-1ubuntu1) hirsute; urgency=medium - - * Merge from Debian unstable. Remaining changes: - - Disable a test that fails on Ubuntu builder farm, but not locally - (LP: #1866881). - - -- Gianfranco Costamagna Thu, 03 Dec 2020 08:12:56 +0100 - puma (4.3.6-1) unstable; urgency=medium * Team upload. @@ -278,14 +180,6 @@ -- Daniel Leidert Thu, 15 Oct 2020 20:57:29 +0200 -puma (4.3.3-3ubuntu1) groovy; urgency=low - - * Merge from Debian unstable. Remaining changes: - - Disable a test that fails on Ubuntu builder farm, but not locally - (LP: #1866881). - - -- Gianfranco Costamagna Thu, 03 Sep 2020 11:42:08 +0200 - puma (4.3.3-3) unstable; urgency=medium * Include patch from gitlab to improve performance @@ -356,14 +250,6 @@ -- Daniel Leidert Thu, 06 Feb 2020 11:45:11 +0100 -puma (3.12.4-1ubuntu2) focal; urgency=medium - - - Merge from Debian unstable. Remaining changes: - - Disable a test that fails on Ubuntu builder farm, but not locally - (LP: #1866881). - - -- Lucas Kanashiro Tue, 10 Mar 2020 15:46:46 -0300 - puma (3.12.4-1) unstable; urgency=medium * Team upload. @@ -386,13 +272,6 @@ -- Daniel Leidert Wed, 04 Mar 2020 23:09:16 +0100 -puma (3.12.0-4ubuntu1) focal; urgency=low - - * Merge from Debian unstable. Remaining changes: - - Disable a test that fails on Ubuntu builder farm, but not locally - - -- Gianfranco Costamagna Fri, 07 Feb 2020 11:02:15 +0100 - puma (3.12.0-4) unstable; urgency=medium * Team upload. @@ -425,12 +304,6 @@ -- Daniel Leidert Wed, 05 Feb 2020 18:20:58 +0100 -puma (3.12.0-2ubuntu1) disco; urgency=medium - - * Disable a test that fails on Ubuntu builder farm, but not locally - - -- Gianfranco Costamagna Sat, 13 Apr 2019 16:25:42 +0200 - puma (3.12.0-2) unstable; urgency=medium * Disable tests failing in single cpu (Closes: #921931) diff -Nru puma-5.6.5/debian/control puma-6.4.2/debian/control --- puma-5.6.5/debian/control 2023-07-21 19:27:57.000000000 +0000 +++ puma-6.4.2/debian/control 2024-02-07 22:16:00.000000000 +0000 @@ -14,18 +14,18 @@ ruby-minitest-stub-const, ruby-nio4r (>= 2), ruby-rack (<< 3), - ruby-sd-notify + ruby-sd-notify, + procps Standards-Version: 4.6.2 -Vcs-Git: https://salsa.debian.org/ruby-team/puma.git +Vcs-Git: https://salsa.debian.org/ruby-team/puma.git -b debian/experimental Vcs-Browser: https://salsa.debian.org/ruby-team/puma Homepage: https://puma.io Testsuite: autopkgtest-pkg-ruby -XS-Ruby-Versions: all Rules-Requires-Root: binary-targets Package: puma Architecture: any -XB-Ruby-Versions: ${ruby:Versions} +Breaks: rails (<< 2:6.1.7.3+dfsg-3~) Depends: ruby, ${misc:Depends}, ${ruby:Depends}, ${shlibs:Depends} Description: threaded HTTP 1.1 server for Ruby/Rack applications Puma is a simple, fast, threaded, and highly concurrent HTTP 1.1 server for diff -Nru puma-5.6.5/debian/copyright puma-6.4.2/debian/copyright --- puma-5.6.5/debian/copyright 2021-11-24 13:50:40.000000000 +0000 +++ puma-6.4.2/debian/copyright 2024-02-06 12:43:41.000000000 +0000 @@ -10,7 +10,8 @@ Files: debian/* Copyright: 2016 Antonio Terceiro - 2020 Daniel Leidert + 2020,2023 Daniel Leidert + 2023 Debian Ruby Extras Maintainers License: BSD-3-clause Comment: The Debian packaging is licensed under the same terms as the source. diff -Nru puma-5.6.5/debian/gbp.conf puma-6.4.2/debian/gbp.conf --- puma-5.6.5/debian/gbp.conf 2023-04-05 15:19:24.000000000 +0000 +++ puma-6.4.2/debian/gbp.conf 1970-01-01 00:00:00.000000000 +0000 @@ -1,3 +0,0 @@ -[DEFAULT] -pristine-tar = true -verbose = true diff -Nru puma-5.6.5/debian/patches/0004-puma.gemspec-drop-git-usage.patch puma-6.4.2/debian/patches/0004-puma.gemspec-drop-git-usage.patch --- puma-5.6.5/debian/patches/0004-puma.gemspec-drop-git-usage.patch 2021-11-24 14:07:21.000000000 +0000 +++ puma-6.4.2/debian/patches/0004-puma.gemspec-drop-git-usage.patch 2024-02-06 12:43:41.000000000 +0000 @@ -7,9 +7,11 @@ puma.gemspec | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) +diff --git a/puma.gemspec b/puma.gemspec +index f5e01c2..9ff86a3 100644 --- a/puma.gemspec +++ b/puma.gemspec -@@ -13,8 +13,7 @@ +@@ -13,8 +13,7 @@ Gem::Specification.new do |s| if RbConfig::CONFIG['ruby_version'] >= '2.5' s.metadata["msys2_mingw_dependencies"] = "openssl" end diff -Nru puma-5.6.5/debian/patches/0011-disable-minitest-extensions.patch puma-6.4.2/debian/patches/0011-disable-minitest-extensions.patch --- puma-5.6.5/debian/patches/0011-disable-minitest-extensions.patch 2023-04-05 15:19:24.000000000 +0000 +++ puma-6.4.2/debian/patches/0011-disable-minitest-extensions.patch 2024-02-06 12:43:41.000000000 +0000 @@ -4,12 +4,12 @@ Forwarded: not-needed --- - test/helper.rb | 8 ++++---- - 1 file changed, 4 insertions(+), 4 deletions(-) + test/helper.rb | 7 +------ + 1 file changed, 1 insertion(+), 6 deletions(-) --- a/test/helper.rb +++ b/test/helper.rb -@@ -14,7 +14,6 @@ +@@ -16,7 +16,6 @@ require_relative "minitest/verbose" require "minitest/autorun" require "minitest/pride" @@ -17,19 +17,21 @@ require "minitest/stub_const" require "net/http" require_relative "helpers/apps" -@@ -102,10 +101,6 @@ - end - +@@ -110,12 +109,9 @@ Minitest::Test.prepend TimeoutEveryTestCase --if ENV['CI'] + + if ENV['CI'] - require 'minitest/retry' -- Minitest::Retry.use! --end - module TestSkips + SUMMARY_FILE = ENV['GITHUB_STEP_SUMMARY'] + +- Minitest::Retry.use! +- + if SUMMARY_FILE && ENV['GITHUB_ACTIONS'] == 'true' -@@ -178,7 +173,7 @@ - REPO_NAME = ENV['GITHUB_REPOSITORY'] ? ENV['GITHUB_REPOSITORY'][/[^\/]+\z/] : 'puma' + GITHUB_STEP_SUMMARY_MUTEX = Mutex.new +@@ -215,7 +211,7 @@ + PROJECT_ROOT = File.dirname(__dir__) def self.run(reporter, options = {}) # :nodoc: - prove_it! diff -Nru puma-5.6.5/debian/patches/0012-disable-cli-ssl-tests.patch puma-6.4.2/debian/patches/0012-disable-cli-ssl-tests.patch --- puma-5.6.5/debian/patches/0012-disable-cli-ssl-tests.patch 2023-04-05 15:19:24.000000000 +0000 +++ puma-6.4.2/debian/patches/0012-disable-cli-ssl-tests.patch 2024-02-06 12:43:41.000000000 +0000 @@ -4,18 +4,23 @@ Forwarded: not-needed --- - test/test_cli.rb | 2 +- test/test_pumactl.rb | 2 +- - 2 files changed, 2 insertions(+), 2 deletions(-) + 1 file changed, 1 insertion(+), 1 deletion(-) --- a/test/test_pumactl.rb +++ b/test/test_pumactl.rb -@@ -223,7 +223,7 @@ - refute_includes log, 'send_request' +@@ -255,12 +255,12 @@ end -- def test_control_ssl -+ def __test_control_ssl + +- def test_control_ssl_ipv4 ++ def __test_control_ssl_ipv4 skip_unless :ssl + control_ssl '127.0.0.1' + end - host = "127.0.0.1" +- def test_control_ssl_ipv6 ++ def __test_control_ssl_ipv6 + skip_unless :ssl + control_ssl '[::1]' + end diff -Nru puma-5.6.5/debian/patches/0013-fix-test-term-not-accepts-new-connections.patch puma-6.4.2/debian/patches/0013-fix-test-term-not-accepts-new-connections.patch --- puma-5.6.5/debian/patches/0013-fix-test-term-not-accepts-new-connections.patch 2021-11-24 14:07:21.000000000 +0000 +++ puma-6.4.2/debian/patches/0013-fix-test-term-not-accepts-new-connections.patch 2024-02-06 12:43:41.000000000 +0000 @@ -3,14 +3,16 @@ Subject: Fix test to read output locale independent The test fails if run in a non-English environment. + +Forwarded: https://github.com/puma/puma/issues/2149 --- test/test_integration_single.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) --- a/test/test_integration_single.rb +++ b/test/test_integration_single.rb -@@ -76,7 +76,7 @@ - true while @server.gets !~ /Gracefully stopping/ # wait for server to begin graceful shutdown +@@ -112,7 +112,7 @@ + assert wait_for_server_to_include('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}") diff -Nru puma-5.6.5/debian/patches/0014-disable-test-failing-on-amd64.patch puma-6.4.2/debian/patches/0014-disable-test-failing-on-amd64.patch --- puma-5.6.5/debian/patches/0014-disable-test-failing-on-amd64.patch 2023-04-05 15:19:24.000000000 +0000 +++ puma-6.4.2/debian/patches/0014-disable-test-failing-on-amd64.patch 2024-02-06 12:43:41.000000000 +0000 @@ -1,9 +1,18 @@ +From: Pirate Praveen +Date: Mon, 8 Mar 2021 23:03:21 +0530 +Subject: Disable test that failied on amd64 buildd + This test failed on amd64 buildd https://buildd.debian.org/status/fetch.php?pkg=puma&arch=amd64&ver=5.2.2-1&stamp=1615133735&raw=0 +Forwarded: not-needed +--- + test/test_puma_server.rb | 2 +- + 1 file changed, 1 insertion(+), 1 deletion(-) + --- a/test/test_puma_server.rb +++ b/test/test_puma_server.rb -@@ -1294,7 +1294,7 @@ +@@ -1566,7 +1566,7 @@ end end diff -Nru puma-5.6.5/debian/patches/0015-disable-different-output-test.patch puma-6.4.2/debian/patches/0015-disable-different-output-test.patch --- puma-5.6.5/debian/patches/0015-disable-different-output-test.patch 1970-01-01 00:00:00.000000000 +0000 +++ puma-6.4.2/debian/patches/0015-disable-different-output-test.patch 2024-02-06 12:43:41.000000000 +0000 @@ -0,0 +1,13 @@ +Output changed from 'OK' to '::Rack::URLMap is loaded' + +--- a/test/test_url_map.rb ++++ b/test/test_url_map.rb +@@ -9,7 +9,7 @@ + end + + # make sure the mapping defined in url_map_test/config.ru works +- def test_basic_url_mapping ++ def __test_basic_url_mapping + skip_if :jruby + env = { "BUNDLE_GEMFILE" => "#{__dir__}/url_map_test/Gemfile" } + Dir.chdir("#{__dir__}/url_map_test") do diff -Nru puma-5.6.5/debian/patches/0016-disable-test-failing-on-arm64.patch puma-6.4.2/debian/patches/0016-disable-test-failing-on-arm64.patch --- puma-5.6.5/debian/patches/0016-disable-test-failing-on-arm64.patch 1970-01-01 00:00:00.000000000 +0000 +++ puma-6.4.2/debian/patches/0016-disable-test-failing-on-arm64.patch 2024-02-06 12:43:41.000000000 +0000 @@ -0,0 +1,13 @@ +this test is failing on arm64 buildd (sometimes on other archs as well) + +--- a/test/test_plugin_systemd.rb ++++ b/test/test_plugin_systemd.rb +@@ -32,7 +32,7 @@ + @sockaddr = nil + end + +- def test_systemd_notify_usr1_phased_restart_cluster ++ def __test_systemd_notify_usr1_phased_restart_cluster + skip_unless :fork + assert_restarts_with_systemd :USR1 + end diff -Nru puma-5.6.5/debian/patches/0017-disable-test-failing-on-armhf.patch puma-6.4.2/debian/patches/0017-disable-test-failing-on-armhf.patch --- puma-5.6.5/debian/patches/0017-disable-test-failing-on-armhf.patch 1970-01-01 00:00:00.000000000 +0000 +++ puma-6.4.2/debian/patches/0017-disable-test-failing-on-armhf.patch 2024-02-06 12:43:41.000000000 +0000 @@ -0,0 +1,13 @@ +https://ci.debian.net/packages/p/puma/testing/armhf/42745746/ + +--- a/test/test_integration_ssl_session.rb ++++ b/test/test_integration_ssl_session.rb +@@ -122,7 +122,7 @@ + assert reused, 'session was not reused' + end + +- def test_off_tls1_2 ++ def __test_off_tls1_2 + ssl_vers = Puma::MiniSSL::OPENSSL_LIBRARY_VERSION + old_ssl = ssl_vers.include?(' 1.0.') || ssl_vers.match?(/ 1\.1\.1[ a-e]/) + skip 'Requires 1.1.1f or later' if old_ssl diff -Nru puma-5.6.5/debian/patches/0018-disable-test-failing-with-ruby3.2.patch puma-6.4.2/debian/patches/0018-disable-test-failing-with-ruby3.2.patch --- puma-5.6.5/debian/patches/0018-disable-test-failing-with-ruby3.2.patch 1970-01-01 00:00:00.000000000 +0000 +++ puma-6.4.2/debian/patches/0018-disable-test-failing-with-ruby3.2.patch 2024-02-07 22:16:00.000000000 +0000 @@ -0,0 +1,143 @@ +Description: Skip tests failing with ruby3.2 in Ubuntu + Those tests reported the following errors: + . + 1) Error: +TestRackUp::RackUp#test_bin: +TimeoutEveryTestCase::TestTookTooLong: execution expired + /<>/test/test_rack_handler.rb:318:in `sysread' + /<>/test/test_rack_handler.rb:318:in `test_bin' + /<>/test/helper.rb:90:in `block (4 levels) in run' + /usr/lib/ruby/3.2.0/timeout.rb:189:in `block in timeout' + /usr/lib/ruby/3.2.0/timeout.rb:196:in `timeout' + /<>/test/helper.rb:88:in `block (3 levels) in run' + + 2) Error: +TestCLI#test_control_clustered: +TimeoutEveryTestCase::TestTookTooLong: execution expired + /<>/test/test_cli.rb:136:in `join' + /<>/test/test_cli.rb:136:in `test_control_clustered' + /<>/test/helper.rb:90:in `block (4 levels) in run' + /usr/lib/ruby/3.2.0/timeout.rb:189:in `block in timeout' + /usr/lib/ruby/3.2.0/timeout.rb:196:in `timeout' + /<>/test/helper.rb:88:in `block (3 levels) in run' + + 3) Error: +TestPluginSystemd#test_systemd_notify_usr2_hot_restart_cluster: +Errno::EPIPE: Broken pipe + /<>/test/test_plugin_systemd.rb:90:in `write' + /<>/test/test_plugin_systemd.rb:90:in `assert_restarts_with_systemd' + /<>/test/test_plugin_systemd.rb:42:in `test_systemd_notify_usr2_hot_restart_cluster' + /<>/test/helper.rb:90:in `block (4 levels) in run' + /usr/lib/ruby/3.2.0/timeout.rb:189:in `block in timeout' + /usr/lib/ruby/3.2.0/timeout.rb:196:in `timeout' + /<>/test/helper.rb:88:in `block (3 levels) in run' + + 4) Error: +TestIntegrationPumactl#test_halt_unix: +Errno::ECHILD: No child processes + /<>/test/test_integration_pumactl.rb:55:in `wait2' + /<>/test/test_integration_pumactl.rb:55:in `ctl_unix' + /<>/test/test_integration_pumactl.rb:42:in `test_halt_unix' + /<>/test/helper.rb:90:in `block (4 levels) in run' + /usr/lib/ruby/3.2.0/timeout.rb:189:in `block in timeout' + /usr/lib/ruby/3.2.0/timeout.rb:196:in `timeout' + /<>/test/helper.rb:88:in `block (3 levels) in run' + + 5) Error: +TestIntegrationPumactl#test_stop_unix: +Errno::ECHILD: No child processes + /<>/test/test_integration_pumactl.rb:55:in `wait2' + /<>/test/test_integration_pumactl.rb:55:in `ctl_unix' + /<>/test/test_integration_pumactl.rb:38:in `test_stop_unix' + /<>/test/helper.rb:90:in `block (4 levels) in run' + /usr/lib/ruby/3.2.0/timeout.rb:189:in `block in timeout' + /usr/lib/ruby/3.2.0/timeout.rb:196:in `timeout' + /<>/test/helper.rb:88:in `block (3 levels) in run' + + 6) Error: +TestPumaServer#test_timeout_in_data_phase: +TimeoutEveryTestCase::TestTookTooLong: execution expired + /<>/debian/puma/usr/lib/x86_64-linux-gnu/rubygems-integration/3.2.0/gems/puma-6.4.2/lib/puma/server.rb:627:in `join' + /<>/debian/puma/usr/lib/x86_64-linux-gnu/rubygems-integration/3.2.0/gems/puma-6.4.2/lib/puma/server.rb:627:in `stop' + /<>/test/test_puma_server.rb:31:in `teardown' + /<>/test/helper.rb:96:in `block (5 levels) in run' + /<>/test/helper.rb:96:in `each' + /<>/test/helper.rb:96:in `block (4 levels) in run' + /usr/lib/ruby/3.2.0/timeout.rb:189:in `block in timeout' + /usr/lib/ruby/3.2.0/timeout.rb:196:in `timeout' + /<>/test/helper.rb:95:in `block (3 levels) in run' + . + They do not seem real regressions, so skipping them for now. +Author: Lucas Kanashiro +Last-Updated: 2024-02-07 +Forwarded: not-needed + +--- a/test/test_cli.rb ++++ b/test/test_cli.rb +@@ -101,7 +101,7 @@ + t&.join + end + +- def test_control_clustered ++ def __test_control_clustered + skip_unless :fork + skip_unless :unix + url = "unix://#{@tmp_path}" +--- a/test/test_integration_pumactl.rb ++++ b/test/test_integration_pumactl.rb +@@ -34,11 +34,11 @@ + @server = nil + end + +- def test_stop_unix ++ def __test_stop_unix + ctl_unix + end + +- def test_halt_unix ++ def __test_halt_unix + ctl_unix 'halt' + end + +--- a/test/test_plugin_systemd.rb ++++ b/test/test_plugin_systemd.rb +@@ -37,7 +37,7 @@ + assert_restarts_with_systemd :USR1 + end + +- def test_systemd_notify_usr2_hot_restart_cluster ++ def __test_systemd_notify_usr2_hot_restart_cluster + skip_unless :fork + assert_restarts_with_systemd :USR2 + end +--- a/test/test_rack_handler.rb ++++ b/test/test_rack_handler.rb +@@ -308,7 +308,7 @@ + FileUtils.rm 'config.ru' + end + +- def test_bin ++ def __test_bin + pid = nil + # JRuby & TruffleRuby take a long time using IO.popen + skip_unless :mri +--- a/test/test_puma_server.rb ++++ b/test/test_puma_server.rb +@@ -593,7 +593,7 @@ + assert_equal [:booting, :running, :stop, :done], states + end + +- def test_timeout_in_data_phase(**options) ++ def __test_timeout_in_data_phase(**options) + server_run(first_data_timeout: 1, **options) + + socket = send_http "POST / HTTP/1.1\r\nHost: test.com\r\nContent-Type: text/plain\r\nContent-Length: 5\r\n\r\n" +@@ -606,7 +606,7 @@ + assert_equal "HTTP/1.1 408 #{STATUS_CODES[408]}\r\n", data + end + +- def test_timeout_data_no_queue ++ def __test_timeout_data_no_queue + test_timeout_in_data_phase(queue_requests: false) + end + diff -Nru puma-5.6.5/debian/patches/CVE-2023-40175.patch puma-6.4.2/debian/patches/CVE-2023-40175.patch --- puma-5.6.5/debian/patches/CVE-2023-40175.patch 2023-09-22 17:01:31.000000000 +0000 +++ puma-6.4.2/debian/patches/CVE-2023-40175.patch 1970-01-01 00:00:00.000000000 +0000 @@ -1,143 +0,0 @@ -From 7405a219801dcebc0ad6e0aa108d4319ca23f662 Mon Sep 17 00:00:00 2001 -From: Nate Berkopec -Date: Fri, 18 Aug 2023 09:47:23 +0900 -Subject: [PATCH] Merge pull request from GHSA-68xg-gqqm-vgj8 - -* Reject empty string for Content-Length - -* Ignore trailers in last chunk - -* test_puma_server.rb - use heredoc, test_cl_and_te_smuggle - -* client.rb - stye/RubyCop - -* test_puma_server.rb - indented heredoc rubocop disable - -* Dentarg comments - -* Remove unused variable - ---------- - -Co-authored-by: MSP-Greg ---- - lib/puma/client.rb | 23 ++++++++++++++-------- - test/test_puma_server.rb | 42 +++++++++++++++++++++++++++++++++++++++- - 2 files changed, 56 insertions(+), 9 deletions(-) - -diff --git a/lib/puma/client.rb b/lib/puma/client.rb -index e966f995e8..9c11912caa 100644 ---- a/lib/puma/client.rb -+++ b/lib/puma/client.rb -@@ -45,7 +45,8 @@ class Client - - # chunked body validation - CHUNK_SIZE_INVALID = /[^\h]/.freeze -- CHUNK_VALID_ENDING = "\r\n".freeze -+ CHUNK_VALID_ENDING = Const::LINE_END -+ CHUNK_VALID_ENDING_SIZE = CHUNK_VALID_ENDING.bytesize - - # Content-Length header value validation - CONTENT_LENGTH_VALUE_INVALID = /[^\d]/.freeze -@@ -347,8 +348,8 @@ def setup_body - cl = @env[CONTENT_LENGTH] - - if cl -- # cannot contain characters that are not \d -- if cl =~ CONTENT_LENGTH_VALUE_INVALID -+ # cannot contain characters that are not \d, or be empty -+ if cl =~ CONTENT_LENGTH_VALUE_INVALID || cl.empty? - raise HttpParserError, "Invalid Content-Length: #{cl.inspect}" - end - else -@@ -509,7 +510,7 @@ def decode_chunk(chunk) - - while !io.eof? - line = io.gets -- if line.end_with?("\r\n") -+ if line.end_with?(CHUNK_VALID_ENDING) - # 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[^;]+/] -@@ -521,13 +522,19 @@ def decode_chunk(chunk) - @in_last_chunk = true - @body.rewind - rest = io.read -- last_crlf_size = "\r\n".bytesize -- if rest.bytesize < last_crlf_size -+ if rest.bytesize < CHUNK_VALID_ENDING_SIZE - @buffer = nil -- @partial_part_left = last_crlf_size - rest.bytesize -+ @partial_part_left = CHUNK_VALID_ENDING_SIZE - rest.bytesize - return false - else -- @buffer = rest[last_crlf_size..-1] -+ # if the next character is a CRLF, set buffer to everything after that CRLF -+ start_of_rest = if rest.start_with?(CHUNK_VALID_ENDING) -+ CHUNK_VALID_ENDING_SIZE -+ else # we have started a trailer section, which we do not support. skip it! -+ rest.index(CHUNK_VALID_ENDING*2) + CHUNK_VALID_ENDING_SIZE*2 -+ end -+ -+ @buffer = rest[start_of_rest..-1] - @buffer = nil if @buffer.empty? - set_ready - return true -diff --git a/test/test_puma_server.rb b/test/test_puma_server.rb -index 298e44b439..2bfaf98848 100644 ---- a/test/test_puma_server.rb -+++ b/test/test_puma_server.rb -@@ -627,7 +627,7 @@ def test_large_chunked_request - [200, {}, [""]] - } - -- header = "GET / HTTP/1.1\r\nConnection: close\r\nTransfer-Encoding: chunked\r\n\r\n" -+ header = "GET / HTTP/1.1\r\nConnection: close\r\nContent-Length: 200\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. -@@ -1365,4 +1365,44 @@ def test_rack_url_scheme_user - data = send_http_and_read "GET / HTTP/1.0\r\n\r\n" - assert_equal "user", data.split("\r\n").last - end -+ -+ def test_cl_empty_string -+ server_run do |env| -+ [200, {}, [""]] -+ end -+ -+ empty_cl_request = "GET / HTTP/1.1\r\nHost: localhost\r\nContent-Length:\r\n\r\nGET / HTTP/1.1\r\nHost: localhost\r\n\r\n" -+ -+ data = send_http_and_read empty_cl_request -+ assert_operator data, :start_with?, 'HTTP/1.1 400 Bad Request' -+ end -+ -+ def test_crlf_trailer_smuggle -+ server_run do |env| -+ [200, {}, [""]] -+ end -+ -+ smuggled_payload = "GET / HTTP/1.1\r\nTransfer-Encoding: chunked\r\nHost: whatever\r\n\r\n0\r\nX:POST / HTTP/1.1\r\nHost: whatever\r\n\r\nGET / HTTP/1.1\r\nHost: whatever\r\n\r\n" -+ -+ data = send_http_and_read smuggled_payload -+ assert_equal 2, data.scan("HTTP/1.1 200 OK").size -+ end -+ -+ # test to check if content-length is ignored when 'transfer-encoding: chunked' -+ # is used. See also test_large_chunked_request -+ def test_cl_and_te_smuggle -+ body = nil -+ server_run { |env| -+ body = env['rack.input'].read -+ [200, {}, [""]] -+ } -+ -+ req = "POST /search HTTP/1.1\r\nHost: vulnerable-website.com\r\nContent-Type: application/x-www-form-urlencoded\r\nContent-Length: 4\r\nTransfer-Encoding: chunked\r\n\r\n7b\r\nGET /404 HTTP/1.1\r\nHost: vulnerable-website.com\r\nContent-Type: application/x-www-form-urlencoded\r\nContent-Length: 144\r\n\r\nx=\r\n0\r\n\r\n" -+ -+ data = send_http_and_read req -+ -+ assert_includes body, "GET /404 HTTP/1.1\r\n" -+ assert_includes body, "Content-Length: 144\r\n" -+ assert_equal 1, data.scan("HTTP/1.1 200 OK").size -+ end - end diff -Nru puma-5.6.5/debian/patches/CVE-2024-21647.patch puma-6.4.2/debian/patches/CVE-2024-21647.patch --- puma-5.6.5/debian/patches/CVE-2024-21647.patch 2024-01-23 17:50:14.000000000 +0000 +++ puma-6.4.2/debian/patches/CVE-2024-21647.patch 1970-01-01 00:00:00.000000000 +0000 @@ -1,100 +0,0 @@ -Ubuntu note: simplified test case as to not hit this upstream bug: -https://github.com/puma/puma/issues/3307 - -From bbb880ffb6debbfdea535b4b3eb2204d49ae151d Mon Sep 17 00:00:00 2001 -From: Nate Berkopec -Date: Mon, 8 Jan 2024 14:48:43 +0900 -Subject: [PATCH] Merge pull request from GHSA-c2f4-cvqm-65w2 - -Co-authored-by: MSP-Greg -Co-authored-by: Patrik Ragnarsson -Co-authored-by: Evan Phoenix ---- - lib/puma/client.rb | 27 +++++++++++++++++++++++++++ - test/test_puma_server.rb | 14 ++++++++++++++ - 2 files changed, 41 insertions(+) - -diff --git a/lib/puma/client.rb b/lib/puma/client.rb -index 9c11912caa..b5a1569c68 100644 ---- a/lib/puma/client.rb -+++ b/lib/puma/client.rb -@@ -48,6 +48,14 @@ class Client - CHUNK_VALID_ENDING = Const::LINE_END - CHUNK_VALID_ENDING_SIZE = CHUNK_VALID_ENDING.bytesize - -+ # The maximum number of bytes we'll buffer looking for a valid -+ # chunk header. -+ MAX_CHUNK_HEADER_SIZE = 4096 -+ -+ # The maximum amount of excess data the client sends -+ # using chunk size extensions before we abort the connection. -+ MAX_CHUNK_EXCESS = 16 * 1024 -+ - # Content-Length header value validation - CONTENT_LENGTH_VALUE_INVALID = /[^\d]/.freeze - -@@ -460,6 +468,7 @@ def setup_chunked_body(body) - @chunked_body = true - @partial_part_left = 0 - @prev_chunk = "" -+ @excess_cr = 0 - - @body = Tempfile.new(Const::PUMA_TMP_BASE) - @body.unlink -@@ -541,6 +550,20 @@ def decode_chunk(chunk) - end - end - -+ # Track the excess as a function of the size of the -+ # header vs the size of the actual data. Excess can -+ # go negative (and is expected to) when the body is -+ # significant. -+ # The additional of chunk_hex.size and 2 compensates -+ # for a client sending 1 byte in a chunked body over -+ # a long period of time, making sure that that client -+ # isn't accidentally eventually punished. -+ @excess_cr += (line.size - len - chunk_hex.size - 2) -+ -+ if @excess_cr >= MAX_CHUNK_EXCESS -+ raise HttpParserError, "Maximum chunk excess detected" -+ end -+ - len += 2 - - part = io.read(len) -@@ -568,6 +591,10 @@ def decode_chunk(chunk) - @partial_part_left = len - part.size - end - else -+ if @prev_chunk.size + chunk.size >= MAX_CHUNK_HEADER_SIZE -+ raise HttpParserError, "maximum size of chunk header exceeded" -+ end -+ - @prev_chunk = line - return false - end -diff --git a/test/test_puma_server.rb b/test/test_puma_server.rb -index 2bfaf98848..05bf83e20d 100644 ---- a/test/test_puma_server.rb -+++ b/test/test_puma_server.rb -@@ -648,6 +648,20 @@ def test_large_chunked_request - end - end - -+ def test_large_chunked_request_header -+ server_run(environment: :production) { |env| -+ [200, {}, [""]] -+ } -+ -+ max_chunk_header_size = Puma::Client::MAX_CHUNK_HEADER_SIZE -+ header = "GET / HTTP/1.1\r\nConnection: close\r\nContent-Length: 200\r\nTransfer-Encoding: chunked\r\n\r\n" -+ socket = send_http "#{header}1;t#{'x' * (max_chunk_header_size + 2)}" -+ -+ data = socket.read -+ -+ assert_match "HTTP/1.1 400 Bad Request\r\n\r\n", data -+ end -+ - def test_chunked_request_pause_before_value - body = nil - content_length = nil diff -Nru puma-5.6.5/debian/patches/series puma-6.4.2/debian/patches/series --- puma-5.6.5/debian/patches/series 2024-01-23 17:49:58.000000000 +0000 +++ puma-6.4.2/debian/patches/series 2024-02-07 22:12:23.000000000 +0000 @@ -3,7 +3,7 @@ 0012-disable-cli-ssl-tests.patch 0013-fix-test-term-not-accepts-new-connections.patch 0014-disable-test-failing-on-amd64.patch -skip-tests-hanging-on-different-arches.patch -skip-integration-tests-failing-in-ubuntu-autopkgtest-env.patch -CVE-2023-40175.patch -CVE-2024-21647.patch +0015-disable-different-output-test.patch +0016-disable-test-failing-on-arm64.patch +0017-disable-test-failing-on-armhf.patch +0018-disable-test-failing-with-ruby3.2.patch diff -Nru puma-5.6.5/debian/patches/skip-integration-tests-failing-in-ubuntu-autopkgtest-env.patch puma-6.4.2/debian/patches/skip-integration-tests-failing-in-ubuntu-autopkgtest-env.patch --- puma-5.6.5/debian/patches/skip-integration-tests-failing-in-ubuntu-autopkgtest-env.patch 2023-07-21 19:27:25.000000000 +0000 +++ puma-6.4.2/debian/patches/skip-integration-tests-failing-in-ubuntu-autopkgtest-env.patch 1970-01-01 00:00:00.000000000 +0000 @@ -1,60 +0,0 @@ -Description: Skip integration tests failing in Ubuntu autopkgtest environment - The following test failures is happening in autopgktest but not during build time: - . - 1) Failure: - TestIntegrationSingle#test_write_to_log [/tmp/autopkgtest.to6T3c/build.Xkl/src/test/test_integration_single.rb:142]: - Expected /GET \/ HTTP\/1\.1/ to match "=== puma startup: 2021-11-23 22:23:04 +0000 ===\n- Gracefully stopping, waiting for requests to finish\n". - - 2) Failure: - TestIntegrationSingle#test_puma_started_log_writing [/tmp/autopkgtest.to6T3c/build.Xkl/src/test/test_integration_single.rb:162]: - Expected /GET \/ HTTP\/1\.1/ to match "=== puma startup: 2021-11-23 22:23:06 +0000 ===\n- Gracefully stopping, waiting for requests to finish\n". - - 3) Error: - TestIntegrationSingle#test_term_not_accepts_new_connections: - Errno::ESRCH: No such process - /tmp/autopkgtest.to6T3c/build.Xkl/src/test/test_integration_single.rb:82:in `getpgid' - /tmp/autopkgtest.to6T3c/build.Xkl/src/test/test_integration_single.rb:82:in `test_term_not_accepts_new_connections' - /tmp/autopkgtest.to6T3c/build.Xkl/src/test/helper.rb:81:in `block (4 levels) in run' - /usr/lib/ruby/2.7.0/timeout.rb:95:in `block in timeout' - /usr/lib/ruby/2.7.0/timeout.rb:105:in `timeout' - /tmp/autopkgtest.to6T3c/build.Xkl/src/test/helper.rb:79:in `block (3 levels) in run' - /usr/lib/ruby/vendor_ruby/minitest/test.rb:195:in `capture_exceptions' - /tmp/autopkgtest.to6T3c/build.Xkl/src/test/helper.rb:78:in `block (2 levels) in run' - /usr/lib/ruby/vendor_ruby/minitest.rb:270:in `time_it' - /tmp/autopkgtest.to6T3c/build.Xkl/src/test/helper.rb:77:in `block in run' - /usr/lib/ruby/vendor_ruby/minitest.rb:365:in `on_signal' - /usr/lib/ruby/vendor_ruby/minitest/test.rb:211:in `with_info_handler' - /tmp/autopkgtest.to6T3c/build.Xkl/src/test/helper.rb:76:in `run' - /usr/lib/ruby/vendor_ruby/minitest.rb:1029:in `run_one_method' - /usr/lib/ruby/vendor_ruby/minitest/parallel.rb:33:in `block (2 levels) in start' - . -Author: Lucas Kanashiro -Forwarded: not-needed -Last-Updated: 2021-11-23 - ---- a/test/test_integration_single.rb -+++ b/test/test_integration_single.rb -@@ -64,6 +64,7 @@ - end - - def test_term_not_accepts_new_connections -+ skip "This test is failing in Ubuntu autopkgtest env" - skip_unless_signal_exist? :TERM - skip_if :jruby - -@@ -124,6 +125,7 @@ - end - - def test_write_to_log -+ skip "This test is failing in Ubuntu autopkgtest env" - skip_unless_signal_exist? :TERM - - suppress_output = '> /dev/null 2>&1' -@@ -143,6 +145,7 @@ - end - - def test_puma_started_log_writing -+ skip "This test is failing in Ubuntu autopkgtest env" - skip_unless_signal_exist? :TERM - - suppress_output = '> /dev/null 2>&1' diff -Nru puma-5.6.5/debian/patches/skip-tests-hanging-on-different-arches.patch puma-6.4.2/debian/patches/skip-tests-hanging-on-different-arches.patch --- puma-5.6.5/debian/patches/skip-tests-hanging-on-different-arches.patch 2023-07-21 19:27:25.000000000 +0000 +++ puma-6.4.2/debian/patches/skip-tests-hanging-on-different-arches.patch 1970-01-01 00:00:00.000000000 +0000 @@ -1,21 +0,0 @@ -Description: skip test hanging on multiple architectures - The new TestPlugin#test_plugin is hanging on s390x. This issue was also noticed - in Debian but it did not block its migration there. Since this test is new (not - present in the puma's version in the Hirsute release pocket) I am not - considering it as a real regresion. However, a discussion was started with the - Debian maintainer to investigate it. -Author: Lucas Kanashiro -Bug-Upstream: https://github.com/puma/puma/issues/2148 -Bug-Ubuntu: https://bugs.launchpad.net/ubuntu/+source/puma/+bug/1916954 -Last-Updated: 2021-11-24 - ---- a/test/test_plugin.rb -+++ b/test/test_plugin.rb -@@ -3,6 +3,7 @@ - - class TestPlugin < TestIntegration - def test_plugin -+ skip "This test is reliably timing out on multiple architectures" - 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 diff -Nru puma-5.6.5/debian/puma.lintian-overrides puma-6.4.2/debian/puma.lintian-overrides --- puma-5.6.5/debian/puma.lintian-overrides 2021-11-24 13:50:40.000000000 +0000 +++ puma-6.4.2/debian/puma.lintian-overrides 2024-02-06 12:43:41.000000000 +0000 @@ -1,2 +1,4 @@ # this is one of several sub-directories; no need to rename it -repeated-path-segment puma usr/share/doc/puma/examples/puma/ +repeated-path-segment puma [usr/share/doc/puma/examples/puma/] +repeated-path-segment 3.1.0 [usr/lib/x86_64-linux-gnu/rubygems-integration/3.1.0/extensions/x86_64-linux/3.1.0/] +repeated-path-segment lib [usr/lib/x86_64-linux-gnu/rubygems-integration/3.1.0/gems/puma-5.6.7/lib/] diff -Nru puma-5.6.5/debian/ruby-tests.rake puma-6.4.2/debian/ruby-tests.rake --- puma-5.6.5/debian/ruby-tests.rake 2023-07-21 19:27:57.000000000 +0000 +++ puma-6.4.2/debian/ruby-tests.rake 2024-02-06 12:43:41.000000000 +0000 @@ -8,14 +8,10 @@ else t.test_files = FileList['test/**/*_test.rb'] + FileList['test/**/test_*.rb'] - FileList[ 'test/test_*ssl.rb', - 'test/test_integration_systemd.rb', 'test/test_integration_cluster.rb', - 'test/test_integration_pumactl.rb', 'test/test_worker_gem_independence.rb', + 'test/test_rack_version_restriction.rb', 'test/test_preserve_bundler_env.rb', - 'test/test_request_invalid.rb', - 'test/test_busy_worker.rb', - 'test/test_out_of_band_server.rb', ] end t.verbose = true @@ -26,8 +22,7 @@ test_logs_all_localhost_bindings test_multiple_requests_waiting_on_less_busy_worker test_term_not_accepts_new_connections - test_no_timeout_after_data_received - test_chunked_keep_alive_two_back_to_back + test_prune_bundler_with_multiple_workers ] t.options << ' ' << "-e'/" << exclude.join('|') << "/'" end diff -Nru puma-5.6.5/debian/rules puma-6.4.2/debian/rules --- puma-5.6.5/debian/rules 2021-11-24 14:07:21.000000000 +0000 +++ puma-6.4.2/debian/rules 2024-02-06 12:43:41.000000000 +0000 @@ -4,6 +4,8 @@ export DH_RUBY = --gem-install export DH_RUBY_GEM_INSTALL_EXCLUDE = benchmarks/* docs/* win_gem_test/* tools/* bin/puma-wild export LANG = C.UTF-8 +export LC_ALL = C.UTF-8 +export TEST_CASE_TIMEOUT = 300 %: dh $@ --buildsystem=ruby --with ruby diff -Nru puma-5.6.5/debian/salsa-ci.yml puma-6.4.2/debian/salsa-ci.yml --- puma-5.6.5/debian/salsa-ci.yml 2023-04-05 15:19:24.000000000 +0000 +++ puma-6.4.2/debian/salsa-ci.yml 1970-01-01 00:00:00.000000000 +0000 @@ -1,4 +0,0 @@ ---- -include: - - https://salsa.debian.org/salsa-ci-team/pipeline/raw/master/salsa-ci.yml - - https://salsa.debian.org/salsa-ci-team/pipeline/raw/master/pipeline-jobs.yml diff -Nru puma-5.6.5/debian/tests/test_puma_server_ssl puma-6.4.2/debian/tests/test_puma_server_ssl --- puma-5.6.5/debian/tests/test_puma_server_ssl 1970-01-01 00:00:00.000000000 +0000 +++ puma-6.4.2/debian/tests/test_puma_server_ssl 2024-02-06 12:43:41.000000000 +0000 @@ -0,0 +1,19 @@ +#!/bin/sh + +set -x + +# override default config +# https://github.com/puma/puma/issues/2147 +OPENSSL_CONF='' +export OPENSSL_CONF + +mv lib .gem2deb.lib + +RUBYLIB=. ruby2.5 -S rake -f ./debian/tests/test_puma_server_ssl.rake +RUBYLIB=. ruby2.7 -S rake -f ./debian/tests/test_puma_server_ssl.rake + +mv .gem2deb.lib lib + +unset OPENSSL_CONF + +set +x diff -Nru puma-5.6.5/debian/tests/test_puma_server_ssl.rake puma-6.4.2/debian/tests/test_puma_server_ssl.rake --- puma-5.6.5/debian/tests/test_puma_server_ssl.rake 1970-01-01 00:00:00.000000000 +0000 +++ puma-6.4.2/debian/tests/test_puma_server_ssl.rake 2024-02-06 12:43:41.000000000 +0000 @@ -0,0 +1,7 @@ +require 'gem2deb/rake/testtask' + +Gem2Deb::Rake::TestTask.new do |t| + t.libs = ['test'] + t.test_files = FileList['test/test_puma_server_ssl.rb'] + t.verbose = true +end diff -Nru puma-5.6.5/debian/upstream/metadata puma-6.4.2/debian/upstream/metadata --- puma-5.6.5/debian/upstream/metadata 2021-11-24 13:50:40.000000000 +0000 +++ puma-6.4.2/debian/upstream/metadata 2024-02-06 12:43:41.000000000 +0000 @@ -1,7 +1,7 @@ --- Archive: GitHub Bug-Database: https://github.com/puma/puma/issues -Bug-Submit: https://github.com/puma/puma/issues -Changelog: https://github.com/puma/puma/tags +Bug-Submit: https://github.com/puma/puma/issues/new +Changelog: https://github.com/puma/puma/releases Repository: https://github.com/puma/puma.git Repository-Browse: https://github.com/puma/puma diff -Nru puma-5.6.5/debian/watch puma-6.4.2/debian/watch --- puma-5.6.5/debian/watch 2023-04-05 15:19:24.000000000 +0000 +++ puma-6.4.2/debian/watch 2024-02-06 12:43:41.000000000 +0000 @@ -1,5 +1,5 @@ version=4 opts="searchmode=plain, \ filenamemangle=s/.+\/v@ANY_VERSION@/@PACKAGE@-$1\.tar\.gz/" \ -https://api.github.com/repos/puma/puma/releases \ -https://api.github.com/repos/puma/puma/tarball/v@ANY_VERSION@ +https://api.github.com/repos/puma/puma/tags \ +https://api.github.com/repos/puma/puma/tarball/refs/tags/v@ANY_VERSION@ diff -Nru puma-5.6.5/docs/compile_options.md puma-6.4.2/docs/compile_options.md --- puma-5.6.5/docs/compile_options.md 2022-08-22 23:33:14.000000000 +0000 +++ puma-6.4.2/docs/compile_options.md 2024-01-08 05:53:42.000000000 +0000 @@ -19,3 +19,37 @@ ``` bundle config build.puma "--with-cflags='-D PUMA_QUERY_STRING_MAX_LENGTH=64000'" ``` + +## Request Path, `PUMA_REQUEST_PATH_MAX_LENGTH` + +By default, the max length of `REQUEST_PATH` is `8192`. But you may want to +adjust it to accept longer paths in requests. + +For manual install, pass the `PUMA_REQUEST_PATH_MAX_LENGTH` option like this: + +``` +gem install puma -- --with-cflags="-D PUMA_REQUEST_PATH_MAX_LENGTH=64000" +``` + +For Bundler, use its configuration system: + +``` +bundle config build.puma "--with-cflags='-D PUMA_REQUEST_PATH_MAX_LENGTH=64000'" +``` + +## Request URI, `PUMA_REQUEST_URI_MAX_LENGTH` + +By default, the max length of `REQUEST_URI` is `1024 * 12`. But you may want to +adjust it to accept longer URIs in requests. + +For manual install, pass the `PUMA_REQUEST_URI_MAX_LENGTH` option like this: + +``` +gem install puma -- --with-cflags="-D PUMA_REQUEST_URI_MAX_LENGTH=64000" +``` + +For Bundler, use its configuration system: + +``` +bundle config build.puma "--with-cflags='-D PUMA_REQUEST_URI_MAX_LENGTH=64000'" +``` diff -Nru puma-5.6.5/docs/fork_worker.md puma-6.4.2/docs/fork_worker.md --- puma-5.6.5/docs/fork_worker.md 2022-08-22 23:33:14.000000000 +0000 +++ puma-6.4.2/docs/fork_worker.md 2024-01-08 05:53:42.000000000 +0000 @@ -10,7 +10,7 @@ 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: +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. @@ -24,8 +24,6 @@ ### 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. diff -Nru puma-5.6.5/docs/kubernetes.md puma-6.4.2/docs/kubernetes.md --- puma-5.6.5/docs/kubernetes.md 2022-08-22 23:33:14.000000000 +0000 +++ puma-6.4.2/docs/kubernetes.md 2024-01-08 05:53:42.000000000 +0000 @@ -64,3 +64,15 @@ 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. + +## Workers Per Pod, and Other Config Issues + +With containerization, you will have to make a decision about how "big" to make each pod. Should you run 2 pods with 50 workers each? 25 pods, each with 4 workers? 100 pods, with each Puma running in single mode? Each scenario represents the same total amount of capacity (100 Puma processes that can respond to requests), but there are tradeoffs to make. + +* Worker counts should be somewhere between 4 and 32 in most cases. You want more than 4 in order to minimize time spent in request queueing for a free Puma worker, but probably less than ~32 because otherwise autoscaling is working in too large of an increment or they probably won't fit very well into your nodes. In any queueing system, queue time is proportional to 1/n, where n is the number of things pulling from the queue. Each pod will have its own request queue (i.e., the socket backlog). If you have 4 pods with 1 worker each (4 request queues), wait times are, proportionally, about 4 times higher than if you had 1 pod with 4 workers (1 request queue). +* Unless you have a very I/O-heavy application (50%+ time spent waiting on IO), use the default thread count (5 for MRI). Using higher numbers of threads with low I/O wait (<50%) will lead to additional request queueing time (latency!) and additional memory usage. +* More processes per pod reduces memory usage per process, because of copy-on-write memory and because the cost of the single master process is "amortized" over more child processes. +* Don't run less than 4 processes per pod if you can. Low numbers of processes per pod will lead to high request queueing, which means you will have to run more pods. +* If multithreaded, allocate 1 CPU per worker. If single threaded, allocate 0.75 cpus per worker. Most web applications spend about 25% of their time in I/O - but when you're running multi-threaded, your Puma process will have higher CPU usage and should be able to fully saturate a CPU core. +* Most Puma processes will use about ~512MB-1GB per worker, and about 1GB for the master process. However, you probably shouldn't bother with setting memory limits lower than around 2GB per process, because most places you are deploying will have 2GB of RAM per CPU. A sensible memory limit for a Puma configuration of 4 child workers might be something like 8 GB (1 GB for the master, 7GB for the 4 children). + diff -Nru puma-5.6.5/docs/nginx.md puma-6.4.2/docs/nginx.md --- puma-5.6.5/docs/nginx.md 2022-08-22 23:33:14.000000000 +0000 +++ puma-6.4.2/docs/nginx.md 2024-01-08 05:53:42.000000000 +0000 @@ -2,7 +2,7 @@ This is a very common setup using an upstream. It was adapted from some Capistrano recipe I found on the Internet a while ago. -``` +```nginx upstream myapp { server unix:///myapp/tmp/puma.sock; } diff -Nru puma-5.6.5/docs/restart.md puma-6.4.2/docs/restart.md --- puma-5.6.5/docs/restart.md 2022-08-22 23:33:14.000000000 +0000 +++ puma-6.4.2/docs/restart.md 2024-01-08 05:53:42.000000000 +0000 @@ -27,6 +27,7 @@ ### Additional notes +* The newly started Puma 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. * 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. diff -Nru puma-5.6.5/docs/systemd.md puma-6.4.2/docs/systemd.md --- puma-5.6.5/docs/systemd.md 2022-08-22 23:33:14.000000000 +0000 +++ puma-6.4.2/docs/systemd.md 2024-01-08 05:53:42.000000000 +0000 @@ -24,8 +24,7 @@ [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. +# monitoring, 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 @@ -52,7 +51,7 @@ # Variant: Rails start. # ExecStart=//bin/puma -C /config/puma.rb ../config.ru -# Variant: Use `bundle exec --keep-file-descriptors puma` instead of binstub +# Variant: Use `bundle exec 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 @@ -77,9 +76,7 @@ **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]. +puma master process. **Note:** Socket activation doesn't currently work on JRuby. This is tracked in [#1367]. diff -Nru puma-5.6.5/docs/testing_benchmarks_local_files.md puma-6.4.2/docs/testing_benchmarks_local_files.md --- puma-5.6.5/docs/testing_benchmarks_local_files.md 1970-01-01 00:00:00.000000000 +0000 +++ puma-6.4.2/docs/testing_benchmarks_local_files.md 2024-01-08 05:53:42.000000000 +0000 @@ -0,0 +1,150 @@ +# Testing - benchmark/local files + +These files generate data that shows request-per-second (RPS), etc. Typically, files are in +pairs, a shell script and a Ruby script. The shell script starts the server, then runs the +Ruby file, which starts client request stream(s), then collects and logs metrics. + +## response_time_wrk.sh + +This uses [wrk] for generating data. One or more wrk runs are performed. Summarizes RPS and +wrk latency times. The default for the `-b` argument runs 28 different client request streams, +and takes a bit over 5 minutes. See 'Request Stream Configuration' below for `-b` argument +description. + +
+ Summary output for
benchmarks/local/response_time_wrk.sh -w2 -t5:5 -s tcp6:
+ +``` +Type req/sec 50% 75% 90% 99% 100% Resp Size +───────────────────────────────────────────────────────────────── 1kB +array 13710 0.74 2.52 5.23 7.76 37.45 1024 +chunk 13502 0.76 2.55 5.28 7.84 11.23 1042 +string 13794 0.74 2.51 5.20 7.75 14.07 1024 +io 9615 1.16 3.45 7.13 10.57 15.75 1024 +───────────────────────────────────────────────────────────────── 10kB +array 13458 0.76 2.57 5.31 7.93 13.94 10239 +chunk 13066 0.78 2.64 5.46 8.18 38.48 10320 +string 13500 0.76 2.55 5.29 7.88 11.42 10240 +io 9293 1.18 3.59 7.39 10.94 16.99 10240 +───────────────────────────────────────────────────────────────── 100kB +array 11315 0.96 3.06 6.33 9.49 17.69 102424 +chunk 9916 1.10 3.48 7.20 10.73 15.14 103075 +string 10948 1.00 3.17 6.57 9.83 17.88 102378 +io 8901 1.21 3.72 7.48 11.27 59.98 102407 +───────────────────────────────────────────────────────────────── 256kB +array 9217 1.15 3.82 7.88 11.74 17.12 262212 +chunk 7339 1.45 4.76 9.81 14.63 22.70 264007 +string 8574 1.19 3.81 7.73 11.21 15.80 262147 +io 8911 1.19 3.80 7.55 15.25 60.01 262183 +───────────────────────────────────────────────────────────────── 512kB +array 6951 1.49 5.03 10.28 15.90 25.08 524378 +chunk 5234 2.03 6.56 13.57 20.46 32.15 527862 +string 6438 1.55 5.04 10.12 16.28 72.87 524275 +io 8533 1.15 4.62 8.79 48.15 70.51 524327 +───────────────────────────────────────────────────────────────── 1024kB +array 4122 1.80 15.59 41.87 67.79 121.00 1048565 +chunk 3158 2.82 15.22 31.00 71.39 99.90 1055654 +string 4710 2.24 6.66 13.65 20.38 70.44 1048575 +io 8355 1.23 3.95 7.94 14.08 68.54 1048498 +───────────────────────────────────────────────────────────────── 2048kB +array 2454 4.12 14.02 27.70 43.48 88.89 2097415 +chunk 1743 6.26 17.65 36.98 55.78 92.10 2111358 +string 2479 4.38 12.52 25.65 38.44 95.62 2097502 +io 8264 1.25 3.83 7.76 11.73 65.69 2097090 + +Body ────────── req/sec ────────── ─────── req 50% times ─────── + KB array chunk string io array chunk string io +1 13710 13502 13794 9615 0.745 0.757 0.741 1.160 +10 13458 13066 13500 9293 0.760 0.784 0.759 1.180 +100 11315 9916 10948 8901 0.960 1.100 1.000 1.210 +256 9217 7339 8574 8911 1.150 1.450 1.190 1.190 +512 6951 5234 6438 8533 1.490 2.030 1.550 1.150 +1024 4122 3158 4710 8355 1.800 2.820 2.240 1.230 +2048 2454 1743 2479 8264 4.120 6.260 4.380 1.250 +───────────────────────────────────────────────────────────────────── +wrk -t8 -c16 -d10s +benchmarks/local/response_time_wrk.sh -w2 -t5:5 -s tcp6 -Y +Server cluster mode -w2 -t5:5, bind: tcp6 +Puma repo branch 00-response-refactor +ruby 3.2.0dev (2022-06-14T01:21:55Z master 048f14221c) +YJIT [x86_64-linux] + +[2136] - Gracefully shutting down workers... +[2136] === puma shutdown: 2022-06-13 21:16:13 -0500 === +[2136] - Goodbye! + + 5:15 Total Time +``` +

+ +## bench_base.sh, bench_base.rb + +These two files setup parameters for the Puma server, which is normally started in a shell +script. It then starts a Ruby file (a subclass of BenchBase), passing arguments to it. The +Ruby file is normally used to generate a client request stream(s). + +### Puma Configuration + +The following arguments are used for the Puma server: + +* **`-C`** - configuration file +* **`-d`** - app delay +* **`-r`** - rackup file, often defaults to test/rackup/ci_select.ru +* **`-s`** - bind socket type, default is tcp/tcp4, also tcp6, ssl/ssl4, ssl6, unix, or aunix + (unix & abstract unix are not available with wrk). +* **`-t`** - threads, expressed as '5:5', same as Puma --thread +* **`-w`** - workers, same as Puma --worker +* **`-Y`** - enable Ruby YJIT + +### Request Stream Configuration + +The following arguments are used for request streams: + +* **`-b`** - response body configuration. Body type options are a array, c chunked, s string, + and i for File/IO. None or any combination can be specified, they should start the option. + Then, any combination of comma separated integers can be used for the response body size + in kB. The string 'ac50,100' would create four runs, 50kb array, 50kB chunked, 100kB array, + and 100kB chunked. See 'Testing - test/rackup/ci-*.ru files' for more info. +* **`-c`** - connections per client request stream thread, defaults to 2 for wrk. +* **`-D`** - duration of client request stream in seconds. +* **`-T`** - number of threads in the client request stream. For wrk, this defaults to + 80% of Puma workers * max_threads. + +### Notes - Configuration + +The above lists script arguments. + +`bench_base.sh` contains most server defaults. Many can be set via ENV variables. + +`bench_base.rb` contains the client request stream defaults. The default value for +`-b` is `acsi1,10,100,256,512,1024,2048`, which is a 4 x 7 matrix, and hence, runs +28 jobs. Also, the i body type (File/IO) generates files, they are placed in the +`"#{Dir.tmpdir}/.puma_response_body_io"` directory, which is created. + +### Notes - wrk + +The shell scripts use `-T` for wrk's thread count, since `-t` is used for Puma +server threads. Regarding the `-c` argument, wrk has an interesting behavior. +The total number of connections is set by `(connections/threads).to_i`. The scripts +here use `-c` as connections per thread. Hence, using `-T4 -c2` will yield a total +of eight wrk connections, two per thread. The equivalent wrk arguments would be `-t4 -c8`. + +Puma can only process so many requests, and requests will queue in the backlog +until Puma can respond to them. With wrk, if the number of total connections is +too high, one will see the upper latency times increase, pushing into the lower +latency times as the connections are increased. The default values for wrk's +threads and connections were chosen to minimize requests' time in the backlog. + +An example with four wrk runs using `-b s10`. Notice that `req/sec` varies by +less than 1%, but the `75%` times increase by an order of magnitude: +``` +req/sec 50% 75% 90% 99% 100% Resp Size wrk cmd line +───────────────────────────────────────────────────────────────────────────── + 13597 0.755 2.550 5.260 7.800 13.310 12040 wrk -t8 -c16 -d10 + 13549 0.793 4.430 8.140 11.220 16.600 12002 wrk -t10 -c20 -d10 + 13570 1.040 25.790 40.010 49.070 58.300 11982 wrk -t8 -c64 -d10 + 13684 1.050 25.820 40.080 49.160 66.190 12033 wrk -t16 -c64 -d10 +``` +Finally, wrk's output may cause rounding errors, so the response body size calculation is +imprecise. + +[wrk]: diff -Nru puma-5.6.5/docs/testing_test_rackup_ci_files.md puma-6.4.2/docs/testing_test_rackup_ci_files.md --- puma-5.6.5/docs/testing_test_rackup_ci_files.md 1970-01-01 00:00:00.000000000 +0000 +++ puma-6.4.2/docs/testing_test_rackup_ci_files.md 2024-01-08 05:53:42.000000000 +0000 @@ -0,0 +1,36 @@ +# Testing - test/rackup/ci-*.ru files + +## Overview + +Puma should efficiently handle a variety of response bodies, varying both by size +and by the type of object used for the body. + +Five rackup files are located in 'test/rackup' that can be used. All have their +request body size (in kB) set via `Body-Conf` header or with `ENV['CI_BODY_CONF']`. +Additionally, the ci_select.ru file can have it's body type set via a starting +character. + +* **ci_array.ru** - body is an `Array` of 1kB strings. `Content-Length` is not set. +* **ci_chunked.ru** - body is an `Enumerator` of 1kB strings. `Content-Length` is not set. +* **ci_io.ru** - body is a File/IO object. `Content-Length` is set. +* **ci_string.ru** - body is a single string. `Content-Length` is set. +* **ci_select.ru** - can be any of the above. + +All responses have 25 headers, total length approx 1kB. ci_array.ru and ci_chunked.ru +contain 1kB items. + +All can be delayed by a float value (seconds) specified by the `Dly` header + +Note that rhe `Body-Conf` header takes precedence, and `ENV['CI_BODY_CONF']` is +only read on load. + +## ci_select.ru + +The ci_select.ru file allows a starting character to specify the body type in the +`Body-Conf` header or with `ENV['CI_BODY_CONF']`. +* **a** - array of strings +* **c** - chunked (enum) +* **s** - single string +* **i** - File/IO + +A value of `a100` would return a body as an array of 100 1kB strings. diff -Nru puma-5.6.5/examples/puma/chain_cert/ca.crt puma-6.4.2/examples/puma/chain_cert/ca.crt --- puma-5.6.5/examples/puma/chain_cert/ca.crt 1970-01-01 00:00:00.000000000 +0000 +++ puma-6.4.2/examples/puma/chain_cert/ca.crt 2024-01-08 05:53:42.000000000 +0000 @@ -0,0 +1,19 @@ +-----BEGIN CERTIFICATE----- +MIIDKDCCAhCgAwIBAgIBATANBgkqhkiG9w0BAQ0FADAcMRowGAYDVQQDDBFjYS5w +dW1hLmxvY2FsaG9zdDAeFw0yMzA2MDEwMDAwMDBaFw0yNzA2MDEwMDAwMDBaMBwx +GjAYBgNVBAMMEWNhLnB1bWEubG9jYWxob3N0MIIBIjANBgkqhkiG9w0BAQEFAAOC +AQ8AMIIBCgKCAQEAtHILxmP3PDm0UxL6CRTqrUWf1PYmBXgoLy7tZNj3KGMQVsw0 +jeeyAUI9UimtNtgAbKVCrtC46phxwAn0c0wcPiXpckfAaF1pViXRe9WrMLmFeo47 +Uyy2uWuApuFPpHBw+baflr+h1haEYVSFwsJaIPyuuf8vh5PuvOtfdqrG+V7gve86 +Utk2NTZUIpB0oaI/DqXyBor9Ra3IucuaAKHh+Mjc61WIJhjMIgbtfl+FWuDXiYz6 +hNbXkr4LtU2hKQCD1NKZjI4I/UIPnB5Wf+cdAIiNz2UvTvEfrCTew0mtckDFsC2x +gMpHnkuUi/ZxM5n8UwikHqtLVVmFpYCzN3idrwIDAQABo3UwczAdBgNVHQ4EFgQU +gMSutCsZtiRRpYv73dV9KoWPd9YwDgYDVR0PAQH/BAQDAgIEMBMGA1UdJQQMMAoG +CCsGAQUFBwMBMAwGA1UdEwQFMAMBAf8wHwYDVR0jBBgwFoAUgMSutCsZtiRRpYv7 +3dV9KoWPd9YwDQYJKoZIhvcNAQENBQADggEBAANVPJJZttOrWM4PfftJ7e2MHrM4 +f3EUtNgAsbRNw1MAvhAxaR7JjyXYYKXNkfz5H1o8V15iZvupG4jOQRRrQfgAu+JR +ExOCoidD/uyk63kFre6OmeyjblKkuTnbrt/zBHVej+5eLqFMIQhAsHZCZn3Yrc36 +rKtoYgWgmkL1AMG830QR1uNT4NuReP/XPkdUgoJyw0YPypMjmVNczAHFcVS4jW1p +OJx2Sp1Q4HCUY5gzXEy5wEIuuQcmQZEsxA5J2BLV6ciHuwKvI8WDqvTb0/fipcBQ +AtK32KFAGMgaYZ7ivAiC8WcZCp5fXToEhu7F8uRd4ZJlMf2UCyQvEroTD0Y= +-----END CERTIFICATE----- diff -Nru puma-5.6.5/examples/puma/chain_cert/ca.key puma-6.4.2/examples/puma/chain_cert/ca.key --- puma-5.6.5/examples/puma/chain_cert/ca.key 1970-01-01 00:00:00.000000000 +0000 +++ puma-6.4.2/examples/puma/chain_cert/ca.key 2024-01-08 05:53:42.000000000 +0000 @@ -0,0 +1,27 @@ +-----BEGIN RSA PRIVATE KEY----- +MIIEpQIBAAKCAQEAtHILxmP3PDm0UxL6CRTqrUWf1PYmBXgoLy7tZNj3KGMQVsw0 +jeeyAUI9UimtNtgAbKVCrtC46phxwAn0c0wcPiXpckfAaF1pViXRe9WrMLmFeo47 +Uyy2uWuApuFPpHBw+baflr+h1haEYVSFwsJaIPyuuf8vh5PuvOtfdqrG+V7gve86 +Utk2NTZUIpB0oaI/DqXyBor9Ra3IucuaAKHh+Mjc61WIJhjMIgbtfl+FWuDXiYz6 +hNbXkr4LtU2hKQCD1NKZjI4I/UIPnB5Wf+cdAIiNz2UvTvEfrCTew0mtckDFsC2x +gMpHnkuUi/ZxM5n8UwikHqtLVVmFpYCzN3idrwIDAQABAoIBAQCwU5VwCvVoc5bj +avLL9xWPti6GYvYqeA0Edl3iIyX54DvyJV/hnxxRoJHdfP5XTmGzyRXNUAayr77Q +AqpOFHywuklRtA2vrkAlv5Th5px/Y3qslNoh39q6e/Nen2M88+diDPQL0jzpwF0h +4v9GnraF74UqGdQvLv6mu3Ywtpbyy/9zkF88NnHK9687q7y1l9sbGPEtaLEQNhtN +Y/MGrVMkdde/+w83MrPDMp2Kk1TcT3YJkY22KLc9nEDv1Qi1Gk8JYFHyiYRCPzW5 +TGdZ9B+Dn/M5TijZCqKYCqfbl3TvKDbFLNvxykbNEhVTYlN8x+y1MOqzCQ1u1Lhm +/dXYZE/RAoGBAOkC4GlHTOXk6vEtgvSMmxPAKyz8uWxBgSgOb7sS+EetP4cI2vxK +2L3Hycpy1s5TyrTGjPPXQ1/Mbr6W/IVTz0Xacuz/VfccrvBmaHZTCphFBEQ0mAZN +09k3FwerWlz++N+SIhc+A2VJmiOicrBTSgLC3nOxe0vt9oSsOGcL5B+nAoGBAMY/ +hpWsPQpdZGXRZ1hh20U/AmCxd7373/2bm6Lkb11f/MfbO2sAHnIc7LGHbVvV5Y2z +esxeWFgaBKaf7xTUJyfv+ZcS7304rzM2AXj/+ev5sOlac/4ghCy9L9W3prf6P/LC +fEclwjNjt0mo0Ue/1/MllB4kdYX0QYEb2/vL/BK5AoGAL00eMUEAI0stRnhutSY1 +9PR1z1QecBN8HJ2RoPBg5mwNEWSCz+SBy0TbefWGFax84eXMh1OTocbmVFpiOM6i +rRODcQkEcn2oJbUkT6Db7b1U+GOU2PLDprzAOBZY6bf43anUsdMs7Urbt5AqqBDA +XX8hmWrWFLvh51zutjx7utECgYEAt1jbHKOt1F8pUi1Hmeruwu0SQuD+sFs4/jCi +0RTZlg8HFsNAAaabgcgUc9+fGVcKNXIveMEsjVaKxJuXnrjS+dGsELd3fGUnS4J/ +/CspNb+4iSiZrAbglwvlKI/wBajQ6bBLBfX61FI9mkciPmxDyWEQyovHkTqkNkbQ +veAa4ikCgYEA4+Cn2murnFcQpIx1uaF7SkBXBIUchyZW5wW2BMv433VdWH+Y6+PL ++Hjcs/Ix7MDnuHm4WTEJGmyNKw5lGdbSMdjWhnKdlfN8U3EGRcTb6QedBdFF0Bbg +kIQduC2aFiv2CiZ+t2GHyiyV5kCW0+WGczw9fFwCUJlByhlWuXyYBAc= +-----END RSA PRIVATE KEY----- diff -Nru puma-5.6.5/examples/puma/chain_cert/ca_chain.pem puma-6.4.2/examples/puma/chain_cert/ca_chain.pem --- puma-5.6.5/examples/puma/chain_cert/ca_chain.pem 1970-01-01 00:00:00.000000000 +0000 +++ puma-6.4.2/examples/puma/chain_cert/ca_chain.pem 2024-01-08 05:53:42.000000000 +0000 @@ -0,0 +1,39 @@ +-----BEGIN CERTIFICATE----- +MIIDMjCCAhqgAwIBAgIBCzANBgkqhkiG9w0BAQ0FADAcMRowGAYDVQQDDBFjYS5w +dW1hLmxvY2FsaG9zdDAeFw0yMzA2MDEwMDAwMDBaFw0yNzA2MDEwMDAwMDBaMCYx +JDAiBgNVBAMMG2ludGVybWVkaWF0ZS5wdW1hLmxvY2FsaG9zdDCCASIwDQYJKoZI +hvcNAQEBBQADggEPADCCAQoCggEBAOHKwUnnX7QXvewBtH5XAtRbhhgAIQQ14pxs +9w3WBWY6B7zKZdkRnu/bULwP4QGHPc/YAtWSMN+1aWWm/6od3xn0AfFrzWxzMXVT +ZTnI6aQ3emxIec3gc5Xa4+oF0SUmVZiY3U9l4Apk1d4xNnV81UzE7KpdSUqYXdS/ +Ja7T9QXFbE0/5L7Ci9luVQLyUYNiW2CmaiiC1YE9292kfZTujsKKf4Og/65U4qgF +mfSTnIBSAHTkiF5Qo8QTx+qz55A0ue2NX3QXVZOYJMtjt8OQEWkGKP2iPFnGnd3i +TW57THaNNzVan0A96IZv3hGVNqqlUto6L0tni+QD+3d14FDRLKMCAwEAAaN1MHMw +HQYDVR0OBBYEFPsAsa4SCE1WrZvYs/3TKSllpTUbMA4GA1UdDwEB/wQEAwICBDAT +BgNVHSUEDDAKBggrBgEFBQcDATAMBgNVHRMEBTADAQH/MB8GA1UdIwQYMBaAFIDE +rrQrGbYkUaWL+93VfSqFj3fWMA0GCSqGSIb3DQEBDQUAA4IBAQCCEJymTKz93kmQ +Bfgj1UkVo1MC4GQAwVDJJTdEk80a3AFPuwmuwcl50rq2w4UBN9vkleKWz9ysWSrQ +Qs5RoM08ggca1dqgzIKHH95ft0BFZixEfkAhfAcrEiBNT6H5lgJ+EcFNq1n1T435 +ow3r9P3u4FlBP4BmfpffnOFlY1cTYsEOFtDSGmBe8mNJkx2z37OAiLtSKPtkfpeq +84haUFAvfJX/93JLsuHbrhZXTjXVGDsbITxheiqmxaN5HiFacG0Ju2USPtHAge8H +Rz3fbPlN2Txn83ejbaHetj2zrbsd+QobPusfDRZKUcDG/CSXV7lc65+Lp0iPbCd/ +KG6q1Yf+ +-----END CERTIFICATE----- +-----BEGIN CERTIFICATE----- +MIIDKDCCAhCgAwIBAgIBATANBgkqhkiG9w0BAQ0FADAcMRowGAYDVQQDDBFjYS5w +dW1hLmxvY2FsaG9zdDAeFw0yMzA2MDEwMDAwMDBaFw0yNzA2MDEwMDAwMDBaMBwx +GjAYBgNVBAMMEWNhLnB1bWEubG9jYWxob3N0MIIBIjANBgkqhkiG9w0BAQEFAAOC +AQ8AMIIBCgKCAQEAtHILxmP3PDm0UxL6CRTqrUWf1PYmBXgoLy7tZNj3KGMQVsw0 +jeeyAUI9UimtNtgAbKVCrtC46phxwAn0c0wcPiXpckfAaF1pViXRe9WrMLmFeo47 +Uyy2uWuApuFPpHBw+baflr+h1haEYVSFwsJaIPyuuf8vh5PuvOtfdqrG+V7gve86 +Utk2NTZUIpB0oaI/DqXyBor9Ra3IucuaAKHh+Mjc61WIJhjMIgbtfl+FWuDXiYz6 +hNbXkr4LtU2hKQCD1NKZjI4I/UIPnB5Wf+cdAIiNz2UvTvEfrCTew0mtckDFsC2x +gMpHnkuUi/ZxM5n8UwikHqtLVVmFpYCzN3idrwIDAQABo3UwczAdBgNVHQ4EFgQU +gMSutCsZtiRRpYv73dV9KoWPd9YwDgYDVR0PAQH/BAQDAgIEMBMGA1UdJQQMMAoG +CCsGAQUFBwMBMAwGA1UdEwQFMAMBAf8wHwYDVR0jBBgwFoAUgMSutCsZtiRRpYv7 +3dV9KoWPd9YwDQYJKoZIhvcNAQENBQADggEBAANVPJJZttOrWM4PfftJ7e2MHrM4 +f3EUtNgAsbRNw1MAvhAxaR7JjyXYYKXNkfz5H1o8V15iZvupG4jOQRRrQfgAu+JR +ExOCoidD/uyk63kFre6OmeyjblKkuTnbrt/zBHVej+5eLqFMIQhAsHZCZn3Yrc36 +rKtoYgWgmkL1AMG830QR1uNT4NuReP/XPkdUgoJyw0YPypMjmVNczAHFcVS4jW1p +OJx2Sp1Q4HCUY5gzXEy5wEIuuQcmQZEsxA5J2BLV6ciHuwKvI8WDqvTb0/fipcBQ +AtK32KFAGMgaYZ7ivAiC8WcZCp5fXToEhu7F8uRd4ZJlMf2UCyQvEroTD0Y= +-----END CERTIFICATE----- diff -Nru puma-5.6.5/examples/puma/chain_cert/cert.crt puma-6.4.2/examples/puma/chain_cert/cert.crt --- puma-5.6.5/examples/puma/chain_cert/cert.crt 1970-01-01 00:00:00.000000000 +0000 +++ puma-6.4.2/examples/puma/chain_cert/cert.crt 2024-01-08 05:53:42.000000000 +0000 @@ -0,0 +1,20 @@ +-----BEGIN CERTIFICATE----- +MIIDLjCCAhagAwIBAgIBbzANBgkqhkiG9w0BAQ0FADAmMSQwIgYDVQQDDBtpbnRl +cm1lZGlhdGUucHVtYS5sb2NhbGhvc3QwHhcNMjMwNjA1MDAwMDAwWhcNMjQwNjA1 +MDAwMDAwWjAeMRwwGgYDVQQDDBN0ZXN0LnB1bWEubG9jYWxob3N0MIIBIjANBgkq +hkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA1iY3+/B0oIXUChZFnXVQvZ2PfYJ8UVeW +3Oq9a6zQTP95aSwtv/rAt1jYA1H18p6npa254ODZcdWp+EtvUIiTFIII9X0B6WH/ +rDm8D6mDkMGi/L3DlJ1vJaOVbXe9NL33zJJznAZ0T2lcg9qXN6wbUIhcChRSMsFw +xR0pPnvZv34NBvefxNWnJpFKqyXEi64ONHwjil4/Sk7lunmguwLAPano3wp81qsp +rv8KTiqJUfF78CrtwY1LMWvC3AcbPBLlyBrr7mPfAoVuxu+s6tXqpcOsp/FoKa9Y +lIxS82po1aKLnXkAMc75rlT5WVXrPVeRWgDucxUDOXFpl955pNSQRQIDAQABo28w +bTAdBgNVHQ4EFgQU3FVgqa85bI7/5jVEfOh/w1RfvH4wCwYDVR0PBAQDAgbAMBMG +A1UdJQQMMAoGCCsGAQUFBwMBMAkGA1UdEwQCMAAwHwYDVR0jBBgwFoAU+wCxrhII +TVatm9iz/dMpKWWlNRswDQYJKoZIhvcNAQENBQADggEBAG0gjxN6U8EaUhaJJj8C +Av/5A+F5SPDotbpj4T/1ciSn8wQf3aotBaNCzv7mC2mWtl4PIrOZ8bH42dZ0sWEU +Ft1h4HVLQADv5QU0RKNuKXoDRvVXB6IDNAIWB+8NQwYwj+WvYH+BLoc53yBfHQGK +B+y8SVeEcQVjzmcZ2TUT2/b5XEsiV+ugLce294lUPIlSmWK043Oe3UfMRuurPwyj +Qjn/pwl7S22BRDCorQ5NThb+/lsO54J/8zpCxHmhgm152mXcCYBNjYLwd5SYvawN +Q5EDcE/31xqtZkGtBDb/ZqwUSbmwLb3qFRjgM/t+H2eUMyZUxbvmxzlZdAO7xAOC +fho= +-----END CERTIFICATE----- diff -Nru puma-5.6.5/examples/puma/chain_cert/cert.key puma-6.4.2/examples/puma/chain_cert/cert.key --- puma-5.6.5/examples/puma/chain_cert/cert.key 1970-01-01 00:00:00.000000000 +0000 +++ puma-6.4.2/examples/puma/chain_cert/cert.key 2024-01-08 05:53:42.000000000 +0000 @@ -0,0 +1,27 @@ +-----BEGIN RSA PRIVATE KEY----- +MIIEowIBAAKCAQEA1iY3+/B0oIXUChZFnXVQvZ2PfYJ8UVeW3Oq9a6zQTP95aSwt +v/rAt1jYA1H18p6npa254ODZcdWp+EtvUIiTFIII9X0B6WH/rDm8D6mDkMGi/L3D +lJ1vJaOVbXe9NL33zJJznAZ0T2lcg9qXN6wbUIhcChRSMsFwxR0pPnvZv34NBvef +xNWnJpFKqyXEi64ONHwjil4/Sk7lunmguwLAPano3wp81qsprv8KTiqJUfF78Crt +wY1LMWvC3AcbPBLlyBrr7mPfAoVuxu+s6tXqpcOsp/FoKa9YlIxS82po1aKLnXkA +Mc75rlT5WVXrPVeRWgDucxUDOXFpl955pNSQRQIDAQABAoIBAHYIVq8cV4vqd3af +0/r3oGsCnwYUl6TV3Ccjkwf4Fk96OFcJrKW19eaYp2cdE6yIWertmBgklnUxyR87 +pL0EqdyR15JHNniGNT+eCtOvIP72W3lmtpgBNjPOuBu/9Z9OXXh5+BK1VAI5Fm7u +Wo6q49s9bU146d1j1V4vtn3kEZ6DP1M80oWHYMzB4e2sYveWxvckq98zEtQjAlj0 +vpoiOB5Kfm5k5Mh6EP02ZrqnfqnBpdqwXjGIPREEMN4qsIwcdHBY5qMzWsw7wQvU +MVUAyNb+D1W6tx1FIq0WhEhedLjPaB5OyYPfskFv4QzUHeu4j97yZDCOuniIV1fn +lNhurLkCgYEA7d2O9FLtLcpAFADuikjsJOYi2gJ/I/8MqOVO/efngyJl1f6GN6iw +RIQ9vJUaA/aA+t3JMJar4iTG9G/YLa7oOKURybbmm7IOZUQiqZHLySLJnpBzcnN/ +Tgkx+fQNXs+koioyQwhiSWsgWm0AafxJPDqofqEzzHSvvfLshnNVLU8CgYEA5nnK +JbWBaUWemYhzWZX9d6TQW32IJdS8Pt+/NocR+y3CRozAhDA2q+iNQaOWzD9i9Ngc +MbG3bBLiu/N6pDHAEX+j7EiDN1NsaV+5oPTQJToSkvZLtNKFJLVUEqxPx0+KcUFV +kP2IXqr5TGHCRbWioDFCgFN3w86WcKntiVclzCsCgYA0IGuli07CzCHCwHmGAHkP +lQdqM0Xdg5UopifraJjJmg4rGT4ckHEgcsJ8w0gSOkEFuPjQFxTP2DNpeeEsEbp+ +P15okBZ1ZE3XT1kxQ+wexerdPtat7DWnykgTeLI9Zs+zYf/lxL6VTE6owl5m24zJ +ECnApl8NnTyuKcA/rqKp7QKBgFOhm/XFABmYFq31so2ufJQ+rRCV46J+qHRUMolx +x9eSSi3Zgz40VJJax28rElw5IApipRBvQXSpAbdb6YPNPbnbzDrAMUURM4SlJLHA +RAtOIFFNqDkMLx4b4k8IUcasGTtxjsAXD7XyapYJ3zn2Z/WjClOQdiQKQdLOBpDQ +m7mTAoGBAKmCzouXa6kGV2TxyuYdZVp71zjtAWEradWZUtHkNpySbx6u1HX102N+ +1zU0nhwDm75uZskage1+4WyXoQymmgUBK3L7lPjkl5O/7ILqAt05Gqn2fFG3GCdZ +CAYURtiFsmjisNRMC+DO/s4li5HNBrvoK/t+CQE0RmjMulhPZajH +-----END RSA PRIVATE KEY----- diff -Nru puma-5.6.5/examples/puma/chain_cert/cert_chain.pem puma-6.4.2/examples/puma/chain_cert/cert_chain.pem --- puma-5.6.5/examples/puma/chain_cert/cert_chain.pem 1970-01-01 00:00:00.000000000 +0000 +++ puma-6.4.2/examples/puma/chain_cert/cert_chain.pem 2024-01-08 05:53:42.000000000 +0000 @@ -0,0 +1,59 @@ +-----BEGIN CERTIFICATE----- +MIIDLjCCAhagAwIBAgIBbzANBgkqhkiG9w0BAQ0FADAmMSQwIgYDVQQDDBtpbnRl +cm1lZGlhdGUucHVtYS5sb2NhbGhvc3QwHhcNMjMwNjA1MDAwMDAwWhcNMjQwNjA1 +MDAwMDAwWjAeMRwwGgYDVQQDDBN0ZXN0LnB1bWEubG9jYWxob3N0MIIBIjANBgkq +hkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA1iY3+/B0oIXUChZFnXVQvZ2PfYJ8UVeW +3Oq9a6zQTP95aSwtv/rAt1jYA1H18p6npa254ODZcdWp+EtvUIiTFIII9X0B6WH/ +rDm8D6mDkMGi/L3DlJ1vJaOVbXe9NL33zJJznAZ0T2lcg9qXN6wbUIhcChRSMsFw +xR0pPnvZv34NBvefxNWnJpFKqyXEi64ONHwjil4/Sk7lunmguwLAPano3wp81qsp +rv8KTiqJUfF78CrtwY1LMWvC3AcbPBLlyBrr7mPfAoVuxu+s6tXqpcOsp/FoKa9Y +lIxS82po1aKLnXkAMc75rlT5WVXrPVeRWgDucxUDOXFpl955pNSQRQIDAQABo28w +bTAdBgNVHQ4EFgQU3FVgqa85bI7/5jVEfOh/w1RfvH4wCwYDVR0PBAQDAgbAMBMG +A1UdJQQMMAoGCCsGAQUFBwMBMAkGA1UdEwQCMAAwHwYDVR0jBBgwFoAU+wCxrhII +TVatm9iz/dMpKWWlNRswDQYJKoZIhvcNAQENBQADggEBAG0gjxN6U8EaUhaJJj8C +Av/5A+F5SPDotbpj4T/1ciSn8wQf3aotBaNCzv7mC2mWtl4PIrOZ8bH42dZ0sWEU +Ft1h4HVLQADv5QU0RKNuKXoDRvVXB6IDNAIWB+8NQwYwj+WvYH+BLoc53yBfHQGK +B+y8SVeEcQVjzmcZ2TUT2/b5XEsiV+ugLce294lUPIlSmWK043Oe3UfMRuurPwyj +Qjn/pwl7S22BRDCorQ5NThb+/lsO54J/8zpCxHmhgm152mXcCYBNjYLwd5SYvawN +Q5EDcE/31xqtZkGtBDb/ZqwUSbmwLb3qFRjgM/t+H2eUMyZUxbvmxzlZdAO7xAOC +fho= +-----END CERTIFICATE----- +-----BEGIN CERTIFICATE----- +MIIDMjCCAhqgAwIBAgIBCzANBgkqhkiG9w0BAQ0FADAcMRowGAYDVQQDDBFjYS5w +dW1hLmxvY2FsaG9zdDAeFw0yMzA2MDEwMDAwMDBaFw0yNzA2MDEwMDAwMDBaMCYx +JDAiBgNVBAMMG2ludGVybWVkaWF0ZS5wdW1hLmxvY2FsaG9zdDCCASIwDQYJKoZI +hvcNAQEBBQADggEPADCCAQoCggEBAOHKwUnnX7QXvewBtH5XAtRbhhgAIQQ14pxs +9w3WBWY6B7zKZdkRnu/bULwP4QGHPc/YAtWSMN+1aWWm/6od3xn0AfFrzWxzMXVT +ZTnI6aQ3emxIec3gc5Xa4+oF0SUmVZiY3U9l4Apk1d4xNnV81UzE7KpdSUqYXdS/ +Ja7T9QXFbE0/5L7Ci9luVQLyUYNiW2CmaiiC1YE9292kfZTujsKKf4Og/65U4qgF +mfSTnIBSAHTkiF5Qo8QTx+qz55A0ue2NX3QXVZOYJMtjt8OQEWkGKP2iPFnGnd3i +TW57THaNNzVan0A96IZv3hGVNqqlUto6L0tni+QD+3d14FDRLKMCAwEAAaN1MHMw +HQYDVR0OBBYEFPsAsa4SCE1WrZvYs/3TKSllpTUbMA4GA1UdDwEB/wQEAwICBDAT +BgNVHSUEDDAKBggrBgEFBQcDATAMBgNVHRMEBTADAQH/MB8GA1UdIwQYMBaAFIDE +rrQrGbYkUaWL+93VfSqFj3fWMA0GCSqGSIb3DQEBDQUAA4IBAQCCEJymTKz93kmQ +Bfgj1UkVo1MC4GQAwVDJJTdEk80a3AFPuwmuwcl50rq2w4UBN9vkleKWz9ysWSrQ +Qs5RoM08ggca1dqgzIKHH95ft0BFZixEfkAhfAcrEiBNT6H5lgJ+EcFNq1n1T435 +ow3r9P3u4FlBP4BmfpffnOFlY1cTYsEOFtDSGmBe8mNJkx2z37OAiLtSKPtkfpeq +84haUFAvfJX/93JLsuHbrhZXTjXVGDsbITxheiqmxaN5HiFacG0Ju2USPtHAge8H +Rz3fbPlN2Txn83ejbaHetj2zrbsd+QobPusfDRZKUcDG/CSXV7lc65+Lp0iPbCd/ +KG6q1Yf+ +-----END CERTIFICATE----- +-----BEGIN CERTIFICATE----- +MIIDKDCCAhCgAwIBAgIBATANBgkqhkiG9w0BAQ0FADAcMRowGAYDVQQDDBFjYS5w +dW1hLmxvY2FsaG9zdDAeFw0yMzA2MDEwMDAwMDBaFw0yNzA2MDEwMDAwMDBaMBwx +GjAYBgNVBAMMEWNhLnB1bWEubG9jYWxob3N0MIIBIjANBgkqhkiG9w0BAQEFAAOC +AQ8AMIIBCgKCAQEAtHILxmP3PDm0UxL6CRTqrUWf1PYmBXgoLy7tZNj3KGMQVsw0 +jeeyAUI9UimtNtgAbKVCrtC46phxwAn0c0wcPiXpckfAaF1pViXRe9WrMLmFeo47 +Uyy2uWuApuFPpHBw+baflr+h1haEYVSFwsJaIPyuuf8vh5PuvOtfdqrG+V7gve86 +Utk2NTZUIpB0oaI/DqXyBor9Ra3IucuaAKHh+Mjc61WIJhjMIgbtfl+FWuDXiYz6 +hNbXkr4LtU2hKQCD1NKZjI4I/UIPnB5Wf+cdAIiNz2UvTvEfrCTew0mtckDFsC2x +gMpHnkuUi/ZxM5n8UwikHqtLVVmFpYCzN3idrwIDAQABo3UwczAdBgNVHQ4EFgQU +gMSutCsZtiRRpYv73dV9KoWPd9YwDgYDVR0PAQH/BAQDAgIEMBMGA1UdJQQMMAoG +CCsGAQUFBwMBMAwGA1UdEwQFMAMBAf8wHwYDVR0jBBgwFoAUgMSutCsZtiRRpYv7 +3dV9KoWPd9YwDQYJKoZIhvcNAQENBQADggEBAANVPJJZttOrWM4PfftJ7e2MHrM4 +f3EUtNgAsbRNw1MAvhAxaR7JjyXYYKXNkfz5H1o8V15iZvupG4jOQRRrQfgAu+JR +ExOCoidD/uyk63kFre6OmeyjblKkuTnbrt/zBHVej+5eLqFMIQhAsHZCZn3Yrc36 +rKtoYgWgmkL1AMG830QR1uNT4NuReP/XPkdUgoJyw0YPypMjmVNczAHFcVS4jW1p +OJx2Sp1Q4HCUY5gzXEy5wEIuuQcmQZEsxA5J2BLV6ciHuwKvI8WDqvTb0/fipcBQ +AtK32KFAGMgaYZ7ivAiC8WcZCp5fXToEhu7F8uRd4ZJlMf2UCyQvEroTD0Y= +-----END CERTIFICATE----- diff -Nru puma-5.6.5/examples/puma/chain_cert/generate_chain_test.rb puma-6.4.2/examples/puma/chain_cert/generate_chain_test.rb --- puma-5.6.5/examples/puma/chain_cert/generate_chain_test.rb 1970-01-01 00:00:00.000000000 +0000 +++ puma-6.4.2/examples/puma/chain_cert/generate_chain_test.rb 2024-01-08 05:53:42.000000000 +0000 @@ -0,0 +1,129 @@ +# frozen_string_literal: true + +=begin +regenerates ca.pem, ca_keypair.pem, + subca.pem, subca_keypair.pem, + cert.pem, cert_keypair.pem + ca_chain.pem, + cert_chain.pem + +certs before date will be the first of the current month + +expires in 4 years + +=end + +require 'bundler/inline' + + +require 'certificate_authority' +gemfile do + source 'https://rubygems.org' + gem 'certificate_authority' +end + +module Generate + + CA = "ca.crt" + CA_KEY = "ca.key" + INTERMEDIATE = "intermediate.crt" + INTERMEDIATE_KEY = "intermediate.key" + CERT = "cert.crt" + CERT_KEY = "cert.key" + + CA_CHAIN = "ca_chain.pem" + CERT_CHAIN = "cert_chain.pem" + + class << self + + def path + File.expand_path(__dir__) + end + + def before_after + @before_after ||= ( + now = Time.now.utc + mo = now.month + yr = now.year + zone = '+00:00' + + { + not_before: Time.new(yr, mo, 1, 0, 0, 0, zone), + not_after: Time.new(yr+4, mo, 1, 0, 0, 0, zone) + } + ) + end + + def root_ca + @root_ca ||= generate_ca + end + + def intermediate_ca + @intermediate_ca ||= generate_ca(common_name: "intermediate.puma.localhost", parent: root_ca) + end + + def generate_ca(common_name: "ca.puma.localhost", parent: nil) + ca = CertificateAuthority::Certificate.new + + ca.subject.common_name = common_name + ca.signing_entity = true + ca.not_before = before_after[:not_before] + ca.not_after = before_after[:not_after] + + ca.key_material.generate_key + + if parent + ca.serial_number.number = parent.serial_number.number + 10 + ca.parent = parent + else + ca.serial_number.number = 1 + end + + signing_profile = {"extensions" => {"keyUsage" => {"usage" => ["critical", "keyCertSign"] }} } + + ca.sign!(signing_profile) + + ca + end + + def generate_cert(common_name: "test.puma.localhost", parent: intermediate_ca) + + cert = CertificateAuthority::Certificate.new + + cert.subject.common_name = common_name + cert.serial_number.number = parent.serial_number.number + 100 + cert.parent = parent + + cert.key_material.generate_key + cert.sign! + + cert + end + + def run + cert = generate_cert + + Dir.chdir path do + File.write CA, root_ca.to_pem, mode: 'wb' + File.write CA_KEY, root_ca.key_material.private_key.to_pem, mode: 'wb' + + File.write INTERMEDIATE, intermediate_ca.to_pem, mode: 'wb' + File.write INTERMEDIATE_KEY, intermediate_ca.key_material.private_key.to_pem, mode: 'wb' + + File.write CERT, cert.to_pem, mode: 'wb' + File.write CERT_KEY, cert.key_material.private_key.to_pem, mode: 'wb' + + ca_chain = intermediate_ca.to_pem + root_ca.to_pem + File.write CA_CHAIN, ca_chain, mode: 'wb' + + cert_chain = cert.to_pem + ca_chain + File.write CERT_CHAIN, cert_chain, mode: 'wb' + end + + rescue => e + puts "error: #{e.message}" + end + end +end + +Generate.run diff -Nru puma-5.6.5/examples/puma/chain_cert/intermediate.crt puma-6.4.2/examples/puma/chain_cert/intermediate.crt --- puma-5.6.5/examples/puma/chain_cert/intermediate.crt 1970-01-01 00:00:00.000000000 +0000 +++ puma-6.4.2/examples/puma/chain_cert/intermediate.crt 2024-01-08 05:53:42.000000000 +0000 @@ -0,0 +1,20 @@ +-----BEGIN CERTIFICATE----- +MIIDMjCCAhqgAwIBAgIBCzANBgkqhkiG9w0BAQ0FADAcMRowGAYDVQQDDBFjYS5w +dW1hLmxvY2FsaG9zdDAeFw0yMzA2MDEwMDAwMDBaFw0yNzA2MDEwMDAwMDBaMCYx +JDAiBgNVBAMMG2ludGVybWVkaWF0ZS5wdW1hLmxvY2FsaG9zdDCCASIwDQYJKoZI +hvcNAQEBBQADggEPADCCAQoCggEBAOHKwUnnX7QXvewBtH5XAtRbhhgAIQQ14pxs +9w3WBWY6B7zKZdkRnu/bULwP4QGHPc/YAtWSMN+1aWWm/6od3xn0AfFrzWxzMXVT +ZTnI6aQ3emxIec3gc5Xa4+oF0SUmVZiY3U9l4Apk1d4xNnV81UzE7KpdSUqYXdS/ +Ja7T9QXFbE0/5L7Ci9luVQLyUYNiW2CmaiiC1YE9292kfZTujsKKf4Og/65U4qgF +mfSTnIBSAHTkiF5Qo8QTx+qz55A0ue2NX3QXVZOYJMtjt8OQEWkGKP2iPFnGnd3i +TW57THaNNzVan0A96IZv3hGVNqqlUto6L0tni+QD+3d14FDRLKMCAwEAAaN1MHMw +HQYDVR0OBBYEFPsAsa4SCE1WrZvYs/3TKSllpTUbMA4GA1UdDwEB/wQEAwICBDAT +BgNVHSUEDDAKBggrBgEFBQcDATAMBgNVHRMEBTADAQH/MB8GA1UdIwQYMBaAFIDE +rrQrGbYkUaWL+93VfSqFj3fWMA0GCSqGSIb3DQEBDQUAA4IBAQCCEJymTKz93kmQ +Bfgj1UkVo1MC4GQAwVDJJTdEk80a3AFPuwmuwcl50rq2w4UBN9vkleKWz9ysWSrQ +Qs5RoM08ggca1dqgzIKHH95ft0BFZixEfkAhfAcrEiBNT6H5lgJ+EcFNq1n1T435 +ow3r9P3u4FlBP4BmfpffnOFlY1cTYsEOFtDSGmBe8mNJkx2z37OAiLtSKPtkfpeq +84haUFAvfJX/93JLsuHbrhZXTjXVGDsbITxheiqmxaN5HiFacG0Ju2USPtHAge8H +Rz3fbPlN2Txn83ejbaHetj2zrbsd+QobPusfDRZKUcDG/CSXV7lc65+Lp0iPbCd/ +KG6q1Yf+ +-----END CERTIFICATE----- diff -Nru puma-5.6.5/examples/puma/chain_cert/intermediate.key puma-6.4.2/examples/puma/chain_cert/intermediate.key --- puma-5.6.5/examples/puma/chain_cert/intermediate.key 1970-01-01 00:00:00.000000000 +0000 +++ puma-6.4.2/examples/puma/chain_cert/intermediate.key 2024-01-08 05:53:42.000000000 +0000 @@ -0,0 +1,27 @@ +-----BEGIN RSA PRIVATE KEY----- +MIIEogIBAAKCAQEA4crBSedftBe97AG0flcC1FuGGAAhBDXinGz3DdYFZjoHvMpl +2RGe79tQvA/hAYc9z9gC1ZIw37VpZab/qh3fGfQB8WvNbHMxdVNlOcjppDd6bEh5 +zeBzldrj6gXRJSZVmJjdT2XgCmTV3jE2dXzVTMTsql1JSphd1L8lrtP1BcVsTT/k +vsKL2W5VAvJRg2JbYKZqKILVgT3b3aR9lO6Owop/g6D/rlTiqAWZ9JOcgFIAdOSI +XlCjxBPH6rPnkDS57Y1fdBdVk5gky2O3w5ARaQYo/aI8Wcad3eJNbntMdo03NVqf +QD3ohm/eEZU2qqVS2jovS2eL5AP7d3XgUNEsowIDAQABAoIBAHZfS5IpIL1TrRfr +lOqfRzZ5fQVcG/MPJOyJG8Q/Lbl4NtI88cQpPr/UpLDTSkz4z+kFAAdjiwfdHZJT +SLmwy2PZzqL4t0th4M33mJwAvqx/AUl/fYv3XeF0TgREZG8rd7h2e5/CcwA/+Pdx +qXFSrqh+nOx7146p7pc4VtMe/9ezunJNWj1QMXlF3tC8ikv6Pc/T/2dRVCx1aWvF +j/nrHNYDbWs9zEUNCf0ZjQnFKWPOwg/ppRkpBYPf/hSCg8KLarKlpcXO8ZwT1DMk +4PLo2Wt4jmCaEhoD687T+GNrHbnt+wZnWrqG7/SvY2dm+MLO3Q51lqqTYGYrH0OC +Afvd18ECgYEA9ys1OxTx75qBm2Nywaoqx9/8/24Vo2XM0nLPlIND7SZR7yCsKyZ6 +ucViMwtM2r3TtkBUiDCles5yhys0GlSQZSr1F3/VwoGoEWISNTScvstUmlu4rwk8 +jG43WkPYH2s/6ns6hZLZgTZ3B+IUE+1NVzshdybSmz4pPE1haHeRlbMCgYEA6dwE +4HPlgwkmCYXISltlblJguyix/JxkErFANMJ8CtMDacF8LEvvzfAZIEf90XlahreN +cedgGBGmL4/4+2f3Ypaw9Jyc1pFxWQ5CeSgomDhvbHBYX0/PGFhZEdbY/j6NRDrn +qIcoCEVScPNyaVEVhXgfjQfKfYEQsiZ6p4VSdVECgYBX+PsDQlsyKs4CnozTvVto +tKJ5z5bIB421QcP8WhQtLjxvXjOpUBLSWByxik4adQILli4AI0Biy2QcFBaBYKPc +PkPpz0gn6LoHJd7RLR61Ee3U2tyLAECawwfUit07oZKoRJ/5tuDPirEnDyKSTR3/ +9D3fCORg+Oj4W5pV8mjQ3QKBgAqAbduieLkErSeaUV89cXWdz2g4MJ32a+wG96om +3akixrF2FdxrYI5v7MDtWrGQcIdCMODfkgoiqMLUBUtM5OgRekrRyZ09FMj6AfQs +4H3Ncvt8pAtLqzIdrYpGiqIILxHUT1jbEOomKsiVthqSoJPIzCnqIqa2KAjH/5QM +QaKxAoGAXiCchLWpgbTv4SpaqU0p1KGzN8mYJanBxTGitNEtN/oecAGXY7l8Mst6 +CWNsVBoENGyTa+nkQ2uzkI7rBsJpjmmYveKlIF2MN9AIJcAHnYQHcvvLgEgB/tzx +vPSUA0etUqyL0QNLm+EYnoS7zsmR6Xwl08OVFJIPunm8F8UAg6c= +-----END RSA PRIVATE KEY----- Binary files /tmp/tmp_mcb5jbs/pHIjZIw8ua/puma-5.6.5/examples/puma/client-certs/ca_store.jks and /tmp/tmp_mcb5jbs/b0WO_adysV/puma-6.4.2/examples/puma/client-certs/ca_store.jks differ Binary files /tmp/tmp_mcb5jbs/pHIjZIw8ua/puma-5.6.5/examples/puma/client-certs/ca_store.p12 and /tmp/tmp_mcb5jbs/b0WO_adysV/puma-6.4.2/examples/puma/client-certs/ca_store.p12 differ diff -Nru puma-5.6.5/examples/puma/client-certs/run_server_with_certs.rb puma-6.4.2/examples/puma/client-certs/run_server_with_certs.rb --- puma-5.6.5/examples/puma/client-certs/run_server_with_certs.rb 2022-08-22 23:33:14.000000000 +0000 +++ puma-6.4.2/examples/puma/client-certs/run_server_with_certs.rb 2024-01-08 05:53:42.000000000 +0000 @@ -8,8 +8,8 @@ p env['puma.peercert'] [200, {}, [ env['puma.peercert'] ]] } -events = Puma::Events.new($stdout, $stderr) -server = Puma::Server.new(app, events) +log_writer = Puma::LogWriter.new($stdout, $stderr) +server = Puma::Server.new(app, log_writer) context = Puma::MiniSSL::Context.new context.key = "certs/server.key" Binary files /tmp/tmp_mcb5jbs/pHIjZIw8ua/puma-5.6.5/examples/puma/client-certs/unknown_ca_store.p12 and /tmp/tmp_mcb5jbs/b0WO_adysV/puma-6.4.2/examples/puma/client-certs/unknown_ca_store.p12 differ diff -Nru puma-5.6.5/examples/puma/encrypted_puma_keypair.pem puma-6.4.2/examples/puma/encrypted_puma_keypair.pem --- puma-5.6.5/examples/puma/encrypted_puma_keypair.pem 1970-01-01 00:00:00.000000000 +0000 +++ puma-6.4.2/examples/puma/encrypted_puma_keypair.pem 2024-01-08 05:53:42.000000000 +0000 @@ -0,0 +1,30 @@ +-----BEGIN RSA PRIVATE KEY----- +Proc-Type: 4,ENCRYPTED +DEK-Info: AES-256-CBC,90F30BF02BE2889D8DA5B8168E8BC346 + +QxHwMvAj+xu2Qxe3joiBRrTneQAnby1vpLZb+AnsPbwUWC1TuSeQJdRNs9qYlYwg +Ow4/plNp1cPPcjEY2TQlTNtr2ur/Exy11Bs0TLcPxkiQfm+pT/uok2+Y8Qs8WMDm +3MLqMPw0wiqnOoLa8j/oUDC2VsutItCtIsld04NgJYOyQTOdNhZ284x4WLp6vUUy +atWFOWWfRTDGMzXVKnpY8RQw09D/NcHxRzdmWYelVNQpZMfFAMYygNqofoWDrS3Z +XX04fOSxdQg3QwjPkpwD/XH8XT0hGkQaKaAXl5TbCdOOuPtagcDZgt0f4MQJUhEq +HqR2bHACS4MvLk/N6l1DvoqZErepAt5uVSRclREfxnqxgXhxvhMz9fe7z0uCJzQU +Panj8sg3ytP2oAOAg9kPFYfUr+weUOIBXwioHqEnA3K1j2ADJnZvByIqekQ863vl +2f0iweHMJp23gs6FFGA0brLDY78un2L8EPQC9wgPFSZpjFmXZU7ur05bm8fndnn6 +jGy/RLQ9N+fXBAALmwL+nj0Q5luxdQqx6ouCiDKB2ehz3LO/KMD3QI5ysT7GpY2F +2Out0Ud4e2XdS+5CDs28LCBnFejaxlIvJBxXu5PhtJ+7Plm4g1uIMdRspgO+Wukw +5/eNMgWm3Q7+1NGlA4kg3HW6K7GV/QIITYWKk7ETX06oD4Uj/XUxinrADT48IMYQ +iGcSECrs9uc3MNEGA3AHDqTvmCFsBZwjtx2JZX3HwwgXUxYSX52d07fctJJe3wYX +hQi9b4EEyGb2uOMWH3gMLzJaLDM89sgwRz0YNHlQK2wV7mfkmjjNHVGix6M+dfo6 +kbXZD+rertIqytuaE++uVQEjBHk/yMPK9Yn/jA4TJlHsxNyZwrbYnHYgeGs0//fK +fe+ez1A67pvdS1/xAvFBuicqcI+D/Ib1kY3FIZBcnEpoNOCSBjtdZR06JeCKiD7S +QL5yNLgb0I5LrrO0D0Rr3jjCHpwe6WSEvO0Og6ktzXMKCTldk7YiQP1/Oy4BZBbM +EQh2OyqVLXAQdpgQ+J8xTI62khyqnvDelorcn1xQZbBLH7oS7pturxuFgVTW4RXL +SHq9GLfD4dzzIDo+fSII7PjTYwJiKFYWUZKQGboWkPHhgx3LR3geB6i+4HjuRt70 +mRKA1aitwIZsKKkuiTtlUhBN8gLctGJpoP92yMcmADLDLmysWIDb1G/Ogo1a1qLI +2N5pbJUFDrjZ82j2xLtFiVEt17cQ+7x2mxMdb5DaxliZy05XhM6SbnOETBwhIm04 +qV5IjZCCIqY3eEOsgeLz6vdjiohn2P2eXbgokl1vRQnzSwYaCTo64pIN4spgl+jN +WNsD1kQBgyvQPXoQntiiwi5CLlmVlE9RY32DYZJs0RazxcN5mBC9qP9JAVHOhUGR +JhaQfPl2GVtKyzz1dkvvXYjPFu7TKkt3Qtu59YmRfDu85JSzZhSFbssKu7lzNJv1 +wno95r0fg2uG0dvCMB1MhSaRVBnReyubPvIzycU9PYkaS23I88ZzPsxCGMlJwxCC +A9AlbH46bfzrToF/UgbRTbpX7kNjDcDOFb01E47b2h2Tm8bYg7winVFS1qdWr6CG +-----END RSA PRIVATE KEY----- diff -Nru puma-5.6.5/examples/puma/key_password_command.sh puma-6.4.2/examples/puma/key_password_command.sh --- puma-5.6.5/examples/puma/key_password_command.sh 1970-01-01 00:00:00.000000000 +0000 +++ puma-6.4.2/examples/puma/key_password_command.sh 2024-01-08 05:53:42.000000000 +0000 @@ -0,0 +1,3 @@ +#!/bin/sh + +echo "hello world" diff -Nru puma-5.6.5/ext/puma_http11/extconf.rb puma-6.4.2/ext/puma_http11/extconf.rb --- puma-5.6.5/ext/puma_http11/extconf.rb 2022-08-22 23:33:14.000000000 +0000 +++ puma-6.4.2/ext/puma_http11/extconf.rb 2024-01-08 05:53:42.000000000 +0000 @@ -2,20 +2,26 @@ dir_config("puma_http11") -if $mingw && RUBY_VERSION >= '2.4' +if $mingw 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"] +unless ENV["PUMA_DISABLE_SSL"] # don't use pkg_config('openssl') if '--with-openssl-dir' is used - has_openssl_dir = dir_config('openssl').any? + # also looks within the Ruby build for directory info + has_openssl_dir = dir_config('openssl').any? || + RbConfig::CONFIG['configure_args']&.include?('openssl') || + Dir.exist?("#{RbConfig::TOPDIR}/src/main/c/openssl") # TruffleRuby + found_pkg_config = !has_openssl_dir && pkg_config('openssl') - found_ssl = if (!$mingw || RUBY_VERSION >= '2.4') && found_pkg_config + found_ssl = if !$mingw && found_pkg_config puts 'using OpenSSL pkgconfig (openssl.pc)' true + elsif have_library('libcrypto', 'BIO_read') && have_library('libssl', 'SSL_CTX_new') + true elsif %w'crypto libeay32'.find {|crypto| have_library(crypto, 'BIO_read')} && %w'ssl ssleay32'.find {|ssl| have_library(ssl, 'SSL_CTX_new')} true @@ -28,13 +34,14 @@ have_header "openssl/bio.h" # below is yes for 1.0.2 & later - have_func "DTLS_method" , "openssl/ssl.h" + have_func "DTLS_method" , "openssl/ssl.h" + have_func "SSL_CTX_set_session_cache_mode(NULL, 0)", "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 "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 "X509_STORE_up_ref" have_func "SSL_CTX_set_ecdh_auto(NULL, 0)" , "openssl/ssl.h" # below exists in 1.1.0 and later, but isn't documented until 3.0.0 @@ -53,7 +60,7 @@ end end -if ENV["MAKE_WARNINGS_INTO_ERRORS"] +if ENV["PUMA_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 diff -Nru puma-5.6.5/ext/puma_http11/http11_parser.c puma-6.4.2/ext/puma_http11/http11_parser.c --- puma-5.6.5/ext/puma_http11/http11_parser.c 2022-08-22 23:33:14.000000000 +0000 +++ puma-6.4.2/ext/puma_http11/http11_parser.c 2024-01-08 05:53:42.000000000 +0000 @@ -297,7 +297,7 @@ tr18: #line 65 "ext/puma_http11/http11_parser.rl" { - parser->http_version(parser, PTR_TO(mark), LEN(mark, p)); + parser->server_protocol(parser, PTR_TO(mark), LEN(mark, p)); } goto st14; tr26: diff -Nru puma-5.6.5/ext/puma_http11/http11_parser.h puma-6.4.2/ext/puma_http11/http11_parser.h --- puma-5.6.5/ext/puma_http11/http11_parser.h 2022-08-22 23:33:14.000000000 +0000 +++ puma-6.4.2/ext/puma_http11/http11_parser.h 2024-01-08 05:53:42.000000000 +0000 @@ -46,7 +46,7 @@ element_cb fragment; element_cb request_path; element_cb query_string; - element_cb http_version; + element_cb server_protocol; element_cb header_done; char buf[BUFFER_LEN]; diff -Nru puma-5.6.5/ext/puma_http11/http11_parser.java.rl puma-6.4.2/ext/puma_http11/http11_parser.java.rl --- puma-5.6.5/ext/puma_http11/http11_parser.java.rl 2022-08-22 23:33:14.000000000 +0000 +++ puma-6.4.2/ext/puma_http11/http11_parser.java.rl 2024-01-08 05:53:42.000000000 +0000 @@ -39,8 +39,8 @@ 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 server_protocol { + Http11.server_protocol(runtime, parser.data, parser.buffer, parser.mark, fpc-parser.mark); } action request_path { diff -Nru puma-5.6.5/ext/puma_http11/http11_parser.rl puma-6.4.2/ext/puma_http11/http11_parser.rl --- puma-5.6.5/ext/puma_http11/http11_parser.rl 2022-08-22 23:33:14.000000000 +0000 +++ puma-6.4.2/ext/puma_http11/http11_parser.rl 2024-01-08 05:53:42.000000000 +0000 @@ -62,8 +62,8 @@ 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 server_protocol { + parser->server_protocol(parser, PTR_TO(mark), LEN(mark, fpc)); } action request_path { diff -Nru puma-5.6.5/ext/puma_http11/http11_parser_common.rl puma-6.4.2/ext/puma_http11/http11_parser_common.rl --- puma-5.6.5/ext/puma_http11/http11_parser_common.rl 2022-08-22 23:33:14.000000000 +0000 +++ puma-6.4.2/ext/puma_http11/http11_parser_common.rl 2024-01-08 05:53:42.000000000 +0000 @@ -38,8 +38,8 @@ 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 ) ; + Server_Protocol = ( "HTTP/" http_number ) >mark %server_protocol ; + Request_Line = ( Method " " Request_URI ("#" Fragment){0,1} " " Server_Protocol CRLF ) ; field_name = ( token -- ":" )+ >start_field $snake_upcase_field %write_field; diff -Nru puma-5.6.5/ext/puma_http11/mini_ssl.c puma-6.4.2/ext/puma_http11/mini_ssl.c --- puma-5.6.5/ext/puma_http11/mini_ssl.c 2022-08-22 23:33:14.000000000 +0000 +++ puma-6.4.2/ext/puma_http11/mini_ssl.c 2024-01-08 05:53:42.000000000 +0000 @@ -36,6 +36,12 @@ rb_raise(eError, "%s: error in file '%s': %s", caller, filename, ERR_error_string(ERR_get_error(), NULL)); } +NORETURN(void raise_param_error(const char* caller, const char *param)); + +void raise_param_error(const char* caller, const char *param) { + rb_raise(eError, "%s: error with parameter '%s': %s", caller, param, ERR_error_string(ERR_get_error(), NULL)); +} + void engine_free(void *ptr) { ms_conn *conn = ptr; ms_cert_buf* cert_buf = (ms_cert_buf*)SSL_get_app_data(conn->ssl); @@ -185,6 +191,18 @@ return preverify_ok; } +static int password_callback(char *buf, int size, int rwflag, void *userdata) { + const char *password = (const char *) userdata; + size_t len = strlen(password); + + if (len > (size_t) size) { + return 0; + } + + memcpy(buf, password, len); + return (int) len; +} + static VALUE sslctx_alloc(VALUE klass) { SSL_CTX *ctx; @@ -210,28 +228,35 @@ VALUE sslctx_initialize(VALUE self, VALUE mini_ssl_ctx) { SSL_CTX* ctx; - + 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, key_password_command, key_password; + BIO *bio; + X509 *x509 = NULL; + EVP_PKEY *pkey; + pem_password_cb *password_cb = NULL; + const char *password = NULL; #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_CTX_SET_DH_AUTO DH *dh; #endif - BIO *bio; - X509 *x509; - EVP_PKEY *pkey; - #if OPENSSL_VERSION_NUMBER < 0x10002000L EC_KEY *ecdh; #endif +#ifdef HAVE_SSL_CTX_SET_SESSION_CACHE_MODE + VALUE reuse, reuse_cache_size, reuse_timeout; - TypedData_Get_Struct(self, SSL_CTX, &sslctx_type, ctx); + reuse = rb_funcall(mini_ssl_ctx, rb_intern_const("reuse"), 0); + reuse_cache_size = rb_funcall(mini_ssl_ctx, rb_intern_const("reuse_cache_size"), 0); + reuse_timeout = rb_funcall(mini_ssl_ctx, rb_intern_const("reuse_timeout"), 0); +#endif key = rb_funcall(mini_ssl_ctx, rb_intern_const("key"), 0); + key_password_command = rb_funcall(mini_ssl_ctx, rb_intern_const("key_password_command"), 0); + cert = rb_funcall(mini_ssl_ctx, rb_intern_const("cert"), 0); ca = rb_funcall(mini_ssl_ctx, rb_intern_const("ca"), 0); @@ -248,6 +273,8 @@ no_tlsv1_1 = rb_funcall(mini_ssl_ctx, rb_intern_const("no_tlsv1_1"), 0); + TypedData_Get_Struct(self, SSL_CTX, &sslctx_type, ctx); + if (!NIL_P(cert)) { StringValue(cert); @@ -256,6 +283,18 @@ } } + if (!NIL_P(key_password_command)) { + key_password = rb_funcall(mini_ssl_ctx, rb_intern_const("key_password"), 0); + + if (!NIL_P(key_password)) { + StringValue(key_password); + password_cb = password_callback; + password = RSTRING_PTR(key_password); + SSL_CTX_set_default_passwd_cb(ctx, password_cb); + SSL_CTX_set_default_passwd_cb_userdata(ctx, (void *) password); + } + } + if (!NIL_P(key)) { StringValue(key); @@ -265,23 +304,78 @@ } if (!NIL_P(cert_pem)) { + X509 *ca = NULL; + unsigned long err; + bio = BIO_new(BIO_s_mem()); BIO_puts(bio, RSTRING_PTR(cert_pem)); + + /** + * Much of this pulled as a simplified version of the `use_certificate_chain_file` method + * from openssl's `ssl_rsa.c` file. + */ + + /* first read the cert as the first item in the pem file */ x509 = PEM_read_bio_X509(bio, NULL, NULL, NULL); + if (NULL == x509) { + BIO_free_all(bio); + raise_param_error("PEM_read_bio_X509", "cert_pem"); + } + + /* Add the cert to the context */ + /* 1 is success - otherwise check the error codes */ + if (1 != SSL_CTX_use_certificate(ctx, x509)) { + BIO_free_all(bio); + raise_param_error("SSL_CTX_use_certificate", "cert_pem"); + } + + X509_free(x509); /* no longer need our reference */ + + /* Now lets load up the rest of the certificate chain */ + /* 1 is success 0 is error */ + if (0 == SSL_CTX_clear_chain_certs(ctx)) { + BIO_free_all(bio); + raise_param_error("SSL_CTX_clear_chain_certs","cert_pem"); + } + + while (1) { + ca = PEM_read_bio_X509(bio, NULL, NULL, NULL); + + if (NULL == ca) { + break; + } + + if (0 == SSL_CTX_add0_chain_cert(ctx, ca)) { + BIO_free_all(bio); + raise_param_error("SSL_CTX_add0_chain_cert","cert_pem"); + } + /* don't free ca - its now owned by the context */ + } - if (SSL_CTX_use_certificate(ctx, x509) != 1) { - raise_file_error("SSL_CTX_use_certificate", RSTRING_PTR(cert_pem)); + /* ca is NULL - so its either the end of the file or an error */ + err = ERR_peek_last_error(); + + /* If its the end of the file - then we are done, in any case free the bio */ + BIO_free_all(bio); + + if ((ERR_GET_LIB(err) == ERR_LIB_PEM) && (ERR_GET_REASON(err) == PEM_R_NO_START_LINE)) { + ERR_clear_error(); + } else { + raise_param_error("PEM_read_bio_X509","cert_pem"); } } 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); + pkey = PEM_read_bio_PrivateKey(bio, NULL, password_cb, (void *) password); if (SSL_CTX_use_PrivateKey(ctx, pkey) != 1) { + BIO_free(bio); raise_file_error("SSL_CTX_use_PrivateKey", RSTRING_PTR(key_pem)); } + EVP_PKEY_free(pkey); + BIO_free(bio); } verification_flags = rb_funcall(mini_ssl_ctx, rb_intern_const("verification_flags"), 0); @@ -314,8 +408,6 @@ 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; @@ -326,10 +418,23 @@ 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); +#ifdef HAVE_SSL_CTX_SET_SESSION_CACHE_MODE + if (!NIL_P(reuse)) { + SSL_CTX_set_session_cache_mode(ctx, SSL_SESS_CACHE_SERVER); + if (!NIL_P(reuse_cache_size)) { + SSL_CTX_sess_set_cache_size(ctx, NUM2INT(reuse_cache_size)); + } + if (!NIL_P(reuse_timeout)) { + SSL_CTX_set_timeout(ctx, NUM2INT(reuse_timeout)); + } + } else { + SSL_CTX_set_session_cache_mode(ctx, SSL_SESS_CACHE_OFF); + } +#endif + + SSL_CTX_set_options(ctx, ssl_options); if (!NIL_P(ssl_cipher_filter)) { StringValue(ssl_cipher_filter); @@ -340,8 +445,7 @@ } #if OPENSSL_VERSION_NUMBER < 0x10002000L - // Remove this case if OpenSSL 1.0.1 (now EOL) support is no - // longer needed. + // 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); @@ -442,7 +546,7 @@ void raise_error(SSL* ssl, int result) { char buf[512]; - char msg[512]; + char msg[768]; const char* err_str; int err = errno; int mask = 4095; @@ -700,6 +804,10 @@ rb_define_method(eng, "init?", engine_init, 0); + /* @!attribute [r] peercert + * Returns `nil` when `MiniSSL::Context#verify_mode` is set to `VERIFY_NONE`. + * @return [String, nil] DER encoded cert + */ rb_define_method(eng, "peercert", engine_peercert, 0); rb_define_method(eng, "ssl_vers_st", engine_ssl_vers_st, 0); diff -Nru puma-5.6.5/ext/puma_http11/org/jruby/puma/Http11.java puma-6.4.2/ext/puma_http11/org/jruby/puma/Http11.java --- puma-5.6.5/ext/puma_http11/org/jruby/puma/Http11.java 2022-08-22 23:33:14.000000000 +0000 +++ puma-6.4.2/ext/puma_http11/org/jruby/puma/Http11.java 2024-01-08 05:53:42.000000000 +0000 @@ -46,7 +46,7 @@ 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")); + public static final ByteList SERVER_PROTOCOL_BYTELIST = new ByteList(ByteList.plain("SERVER_PROTOCOL")); private static ObjectAllocator ALLOCATOR = new ObjectAllocator() { public IRubyObject allocate(Ruby runtime, RubyClass klass) { @@ -153,9 +153,9 @@ req.fastASet(RubyString.newStringShared(runtime, QUERY_STRING_BYTELIST),val); } - public static void http_version(Ruby runtime, RubyHash req, ByteList buffer, int at, int length) { + public static void server_protocol(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); + req.fastASet(RubyString.newStringShared(runtime, SERVER_PROTOCOL_BYTELIST),val); } public void header_done(Ruby runtime, RubyHash req, ByteList buffer, int at, int length) { diff -Nru puma-5.6.5/ext/puma_http11/org/jruby/puma/Http11Parser.java puma-6.4.2/ext/puma_http11/org/jruby/puma/Http11Parser.java --- puma-5.6.5/ext/puma_http11/org/jruby/puma/Http11Parser.java 2022-08-22 23:33:14.000000000 +0000 +++ puma-6.4.2/ext/puma_http11/org/jruby/puma/Http11Parser.java 2024-01-08 05:53:42.000000000 +0000 @@ -383,7 +383,7 @@ case 11: // line 42 "ext/puma_http11/http11_parser.java.rl" { - Http11.http_version(runtime, parser.data, parser.buffer, parser.mark, p-parser.mark); + Http11.server_protocol(runtime, parser.data, parser.buffer, parser.mark, p-parser.mark); } break; case 12: diff -Nru puma-5.6.5/ext/puma_http11/org/jruby/puma/MiniSSL.java puma-6.4.2/ext/puma_http11/org/jruby/puma/MiniSSL.java --- puma-5.6.5/ext/puma_http11/org/jruby/puma/MiniSSL.java 2022-08-22 23:33:14.000000000 +0000 +++ puma-6.4.2/ext/puma_http11/org/jruby/puma/MiniSSL.java 2024-01-08 05:53:42.000000000 +0000 @@ -1,6 +1,7 @@ package org.jruby.puma; import org.jruby.Ruby; +import org.jruby.RubyArray; import org.jruby.RubyClass; import org.jruby.RubyModule; import org.jruby.RubyObject; @@ -15,6 +16,7 @@ import org.jruby.util.ByteList; import javax.net.ssl.KeyManagerFactory; +import javax.net.ssl.TrustManager; import javax.net.ssl.TrustManagerFactory; import javax.net.ssl.SSLContext; import javax.net.ssl.SSLEngine; @@ -22,6 +24,7 @@ import javax.net.ssl.SSLException; import javax.net.ssl.SSLPeerUnverifiedException; import javax.net.ssl.SSLSession; +import javax.net.ssl.X509TrustManager; import java.io.FileInputStream; import java.io.InputStream; import java.io.IOException; @@ -32,15 +35,19 @@ import java.security.KeyStoreException; import java.security.NoSuchAlgorithmException; import java.security.UnrecoverableKeyException; +import java.security.cert.Certificate; import java.security.cert.CertificateEncodingException; import java.security.cert.CertificateException; +import java.security.cert.X509Certificate; import java.util.concurrent.ConcurrentHashMap; import java.util.Map; +import java.util.function.Supplier; import static javax.net.ssl.SSLEngineResult.Status; import static javax.net.ssl.SSLEngineResult.HandshakeStatus; -public class MiniSSL extends RubyObject { +public class MiniSSL extends RubyObject { // MiniSSL::Engine + private static final long serialVersionUID = -6903439483039141234L; private static ObjectAllocator ALLOCATOR = new ObjectAllocator() { public IRubyObject allocate(Ruby runtime, RubyClass klass) { return new MiniSSL(runtime, klass); @@ -51,11 +58,10 @@ RubyModule mPuma = runtime.defineModule("Puma"); RubyModule ssl = mPuma.defineModuleUnder("MiniSSL"); - mPuma.defineClassUnder("SSLError", - runtime.getClass("IOError"), - runtime.getClass("IOError").getAllocator()); + // Puma::MiniSSL::SSLError + ssl.defineClassUnder("SSLError", runtime.getStandardError(), runtime.getStandardError().getAllocator()); - RubyClass eng = ssl.defineClassUnder("Engine",runtime.getObject(),ALLOCATOR); + RubyClass eng = ssl.defineClassUnder("Engine", runtime.getObject(), ALLOCATOR); eng.defineAnnotatedMethods(MiniSSL.class); } @@ -137,74 +143,116 @@ private static Map keyManagerFactoryMap = new ConcurrentHashMap(); private static Map trustManagerFactoryMap = new ConcurrentHashMap(); - @JRubyMethod(meta = true) + @JRubyMethod(meta = true) // Engine.server 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(); + String keystoreFile = asStringValue(miniSSLContext.callMethod(context, "keystore"), null); + char[] keystorePass = asStringValue(miniSSLContext.callMethod(context, "keystore_pass"), null).toCharArray(); + String keystoreType = asStringValue(miniSSLContext.callMethod(context, "keystore_type"), KeyStore::getDefaultType); + + String truststoreFile; + char[] truststorePass; + String truststoreType; + IRubyObject truststore = miniSSLContext.callMethod(context, "truststore"); + if (truststore.isNil()) { + truststoreFile = keystoreFile; + truststorePass = keystorePass; + truststoreType = keystoreType; + } else if (!isDefaultSymbol(context, truststore)) { + truststoreFile = truststore.convertToString().asJavaString(); + IRubyObject pass = miniSSLContext.callMethod(context, "truststore_pass"); + if (pass.isNil()) { + truststorePass = null; + } else { + truststorePass = asStringValue(pass, null).toCharArray(); + } + truststoreType = asStringValue(miniSSLContext.callMethod(context, "truststore_type"), KeyStore::getDefaultType); + } else { // self.truststore = :default + truststoreFile = null; + truststorePass = null; + truststoreType = null; + } - KeyStore ks = KeyStore.getInstance(KeyStore.getDefaultType()); + KeyStore ks = KeyStore.getInstance(keystoreType); InputStream is = new FileInputStream(keystoreFile); try { - ks.load(is, password); + ks.load(is, keystorePass); } finally { is.close(); } KeyManagerFactory kmf = KeyManagerFactory.getInstance("SunX509"); - kmf.init(ks, password); + kmf.init(ks, keystorePass); keyManagerFactoryMap.put(keystoreFile, kmf); - KeyStore ts = KeyStore.getInstance(KeyStore.getDefaultType()); - is = new FileInputStream(keystoreFile); - try { - ts.load(is, password); - } finally { - is.close(); + if (truststoreFile != null) { + KeyStore ts = KeyStore.getInstance(truststoreType); + is = new FileInputStream(truststoreFile); + try { + ts.load(is, truststorePass); + } finally { + is.close(); + } + TrustManagerFactory tmf = TrustManagerFactory.getInstance("SunX509"); + tmf.init(ts); + trustManagerFactoryMap.put(truststoreFile, tmf); } - 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); + return klass.newInstance(context, miniSSLContext, Block.NULL_BLOCK); + } + + private static String asStringValue(IRubyObject value, Supplier defaultValue) { + if (defaultValue != null && value.isNil()) return defaultValue.get(); + return value.convertToString().asJavaString(); + } + + private static boolean isDefaultSymbol(ThreadContext context, IRubyObject truststore) { + return context.runtime.newSymbol("default").equals(truststore); } @JRubyMethod - public IRubyObject initialize(ThreadContext threadContext, IRubyObject miniSSLContext) + public IRubyObject initialize(ThreadContext context, IRubyObject miniSSLContext) throws KeyStoreException, NoSuchAlgorithmException, KeyManagementException { - String keystoreFile = miniSSLContext.callMethod(threadContext, "keystore").convertToString().asJavaString(); + String keystoreFile = miniSSLContext.callMethod(context, "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); + IRubyObject truststore = miniSSLContext.callMethod(context, "truststore"); + String truststoreFile = isDefaultSymbol(context, truststore) ? "" : asStringValue(truststore, () -> keystoreFile); + TrustManagerFactory tmf = trustManagerFactoryMap.get(truststoreFile); // null if self.truststore = :default + if (kmf == null) { + throw new KeyStoreException("Could not find KeyManagerFactory for keystore: " + keystoreFile + " truststore: " + truststoreFile); } SSLContext sslCtx = SSLContext.getInstance("TLS"); - sslCtx.init(kmf.getKeyManagers(), tmf.getTrustManagers(), null); + sslCtx.init(kmf.getKeyManagers(), getTrustManagers(tmf), 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" }; - } + String[] enabledProtocols; + IRubyObject protocols = miniSSLContext.callMethod(context, "protocols"); + if (protocols.isNil()) { + if (miniSSLContext.callMethod(context, "no_tlsv1").isTrue()) { + enabledProtocols = new String[] { "TLSv1.1", "TLSv1.2", "TLSv1.3" }; + } else { + enabledProtocols = new String[] { "TLSv1", "TLSv1.1", "TLSv1.2", "TLSv1.3" }; + } - if(miniSSLContext.callMethod(threadContext, "no_tlsv1_1").isTrue()) { - protocols = new String[] { "TLSv1.2" }; + if (miniSSLContext.callMethod(context, "no_tlsv1_1").isTrue()) { + enabledProtocols = new String[] { "TLSv1.2", "TLSv1.3" }; + } + } else if (protocols instanceof RubyArray) { + enabledProtocols = (String[]) ((RubyArray) protocols).toArray(new String[0]); + } else { + throw context.runtime.newTypeError(protocols, context.runtime.getArray()); } + engine.setEnabledProtocols(enabledProtocols); - engine.setEnabledProtocols(protocols); engine.setUseClientMode(false); - long verify_mode = miniSSLContext.callMethod(threadContext, "verify_mode").convertToInteger("to_i").getLongValue(); + long verify_mode = miniSSLContext.callMethod(context, "verify_mode").convertToInteger("to_i").getLongValue(); if ((verify_mode & 0x1) != 0) { // 'peer' engine.setWantClientAuth(true); } @@ -212,10 +260,11 @@ engine.setNeedClientAuth(true); } - IRubyObject sslCipherListObject = miniSSLContext.callMethod(threadContext, "ssl_cipher_list"); - if (!sslCipherListObject.isNil()) { - String[] sslCipherList = sslCipherListObject.convertToString().asJavaString().split(","); - engine.setEnabledCipherSuites(sslCipherList); + IRubyObject cipher_suites = miniSSLContext.callMethod(context, "cipher_suites"); + if (cipher_suites instanceof RubyArray) { + engine.setEnabledCipherSuites((String[]) ((RubyArray) cipher_suites).toArray(new String[0])); + } else if (!cipher_suites.isNil()) { + throw context.runtime.newTypeError(cipher_suites, context.runtime.getArray()); } SSLSession session = engine.getSession(); @@ -227,6 +276,48 @@ return this; } + private TrustManager[] getTrustManagers(TrustManagerFactory factory) { + if (factory == null) return null; // use JDK trust defaults + final TrustManager[] tms = factory.getTrustManagers(); + if (tms != null) { + for (int i=0; i 0 ? chain[0] : null; + delegate.checkClientTrusted(chain, authType); + } + + @Override + public void checkServerTrusted(X509Certificate[] chain, String authType) throws CertificateException { + delegate.checkServerTrusted(chain, authType); + } + + @Override + public X509Certificate[] getAcceptedIssuers() { + return delegate.getAcceptedIssuers(); + } + + } + @JRubyMethod public IRubyObject inject(IRubyObject arg) { ByteList bytes = arg.convertToString().getByteList(); @@ -251,7 +342,7 @@ res = engine.unwrap(src.getRawBuffer(), dst.getRawBuffer()); break; default: - throw new IllegalStateException("Unknown SSLOperation: " + sslOp); + throw new AssertionError("Unknown SSLOperation: " + sslOp); } switch (res.getStatus()) { @@ -336,9 +427,7 @@ return RubyString.newString(getRuntime(), appDataByteList); } catch (SSLException e) { - RaiseException re = getRuntime().newEOFError(e.getMessage()); - re.initCause(e); - throw re; + throw newSSLError(getRuntime(), e); } } @@ -371,19 +460,19 @@ return RubyString.newString(context.runtime, dataByteList); } catch (SSLException e) { - RaiseException ex = context.runtime.newRuntimeError(e.toString()); - ex.initCause(e); - throw ex; + throw newSSLError(getRuntime(), e); } } @JRubyMethod - public IRubyObject peercert() throws CertificateEncodingException { + public IRubyObject peercert(ThreadContext context) throws CertificateEncodingException { + Certificate peerCert; try { - return JavaEmbedUtils.javaToRuby(getRuntime(), engine.getSession().getPeerCertificates()[0].getEncoded()); + peerCert = engine.getSession().getPeerCertificates()[0]; } catch (SSLPeerUnverifiedException e) { - return getRuntime().getNil(); + peerCert = lastCheckedCert0; // null if trust check did not happen } + return peerCert == null ? context.nil : JavaEmbedUtils.javaToRuby(context.runtime, peerCert.getEncoded()); } @JRubyMethod(name = "init?") @@ -402,4 +491,19 @@ return getRuntime().getFalse(); } } + + private static RubyClass getSSLError(Ruby runtime) { + return (RubyClass) ((RubyModule) runtime.getModule("Puma").getConstantAt("MiniSSL")).getConstantAt("SSLError"); + } + + private static RaiseException newSSLError(Ruby runtime, SSLException cause) { + return newError(runtime, getSSLError(runtime), cause.toString(), cause); + } + + private static RaiseException newError(Ruby runtime, RubyClass errorClass, String message, Throwable cause) { + RaiseException ex = RaiseException.from(runtime, errorClass, message); + ex.initCause(cause); + return ex; + } + } diff -Nru puma-5.6.5/ext/puma_http11/puma_http11.c puma-6.4.2/ext/puma_http11/puma_http11.c --- puma-5.6.5/ext/puma_http11/puma_http11.c 2022-08-22 23:33:14.000000000 +0000 +++ puma-6.4.2/ext/puma_http11/puma_http11.c 2024-01-08 05:53:42.000000000 +0000 @@ -36,13 +36,13 @@ static VALUE global_request_uri; static VALUE global_fragment; static VALUE global_query_string; -static VALUE global_http_version; +static VALUE global_server_protocol; 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)" +#define EXPAND_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 " EXPAND_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); } @@ -52,15 +52,23 @@ /* Defines the maximum allowed lengths for various input elements.*/ +#ifndef PUMA_REQUEST_URI_MAX_LENGTH +#define PUMA_REQUEST_URI_MAX_LENGTH (1024 * 12) +#endif + +#ifndef PUMA_REQUEST_PATH_MAX_LENGTH +#define PUMA_REQUEST_PATH_MAX_LENGTH (8192) +#endif + #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(REQUEST_URI, PUMA_REQUEST_URI_MAX_LENGTH); 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(REQUEST_PATH, PUMA_REQUEST_PATH_MAX_LENGTH); DEF_MAX_LENGTH(QUERY_STRING, PUMA_QUERY_STRING_MAX_LENGTH); DEF_MAX_LENGTH(HEADER, (1024 * (80 + 32))); @@ -236,10 +244,10 @@ rb_hash_aset(hp->request, global_query_string, val); } -void http_version(puma_parser* hp, const char *at, size_t length) +void server_protocol(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); + rb_hash_aset(hp->request, global_server_protocol, val); } /** Finalizes the request header to have a bunch of stuff that's @@ -281,7 +289,7 @@ hp->fragment = fragment; hp->request_path = request_path; hp->query_string = query_string; - hp->http_version = http_version; + hp->server_protocol = server_protocol; hp->header_done = header_done; hp->request = Qnil; @@ -461,7 +469,7 @@ DEF_GLOBAL(request_uri, "REQUEST_URI"); DEF_GLOBAL(fragment, "FRAGMENT"); DEF_GLOBAL(query_string, "QUERY_STRING"); - DEF_GLOBAL(http_version, "HTTP_VERSION"); + DEF_GLOBAL(server_protocol, "SERVER_PROTOCOL"); DEF_GLOBAL(request_path, "REQUEST_PATH"); eHttpParserError = rb_define_class_under(mPuma, "HttpParserError", rb_eIOError); diff -Nru puma-5.6.5/lib/puma/app/status.rb puma-6.4.2/lib/puma/app/status.rb --- puma-5.6.5/lib/puma/app/status.rb 2022-08-22 23:33:14.000000000 +0000 +++ puma-6.4.2/lib/puma/app/status.rb 2024-01-08 05:53:42.000000000 +0000 @@ -1,5 +1,5 @@ # frozen_string_literal: true -require 'puma/json_serialization' +require_relative '../json_serialization' module Puma module App @@ -80,13 +80,13 @@ def authenticate(env) return true unless @auth_token - env['QUERY_STRING'].to_s.split(/&;/).include?("token=#{@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 + 'content-type' => content_type, + 'content-length' => body.bytesize.to_s } [status, headers, [body]] diff -Nru puma-5.6.5/lib/puma/binder.rb puma-6.4.2/lib/puma/binder.rb --- puma-5.6.5/lib/puma/binder.rb 2022-08-22 23:33:14.000000000 +0000 +++ puma-6.4.2/lib/puma/binder.rb 2024-01-08 05:53:42.000000000 +0000 @@ -3,24 +3,15 @@ require 'uri' require 'socket' -require 'puma/const' -require 'puma/util' -require 'puma/configuration' +require_relative 'const' +require_relative 'util' +require_relative '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 + require_relative 'minissl' + require_relative 'minissl/context_builder' end class Binder @@ -28,8 +19,8 @@ RACK_VERSION = [1,6].freeze - def initialize(events, conf = Configuration.new) - @events = events + def initialize(log_writer, conf = Configuration.new) + @log_writer = log_writer @conf = conf @listeners = [] @inherited_fds = {} @@ -38,7 +29,7 @@ @proto_env = { "rack.version".freeze => RACK_VERSION, - "rack.errors".freeze => events.stderr, + "rack.errors".freeze => log_writer.stderr, "rack.multithread".freeze => conf.options[:max_threads] > 1, "rack.multiprocess".freeze => conf.options[:workers] >= 1, "rack.run_once".freeze => false, @@ -51,14 +42,12 @@ # 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 @@ -80,7 +69,7 @@ # @!attribute [r] connected_ports # @version 5.0.0 def connected_ports - ios.map { |io| io.addr[1] }.uniq + t = ios.map { |io| io.addr[1] }; t.uniq!; t end # @version 5.0.0 @@ -98,7 +87,7 @@ # @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}" + @log_writer.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)) @@ -106,11 +95,11 @@ [: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 =~ /\:/ + addr = "[#{addr}]" if addr&.include? ':' [:tcp, addr, port] end @activated_sockets[key] = sock - @events.debug "Registered #{key.join ':'} for activation from LISTEN_FDS" + @log_writer.debug "Registered #{key.join ':'} for activation from LISTEN_FDS" end ["LISTEN_FDS", "LISTEN_PID"] # Signal to remove these keys from ENV end @@ -152,29 +141,30 @@ end end - def parse(binds, logger, log_msg = 'Listening') + def parse(binds, log_writer = nil, log_msg = 'Listening') + log_writer ||= @log_writer 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}" + log_writer.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}" + log_writer.log "* Activated #{str}" else ios_len = @ios.length params = Util.parse_query uri.query - opt = params.key?('low_latency') && params['low_latency'] != 'false' + low_latency = 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 + io = add_tcp_listener uri.host, uri.port, low_latency, backlog @ios[ios_len..-1].each do |i| addr = loc_addr_str i - logger.log "* #{log_msg} on http://#{addr}" + log_writer.log "* #{log_msg} on http://#{addr}" end end @@ -191,12 +181,12 @@ if fd = @inherited_fds.delete(str) @unix_paths << path unless abstract || File.exist?(path) io = inherit_unix_listener path, fd - logger.log "* Inherited #{str}" + log_writer.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}" + log_writer.log "* Activated #{str}" else umask = nil mode = nil @@ -220,11 +210,12 @@ @unix_paths << path unless abstract || File.exist?(path) io = add_unix_listener path, umask, mode, backlog - logger.log "* #{log_msg} on #{str}" + log_writer.log "* #{log_msg} on #{str}" end @listeners << [str, io] when "ssl" + cert_key = %w[cert key] raise "Puma compiled without SSL support" unless HAS_SSL @@ -233,49 +224,51 @@ # 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? } + # Ruby 3 `values_at` accepts an array, earlier do not + 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:') + cert_key.each do |v| + if 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 + MiniSSL::ContextBuilder.new(params, @log_writer).context end if fd = @inherited_fds.delete(str) - logger.log "* Inherited #{str}" + log_writer.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}" + log_writer.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 + low_latency = params['low_latency'] != 'false' + io = add_ssl_listener uri.host, uri.port, ctx, low_latency, backlog @ios[ios_len..-1].each do |i| addr = loc_addr_str i - logger.log "* #{log_msg} on ssl://#{addr}?#{uri.query}" + log_writer.log "* #{log_msg} on ssl://#{addr}?#{uri.query}" end end @listeners << [str, io] if io else - logger.error "Invalid URI: #{str}" + log_writer.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}" + log_writer.log "* Closing unused inherited connection: #{str}" begin IO.for_fd(fd).close @@ -295,7 +288,7 @@ 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 ':'}" + log_writer.log "* Closing unused activated socket: #{key.first}://#{key[1..-1].join ':'}" begin sock.close rescue SystemCallError @@ -319,7 +312,7 @@ 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 + MiniSSL::ContextBuilder.new({ "key" => key_path, "cert" => crt_path }, @log_writer).context end # Tell the server to listen on host +host+, port +port+. @@ -337,7 +330,7 @@ return end - host = host[1..-2] if host and host[0..0] == '[' + host = host[1..-2] if host&.start_with? '[' tcp_server = TCPServer.new(host, port) if optimize_for_latency @@ -371,7 +364,7 @@ return end - host = host[1..-2] if host[0..0] == '[' + host = host[1..-2] if host&.start_with? '[' s = TCPServer.new(host, port) if optimize_for_latency s.setsockopt(Socket::IPPROTO_TCP, Socket::TCP_NODELAY, 1) @@ -457,11 +450,14 @@ 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) + begin + 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) + rescue Errno::EBADF + end end end @@ -482,9 +478,10 @@ # @!attribute [r] loopback_addresses def loopback_addresses - Socket.ip_address_list.select do |addrinfo| + t = Socket.ip_address_list.select do |addrinfo| addrinfo.ipv6_loopback? || addrinfo.ipv4_loopback? - end.map { |addrinfo| addrinfo.ip_address }.uniq + end + t.map! { |addrinfo| addrinfo.ip_address }; t.uniq!; t end def loc_addr_str(io) diff -Nru puma-5.6.5/lib/puma/cli.rb puma-6.4.2/lib/puma/cli.rb --- puma-5.6.5/lib/puma/cli.rb 2022-08-22 23:33:14.000000000 +0000 +++ puma-6.4.2/lib/puma/cli.rb 2024-01-08 05:53:42.000000000 +0000 @@ -3,11 +3,11 @@ require 'optparse' require 'uri' -require 'puma' -require 'puma/configuration' -require 'puma/launcher' -require 'puma/const' -require 'puma/events' +require_relative '../puma' +require_relative 'configuration' +require_relative 'launcher' +require_relative 'const' +require_relative 'log_writer' module Puma class << self @@ -21,19 +21,13 @@ # 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) + def initialize(argv, log_writer = LogWriter.stdio, events = Events.new) @debug = false @argv = argv.dup - + @log_writer = log_writer @events = events @conf = nil @@ -69,7 +63,7 @@ end end - @launcher = Puma::Launcher.new(@conf, :events => @events, :argv => argv) + @launcher = Puma::Launcher.new(@conf, :log_writer => @log_writer, :events => @events, :argv => argv) end attr_reader :launcher @@ -83,7 +77,7 @@ private def unsupported(str) - @events.error(str) + @log_writer.error(str) raise UnsupportedOption end @@ -99,7 +93,7 @@ # def setup_options - @conf = Configuration.new do |user_config, file_config| + @conf = Configuration.new({}, {events: @events}) 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 @@ -150,9 +144,13 @@ $LOAD_PATH.unshift(*arg.split(':')) end + o.on "--idle-timeout SECONDS", "Number of seconds until the next request before automatic shutdown" do |arg| + user_config.idle_timeout arg + 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}" + user_config.bind "tcp://#{Configuration::DEFAULTS[:tcp_host]}:#{arg}" end o.on "--pidfile PATH", "Use PATH as a pidfile" do |arg| @@ -186,7 +184,7 @@ end o.on "-s", "--silent", "Do not log prompt messages other than errors" do - @events = Events.new NullIO.new, $stderr + @log_writer = LogWriter.new(NullIO.new, $stderr) end o.on "-S", "--state PATH", "Where to store the state details" do |arg| diff -Nru puma-5.6.5/lib/puma/client.rb puma-6.4.2/lib/puma/client.rb --- puma-5.6.5/lib/puma/client.rb 2022-08-22 23:33:14.000000000 +0000 +++ puma-6.4.2/lib/puma/client.rb 2024-01-08 05:53:42.000000000 +0000 @@ -8,9 +8,9 @@ end end -require 'puma/detect' +require_relative 'detect' +require_relative 'io_buffer' require 'tempfile' -require 'forwardable' if Puma::IS_JRUBY # We have to work around some OpenSSL buffer/io-readiness bugs @@ -25,6 +25,9 @@ class HttpParserError501 < IOError; end + #———————————————————————— DO NOT USE — this class is for internal use only ——— + + # 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. # @@ -38,14 +41,23 @@ # 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 + class Client # :nodoc: # 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 + CHUNK_VALID_ENDING = Const::LINE_END + CHUNK_VALID_ENDING_SIZE = CHUNK_VALID_ENDING.bytesize + + # The maximum number of bytes we'll buffer looking for a valid + # chunk header. + MAX_CHUNK_HEADER_SIZE = 4096 + + # The maximum amount of excess data the client sends + # using chunk size extensions before we abort the connection. + MAX_CHUNK_EXCESS = 16 * 1024 # Content-Length header value validation CONTENT_LENGTH_VALUE_INVALID = /[^\d]/.freeze @@ -57,17 +69,13 @@ EmptyBody = NullIO.new include Puma::Const - extend Forwardable def initialize(io, env=nil) @io = io @to_io = io.to_io + @io_buffer = IOBuffer.new @proto_env = env - if !env - @env = nil - else - @env = env.dup - end + @env = env&.dup @parser = HttpParser.new @parsed_bytes = 0 @@ -85,7 +93,11 @@ @requests_served = 0 @hijacked = false + @http_content_length_limit = nil + @http_content_length_limit_exceeded = false + @peerip = nil + @peer_family = nil @listener = nil @remote_addr_header = nil @expect_proxy_proto = false @@ -93,16 +105,22 @@ @body_remain = 0 @in_last_chunk = false + + # need unfrozen ASCII-8BIT, +'' is UTF-8 + @read_buffer = String.new # rubocop: disable Performance/UnfreezeString end attr_reader :env, :to_io, :body, :io, :timeout_at, :ready, :hijacked, - :tempfile + :tempfile, :io_buffer, :http_content_length_limit_exceeded - attr_writer :peerip + attr_writer :peerip, :http_content_length_limit attr_accessor :remote_addr_header, :listener - def_delegators :@io, :closed? + # Remove in Puma 7? + def closed? + @to_io.closed? + end # Test to see if io meets a bare minimum of functioning, @to_io needs to be # used for MiniSSL::Socket @@ -138,6 +156,7 @@ def reset(fast_check=true) @parser.reset + @io_buffer.reset @read_header = true @read_proxy = !!@expect_proxy_proto @env = @proto_env.dup @@ -148,6 +167,7 @@ @body_remain = 0 @peerip = nil if @remote_addr_header @in_last_chunk = false + @http_content_length_limit_exceeded = false if @buffer return false unless try_to_parse_proxy_protocol @@ -207,6 +227,17 @@ end def try_to_finish + if env[CONTENT_LENGTH] && above_http_content_limit(env[CONTENT_LENGTH].to_i) + @http_content_length_limit_exceeded = true + end + + if @http_content_length_limit_exceeded + @buffer = nil + @body = EmptyBody + set_ready + return true + end + return read_body if in_data_phase begin @@ -236,6 +267,10 @@ @parsed_bytes = @parser.execute(@env, @buffer, @parsed_bytes) + if @parser.finished? && above_http_content_limit(@parser.body.bytesize) + @http_content_length_limit_exceeded = true + end + if @parser.finished? return setup_body elsif @parsed_bytes >= MAX_HEADER @@ -273,7 +308,7 @@ return @peerip if @peerip if @remote_addr_header - hdr = (@env[@remote_addr_header] || LOCALHOST_IP).split(/[\s,]/).first + hdr = (@env[@remote_addr_header] || @io.peeraddr.last).split(/[\s,]/).first @peerip = hdr return hdr end @@ -281,6 +316,16 @@ @peerip ||= @io.peeraddr.last end + def peer_family + return @peer_family if @peer_family + + @peer_family ||= begin + @io.local_address.afamily + rescue + Socket::AF_INET + end + end + # Returns true if the persistent connection can be closed immediately # without waiting for the configured idle/shutdown timeout. # @version 5.0.0 @@ -304,7 +349,7 @@ private def setup_body - @body_read_start = Process.clock_gettime(Process::CLOCK_MONOTONIC, :millisecond) + @body_read_start = Process.clock_gettime(Process::CLOCK_MONOTONIC, :float_millisecond) if @env[HTTP_EXPECT] == CONTINUE # TODO allow a hook here to check the headers before @@ -347,8 +392,8 @@ cl = @env[CONTENT_LENGTH] if cl - # cannot contain characters that are not \d - if cl =~ CONTENT_LENGTH_VALUE_INVALID + # cannot contain characters that are not \d, or be empty + if CONTENT_LENGTH_VALUE_INVALID.match?(cl) || cl.empty? raise HttpParserError, "Invalid Content-Length: #{cl.inspect}" end else @@ -401,7 +446,7 @@ end begin - chunk = @io.read_nonblock(want) + chunk = @io.read_nonblock(want, @read_buffer) rescue IO::WaitReadable return false rescue SystemCallError, IOError @@ -433,7 +478,7 @@ def read_chunked_body while true begin - chunk = @io.read_nonblock(4096) + chunk = @io.read_nonblock(4096, @read_buffer) rescue IO::WaitReadable return false rescue SystemCallError, IOError @@ -459,6 +504,7 @@ @chunked_body = true @partial_part_left = 0 @prev_chunk = "" + @excess_cr = 0 @body = Tempfile.new(Const::PUMA_TMP_BASE) @body.unlink @@ -509,11 +555,11 @@ while !io.eof? line = io.gets - if line.end_with?("\r\n") + if line.end_with?(CHUNK_VALID_ENDING) # 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 + if CHUNK_SIZE_INVALID.match? chunk_hex raise HttpParserError, "Invalid chunk size: '#{chunk_hex}'" end len = chunk_hex.to_i(16) @@ -521,19 +567,39 @@ @in_last_chunk = true @body.rewind rest = io.read - last_crlf_size = "\r\n".bytesize - if rest.bytesize < last_crlf_size + if rest.bytesize < CHUNK_VALID_ENDING_SIZE @buffer = nil - @partial_part_left = last_crlf_size - rest.bytesize + @partial_part_left = CHUNK_VALID_ENDING_SIZE - rest.bytesize return false else - @buffer = rest[last_crlf_size..-1] + # if the next character is a CRLF, set buffer to everything after that CRLF + start_of_rest = if rest.start_with?(CHUNK_VALID_ENDING) + CHUNK_VALID_ENDING_SIZE + else # we have started a trailer section, which we do not support. skip it! + rest.index(CHUNK_VALID_ENDING*2) + CHUNK_VALID_ENDING_SIZE*2 + end + + @buffer = rest[start_of_rest..-1] @buffer = nil if @buffer.empty? set_ready return true end end + # Track the excess as a function of the size of the + # header vs the size of the actual data. Excess can + # go negative (and is expected to) when the body is + # significant. + # The additional of chunk_hex.size and 2 compensates + # for a client sending 1 byte in a chunked body over + # a long period of time, making sure that that client + # isn't accidentally eventually punished. + @excess_cr += (line.size - len - chunk_hex.size - 2) + + if @excess_cr >= MAX_CHUNK_EXCESS + raise HttpParserError, "Maximum chunk excess detected" + end + len += 2 part = io.read(len) @@ -561,6 +627,10 @@ @partial_part_left = len - part.size end else + if @prev_chunk.size + chunk.size >= MAX_CHUNK_HEADER_SIZE + raise HttpParserError, "maximum size of chunk header exceeded" + end + @prev_chunk = line return false end @@ -576,10 +646,14 @@ def set_ready if @body_read_start - @env['puma.request_body_wait'] = Process.clock_gettime(Process::CLOCK_MONOTONIC, :millisecond) - @body_read_start + @env['puma.request_body_wait'] = Process.clock_gettime(Process::CLOCK_MONOTONIC, :float_millisecond) - @body_read_start end @requests_served += 1 @ready = true end + + def above_http_content_limit(value) + @http_content_length_limit&.< value + end end end diff -Nru puma-5.6.5/lib/puma/cluster/worker.rb puma-6.4.2/lib/puma/cluster/worker.rb --- puma-5.6.5/lib/puma/cluster/worker.rb 2022-08-22 23:33:14.000000000 +0000 +++ puma-6.4.2/lib/puma/cluster/worker.rb 2024-01-08 05:53:42.000000000 +0000 @@ -2,27 +2,29 @@ module Puma class Cluster < Puma::Runner + #—————————————————————— DO NOT USE — this class is for internal use only ——— + + # 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 + class Worker < Puma::Runner # :nodoc: attr_reader :index, :master def initialize(index:, master:, launcher:, pipes:, server: nil) - super launcher, launcher.events + super(launcher) @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 + @hook_data = {} end def run @@ -52,13 +54,14 @@ # 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 + @config.run_hooks(:before_worker_boot, index, @log_writer, @hook_data) begin server = @server ||= start_server rescue Exception => e log "! Unable to start worker" - log e.backtrace[0] + log e + log e.backtrace.join("\n ") exit 1 end @@ -83,8 +86,7 @@ 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] + @config.run_hooks(:before_refork, nil, @log_writer, @hook_data) end elsif idx == 0 # restart server restart_server << true << false @@ -113,6 +115,11 @@ while restart_server.pop server_thread = server.run + + if @log_writer.debug? && index == 0 + debug_loaded_extensions "Loaded Extensions - worker 0:" + end + stat_thread ||= Thread.new(@worker_write) do |io| Puma.set_thread_name "stat pld" base_payload = "p#{Process.pid}" @@ -138,7 +145,7 @@ # 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 + @config.run_hooks(:before_worker_shutdown, index, @log_writer, @hook_data) ensure @worker_write << "t#{Process.pid}\n" rescue nil @worker_write.close @@ -147,7 +154,7 @@ private def spawn_worker(idx) - @launcher.config.run_hooks :before_worker_fork, idx, @launcher.events + @config.run_hooks(:before_worker_fork, idx, @log_writer, @hook_data) pid = fork do new_worker = Worker.new index: idx, @@ -165,7 +172,7 @@ exit! 1 end - @launcher.config.run_hooks :after_worker_fork, idx, @launcher.events + @config.run_hooks(:after_worker_fork, idx, @log_writer, @hook_data) pid end end diff -Nru puma-5.6.5/lib/puma/cluster/worker_handle.rb puma-6.4.2/lib/puma/cluster/worker_handle.rb --- puma-5.6.5/lib/puma/cluster/worker_handle.rb 2022-08-22 23:33:14.000000000 +0000 +++ puma-6.4.2/lib/puma/cluster/worker_handle.rb 2024-01-08 05:53:42.000000000 +0000 @@ -2,12 +2,15 @@ module Puma class Cluster < Runner + #—————————————————————— DO NOT USE — this class is for internal use only ——— + + # 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 + class WorkerHandle # :nodoc: def initialize(idx, pid, phase, options) @index = idx @pid = pid diff -Nru puma-5.6.5/lib/puma/cluster.rb puma-6.4.2/lib/puma/cluster.rb --- puma-5.6.5/lib/puma/cluster.rb 2022-08-22 23:33:14.000000000 +0000 +++ puma-6.4.2/lib/puma/cluster.rb 2024-01-08 05:53:42.000000000 +0000 @@ -1,12 +1,10 @@ # frozen_string_literal: true -require 'puma/runner' -require 'puma/util' -require 'puma/plugin' -require 'puma/cluster/worker_handle' -require 'puma/cluster/worker' - -require 'time' +require_relative 'runner' +require_relative 'util' +require_relative 'plugin' +require_relative 'cluster/worker_handle' +require_relative 'cluster/worker' module Puma # This class is instantiated by the `Puma::Launcher` and used @@ -17,8 +15,8 @@ # 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 + def initialize(launcher) + super(launcher) @phase = 0 @workers = [] @@ -27,6 +25,10 @@ @phased_restart = false end + # Returns the list of cluster worker handles. + # @return [Array] + attr_reader :workers + def stop_workers log "- Gracefully shutting down workers..." @workers.each { |x| x.term } @@ -83,16 +85,14 @@ @workers << WorkerHandle.new(idx, pid, @phase, @options) end - if @options[:fork_worker] && - @workers.all? {|x| x.phase == @phase} - + if @options[:fork_worker] && all_workers_in_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 + @config.run_hooks(:before_worker_fork, idx, @log_writer) pid = fork { worker(idx, master) } if !pid @@ -101,7 +101,7 @@ exit! 1 end - @launcher.config.run_hooks :after_worker_fork, idx, @launcher.events + @config.run_hooks(:after_worker_fork, idx, @log_writer) pid end @@ -146,10 +146,22 @@ idx end + def worker_at(idx) + @workers.find { |w| w.index == idx } + end + def all_workers_booted? @workers.count { |w| !w.booted? } == 0 end + def all_workers_in_phase? + @workers.all? { |w| w.phase == @phase } + end + + def all_workers_idle_timed_out? + (@workers.map(&:pid) - idle_timed_out_worker_pids).empty? + end + def check_workers return if @next_check >= Time.now @@ -176,10 +188,10 @@ end end - @next_check = [ - @workers.reject(&:term?).map(&:ping_timeout).min, - @next_check - ].compact.min + t = @workers.reject(&:term?) + t.map!(&:ping_timeout) + + @next_check = [t.min, @next_check].compact.min end def worker(index, master) @@ -209,8 +221,8 @@ stop end - def phased_restart - return false if @options[:preload_app] + def phased_restart(refork = false) + return false if @options[:preload_app] && !refork @phased_restart = true wakeup! @@ -226,7 +238,7 @@ def stop_blocked @status = :stop if @status == :run wakeup! - @control.stop(true) if @control + @control&.stop true Process.waitall end @@ -248,24 +260,24 @@ old_worker_count = @workers.count { |w| w.phase != @phase } worker_status = @workers.map do |w| { - started_at: w.started_at.utc.iso8601, + started_at: utc_iso8601(w.started_at), pid: w.pid, index: w.index, phase: w.phase, booted: w.booted?, - last_checkin: w.last_checkin.utc.iso8601, + last_checkin: utc_iso8601(w.last_checkin), last_status: w.last_status, } end { - started_at: @started_at.utc.iso8601, + started_at: utc_iso8601(@started_at), workers: @workers.size, phase: @phase, booted_workers: worker_status.count { |w| w[:booted] }, old_workers: old_worker_count, worker_status: worker_status, - } + }.merge(super) end def preload? @@ -274,10 +286,10 @@ # @version 5.0.0 def fork_worker! - if (worker = @workers.find { |w| w.index == 0 }) + if (worker = worker_at 0) worker.phase += 1 end - phased_restart + phased_restart(true) end # We do this in a separate method to keep the lambda scope @@ -290,7 +302,7 @@ # Auto-fork after the specified number of requests. if (fork_requests = @options[:fork_worker].to_i) > 0 - @launcher.events.register(:ping!) do |w| + @events.register(:ping!) do |w| fork_worker! if w.index == 0 && w.phase == 0 && w.last_status[:requests_count] >= fork_requests @@ -336,6 +348,8 @@ def run @status = :run + @idle_workers = {} + output_header "cluster" # This is aligned with the output from Runner, see Runner#output_header @@ -372,12 +386,12 @@ else log "* Restarts: (\u2714) hot (\u2714) phased" - unless @launcher.config.app_configured? + unless @config.app_configured? error "No application configured, nothing to run" exit 1 end - @launcher.binder.parse @options[:binds], self + @launcher.binder.parse @options[:binds] end read, @wakeup = Puma::Util.pipe @@ -409,8 +423,9 @@ @master_read, @worker_write = read, @wakeup - @launcher.config.run_hooks :before_fork, nil, @launcher.events - Puma::Util.nakayoshi_gc @events if @options[:nakayoshi_fork] + @options[:worker_write] = @worker_write + + @config.run_hooks(:before_fork, nil, @log_writer) spawn_workers @@ -425,6 +440,11 @@ while @status == :run begin + if all_workers_idle_timed_out? + log "- All workers reached idle timeout" + break + end + if @phased_restart start_phased_restart @phased_restart = false @@ -445,7 +465,7 @@ if req == "b" || req == "f" pid, idx = result.split(':').map(&:to_i) - w = @workers.find {|x| x.index == idx} + w = worker_at idx w.pid = pid if w.pid.nil? end @@ -462,22 +482,37 @@ when "t" w.term unless w.term? when "p" - w.ping!(result.sub(/^\d+/,'').chomp) - @launcher.events.fire(:ping!, w) + status = result.sub(/^\d+/,'').chomp + w.ping!(status) + @events.fire(:ping!, w) + + if in_phased_restart && workers_not_booted.positive? && w0 = worker_at(0) + w0.ping!(status) + @events.fire(:ping!, w0) + end + if !booted && @workers.none? {|worker| worker.last_status.empty?} - @launcher.events.fire_on_booted! + @events.fire_on_booted! + debug_loaded_extensions("Loaded Extensions - master:") if @log_writer.debug? booted = true end + when "i" + if @idle_workers[pid] + @idle_workers.delete pid + else + @idle_workers[pid] = 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! + debug_loaded_extensions("Loaded Extensions - master:") if @log_writer.debug? in_phased_restart = false end - rescue Interrupt @status = :stop end @@ -506,10 +541,28 @@ # loops thru @workers, removing workers that exited, and calling # `#term` if needed def wait_workers + # Reap all children, known workers or otherwise. + # If puma has PID 1, as it's common in containerized environments, + # then it's responsible for reaping orphaned processes, so we must reap + # all our dead children, regardless of whether they are workers we spawned + # or some reattached processes. + reaped_children = {} + loop do + begin + pid, status = Process.wait2(-1, Process::WNOHANG) + break unless pid + reaped_children[pid] = status + rescue Errno::ECHILD + break + end + end + @workers.reject! do |w| next false if w.pid.nil? begin - if Process.wait(w.pid, Process::WNOHANG) + # When `fork_worker` is enabled, some worker may not be direct children, but grand children. + # Because of this they won't be reaped by `Process.wait2(-1)`, so we need to check them individually) + if reaped_children.delete(w.pid) || (@options[:fork_worker] && Process.wait(w.pid, Process::WNOHANG)) true else w.term if w.term? @@ -526,6 +579,11 @@ end end end + + # Log unknown children + reaped_children.each do |pid, status| + log "! reaped unknown child process pid=#{pid} status=#{status}" + end end # @version 5.0.0 @@ -533,14 +591,18 @@ @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)" + "(Worker #{w.index} failed to check in within #{@options[:worker_timeout]} seconds)" else - "(worker failed to boot within #{@options[:worker_boot_timeout]} seconds)" + "(Worker #{w.index} failed to boot within #{@options[:worker_boot_timeout]} seconds)" end log "! Terminating timed out worker #{details}: #{w.pid}" w.kill end end end + + def idle_timed_out_worker_pids + @idle_workers.keys + end end end diff -Nru puma-5.6.5/lib/puma/commonlogger.rb puma-6.4.2/lib/puma/commonlogger.rb --- puma-5.6.5/lib/puma/commonlogger.rb 2022-08-22 23:33:14.000000000 +0000 +++ puma-6.4.2/lib/puma/commonlogger.rb 2024-01-08 05:53:42.000000000 +0000 @@ -3,7 +3,7 @@ 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] + # {Apache common log format}[https://httpd.apache.org/docs/2.4/logs.html#common] # to the +logger+. # # If +logger+ is nil, CommonLogger will fall back +rack.errors+, which is @@ -16,7 +16,7 @@ # (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 + # Common Log Format: https://httpd.apache.org/docs/2.4/logs.html#common # # lilith.local - - [07/Aug/2006 23:58:02 -0400] "GET / HTTP/1.1" 500 - # @@ -25,10 +25,17 @@ 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 + LOG_TIME_FORMAT = '%d/%b/%Y:%H:%M:%S %z' + + CONTENT_LENGTH = 'Content-Length' # should be lower case from app, + # Util::HeaderHash allows mixed + HTTP_VERSION = Const::HTTP_VERSION + HTTP_X_FORWARDED_FOR = Const::HTTP_X_FORWARDED_FOR + PATH_INFO = Const::PATH_INFO + QUERY_STRING = Const::QUERY_STRING + REMOTE_ADDR = Const::REMOTE_ADDR + REMOTE_USER = 'REMOTE_USER' + REQUEST_METHOD = Const::REQUEST_METHOD def initialize(app, logger=nil) @app = app @@ -57,13 +64,13 @@ 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[HTTP_X_FORWARDED_FOR] || env[REMOTE_ADDR] || "-", + env[REMOTE_USER] || "-", + now.strftime(LOG_TIME_FORMAT), env[REQUEST_METHOD], env[PATH_INFO], env[QUERY_STRING].empty? ? "" : "?#{env[QUERY_STRING]}", - env["HTTP_VERSION"], + env[HTTP_VERSION], now - began_at ] write(msg) @@ -74,13 +81,13 @@ 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[HTTP_X_FORWARDED_FOR] || env[REMOTE_ADDR] || "-", + env[REMOTE_USER] || "-", + now.strftime(LOG_TIME_FORMAT), env[REQUEST_METHOD], env[PATH_INFO], env[QUERY_STRING].empty? ? "" : "?#{env[QUERY_STRING]}", - env["HTTP_VERSION"], + env[HTTP_VERSION], status.to_s[0..3], length, now - began_at ] diff -Nru puma-5.6.5/lib/puma/configuration.rb puma-6.4.2/lib/puma/configuration.rb --- puma-5.6.5/lib/puma/configuration.rb 2022-08-22 23:33:14.000000000 +0000 +++ puma-6.4.2/lib/puma/configuration.rb 2024-01-08 05:53:42.000000000 +0000 @@ -1,21 +1,11 @@ # frozen_string_literal: true -require 'puma/rack/builder' -require 'puma/plugin' -require 'puma/const' +require_relative 'rack/builder' +require_relative 'plugin' +require_relative 'const' +require_relative 'dsl' 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 @@ -136,7 +126,52 @@ # is done because an environment variable may have been modified while loading # configuration files. class Configuration - include ConfigDefault + DEFAULTS = { + auto_trim_time: 30, + binds: ['tcp://0.0.0.0:9292'.freeze], + clean_thread_locals: false, + debug: false, + early_hints: nil, + environment: 'development'.freeze, + # Number of seconds to wait until we get the first data for the request. + first_data_timeout: 30, + # Number of seconds to wait until the next request before shutting down. + idle_timeout: nil, + io_selector_backend: :auto, + log_requests: false, + logger: STDOUT, + # 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, + max_threads: Puma.mri? ? 5 : 16, + min_threads: 0, + mode: :http, + mutate_stdout_and_stderr_to_sync_on_write: true, + out_of_band: [], + # Number of seconds for another request within a persistent session. + persistent_timeout: 20, + queue_requests: true, + rackup: 'config.ru'.freeze, + raise_exception_on_sigterm: true, + reaping_time: 1, + remote_address: :socket, + silence_single_worker_warning: false, + silence_fork_callback_warning: false, + tag: File.basename(Dir.getwd), + tcp_host: '0.0.0.0'.freeze, + tcp_port: 9292, + wait_for_less_busy_worker: 0.005, + worker_boot_timeout: 60, + worker_check_interval: 5, + worker_culling_strategy: :youngest, + worker_shutdown_timeout: 30, + worker_timeout: 60, + workers: 0, + http_content_length_limit: nil + } def initialize(user_options={}, default_options = {}, &block) default_options = self.puma_default_options.merge(default_options) @@ -181,37 +216,22 @@ self end - # @version 5.0.0 - def default_max_threads - Puma.mri? ? 5 : 16 + def puma_default_options + defaults = DEFAULTS.dup + puma_options_from_env.each { |k,v| defaults[k] = v if v } + defaults end - def puma_default_options + def puma_options_from_env + min = ENV['PUMA_MIN_THREADS'] || ENV['MIN_THREADS'] + max = ENV['PUMA_MAX_THREADS'] || ENV['MAX_THREADS'] + workers = ENV['WEB_CONCURRENCY'] + { - :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, + min_threads: min && Integer(min), + max_threads: max && Integer(max), + workers: workers && Integer(workers), + environment: ENV['APP_ENV'] || ENV['RACK_ENV'] || ENV['RAILS_ENV'], } end @@ -227,7 +247,7 @@ return [] if files == ['-'] return files if files.any? - first_default_file = %W(config/puma/#{environment_str}.rb config/puma.rb).find do |f| + first_default_file = %W(config/puma/#{@options[:environment]}.rb config/puma.rb).find do |f| File.exist?(f) end @@ -270,7 +290,7 @@ found = options[:app] || load_rackup if @options[:log_requests] - require 'puma/commonlogger' + require_relative 'commonlogger' logger = @options[:logger] found = CommonLogger.new(found, logger) end @@ -283,21 +303,25 @@ @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) + # @param key [:Symbol] hook to run + # @param arg [Launcher, Int] `:on_restart` passes Launcher + # + def run_hooks(key, arg, log_writer, hook_data = nil) @options.all_of(key).each do |b| begin - b.call arg + if Array === b + hook_data[b[1]] ||= Hash.new + b[0].call arg, hook_data[b[1]] + else + b.call arg + end rescue => e - events.log "WARNING hook #{key} failed with exception (#{e.class}) #{e.message}" - events.debug e.backtrace.join("\n") + log_writer.log "WARNING hook #{key} failed with exception (#{e.class}) #{e.message}" + log_writer.debug e.backtrace.join("\n") end end end @@ -315,10 +339,6 @@ 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 @@ -367,5 +387,3 @@ end end end - -require 'puma/dsl' diff -Nru puma-5.6.5/lib/puma/const.rb puma-6.4.2/lib/puma/const.rb --- puma-5.6.5/lib/puma/const.rb 2022-08-22 23:33:14.000000000 +0000 +++ puma-6.4.2/lib/puma/const.rb 2024-01-08 05:53:42.000000000 +0000 @@ -5,7 +5,6 @@ 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. @@ -19,6 +18,7 @@ 100 => 'Continue', 101 => 'Switching Protocols', 102 => 'Processing', + 103 => 'Early Hints', 200 => 'OK', 201 => 'Created', 202 => 'Accepted', @@ -50,16 +50,16 @@ 410 => 'Gone', 411 => 'Length Required', 412 => 'Precondition Failed', - 413 => 'Payload Too Large', + 413 => 'Content 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', + 422 => 'Unprocessable Content', 423 => 'Locked', 424 => 'Failed Dependency', + 425 => 'Too Early', 426 => 'Upgrade Required', 428 => 'Precondition Required', 429 => 'Too Many Requests', @@ -74,7 +74,7 @@ 506 => 'Variant Also Negotiates', 507 => 'Insufficient Storage', 508 => 'Loop Detected', - 510 => 'Not Extended', + 510 => 'Not Extended (OBSOLETED)', 511 => 'Network Authentication Required' }.freeze @@ -100,55 +100,40 @@ # too taxing on performance. module Const - PUMA_VERSION = VERSION = "5.6.5".freeze - CODE_NAME = "Birdie's Version".freeze + PUMA_VERSION = VERSION = "6.4.2" + CODE_NAME = "The Eagle of Durango" - PUMA_SERVER_STRING = ['puma', PUMA_VERSION, CODE_NAME].join(' ').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 + REQUEST_URI= "REQUEST_URI" + REQUEST_PATH = "REQUEST_PATH" + QUERY_STRING = "QUERY_STRING" + CONTENT_LENGTH = "CONTENT_LENGTH" - PATH_INFO = 'PATH_INFO'.freeze + PATH_INFO = "PATH_INFO" - PUMA_TMP_BASE = "puma".freeze + PUMA_TMP_BASE = "puma" ERROR_RESPONSE = { # Indicate that we couldn't parse the request - 400 => "HTTP/1.1 400 Bad Request\r\n\r\n".freeze, + 400 => "HTTP/1.1 400 Bad Request\r\n\r\n", # 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, + 404 => "HTTP/1.1 404 Not Found\r\nConnection: close\r\n\r\n", # 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, + 408 => "HTTP/1.1 408 Request Timeout\r\nConnection: close\r\n\r\n", # Indicate that there was an internal error, obviously. - 500 => "HTTP/1.1 500 Internal Server Error\r\n\r\n".freeze, + 500 => "HTTP/1.1 500 Internal Server Error\r\n\r\n", # Incorrect or invalid header value - 501 => "HTTP/1.1 501 Not Implemented\r\n\r\n".freeze, + 501 => "HTTP/1.1 501 Not Implemented\r\n\r\n", # 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 + 503 => "HTTP/1.1 503 Service Unavailable\r\n\r\n" }.freeze # The basic max request size we'll try to read. @@ -161,84 +146,136 @@ # 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 + REQUEST_METHOD = "REQUEST_METHOD" + HEAD = "HEAD" + + # based on https://www.rfc-editor.org/rfc/rfc9110.html#name-overview, + # with CONNECT removed, and PATCH added + SUPPORTED_HTTP_METHODS = %w[HEAD GET POST PUT DELETE OPTIONS TRACE PATCH].freeze + + # list from https://www.iana.org/assignments/http-methods/http-methods.xhtml + # as of 04-May-23 + IANA_HTTP_METHODS = %w[ + ACL + BASELINE-CONTROL + BIND + CHECKIN + CHECKOUT + CONNECT + COPY + DELETE + GET + HEAD + LABEL + LINK + LOCK + MERGE + MKACTIVITY + MKCALENDAR + MKCOL + MKREDIRECTREF + MKWORKSPACE + MOVE + OPTIONS + ORDERPATCH + PATCH + POST + PRI + PROPFIND + PROPPATCH + PUT + REBIND + REPORT + SEARCH + TRACE + UNBIND + UNCHECKOUT + UNLINK + UNLOCK + UPDATE + UPDATEREDIRECTREF + VERSION-CONTROL + ].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 + LINE_END = "\r\n" + REMOTE_ADDR = "REMOTE_ADDR" + HTTP_X_FORWARDED_FOR = "HTTP_X_FORWARDED_FOR" + HTTP_X_FORWARDED_SSL = "HTTP_X_FORWARDED_SSL" + HTTP_X_FORWARDED_SCHEME = "HTTP_X_FORWARDED_SCHEME" + HTTP_X_FORWARDED_PROTO = "HTTP_X_FORWARDED_PROTO" + + SERVER_NAME = "SERVER_NAME" + SERVER_PORT = "SERVER_PORT" + HTTP_HOST = "HTTP_HOST" + PORT_80 = "80" + PORT_443 = "443" + LOCALHOST = "localhost" + LOCALHOST_IPV4 = "127.0.0.1" + LOCALHOST_IPV6 = "::1" + UNSPECIFIED_IPV4 = "0.0.0.0" + UNSPECIFIED_IPV6 = "::" + + SERVER_PROTOCOL = "SERVER_PROTOCOL" + HTTP_11 = "HTTP/1.1" + + SERVER_SOFTWARE = "SERVER_SOFTWARE" + GATEWAY_INTERFACE = "GATEWAY_INTERFACE" + CGI_VER = "CGI/1.2" + + STOP_COMMAND = "?" + HALT_COMMAND = "!" + RESTART_COMMAND = "R" + + RACK_INPUT = "rack.input" + RACK_URL_SCHEME = "rack.url_scheme" + RACK_AFTER_REPLY = "rack.after_reply" + PUMA_SOCKET = "puma.socket" + PUMA_CONFIG = "puma.config" + PUMA_PEERCERT = "puma.peercert" + + HTTP = "http" + HTTPS = "https" + + HTTPS_KEY = "HTTPS" + + HTTP_VERSION = "HTTP_VERSION" + HTTP_CONNECTION = "HTTP_CONNECTION" + HTTP_EXPECT = "HTTP_EXPECT" + CONTINUE = "100-continue" + + HTTP_11_100 = "HTTP/1.1 100 Continue\r\n\r\n" + HTTP_11_200 = "HTTP/1.1 200 OK\r\n" + HTTP_10_200 = "HTTP/1.0 200 OK\r\n" + + CLOSE = "close" + KEEP_ALIVE = "keep-alive" + + CONTENT_LENGTH2 = "content-length" + CONTENT_LENGTH_S = "Content-Length: " + TRANSFER_ENCODING = "transfer-encoding" + TRANSFER_ENCODING2 = "HTTP_TRANSFER_ENCODING" + + CONNECTION_CLOSE = "Connection: close\r\n" + CONNECTION_KEEP_ALIVE = "Connection: Keep-Alive\r\n" + + TRANSFER_ENCODING_CHUNKED = "Transfer-Encoding: chunked\r\n" + CLOSE_CHUNKED = "0\r\n\r\n" + + CHUNKED = "chunked" + + COLON = ": " + + NEWLINE = "\n" + + HIJACK_P = "rack.hijack?" + HIJACK = "rack.hijack" + HIJACK_IO = "rack.hijack_io" - EARLY_HINTS = "rack.early_hints".freeze + EARLY_HINTS = "rack.early_hints" # Illegal character in the key or value of response header - DQUOTE = "\"".freeze + DQUOTE = "\"" HTTP_HEADER_DELIMITER = Regexp.escape("(),/:;<=>?@[]{}\\").freeze ILLEGAL_HEADER_KEY_REGEX = /[\x00-\x20#{DQUOTE}#{HTTP_HEADER_DELIMITER}]/.freeze # header values can contain HTAB? diff -Nru puma-5.6.5/lib/puma/control_cli.rb puma-6.4.2/lib/puma/control_cli.rb --- puma-5.6.5/lib/puma/control_cli.rb 2022-08-22 23:33:14.000000000 +0000 +++ puma-6.4.2/lib/puma/control_cli.rb 2024-01-08 05:53:42.000000000 +0000 @@ -1,10 +1,8 @@ # frozen_string_literal: true require 'optparse' -require_relative 'state_file' require_relative 'const' require_relative 'detect' -require_relative 'configuration' require 'uri' require 'socket' @@ -33,9 +31,6 @@ 'worker-count-up' => 'SIGTTIN' }.freeze - # @deprecated 6.0.0 - COMMANDS = CMD_PATH_SIG_MAP.keys.freeze - # commands that cannot be used in a request NO_REQ_COMMANDS = %w[info reopen-log worker-count-down worker-count-up].freeze @@ -129,6 +124,9 @@ end if @config_file + require_relative 'configuration' + require_relative 'log_writer' + config = Puma::Configuration.new({ config_files: [@config_file] }, {}) config.load @state ||= config.options[:state] @@ -152,6 +150,8 @@ raise "State file not found: #{@state}" end + require_relative 'state_file' + sf = Puma::StateFile.new sf.load @state @@ -167,22 +167,26 @@ def send_request uri = URI.parse @control_url + host = uri.host + # create server object by scheme server = case uri.scheme when 'ssl' require 'openssl' + host = host[1..-2] if host&.start_with? '[' OpenSSL::SSL::SSLSocket.new( - TCPSocket.new(uri.host, uri.port), + TCPSocket.new(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 + host = host[1..-2] if host&.start_with? '[' + TCPSocket.new 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}") + "\0#{host}#{uri.path}" : "#{host}#{uri.path}") else raise "Invalid scheme: #{uri.scheme}" end @@ -287,7 +291,7 @@ private def start - require 'puma/cli' + require_relative 'cli' run_args = [] @@ -299,13 +303,13 @@ run_args += ["-C", @config_file] if @config_file run_args += ["-e", @environment] if @environment - events = Puma::Events.new @stdout, @stderr + log_writer = Puma::LogWriter.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 = Puma::CLI.new run_args, log_writer cli.run end end diff -Nru puma-5.6.5/lib/puma/detect.rb puma-6.4.2/lib/puma/detect.rb --- puma-5.6.5/lib/puma/detect.rb 2022-08-22 23:33:14.000000000 +0000 +++ puma-6.4.2/lib/puma/detect.rb 2024-01-08 05:53:42.000000000 +0000 @@ -8,15 +8,18 @@ # @version 5.2.1 HAS_FORK = ::Process.respond_to? :fork + HAS_NATIVE_IO_WAIT = ::IO.public_instance_methods(false).include? :wait_readable + IS_JRUBY = Object.const_defined? :JRUBY_VERSION - IS_OSX = RUBY_PLATFORM.include? 'darwin' + IS_OSX = RUBY_DESCRIPTION.include? 'darwin' + + IS_WINDOWS = RUBY_DESCRIPTION.match?(/mswin|ming|cygwin/) - IS_WINDOWS = !!(RUBY_PLATFORM =~ /mswin|ming|cygwin/) || - IS_JRUBY && RUBY_DESCRIPTION.include?('mswin') + IS_LINUX = !(IS_OSX || IS_WINDOWS) # @version 5.2.0 - IS_MRI = (RUBY_ENGINE == 'ruby' || RUBY_ENGINE.nil?) + IS_MRI = RUBY_ENGINE == 'ruby' def self.jruby? IS_JRUBY diff -Nru puma-5.6.5/lib/puma/dsl.rb puma-6.4.2/lib/puma/dsl.rb --- puma-5.6.5/lib/puma/dsl.rb 2022-08-22 23:33:14.000000000 +0000 +++ puma-6.4.2/lib/puma/dsl.rb 2024-01-08 05:53:42.000000000 +0000 @@ -1,7 +1,7 @@ # frozen_string_literal: true -require 'puma/const' -require 'puma/util' +require_relative 'const' +require_relative 'util' module Puma # The methods that are available for use inside the configuration file. @@ -32,8 +32,24 @@ # You can also find many examples being used by the test suite in # +test/config+. # + # Puma v6 adds the option to specify a key name (String or Symbol) to the + # hooks that run inside the forked workers. All the hooks run inside the + # {Puma::Cluster::Worker#run} method. + # + # Previously, the worker index and the LogWriter instance were passed to the + # hook blocks/procs. If a key name is specified, a hash is passed as the last + # parameter. This allows storage of data, typically objects that are created + # before the worker that need to be passed to the hook when the worker is shutdown. + # + # The following hooks have been updated: + # + # | DSL Method | Options Key | Fork Block Location | + # | on_worker_boot | :before_worker_boot | inside, before | + # | on_worker_shutdown | :before_worker_shutdown | inside, after | + # | on_refork | :before_refork | inside | + # class DSL - include ConfigDefault + ON_WORKER_KEY = [String, Symbol].freeze # convenience method so logic can be used in CI # @see ssl_bind @@ -49,28 +65,58 @@ ca_additions = "&ca=#{Puma::Util.escape(opts[:ca])}" if ['peer', 'force_peer'].include?(verify) + low_latency_str = opts.key?(:low_latency) ? "&low_latency=#{opts[:low_latency]}" : '' 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 + cipher_suites = opts[:ssl_cipher_list] ? "&ssl_cipher_list=#{opts[:ssl_cipher_list]}" : nil # old name + cipher_suites = "#{cipher_suites}&cipher_suites=#{opts[:cipher_suites]}" if opts[:cipher_suites] + protocols = opts[:protocols] ? "&protocols=#{opts[:protocols]}" : nil keystore_additions = "keystore=#{opts[:keystore]}&keystore-pass=#{opts[:keystore_pass]}" + keystore_additions = "#{keystore_additions}&keystore-type=#{opts[:keystore_type]}" if opts[:keystore_type] + if opts[:truststore] + truststore_additions = "&truststore=#{opts[:truststore]}" + truststore_additions = "#{truststore_additions}&truststore-pass=#{opts[:truststore_pass]}" if opts[:truststore_pass] + truststore_additions = "#{truststore_additions}&truststore-type=#{opts[:truststore_type]}" if opts[:truststore_type] + end - "ssl://#{host}:#{port}?#{keystore_additions}#{ssl_cipher_list}" \ + "ssl://#{host}:#{port}?#{keystore_additions}#{truststore_additions}#{cipher_suites}#{protocols}" \ "&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 + 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 - v_flags = (ary = opts[:verification_flags]) ? - "&verification_flags=#{Array(ary).join ','}" : nil - - cert_flags = (cert = opts[:cert]) ? "cert=#{Puma::Util.escape(opts[:cert])}" : nil - key_flags = (cert = opts[:key]) ? "&key=#{Puma::Util.escape(opts[:key])}" : nil + cert_flags = (cert = opts[:cert]) ? "cert=#{Puma::Util.escape(cert)}" : nil + key_flags = (key = opts[:key]) ? "&key=#{Puma::Util.escape(key)}" : nil + password_flags = (password_command = opts[:key_password_command]) ? "&key_password_command=#{Puma::Util.escape(password_command)}" : nil + + reuse_flag = + if (reuse = opts[:reuse]) + if reuse == true + '&reuse=dflt' + elsif reuse.is_a?(Hash) && (reuse.key?(:size) || reuse.key?(:timeout)) + val = +'' + if (size = reuse[:size]) && Integer === size + val << size.to_s + end + if (timeout = reuse[:timeout]) && Integer === timeout + val << ",#{timeout}" + end + if val.empty? + nil + else + "&reuse=#{val}" + end + else + nil + end + else + nil + end - "ssl://#{host}:#{port}?#{cert_flags}#{key_flags}" \ - "#{ssl_cipher_filter}&verify_mode=#{verify}#{tls_str}#{ca_additions}#{v_flags}#{backlog_str}" + "ssl://#{host}:#{port}?#{cert_flags}#{key_flags}#{password_flags}#{ssl_cipher_filter}" \ + "#{reuse_flag}&verify_mode=#{verify}#{tls_str}#{ca_additions}#{v_flags}#{backlog_str}#{low_latency_str}" end end @@ -106,7 +152,7 @@ end def default_host - @options[:default_host] || Configuration::DefaultTCPHost + @options[:default_host] || Configuration::DEFAULTS[:tcp_host] end def inject(&blk) @@ -206,6 +252,7 @@ # # * Set the socket backlog depth with +backlog+, default is 1024. # * Set up an SSL certificate with +key+ & +cert+. + # * Set up an SSL certificate for mTLS with +key+, +cert+, +ca+ and +verify_mode+. # * 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+. @@ -215,6 +262,8 @@ # 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 SSL cert for mutual TLS (mTLS) + # bind 'ssl://127.0.0.1:9292?key=key.key&cert=cert.pem&ca=ca.pem&verify_mode=force_peer' # @example Disable optimization for low latency # bind 'tcp://0.0.0.0:9292?low_latency=false' # @example Socket permissions @@ -266,16 +315,22 @@ bind URI::Generic.build(scheme: 'tcp', host: host, port: Integer(port)).to_s 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 + # 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. + # If a new request is not received within this number of seconds, begin shutting down. # @see Puma::Server.new - def first_data_timeout(seconds) - @options[:first_data_timeout] = Integer(seconds) + def idle_timeout(seconds) + @options[:idle_timeout] = Integer(seconds) end # Work around leaky apps that leave garbage in Thread locals @@ -371,6 +426,11 @@ @options[:log_requests] = which end + # Pass in a custom logging class instance + def custom_logger(custom_logger) + @options[:custom_logger] = custom_logger + end + # Show debugging info # def debug @@ -452,6 +512,16 @@ # Puma will assume you are using the +localhost+ gem and try to load the # appropriate files. # + # When using the options hash parameter, the `reuse:` value is either + # `true`, which sets reuse 'on' with default values, or a hash, with `:size` + # and/or `:timeout` keys, each with integer values. + # + # The `cert:` options hash parameter can be the path to a certificate + # file including all intermediate certificates in PEM format. + # + # The `cert_pem:` options hash parameter can be String containing the + # cerificate and all intermediate certificates in PEM format. + # # @example # ssl_bind '127.0.0.1', '9292', { # cert: path_to_cert, @@ -459,6 +529,7 @@ # ssl_cipher_filter: cipher_filter, # optional # verify_mode: verify_mode, # default 'none' # verification_flags: flags, # optional, not supported by JRuby + # reuse: true # optional # } # # @example Using self-signed certificate with the +localhost+ gem: @@ -468,6 +539,7 @@ # ssl_bind '127.0.0.1', '9292', { # cert_pem: File.read(path_to_cert), # key_pem: File.read(path_to_key), + # reuse: {size: 2_000, timeout: 20} # optional # } # # @example For JRuby, two keys are required: +keystore+ & +keystore_pass+ @@ -531,6 +603,11 @@ @options[:silence_single_worker_warning] = true end + # Disable warning message when running single mode with callback hook defined. + def silence_fork_callback_warning + @options[:silence_fork_callback_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 @@ -546,6 +623,8 @@ # puts "Starting workers..." # end def before_fork(&block) + warn_if_in_single_mode('before_fork') + @options[:before_fork] ||= [] @options[:before_fork] << block end @@ -560,9 +639,10 @@ # on_worker_boot do # puts 'Before worker boot...' # end - def on_worker_boot(&block) - @options[:before_worker_boot] ||= [] - @options[:before_worker_boot] << block + def on_worker_boot(key = nil, &block) + warn_if_in_single_mode('on_worker_boot') + + process_hook :before_worker_boot, key, block, 'on_worker_boot' end # Code to run immediately before a worker shuts @@ -577,9 +657,10 @@ # on_worker_shutdown do # puts 'On worker shutdown...' # end - def on_worker_shutdown(&block) - @options[:before_worker_shutdown] ||= [] - @options[:before_worker_shutdown] << block + def on_worker_shutdown(key = nil, &block) + warn_if_in_single_mode('on_worker_shutdown') + + process_hook :before_worker_shutdown, key, block, 'on_worker_shutdown' end # Code to run in the master right before a worker is started. The worker's @@ -593,8 +674,9 @@ # puts 'Before worker fork...' # end def on_worker_fork(&block) - @options[:before_worker_fork] ||= [] - @options[:before_worker_fork] << block + warn_if_in_single_mode('on_worker_fork') + + process_hook :before_worker_fork, nil, block, 'on_worker_fork' end # Code to run in the master after a worker has been started. The worker's @@ -608,12 +690,23 @@ # puts 'After worker fork...' # end def after_worker_fork(&block) - @options[:after_worker_fork] ||= [] - @options[:after_worker_fork] << block + warn_if_in_single_mode('after_worker_fork') + + process_hook :after_worker_fork, nil, block, 'after_worker_fork' end alias_method :after_worker_boot, :after_worker_fork + # Code to run after puma is booted (works for both: single and clustered) + # + # @example + # on_booted do + # puts 'After booting...' + # end + def on_booted(&block) + @config.options[:events].on_booted(&block) + end + # 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 @@ -632,9 +725,53 @@ # end # @version 5.0.0 # - def on_refork(&block) - @options[:before_refork] ||= [] - @options[:before_refork] << block + def on_refork(key = nil, &block) + process_hook :before_refork, key, block, 'on_refork' + end + + # Provide a block to be executed just before a thread is added to the thread + # pool. Be careful: while the block executes, thread creation is delayed, and + # probably a request will have to wait too! The new thread will not be added to + # the threadpool until the provided block returns. + # + # Return values are ignored. + # Raising an exception will log a warning. + # + # This hook is useful for doing something when the thread pool grows. + # + # This can be called multiple times to add several hooks. + # + # @example + # on_thread_start do + # puts 'On thread start...' + # end + def on_thread_start(&block) + @options[:before_thread_start] ||= [] + @options[:before_thread_start] << block + end + + # Provide a block to be executed after a thread is trimmed from the thread + # pool. Be careful: while this block executes, Puma's main loop is + # blocked, so no new requests will be picked up. + # + # This hook only runs when a thread in the threadpool is trimmed by Puma. + # It does not run when a thread dies due to exceptions or any other cause. + # + # Return values are ignored. + # Raising an exception will log a warning. + # + # This hook is useful for cleaning up thread local resources when a thread + # is trimmed. + # + # This can be called multiple times to add several hooks. + # + # @example + # on_thread_exit do + # puts 'On thread exit...' + # end + def on_thread_exit(&block) + @options[:before_thread_exit] ||= [] + @options[:before_thread_exit] << block end # Code to run out-of-band when the worker is idle. @@ -647,8 +784,7 @@ # # This can be called multiple times to add several hooks. def out_of_band(&block) - @options[:out_of_band] ||= [] - @options[:out_of_band] << block + process_hook :out_of_band, nil, block, 'out_of_band' end # The directory to operate out of. @@ -769,7 +905,8 @@ # 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. + # This value must be greater than worker_check_interval. + # The default value is 60 seconds. # # @note Cluster mode only. # @example @@ -778,7 +915,7 @@ # def worker_timeout(timeout) timeout = Integer(timeout) - min = @options.fetch(:worker_check_interval, Puma::ConfigDefault::DefaultWorkerCheckInterval) + min = @options.fetch(:worker_check_interval, Configuration::DEFAULTS[:worker_check_interval]) if timeout <= min raise "The minimum worker_timeout must be greater than the worker reporting interval (#{min})" @@ -882,13 +1019,16 @@ # There are 5 possible values: # # 1. **:socket** (the default) - read the peername from the socket using the - # syscall. This is the normal behavior. + # syscall. This is the normal behavior. If this fails for any reason (e.g., + # if the peer disconnects between the connection being accepted and the getpeername + # system call), Puma will return "0.0.0.0" # 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. + # headers such as X-Forwarded-For to be used as well. If this header is absent, + # Puma will fall back to the behavior of :socket # 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 @@ -942,23 +1082,6 @@ @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. # @@ -989,6 +1112,51 @@ @options[:mutate_stdout_and_stderr_to_sync_on_write] = enabled end + # Specify how big the request payload should be, in bytes. + # This limit is compared against Content-Length HTTP header. + # If the payload size (CONTENT_LENGTH) is larger than http_content_length_limit, + # HTTP 413 status code is returned. + # + # When no Content-Length http header is present, it is compared against the + # size of the body of the request. + # + # The default value for http_content_length_limit is nil. + def http_content_length_limit(limit) + @options[:http_content_length_limit] = limit + end + + # Supported http methods, which will replace `Puma::Const::SUPPORTED_HTTP_METHODS`. + # The value of `:any` will allows all methods, otherwise, the value must be + # an array of strings. Note that methods are all uppercase. + # + # `Puma::Const::SUPPORTED_HTTP_METHODS` is conservative, if you want a + # complete set of methods, the methods defined by the + # [IANA Method Registry](https://www.iana.org/assignments/http-methods/http-methods.xhtml) + # are pre-defined as the constant `Puma::Const::IANA_HTTP_METHODS`. + # + # @note If the `methods` value is `:any`, no method check with be performed, + # similar to Puma v5 and earlier. + # + # @example Adds 'PROPFIND' to existing supported methods + # supported_http_methods(Puma::Const::SUPPORTED_HTTP_METHODS + ['PROPFIND']) + # @example Restricts methods to the array elements + # supported_http_methods %w[HEAD GET POST PUT DELETE OPTIONS PROPFIND] + # @example Restricts methods to the methods in the IANA Registry + # supported_http_methods Puma::Const::IANA_HTTP_METHODS + # @example Allows any method + # supported_http_methods :any + # + def supported_http_methods(methods) + if methods == :any + @options[:supported_http_methods] = :any + elsif Array === methods && methods == (ary = methods.grep(String).uniq) && + !ary.empty? + @options[:supported_http_methods] = ary + else + raise "supported_http_methods must be ':any' or a unique array of strings" + end + end + private # To avoid adding cert_pem and key_pem as URI params, we store them on the @@ -1008,5 +1176,31 @@ end end end + + def process_hook(options_key, key, block, meth) + @options[options_key] ||= [] + if ON_WORKER_KEY.include? key.class + @options[options_key] << [block, key.to_sym] + elsif key.nil? + @options[options_key] << block + else + raise "'#{meth}' key must be String or Symbol" + end + end + + def warn_if_in_single_mode(hook_name) + return if @options[:silence_fork_callback_warning] + # user_options (CLI) have precedence over config file + workers_val = @config.options.user_options[:workers] || @options[:workers] || + @config.puma_default_options[:workers] || 0 + if workers_val == 0 + log_string = + "Warning: You specified code to run in a `#{hook_name}` block, " \ + "but Puma is not configured to run in cluster mode (worker count > 0 ), " \ + "so your `#{hook_name}` block did not run" + + LogWriter.stdio.log(log_string) + end + end end end diff -Nru puma-5.6.5/lib/puma/error_logger.rb puma-6.4.2/lib/puma/error_logger.rb --- puma-5.6.5/lib/puma/error_logger.rb 2022-08-22 23:33:14.000000000 +0000 +++ puma-6.4.2/lib/puma/error_logger.rb 2024-01-08 05:53:42.000000000 +0000 @@ -1,6 +1,6 @@ # frozen_string_literal: true -require 'puma/const' +require_relative 'const' module Puma # The implementation of a detailed error logging. @@ -13,6 +13,8 @@ REQUEST_FORMAT = %{"%s %s%s" - (%s)} + LOG_QUEUE = Queue.new + def initialize(ioerr) @ioerr = ioerr @@ -31,7 +33,7 @@ # and before all remaining info. # def info(options={}) - log title(options) + internal_write title(options) end # Print occurred error details only if @@ -53,7 +55,7 @@ string_block << request_dump(req) if request_parsed?(req) string_block << error.backtrace if error - log string_block.join("\n") + internal_write string_block.join("\n") end def title(options={}) @@ -93,12 +95,19 @@ req && req.env[REQUEST_METHOD] end - private - - def log(str) - ioerr.puts str - - ioerr.flush unless ioerr.sync + def internal_write(str) + LOG_QUEUE << str + while (w_str = LOG_QUEUE.pop(true)) do + begin + @ioerr.is_a?(IO) and @ioerr.wait_writable(1) + @ioerr.write "#{w_str}\n" + @ioerr.flush unless @ioerr.sync + rescue Errno::EPIPE, Errno::EBADF, IOError, Errno::EINVAL + # 'Invalid argument' (Errno::EINVAL) may be raised by flush + end + end + rescue ThreadError end + private :internal_write end end diff -Nru puma-5.6.5/lib/puma/events.rb puma-6.4.2/lib/puma/events.rb --- puma-5.6.5/lib/puma/events.rb 2022-08-22 23:33:14.000000000 +0000 +++ puma-6.4.2/lib/puma/events.rb 2024-01-08 05:53:42.000000000 +0000 @@ -1,52 +1,23 @@ # 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) + # This is an event sink used by `Puma::Server` to handle + # lifecycle events such as :on_booted, :on_restart, and :on_stopped. + # Using `Puma::DSL` it is possible to register callback hooks + # for each event type. + class Events + def initialize @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" @@ -59,79 +30,6 @@ 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 @@ -155,23 +53,5 @@ 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 -Nru puma-5.6.5/lib/puma/io_buffer.rb puma-6.4.2/lib/puma/io_buffer.rb --- puma-5.6.5/lib/puma/io_buffer.rb 2022-08-22 23:33:14.000000000 +0000 +++ puma-6.4.2/lib/puma/io_buffer.rb 2024-01-08 05:53:42.000000000 +0000 @@ -1,11 +1,46 @@ # frozen_string_literal: true +require 'stringio' + module Puma - class IOBuffer < String - def append(*args) - args.each { |a| concat(a) } + class IOBuffer < StringIO + def initialize + super.binmode + end + + def empty? + length.zero? end - alias reset clear + def reset + truncate 0 + rewind + end + + def to_s + rewind + read + end + + # Read & Reset - returns contents and resets + # @return [String] StringIO contents + def read_and_reset + rewind + str = read + truncate 0 + rewind + str + end + + alias_method :clear, :reset + + # before Ruby 2.5, `write` would only take one argument + if RUBY_VERSION >= '2.5' && RUBY_ENGINE != 'truffleruby' + alias_method :append, :write + else + def append(*strs) + strs.each { |str| write str } + end + end end end diff -Nru puma-5.6.5/lib/puma/jruby_restart.rb puma-6.4.2/lib/puma/jruby_restart.rb --- puma-5.6.5/lib/puma/jruby_restart.rb 2022-08-22 23:33:14.000000000 +0000 +++ puma-6.4.2/lib/puma/jruby_restart.rb 2024-01-08 05:53:42.000000000 +0000 @@ -16,7 +16,8 @@ def self.chdir_exec(dir, argv) chdir(dir) cmd = argv.first - argv = ([:string] * argv.size).zip(argv).flatten + argv = ([:string] * argv.size).zip(argv) + argv.flatten! argv << :string argv << nil execlp(cmd, *argv) diff -Nru puma-5.6.5/lib/puma/launcher/bundle_pruner.rb puma-6.4.2/lib/puma/launcher/bundle_pruner.rb --- puma-5.6.5/lib/puma/launcher/bundle_pruner.rb 1970-01-01 00:00:00.000000000 +0000 +++ puma-6.4.2/lib/puma/launcher/bundle_pruner.rb 2024-01-08 05:53:42.000000000 +0000 @@ -0,0 +1,104 @@ +# frozen_string_literal: true + +module Puma + class Launcher + + # This class is used to pickup Gemfile changes during + # application restarts. + class BundlePruner + + def initialize(original_argv, extra_runtime_dependencies, log_writer) + @original_argv = Array(original_argv) + @extra_runtime_dependencies = Array(extra_runtime_dependencies) + @log_writer = log_writer + end + + def prune + return if ENV['PUMA_BUNDLER_PRUNED'] + return unless defined?(Bundler) + + require_rubygems_min_version! + + unless puma_wild_path + log "! Unable to prune Bundler environment, continuing" + return + end + + dirs = paths_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_path, '-I', dirs.join(':')] + @original_argv + # Ruby 2.0+ defaults to true which breaks socket activation + args += [{:close_others => false}] + Kernel.exec(*args) + end + end + + private + + def require_rubygems_min_version! + min_version = Gem::Version.new('2.2') + + return if min_version <= Gem::Version.new(Gem::VERSION) + + raise "prune_bundler is not supported on your version of RubyGems. " \ + "You must have RubyGems #{min_version}+ to use this feature." + end + + def puma_wild_path + puma_lib_dir = puma_require_paths.detect { |x| File.exist? File.join(x, '../bin/puma-wild') } + File.expand_path(File.join(puma_lib_dir, '../bin/puma-wild')) + 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 + + def paths_to_require_after_prune + puma_require_paths + extra_runtime_deps_paths + end + + def extra_runtime_deps_paths + t = @extra_runtime_dependencies.map do |dep_name| + if (spec = spec_for_gem(dep_name)) + require_paths_for_gem(spec) + else + log "* Could not load extra dependency: #{dep_name}" + nil + end + end + t.flatten!; t.compact!; t + end + + def puma_require_paths + require_paths_for_gem(spec_for_gem('puma')) + 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) + @log_writer.log(str) + end + end + end +end diff -Nru puma-5.6.5/lib/puma/launcher.rb puma-6.4.2/lib/puma/launcher.rb --- puma-5.6.5/lib/puma/launcher.rb 2022-08-22 23:33:14.000000000 +0000 +++ puma-6.4.2/lib/puma/launcher.rb 2024-01-08 05:53:42.000000000 +0000 @@ -1,11 +1,12 @@ # frozen_string_literal: true -require 'puma/events' -require 'puma/detect' -require 'puma/cluster' -require 'puma/single' -require 'puma/const' -require 'puma/binder' +require_relative 'log_writer' +require_relative 'events' +require_relative 'detect' +require_relative 'cluster' +require_relative 'single' +require_relative 'const' +require_relative 'binder' module Puma # Puma::Launcher is the single entry point for starting a Puma server based on user @@ -15,18 +16,14 @@ # 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 - ] + autoload :BundlePruner, 'puma/launcher/bundle_pruner' + # 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 is expected to hold an object similar to an `Puma::LogWriter.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 @@ -40,25 +37,35 @@ # [200, {}, ["hello world"]] # end # end - # Puma::Launcher.new(conf, events: Puma::Events.stdio).run + # Puma::Launcher.new(conf, log_writer: Puma::LogWriter.stdio).run def initialize(conf, launcher_args={}) @runner = nil - @events = launcher_args[:events] || Events::DEFAULT + @log_writer = launcher_args[:log_writer] || LogWriter::DEFAULT + @events = launcher_args[:events] || Events.new @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 + @config.options[:log_writer] = @log_writer # Advertise the Configuration Puma.cli_config = @config if defined?(Puma.cli_config) @config.load + @binder = Binder.new(@log_writer, conf) + @binder.create_inherited_fds(ENV).each { |k| ENV.delete k } + @binder.create_activated_fds(ENV).each { |k| ENV.delete k } + + @environment = conf.environment + + # Load the systemd integration if we detect systemd's NOTIFY_SOCKET. + # Skip this on JRuby though, because it is incompatible with the systemd + # integration due to https://github.com/jruby/jruby/issues/6504 + if ENV["NOTIFY_SOCKET"] && !Puma.jruby? + @config.plugins.create('systemd') + end + if @config.options[:bind_to_activated_sockets] @config.options[:binds] = @binder.synthesize_binds_from_activated_fs( @config.options[:binds], @@ -69,8 +76,10 @@ @options = @config.options @config.clamp - @events.formatter = Events::PidFormatter.new if clustered? - @events.formatter = options[:log_formatter] if @options[:log_formatter] + @log_writer.formatter = LogWriter::PidFormatter.new if clustered? + @log_writer.formatter = options[:log_formatter] if @options[:log_formatter] + + @log_writer.custom_logger = options[:custom_logger] if @options[:custom_logger] generate_restart_data @@ -80,17 +89,17 @@ Dir.chdir(@restart_dir) - prune_bundler if prune_bundler? + prune_bundler! @environment = @options[:environment] if @options[:environment] set_rack_environment if clustered? - @options[:logger] = @events + @options[:logger] = @log_writer - @runner = Cluster.new(self, @events) + @runner = Cluster.new(self) else - @runner = Single.new(self, @events) + @runner = Single.new(self) end Puma.stats_object = @runner @@ -99,7 +108,7 @@ log_config if ENV['PUMA_LOG_CONFIG'] end - attr_reader :binder, :events, :config, :options, :restart_dir + attr_reader :binder, :log_writer, :events, :config, :options, :restart_dir # Return stats about the server def stats @@ -115,7 +124,7 @@ permission = @options[:state_permission] return unless path - require 'puma/state_file' + require_relative 'state_file' sf = StateFile.new sf.pid = Process.pid @@ -172,16 +181,7 @@ # 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 + previous_env = get_env @config.clamp @@ -189,24 +189,11 @@ setup_signals set_process_title - integrate_with_systemd + + # This blocks until the server is stopped @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 + do_run_finished(previous_env) end # Return all tcp ports the launcher may be using, TCP or SSL @@ -250,30 +237,56 @@ 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 + def get_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 end - def reload_worker_directory - @runner.reload_worker_directory if @runner.respond_to?(:reload_worker_directory) + def do_run_finished(previous_env) + case @status + when :halt + do_forceful_stop + when :run, :stop + do_graceful_stop + when :restart + do_restart(previous_env) + end + + close_binder_listeners unless @status == :restart + end + + def do_forceful_stop + log "* Stopping immediately!" + @runner.stop_control + end + + def do_graceful_stop + @events.fire_on_stopped! + @runner.stop_blocked + end + + def do_restart(previous_env) + log "* Restarting..." + ENV.replace(previous_env) + @runner.stop_control + restart! end def restart! @events.fire_on_restart! - @config.run_hooks :on_restart, self, @events + @config.run_hooks :on_restart, self, @log_writer if Puma.jruby? close_binder_listeners - require 'puma/jruby_restart' + require_relative 'jruby_restart' JRubyRestart.chdir_exec(@restart_dir, restart_args) elsif Puma.windows? close_binder_listeners @@ -290,94 +303,24 @@ 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 + # 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 - - 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 + def reload_worker_directory + @runner.reload_worker_directory if @runner.respond_to?(:reload_worker_directory) end def log(str) - @events.log str + @log_writer.log(str) end def clustered? @@ -385,15 +328,10 @@ end def unsupported(str) - @events.error(str) + @log_writer.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 @@ -419,6 +357,11 @@ @options[:prune_bundler] && clustered? && !@options[:preload_app] end + def prune_bundler! + return unless prune_bundler? + BundlePruner.new(@original_argv, @options[:extra_runtime_dependencies], @log_writer).prune + end + def generate_restart_data if dir = @options[:directory] @restart_dir = dir @@ -485,7 +428,8 @@ begin Signal.trap "SIGTERM" do - graceful_stop + # Shortcut the control flow in case raise_exception_on_sigterm is true + do_graceful_stop raise(SignalException, "SIGTERM") if @options[:raise_exception_on_sigterm] end @@ -517,8 +461,8 @@ 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}" } + @log_writer.log(name) + @log_writer.log(backtrace.map { |bt| " #{bt}" }) end end end @@ -528,23 +472,6 @@ 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:" diff -Nru puma-5.6.5/lib/puma/log_writer.rb puma-6.4.2/lib/puma/log_writer.rb --- puma-5.6.5/lib/puma/log_writer.rb 1970-01-01 00:00:00.000000000 +0000 +++ puma-6.4.2/lib/puma/log_writer.rb 2024-01-08 05:53:42.000000000 +0000 @@ -0,0 +1,147 @@ +# frozen_string_literal: true + +require_relative 'null_io' +require_relative 'error_logger' +require 'stringio' +require 'io/wait' unless Puma::HAS_NATIVE_IO_WAIT + +module Puma + + # Handles logging concerns for both standard messages + # (+stdout+) and errors (+stderr+). + class LogWriter + + class DefaultFormatter + def call(str) + str + end + end + + class PidFormatter + def call(str) + "[#{$$}] #{str}" + end + end + + LOG_QUEUE = Queue.new + + attr_reader :stdout, + :stderr + + attr_accessor :formatter, :custom_logger + + # Create a LogWriter that prints to +stdout+ and +stderr+. + def initialize(stdout, stderr) + @formatter = DefaultFormatter.new + @custom_logger = nil + @stdout = stdout + @stderr = stderr + + @debug = ENV.key?('PUMA_DEBUG') + @error_logger = ErrorLogger.new(@stderr) + end + + DEFAULT = new(STDOUT, STDERR) + + # Returns an LogWriter object which writes its status to + # two StringIO objects. + def self.strings + LogWriter.new(StringIO.new, StringIO.new) + end + + def self.stdio + LogWriter.new($stdout, $stderr) + end + + def self.null + n = NullIO.new + LogWriter.new(n, n) + end + + # Write +str+ to +@stdout+ + def log(str) + if @custom_logger&.respond_to?(:write) + @custom_logger.write(format(str)) + else + internal_write "#{@formatter.call str}\n" + end + end + + def write(str) + internal_write @formatter.call(str) + end + + def internal_write(str) + LOG_QUEUE << str + while (w_str = LOG_QUEUE.pop(true)) do + begin + @stdout.is_a?(IO) and @stdout.wait_writable(1) + @stdout.write w_str + @stdout.flush unless @stdout.sync + rescue Errno::EPIPE, Errno::EBADF, IOError, Errno::EINVAL + # 'Invalid argument' (Errno::EINVAL) may be raised by flush + end + end + rescue ThreadError + end + private :internal_write + + def debug? + @debug + end + + def debug(str) + log("% #{str}") if @debug + end + + # Write +str+ to +@stderr+ + def error(str) + @error_logger.info(text: @formatter.call("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&.subject + @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 + end +end diff -Nru puma-5.6.5/lib/puma/minissl/context_builder.rb puma-6.4.2/lib/puma/minissl/context_builder.rb --- puma-5.6.5/lib/puma/minissl/context_builder.rb 2022-08-22 23:33:14.000000000 +0000 +++ puma-6.4.2/lib/puma/minissl/context_builder.rb 2024-01-08 05:53:42.000000000 +0000 @@ -1,9 +1,9 @@ module Puma module MiniSSL class ContextBuilder - def initialize(params, events) + def initialize(params, log_writer) @params = params - @events = events + @log_writer = log_writer end def context @@ -11,27 +11,37 @@ if defined?(JRUBY_VERSION) unless params['keystore'] - events.error "Please specify the Java keystore via 'keystore='" + log_writer.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='" + log_writer.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'] + ctx.keystore_type = params['keystore-type'] + + if truststore = params['truststore'] + ctx.truststore = truststore.eql?('default') ? :default : truststore + ctx.truststore_pass = params['truststore-pass'] + ctx.truststore_type = params['truststore-type'] + end + + ctx.cipher_suites = params['cipher_suites'] || params['ssl_cipher_list'] + ctx.protocols = params['protocols'] if params['protocols'] else if params['key'].nil? && params['key_pem'].nil? - events.error "Please specify the SSL key via 'key=' or 'key_pem='" + log_writer.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'] + ctx.key_password_command = params['key_password_command'] if params['key_password_command'] if params['cert'].nil? && params['cert_pem'].nil? - events.error "Please specify the SSL cert via 'cert=' or 'cert_pem='" + log_writer.error "Please specify the SSL cert via 'cert=' or 'cert_pem='" end ctx.cert = params['cert'] if params['cert'] @@ -39,16 +49,20 @@ if ['peer', 'force_peer'].include?(params['verify_mode']) unless params['ca'] - events.error "Please specify the SSL ca via 'ca='" + log_writer.error "Please specify the SSL ca via 'ca='" end + # needed for Puma::MiniSSL::Socket#peercert, env['puma.peercert'] + require 'openssl' end ctx.ca = params['ca'] if params['ca'] ctx.ssl_cipher_filter = params['ssl_cipher_filter'] if params['ssl_cipher_filter'] + + ctx.reuse = params['reuse'] if params['reuse'] end - ctx.no_tlsv1 = true if params['no_tlsv1'] == 'true' - ctx.no_tlsv1_1 = true if params['no_tlsv1_1'] == 'true' + ctx.no_tlsv1 = params['no_tlsv1'] == 'true' + ctx.no_tlsv1_1 = params['no_tlsv1_1'] == 'true' if params['verify_mode'] ctx.verify_mode = case params['verify_mode'] @@ -59,7 +73,7 @@ when "none" MiniSSL::VERIFY_NONE else - events.error "Please specify a valid verify_mode=" + log_writer.error "Please specify a valid verify_mode=" MiniSSL::VERIFY_NONE end end @@ -75,7 +89,7 @@ private - attr_reader :params, :events + attr_reader :params, :log_writer end end end diff -Nru puma-5.6.5/lib/puma/minissl.rb puma-6.4.2/lib/puma/minissl.rb --- puma-5.6.5/lib/puma/minissl.rb 2022-08-22 23:33:14.000000000 +0000 +++ puma-6.4.2/lib/puma/minissl.rb 2024-01-08 05:53:42.000000000 +0000 @@ -1,11 +1,13 @@ # frozen_string_literal: true begin - require 'io/wait' + require 'io/wait' unless Puma::HAS_NATIVE_IO_WAIT rescue LoadError end +require 'open3' # need for Puma::MiniSSL::OPENSSL constants used in `HAS_TLS1_3` +# use require, see https://github.com/puma/puma/pull/2381 require 'puma/puma_http11' module Puma @@ -13,15 +15,16 @@ # 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 + 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 + @reuse = nil end # @!attribute [r] to_io @@ -50,7 +53,7 @@ # 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'] + HAS_TLS1_3 && ssl_version_state == ['TLSv1.3', 'SSLERR'] end private :bad_tlsv1_3? @@ -123,7 +126,7 @@ while true wrote = @engine.write data - enc_wr = ''.dup + enc_wr = +'' while (enc = @engine.extract) enc_wr << enc end @@ -181,6 +184,11 @@ @socket.peeraddr end + # OpenSSL is loaded in `MiniSSL::ContextBuilder` when + # `MiniSSL::Context#verify_mode` is not `VERIFY_NONE`. + # When `VERIFY_NONE`, `MiniSSL::Engine#peercert` is nil, regardless of + # whether the client sends a cert. + # @return [OpenSSL::X509::Certificate, nil] # @!attribute [r] peercert def peercert return @peercert if @peercert @@ -195,10 +203,6 @@ 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 @@ -212,6 +216,9 @@ @cert = nil @key_pem = nil @cert_pem = nil + @reuse = nil + @reuse_cache_size = nil + @reuse_timeout = nil end def check_file(file, desc) @@ -222,21 +229,61 @@ if IS_JRUBY # jruby-specific Context properties: java uses a keystore and password pair rather than a cert/key pair attr_reader :keystore + attr_reader :keystore_type attr_accessor :keystore_pass - attr_accessor :ssl_cipher_list + attr_reader :truststore + attr_reader :truststore_type + attr_accessor :truststore_pass + attr_reader :cipher_suites + attr_reader :protocols def keystore=(keystore) check_file keystore, 'Keystore' @keystore = keystore end + def truststore=(truststore) + # NOTE: historically truststore was assumed the same as keystore, this is kept for backwards + # compatibility, to rely on JVM's trust defaults we allow setting `truststore = :default` + unless truststore.eql?(:default) + raise ArgumentError, "No such truststore file '#{truststore}'" unless File.exist?(truststore) + end + @truststore = truststore + end + + def keystore_type=(type) + raise ArgumentError, "Invalid keystore type: #{type.inspect}" unless ['pkcs12', 'jks', nil].include?(type) + @keystore_type = type + end + + def truststore_type=(type) + raise ArgumentError, "Invalid truststore type: #{type.inspect}" unless ['pkcs12', 'jks', nil].include?(type) + @truststore_type = type + end + + def cipher_suites=(list) + list = list.split(',').map(&:strip) if list.is_a?(String) + @cipher_suites = list + end + + # aliases for backwards compatibility + alias_method :ssl_cipher_list, :cipher_suites + alias_method :ssl_cipher_list=, :cipher_suites= + + def protocols=(list) + list = list.split(',').map(&:strip) if list.is_a?(String) + @protocols = list + end + def check raise "Keystore not configured" unless @keystore + # @truststore defaults to @keystore due backwards compatibility end else # non-jruby Context properties attr_reader :key + attr_reader :key_password_command attr_reader :cert attr_reader :ca attr_reader :cert_pem @@ -244,11 +291,17 @@ attr_accessor :ssl_cipher_filter attr_accessor :verification_flags + attr_reader :reuse, :reuse_cache_size, :reuse_timeout + def key=(key) check_file key, 'Key' @key = key end + def key_password_command=(key_password_command) + @key_password_command = key_password_command + end + def cert=(cert) check_file cert, 'Cert' @cert = cert @@ -273,6 +326,46 @@ raise "Key not configured" if @key.nil? && @key_pem.nil? raise "Cert not configured" if @cert.nil? && @cert_pem.nil? end + + # Executes the command to return the password needed to decrypt the key. + def key_password + raise "Key password command not configured" if @key_password_command.nil? + + stdout_str, stderr_str, status = Open3.capture3(@key_password_command) + + return stdout_str.chomp if status.success? + + raise "Key password failed with code #{status.exitstatus}: #{stderr_str}" + end + + # Controls session reuse. Allowed values are as follows: + # * 'off' - matches the behavior of Puma 5.6 and earlier. This is included + # in case reuse 'on' is made the default in future Puma versions. + # * 'dflt' - sets session reuse on, with OpenSSL default cache size of + # 20k and default timeout of 300 seconds. + # * 's,t' - where s and t are integer strings, for size and timeout. + # * 's' - where s is an integer strings for size. + # * ',t' - where t is an integer strings for timeout. + # + def reuse=(reuse_str) + case reuse_str + when 'off' + @reuse = nil + when 'dflt' + @reuse = true + when /\A\d+\z/ + @reuse = true + @reuse_cache_size = reuse_str.to_i + when /\A\d+,\d+\z/ + @reuse = true + size, time = reuse_str.split ',' + @reuse_cache_size = size.to_i + @reuse_timeout = time.to_i + when /\A,\d+\z/ + @reuse = true + @reuse_timeout = reuse_str.delete(',').to_i + end + end end # disables TLSv1 diff -Nru puma-5.6.5/lib/puma/null_io.rb puma-6.4.2/lib/puma/null_io.rb --- puma-5.6.5/lib/puma/null_io.rb 2022-08-22 23:33:14.000000000 +0000 +++ puma-6.4.2/lib/puma/null_io.rb 2024-01-08 05:53:42.000000000 +0000 @@ -18,8 +18,22 @@ # Mimics IO#read with no data. # - def read(count = nil, _buffer = nil) - count && count > 0 ? nil : "" + def read(length = nil, buffer = nil) + if length.to_i < 0 + raise ArgumentError, "(negative length #{length} given)" + end + + buffer = if buffer.nil? + "".b + else + String.try_convert(buffer) or raise TypeError, "no implicit conversion of #{buffer.class} into String" + end + buffer.clear + if length.to_i > 0 + nil + else + buffer + end end def rewind diff -Nru puma-5.6.5/lib/puma/plugin/systemd.rb puma-6.4.2/lib/puma/plugin/systemd.rb --- puma-5.6.5/lib/puma/plugin/systemd.rb 1970-01-01 00:00:00.000000000 +0000 +++ puma-6.4.2/lib/puma/plugin/systemd.rb 2024-01-08 05:53:42.000000000 +0000 @@ -0,0 +1,90 @@ +# frozen_string_literal: true + +require_relative '../plugin' + +# 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 +# 4. periodically set the status +Puma::Plugin.create do + def start(launcher) + require_relative '../sd_notify' + + launcher.log_writer.log "* Enabling systemd notification integration" + + # hook_events + launcher.events.on_booted { Puma::SdNotify.ready } + launcher.events.on_stopped { Puma::SdNotify.stopping } + launcher.events.on_restart { Puma::SdNotify.reloading } + + # start watchdog + if Puma::SdNotify.watchdog? + ping_f = watchdog_sleep_time + + in_background do + launcher.log_writer.log "Pinging systemd watchdog every #{ping_f.round(1)} sec" + loop do + sleep ping_f + Puma::SdNotify.watchdog + end + end + end + + # start status loop + instance = self + sleep_time = 1.0 + in_background do + launcher.log_writer.log "Sending status to systemd every #{sleep_time.round(1)} sec" + + loop do + sleep sleep_time + # TODO: error handling? + Puma::SdNotify.status(instance.status) + end + end + end + + def status + if clustered? + messages = stats[:worker_status].map do |worker| + common_message(worker[:last_status]) + end.join(',') + + "Puma #{Puma::Const::VERSION}: cluster: #{booted_workers}/#{workers}, worker_status: [#{messages}]" + else + "Puma #{Puma::Const::VERSION}: worker: #{common_message(stats)}" + 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 stats + Puma.stats_hash + end + + def clustered? + stats.has_key?(:workers) + end + + def workers + stats.fetch(:workers, 1) + end + + def booted_workers + stats.fetch(:booted_workers, 1) + end + + def common_message(stats) + "{ #{stats[:running]}/#{stats[:max_threads]} threads, #{stats[:pool_capacity]} available, #{stats[:backlog]} backlog }" + end +end diff -Nru puma-5.6.5/lib/puma/plugin/tmp_restart.rb puma-6.4.2/lib/puma/plugin/tmp_restart.rb --- puma-5.6.5/lib/puma/plugin/tmp_restart.rb 2022-08-22 23:33:14.000000000 +0000 +++ puma-6.4.2/lib/puma/plugin/tmp_restart.rb 2024-01-08 05:53:42.000000000 +0000 @@ -1,6 +1,6 @@ # frozen_string_literal: true -require 'puma/plugin' +require_relative '../plugin' Puma::Plugin.create do def start(launcher) diff -Nru puma-5.6.5/lib/puma/queue_close.rb puma-6.4.2/lib/puma/queue_close.rb --- puma-5.6.5/lib/puma/queue_close.rb 2022-08-22 23:33:14.000000000 +0000 +++ puma-6.4.2/lib/puma/queue_close.rb 1970-01-01 00:00:00.000000000 +0000 @@ -1,26 +0,0 @@ -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 -Nru puma-5.6.5/lib/puma/rack/builder.rb puma-6.4.2/lib/puma/rack/builder.rb --- puma-5.6.5/lib/puma/rack/builder.rb 2022-08-22 23:33:14.000000000 +0000 +++ puma-6.4.2/lib/puma/rack/builder.rb 2024-01-08 05:53:42.000000000 +0000 @@ -102,13 +102,13 @@ begin info = [] server = Rack::Handler.get(options[:server]) || Rack::Handler.default(options) - if server && server.respond_to?(:valid_options) + if 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. + next if /^(Host|Port)[^a-zA-Z]/.match? name.to_s # ignore handler's host and port options, we do our own. info << " -O %-21s %s" % [name, description] has_options = true @@ -173,7 +173,7 @@ TOPLEVEL_BINDING, file, 0 end - def initialize(default_app = nil,&block) + def initialize(default_app = nil, &block) @use, @map, @run, @warmup = [], nil, default_app, nil # Conditionally load rack now, so that any rack middlewares, @@ -183,7 +183,7 @@ rescue LoadError end - instance_eval(&block) if block_given? + instance_eval(&block) if block end def self.app(default_app = nil, &block) @@ -276,7 +276,7 @@ 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 + @warmup&.call app app end @@ -287,7 +287,7 @@ private def generate_map(default_app, mapping) - require 'puma/rack/urlmap' + require_relative 'urlmap' mapped = default_app ? {'/' => default_app} : {} mapping.each { |r,b| mapped[r] = self.class.new(default_app, &b).to_app } diff -Nru puma-5.6.5/lib/puma/rack/urlmap.rb puma-6.4.2/lib/puma/rack/urlmap.rb --- puma-5.6.5/lib/puma/rack/urlmap.rb 2022-08-22 23:33:14.000000000 +0000 +++ puma-6.4.2/lib/puma/rack/urlmap.rb 2024-01-08 05:53:42.000000000 +0000 @@ -34,7 +34,7 @@ end location = location.chomp('/') - match = Regexp.new("^#{Regexp.quote(location).gsub('/', '/+')}(.*)", nil, 'n') + match = Regexp.new("^#{Regexp.quote(location).gsub('/', '/+')}(.*)", Regexp::NOENCODING) [host, location, match, app] }.sort_by do |(host, location, _, _)| diff -Nru puma-5.6.5/lib/puma/rack_default.rb puma-6.4.2/lib/puma/rack_default.rb --- puma-5.6.5/lib/puma/rack_default.rb 2022-08-22 23:33:14.000000000 +0000 +++ puma-6.4.2/lib/puma/rack_default.rb 2024-01-08 05:53:42.000000000 +0000 @@ -1,9 +1,24 @@ # frozen_string_literal: true -require 'rack/handler/puma' +require_relative '../rack/handler/puma' -module Rack::Handler - def self.default(options = {}) - Rack::Handler::Puma +# rackup was removed in Rack 3, it is now a separate gem +if Object.const_defined? :Rackup + module Rackup + module Handler + def self.default(options = {}) + ::Rackup::Handler::Puma + end + end end +elsif Object.const_defined?(:Rack) && Rack.release < '3' + module Rack + module Handler + def self.default(options = {}) + ::Rack::Handler::Puma + end + end + end +else + raise "Rack 3 must be used with the Rackup gem" end diff -Nru puma-5.6.5/lib/puma/reactor.rb puma-6.4.2/lib/puma/reactor.rb --- puma-5.6.5/lib/puma/reactor.rb 2022-08-22 23:33:14.000000000 +0000 +++ puma-6.4.2/lib/puma/reactor.rb 2024-01-08 05:53:42.000000000 +0000 @@ -1,7 +1,5 @@ # frozen_string_literal: true -require 'puma/queue_close' unless ::Queue.instance_methods.include? :close - module Puma class UnsupportedBackend < StandardError; end @@ -22,10 +20,12 @@ # 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(', ')})" + valid_backends = [:auto, *::NIO::Selector.backends] + unless valid_backends.include?(backend) + raise ArgumentError.new("unsupported IO selector backend: #{backend} (available backends: #{valid_backends.join(', ')})") end - @selector = backend == :auto ? NIO::Selector.new : NIO::Selector.new(backend) + + @selector = ::NIO::Selector.new(NIO::Selector.backends.delete(backend)) @input = Queue.new @timeouts = [] @block = block @@ -50,7 +50,7 @@ @input << client @selector.wakeup true - rescue ClosedQueueError + rescue ClosedQueueError, IOError # Ignore if selector is already closed false end @@ -61,12 +61,13 @@ @selector.wakeup rescue IOError # Ignore if selector is already closed end - @thread.join if @thread + @thread&.join end private def select_loop + close_selector = true begin until @input.closed? && @input.empty? # Wakeup any registered object that receives incoming data. @@ -76,7 +77,7 @@ # Wakeup all objects that timed out. timed_out = @timeouts.take_while {|t| t.timeout == 0} - timed_out.each(&method(:wakeup!)) + timed_out.each { |c| wakeup! c } unless @input.empty? until @input.empty? @@ -89,11 +90,19 @@ rescue StandardError => e STDERR.puts "Error in reactor loop escaped: #{e.message} (#{e.class})" STDERR.puts e.backtrace - retry + + # NoMethodError may be rarely raised when calling @selector.select, which + # is odd. Regardless, it may continue for thousands of calls if retried. + # Also, when it raises, @selector.close also raises an error. + if NoMethodError === e + close_selector = false + else + retry + end end # Wakeup all remaining objects on shutdown. @timeouts.each(&@block) - @selector.close + @selector.close if close_selector end # Start monitoring the object. diff -Nru puma-5.6.5/lib/puma/request.rb puma-6.4.2/lib/puma/request.rb --- puma-5.6.5/lib/puma/request.rb 2022-08-22 23:33:14.000000000 +0000 +++ puma-6.4.2/lib/puma/request.rb 2024-01-08 05:53:42.000000000 +0000 @@ -1,6 +1,8 @@ # frozen_string_literal: true module Puma + #———————————————————————— DO NOT USE — this class is for internal use only ——— + # 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 @@ -10,7 +12,24 @@ # #handle_request, which is called in Server#process_client. # @version 5.0.3 # - module Request + module Request # :nodoc: + + # Single element array body: smaller bodies are written to io_buffer first, + # then a single write from io_buffer. Larger sizes are written separately. + # Also fixes max size of chunked file body read. + BODY_LEN_MAX = 1_024 * 256 + + # File body: smaller bodies are combined with io_buffer, then written to + # socket. Larger bodies are written separately using `copy_stream` + IO_BODY_MAX = 1_024 * 64 + + # Array body: elements are collected in io_buffer. When io_buffer's size + # exceeds value, they are written to the socket. + IO_BUFFER_LEN_MAX = 1_024 * 512 + + SOCKET_WRITE_ERR_MSG = "Socket timeout writing data" + + CUSTOM_STAT = 'CUSTOM' include Puma::Const @@ -25,40 +44,44 @@ # # 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) + def handle_request(client, requests) env = client.env - io = client.io # io may be a MiniSSL::Socket + io_buffer = client.io_buffer + socket = client.io # io may be a MiniSSL::Socket + app_body = nil + + + return false if closed_socket?(socket) - return false if closed_socket?(io) + if client.http_content_length_limit_exceeded + return prepare_response(413, {}, ["Payload Too Large"], requests, client) + end normalize_env env, client - env[PUMA_SOCKET] = io + env[PUMA_SOCKET] = socket - if env[HTTPS_KEY] && io.peercert - env[PUMA_PEERCERT] = io.peercert + if env[HTTPS_KEY] && socket.peercert + env[PUMA_PEERCERT] = socket.peercert end env[HIJACK_P] = true env[HIJACK] = client - body = client.body - - head = env[REQUEST_METHOD] == HEAD - - env[RACK_INPUT] = body + env[RACK_INPUT] = client.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) + unless (str = str_early_hints headers).empty? + fast_write_str socket, "HTTP/1.1 103 Early Hints\r\n#{str}\r\n" + end rescue ConnectionError => e - @events.debug_error e + @log_writer.debug_error e # noop, if we lost the socket we just won't send the early hints end } @@ -69,123 +92,174 @@ # 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] = [] + env[RACK_AFTER_REPLY] ||= [] begin - begin - status, headers, res_body = @thread_pool.with_force_shutdown do + if @supported_http_methods == :any || @supported_http_methods.key?(env[REQUEST_METHOD]) + status, headers, app_body = @thread_pool.with_force_shutdown do @app.call(env) end + else + @log_writer.log "Unsupported HTTP method used: #{env[REQUEST_METHOD]}" + status, headers, app_body = [501, {}, ["#{env[REQUEST_METHOD]} method is not supported"]] + end - return :async if client.hijacked + # app_body needs to always be closed, hold value in case lowlevel_error + # is called + res_body = app_body - status = status.to_i + # full hijack, app called env['rack.hijack'] + return :async if client.hijacked - if status == -1 - unless headers.empty? and res_body == [] - raise "async response must have empty headers and body" - end + status = status.to_i - return :async + if status == -1 + unless headers.empty? and res_body == [] + raise "async response must have empty headers and body" 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) + return :async end + rescue ThreadPool::ForceShutdown => e + @log_writer.unknown_error e, client, "Rack app" + @log_writer.log "Detected force shutdown of a thread" + + status, headers, res_body = lowlevel_error(e, env, 503) + rescue Exception => e + @log_writer.unknown_error e, client, "Rack app" + + status, headers, res_body = lowlevel_error(e, env, 500) + end + prepare_response(status, headers, res_body, requests, client) + ensure + io_buffer.reset + uncork_socket client.io + app_body.close if app_body.respond_to? :close + client.tempfile&.unlink + after_reply = env[RACK_AFTER_REPLY] || [] + begin + after_reply.each { |o| o.call } + rescue StandardError => e + @log_writer.debug_error e + end unless after_reply.empty? + end - res_info = {} - res_info[:content_length] = nil - res_info[:no_body] = head + # Assembles the headers and prepares the body for actually sending the + # response via `#fast_write_response`. + # + # @param status [Integer] the status returned by the Rack application + # @param headers [Hash] the headers returned by the Rack application + # @param res_body [Array] the body returned by the Rack application or + # a call to `Server#lowlevel_error` + # @param requests [Integer] number of inline requests handled + # @param client [Puma::Client] + # @return [Boolean,:async] keep-alive status or `:async` + def prepare_response(status, headers, res_body, requests, client) + env = client.env + socket = client.io + io_buffer = client.io_buffer - res_info[:content_length] = if res_body.kind_of? Array and res_body.size == 1 - res_body[0].bytesize - else - nil - end + return false if closed_socket?(socket) - cork_socket io + # 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. + force_keep_alive = requests < @max_fast_inline || + @thread_pool.busy_threads < @max_threads || + !client.listener.to_io.wait_readable(0) + + resp_info = str_headers(env, status, headers, res_body, io_buffer, force_keep_alive) - str_headers(env, status, headers, res_info, lines, requests, client) + close_body = false + response_hijack = nil + content_length = resp_info[:content_length] + keep_alive = resp_info[:keep_alive] + + if res_body.respond_to?(:each) && !resp_info[:response_hijack] + # below converts app_body into body, dependent on app_body's characteristics, and + # content_length will be set if it can be determined + if !content_length && !resp_info[:transfer_encoding] && status != 204 + if res_body.respond_to?(:to_ary) && (array_body = res_body.to_ary) && + array_body.is_a?(Array) + body = array_body.compact + content_length = body.sum(&:bytesize) + elsif res_body.is_a?(File) && res_body.respond_to?(:size) + body = res_body + content_length = body.size + elsif res_body.respond_to?(:to_path) && File.readable?(fn = res_body.to_path) + body = File.open fn, 'rb' + content_length = body.size + close_body = true + else + body = res_body + end + elsif !res_body.is_a?(::File) && res_body.respond_to?(:to_path) && + File.readable?(fn = res_body.to_path) + body = File.open fn, 'rb' + content_length = body.size + close_body = true + elsif !res_body.is_a?(::File) && res_body.respond_to?(:filename) && + res_body.respond_to?(:bytesize) && File.readable?(fn = res_body.filename) + # Sprockets::Asset + content_length = res_body.bytesize unless content_length + if (body_str = res_body.to_hash[:source]) + body = [body_str] + else # avoid each and use a File object + body = File.open fn, 'rb' + close_body = true + end + else + body = res_body + end + else + # partial hijack, from Rack spec: + # Servers must ignore the body part of the response tuple when the + # rack.hijack response header is present. + response_hijack = resp_info[:response_hijack] || res_body + end - line_ending = LINE_END + line_ending = LINE_END - content_length = res_info[:content_length] - response_hijack = res_info[:response_hijack] + cork_socket socket - if res_info[:no_body] - if content_length and status != 204 - lines.append CONTENT_LENGTH_S, content_length.to_s, line_ending + if resp_info[:no_body] + # 101 (Switching Protocols) doesn't return here or have content_length, + # it should be using `response_hijack` + unless status == 101 + if content_length && status != 204 + io_buffer.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] + io_buffer << LINE_END + fast_write_str socket, io_buffer.read_and_reset + socket.flush + return keep_alive end - + else if content_length - lines.append CONTENT_LENGTH_S, content_length.to_s, line_ending + io_buffer.append CONTENT_LENGTH_S, content_length.to_s, line_ending chunked = false - elsif !response_hijack and res_info[:allow_chunked] - lines << TRANSFER_ENCODING_CHUNKED + elsif !response_hijack && resp_info[:allow_chunked] + io_buffer << TRANSFER_ENCODING_CHUNKED chunked = true end + 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 + io_buffer << line_ending - begin - after_reply.each { |o| o.call } - rescue StandardError => e - @log_writer.debug_error e - end + # partial hijack, we write headers, then hand the socket to the app via + # response_hijack.call + if response_hijack + fast_write_str socket, io_buffer.read_and_reset + uncork_socket socket + response_hijack.call socket + return :async end - res_info[:keep_alive] + fast_write_response socket, body, io_buffer, chunked, content_length.to_i + body.close if close_body + keep_alive end # @param env [Hash] see Puma::Client#env, from request @@ -199,45 +273,132 @@ end end - # Writes to an io (normally Client#io) using #syswrite - # @param io [#syswrite] the io to write to + # Used to write 'early hints', 'no body' responses, 'hijacked' responses, + # and body segments (called by `fast_write_response`). + # Writes a string to a socket (normally `Client#io`) using `write_nonblock`. + # Large strings may not be written in one pass, especially if `io` is a + # `MiniSSL::Socket`. + # @param socket [#write_nonblock] the request/response socket # @param str [String] the string written to the io # @raise [ConnectionError] # - def fast_write(io, str) + def fast_write_str(socket, str) n = 0 - while true + byte_size = str.bytesize + while n < byte_size begin - n = io.syswrite str + n += socket.write_nonblock(n.zero? ? str : str.byteslice(n..-1)) rescue Errno::EAGAIN, Errno::EWOULDBLOCK - unless io.wait_writable WRITE_TIMEOUT - raise ConnectionError, "Socket timeout writing data" + unless socket.wait_writable WRITE_TIMEOUT + raise ConnectionError, SOCKET_WRITE_ERR_MSG end - retry rescue Errno::EPIPE, SystemCallError, IOError - raise ConnectionError, "Socket timeout writing data" + raise ConnectionError, SOCKET_WRITE_ERR_MSG 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 + # Used to write headers and body. + # Writes to a socket (normally `Client#io`) using `#fast_write_str`. + # Accumulates `body` items into `io_buffer`, then writes to socket. + # @param socket [#write] the response socket + # @param body [Enumerable, File] the body object + # @param io_buffer [Puma::IOBuffer] contains headers + # @param chunked [Boolean] + # @paramn content_length [Integer + # @raise [ConnectionError] # - def fetch_status_code(status) - HTTP_STATUS_CODES.fetch(status) { 'CUSTOM' } + def fast_write_response(socket, body, io_buffer, chunked, content_length) + if body.is_a?(::File) && body.respond_to?(:read) + if chunked # would this ever happen? + while chunk = body.read(BODY_LEN_MAX) + io_buffer.append chunk.bytesize.to_s(16), LINE_END, chunk, LINE_END + end + fast_write_str socket, CLOSE_CHUNKED + else + if content_length <= IO_BODY_MAX + io_buffer.write body.read(content_length) + fast_write_str socket, io_buffer.read_and_reset + else + fast_write_str socket, io_buffer.read_and_reset + IO.copy_stream body, socket + end + end + elsif body.is_a?(::Array) && body.length == 1 + body_first = nil + # using body_first = body.first causes issues? + body.each { |str| body_first ||= str } + + if body_first.is_a?(::String) && body_first.bytesize < BODY_LEN_MAX + # smaller body, write to io_buffer first + io_buffer.write body_first + fast_write_str socket, io_buffer.read_and_reset + else + # large body, write both header & body to socket + fast_write_str socket, io_buffer.read_and_reset + fast_write_str socket, body_first + end + elsif body.is_a?(::Array) + # for array bodies, flush io_buffer to socket when size is greater than + # IO_BUFFER_LEN_MAX + if chunked + body.each do |part| + next if (byte_size = part.bytesize).zero? + io_buffer.append byte_size.to_s(16), LINE_END, part, LINE_END + if io_buffer.length > IO_BUFFER_LEN_MAX + fast_write_str socket, io_buffer.read_and_reset + end + end + io_buffer.write CLOSE_CHUNKED + else + body.each do |part| + next if part.bytesize.zero? + io_buffer.write part + if io_buffer.length > IO_BUFFER_LEN_MAX + fast_write_str socket, io_buffer.read_and_reset + end + end + end + # may write last body part for non-chunked, also headers if array is empty + fast_write_str(socket, io_buffer.read_and_reset) unless io_buffer.length.zero? + else + # for enum bodies + if chunked + empty_body = true + body.each do |part| + next if part.nil? || (byte_size = part.bytesize).zero? + empty_body = false + io_buffer.append byte_size.to_s(16), LINE_END, part, LINE_END + fast_write_str socket, io_buffer.read_and_reset + end + if empty_body + io_buffer << CLOSE_CHUNKED + fast_write_str socket, io_buffer.read_and_reset + else + fast_write_str socket, CLOSE_CHUNKED + end + else + fast_write_str socket, io_buffer.read_and_reset + body.each do |part| + next if part.bytesize.zero? + fast_write_str socket, part + end + end + end + socket.flush + rescue Errno::EAGAIN, Errno::EWOULDBLOCK + raise ConnectionError, SOCKET_WRITE_ERR_MSG + rescue Errno::EPIPE, SystemCallError, IOError + raise ConnectionError, SOCKET_WRITE_ERR_MSG end - private :fetch_status_code + + private :fast_write_str, :fast_write_response # 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] @@ -259,17 +420,19 @@ unless env[REQUEST_PATH] # it might be a dumbass full host request header - uri = URI.parse(env[REQUEST_URI]) + uri = begin + URI.parse(env[REQUEST_URI]) + rescue URI::InvalidURIError + raise Puma::HttpParserError + end 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] + env[PATH_INFO] = env[REQUEST_PATH].to_s # #to_s in case it's nil # From https://www.ietf.org/rfc/rfc3875 : # "Script authors should be aware that the REMOTE_ADDR and @@ -285,17 +448,31 @@ 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 + # peeraddr from the socket; default to unspec. + if client.peer_family == Socket::AF_INET6 + addr = UNSPECIFIED_IPV6 + else + addr = UNSPECIFIED_IPV4 + end end # Set unix socket addrs to localhost - addr = LOCALHOST_IP if addr.empty? + if addr.empty? + if client.peer_family == Socket::AF_INET6 + addr = LOCALHOST_IPV6 + else + addr = LOCALHOST_IPV4 + end + end env[REMOTE_ADDR] = addr end + + # The legacy HTTP_VERSION header can be sent as a client header. + # Rack v4 may remove using HTTP_VERSION. If so, remove this line. + env[HTTP_VERSION] = env[SERVER_PROTOCOL] end - # private :normalize_env + private :normalize_env # @param header_key [#to_s] # @return [Boolean] @@ -326,7 +503,7 @@ to_add = nil env.each do |k,v| - if k.start_with?("HTTP_") and k.include?(",") and k != "HTTP_TRANSFER,ENCODING" + if k.start_with?("HTTP_") && k.include?(",") && k != "HTTP_TRANSFER,ENCODING" if to_delete to_delete << k else @@ -354,7 +531,7 @@ # @version 5.0.3 # def str_early_hints(headers) - eh_str = "HTTP/1.1 103 Early Hints\r\n".dup + eh_str = +"" headers.each_pair do |k, vs| next if illegal_header_key?(k) @@ -363,74 +540,82 @@ next if illegal_header_value?(v) eh_str << "#{k}: #{v}\r\n" end - else + elsif !(vs.to_s.empty? || !illegal_header_value?(vs)) eh_str << "#{k}: #{vs}\r\n" end end - "#{eh_str}\r\n".freeze + eh_str.freeze end private :str_early_hints + # @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_STAT } + end + private :fetch_status_code + # 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] + # @param content_length [Integer,nil] content length if it can be determined from the + # response body + # @param io_buffer [Puma::IOBuffer] modified inn place + # @param force_keep_alive [Boolean] 'anded' with keep_alive, based on system + # status and `@max_fast_inline` + # @return [Hash] resp_info # @version 5.0.3 # - def str_headers(env, status, headers, res_info, lines, requests, client) + def str_headers(env, status, headers, res_body, io_buffer, force_keep_alive) + line_ending = LINE_END colon = COLON - http_11 = env[HTTP_VERSION] == HTTP_11 + resp_info = {} + resp_info[:no_body] = env[REQUEST_METHOD] == HEAD + + http_11 = env[SERVER_PROTOCOL] == HTTP_11 if http_11 - res_info[:allow_chunked] = true - res_info[:keep_alive] = env.fetch(HTTP_CONNECTION, "").downcase != CLOSE + resp_info[:allow_chunked] = true + resp_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 + io_buffer << HTTP_11_200 else - lines.append "HTTP/1.1 ", status.to_s, " ", - fetch_status_code(status), line_ending + io_buffer.append "#{HTTP_11} #{status} ", fetch_status_code(status), line_ending - res_info[:no_body] ||= status < 200 || STATUS_WITH_NO_ENTITY_BODY[status] + resp_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 + resp_info[:allow_chunked] = false + resp_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 + io_buffer << HTTP_10_200 else - lines.append "HTTP/1.0 ", status.to_s, " ", + io_buffer.append "HTTP/1.0 #{status} ", fetch_status_code(status), line_ending - res_info[:no_body] ||= status < 200 || STATUS_WITH_NO_ENTITY_BODY[status] + resp_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 + resp_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) + # see prepare_response + resp_info[:keep_alive] &&= force_keep_alive - res_info[:response_hijack] = nil + resp_info[:response_hijack] = nil headers.each do |k, vs| next if illegal_header_key?(k) @@ -438,25 +623,34 @@ case k.downcase when CONTENT_LENGTH2 next if illegal_header_value?(vs) - res_info[:content_length] = vs + # nil.to_i is 0, nil&.to_i is nil + resp_info[:content_length] = vs&.to_i next when TRANSFER_ENCODING - res_info[:allow_chunked] = false - res_info[:content_length] = nil + resp_info[:allow_chunked] = false + resp_info[:content_length] = nil + resp_info[:transfer_encoding] = vs when HIJACK - res_info[:response_hijack] = vs + resp_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| + ary = if vs.is_a?(::Array) && !vs.empty? + vs + elsif vs.respond_to?(:to_s) && !vs.to_s.empty? + vs.to_s.split NEWLINE + else + nil + end + if ary + ary.each do |v| next if illegal_header_value?(v) - lines.append k, colon, v, line_ending + io_buffer.append k, colon, v, line_ending end else - lines.append k, colon, line_ending + io_buffer.append k, colon, line_ending end end @@ -466,10 +660,11 @@ # 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] + io_buffer << CONNECTION_CLOSE if !resp_info[:keep_alive] else - lines << CONNECTION_KEEP_ALIVE if res_info[:keep_alive] + io_buffer << CONNECTION_KEEP_ALIVE if resp_info[:keep_alive] end + resp_info end private :str_headers end diff -Nru puma-5.6.5/lib/puma/runner.rb puma-6.4.2/lib/puma/runner.rb --- puma-5.6.5/lib/puma/runner.rb 2022-08-22 23:33:14.000000000 +0000 +++ puma-6.4.2/lib/puma/runner.rb 2024-01-08 05:53:42.000000000 +0000 @@ -1,23 +1,29 @@ # frozen_string_literal: true -require 'puma/server' -require 'puma/const' +require_relative 'server' +require_relative '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 + def initialize(launcher) + @launcher = launcher + @log_writer = launcher.log_writer + @events = launcher.events + @config = launcher.config + @options = launcher.options @app = nil @control = nil @started_at = Time.now @wakeup = nil end + # Returns the hash of configuration options. + # @return [Puma::UserFileDefaultOptions] + attr_reader :options + def wakeup! return unless @wakeup @@ -36,27 +42,27 @@ end def log(str) - @events.log str + @log_writer.log str end # @version 5.0.0 def stop_control - @control.stop(true) if @control + @control&.stop true end def error(str) - @events.error str + @log_writer.error str end def debug(str) - @events.log "- #{str}" if @options[:debug] + @log_writer.log "- #{str}" if @options[:debug] end def start_control str = @options[:control_url] return unless str - require 'puma/app/status' + require_relative 'app/status' if token = @options[:control_auth_token] token = nil if token.empty? || token == 'none' @@ -64,10 +70,16 @@ app = Puma::App::Status.new @launcher, token - control = Puma::Server.new app, @launcher.events, - { min_threads: 0, max_threads: 1, queue_requests: false } + # A Reactor is not created and nio4r is not loaded when 'queue_requests: false' + # Use `nil` for events, no hooks in control server + control = Puma::Server.new app, nil, + { min_threads: 0, max_threads: 1, queue_requests: false, log_writer: @log_writer } - control.binder.parse [str], self, 'Starting control server' + begin + control.binder.parse [str], nil, 'Starting control server' + rescue Errno::EADDRINUSE, Errno::EACCES => e + raise e, "Error: Control server address '#{str}' is already in use. Original error: #{e.message}" + end control.run thread_name: 'ctl' @control = control @@ -141,29 +153,29 @@ end def load_and_bind - unless @launcher.config.app_configured? + unless @config.app_configured? error "No application configured, nothing to run" exit 1 end begin - @app = @launcher.config.app + @app = @config.app rescue Exception => e log "! Unable to load application: #{e.class}: #{e.message}" raise e end - @launcher.binder.parse @options[:binds], self + @launcher.binder.parse @options[:binds] end # @!attribute [r] app def app - @app ||= @launcher.config.app + @app ||= @config.app end def start_server - server = Puma::Server.new app, @launcher.events, @options - server.inherit_binder @launcher.binder + server = Puma::Server.new(app, @events, @options) + server.inherit_binder(@launcher.binder) server end @@ -173,5 +185,29 @@ raise "Cannot redirect #{io_name} to #{path}" end end + + def utc_iso8601(val) + "#{val.utc.strftime '%FT%T'}Z" + end + + def stats + { + versions: { + puma: Puma::Const::PUMA_VERSION, + ruby: { + engine: RUBY_ENGINE, + version: RUBY_VERSION, + patchlevel: RUBY_PATCHLEVEL + } + } + } + end + + # this method call should always be guarded by `@log_writer.debug?` + def debug_loaded_extensions(str) + @log_writer.debug "────────────────────────────────── #{str}" + re_ext = /\.#{RbConfig::CONFIG['DLEXT']}\z/i + $LOADED_FEATURES.grep(re_ext).each { |f| @log_writer.debug(" #{f}") } + end end end diff -Nru puma-5.6.5/lib/puma/sd_notify.rb puma-6.4.2/lib/puma/sd_notify.rb --- puma-5.6.5/lib/puma/sd_notify.rb 1970-01-01 00:00:00.000000000 +0000 +++ puma-6.4.2/lib/puma/sd_notify.rb 2024-01-08 05:53:42.000000000 +0000 @@ -0,0 +1,149 @@ +# frozen_string_literal: true + +require "socket" + +module Puma + # The MIT License + # + # Copyright (c) 2017-2022 Agis Anastasopoulos + # + # Permission is hereby granted, free of charge, to any person obtaining a copy of + # this software and associated documentation files (the "Software"), to deal in + # the Software without restriction, including without limitation the rights to + # use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + # the Software, and to permit persons to whom the Software is furnished to do so, + # subject to the following conditions: + # + # The above copyright notice and this permission notice shall be included in all + # copies or substantial portions of the Software. + # + # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + # FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + # COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER + # IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN + # CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + # + # This is a copy of https://github.com/agis/ruby-sdnotify as of commit cca575c + # The only changes made was "rehoming" it within the Puma module to avoid + # namespace collisions and applying standard's code formatting style. + # + # SdNotify is a pure-Ruby implementation of sd_notify(3). It can be used to + # notify systemd about state changes. Methods of this package are no-op on + # non-systemd systems (eg. Darwin). + # + # The API maps closely to the original implementation of sd_notify(3), + # therefore be sure to check the official man pages prior to using SdNotify. + # + # @see https://www.freedesktop.org/software/systemd/man/sd_notify.html + module SdNotify + # Exception raised when there's an error writing to the notification socket + class NotifyError < RuntimeError; end + + READY = "READY=1" + RELOADING = "RELOADING=1" + STOPPING = "STOPPING=1" + STATUS = "STATUS=" + ERRNO = "ERRNO=" + MAINPID = "MAINPID=" + WATCHDOG = "WATCHDOG=1" + FDSTORE = "FDSTORE=1" + + def self.ready(unset_env=false) + notify(READY, unset_env) + end + + def self.reloading(unset_env=false) + notify(RELOADING, unset_env) + end + + def self.stopping(unset_env=false) + notify(STOPPING, unset_env) + end + + # @param status [String] a custom status string that describes the current + # state of the service + def self.status(status, unset_env=false) + notify("#{STATUS}#{status}", unset_env) + end + + # @param errno [Integer] + def self.errno(errno, unset_env=false) + notify("#{ERRNO}#{errno}", unset_env) + end + + # @param pid [Integer] + def self.mainpid(pid, unset_env=false) + notify("#{MAINPID}#{pid}", unset_env) + end + + def self.watchdog(unset_env=false) + notify(WATCHDOG, unset_env) + end + + def self.fdstore(unset_env=false) + notify(FDSTORE, unset_env) + end + + # @param [Boolean] true if the service manager expects watchdog keep-alive + # notification messages to be sent from this process. + # + # If the $WATCHDOG_USEC environment variable is set, + # and the $WATCHDOG_PID variable is unset or set to the PID of the current + # process + # + # @note Unlike sd_watchdog_enabled(3), this method does not mutate the + # environment. + def self.watchdog? + wd_usec = ENV["WATCHDOG_USEC"] + wd_pid = ENV["WATCHDOG_PID"] + + return false if !wd_usec + + begin + wd_usec = Integer(wd_usec) + rescue + return false + end + + return false if wd_usec <= 0 + return true if !wd_pid || wd_pid == $$.to_s + + false + end + + # Notify systemd with the provided state, via the notification socket, if + # any. + # + # Generally this method will be used indirectly through the other methods + # of the library. + # + # @param state [String] + # @param unset_env [Boolean] + # + # @return [Fixnum, nil] the number of bytes written to the notification + # socket or nil if there was no socket to report to (eg. the program wasn't + # started by systemd) + # + # @raise [NotifyError] if there was an error communicating with the systemd + # socket + # + # @see https://www.freedesktop.org/software/systemd/man/sd_notify.html + def self.notify(state, unset_env=false) + sock = ENV["NOTIFY_SOCKET"] + + return nil if !sock + + ENV.delete("NOTIFY_SOCKET") if unset_env + + begin + Addrinfo.unix(sock, :DGRAM).connect do |s| + s.close_on_exec = true + s.write(state) + end + rescue StandardError => e + raise NotifyError, "#{e.class}: #{e.message}", e.backtrace + end + end + end +end diff -Nru puma-5.6.5/lib/puma/server.rb puma-6.4.2/lib/puma/server.rb --- puma-5.6.5/lib/puma/server.rb 2022-08-22 23:33:14.000000000 +0000 +++ puma-6.4.2/lib/puma/server.rb 2024-01-08 05:53:42.000000000 +0000 @@ -2,20 +2,19 @@ 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_relative 'thread_pool' +require_relative 'const' +require_relative 'log_writer' +require_relative 'events' +require_relative 'null_io' +require_relative 'reactor' +require_relative 'client' +require_relative 'binder' +require_relative 'util' +require_relative 'request' require 'socket' -require 'io/wait' -require 'forwardable' +require 'io/wait' unless Puma::HAS_NATIVE_IO_WAIT module Puma @@ -30,39 +29,30 @@ # # 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 :log_writer attr_reader :events attr_reader :min_threads, :max_threads # for #stats attr_reader :requests_count # @version 5.0.0 - attr_reader :log_writer # to help with backports # @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 + THREAD_LOCAL_KEY = :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. + # +log_writer+ is a Puma::LogWriter object used to log info and error messages. + # + # +events+ is a Puma::Events object used to notify application status events. # # Server#run returns a thread that you can join on to wait for the server # to do its work. @@ -71,35 +61,56 @@ # 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={}) + # @note The `events` parameter is set to nil, and set to `Events.new` in code. + # Often `options` needs to be passed, but `events` does not. Using nil allows + # calling code to not require events.rb. + # + def initialize(app, events = nil, options = {}) @app = app - @events = events - @log_writer = events + @events = events || Events.new @check, @notify = nil @status = :stop - @auto_trim_time = 30 - @reaping_time = 1 - @thread = nil @thread_pool = nil - @options = options + @options = if options.is_a?(UserFileDefaultOptions) + options + else + UserFileDefaultOptions.new(options, Configuration::DEFAULTS) + end - @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 + @clustered = (@options.fetch :workers, 0) > 0 + @worker_write = @options[:worker_write] + @log_writer = @options.fetch :log_writer, LogWriter.stdio + @early_hints = @options[:early_hints] + @first_data_timeout = @options[:first_data_timeout] + @persistent_timeout = @options[:persistent_timeout] + @idle_timeout = @options[:idle_timeout] + @min_threads = @options[:min_threads] + @max_threads = @options[:max_threads] + @queue_requests = @options[:queue_requests] + @max_fast_inline = @options[:max_fast_inline] + @io_selector_backend = @options[:io_selector_backend] + @http_content_length_limit = @options[:http_content_length_limit] + + # make this a hash, since we prefer `key?` over `include?` + @supported_http_methods = + if @options[:supported_http_methods] == :any + :any + else + if (ary = @options[:supported_http_methods]) + ary + else + SUPPORTED_HTTP_METHODS + end.sort.product([nil]).to_h.freeze + end temp = !!(@options[:environment] =~ /\A(development|test)\z/) @leak_stack_on_error = @options[:environment] ? temp : true - @binder = Binder.new(events) + @binder = Binder.new(log_writer) ENV['RACK_ENV'] ||= "development" @@ -108,6 +119,8 @@ @precheck_closing = true @requests_count = 0 + + @idle_timeout_reached = false end def inherit_binder(bind) @@ -117,7 +130,7 @@ class << self # @!attribute [r] current def current - Thread.current[ThreadLocalKey] + Thread.current[THREAD_LOCAL_KEY] end # :nodoc: @@ -195,12 +208,12 @@ # @!attribute [r] backlog def backlog - @thread_pool and @thread_pool.backlog + @thread_pool&.backlog end # @!attribute [r] running def running - @thread_pool and @thread_pool.spawned + @thread_pool&.spawned end @@ -213,7 +226,7 @@ # value would be 4 until it finishes processing. # @!attribute [r] pool_capacity def pool_capacity - @thread_pool and @thread_pool.pool_capacity + @thread_pool&.pool_capacity end # Runs the server. @@ -229,29 +242,16 @@ @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] + @thread_pool = ThreadPool.new(thread_name, @options) { |client| process_client client } if @queue_requests - @reactor = Reactor.new(@io_selector_backend, &method(:reactor_wakeup)) + @reactor = Reactor.new(@io_selector_backend) { |c| reactor_wakeup c } @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 + @thread_pool.auto_reap! if @options[:reaping_time] + @thread_pool.auto_trim! if @options[:auto_trim_time] @check, @notify = Puma::Util.pipe unless @notify @@ -330,8 +330,28 @@ while @status == :run || (drain && shutting_down?) begin - ios = IO.select sockets, nil, nil, (shutting_down? ? 0 : nil) - break unless ios + ios = IO.select sockets, nil, nil, (shutting_down? ? 0 : @idle_timeout) + unless ios + unless shutting_down? + @idle_timeout_reached = true + + if @clustered + @worker_write << "i#{Process.pid}\n" rescue nil + next + else + @log_writer.log "- Idle timeout reached" + @status = :stop + end + end + + break + end + + if @idle_timeout_reached && @clustered + @idle_timeout_reached = false + @worker_write << "i#{Process.pid}\n" rescue nil + end + ios.first.each do |sock| if sock == check break if handle_check @@ -347,6 +367,7 @@ drain += 1 if shutting_down? pool << Client.new(io, @binder.env(sock)).tap { |c| c.listener = sock + c.http_content_length_limit = @http_content_length_limit c.send(addr_send_name, addr_value) if addr_value } end @@ -355,27 +376,27 @@ # In the case that any of the sockets are unexpectedly close. raise rescue StandardError => e - @events.unknown_error e, nil, "Listen loop" + @log_writer.unknown_error e, nil, "Listen loop" end end - @events.debug "Drained #{drain} additional connections." if drain + @log_writer.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" + @log_writer.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 + rescue Errno::EBADF end end @notify = nil @@ -414,9 +435,9 @@ # returning. # # Return true if one or more requests were processed. - def process_client(client, buffer) + def process_client(client) # Advertise this server into the thread - Thread.current[ThreadLocalKey] = self + Thread.current[THREAD_LOCAL_KEY] = self clean_thread_locals = @options[:clean_thread_locals] close_socket = true @@ -440,15 +461,13 @@ while true @requests_count += 1 - case handle_request(client, buffer, requests + 1) + case handle_request(client, 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 @@ -478,11 +497,11 @@ end true rescue StandardError => e - client_error(e, client) + client_error(e, client, requests) # The ensure tries to close +client+ down requests > 0 ensure - buffer.reset + client.io_buffer.reset begin client.close if close_socket @@ -490,7 +509,7 @@ Puma::Util.purge_interrupt_queue # Already closed rescue StandardError => e - @events.unknown_error e, nil, "Client" + @log_writer.unknown_error e, nil, "Client" end end end @@ -506,23 +525,23 @@ # :nocov: # Handle various error types thrown by Client I/O operations. - def client_error(e, client) + def client_error(e, client, requests = 1) # 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 + lowlevel_error(e, client.env) + @log_writer.ssl_error e, client.io when HttpParserError - client.write_error(400) - @events.parse_error e, client + response_to_error(client, requests, e, 400) + @log_writer.parse_error e, client when HttpParserError501 - client.write_error(501) - @events.parse_error e, client + response_to_error(client, requests, e, 501) + @log_writer.parse_error e, client else - client.write_error(500) - @events.unknown_error e, nil, "Read" + response_to_error(client, requests, e, 500) + @log_writer.unknown_error e, nil, "Read" end end @@ -543,10 +562,17 @@ 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"]] + [status, {}, [""]] end end + def response_to_error(client, requests, err, status_code) + status, headers, res_body = lowlevel_error(err, client.env, status_code) + prepare_response(status, headers, res_body, requests, client) + client.write_error(status_code) + end + private :response_to_error + # Wait for all outstanding requests to finish. # def graceful_shutdown @@ -580,7 +606,7 @@ def notify_safely(message) @notify << message - rescue IOError, NoMethodError, Errno::EPIPE + rescue IOError, NoMethodError, Errno::EPIPE, Errno::EBADF # The server, in another thread, is shutting down Puma::Util.purge_interrupt_queue rescue RuntimeError => e @@ -625,5 +651,27 @@ def stats STAT_METHODS.map {|name| [name, send(name) || 0]}.to_h end + + # below are 'delegations' to binder + # remove in Puma 7? + + + def add_tcp_listener(host, port, optimize_for_latency = true, backlog = 1024) + @binder.add_tcp_listener host, port, optimize_for_latency, backlog + end + + def add_ssl_listener(host, port, ctx, optimize_for_latency = true, + backlog = 1024) + @binder.add_ssl_listener host, port, ctx, optimize_for_latency, backlog + end + + def add_unix_listener(path, umask = nil, mode = nil, backlog = 1024) + @binder.add_unix_listener path, umask, mode, backlog + end + + # @!attribute [r] connected_ports + def connected_ports + @binder.connected_ports + end end end diff -Nru puma-5.6.5/lib/puma/single.rb puma-6.4.2/lib/puma/single.rb --- puma-5.6.5/lib/puma/single.rb 2022-08-22 23:33:14.000000000 +0000 +++ puma-6.4.2/lib/puma/single.rb 2024-01-08 05:53:42.000000000 +0000 @@ -1,8 +1,8 @@ # frozen_string_literal: true -require 'puma/runner' -require 'puma/detect' -require 'puma/plugin' +require_relative 'runner' +require_relative 'detect' +require_relative 'plugin' module Puma # This class is instantiated by the `Puma::Launcher` and used @@ -16,26 +16,26 @@ # @!attribute [r] stats def stats { - started_at: @started_at.utc.iso8601 - }.merge(@server.stats) + started_at: utc_iso8601(@started_at) + }.merge(@server.stats).merge(super) end def restart - @server.begin_restart + @server&.begin_restart end def stop - @server.stop(false) if @server + @server&.stop false end def halt - @server.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 + @control&.stop true + @server&.stop true end def run @@ -55,7 +55,9 @@ log "Use Ctrl-C to stop" redirect_io - @launcher.events.fire_on_booted! + @events.fire_on_booted! + + debug_loaded_extensions("Loaded Extensions:") if @log_writer.debug? begin server_thread.join diff -Nru puma-5.6.5/lib/puma/state_file.rb puma-6.4.2/lib/puma/state_file.rb --- puma-5.6.5/lib/puma/state_file.rb 2022-08-22 23:33:14.000000000 +0000 +++ puma-6.4.2/lib/puma/state_file.rb 2024-01-08 05:53:42.000000000 +0000 @@ -15,15 +15,12 @@ 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 + contents = +"---\n" @options.each do |k,v| next unless ALLOWED_FIELDS.include? k case v @@ -59,11 +56,11 @@ end ALLOWED_FIELDS.each do |f| - define_method f do + define_method f.to_sym do @options[f] end - define_method "#{f}=" do |v| + define_method :"#{f}=" do |v| @options[f] = v end end diff -Nru puma-5.6.5/lib/puma/systemd.rb puma-6.4.2/lib/puma/systemd.rb --- puma-5.6.5/lib/puma/systemd.rb 2022-08-22 23:33:14.000000000 +0000 +++ puma-6.4.2/lib/puma/systemd.rb 1970-01-01 00:00:00.000000000 +0000 @@ -1,46 +0,0 @@ -# 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 -Nru puma-5.6.5/lib/puma/thread_pool.rb puma-6.4.2/lib/puma/thread_pool.rb --- puma-5.6.5/lib/puma/thread_pool.rb 2022-08-22 23:33:14.000000000 +0000 +++ puma-6.4.2/lib/puma/thread_pool.rb 2024-01-08 05:53:42.000000000 +0000 @@ -2,6 +2,8 @@ require 'thread' +require_relative 'io_buffer' + module Puma # Internal Docs for A simple thread pool management object. # @@ -29,7 +31,7 @@ # The block passed is the work that will be performed in each # thread. # - def initialize(name, min, max, *extra, &block) + def initialize(name, options = {}, &block) @not_empty = ConditionVariable.new @not_full = ConditionVariable.new @mutex = Mutex.new @@ -40,10 +42,19 @@ @waiting = 0 @name = name - @min = Integer(min) - @max = Integer(max) + @min = Integer(options[:min_threads]) + @max = Integer(options[:max_threads]) + # Not an 'exposed' option, options[:pool_shutdown_grace_time] is used in CI + # to shorten @shutdown_grace_time from SHUTDOWN_GRACE_TIME. Parallel CI + # makes stubbing constants difficult. + @shutdown_grace_time = Float(options[:pool_shutdown_grace_time] || SHUTDOWN_GRACE_TIME) @block = block - @extra = extra + @out_of_band = options[:out_of_band] + @clean_thread_locals = options[:clean_thread_locals] + @before_thread_start = options[:before_thread_start] + @before_thread_exit = options[:before_thread_exit] + @reaping_time = options[:reaping_time] + @auto_trim_time = options[:auto_trim_time] @shutdown = false @@ -62,14 +73,11 @@ 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 @@ -101,6 +109,7 @@ def spawn_thread @spawned += 1 + trigger_before_thread_start_hooks th = Thread.new(@spawned) do |spawned| Puma.set_thread_name '%s tp %03i' % [@name, spawned] todo = @todo @@ -109,8 +118,6 @@ not_empty = @not_empty not_full = @not_full - extra = @extra.map { |i| i.new } - while true work = nil @@ -121,6 +128,7 @@ @spawned -= 1 @workers.delete th not_full.signal + trigger_before_thread_exit_hooks Thread.exit end @@ -144,7 +152,7 @@ end begin - @out_of_band_pending = true if block.call(work, *extra) + @out_of_band_pending = true if block.call(work) rescue Exception => e STDERR.puts "Error reached top of thread-pool: #{e.message} (#{e.class})" end @@ -158,14 +166,44 @@ private :spawn_thread + def trigger_before_thread_start_hooks + return unless @before_thread_start&.any? + + @before_thread_start.each do |b| + begin + b.call + rescue Exception => e + STDERR.puts "WARNING before_thread_start hook failed with exception (#{e.class}) #{e.message}" + end + end + nil + end + + private :trigger_before_thread_start_hooks + + def trigger_before_thread_exit_hooks + return unless @before_thread_exit&.any? + + @before_thread_exit.each do |b| + begin + b.call + rescue Exception => e + STDERR.puts "WARNING before_thread_exit hook failed with exception (#{e.class}) #{e.message}" + end + end + nil + end + + private :trigger_before_thread_exit_hooks + # @version 5.0.0 def trigger_out_of_band_hook - return false unless out_of_band_hook && out_of_band_hook.any? + return false unless @out_of_band&.any? # we execute on idle hook when all threads are free return false unless @spawned == @waiting - out_of_band_hook.each(&:call) + @out_of_band.each(&:call) true rescue Exception => e STDERR.puts "Exception calling out_of_band_hook: #{e.message} (#{e.class})" @@ -319,12 +357,12 @@ end end - def auto_trim!(timeout=30) + def auto_trim!(timeout=@auto_trim_time) @auto_trim = Automaton.new(self, timeout, "#{@name} threadpool trimmer", :trim) @auto_trim.start! end - def auto_reap!(timeout=5) + def auto_reap!(timeout=@reaping_time) @reaper = Automaton.new(self, timeout, "#{@name} threadpool reaper", :reap) @reaper.start! end @@ -344,8 +382,8 @@ # 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. + # Next, wait an extra +@shutdown_grace_time+ seconds then force-kill remaining + # threads. Finally, wait 1 second for remaining threads to exit. # def shutdown(timeout=-1) threads = with_mutex do @@ -354,8 +392,8 @@ @not_empty.broadcast @not_full.broadcast - @auto_trim.stop if @auto_trim - @reaper.stop if @reaper + @auto_trim&.stop + @reaper&.stop # dup workers so that we join them all safely @workers.dup end @@ -382,7 +420,7 @@ t.raise ForceShutdown if t[:with_force_shutdown] end end - join.call(SHUTDOWN_GRACE_TIME) + join.call(@shutdown_grace_time) # If threads are _still_ running, forcefully kill them and wait to finish. threads.each(&:kill) diff -Nru puma-5.6.5/lib/puma/util.rb puma-6.4.2/lib/puma/util.rb --- puma-5.6.5/lib/puma/util.rb 2022-08-22 23:33:14.000000000 +0000 +++ puma-6.4.2/lib/puma/util.rb 2024-01-08 05:53:42.000000000 +0000 @@ -39,17 +39,6 @@ end module_function :unescape, :escape - # @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: diff -Nru puma-5.6.5/lib/puma.rb puma-6.4.2/lib/puma.rb --- puma-5.6.5/lib/puma.rb 2022-08-22 23:33:14.000000000 +0000 +++ puma-6.4.2/lib/puma.rb 2024-01-08 05:53:42.000000000 +0000 @@ -3,30 +3,33 @@ # Standard libraries require 'socket' require 'tempfile' -require 'time' -require 'etc' require 'uri' require 'stringio' require 'thread' +# use require, see https://github.com/puma/puma/pull/2381 require 'puma/puma_http11' -require 'puma/detect' -require 'puma/json_serialization' + +require_relative 'puma/detect' +require_relative 'puma/json_serialization' module Puma - autoload :Const, 'puma/const' - autoload :Server, 'puma/server' - autoload :Launcher, 'puma/launcher' + # when Puma is loaded via `Puma::CLI`, all files are loaded via + # `require_relative`. The below are for non-standard loading + autoload :Const, "#{__dir__}/puma/const" + autoload :Server, "#{__dir__}/puma/server" + autoload :Launcher, "#{__dir__}/puma/launcher" + autoload :LogWriter, "#{__dir__}/puma/log_writer" # 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 + HAS_UNIX_SOCKET = Object.const_defined?(:UNIXSocket) && !IS_WINDOWS if HAS_SSL - require 'puma/minissl' + require_relative 'puma/minissl' else module MiniSSL # this class is defined so that it exists when Puma is compiled @@ -69,9 +72,7 @@ @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 -Nru puma-5.6.5/lib/rack/handler/puma.rb puma-6.4.2/lib/rack/handler/puma.rb --- puma-5.6.5/lib/rack/handler/puma.rb 2022-08-22 23:33:14.000000000 +0000 +++ puma-6.4.2/lib/rack/handler/puma.rb 2024-01-08 05:53:42.000000000 +0000 @@ -1,114 +1,141 @@ # 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 +# This module is used as an 'include' file in code at bottom of file +module Puma + module RackHandler + DEFAULT_OPTIONS = { + :Verbose => false, + :Silent => false + } + + def config(app, options = {}) + require_relative '../../puma' + require_relative '../../puma/configuration' + require_relative '../../puma/log_writer' + require_relative '../../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 + @events = options[:events] || ::Puma::Events.new - if options[:environment] - user_config.environment options[:environment] + conf = ::Puma::Configuration.new(options, default_options.merge({events: @events})) do |user_config, file_config, default_config| + if options.delete(:Verbose) + begin + require 'rack/commonlogger' # Rack 1.x + rescue LoadError + require 'rack/common_logger' # Rack 2 and later end + app = ::Rack::CommonLogger.new(app, STDOUT) + end - if options[:Threads] - min, max = options.delete(:Threads).split(':', 2) - user_config.threads min, max - end + if options[:environment] + user_config.environment options[:environment] + 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 options[:Threads] + min, max = options.delete(:Threads).split(':', 2) + user_config.threads min, max + 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) + 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 - user_config.app app + if default_options[:Host] + file_config.set_default_host(default_options[:Host]) end - conf + 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) + def run(app, **options) + conf = self.config(app, options) - events = options.delete(:Silent) ? ::Puma::Events.strings : ::Puma::Events.stdio + log_writer = options.delete(:Silent) ? ::Puma::LogWriter.strings : ::Puma::LogWriter.stdio - launcher = ::Puma::Launcher.new(conf, :events => events) + launcher = ::Puma::Launcher.new(conf, :log_writer => log_writer, 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 + 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 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 + def 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 && (host[0,1] == '.' || host[0,1] == '/') + config.bind "unix://#{host}" + elsif host && host =~ /^ssl:\/\// + uri = URI.parse(host) + uri.port ||= port || ::Puma::Configuration::DEFAULTS[:tcp_port] + config.bind uri.to_s + else - if host - port ||= ::Puma::Configuration::DefaultTCPPort - end + if host + port ||= ::Puma::Configuration::DEFAULTS[:tcp_port] + end - if port - host ||= ::Puma::Configuration::DefaultTCPHost - config.port port, host - end + if port + host ||= ::Puma::Configuration::DEFAULTS[:tcp_host] + config.port port, host end end end + end +end - register :puma, Puma +# rackup was removed in Rack 3, it is now a separate gem +if Object.const_defined? :Rackup + module Rackup + module Handler + module Puma + class << self + include ::Puma::RackHandler + end + end + register :puma, Puma + end + end +else + do_register = Object.const_defined?(:Rack) && Rack.release < '3' + module Rack + module Handler + module Puma + class << self + include ::Puma::RackHandler + end + end + end end + ::Rack::Handler.register(:puma, ::Rack::Handler::Puma) if do_register end diff -Nru puma-5.6.5/puma.gemspec puma-6.4.2/puma.gemspec --- puma-5.6.5/puma.gemspec 2022-08-22 23:33:14.000000000 +0000 +++ puma-6.4.2/puma.gemspec 2024-01-08 05:53:42.000000000 +0000 @@ -22,10 +22,11 @@ "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" + "source_code_uri" => "https://github.com/puma/puma", + "rubygems_mfa_required" => "true" } end s.license = "BSD-3-Clause" - s.required_ruby_version = Gem::Requirement.new(">= 2.2") + s.required_ruby_version = Gem::Requirement.new(">= 2.4") end diff -Nru puma-5.6.5/test/config/app.rb puma-6.4.2/test/config/app.rb --- puma-5.6.5/test/config/app.rb 2022-08-22 23:33:14.000000000 +0000 +++ puma-6.4.2/test/config/app.rb 2024-01-08 05:53:42.000000000 +0000 @@ -1,4 +1,4 @@ -port ENV['PORT'] if ENV['PORT'] +port ENV.fetch('PORT', 0) app do |env| [200, {}, ["embedded app"]] diff -Nru puma-5.6.5/test/config/cpu_spin.rb puma-6.4.2/test/config/cpu_spin.rb --- puma-5.6.5/test/config/cpu_spin.rb 2022-08-22 23:33:14.000000000 +0000 +++ puma-6.4.2/test/config/cpu_spin.rb 2024-01-08 05:53:42.000000000 +0000 @@ -4,7 +4,7 @@ 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 +wait_for_less_busy_worker ENV.fetch('PUMA_WAIT_FOR_LESS_BUSY_WORKERS', '0.005').to_f app do |env| iterations = (env['REQUEST_PATH'][/\/cpu\/(\d.*)/,1] || '1000').to_i diff -Nru puma-5.6.5/test/config/custom_logger.rb puma-6.4.2/test/config/custom_logger.rb --- puma-5.6.5/test/config/custom_logger.rb 1970-01-01 00:00:00.000000000 +0000 +++ puma-6.4.2/test/config/custom_logger.rb 2024-01-08 05:53:42.000000000 +0000 @@ -0,0 +1,13 @@ +class CustomLogger + def initialize(output=STDOUT) + @output = output + end + + def write(msg) + @output.puts 'Custom logging: ' + msg + @output.flush + end +end + +log_requests +custom_logger CustomLogger.new(STDOUT) diff -Nru puma-5.6.5/test/config/event_on_booted.rb puma-6.4.2/test/config/event_on_booted.rb --- puma-5.6.5/test/config/event_on_booted.rb 1970-01-01 00:00:00.000000000 +0000 +++ puma-6.4.2/test/config/event_on_booted.rb 2024-01-08 05:53:42.000000000 +0000 @@ -0,0 +1,3 @@ +on_booted do + puts "on_booted called" +end diff -Nru puma-5.6.5/test/config/event_on_booted_exit.rb puma-6.4.2/test/config/event_on_booted_exit.rb --- puma-5.6.5/test/config/event_on_booted_exit.rb 1970-01-01 00:00:00.000000000 +0000 +++ puma-6.4.2/test/config/event_on_booted_exit.rb 2024-01-08 05:53:42.000000000 +0000 @@ -0,0 +1,12 @@ +on_booted do + pid = Process.pid + begin + Process.kill :TERM, pid + rescue Errno::ESRCH + end + + begin + Process.wait2 pid + rescue Errno::ECHILD + end +end diff -Nru puma-5.6.5/test/config/hook_data.rb puma-6.4.2/test/config/hook_data.rb --- puma-5.6.5/test/config/hook_data.rb 1970-01-01 00:00:00.000000000 +0000 +++ puma-6.4.2/test/config/hook_data.rb 2024-01-08 05:53:42.000000000 +0000 @@ -0,0 +1,9 @@ +workers 2 + +on_worker_boot(:test) do |index, data| + data[:test] = index +end + +on_worker_shutdown(:test) do |index, data| + File.write "hook_data-#{index}.txt", "index #{index} data #{data[:test]}", mode: 'wb:UTF-8' +end diff -Nru puma-5.6.5/test/config/plugin1.rb puma-6.4.2/test/config/plugin1.rb --- puma-5.6.5/test/config/plugin1.rb 2022-08-22 23:33:14.000000000 +0000 +++ puma-6.4.2/test/config/plugin1.rb 1970-01-01 00:00:00.000000000 +0000 @@ -1 +0,0 @@ -plugin 'tmp_restart' diff -Nru puma-5.6.5/test/config/prune_bundler_with_deps.rb puma-6.4.2/test/config/prune_bundler_with_deps.rb --- puma-5.6.5/test/config/prune_bundler_with_deps.rb 2022-08-22 23:33:14.000000000 +0000 +++ puma-6.4.2/test/config/prune_bundler_with_deps.rb 2024-01-08 05:53:42.000000000 +0000 @@ -1,5 +1,5 @@ prune_bundler true -extra_runtime_dependencies ["rdoc"] +extra_runtime_dependencies ["minitest"] before_fork do $LOAD_PATH.each do |path| puts "LOAD_PATH: #{path}" diff -Nru puma-5.6.5/test/config/rack_url_scheme.rb puma-6.4.2/test/config/rack_url_scheme.rb --- puma-5.6.5/test/config/rack_url_scheme.rb 1970-01-01 00:00:00.000000000 +0000 +++ puma-6.4.2/test/config/rack_url_scheme.rb 2024-01-08 05:53:42.000000000 +0000 @@ -0,0 +1 @@ +rack_url_scheme "https" diff -Nru puma-5.6.5/test/helper.rb puma-6.4.2/test/helper.rb --- puma-5.6.5/test/helper.rb 2022-08-22 23:33:14.000000000 +0000 +++ puma-6.4.2/test/helper.rb 2024-01-08 05:53:42.000000000 +0000 @@ -2,7 +2,7 @@ # 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 +if RUBY_VERSION == '2.4.1' begin require 'stopgap_13632' rescue LoadError @@ -11,6 +11,8 @@ end end +require "securerandom" + require_relative "minitest/verbose" require "minitest/autorun" require "minitest/pride" @@ -21,8 +23,8 @@ Thread.abort_on_exception = true -$debugging_info = ''.dup -$debugging_hold = false # needed for TestCLI#test_control_clustered +$debugging_info = [] +$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 @@ -30,11 +32,15 @@ require "puma" require "puma/detect" +unless ::Puma::HAS_NATIVE_IO_WAIT + require "io/wait" +end + # 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 + require 'puma/log_writer' + class SSLLogWriterHelper < ::Puma::LogWriter attr_accessor :addr, :cert, :error def ssl_error(error, ssl_socket) @@ -63,8 +69,8 @@ end module UniquePort - def self.call - TCPServer.open('127.0.0.1', 0) do |server| + def self.call(host = '127.0.0.1') + TCPServer.open(host, 0) do |server| server.connect_address.ip_port end end @@ -72,7 +78,7 @@ require "timeout" module TimeoutEveryTestCase - # our own subclass so we never confused different timeouts + # our own subclass so we never confuse different timeouts class TestTookTooLong < Timeout::Error end @@ -102,15 +108,41 @@ end Minitest::Test.prepend TimeoutEveryTestCase + if ENV['CI'] require 'minitest/retry' + + SUMMARY_FILE = ENV['GITHUB_STEP_SUMMARY'] + Minitest::Retry.use! + + if SUMMARY_FILE && ENV['GITHUB_ACTIONS'] == 'true' + + GITHUB_STEP_SUMMARY_MUTEX = Mutex.new + + Minitest::Retry.on_failure do |klass, test_name, result| + full_method = "#{klass}##{test_name}" + result_str = result.to_s.gsub(/#{full_method}:?\s*/, '').dup + result_str.gsub!(/\A(Failure:|Error:)\s/, '\1 ') + issue = result_str[/\A[^\n]+/] + result_str.gsub!(issue, '') + # shorten directory lists + result_str.gsub! ENV['GITHUB_WORKSPACE'], 'puma' + result_str.gsub! ENV['RUNNER_TOOL_CACHE'], '' + # remove indent + result_str.gsub!(/^ +/, '') + str = "\n**#{full_method}**\n**#{issue}**\n```\n#{result_str.strip}\n```\n" + GITHUB_STEP_SUMMARY_MUTEX.synchronize { + File.write SUMMARY_FILE, str, mode: 'a+' + } + end + end end module TestSkips HAS_FORK = ::Process.respond_to? :fork - UNIX_SKT_EXIST = Object.const_defined? :UNIXSocket + UNIX_SKT_EXIST = Object.const_defined?(:UNIXSocket) && !Puma::IS_WINDOWS MSG_FORK = "Kernel.fork isn't available on #{RUBY_ENGINE} on #{RUBY_PLATFORM}" MSG_UNIX = "UNIXSockets aren't available on the #{RUBY_PLATFORM} platform" @@ -118,11 +150,12 @@ SIGNAL_LIST = Signal.list.keys.map(&:to_sym) - (Puma.windows? ? [:INT, :TERM] : []) - JRUBY_HEAD = Puma::IS_JRUBY && RUBY_DESCRIPTION =~ /SNAPSHOT/ + JRUBY_HEAD = Puma::IS_JRUBY && RUBY_DESCRIPTION.include?('SNAPSHOT') DARWIN = RUBY_PLATFORM.include? 'darwin' TRUFFLE = RUBY_ENGINE == 'truffleruby' + TRUFFLE_HEAD = TRUFFLE && RUBY_DESCRIPTION.include?('-dev-') # usage: skip_unless_signal_exist? :USR2 def skip_unless_signal_exist?(sig, bt: caller) @@ -138,6 +171,7 @@ def skip_if(*engs, suffix: '', bt: caller) engs.each do |eng| skip_msg = case eng + when :linux then "Skipped if Linux#{suffix}" if Puma::IS_LINUX 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 @@ -148,6 +182,7 @@ 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? + when :rack3 then "Skipped if Rack 3.x" if Rack.release >= '3' else false end skip skip_msg, bt if skip_msg @@ -157,6 +192,7 @@ # called with only one param def skip_unless(eng, bt: caller) skip_msg = case eng + when :linux then "Skip unless Linux" unless Puma::IS_LINUX 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 @@ -165,6 +201,7 @@ 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? + when :rack3 then "Skipped unless Rack >= 3.x" unless ::Rack.release >= '3' else false end skip skip_msg, bt if skip_msg @@ -175,7 +212,7 @@ class Minitest::Test - REPO_NAME = ENV['GITHUB_REPOSITORY'] ? ENV['GITHUB_REPOSITORY'][/[^\/]+\z/] : 'puma' + PROJECT_ROOT = File.dirname(__dir__) def self.run(reporter, options = {}) # :nodoc: prove_it! @@ -189,8 +226,9 @@ Minitest.after_run do # needed for TestCLI#test_control_clustered - unless $debugging_hold - out = $debugging_info.strip + if !$debugging_hold && ENV['PUMA_TEST_DEBUG'] + $debugging_info.sort! + out = $debugging_info.join.strip unless out.empty? dash = "\u2500" wid = ENV['GITHUB_ACTIONS'] ? 88 : 90 @@ -206,13 +244,18 @@ module AggregatedResults def aggregated_results(io) + is_github_actions = ENV['GITHUB_ACTIONS'] == 'true' filtered_results = results.dup if options[:verbose] skips = filtered_results.select(&:skipped?) unless skips.empty? dash = "\u2500" - io.puts '', "Skips:" + if is_github_actions + puts "", "##[group]Skips:" + else + io.puts '', 'Skips:' + end hsh = skips.group_by { |f| f.failures.first.error.message } hsh_s = {} hsh.each { |k, ary| @@ -234,6 +277,7 @@ puts '' } } + puts '::[endgroup]' if is_github_actions end end @@ -249,3 +293,16 @@ end end Minitest::SummaryReporter.prepend AggregatedResults + +module TestTempFile + require "tempfile" + def tempfile_create(basename, data, mode: File::BINARY) + fio = Tempfile.create(basename, mode: mode) + fio.write data + fio.flush + fio.rewind + @ios << fio + fio + end +end +Minitest::Test.include TestTempFile diff -Nru puma-5.6.5/test/helpers/integration.rb puma-6.4.2/test/helpers/integration.rb --- puma-5.6.5/test/helpers/integration.rb 2022-08-22 23:33:14.000000000 +0000 +++ puma-6.4.2/test/helpers/integration.rb 2024-01-08 05:53:42.000000000 +0000 @@ -3,25 +3,31 @@ 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" + # used in wait_for_server_to_* methods + LOG_TIMEOUT = Puma::IS_JRUBY ? 20 : 10 + LOG_WAIT_READ = Puma::IS_JRUBY ? 5 : 2 + LOG_ERROR_SLEEP = 0.2 + LOG_ERROR_QTY = 5 + BASE = defined?(Bundler) ? "bundle exec #{Gem.ruby} -Ilib" : "#{Gem.ruby} -Ilib" def setup @server = nil + @server_log = +'' + @pid = nil @ios_to_close = [] @bind_path = tmp_path('.sock') end @@ -33,9 +39,11 @@ 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? + @ios_to_close&.each do |io| + begin + io.close if io.respond_to?(:close) && !io.closed? + rescue + ensure io = nil end end @@ -47,8 +55,12 @@ # wait until the end for OS buffering? if @server - @server.close unless @server.closed? - @server = nil + begin + @server.close unless @server.closed? + rescue + ensure + @server = nil + end end end @@ -58,26 +70,45 @@ assert(system(*args, out: File::NULL, err: File::NULL)) end - def cli_server(argv, unix: false, config: nil, merge_err: false) + def cli_server(argv, # rubocop:disable Metrics/ParameterLists + unix: false, # uses a UNIXSocket for the server listener when true + config: nil, # string to use for config file + no_bind: nil, # bind is defined by args passed or config file + merge_err: false, # merge STDERR into STDOUT + log: false, # output server log to console (for debugging) + no_wait: false, # don't wait for server to boot + puma_debug: nil, # set env['PUMA_DEBUG'] = 'true' + env: {}) # pass env setting to Puma process in IO.popen + 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 + + cmd = + if no_bind + "#{BASE} #{puma_path} #{config} #{argv}" + elsif unix + "#{BASE} #{puma_path} #{config} -b unix://#{@bind_path} #{argv}" + else + @tcp_port = UniquePort.call + "#{BASE} #{puma_path} #{config} -b tcp://#{HOST}:#{@tcp_port} #{argv}" + end + + env['PUMA_DEBUG'] = 'true' if puma_debug + + STDOUT.syswrite "\n#{full_name}\n #{cmd}\n" if log + if merge_err - @server = IO.popen(cmd, "r", :err=>[:child, :out]) + @server = IO.popen(env, cmd, :err=>[:child, :out]) else - @server = IO.popen(cmd, "r") + @server = IO.popen(env, cmd) end - wait_for_server_to_boot + wait_for_server_to_boot(log: log) unless no_wait @pid = @server.pid @server end @@ -96,11 +127,11 @@ end end - def restart_server_and_listen(argv) + def restart_server_and_listen(argv, log: false) cli_server argv connection = connect initial_reply = read_body(connection) - restart_server connection + restart_server connection, log: log [initial_reply, read_body(connect)] end @@ -108,23 +139,62 @@ 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) + 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') + wait_for_server_to_include 'Ctrl-C', log: log + end + + # Returns true if and when server log includes str. Will timeout otherwise. + def wait_for_server_to_include(str, timeout: LOG_TIMEOUT, log: false) + time_timeout = Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout + line = '' + + puts "\n——— #{full_name} waiting for '#{str}'" if log + line = server_gets(str, time_timeout, log: log) until line&.include?(str) + true + end + + # Returns line if and when server log matches re, unless idx is specified, + # then returns regex match. Will timeout otherwise. + def wait_for_server_to_match(re, idx = nil, timeout: LOG_TIMEOUT, log: false) + time_timeout = Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout + line = '' + + puts "\n——— #{full_name} waiting for '#{re.inspect}'" if log + line = server_gets(re, time_timeout, log: log) until line&.match?(re) + idx ? line[re, idx] : line + end + + def server_gets(match_obj, time_timeout, log: false) + error_retries = 0 + line = '' + + sleep 0.05 unless @server.is_a?(IO) or Process.clock_gettime(Process::CLOCK_MONOTONIC) > time_timeout + + raise Minitest::Assertion, "@server is not an IO" unless @server.is_a?(IO) + if Process.clock_gettime(Process::CLOCK_MONOTONIC) > time_timeout + raise Minitest::Assertion, "Timeout waiting for server to log #{match_obj.inspect}" end + + begin + if @server.wait_readable(LOG_WAIT_READ) and line = @server&.gets + @server_log << line + puts " #{line}" if log + end + rescue StandardError => e + error_retries += 1 + raise(e, "Waiting for server to log #{match_obj.inspect}") if error_retries == LOG_ERROR_QTY + sleep LOG_ERROR_SLEEP + retry + end + if Process.clock_gettime(Process::CLOCK_MONOTONIC) > time_timeout + raise Minitest::Assertion, "Timeout waiting for server to log #{match_obj.inspect}" + end + line end def connect(path = nil, unix: false) @@ -149,7 +219,7 @@ begin n = io.syswrite str rescue Errno::EAGAIN, Errno::EWOULDBLOCK => e - if !IO.select(nil, [io], nil, 5) + unless io.wait_writable 5 raise e end @@ -171,7 +241,7 @@ timeout ||= RESP_READ_TIMEOUT content_length = nil chunked = nil - response = ''.dup + response = +'' t_st = Process.clock_gettime Process::CLOCK_MONOTONIC if connection.to_io.wait_readable timeout loop do @@ -219,11 +289,11 @@ end # gets worker pids from @server output - def get_worker_pids(phase = 0, size = workers) + def get_worker_pids(phase = 0, size = workers, log: false) pids = [] re = /PID: (\d+)\) booted in [.0-9]+s, phase: #{phase}/ while pids.size < size - if pid = @server.gets[re, 1] + if pid = wait_for_server_to_match(re, 1, log: log) pids << pid end end @@ -233,29 +303,61 @@ # used to define correct 'refused' errors def thread_run_refused(unix: false) if unix - DARWIN ? [Errno::ENOENT, Errno::EPIPE, IOError] : - [IOError, Errno::ENOENT] + DARWIN ? [IOError, Errno::ENOENT, Errno::EPIPE] : + [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] + DARWIN ? [IOError, Errno::ECONNREFUSED, Errno::EPIPE, Errno::EBADF, EOFError, Errno::ECONNABORTED] : + [IOError, Errno::ECONNREFUSED, Errno::EPIPE] end end - def cli_pumactl(argv, unix: false) + def set_pumactl_args(unix: false) + if unix + @control_path = tmp_path('.cntl_sock') + "--control-url unix://#{@control_path} --control-token #{TOKEN}" + else + @control_tcp_port = UniquePort.call + "--control-url tcp://#{HOST}:#{@control_tcp_port} --control-token #{TOKEN}" + end + end + + def cli_pumactl(argv, unix: false, no_bind: nil) arg = - if unix + if no_bind + argv.split(/ +/) + elsif 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 + Puma::ControlCLI.new(arg, w, w).run + w.close r end + def cli_pumactl_spawn(argv, unix: false, no_bind: nil) + arg = + if no_bind + argv + elsif unix + %Q[-C unix://#{@control_path} -T #{TOKEN} #{argv}] + else + %Q[-C tcp://#{HOST}:#{@control_tcp_port} -T #{TOKEN} #{argv}] + end + + pumactl_path = File.expand_path '../../../bin/pumactl', __FILE__ + + cmd = "#{BASE} #{pumactl_path} #{arg}" + + io = IO.popen(cmd, :err=>[:child, :out]) + @ios_to_close << io + io + end + def get_stats read_pipe = cli_pumactl "stats" JSON.parse(read_pipe.readlines.last) @@ -267,9 +369,8 @@ - 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" + args = "-w #{workers} -t 5: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}" @@ -292,8 +393,13 @@ 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}" + begin + socket = TCPSocket.new HOST, @tcp_port + fast_write socket, "POST / HTTP/1.1\r\nContent-Length: #{message.bytesize}\r\n\r\n#{message}" + rescue => e + replies[:write_error] += 1 + raise e + end body = read_body(socket, 10) if body == "Hello World" mutex.synchronize { @@ -329,7 +435,7 @@ run = true restart_thread = Thread.new do - sleep 0.30 # let some connections in before 1st restart + sleep 0.2 # let some connections in before 1st restart while run if Puma.windows? cli_pumactl 'restart' @@ -339,7 +445,7 @@ sleep 0.5 wait_for_server_to_boot restart_count += 1 - sleep(Puma.windows? ? 3.0 : 1.0) + sleep(Puma.windows? ? 2.0 : 0.5) end end @@ -349,8 +455,10 @@ if Puma.windows? cli_pumactl 'stop' Process.wait @server.pid - @server = nil + else + stop_server end + @server = nil msg = (" %4d unexpected_response\n" % replies.fetch(:unexpected_response,0)).dup msg << " %4d refused\n" % replies.fetch(:refused,0) @@ -360,22 +468,24 @@ msg << " %4d success after restart\n" % replies.fetch(:restart,0) msg << " %4d restart count\n" % restart_count - reset = replies[:reset] + refused = replies[:refused] + 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" + assert_operator 40, :>=, 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" + max_refused = (0.001 * replies.fetch(:success,0)).round + assert_operator max_refused, :>=, refused, "#{msg}Expected no than #{max_refused} 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] + assert_equal (num_threads * num_requests) - reset - refused, replies[:success] else assert_equal (num_threads * num_requests), replies[:success] end @@ -383,7 +493,7 @@ ensure return if skipped if passed? - msg = " restart_count #{restart_count}, reset #{reset}, success after restart #{replies[:restart]}" + msg = " #{restart_count} restarts, #{reset} resets, #{refused} refused, #{replies[:restart]} success after restart, #{replies[:write_error]} write error" $debugging_info << "#{full_name}\n#{msg}\n" else client_threads.each { |thr| thr.kill if thr.is_a? Thread } diff -Nru puma-5.6.5/test/helpers/test_puma/puma_socket.rb puma-6.4.2/test/helpers/test_puma/puma_socket.rb --- puma-5.6.5/test/helpers/test_puma/puma_socket.rb 1970-01-01 00:00:00.000000000 +0000 +++ puma-6.4.2/test/helpers/test_puma/puma_socket.rb 2024-01-08 05:53:42.000000000 +0000 @@ -0,0 +1,409 @@ +# frozen_string_literal: true + +require 'socket' +require_relative '../test_puma' +require_relative 'response' + +module TestPuma + + # @!macro [new] req + # @param req [String, GET_11] request path + + # @!macro [new] skt + # @param host: [String] tcp/ssl host + # @param port: [Integer/String] tcp/ssl port + # @param path: [String] unix socket, full path + # @param ctx: [OpenSSL::SSL::SSLContext] ssl context + # @param session: [OpenSSL::SSL::Session] ssl session + + # @!macro [new] resp + # @param timeout: [Float, nil] total socket read timeout, defaults to `RESP_READ_TIMEOUT` + # @param len: [ Integer, nil] the `read_nonblock` maxlen, defaults to `RESP_READ_LEN` + + # This module is included in CI test files, and provides methods to create + # client sockets. Normally, the socket parameters are defined by the code + # creating the Puma server (in-process or spawned), so they do not need to be + # specified. Regardless, many of the less frequently used parameters still + # have keyword arguments and they can be set to whatever is required. + # + # This module closes all sockets and performs all reads non-blocking and all + # writes using syswrite. These are helpful for reliable tests. Please do not + # use native Ruby sockets except if absolutely necessary. + # + # #### Methods that return a socket or sockets: + # * `new_socket` - Opens a socket + # * `send_http` - Opens a socket and sends a request, which defaults to `GET_11` + # * `send_http_array` - Creates an array of sockets. It opens each and sends a request on each + # + # All methods that create a socket have the following optional keyword parameters: + # * `host:` - tcp/ssl host (`String`) + # * `port:` - tcp/ssl port (`Integer`, `String`) + # * `path:` - unix socket, full path (`String`) + # * `ctx:` - ssl context (`OpenSSL::SSL::SSLContext`) + # * `session:` - ssl session (`OpenSSL::SSL::Session`) + # + # #### Methods that process the response: + # * `send_http_read_response` - sends a request and returns the whole response + # * `send_http_read_resp_body` - sends a request and returns the response body + # * `send_http_read_resp_headers` - sends a request and returns the response with the body removed as an array of lines + # + # All methods that process the response have the following optional keyword parameters: + # * `timeout:` - total socket read timeout, defaults to `RESP_READ_TIMEOUT` (`Float`) + # * `len:` - the `read_nonblock` maxlen, defaults to `RESP_READ_LEN` (`Integer`) + # + # #### Methods added to socket instances: + # * `read_response` - reads the response and returns it, uses `READ_RESPONSE` + # * `read_body` - reads the response and returns the body, uses `READ_BODY` + # * `<<` - overrides the standard method, writes to the socket with `syswrite`, returns the socket + # + module PumaSocket + GET_10 = "GET / HTTP/1.0\r\n\r\n" + GET_11 = "GET / HTTP/1.1\r\n\r\n" + + HELLO_11 = "HTTP/1.1 200 OK\r\ncontent-type: text/plain\r\n" \ + "Content-Length: 11\r\n\r\nHello World" + + RESP_READ_LEN = 65_536 + RESP_READ_TIMEOUT = 10 + NO_ENTITY_BODY = Puma::STATUS_WITH_NO_ENTITY_BODY + EMPTY_200 = [200, {}, ['']] + + UTF8 = ::Encoding::UTF_8 + + SET_TCP_NODELAY = Socket.const_defined?(:IPPROTO_TCP) && ::Socket.const_defined?(:TCP_NODELAY) + + def before_setup + @ios_to_close ||= [] + @bind_port = nil + @bind_path = nil + @control_port = nil + @control_path = nil + super + end + + # Closes all io's in `@ios_to_close`, also deletes them if they are files + def after_teardown + return if skipped? + super + # Errno::EBADF raised on macOS + @ios_to_close.each do |io| + begin + if io.respond_to? :sysclose + io.sync_close = true + io.sysclose unless io.closed? + else + io.close if io.respond_to?(:close) && !io.closed? + if io.is_a?(File) && (path = io&.path) && File.exist?(path) + File.unlink path + end + end + rescue Errno::EBADF, Errno::ENOENT, IOError + ensure + io = nil + end + end + # not sure about below, may help with gc... + @ios_to_close.clear + @ios_to_close = nil + end + + # rubocop: disable Metrics/ParameterLists + + # Sends a request and returns the response header lines as an array of strings. + # Includes the status line. + # @!macro req + # @!macro skt + # @!macro resp + # @return [Array] array of header lines in the response + def send_http_read_resp_headers(req = GET_11, host: nil, port: nil, path: nil, ctx: nil, + session: nil, len: nil, timeout: nil) + skt = send_http req, host: host, port: port, path: path, ctx: ctx, session: session + resp = skt.read_response timeout: timeout, len: len + resp.split(RESP_SPLIT, 2).first.split "\r\n" + end + + # Sends a request and returns the HTTP response body. + # @!macro req + # @!macro skt + # @!macro resp + # @return [Response] the body portion of the HTTP response + def send_http_read_resp_body(req = GET_11, host: nil, port: nil, path: nil, ctx: nil, + session: nil, len: nil, timeout: nil) + skt = send_http req, host: host, port: port, path: path, ctx: ctx, session: session + skt.read_body timeout: timeout, len: len + end + + # Sends a request and returns whatever can be read. Use when multiple + # responses are sent by the server + # @!macro req + # @!macro skt + # @return [String] socket read string + def send_http_read_all(req = GET_11, host: nil, port: nil, path: nil, ctx: nil, + session: nil, len: nil, timeout: nil) + skt = send_http req, host: host, port: port, path: path, ctx: ctx, session: session + read = String.new # rubocop: disable Performance/UnfreezeString + counter = 0 + prev_size = 0 + loop do + raise(Timeout::Error, 'Client Read Timeout') if counter > 5 + if skt.wait_readable 1 + read << skt.sysread(RESP_READ_LEN) + end + ttl_read = read.bytesize + return read if prev_size == ttl_read && !ttl_read.zero? + prev_size = ttl_read + counter += 1 + end + rescue EOFError + return read + rescue => e + raise e + end + + # Sends a request and returns the HTTP response. Assumes one response is sent + # @!macro req + # @!macro skt + # @!macro resp + # @return [Response] the HTTP response + def send_http_read_response(req = GET_11, host: nil, port: nil, path: nil, ctx: nil, + session: nil, len: nil, timeout: nil) + skt = send_http req, host: host, port: port, path: path, ctx: ctx, session: session + skt.read_response timeout: timeout, len: len + end + + # Sends a request and returns the socket + # @param req [String, nil] The request stirng. + # @!macro req + # @!macro skt + # @return [OpenSSL::SSL::SSLSocket, TCPSocket, UNIXSocket] the created socket + def send_http(req = GET_11, host: nil, port: nil, path: nil, ctx: nil, session: nil) + skt = new_socket host: host, port: port, path: path, ctx: ctx, session: session + skt.syswrite req + skt + end + + # Determines whether the socket has been closed by the server. Only works when + # `Socket::TCP_INFO is defined`, linux/Ubuntu + # @param socket [OpenSSL::SSL::SSLSocket, TCPSocket, UNIXSocket] + # @return [Boolean] true if closed by server, false is indeterminate, as + # it may not be writable + # + def skt_closed_by_server(socket) + skt = socket.to_io + return false unless skt.kind_of?(TCPSocket) + + begin + tcp_info = skt.getsockopt(Socket::IPPROTO_TCP, Socket::TCP_INFO) + rescue IOError, SystemCallError + false + else + state = tcp_info.unpack('C')[0] + # TIME_WAIT: 6, CLOSE: 7, CLOSE_WAIT: 8, LAST_ACK: 9, CLOSING: 11 + (state >= 6 && state <= 9) || state == 11 + end + end + + READ_BODY = -> (timeout: nil, len: nil) { + self.read_response(timeout: nil, len: nil) + .split(RESP_SPLIT, 2).last + } + + READ_RESPONSE = -> (timeout: nil, len: nil) do + content_length = nil + chunked = nil + status = nil + no_body = nil + response = Response.new + read_len = len || RESP_READ_LEN + + timeout ||= RESP_READ_TIMEOUT + time_start = Process.clock_gettime(Process::CLOCK_MONOTONIC) + time_end = time_start + timeout + times = [] + time_read = nil + + loop do + begin + self.to_io.wait_readable timeout + time_read ||= Process.clock_gettime(Process::CLOCK_MONOTONIC) + part = self.read_nonblock(read_len, exception: false) + case part + when String + times << (Process.clock_gettime(Process::CLOCK_MONOTONIC) - time_read).round(4) + status ||= part[/\AHTTP\/1\.[01] (\d{3})/, 1] + if status + no_body ||= NO_ENTITY_BODY.key? status.to_i || status.to_i < 200 + end + if no_body && part.end_with?(RESP_SPLIT) + response.times = times + return response << part + end + + unless content_length || chunked + chunked ||= part.downcase.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... + finished = + if content_length + body.bytesize == content_length + elsif chunked + body.end_with? "0\r\n\r\n" + elsif !hdrs.empty? && !body.empty? + true + else + false + end + response.times = times + return response if finished + end + sleep 0.000_1 + when :wait_readable + # continue loop + when :wait_writable # :wait_writable for ssl + to = time_end - Process.clock_gettime(Process::CLOCK_MONOTONIC) + self.to_io.wait_writable to + when nil + if response.empty? + raise EOFError + else + response.times = times + return response + end + end + timeout = time_end - Process.clock_gettime(Process::CLOCK_MONOTONIC) + if timeout <= 0 + raise Timeout::Error, 'Client Read Timeout' + end + end + end + end + + # @todo verify whole string is written + REQ_WRITE = -> (str) { self.syswrite str; self } + + # Helper for creating an `OpenSSL::SSL::SSLContext`. + # @param &blk [Block] Passed the SSLContext. + # @yield [OpenSSL::SSL::SSLContext] + # @return [OpenSSL::SSL::SSLContext] The new socket + def new_ctx(&blk) + ctx = OpenSSL::SSL::SSLContext.new + if blk + yield ctx + else + ctx.verify_mode = OpenSSL::SSL::VERIFY_NONE + end + ctx + end + + # Creates a new client socket. TCP, SSL, and UNIX are supported + # @!macro req + # @return [OpenSSL::SSL::SSLSocket, TCPSocket, UNIXSocket] the created socket + # + def new_socket(host: nil, port: nil, path: nil, ctx: nil, session: nil) + port ||= @bind_port + path ||= @bind_path + ip ||= (host || HOST.ip).gsub RE_HOST_TO_IP, '' # in case a URI style IPv6 is passed + + skt = + if path && !port && !ctx + UNIXSocket.new path.sub(/\A@/, "\0") # sub is for abstract + elsif port # && !path + tcp = TCPSocket.new ip, port.to_i + tcp.setsockopt(Socket::IPPROTO_TCP, Socket::TCP_NODELAY, 1) if SET_TCP_NODELAY + if ctx + ::OpenSSL::SSL::SSLSocket.new tcp, ctx + else + tcp + end + else + raise 'port or path must be set!' + end + + skt.define_singleton_method :read_response, READ_RESPONSE + skt.define_singleton_method :read_body, READ_BODY + skt.define_singleton_method :<<, REQ_WRITE + @ios_to_close << skt + if ctx + @ios_to_close << tcp + skt.session = session if session + skt.sync_close = true + skt.connect + end + skt + end + + # Creates an array of sockets, sending a request on each + # @param req [String] the request + # @param len [Integer] the number of requests to send + # @return [Array] + # + def send_http_array(req = GET_11, len, dly: 0.000_1, max_retries: 5) + Array.new(len) { + retries = 0 + begin + skt = send_http req + sleep dly + skt + rescue Errno::ECONNREFUSED + retries += 1 + if retries < max_retries + retry + else + flunk 'Generate requests failed from Errno::ECONNREFUSED' + end + end + } + end + + # Reads an array of sockets that have already had requests sent. + # @param skts [Array] an array matching the order of the parameter + # `skts`, contains the response or the error class generated by the socket. + # + def read_response_array(skts, resp_count: nil, body_only: nil) + results = Array.new skts.length + Thread.new do + until skts.compact.empty? + skts.each_with_index do |skt, idx| + next if skt.nil? + begin + next unless skt.wait_readable 0.000_5 + if resp_count + resp = skt.read_response.dup + cntr = 0 + until resp.split(RESP_SPLIT).length == resp_count + 1 || cntr > 20 + cntr += 1 + Thread.pass + if skt.wait_readable 0.001 + begin + resp << skt.read_response + rescue EOFError + break + end + end + end + results[idx] = resp + else + results[idx] = body_only ? skt.read_body : skt.read_response + end + rescue StandardError => e + results[idx] = e.class.to_s + end + begin + skt.close unless skt.closed? # skt.close may return Errno::EBADF + rescue StandardError => e + results[idx] ||= e.class.to_s + end + skts[idx] = nil + end + end + end.join 15 + results + end + end +end diff -Nru puma-5.6.5/test/helpers/test_puma/response.rb puma-6.4.2/test/helpers/test_puma/response.rb --- puma-5.6.5/test/helpers/test_puma/response.rb 1970-01-01 00:00:00.000000000 +0000 +++ puma-6.4.2/test/helpers/test_puma/response.rb 2024-01-08 05:53:42.000000000 +0000 @@ -0,0 +1,56 @@ +module TestPuma + + # A subclass of String, allows processing the response returned by + # `PumaSocket#send_http_read_response` and the `read_response` method added + # to native socket instances (created with `PumaSocket#new_socket` and + # `PumaSocket#send_http`. + # + class Response < String + + attr_accessor :times + + # Returns response headers as an array of lines + # @return [Array] + def headers + @headers ||= begin + ary = self.split(RESP_SPLIT, 2).first.split LINE_SPLIT + @status = ary.shift + ary + end + end + + # Returns response headers as a hash. All keys and values are strings. + # @return [Hash] + def headers_hash + @headers_hash ||= headers.map { |hdr| hdr.split ': ', 2 }.to_h + end + + def status + headers + @status + end + + def body + self.split(RESP_SPLIT, 2).last + end + + # Decodes a chunked body + # @return [String] the decoded body + def decode_body + decoded = String.new # rubocop: disable Performance/UnfreezeString + + body = self.split(RESP_SPLIT, 2).last + body = body.byteslice 0, body.bytesize - 5 # remove terminating bytes + + loop do + size, body = body.split LINE_SPLIT, 2 + size = size.to_i 16 + + decoded << body.byteslice(0, size) + body = body.byteslice (size+2)..-1 # remove segment ending "\r\n" + break if body.empty? || body.nil? + end + decoded + end + end +end diff -Nru puma-5.6.5/test/helpers/test_puma.rb puma-6.4.2/test/helpers/test_puma.rb --- puma-5.6.5/test/helpers/test_puma.rb 1970-01-01 00:00:00.000000000 +0000 +++ puma-6.4.2/test/helpers/test_puma.rb 2024-01-08 05:53:42.000000000 +0000 @@ -0,0 +1,64 @@ +# frozen_string_literal: true + +require 'socket' + +module TestPuma + + RESP_SPLIT = "\r\n\r\n" + LINE_SPLIT = "\r\n" + + RE_HOST_TO_IP = /\A\[|\]\z/o + + HOST4 = begin + t = Socket.ip_address_list.select(&:ipv4_loopback?).map(&:ip_address) + .uniq.sort_by(&:length) + # puts "IPv4 Loopback #{t}" + str = t.include?('127.0.0.1') ? +'127.0.0.1' : +"#{t.first}" + str.define_singleton_method(:ip) { self } + str.freeze + end + + HOST6 = begin + t = Socket.ip_address_list.select(&:ipv6_loopback?).map(&:ip_address) + .uniq.sort_by(&:length) + # puts "IPv6 Loopback #{t}" + str = t.include?('::1') ? +'[::1]' : +"[#{t.first}]" + str.define_singleton_method(:ip) { self.gsub RE_HOST_TO_IP, '' } + str.freeze + end + + LOCALHOST = ENV.fetch 'PUMA_CI_DFLT_HOST', 'localhost' + + if ENV['PUMA_CI_DFLT_IP'] =='IPv6' + HOST = HOST6 + ALT_HOST = HOST4 + else + HOST = HOST4 + ALT_HOST = HOST6 + end + + DARWIN = RUBY_PLATFORM.include? 'darwin' + + TOKEN = "xxyyzz" + + # Returns an available port by using `TCPServer.open(host, 0)` + def new_port(host = HOST) + TCPServer.open(host, 0) { |server| server.connect_address.ip_port } + end + + def bind_uri_str + if @bind_port + "tcp://#{HOST}:#{@bind_port}" + elsif @bind_path + "unix://#{HOST}:#{@bind_path}" + end + end + + def control_uri_str + if @control_port + "tcp://#{HOST}:#{@control_port}" + elsif @control_path + "unix://#{HOST}:#{@control_path}" + end + end +end diff -Nru puma-5.6.5/test/helpers/tmp_path.rb puma-6.4.2/test/helpers/tmp_path.rb --- puma-5.6.5/test/helpers/tmp_path.rb 2022-08-22 23:33:14.000000000 +0000 +++ puma-6.4.2/test/helpers/tmp_path.rb 2024-01-08 05:53:42.000000000 +0000 @@ -1,3 +1,5 @@ +# frozen_string_literal: true + module TmpPath def clean_tmp_paths while path = tmp_paths.pop @@ -7,8 +9,25 @@ private + # With some macOS configurations, the following error may be raised when + # creating a UNIXSocket: + # + # too long unix socket path (106 bytes given but 104 bytes max) (ArgumentError) + # + PUMA_TMPDIR = + begin + if RUBY_DESCRIPTION.include? 'darwin' + # adds subdirectory 'tmp' in repository folder + dir_temp = File.absolute_path("#{__dir__}/../../tmp") + Dir.mkdir dir_temp unless Dir.exist? dir_temp + './tmp' + else + nil + end + end + def tmp_path(extension=nil) - path = Tempfile.create(['', extension]) { |f| f.path } + path = Tempfile.create(['', extension], PUMA_TMPDIR) { |f| f.path } tmp_paths << path path end diff -Nru puma-5.6.5/test/minitest/verbose_progress_plugin.rb puma-6.4.2/test/minitest/verbose_progress_plugin.rb --- puma-5.6.5/test/minitest/verbose_progress_plugin.rb 2022-08-22 23:33:14.000000000 +0000 +++ puma-6.4.2/test/minitest/verbose_progress_plugin.rb 2024-01-08 05:53:42.000000000 +0000 @@ -12,7 +12,7 @@ class VerboseProgressReporter < Reporter def prerecord(klass, name) @current ||= nil - @current = [klass.name, name].tap(&method(:print_start)) + @current = [klass.name, name].tap { |t| print_start t } end def record(result) diff -Nru puma-5.6.5/test/rackup/big_file.ru puma-6.4.2/test/rackup/big_file.ru --- puma-5.6.5/test/rackup/big_file.ru 1970-01-01 00:00:00.000000000 +0000 +++ puma-6.4.2/test/rackup/big_file.ru 2024-01-08 05:53:42.000000000 +0000 @@ -0,0 +1,7 @@ +static_file_path = File.join(Dir.tmpdir, "puma-static.txt") +File.write(static_file_path, "Hello World" * 100_000) + +run lambda { |env| + f = File.open(static_file_path) + [200, {"Content-Type" => "text/plain", "Content-Length" => f.size.to_s}, f] +} diff -Nru puma-5.6.5/test/rackup/ci_array.ru puma-6.4.2/test/rackup/ci_array.ru --- puma-5.6.5/test/rackup/ci_array.ru 1970-01-01 00:00:00.000000000 +0000 +++ puma-6.4.2/test/rackup/ci_array.ru 2024-01-08 05:53:42.000000000 +0000 @@ -0,0 +1,45 @@ +# frozen_string_literal: true + +# Generates a response with array bodies, size set via ENV['CI_BODY_CONF'] or +# `Body-Conf` request header. +# See 'CI - test/rackup/ci-*.ru files' or docs/test_rackup_ci_files.md + +require 'securerandom' + +headers = {} +headers['Content-Type'] = 'text/plain; charset=utf-8'.freeze +25.times { |i| headers["X-My-Header-#{i}"] = SecureRandom.hex(25) } + +hdr_dly = 'HTTP_DLY' +hdr_body_conf = 'HTTP_BODY_CONF' + +# length = 1018 bytesize = 1024 +str_1kb = "──#{SecureRandom.hex 507}─\n".freeze + +env_len = (t = ENV['CI_BODY_CONF']) ? t[/\d+\z/].to_i : 10 + +cache_array = {} + +run lambda { |env| + info = if (dly = env[hdr_dly]) + hash_key = +"#{dly}," + sleep dly.to_f + "#{Process.pid}\nHello World\nSlept #{dly}\n" + else + hash_key = +"," + "#{Process.pid}\nHello World\n" + end + info_len_adj = 1023 - info.bytesize + + len = (t = env[hdr_body_conf]) ? t[/\d+\z/].to_i : env_len + + hash_key << len.to_s + + headers[hdr_content_length] = (1_024 * len).to_s + body = cache_array[hash_key] ||= begin + temp = Array.new len, str_1kb + temp[0] = info + str_1kb.byteslice(0, info_len_adj) + "\n" + temp + end + [200, headers, body] +} diff -Nru puma-5.6.5/test/rackup/ci_chunked.ru puma-6.4.2/test/rackup/ci_chunked.ru --- puma-5.6.5/test/rackup/ci_chunked.ru 1970-01-01 00:00:00.000000000 +0000 +++ puma-6.4.2/test/rackup/ci_chunked.ru 2024-01-08 05:53:42.000000000 +0000 @@ -0,0 +1,44 @@ +# frozen_string_literal: true + +# Generates a response with chunked bodies, size set via ENV['CI_BODY_CONF'] or +# `Body-Conf` request header. +# See 'CI - test/rackup/ci-*.ru files' or docs/test_rackup_ci_files.md + +require 'securerandom' + +headers = {} +headers['Content-Type'] = 'text/plain; charset=utf-8'.freeze +25.times { |i| headers["X-My-Header-#{i}"] = SecureRandom.hex(25) } + +hdr_dly = 'HTTP_DLY' +hdr_body_conf = 'HTTP_BODY_CONF' + +# length = 1018 bytesize = 1024 +str_1kb = "──#{SecureRandom.hex 507}─\n".freeze + +env_len = (t = ENV['CI_BODY_CONF']) ? t[/\d+\z/].to_i : 10 + +cache_chunked = {} + +run lambda { |env| + info = if (dly = env[hdr_dly]) + hash_key = +"#{dly}," + sleep dly.to_f + "#{Process.pid}\nHello World\nSlept #{dly}\n" + else + hash_key = +"," + "#{Process.pid}\nHello World\n" + end + info_len_adj = 1023 - info.bytesize + + len = (t = env[hdr_body_conf]) ? t[/\d+\z/].to_i : env_len + + hash_key << len.to_s + + body = cache_chunked[hash_key] ||= begin + temp = Array.new len, str_1kb + temp[0] = info + str_1kb.byteslice(0, info_len_adj) + "\n" + temp.to_enum + end + [200, headers, body] +} diff -Nru puma-5.6.5/test/rackup/ci_io.ru puma-6.4.2/test/rackup/ci_io.ru --- puma-5.6.5/test/rackup/ci_io.ru 1970-01-01 00:00:00.000000000 +0000 +++ puma-6.4.2/test/rackup/ci_io.ru 2024-01-08 05:53:42.000000000 +0000 @@ -0,0 +1,39 @@ +# frozen_string_literal: true + +# Generates a response with File/IO bodies, size set via ENV['CI_BODY_CONF'] or +# `Body-Conf` request header. +# See 'CI - test/rackup/ci-*.ru files' or docs/test_rackup_ci_files.md + +require 'securerandom' +require 'tmpdir' + +headers = {} +headers['Content-Type'] = 'text/plain; charset=utf-8' +25.times { |i| headers["X-My-Header-#{i}"] = SecureRandom.hex(25) } + +hdr_dly = 'HTTP_DLY' +hdr_body_conf = 'HTTP_BODY_CONF' +hdr_content_length = 'Content-Length' + +env_len = (t = ENV['CI_BODY_CONF']) ? t[/\d+\z/].to_i : 10 + +tmp_folder = "#{Dir.tmpdir}/.puma_response_body_io" + +unless Dir.exist? tmp_folder + STDOUT.syswrite "\nNeeded files do not exist. Run `TestPuma.create_io_files" \ + " contained in benchmarks/local/bench_base.rb\n" + exit 1 +end + +fn_format = "#{tmp_folder}/body_io_%04d.txt" + +run lambda { |env| + if (dly = env[hdr_dly]) + sleep dly.to_f + end + len = (t = env[hdr_body_conf]) ? t[/\d+\z/].to_i : env_len + headers[hdr_content_length] = (1024*len).to_s + fn = format fn_format, len + body = File.open fn + [200, headers, body] +} diff -Nru puma-5.6.5/test/rackup/ci_select.ru puma-6.4.2/test/rackup/ci_select.ru --- puma-5.6.5/test/rackup/ci_select.ru 1970-01-01 00:00:00.000000000 +0000 +++ puma-6.4.2/test/rackup/ci_select.ru 2024-01-08 05:53:42.000000000 +0000 @@ -0,0 +1,82 @@ +# frozen_string_literal: true + +# Generates a response with various body types and sizes, set via ENV['CI_BODY_CONF'] or +# `Body-Conf` request header. +# See 'CI - test/rackup/ci-*.ru files' or docs/test_rackup_ci_files.md + +require 'securerandom' +require 'tmpdir' + +headers = {} +headers['Content-Type'] = 'text/plain; charset=utf-8'.freeze +25.times { |i| headers["X-My-Header-#{i}"] = SecureRandom.hex(25) } + +hdr_dly = 'HTTP_DLY' +hdr_body_conf = 'HTTP_BODY_CONF' +hdr_content_length = 'Content-Length' + +# length = 1018 bytesize = 1024 +str_1kb = "──#{SecureRandom.hex 507}─\n".freeze + +fn_format = "#{Dir.tmpdir}/.puma_response_body_io/body_io_%04d.txt".freeze + +body_types = %w[a c i s].freeze + +cache_array = {} +cache_chunked = {} +cache_string = {} + +run lambda { |env| + info = if (dly = env[hdr_dly]) + hash_key = +"#{dly}," + sleep dly.to_f + +"#{Process.pid}\nHello World\nSlept #{dly}\n" + else + hash_key = +"," + +"#{Process.pid}\nHello World\n" + end + info_len_adj = 1023 - info.bytesize + + body_conf = env[hdr_body_conf] + + if body_conf && body_conf.start_with?(*body_types) + type = body_conf.slice!(0).to_sym + len = body_conf.to_i + elsif body_conf + type = :s + len = body_conf[/\d+\z/].to_i + else # default + type = :s + len = 1 + end + + hash_key << len.to_s + + case type + when :a # body is an array + headers[hdr_content_length] = (1_024 * len).to_s + body = cache_array[hash_key] ||= begin + temp = Array.new len, str_1kb + temp[0] = info + str_1kb.byteslice(0, info_len_adj) + "\n" + temp + end + when :c # body is chunked + headers.delete hdr_content_length + body = cache_chunked[hash_key] ||= begin + temp = Array.new len, str_1kb + temp[0] = info + str_1kb.byteslice(0, info_len_adj) + "\n" + temp.to_enum + end + when :i # body is an io + headers[hdr_content_length] = (1_024 * len).to_s + fn = format fn_format, len + body = File.open fn, 'rb' + when :s # body is a single string in an array + headers[hdr_content_length] = (1_024 * len).to_s + body = cache_string[hash_key] ||= begin + info << str_1kb.byteslice(0, info_len_adj) << "\n" << (str_1kb * (len-1)) + [info] + end + end + [200, headers, body] +} diff -Nru puma-5.6.5/test/rackup/ci_string.ru puma-6.4.2/test/rackup/ci_string.ru --- puma-5.6.5/test/rackup/ci_string.ru 1970-01-01 00:00:00.000000000 +0000 +++ puma-6.4.2/test/rackup/ci_string.ru 2024-01-08 05:53:42.000000000 +0000 @@ -0,0 +1,47 @@ +# frozen_string_literal: true + +# Generates a response with single string bodies, size set via ENV['CI_BODY_CONF'] or +# `Body-Conf` request header. +# See 'CI - test/rackup/ci-*.ru files' or docs/test_rackup_ci_files.md + +require 'securerandom' + +env_len = (t = ENV['CI_BODY_CONF']) ? t[/\d+\z/].to_i : 10 + +headers = {} +headers['Content-Type'] = 'text/plain; charset=utf-8'.freeze +25.times { |i| headers["X-My-Header-#{i}"] = SecureRandom.hex(25) } + +hdr_dly = 'HTTP_DLY' +hdr_body_conf = 'HTTP_BODY_CONF' +hdr_content_length = 'Content-Length' + +# length = 1018 bytesize = 1024 +str_1kb = "──#{SecureRandom.hex 507}─\n".freeze + +env_len = (t = ENV['CI_BODY_CONF']) ? t[/\d+\z/].to_i : 10 + +cache_string = {} + +run lambda { |env| + info = if (dly = env[hdr_dly]) + +hash_key = "#{dly}," + sleep dly.to_f + +"#{Process.pid}\nHello World\nSlept #{dly}\n" + else + +hash_key = "," + +"#{Process.pid}\nHello World\n" + end + info_len_adj = 1023 - info.bytesize + + len = (t = env[hdr_body_conf]) ? t[/\d+\z/].to_i : env_len + + hash_key << len.to_s + + headers[hdr_content_length] = (1_024 * len).to_s + body = cache_string[hash_key] ||= begin + info << str_1kb.byteslice(0, info_len_adj) << "\n" << (str_1kb * (len-1)) + [info] + end + [200, headers, body] +} diff -Nru puma-5.6.5/test/rackup/env-dump.ru puma-6.4.2/test/rackup/env-dump.ru --- puma-5.6.5/test/rackup/env-dump.ru 1970-01-01 00:00:00.000000000 +0000 +++ puma-6.4.2/test/rackup/env-dump.ru 2024-01-08 05:53:42.000000000 +0000 @@ -0,0 +1,6 @@ +run lambda { |env| + body = +"#{'─' * 70} Headers\n" + env.sort.each { |k,v| body << "#{k.ljust 30} #{v}\n" } + body << "#{'─' * 78}\n" + [200, {"Content-Type" => "text/plain"}, [body]] +} diff -Nru puma-5.6.5/test/rackup/hello-bind_rack3.ru puma-6.4.2/test/rackup/hello-bind_rack3.ru --- puma-5.6.5/test/rackup/hello-bind_rack3.ru 1970-01-01 00:00:00.000000000 +0000 +++ puma-6.4.2/test/rackup/hello-bind_rack3.ru 2024-01-08 05:53:42.000000000 +0000 @@ -0,0 +1 @@ +run lambda { |env| [200, {"Content-Type" => "text/plain"}, ["Hello World"]] } diff -Nru puma-5.6.5/test/rackup/hello.ru puma-6.4.2/test/rackup/hello.ru --- puma-5.6.5/test/rackup/hello.ru 2022-08-22 23:33:14.000000000 +0000 +++ puma-6.4.2/test/rackup/hello.ru 2024-01-08 05:53:42.000000000 +0000 @@ -1 +1,3 @@ -run lambda { |env| [200, {"Content-Type" => "text/plain"}, ["Hello World"]] } +hdrs = {'Content-Type'.freeze => 'text/plain'.freeze}.freeze +body = ['Hello World'.freeze].freeze +run lambda { |env| [200, hdrs, body] } diff -Nru puma-5.6.5/test/rackup/url_scheme.ru puma-6.4.2/test/rackup/url_scheme.ru --- puma-5.6.5/test/rackup/url_scheme.ru 1970-01-01 00:00:00.000000000 +0000 +++ puma-6.4.2/test/rackup/url_scheme.ru 2024-01-08 05:53:42.000000000 +0000 @@ -0,0 +1 @@ +run lambda { |env| [200, {"Content-Type" => "text/plain"}, [env["rack.url_scheme"]]] } diff -Nru puma-5.6.5/test/runner puma-6.4.2/test/runner --- puma-5.6.5/test/runner 1970-01-01 00:00:00.000000000 +0000 +++ puma-6.4.2/test/runner 2024-01-08 05:53:42.000000000 +0000 @@ -0,0 +1,50 @@ +#!/usr/bin/env ruby + +=begin +A simplified test runner; it runs all tests by default. +It assumes that "bundle exec rake compile" has been run. + +It can also be passed a glob or test file names. If multiple test file names +are used, separate them by the File::PATH_SEPARATOR character with no spaces. +The file extension is optional. + +If arguments are used that take values (eg seed), use the 'no space' version, +like -s33388 or --seed=33388 + +Finally, to keep the code simple, if you pass an invalid argument for file +filtering it will either error or run minitest with no tests loaded + +Examples, run from the top Puma repo folder: +test/runner +test/runner -v +test/runner -v test_puma_server +test/runner --verbose test_puma_server* +test/runner --verbose test_integration_cluster:test_integration_single +test/runner --verbose test*ssl* +=end + +require 'bundler/setup' + +if ARGV.empty? || ARGV.last.start_with?('-') + if RUBY_VERSION >= '2.5' + Dir['test_*.rb', base: __dir__].each { |tf| require_relative tf } + else + Dir["#{__dir__}/test_*.rb"].each { |tf| require tf } + end +else + file_arg = ARGV.pop.sub(/\.rb\z/, '') + if file_arg.include? File::PATH_SEPARATOR + file_args = file_arg.split(File::PATH_SEPARATOR).map { |fn| fn.sub(/\.rb\z/, '') } + file_args.each { |tf| require_relative "#{tf}.rb" } + elsif file_arg.include? '*' + if RUBY_VERSION >= '2.5' + Dir["#{file_arg}.rb", base: __dir__].each { |tf| require_relative tf } + else + Dir["#{__dir__}/#{file_arg}.rb"].each { |tf| require tf } + end + else + require_relative "#{file_arg}.rb" + end +end + +require 'minitest' diff -Nru puma-5.6.5/test/runner.cmd puma-6.4.2/test/runner.cmd --- puma-5.6.5/test/runner.cmd 1970-01-01 00:00:00.000000000 +0000 +++ puma-6.4.2/test/runner.cmd 2024-01-08 05:53:42.000000000 +0000 @@ -0,0 +1,2 @@ +@ECHO OFF +@ruby.exe -x "%~dpn0" %* diff -Nru puma-5.6.5/test/test_binder.rb puma-6.4.2/test/test_binder.rb --- puma-5.6.5/test/test_binder.rb 2022-08-22 23:33:14.000000000 +0000 +++ puma-6.4.2/test/test_binder.rb 2024-01-08 05:53:42.000000000 +0000 @@ -13,8 +13,8 @@ include TmpPath def setup - @events = Puma::Events.strings - @binder = Puma::Binder.new(@events) + @log_writer = Puma::LogWriter.strings + @binder = Puma::Binder.new(@log_writer) end def teardown @@ -80,14 +80,14 @@ end def test_localhost_addresses_dont_alter_listeners_for_tcp_addresses - @binder.parse ["tcp://localhost:0"], @events + @binder.parse ["tcp://localhost:0"], @log_writer 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 + @binder.parse ["tcp://127.0.0.1:#{port}"], @log_writer assert_equal "tcp://127.0.0.1:#{port}", @binder.listeners[0][0] assert_kind_of TCPServer, @binder.listeners[0][1] @@ -96,14 +96,14 @@ def test_connected_ports ports = (1..3).map { |_| UniquePort.call } - @binder.parse(ports.map { |p| "tcp://localhost:#{p}" }, @events) + @binder.parse(ports.map { |p| "tcp://localhost:#{p}" }, @log_writer) 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 + @binder.parse ["ssl://localhost:0?#{ssl_query}"], @log_writer assert_empty @binder.listeners end @@ -111,16 +111,16 @@ 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 + @binder.parse ["ssl://127.0.0.1:#{port}?#{ssl_query}"], @log_writer 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 + @binder.parse ["tcp://localhost:0"], @log_writer - m = %r!http://127.0.0.1:(\d+)!.match(@events.stdout.string) + m = %r!http://127.0.0.1:(\d+)!.match(@log_writer.stdout.string) port = m[1].to_i refute_equal 0, port @@ -131,30 +131,30 @@ ssl_regex = %r!ssl://127.0.0.1:(\d+)! - @binder.parse ["ssl://localhost:0?#{ssl_query}"], @events + @binder.parse ["ssl://localhost:0?#{ssl_query}"], @log_writer - port = ssl_regex.match(@events.stdout.string)[1].to_i + port = ssl_regex.match(@log_writer.stdout.string)[1].to_i refute_equal 0, port end def test_logs_all_localhost_bindings - @binder.parse ["tcp://localhost:0"], @events + @binder.parse ["tcp://localhost:0"], @log_writer - assert_match %r!http://127.0.0.1:(\d+)!, @events.stdout.string + assert_match %r!http://127.0.0.1:(\d+)!, @log_writer.stdout.string if Socket.ip_address_list.any? {|i| i.ipv6_loopback? } - assert_match %r!http://\[::1\]:(\d+)!, @events.stdout.string + assert_match %r!http://\[::1\]:(\d+)!, @log_writer.stdout.string end end def test_logs_all_localhost_bindings_ssl skip_unless :ssl - @binder.parse ["ssl://localhost:0?#{ssl_query}"], @events + @binder.parse ["ssl://localhost:0?#{ssl_query}"], @log_writer - assert_match %r!ssl://127.0.0.1:(\d+)!, @events.stdout.string + assert_match %r!ssl://127.0.0.1:(\d+)!, @log_writer.stdout.string if Socket.ip_address_list.any? {|i| i.ipv6_loopback? } - assert_match %r!ssl://\[::1\]:(\d+)!, @events.stdout.string + assert_match %r!ssl://\[::1\]:(\d+)!, @log_writer.stdout.string end end @@ -176,9 +176,9 @@ unix_path = tmp_path('.sock') File.open(unix_path, mode: 'wb') { |f| f.puts 'pre existing' } - @binder.parse ["unix://#{unix_path}"], @events + @binder.parse ["unix://#{unix_path}"], @log_writer - assert_match %r!unix://#{unix_path}!, @events.stdout.string + assert_match %r!unix://#{unix_path}!, @log_writer.stdout.string refute_includes @binder.unix_paths, unix_path @@ -192,18 +192,27 @@ end end - def test_binder_parses_nil_low_latency + def test_binder_tcp_defaults_to_low_latency_off skip_if :jruby - @binder.parse ["tcp://0.0.0.0:0?low_latency"], @events + @binder.parse ["tcp://0.0.0.0:0"], @log_writer + + socket = @binder.listeners.first.last + + refute socket.getsockopt(Socket::IPPROTO_TCP, Socket::TCP_NODELAY).bool + end + + def test_binder_tcp_parses_nil_low_latency + skip_if :jruby + @binder.parse ["tcp://0.0.0.0:0?low_latency"], @log_writer socket = @binder.listeners.first.last assert socket.getsockopt(Socket::IPPROTO_TCP, Socket::TCP_NODELAY).bool end - def test_binder_parses_true_low_latency + def test_binder_tcp_parses_true_low_latency skip_if :jruby - @binder.parse ["tcp://0.0.0.0:0?low_latency=true"], @events + @binder.parse ["tcp://0.0.0.0:0?low_latency=true"], @log_writer socket = @binder.listeners.first.last @@ -212,30 +221,50 @@ def test_binder_parses_false_low_latency skip_if :jruby - @binder.parse ["tcp://0.0.0.0:0?low_latency=false"], @events + @binder.parse ["tcp://0.0.0.0:0?low_latency=false"], @log_writer socket = @binder.listeners.first.last refute socket.getsockopt(Socket::IPPROTO_TCP, Socket::TCP_NODELAY).bool end + def test_binder_ssl_defaults_to_true_low_latency + skip_unless :ssl + skip_if :jruby + @binder.parse ["ssl://0.0.0.0:0?#{ssl_query}"], @log_writer + + socket = @binder.listeners.first.last + + assert socket.to_io.getsockopt(Socket::IPPROTO_TCP, Socket::TCP_NODELAY).bool + end + + def test_binder_ssl_parses_false_low_latency + skip_unless :ssl + skip_if :jruby + @binder.parse ["ssl://0.0.0.0:0?#{ssl_query}&low_latency=false"], @log_writer + + socket = @binder.listeners.first.last + + refute socket.to_io.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 + @binder.parse ["ssl://0.0.0.0:0?#{ssl_query}&no_tlsv1=true"], @log_writer 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 + @binder.parse ["ssl://0.0.0.0:0?#{ssl_query}&no_tlsv1=false"], @log_writer 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 + @binder.parse ["ssl://0.0.0.0:0?#{ssl_query}"], @log_writer refute ssl_context_for_binder.no_tlsv1 refute ssl_context_for_binder.no_tlsv1_1 @@ -243,21 +272,21 @@ 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 + @binder.parse ["ssl://0.0.0.0:0?#{ssl_query}&no_tlsv1_1=true"], @log_writer 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 + @binder.parse ["ssl://0.0.0.0:0?#{ssl_query}&no_tlsv1_1=false"], @log_writer 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 + @binder.parse ["ssl://localhost:0?#{ssl_query}"], @log_writer env_hash = @binder.envs[@binder.ios.first] @@ -268,11 +297,11 @@ def test_env_contains_stderr skip_unless :ssl - @binder.parse ["ssl://localhost:0?#{ssl_query}"], @events + @binder.parse ["ssl://localhost:0?#{ssl_query}"], @log_writer env_hash = @binder.envs[@binder.ios.first] - assert_equal @events.stderr, env_hash["rack.errors"] + assert_equal @log_writer.stderr, env_hash["rack.errors"] end def test_close_calls_close_on_ios @@ -286,7 +315,7 @@ end def test_redirects_for_restart_creates_a_hash - @binder.parse ["tcp://127.0.0.1:0"], @events + @binder.parse ["tcp://127.0.0.1:0"], @log_writer result = @binder.redirects_for_restart ios = @binder.listeners.map { |_l, io| io.to_i } @@ -296,7 +325,7 @@ end def test_redirects_for_restart_env - @binder.parse ["tcp://127.0.0.1:0"], @events + @binder.parse ["tcp://127.0.0.1:0"], @log_writer result = @binder.redirects_for_restart_env @@ -306,7 +335,7 @@ end def test_close_listeners_closes_ios - @binder.parse ["tcp://127.0.0.1:#{UniquePort.call}"], @events + @binder.parse ["tcp://127.0.0.1:#{UniquePort.call}"], @log_writer refute @binder.listeners.any? { |_l, io| io.closed? } @@ -316,7 +345,7 @@ end def test_close_listeners_closes_ios_unless_closed? - @binder.parse ["tcp://127.0.0.1:0"], @events + @binder.parse ["tcp://127.0.0.1:0"], @log_writer bomb = @binder.listeners.first[1] bomb.close @@ -333,7 +362,7 @@ skip_unless :unix unix_path = tmp_path('.sock') - @binder.parse ["unix://#{unix_path}"], @events + @binder.parse ["unix://#{unix_path}"], @log_writer assert File.socket?(unix_path) @binder.close_listeners @@ -341,7 +370,7 @@ end def test_import_from_env_listen_inherit - @binder.parse ["tcp://127.0.0.1:0"], @events + @binder.parse ["tcp://127.0.0.1:0"], @log_writer removals = @binder.create_inherited_fds(@binder.redirects_for_restart_env) @binder.listeners.each do |l, io| @@ -381,7 +410,7 @@ end def test_rack_multithread_default_configuration - binder = Puma::Binder.new(@events) + binder = Puma::Binder.new(@log_writer) assert binder.proto_env["rack.multithread"] end @@ -389,13 +418,13 @@ def test_rack_multithread_custom_configuration conf = Puma::Configuration.new(max_threads: 1) - binder = Puma::Binder.new(@events, conf) + binder = Puma::Binder.new(@log_writer, conf) refute binder.proto_env["rack.multithread"] end def test_rack_multiprocess_default_configuration - binder = Puma::Binder.new(@events) + binder = Puma::Binder.new(@log_writer) refute binder.proto_env["rack.multiprocess"] end @@ -403,7 +432,7 @@ def test_rack_multiprocess_custom_configuration conf = Puma::Configuration.new(workers: 1) - binder = Puma::Binder.new(@events, conf) + binder = Puma::Binder.new(@log_writer, conf) assert binder.proto_env["rack.multiprocess"] end @@ -412,7 +441,7 @@ def assert_activates_sockets(path: nil, port: nil, url: nil, sock: nil) hash = { "LISTEN_FDS" => 1, "LISTEN_PID" => $$ } - @events.instance_variable_set(:@debug, true) + @log_writer.instance_variable_set(:@debug, true) @binder.instance_variable_set(:@sock_fd, sock.fileno) def @binder.socket_activation_fd(int); @sock_fd; end @@ -422,7 +451,7 @@ 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_match "Registered #{ary.join(":")} for activation from LISTEN_FDS", @log_writer.stdout.string assert_equal ["LISTEN_FDS", "LISTEN_PID"], @result end @@ -443,8 +472,8 @@ tested_paths = [prepared_paths[order[0]], prepared_paths[order[1]]] - @binder.parse tested_paths, @events - stdout = @events.stdout.string + @binder.parse tested_paths, @log_writer + stdout = @log_writer.stdout.string order.each do |prot| assert_match expected_logs[prot], stdout @@ -467,13 +496,10 @@ end TCPServer.stub(:new, tcp_server) do - @binder.parse ["ssl://#{host}:#{port}?#{ssl_query}&backlog=2048"], @events + @binder.parse ["ssl://#{host}:#{port}?#{ssl_query}&backlog=2048"], @log_writer end assert_equal 2048, Thread.current[:backlog] - rescue - STDOUT.syswrite @events.stdout - STDOUT.syswrite @events.stderr end end @@ -481,13 +507,26 @@ def test_binder_parses_jruby_ssl_options skip_unless :ssl + cipher_suites = ['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}"], @log_writer + + assert_equal @keystore, ssl_context_for_binder.keystore + assert_equal cipher_suites, ssl_context_for_binder.cipher_suites + assert_equal cipher_suites, ssl_context_for_binder.ssl_cipher_list + end + + def test_binder_parses_jruby_ssl_protocols_and_cipher_suites_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" + cipher = "TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256" + ssl_query = "keystore=#{keystore}&keystore-pass=jruby_puma&cipher_suites=#{cipher}&protocols=TLSv1.3,TLSv1.2" - @binder.parse ["ssl://0.0.0.0:8080?#{ssl_query}"], @events + @binder.parse ["ssl://0.0.0.0:8080?#{ssl_query}"], @log_writer - assert_equal keystore, ssl_context_for_binder.keystore - assert_equal ssl_cipher_list, ssl_context_for_binder.ssl_cipher_list + assert_equal [ 'TLSv1.3', 'TLSv1.2' ], ssl_context_for_binder.protocols + assert_equal [ cipher ], ssl_context_for_binder.cipher_suites end end if ::Puma::IS_JRUBY @@ -497,7 +536,7 @@ ssl_cipher_filter = "AES@STRENGTH" - @binder.parse ["ssl://0.0.0.0?#{ssl_query}&ssl_cipher_filter=#{ssl_cipher_filter}"], @events + @binder.parse ["ssl://0.0.0.0?#{ssl_query}&ssl_cipher_filter=#{ssl_cipher_filter}"], @log_writer assert_equal ssl_cipher_filter, ssl_context_for_binder.ssl_cipher_filter end @@ -507,7 +546,7 @@ input = "&verification_flags=TRUSTED_FIRST" - @binder.parse ["ssl://0.0.0.0?#{ssl_query}#{input}"], @events + @binder.parse ["ssl://0.0.0.0?#{ssl_query}#{input}"], @log_writer assert_equal 0x8000, ssl_context_for_binder.verification_flags end @@ -517,7 +556,7 @@ input = "&verification_flags=TRUSTED_FIRST,NO_CHECK_TIME" - @binder.parse ["ssl://0.0.0.0?#{ssl_query}#{input}"], @events + @binder.parse ["ssl://0.0.0.0?#{ssl_query}#{input}"], @log_writer assert_equal 0x8000 | 0x200000, ssl_context_for_binder.verification_flags end diff -Nru puma-5.6.5/test/test_bundle_pruner.rb puma-6.4.2/test/test_bundle_pruner.rb --- puma-5.6.5/test/test_bundle_pruner.rb 1970-01-01 00:00:00.000000000 +0000 +++ puma-6.4.2/test/test_bundle_pruner.rb 2024-01-08 05:53:42.000000000 +0000 @@ -0,0 +1,58 @@ +require_relative 'helper' + +require 'puma/events' + +class TestBundlePruner < Minitest::Test + + PUMA_VERS = "puma-#{Puma::Const::PUMA_VERSION}" + + def test_paths_to_require_after_prune_is_correctly_built_for_no_extra_deps + skip_if :no_bundler + + dirs = bundle_pruner.send(:paths_to_require_after_prune) + + assert_equal(2, dirs.length) + assert_equal(File.join(PROJECT_ROOT, "lib"), dirs[0]) # lib dir + assert_operator dirs[1], :end_with?, PUMA_VERS # native extension dir + refute_match(%r{gems/minitest-[\d.]+/lib$}, dirs[2]) + end + + def test_paths_to_require_after_prune_is_correctly_built_with_extra_deps + skip_if :no_bundler + + dirs = bundle_pruner([], ['minitest']).send(:paths_to_require_after_prune) + + assert_equal(3, dirs.length) + assert_equal(File.join(PROJECT_ROOT, "lib"), dirs[0]) # lib dir + assert_operator dirs[1], :end_with?, PUMA_VERS # native extension dir + assert_match(%r{gems/minitest-[\d.]+/lib$}, dirs[2]) # minitest dir + end + + def test_extra_runtime_deps_paths_is_empty_for_no_config + assert_equal([], bundle_pruner.send(:extra_runtime_deps_paths)) + end + + def test_extra_runtime_deps_paths_is_correctly_built + skip_if :no_bundler + + dep_dirs = bundle_pruner([], ['minitest']).send(:extra_runtime_deps_paths) + + assert_equal(1, dep_dirs.length) + assert_match(%r{gems/minitest-[\d.]+/lib$}, dep_dirs.first) + end + + def test_puma_wild_path_is_an_absolute_path + skip_if :no_bundler + puma_wild_path = bundle_pruner.send(:puma_wild_path) + + assert_match(%r{bin/puma-wild$}, puma_wild_path) + # assert no "/../" in path + refute_match(%r{/\.\./}, puma_wild_path) + end + + private + + def bundle_pruner(original_argv = nil, extra_runtime_dependencies = nil) + @bundle_pruner ||= Puma::Launcher::BundlePruner.new(original_argv, extra_runtime_dependencies, Puma::LogWriter.null) + end +end diff -Nru puma-5.6.5/test/test_busy_worker.rb puma-6.4.2/test/test_busy_worker.rb --- puma-5.6.5/test/test_busy_worker.rb 2022-08-22 23:33:14.000000000 +0000 +++ puma-6.4.2/test/test_busy_worker.rb 2024-01-08 05:53:42.000000000 +0000 @@ -1,5 +1,4 @@ require_relative "helper" -require "puma/events" class TestBusyWorker < Minitest::Test def setup @@ -10,7 +9,7 @@ def teardown return if skipped? - @server.stop(true) if @server + @server&.stop true @ios.each {|i| i.close unless i.closed?} end @@ -53,9 +52,11 @@ 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 + options[:min_threads] ||= 0 + options[:max_threads] ||= 10 + options[:log_writer] ||= Puma::LogWriter.strings + + @server = Puma::Server.new request_handler, nil, **options @port = (@server.add_tcp_listener '127.0.0.1', 0).addr[1] @server.run end diff -Nru puma-5.6.5/test/test_cli.rb puma-6.4.2/test/test_cli.rb --- puma-5.6.5/test/test_cli.rb 2022-08-22 23:33:14.000000000 +0000 +++ puma-6.4.2/test/test_cli.rb 2024-01-08 05:53:42.000000000 +0000 @@ -1,6 +1,7 @@ require_relative "helper" require_relative "helpers/ssl" if ::Puma::HAS_SSL require_relative "helpers/tmp_path" +require_relative "helpers/test_puma/puma_socket" require "puma/cli" require "json" @@ -9,6 +10,7 @@ class TestCLI < Minitest::Test include SSLHelper if ::Puma::HAS_SSL include TmpPath + include TestPuma::PumaSocket def setup @environment = 'production' @@ -21,8 +23,12 @@ @wait, @ready = IO.pipe - @events = Puma::Events.strings + @log_writer = Puma::LogWriter.strings + + @events = Puma::Events.new @events.on_booted { @ready << "!" } + + @puma_version_pattern = "\\d+.\\d+.\\d+(\\.[a-z\\d]+)?" end def wait_booted @@ -41,30 +47,25 @@ end def test_control_for_tcp - cntl = UniquePort.call - url = "tcp://127.0.0.1:#{cntl}/" + control_port = UniquePort.call + url = "tcp://127.0.0.1:#{control_port}/" cli = Puma::CLI.new ["-b", "tcp://127.0.0.1:0", "--control-url", url, "--control-token", "", - "test/rackup/lobster.ru"], @events + "test/rackup/hello.ru"], @log_writer, @events - t = Thread.new do - cli.run - end + t = Thread.new { cli.run } 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 + body = send_http_read_resp_body "GET /stats HTTP/1.0\r\n\r\n", port: control_port 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) - + dmt = Puma::Configuration::DEFAULTS[: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},"requests_count":0,"versions":\{"puma":"#{@puma_version_pattern}","ruby":\{"engine":"\w+","version":"\d+.\d+.\d+","patchlevel":-?\d+\}\}\}/ + assert_match(expected_stats, body) ensure cli.launcher.stop t.join @@ -82,30 +83,22 @@ cli = Puma::CLI.new ["-b", "tcp://127.0.0.1:0", "--control-url", control_url, "--control-token", token, - "test/rackup/lobster.ru"], @events + "test/rackup/hello.ru"], @log_writer, @events - t = Thread.new do - cli.run - end + t = Thread.new { cli.run } 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 + body = send_http_read_resp_body "GET /stats?token=#{token} HTTP/1.0\r\n\r\n", + port: control_port, ctx: new_ctx - dmt = Puma::Configuration.new.default_max_threads + dmt = Puma::Configuration::DEFAULTS[: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) - + assert_match(expected_stats, body) ensure - cli.launcher.stop if cli - t.join if t + # always called, even if skipped + cli&.launcher&.stop + t&.join end def test_control_clustered @@ -118,7 +111,7 @@ "-w", "2", "--control-url", url, "--control-token", "", - "test/rackup/lobster.ru"], @events + "test/rackup/hello.ru"], @log_writer, @events # without this, Minitest.after_run will trigger on this test ? $debugging_hold = true @@ -127,22 +120,16 @@ wait_booted - s = UNIXSocket.new @tmp_path - s << "GET /stats HTTP/1.0\r\n\r\n" - body = s.read - s.close + body = send_http_read_resp_body "GET /stats HTTP/1.0\r\n\r\n", path: @tmp_path - require 'json' - status = JSON.parse(body.split("\n").last) + status = JSON.parse(body) 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 + body = send_http_read_resp_body "GET /stats HTTP/1.0\r\n\r\n", path: @tmp_path - 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) + expected_stats = /\{"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\}\}\],"versions":\{"puma":"#{@puma_version_pattern}","ruby":\{"engine":"\w+","version":"\d+.\d+.\d+","patchlevel":-?\d+\}\}\}/ + assert_match(expected_stats, body) ensure if UNIX_SKT_EXIST && HAS_FORK cli.launcher.stop @@ -150,8 +137,8 @@ done = nil until done - @events.stdout.rewind - log = @events.stdout.readlines.join '' + @log_writer.stdout.rewind + log = @log_writer.stdout.readlines.join '' done = log[/ - Goodbye!/] end @@ -166,19 +153,17 @@ cli = Puma::CLI.new ["-b", "unix://#{@tmp_path2}", "--control-url", url, "--control-token", "", - "test/rackup/lobster.ru"], @events + "test/rackup/hello.ru"], @log_writer, @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 + body = send_http_read_resp_body "GET /stats HTTP/1.0\r\n\r\n", path: @tmp_path - 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) + dmt = Puma::Configuration::DEFAULTS[: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},"requests_count":0,"versions":\{"puma":"#{@puma_version_pattern}","ruby":\{"engine":"\w+","version":"\d+.\d+.\d+","patchlevel":-?\d+\}\}\}/ + assert_match(expected_stats, body) ensure if UNIX_SKT_EXIST cli.launcher.stop @@ -193,59 +178,43 @@ cli = Puma::CLI.new ["-b", "unix://#{@tmp_path2}", "--control-url", url, "--control-token", "", - "test/rackup/lobster.ru"], @events + "test/rackup/hello.ru"], @log_writer, @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 + body = send_http_read_resp_body "GET /stop HTTP/1.0\r\n\r\n", path: @tmp_path - assert_equal '{ "status": "ok" }', body.split("\r\n").last + assert_equal '{ "status": "ok" }', body 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}/" + @bind_port = UniquePort.call + control_port = UniquePort.call + url = "tcp://127.0.0.1:#{control_port}/" - cli = Puma::CLI.new ["-b", "tcp://127.0.0.1:#{tcp}", + cli = Puma::CLI.new ["-b", "tcp://127.0.0.1:#{@bind_port}", "--control-url", url, "--control-token", "", - "test/rackup/lobster.ru"], @events + "test/rackup/hello.ru"], @log_writer, @events - t = Thread.new do - cli.run - end + t = Thread.new { cli.run } 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 + body = send_http_read_resp_body "GET /stats HTTP/1.0\r\n\r\n", port: control_port - assert_equal 0, JSON.parse(body.split(/\r?\n/).last)['requests_count'] + assert_equal 0, JSON.parse(body)['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 + 3.times { send_http_read_resp_body GET_10 } - s = TCPSocket.new "127.0.0.1", cntl - s << "GET /stats HTTP/1.0\r\n\r\n" - body = s.read - s.close + body = send_http_read_resp_body "GET /stats HTTP/1.0\r\n\r\n", port: control_port - assert_equal 3, JSON.parse(body.split(/\r?\n/).last)['requests_count'] + assert_equal 3, JSON.parse(body)['requests_count'] ensure cli.launcher.stop t.join @@ -258,89 +227,27 @@ cli = Puma::CLI.new ["-b", "unix://#{@tmp_path2}", "--control-url", url, "--control-token", "", - "test/rackup/lobster.ru"], @events + "test/rackup/hello.ru"], @log_writer, @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 + if TRUFFLE + Thread.pass + sleep 0.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") + # All thread backtraces may be very large, just get a chunk + socket = send_http "GET /thread-backtraces HTTP/1.0\r\n\r\n", path: @tmp_path + socket.wait_readable 3 + body = socket.sysread 32_768 + assert_match %r{Thread: TID-}, body 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 } + t.join if UNIX_SKT_EXIST 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" @@ -366,9 +273,7 @@ url = data["control_url"] - m = %r!unix://(.*)!.match(url) - - assert m, "'#{url}' is not a URL" + assert_operator url, :start_with?, "unix://", "'#{url}' is not a URL" end def test_state_file_callback_filtering @@ -384,28 +289,28 @@ def test_log_formatter_default_single cli = Puma::CLI.new [ ] - assert_instance_of Puma::Events::DefaultFormatter, cli.launcher.events.formatter + assert_instance_of Puma::LogWriter::DefaultFormatter, cli.launcher.log_writer.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 + assert_instance_of Puma::LogWriter::PidFormatter, cli.launcher.log_writer.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')) + assert_instance_of Proc, cli.launcher.log_writer.formatter + assert_match(/^\[.*\] \[.*\] .*: test$/, cli.launcher.log_writer.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')) + assert_instance_of Proc, cli.launcher.log_writer.formatter + assert_match(/^\[.*\] \[.*\] .*: test$/, cli.launcher.log_writer.format('test')) end def test_state @@ -449,7 +354,7 @@ cli = Puma::CLI.new [] cli.send(:setup_options) - assert_equal 'test', cli.instance_variable_get(:@conf).environment.call + assert_equal 'test', cli.instance_variable_get(:@conf).environment ensure ENV.delete 'APP_ENV' ENV.delete 'RAILS_ENV' @@ -461,7 +366,7 @@ cli = Puma::CLI.new [] cli.send(:setup_options) - assert_equal @environment, cli.instance_variable_get(:@conf).environment.call + assert_equal @environment, cli.instance_variable_get(:@conf).environment end def test_environment_rails_env @@ -471,7 +376,7 @@ cli = Puma::CLI.new [] cli.send(:setup_options) - assert_equal @environment, cli.instance_variable_get(:@conf).environment.call + assert_equal @environment, cli.instance_variable_get(:@conf).environment ensure ENV.delete 'RAILS_ENV' end @@ -480,10 +385,10 @@ cli = Puma::CLI.new ['--silent'] cli.send(:setup_options) - events = cli.instance_variable_get(:@events) + log_writer = cli.instance_variable_get(:@log_writer) - assert_equal events.class, Puma::Events.null.class - assert_equal events.stdout.class, Puma::NullIO - assert_equal events.stderr, $stderr + assert_equal log_writer.class, Puma::LogWriter.null.class + assert_equal log_writer.stdout.class, Puma::NullIO + assert_equal log_writer.stderr, $stderr end end diff -Nru puma-5.6.5/test/test_config.rb puma-6.4.2/test/test_config.rb --- puma-5.6.5/test/test_config.rb 2022-08-22 23:33:14.000000000 +0000 +++ puma-6.4.2/test/test_config.rb 2024-01-08 05:53:42.000000000 +0000 @@ -4,7 +4,8 @@ require_relative "helpers/config_file" require "puma/configuration" -require 'puma/events' +require 'puma/log_writer' +require 'rack' class TestConfigFile < TestConfigFileBase parallelize_me! @@ -12,12 +13,20 @@ 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 + assert_equal max_threads, Puma::Configuration.new.options.default_options[:max_threads] end def test_app_from_rackup + if Rack.release >= '3' + fn = "test/rackup/hello-bind_rack3.ru" + bind = "tcp://0.0.0.0:9292" + else + fn = "test/rackup/hello-bind.ru" + bind = "tcp://127.0.0.1:9292" + end + conf = Puma::Configuration.new do |c| - c.rackup "test/rackup/hello-bind.ru" + c.rackup fn end conf.load @@ -27,7 +36,9 @@ conf.app end - assert_equal ["tcp://127.0.0.1:9292"], conf.options[:binds] + assert_equal [200, {"Content-Type"=>"text/plain"}, ["Hello World"]], conf.app.call({}) + + assert_equal [bind], conf.options[:binds] end def test_app_from_app_DSL @@ -53,7 +64,7 @@ app = conf.app assert bind_configuration =~ %r{ca=.*ca.crt} - assert bind_configuration =~ /verify_mode=peer/ + assert bind_configuration&.include?('verify_mode=peer') assert_equal [200, {}, ["embedded app"]], app.call({}) end @@ -67,9 +78,6 @@ conf.load - bind_configuration = conf.options.file_options[:binds].first - app = conf.app - ssl_binding = "ssl://0.0.0.0:9292?&verify_mode=none" assert_equal [ssl_binding], conf.options[:binds] end @@ -148,10 +156,66 @@ assert ssl_binding.include?('&backlog=2048') end + def test_ssl_bind_with_low_latency_true + skip_unless :ssl + skip_if :jruby + + conf = Puma::Configuration.new do |c| + c.ssl_bind "0.0.0.0", "9292", { + low_latency: true + } + end + + conf.load + + ssl_binding = conf.options[:binds].first + assert ssl_binding.include?('&low_latency=true') + end + + def test_ssl_bind_with_low_latency_false + skip_unless :ssl + skip_if :jruby + + conf = Puma::Configuration.new do |c| + c.ssl_bind "0.0.0.0", "9292", { + low_latency: false + } + end + + conf.load + + ssl_binding = conf.options[:binds].first + assert ssl_binding.include?('&low_latency=false') + end + def test_ssl_bind_jruby skip_unless :jruby skip_unless :ssl + ciphers = "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", + cipher_suites: ciphers, + protocols: 'TLSv1.2', + verify_mode: "the_verify_mode" + } + end + + conf.load + + ssl_binding = "ssl://0.0.0.0:9292?keystore=/path/to/keystore" \ + "&keystore-pass=password&cipher_suites=#{ciphers}&protocols=TLSv1.2" \ + "&verify_mode=the_verify_mode" + assert_equal [ssl_binding], conf.options[:binds] + end + + def test_ssl_bind_jruby_with_ssl_cipher_list + 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| @@ -171,6 +235,30 @@ assert_equal [ssl_binding], conf.options[:binds] end + def test_ssl_bind_jruby_with_truststore + skip_unless :jruby + skip_unless :ssl + + conf = Puma::Configuration.new do |c| + c.ssl_bind "0.0.0.0", "9292", { + keystore: "/path/to/keystore", + keystore_type: "pkcs12", + keystore_pass: "password", + truststore: "default", + truststore_type: "jks", + verify_mode: "none" + } + end + + conf.load + + ssl_binding = "ssl://0.0.0.0:9292?keystore=/path/to/keystore" \ + "&keystore-pass=password&keystore-type=pkcs12" \ + "&truststore=default&truststore-type=jks" \ + "&verify_mode=none" + assert_equal [ssl_binding], conf.options[:binds] + end + def test_ssl_bind_no_tlsv1_1 skip_if :jruby skip_unless :ssl @@ -367,22 +455,40 @@ def test_run_hooks_before_worker_fork assert_run_hooks :before_worker_fork, configured_with: :on_worker_fork + + assert_warning_for_hooks_defined_in_single_mode :on_worker_fork end def test_run_hooks_after_worker_fork assert_run_hooks :after_worker_fork + + assert_warning_for_hooks_defined_in_single_mode :after_worker_fork end def test_run_hooks_before_worker_boot assert_run_hooks :before_worker_boot, configured_with: :on_worker_boot + + assert_warning_for_hooks_defined_in_single_mode :on_worker_boot end def test_run_hooks_before_worker_shutdown assert_run_hooks :before_worker_shutdown, configured_with: :on_worker_shutdown + + assert_warning_for_hooks_defined_in_single_mode :on_worker_shutdown end def test_run_hooks_before_fork assert_run_hooks :before_fork + + assert_warning_for_hooks_defined_in_single_mode :before_fork + end + + def test_run_hooks_before_thread_start + assert_run_hooks :before_thread_start, configured_with: :on_thread_start + end + + def test_run_hooks_before_thread_exit + assert_run_hooks :before_thread_exit, configured_with: :on_thread_exit end def test_run_hooks_and_exception @@ -392,11 +498,11 @@ end end conf.load - events = Puma::Events.strings + log_writer = Puma::LogWriter.strings - conf.run_hooks :on_restart, 'ARG', events + conf.run_hooks(:on_restart, 'ARG', log_writer) expected = /WARNING hook on_restart failed with exception \(RuntimeError\) Error from hook/ - assert_match expected, events.stdout.string + assert_match expected, log_writer.stdout.string end def test_config_does_not_load_workers_by_default @@ -426,6 +532,30 @@ assert_equal true, conf.options[:silence_single_worker_warning] end + def test_silence_fork_callback_warning_default + conf = Puma::Configuration.new + conf.load + + assert_equal false, conf.options[:silence_fork_callback_warning] + end + + def test_silence_fork_callback_warning_overwrite + conf = Puma::Configuration.new do |c| + c.silence_fork_callback_warning + end + conf.load + + assert_equal true, conf.options[:silence_fork_callback_warning] + end + + def test_http_content_length_limit + assert_nil Puma::Configuration.new.options.default_options[:http_content_length_limit] + + conf = Puma::Configuration.new({ http_content_length_limit: 10000}) + + assert_equal 10000, conf.final_options[:http_content_length_limit] + end + private def assert_run_hooks(hook_name, options = {}) @@ -433,17 +563,21 @@ # test single, not an array messages = [] - conf = Puma::Configuration.new + conf = Puma::Configuration.new do |c| + c.silence_fork_callback_warning + end conf.options[hook_name] = -> (a) { messages << "#{hook_name} is called with #{a}" } - conf.run_hooks hook_name, 'ARG', Puma::Events.strings + conf.run_hooks(hook_name, 'ARG', Puma::LogWriter.strings) assert_equal messages, ["#{hook_name} is called with ARG"] # test multiple messages = [] conf = Puma::Configuration.new do |c| + c.silence_fork_callback_warning + c.send(configured_with) do |a| messages << "#{hook_name} is called with #{a} one time" end @@ -454,9 +588,31 @@ end conf.load - conf.run_hooks hook_name, 'ARG', Puma::Events.strings + conf.run_hooks(hook_name, 'ARG', Puma::LogWriter.strings) assert_equal messages, ["#{hook_name} is called with ARG one time", "#{hook_name} is called with ARG a second time"] end + + def assert_warning_for_hooks_defined_in_single_mode(hook_name) + out, _ = capture_io do + Puma::Configuration.new do |c| + c.send(hook_name) + end + end + + assert_match "your `#{hook_name}` block did not run\n", out + end +end + +# contains tests that cannot run parallel +class TestConfigFileSingle < TestConfigFileBase + def test_custom_logger_from_DSL + conf = Puma::Configuration.new { |c| c.load 'test/config/custom_logger.rb' } + + conf.load + out, _ = capture_subprocess_io { conf.options[:custom_logger].write 'test' } + + assert_equal "Custom logging: test\n", out + end end # Thread unsafe modification of ENV @@ -465,7 +621,7 @@ 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}" + user_config.bind "tcp://#{Puma::Configuration::DEFAULTS[:tcp_host]}:#{port}" file_config.load "test/config/app.rb" end @@ -492,7 +648,6 @@ 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 diff -Nru puma-5.6.5/test/test_events.rb puma-6.4.2/test/test_events.rb --- puma-5.6.5/test/test_events.rb 2022-08-22 23:33:14.000000000 +0000 +++ puma-6.4.2/test/test_events.rb 2024-01-08 05:53:42.000000000 +0000 @@ -2,41 +2,10 @@ 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 = Puma::Events.new events.register(:exec) { res = true } @@ -56,7 +25,7 @@ @res = true end - events = Puma::Events.null + events = Puma::Events.new events.register(:exec, obj) @@ -68,7 +37,7 @@ def test_fire_callback_with_multiple_arguments res = [] - events = Puma::Events.null + events = Puma::Events.new events.register(:exec) { |*args| res.concat(args) } @@ -80,7 +49,7 @@ def test_on_booted_callback res = false - events = Puma::Events.null + events = Puma::Events.new events.on_booted { res = true } @@ -88,151 +57,4 @@ 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 -Nru puma-5.6.5/test/test_example_cert_expiration.rb puma-6.4.2/test/test_example_cert_expiration.rb --- puma-5.6.5/test/test_example_cert_expiration.rb 1970-01-01 00:00:00.000000000 +0000 +++ puma-6.4.2/test/test_example_cert_expiration.rb 2024-01-08 05:53:42.000000000 +0000 @@ -0,0 +1,43 @@ +require_relative 'helper' +require 'openssl' + +# +# Thes are tests to ensure that the checked in certs in the ./examples/ +# directory are valid and work as expected. +# +# These tets will start to fail 1 month before the certs expire +# +class TestExampleCertExpiration < Minitest::Test + EXAMPLES_DIR = File.expand_path '../examples', __dir__ + EXPIRE_THRESHOLD = Time.now.utc - (60 * 60 * 24 * 30) # 30 days + + # Explicitly list the files to test + TEST_FILES = %w[ + puma/cert_puma.pem + puma/client-certs/client.crt + puma/client-certs/ca.crt + puma/client-certs/client_unknown.crt + puma/client-certs/server.crt + puma/client-certs/unknown_ca.crt + puma/chain_cert/ca.crt + puma/chain_cert/cert.crt + puma/chain_cert/intermediate.crt + ] + + # TODO: Add these files to the list above if they are not supposed to be expired + # CA/newcerts/cert_1.pem + # CA/newcerts/cert_2.pem + # CA/cacert.pem + + def test_certs_not_expired + TEST_FILES.each do |path| + full_path = File.join(EXAMPLES_DIR, path) + cert = OpenSSL::X509::Certificate.new File.read(full_path) + parent_dir = File.dirname(path) + + msg = "Cert #{path} has expired. Check the #{parent_dir} for a `.rb` with instructions on how to regenerate." + + assert(cert.not_after > EXPIRE_THRESHOLD, msg) + end + end +end diff -Nru puma-5.6.5/test/test_http10.rb puma-6.4.2/test/test_http10.rb --- puma-5.6.5/test/test_http10.rb 2022-08-22 23:33:14.000000000 +0000 +++ puma-6.4.2/test/test_http10.rb 2024-01-08 05:53:42.000000000 +0000 @@ -15,7 +15,7 @@ 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 'HTTP/1.0', req['SERVER_PROTOCOL'] assert_equal '/', req['REQUEST_URI'] assert_equal 'GET', req['REQUEST_METHOD'] assert_nil req['FRAGMENT'] diff -Nru puma-5.6.5/test/test_http11.rb puma-6.4.2/test/test_http11.rb --- puma-5.6.5/test/test_http11.rb 2022-08-22 23:33:14.000000000 +0000 +++ puma-6.4.2/test/test_http11.rb 2024-01-08 05:53:42.000000000 +0000 @@ -22,7 +22,7 @@ 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 'HTTP/1.1', req['SERVER_PROTOCOL'] assert_equal '/?a=1', req['REQUEST_URI'] assert_equal 'GET', req['REQUEST_METHOD'] assert_nil req['FRAGMENT'] @@ -63,7 +63,7 @@ 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_equal 'HTTP/1.1', req['SERVER_PROTOCOL'] assert_nil req['REQUEST_PATH'] assert_nil req['FRAGMENT'] @@ -114,7 +114,6 @@ 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" diff -Nru puma-5.6.5/test/test_integration_cluster.rb puma-6.4.2/test/test_integration_cluster.rb --- puma-5.6.5/test/test_integration_cluster.rb 2022-08-22 23:33:14.000000000 +0000 +++ puma-6.4.2/test/test_integration_cluster.rb 2024-01-08 05:53:42.000000000 +0000 @@ -1,6 +1,8 @@ require_relative "helper" require_relative "helpers/integration" +require "puma/configuration" + require "time" class TestIntegrationCluster < TestIntegration @@ -88,7 +90,6 @@ 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 @@ -117,6 +118,8 @@ end def test_term_exit_code + skip_unless_signal_exist? :TERM + cli_server "-w #{workers} test/rackup/hello.ru" _, status = stop_server @@ -124,6 +127,8 @@ end def test_term_suppress + skip_unless_signal_exist? :TERM + cli_server "-w #{workers} -C test/config/suppress_exception.rb test/rackup/hello.ru" _, status = stop_server @@ -131,9 +136,16 @@ assert_equal 0, status end - def test_term_worker_clean_exit - skip "Intermittent failure on Ruby 2.2" if RUBY_VERSION < '2.3' + def test_on_booted + skip_unless_signal_exist? :TERM + cli_server "-w #{workers} -C test/config/event_on_booted.rb -C test/config/event_on_booted_exit.rb test/rackup/hello.ru", + no_wait: true + assert wait_for_server_to_include('on_booted called') + end + + def test_term_worker_clean_exit + skip_unless_signal_exist? :TERM cli_server "-w #{workers} test/rackup/hello.ru" # Get the PIDs of the child workers. @@ -170,37 +182,60 @@ end def test_worker_check_interval + # iso8601 2022-12-14T00:05:49Z + re_8601 = /\A\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z\z/ @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"]) + checkin_1 = get_stats["worker_status"].first["last_checkin"] + assert_match re_8601, checkin_1 + last_checkin_1 = Time.parse checkin_1 sleep worker_check_interval + 1 - last_checkin_2 = Time.parse(get_stats["worker_status"].first["last_checkin"]) + checkin_2 = get_stats["worker_status"].first["last_checkin"] + assert_match re_8601, checkin_2 + last_checkin_2 = Time.parse checkin_2 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} }") + worker_timeout(timeout, 2, "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 + cli_server '', config: <<~RUBY + workers 1 + fork_worker 0 + app do |_| + pid = spawn('ls', [:out, :err]=>'/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: < '2'}, + config: <<~CONFIG + on_worker_boot(:test) do |index, data| + data[:test] = index + end + CONFIG + + get_worker_pids + line = @server_log[/.+on_worker_boot.+/] + refute line, "Warning below should not be shown!\n#{line}" + end + + def test_puma_debug_loaded_exts + cli_server "-w #{workers} test/rackup/hello.ru", puma_debug: true + + assert wait_for_server_to_include('Loaded Extensions - worker 0:') + assert wait_for_server_to_include('Loaded Extensions - master:') + @pid = @server.pid + end + private def worker_timeout(timeout, iterations, details, config) cli_server "-w #{workers} -t 1:1 test/rackup/hello.ru", config: config pids = [] + re = /Terminating timed out worker \(Worker \d+ #{details}\): (\d+)/ + + # below is messy code, for debugging Timeout.timeout(iterations * timeout + 1) do - (pids << @server.gets[/Terminating timed out worker \(#{details}\): (\d+)/, 1]).compact! while pids.size < workers * iterations - pids.map!(&:to_i) + while (pids.size < workers * iterations) + t = @server.gets + (idx = t[re, 1]&.to_i) and pids << idx + end end assert_equal pids, pids.uniq @@ -551,7 +659,9 @@ 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 + t = replies.map { |body| body[/\d+\z/] } + t.uniq!; t.compact! + qty_pids = t.length msg = "#{responses} responses, #{qty_pids} uniq pids" @@ -622,13 +732,14 @@ # 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| + t = pids.map do |pid| begin pid if Process.kill 0, pid rescue Errno::ESRCH nil end - end.compact + end + t.compact!; t end # used in loop to create several 'requests' diff -Nru puma-5.6.5/test/test_integration_pumactl.rb puma-6.4.2/test/test_integration_pumactl.rb --- puma-5.6.5/test/test_integration_pumactl.rb 2022-08-22 23:33:14.000000000 +0000 +++ puma-6.4.2/test/test_integration_pumactl.rb 2024-01-08 05:53:42.000000000 +0000 @@ -9,15 +9,14 @@ def setup super - - @state_path = tmp_path('.state') - @control_path = tmp_path('.sock') + @control_path = nil + @state_path = tmp_path('.state') end def teardown super - refute File.exist?(@control_path), "Control path must be removed after stop" + refute @control_path && File.exist?(@control_path), "Control path must be removed after stop" ensure [@state_path, @control_path].each { |p| File.unlink(p) rescue nil } end @@ -25,7 +24,7 @@ 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_server "-q test/rackup/sleep.ru #{set_pumactl_args} -S #{@state_path}" cli_pumactl "stop" @@ -46,7 +45,8 @@ 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}", + + cli_server "-q test/rackup/sleep.ru #{set_pumactl_args unix: true} -S #{@state_path}", config: "stdout_redirect nil, '#{stderr.path}'", unix: true @@ -60,9 +60,9 @@ 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 + cli_server "-q -w #{workers} test/rackup/sleep.ru #{set_pumactl_args unix: true} -S #{@state_path}", unix: true - start = Time.now + start = Process.clock_gettime(Process::CLOCK_MONOTONIC) s = UNIXSocket.new @bind_path @ios_to_close << s @@ -89,22 +89,20 @@ _, status = Process.wait2(@pid) assert_equal 0, status - assert_operator Time.now - start, :<, (DARWIN ? 8 : 6) + assert_operator Process.clock_gettime(Process::CLOCK_MONOTONIC) - start, :<, (DARWIN ? 8 : 7) @server = nil end def test_refork_cluster skip_unless :fork wrkrs = 3 - cli_server "-q -w #{wrkrs} test/rackup/sleep.ru --control-url unix://#{@control_path} --control-token #{TOKEN} -S #{@state_path}", + cli_server "-q -w #{wrkrs} test/rackup/sleep.ru #{set_pumactl_args unix: true} -S #{@state_path}", config: 'fork_worker 50', unix: true start = Time.now - s = UNIXSocket.new @bind_path - @ios_to_close << s - s << "GET /sleep1 HTTP/1.0\r\n\r\n" + fast_connect("sleep1", unix: true) # Get the PIDs of the phase 0 workers. phase0_worker_pids = get_worker_pids 0, wrkrs @@ -133,16 +131,13 @@ 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" + cli_server "-q -C test/config/prune_bundler_with_multiple_workers.rb #{set_pumactl_args unix: true} -S #{@state_path}", unix: true - body = s.read + socket = fast_connect(unix: true) + headers, body = read_response(socket) - assert_match "200 OK", body - assert_match "embedded app", body + assert_includes headers, "200 OK" + assert_includes body, "embedded app" cli_pumactl "stop", unix: true @@ -169,4 +164,78 @@ assert_match(/No pid '\d+' found|bad URI\(is not URI\?\)/, sout.readlines.join("")) assert_equal(1, e.status) end + + # calls pumactl with both a config file and a state file, making sure that + # puma files are required, see https://github.com/puma/puma/issues/3186 + def test_require_dependencies + skip_if :jruby + conf_path = tmp_path '.config.rb' + @tcp_port = UniquePort.call + @control_tcp_port = UniquePort.call + + File.write conf_path , <<~CONF + state_path "#{@state_path}" + bind "tcp://127.0.0.1:#{@tcp_port}" + + workers 0 + + before_fork do + end + + activate_control_app "tcp://127.0.0.1:#{@control_tcp_port}", auth_token: "#{TOKEN}" + + app do |env| + [200, {}, ["Hello World"]] + end + CONF + + cli_server "-q -C #{conf_path}", no_bind: true, merge_err: true + + out = cli_pumactl_spawn "-F #{conf_path} restart", no_bind: true + + assert_includes out.read, "Command restart sent success" + + sleep 0.5 # give some time to restart + read_response connect + + out = cli_pumactl_spawn "-S #{@state_path} status", no_bind: true + assert_includes out.read, "Puma is started" + end + + def control_gc_stats(unix: false) + cli_server "-t1:1 -q test/rackup/hello.ru #{set_pumactl_args unix: unix} -S #{@state_path}" + + key = Puma::IS_MRI || TRUFFLE_HEAD ? "count" : "used" + + resp_io = cli_pumactl "gc-stats", unix: unix + before = JSON.parse resp_io.read.split("\n", 2).last + gc_before = before[key].to_i + + 2.times { fast_connect } + + resp_io = cli_pumactl "gc", unix: unix + # below shows gc was called (200 reply) + assert_equal "Command gc sent success", resp_io.read.rstrip + + resp_io = cli_pumactl "gc-stats", unix: unix + after = JSON.parse resp_io.read.split("\n", 2).last + gc_after = after[key].to_i + + # Hitting the /gc route should increment the count by 1 + if key == "count" + assert_operator gc_before, :<, gc_after, "make sure a gc has happened" + elsif !(Puma::IS_OSX && Puma::IS_JRUBY) + refute_equal gc_before, gc_after, "make sure a gc has happened" + end + end + + def test_control_gc_stats_tcp + @control_tcp_port = UniquePort.call + control_gc_stats + end + + def test_control_gc_stats_unix + skip_unless :unix + control_gc_stats unix: true + end end diff -Nru puma-5.6.5/test/test_integration_single.rb puma-6.4.2/test/test_integration_single.rb --- puma-5.6.5/test/test_integration_single.rb 2022-08-22 23:33:14.000000000 +0000 +++ puma-6.4.2/test/test_integration_single.rb 2024-01-08 05:53:42.000000000 +0000 @@ -7,11 +7,16 @@ 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 + ttl_reqs = Puma.windows? ? 500 : 1_000 + hot_restart_does_not_drop_connections num_threads: 5, total_requests: ttl_reqs end def test_hot_restart_does_not_drop_connections - hot_restart_does_not_drop_connections + if Puma.windows? + hot_restart_does_not_drop_connections total_requests: 300 + else + hot_restart_does_not_drop_connections + end end def test_usr2_restart @@ -44,6 +49,15 @@ assert_equal 15, status end + def test_on_booted + skip_unless_signal_exist? :TERM + + cli_server "-C test/config/event_on_booted.rb -C test/config/event_on_booted_exit.rb test/rackup/hello.ru", + no_wait: true + + assert wait_for_server_to_include('on_booted called') + end + def test_term_suppress skip_unless_signal_exist? :TERM @@ -53,6 +67,28 @@ assert_equal 0, status end + def test_rack_url_scheme_default + skip_unless_signal_exist? :TERM + + cli_server("test/rackup/url_scheme.ru") + + reply = read_body(connect) + stop_server + + assert_match("http", reply) + end + + def test_conf_is_loaded_before_passing_it_to_binder + skip_unless_signal_exist? :TERM + + cli_server("-C test/config/rack_url_scheme.rb test/rackup/url_scheme.ru") + + reply = read_body(connect) + stop_server + + assert_match("https", reply) + end + def test_prefer_rackup_file_specified_by_cli skip_unless_signal_exist? :TERM @@ -73,7 +109,7 @@ sleep 1 # ensure curl send a request Process.kill :TERM, @pid - true while @server.gets !~ /Gracefully stopping/ # wait for server to begin graceful shutdown + assert wait_for_server_to_include('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}") @@ -85,7 +121,7 @@ rejected_curl_wait_thread.join assert_match(/Slept 10/, curl_stdout.read) - assert_match(/Connection refused/, rejected_curl_stderr.read) + assert_match(/Connection refused|Couldn't connect to server/, rejected_curl_stderr.read) Process.wait(@server.pid) @server.close unless @server.closed? @@ -136,20 +172,18 @@ 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) + ensure + File.unlink 't1-stdout' if File.file? 't1-stdout' + File.unlink 't1-pid' if File.file? 't1-pid' 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}" + system "curl http://localhost:#{@tcp_port}/ > /dev/null 2>&1" out=`#{BASE} bin/pumactl -F test/config/t2_conf.rb status` @@ -157,22 +191,21 @@ 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) + ensure + File.unlink 't2-stdout' if File.file? 't2-stdout' 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" + cli_server "#{set_pumactl_args} test/rackup/write_to_stdout.ru" read_body connect cli_pumactl 'stop' - assert_equal "hello\n", @server.gets + assert wait_for_server_to_include("hello\n") assert_includes @server.read, 'Goodbye!' @server.close unless @server.closed? @@ -212,4 +245,48 @@ end assert true end + + def test_puma_debug_loaded_exts + cli_server "#{set_pumactl_args} test/rackup/hello.ru", puma_debug: true + + assert wait_for_server_to_include('Loaded Extensions:') + + cli_pumactl 'stop' + end + + def test_idle_timeout + cli_server "test/rackup/hello.ru", config: "idle_timeout 1" + + connect + + sleep 1.15 + + assert_raises Errno::ECONNREFUSED, "Connection refused" do + connect + end + end + + def test_pre_existing_unix_after_idle_timeout + skip_unless :unix + + File.open(@bind_path, mode: 'wb') { |f| f.puts 'pre existing' } + + cli_server "-q test/rackup/hello.ru", unix: :unix, config: "idle_timeout 1" + + sock = connection = connect(nil, unix: true) + read_body(connection) + + sleep 1.15 + + assert sock.wait_readable(1), 'Unexpected timeout' + assert_raises Puma.jruby? ? IOError : Errno::ECONNREFUSED, "Connection refused" do + connection = connect(nil, unix: true) + end + + assert File.exist?(@bind_path) + ensure + if UNIX_SKT_EXIST + File.unlink @bind_path if File.exist? @bind_path + end + end end diff -Nru puma-5.6.5/test/test_integration_ssl.rb puma-6.4.2/test/test_integration_ssl.rb --- puma-5.6.5/test/test_integration_ssl.rb 2022-08-22 23:33:14.000000000 +0000 +++ puma-6.4.2/test/test_integration_ssl.rb 2024-01-08 05:53:42.000000000 +0000 @@ -1,6 +1,12 @@ require_relative 'helper' require_relative "helpers/integration" +if ::Puma::HAS_SSL # don't load any files if no ssl support + require "net/http" + require "openssl" + require_relative "helpers/test_puma/puma_socket" +end + # 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. @@ -12,8 +18,9 @@ class TestIntegrationSSL < TestIntegration parallelize_me! if ::Puma.mri? - require "net/http" - require "openssl" + LOCALHOST = ENV.fetch 'PUMA_CI_DFLT_HOST', 'localhost' + + include TestPuma::PumaSocket def teardown @server.close if @server && !@server.closed? @@ -56,33 +63,34 @@ end def test_ssl_run - config = < 'text/plain' }, ["HELLO", ' ', "THERE"]] } - server = Puma::Server.new(app) - server.max_threads = 1 + opts = {max_threads: 1} + server = Puma::Server.new app, nil, opts if Puma.jruby? ssl_params = { 'keystore' => File.expand_path('../examples/puma/client-certs/keystore.jks', __dir__), @@ -113,10 +177,8 @@ end ssl_params['verify_mode'] = 'force_peer' # 'peer' out_err = StringIO.new - ssl_context = defined?(Puma::LogWriter) ? - Puma::MiniSSL::ContextBuilder.new(ssl_params, Puma::LogWriter.new(out_err, out_err)).context : - Puma::MiniSSL::ContextBuilder.new(ssl_params, Puma::Events.new(out_err, out_err)).context - server.add_ssl_listener(HOST, bind_port, ssl_context) + ssl_context = Puma::MiniSSL::ContextBuilder.new(ssl_params, Puma::LogWriter.new(out_err, out_err)).context + server.add_ssl_listener(LOCALHOST, bind_port, ssl_context) server.run(true) begin @@ -126,7 +188,7 @@ # NOTE: JRuby used to end up in a hang with TLS peer verification enabled # it's easier to reproduce using an external client such as CURL (using net/http client the bug isn't triggered) # also the "hang", being buffering related, seems to showcase better with TLS 1.2 than 1.3 - body = curl_and_get_response "https://localhost:#{bind_port}", + body = curl_and_get_response "https://#{LOCALHOST}:#{bind_port}", args: "--cacert #{ca} --cert #{cert} --key #{key} --tlsv1.2 --tls-max 1.2" warn out_err.string unless out_err.string.empty? @@ -140,22 +202,22 @@ def test_ssl_run_with_pem skip_if :jruby - config = <(ary) { session_pems << ary.last.to_pem } + + skt = OSSL::SSLSocket.new TCPSocket.new(HOST, bind_port), ctx + skt.sync_close = true + skt + end + + def ssl_client(tls_vers: nil) + session_pems = [] + skt = client_skt tls_vers, session_pems + skt.connect + + skt.syswrite GET + skt.to_io.wait_readable 2 + assert_equal RESP, skt.sysread(1_024) + skt.sysclose + + skt = client_skt tls_vers, session_pems + skt.session = OSSL::Session.new(session_pems[0]) + skt.connect + + skt.syswrite GET + skt.to_io.wait_readable 2 + assert_equal RESP, skt.sysread(1_024) + + skt.session_reused? + ensure + skt&.sysclose unless skt&.closed? + end +end if Puma::HAS_SSL && Puma::IS_MRI diff -Nru puma-5.6.5/test/test_integration_systemd.rb puma-6.4.2/test/test_integration_systemd.rb --- puma-5.6.5/test/test_integration_systemd.rb 2022-08-22 23:33:14.000000000 +0000 +++ puma-6.4.2/test/test_integration_systemd.rb 1970-01-01 00:00:00.000000000 +0000 @@ -1,83 +0,0 @@ -require_relative "helper" -require_relative "helpers/integration" - -require 'sd_notify' - -class TestIntegrationSystemd < TestIntegration - def setup - skip "Skipped because Systemd support is linux-only" if windows? || osx? - skip_unless :unix - skip_unless_signal_exist? :TERM - skip_if :jruby - - super - - ::Dir::Tmpname.create("puma_socket") do |sockaddr| - @sockaddr = sockaddr - @socket = Socket.new(:UNIX, :DGRAM, 0) - socket_ai = Addrinfo.unix(sockaddr) - @socket.bind(socket_ai) - ENV["NOTIFY_SOCKET"] = sockaddr - end - end - - def teardown - return if skipped? - @socket.close if @socket - File.unlink(@sockaddr) if @sockaddr - @socket = nil - @sockaddr = nil - ENV["NOTIFY_SOCKET"] = nil - ENV["WATCHDOG_USEC"] = nil - end - - def test_systemd_notify_usr1_phased_restart_cluster - skip_unless :fork - assert_restarts_with_systemd :USR1 - end - - def test_systemd_notify_usr2_hot_restart_cluster - skip_unless :fork - assert_restarts_with_systemd :USR2 - end - - def test_systemd_notify_usr2_hot_restart_single - assert_restarts_with_systemd :USR2, workers: 0 - end - - def test_systemd_watchdog - ENV["WATCHDOG_USEC"] = "1_000_000" - - cli_server "test/rackup/hello.ru" - assert_equal(socket_message, "READY=1") - - assert_equal(socket_message, "WATCHDOG=1") - - stop_server - assert_match(socket_message, "STOPPING=1") - end - - private - - def assert_restarts_with_systemd(signal, workers: 2) - cli_server "-w#{workers} test/rackup/hello.ru" - assert_equal socket_message, 'READY=1' - - Process.kill signal, @pid - connect.write "GET / HTTP/1.1\r\n\r\n" - assert_equal socket_message, 'RELOADING=1' - assert_equal socket_message, 'READY=1' - - Process.kill signal, @pid - connect.write "GET / HTTP/1.1\r\n\r\n" - assert_equal socket_message, 'RELOADING=1' - assert_equal socket_message, 'READY=1' - - stop_server - assert_equal socket_message, 'STOPPING=1' - end - - def socket_message - @socket.recvfrom(15)[0] - end -end diff -Nru puma-5.6.5/test/test_launcher.rb puma-6.4.2/test/test_launcher.rb --- puma-5.6.5/test/test_launcher.rb 2022-08-22 23:33:14.000000000 +0000 +++ puma-6.4.2/test/test_launcher.rb 2024-01-08 05:53:42.000000000 +0000 @@ -2,60 +2,11 @@ require_relative "helpers/tmp_path" require "puma/configuration" -require 'puma/events' +require 'puma/log_writer' 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 @@ -72,7 +23,7 @@ launcher(conf).write_state assert_equal File.read(pid_path).strip.to_i, Process.pid - + ensure File.unlink pid_path end @@ -148,7 +99,7 @@ end launcher = launcher(conf) Thread.new do - sleep Puma::ConfigDefault::DefaultWorkerCheckInterval + 1 + sleep Puma::Configuration::DEFAULTS[:worker_check_interval] + 1 status = Puma.stats_hash[:worker_status].first[:last_status] Puma::Server::STAT_METHODS.each do |stat| assert_includes status, stat @@ -161,17 +112,17 @@ def test_log_config_enabled ENV['PUMA_LOG_CONFIG'] = "1" - assert_match(/Configuration:/, launcher.events.stdout.string) + assert_match(/Configuration:/, launcher.log_writer.stdout.string) launcher.config.final_options.each do |config_key, _value| - assert_match(/#{config_key}/, launcher.events.stdout.string) + assert_match(/#{config_key}/, launcher.log_writer.stdout.string) end ENV.delete('PUMA_LOG_CONFIG') end def test_log_config_disabled - refute_match(/Configuration:/, launcher.events.stdout.string) + refute_match(/Configuration:/, launcher.log_writer.stdout.string) end def test_fire_on_stopped @@ -196,11 +147,11 @@ private - def events - @events ||= Puma::Events.strings + def log_writer + @log_writer ||= Puma::LogWriter.strings end - def launcher(config = Puma::Configuration.new, evts = events) - @launcher ||= Puma::Launcher.new(config, events: evts) + def launcher(config = Puma::Configuration.new, lw = log_writer) + @launcher ||= Puma::Launcher.new(config, log_writer: lw) end end diff -Nru puma-5.6.5/test/test_log_writer.rb puma-6.4.2/test/test_log_writer.rb --- puma-5.6.5/test/test_log_writer.rb 1970-01-01 00:00:00.000000000 +0000 +++ puma-6.4.2/test/test_log_writer.rb 2024-01-08 05:53:42.000000000 +0000 @@ -0,0 +1,182 @@ +require 'puma/detect' +require 'puma/log_writer' +require_relative "helper" + +class TestLogWriter < Minitest::Test + def test_null + log_writer = Puma::LogWriter.null + + assert_instance_of Puma::NullIO, log_writer.stdout + assert_instance_of Puma::NullIO, log_writer.stderr + assert_equal log_writer.stdout, log_writer.stderr + end + + def test_strings + log_writer = Puma::LogWriter.strings + + assert_instance_of StringIO, log_writer.stdout + assert_instance_of StringIO, log_writer.stderr + end + + def test_stdio + log_writer = Puma::LogWriter.stdio + + assert_equal STDOUT, log_writer.stdout + assert_equal STDERR, log_writer.stderr + end + + def test_stdio_respects_sync + log_writer = Puma::LogWriter.stdio + + assert_equal STDOUT.sync, log_writer.stdout.sync + assert_equal STDERR.sync, log_writer.stderr.sync + assert_equal STDOUT, log_writer.stdout + assert_equal STDERR, log_writer.stderr + end + + def test_log_writes_to_stdout + out, _ = capture_io do + Puma::LogWriter.stdio.log("ready") + end + + assert_equal "ready\n", out + end + + def test_null_log_does_nothing + out, _ = capture_io do + Puma::LogWriter.null.log("ready") + end + + assert_equal "", out + end + + def test_write_writes_to_stdout + out, _ = capture_io do + Puma::LogWriter.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::LogWriter.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::LogWriter.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::LogWriter.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 + log_writer = Puma::LogWriter.stdio + + log_writer.formatter = Puma::LogWriter::PidFormatter.new + + log_writer.write("ready") + end + + assert_equal "[#{ pid }] ready", out + end + + def test_custom_log_formatter + custom_formatter = proc { |str| "-> #{ str }" } + + out, _ = capture_io do + log_writer = Puma::LogWriter.stdio + + log_writer.formatter = custom_formatter + + log_writer.write("ready") + end + + assert_equal "-> ready", out + end + + def test_parse_error + app = proc { |_env| [200, {"Content-Type" => "plain/text"}, ["hello\n"]] } + log_writer = Puma::LogWriter.strings + server = Puma::Server.new app, nil, {log_writer: log_writer} + + host = '127.0.0.1' + 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!, log_writer.stderr.string + assert_match %r!\("GET #{path}" - \(-\)\)!, log_writer.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 + log_writer = Puma::LogWriter.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 + } + + log_writer.ssl_error OpenSSL::SSL::SSLError, ssl_mock.call(['127.0.0.1'], 'test_cert') + error = log_writer.stderr.string + assert_includes error, "SSL error" + assert_includes error, "peer: 127.0.0.1" + assert_includes error, "cert: test_cert" + + log_writer.stderr.string = '' + + log_writer.ssl_error OpenSSL::SSL::SSLError, ssl_mock.call(nil, nil) + error = log_writer.stderr.string + assert_includes error, "SSL error" + assert_includes error, "peer: " + assert_includes error, "cert: :" + + end if ::Puma::HAS_SSL +end diff -Nru puma-5.6.5/test/test_minissl.rb puma-6.4.2/test/test_minissl.rb --- puma-5.6.5/test/test_minissl.rb 2022-08-22 23:33:14.000000000 +0000 +++ puma-6.4.2/test/test_minissl.rb 2024-01-08 05:53:42.000000000 +0000 @@ -83,5 +83,12 @@ exception = assert_raises(ArgumentError) { ctx.cert_pem = nil } assert_equal("'cert_pem' is not a String", exception.message) end + + def test_raises_with_invalid_key_password_command + ctx = Puma::MiniSSL::Context.new + ctx.key_password_command = '/unreadable/decrypt_command' + + assert_raises(Errno::ENOENT) { ctx.key_password } + end end end if ::Puma::HAS_SSL diff -Nru puma-5.6.5/test/test_null_io.rb puma-6.4.2/test/test_null_io.rb --- puma-5.6.5/test/test_null_io.rb 2022-08-22 23:33:14.000000000 +0000 +++ puma-6.4.2/test/test_null_io.rb 2024-01-08 05:53:42.000000000 +0000 @@ -47,9 +47,72 @@ assert_nil nio.read(1) end + def test_read_with_negative_length + error = assert_raises ArgumentError do + nio.read(-42) + end + # 2nd match is TruffleRuby + assert_match(/negative length -42 given|length must not be negative/, error.message) + end + + def test_read_with_nil_buffer + assert_equal "", nio.read(nil, nil) + assert_equal "", nio.read(0, nil) + assert_nil nio.read(1, nil) + end + + class ImplicitString + def to_str + "ImplicitString".b + end + end + + def test_read_with_implicit_string_like_buffer + assert_equal "", nio.read(nil, ImplicitString.new) + end + + def test_read_with_invalid_buffer + error = assert_raises TypeError do + nio.read(nil, Object.new) + end + assert_includes error.message, "no implicit conversion of Object into String" + + error = assert_raises TypeError do + nio.read(0, Object.new) + end + + error = assert_raises TypeError do + nio.read(1, Object.new) + end + assert_includes error.message, "no implicit conversion of Object into String" + end + + def test_read_with_frozen_buffer + # Remove when Ruby 2.4 is no longer supported + err = defined? ::FrozenError ? ::FrozenError : ::RuntimeError + + assert_raises err do + nio.read(nil, "".freeze) + end + + assert_raises err do + nio.read(0, "".freeze) + end + + assert_raises err do + nio.read(20, "".freeze) + end + end + def test_read_with_length_and_buffer - buf = "" + buf = "random_data".b assert_nil nio.read(1, buf) + assert_equal "".b, buf + end + + def test_read_with_buffer + buf = "random_data".b + assert_same buf, nio.read(nil, buf) assert_equal "", buf end @@ -69,3 +132,23 @@ assert_equal false, nio.closed? end end + +# Run the same tests but against an empty file to +# ensure all the test behavior is accurate +class TestNullIOConformance < TestNullIO + def setup + self.nio = ::Tempfile.create + nio.sync = true + end + + def teardown + return unless nio.is_a? ::File + nio.close + File.unlink nio.path + end + + def test_string_returns_empty_string + self.nio = StringIO.new + super + end +end diff -Nru puma-5.6.5/test/test_out_of_band_server.rb puma-6.4.2/test/test_out_of_band_server.rb --- puma-5.6.5/test/test_out_of_band_server.rb 2022-08-22 23:33:14.000000000 +0000 +++ puma-6.4.2/test/test_out_of_band_server.rb 2024-01-08 05:53:42.000000000 +0000 @@ -1,5 +1,4 @@ require_relative "helper" -require "puma/events" class TestOutOfBandServer < Minitest::Test parallelize_me! @@ -14,8 +13,16 @@ def teardown @oob_finished.broadcast @app_finished.broadcast - @server.stop(true) if @server - @ios.each {|i| i.close unless i.closed?} + @server&.stop true + + @ios.each do |io| + begin + io.close if io.is_a?(IO) && !io.closed? + rescue + ensure + io = nil + end + end end def new_connection @@ -59,9 +66,11 @@ [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 + options[:min_threads] ||= 1 + options[:max_threads] ||= 1 + options[:log_writer] ||= Puma::LogWriter.strings + + @server = Puma::Server.new app, nil, out_of_band: [oob], **options @port = (@server.add_tcp_listener '127.0.0.1', 0).addr[1] @server.run sleep 0.15 if Puma.jruby? diff -Nru puma-5.6.5/test/test_persistent.rb puma-6.4.2/test/test_persistent.rb --- puma-5.6.5/test/test_persistent.rb 2022-08-22 23:33:14.000000000 +0000 +++ puma-6.4.2/test/test_persistent.rb 2024-01-08 05:53:42.000000000 +0000 @@ -23,9 +23,9 @@ [status, @headers, @body] end - @server = Puma::Server.new @simple + opts = {max_threads: 1} + @server = Puma::Server.new @simple, nil, opts @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 @@ -37,7 +37,7 @@ end def lines(count, s=@client) - str = "".dup + str = +'' Timeout.timeout(5) do count.times { str << (s.gets || "") } end @@ -93,6 +93,7 @@ def test_chunked @body << "Chunked" + @body = @body.to_enum @client << @valid_request @@ -102,6 +103,7 @@ def test_chunked_with_empty_part @body << "" @body << "Chunked" + @body = @body.to_enum @client << @valid_request @@ -110,6 +112,7 @@ def test_no_chunked_in_http10 @body << "Chunked" + @body = @body.to_enum @client << @http10_request @@ -120,6 +123,7 @@ def test_hex str = "This is longer and will be in hex" @body << str + @body = @body.to_enum @client << @valid_request @@ -152,7 +156,7 @@ end def test_persistent_timeout - @server.persistent_timeout = 1 + @server.instance_variable_set(:@persistent_timeout, 1) @client << @valid_request sz = @body[0].size.to_s @@ -189,7 +193,7 @@ def test_two_requests_in_one_chunk - @server.persistent_timeout = 3 + @server.instance_variable_set(:@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" @@ -206,7 +210,7 @@ end def test_second_request_not_in_first_req_body - @server.persistent_timeout = 3 + @server.instance_variable_set(:@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" @@ -233,10 +237,7 @@ 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 c2.wait_readable(1), "2nd request starved" 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) diff -Nru puma-5.6.5/test/test_plugin.rb puma-6.4.2/test/test_plugin.rb --- puma-5.6.5/test/test_plugin.rb 2022-08-22 23:33:14.000000000 +0000 +++ puma-6.4.2/test/test_plugin.rb 2024-01-08 05:53:42.000000000 +0000 @@ -4,36 +4,24 @@ 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 + @control_tcp_port = 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" + cli_server "--control-url tcp://#{HOST}:#{@control_tcp_port} --control-token #{TOKEN} test/rackup/hello.ru", + config: "plugin 'tmp_restart'" + File.open('tmp/restart.txt', mode: 'wb') { |f| f.puts "Restart #{Time.now}" } - true while (l = @server.gets) !~ /Restarting\.\.\./ - assert_match(/Restarting\.\.\./, l) + assert wait_for_server_to_include('Restarting...') - true while (l = @server.gets) !~ /Ctrl-C/ - assert_match(/Ctrl-C/, l) + assert wait_for_server_to_boot - out = StringIO.new + cli_pumactl "stop" - cli_pumactl "-C tcp://#{HOST}:#{@tcp_ctrl} -T #{TOKEN} stop" - true while (l = @server.gets) !~ /Goodbye/ + assert wait_for_server_to_include('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 -Nru puma-5.6.5/test/test_plugin_systemd.rb puma-6.4.2/test/test_plugin_systemd.rb --- puma-5.6.5/test/test_plugin_systemd.rb 1970-01-01 00:00:00.000000000 +0000 +++ puma-6.4.2/test/test_plugin_systemd.rb 2024-01-08 05:53:42.000000000 +0000 @@ -0,0 +1,107 @@ +require_relative "helper" +require_relative "helpers/integration" + +class TestPluginSystemd < TestIntegration + parallelize_me! if ::Puma.mri? + + THREAD_LOG = TRUFFLE ? "{ 0/16 threads, 16 available, 0 backlog }" : + "{ 0/5 threads, 5 available, 0 backlog }" + + def setup + skip_unless :linux + skip_unless :unix + skip_unless_signal_exist? :TERM + skip_if :jruby + + super + + ::Dir::Tmpname.create("puma_socket") do |sockaddr| + @sockaddr = sockaddr + @socket = Socket.new(:UNIX, :DGRAM, 0) + socket_ai = Addrinfo.unix(sockaddr) + @socket.bind(socket_ai) + @env = {"NOTIFY_SOCKET" => sockaddr } + end + end + + def teardown + return if skipped? + @socket&.close + File.unlink(@sockaddr) if @sockaddr + @socket = nil + @sockaddr = nil + end + + def test_systemd_notify_usr1_phased_restart_cluster + skip_unless :fork + assert_restarts_with_systemd :USR1 + end + + def test_systemd_notify_usr2_hot_restart_cluster + skip_unless :fork + assert_restarts_with_systemd :USR2 + end + + def test_systemd_notify_usr2_hot_restart_single + assert_restarts_with_systemd :USR2, workers: 0 + end + + def test_systemd_watchdog + wd_env = @env.merge({"WATCHDOG_USEC" => "1_000_000"}) + cli_server "test/rackup/hello.ru", env: wd_env + assert_message "READY=1" + + assert_message "WATCHDOG=1" + + stop_server + assert_includes @socket.recvfrom(15)[0], "STOPPING=1" + end + + def test_systemd_notify + cli_server "test/rackup/hello.ru", env: @env + assert_message "READY=1" + + assert_message "STATUS=Puma #{Puma::Const::VERSION}: worker: #{THREAD_LOG}" + + stop_server + assert_message "STOPPING=1" + end + + def test_systemd_cluster_notify + skip_unless :fork + cli_server "-w2 test/rackup/hello.ru", env: @env + assert_message "READY=1" + + assert_message( + "STATUS=Puma #{Puma::Const::VERSION}: cluster: 2/2, worker_status: [#{THREAD_LOG},#{THREAD_LOG}]") + + stop_server + assert_message "STOPPING=1" + end + + private + + def assert_restarts_with_systemd(signal, workers: 2) + skip_unless(:fork) unless workers.zero? + cli_server "-w#{workers} test/rackup/hello.ru", env: @env + assert_message 'READY=1' + + Process.kill signal, @pid + connect.write "GET / HTTP/1.1\r\n\r\n" + assert_message 'RELOADING=1' + assert_message 'READY=1' + + Process.kill signal, @pid + connect.write "GET / HTTP/1.1\r\n\r\n" + assert_message 'RELOADING=1' + assert_message 'READY=1' + + stop_server + assert_message 'STOPPING=1' + end + + def assert_message(msg) + @socket.wait_readable 1 + assert_equal msg, @socket.recvfrom(msg.bytesize)[0] + end +end diff -Nru puma-5.6.5/test/test_plugin_systemd_jruby.rb puma-6.4.2/test/test_plugin_systemd_jruby.rb --- puma-5.6.5/test/test_plugin_systemd_jruby.rb 1970-01-01 00:00:00.000000000 +0000 +++ puma-6.4.2/test/test_plugin_systemd_jruby.rb 2024-01-08 05:53:42.000000000 +0000 @@ -0,0 +1,35 @@ +# frozen_string_literal: true + +require_relative "helper" +require_relative "helpers/integration" + +require "puma/plugin" + +class TestPluginSystemdJruby < TestIntegration + + THREAD_LOG = TRUFFLE ? "{ 0/16 threads, 16 available, 0 backlog }" : + "{ 0/5 threads, 5 available, 0 backlog }" + + def setup + skip_unless :linux + skip_unless :unix + skip_unless_signal_exist? :TERM + skip_unless :jruby + + super + + ENV["NOTIFY_SOCKET"] = "/tmp/doesntmatter" + end + + def teardown + super unless skipped? + end + + def test_systemd_plugin_not_loaded + cli_server "test/rackup/hello.ru" + + assert_nil Puma::Plugins.instance_variable_get(:@plugins)["systemd"] + + stop_server + end +end diff -Nru puma-5.6.5/test/test_puma_localhost_authority.rb puma-6.4.2/test/test_puma_localhost_authority.rb --- puma-5.6.5/test/test_puma_localhost_authority.rb 2022-08-22 23:33:14.000000000 +0000 +++ puma-6.4.2/test/test_puma_localhost_authority.rb 2024-01-08 05:53:42.000000000 +0000 @@ -7,40 +7,30 @@ if ::Puma::HAS_SSL && !Puma::IS_JRUBY require "puma/minissl" - require "net/http" - - # net/http (loaded in helper) does not necessarily load OpenSSL + require_relative "helpers/test_puma/puma_socket" require "openssl" unless Object.const_defined? :OpenSSL end class TestPumaLocalhostAuthority < Minitest::Test - parallelize_me! + include TestPuma + include TestPuma::PumaSocket + def setup - @http = nil @server = nil end def teardown - @http.finish if @http && @http.started? - @server.stop(true) if @server + @server&.stop true 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 - + @log_writer = SSLLogWriterHelper.new STDOUT, STDERR + @server = Puma::Server.new app, nil, {log_writer: @log_writer} + @server.add_ssl_listener LOCALHOST, 0, nil + @bind_port = @server.connected_ports[0] @server.run end @@ -53,43 +43,34 @@ assert_equal(File.exist?(File.join(Localhost::Authority.path,"localhost.crt")), true) end -end if ::Puma::HAS_SSL && !Puma::IS_JRUBY +end if ::Puma::HAS_SSL && !Puma::IS_JRUBY class TestPumaSSLLocalhostAuthority < Minitest::Test - def test_self_signed_by_localhost_authority - @host = "localhost" + include TestPuma + include TestPuma::PumaSocket + def test_self_signed_by_localhost_authority app = lambda { |env| [200, {}, [env['rack.url_scheme']]] } - @events = SSLEventsHelper.new STDOUT, STDERR + @log_writer = SSLLogWriterHelper.new STDOUT, STDERR - @server = Puma::Server.new app, @events + @server = Puma::Server.new app, nil, {log_writer: @log_writer} @server.app = app - @server.add_ssl_listener @host, 0,nil - - @http = Net::HTTP.new @host, @server.connected_ports[0] - @http.use_ssl = true + @server.add_ssl_listener LOCALHOST, 0, nil + @bind_port = @server.connected_ports[0] - 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 + cert = nil begin - @http.start do - req = Net::HTTP::Get.new "/", {} - @http.request(req) - @cert = @http.peer_cert - end + cert = send_http(host: LOCALHOST, ctx: new_ctx).peer_cert 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) + assert_equal(cert.to_pem, local_authority_crt.to_pem) end -end if ::Puma::HAS_SSL && !Puma::IS_JRUBY +end if ::Puma::HAS_SSL && !Puma::IS_JRUBY diff -Nru puma-5.6.5/test/test_puma_server.rb puma-6.4.2/test/test_puma_server.rb --- puma-5.6.5/test/test_puma_server.rb 2022-08-22 23:33:14.000000000 +0000 +++ puma-6.4.2/test/test_puma_server.rb 2024-01-08 05:53:42.000000000 +0000 @@ -1,11 +1,19 @@ require_relative "helper" require "puma/events" +require "puma/server" require "net/http" require "nio" require "ipaddr" +class WithoutBacktraceError < StandardError + def backtrace; nil; end + def message; "no backtrace error"; end +end + class TestPumaServer < Minitest::Test - parallelize_me! unless JRUBY_HEAD + parallelize_me! + + STATUS_CODES = ::Puma::HTTP_STATUS_CODES def setup @host = "127.0.0.1" @@ -14,26 +22,38 @@ @app = ->(env) { [200, {}, [env['rack.url_scheme']]] } - @events = Puma::Events.strings - @server = Puma::Server.new @app, @events + @log_writer = Puma::LogWriter.strings + @events = Puma::Events.new + @server = Puma::Server.new @app, @events, {log_writer: @log_writer} end def teardown @server.stop(true) - @ios.each { |io| io.close if io && !io.closed? } + # Errno::EBADF raised on macOS + @ios.each do |io| + begin + io.close if io.respond_to?(:close) && !io.closed? + File.unlink io.path if io.is_a? File + rescue Errno::EBADF + ensure + io = nil + end + end end def server_run(**options, &block) + options[:log_writer] ||= @log_writer + options[:min_threads] ||= 1 @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) + def header(socket) header = [] while true - line = sock.gets + socket.wait_readable 5 + line = socket.gets break if line == "\r\n" header << line.strip end @@ -41,8 +61,17 @@ header end + # only for shorter bodies! + def send_http_and_sysread(req) + socket = send_http(req) + socket.wait_readable 5 + socket.sysread 2_048 + end + def send_http_and_read(req) - send_http(req).read + socket = send_http req + socket.wait_readable 5 + socket.read end def send_http(req) @@ -63,9 +92,8 @@ end end - def new_connection - TCPSocket.new(@host, @port).tap {|sock| @ios << sock} + TCPSocket.new(@host, @port).tap {|socket| @ios << socket} end def test_normalize_host_header_missing @@ -116,6 +144,63 @@ assert_equal "[::1]\n80", data.split("\r\n").last end + def test_streaming_body + server_run do |env| + body = lambda do |stream| + stream.write("Hello World") + stream.close + end + + [200, {}, body] + end + + data = send_http_and_read "GET / HTTP/1.0\r\nConnection: close\r\n\r\n" + + assert_equal "Hello World", data.split("\r\n\r\n", 2).last + end + + def test_file_body + random_bytes = SecureRandom.random_bytes(4096 * 32) + + tf = tempfile_create("test_file_body", random_bytes) + + server_run { |env| [200, {}, tf] } + + data = +'' + socket = send_http("GET / HTTP/1.1\r\nHost: [::ffff:127.0.0.1]:#{@port}\r\n\r\n") + data << socket.sysread(65_536) while socket.wait_readable(0.1) + + ary = data.split("\r\n\r\n", 2) + + assert_equal random_bytes.bytesize, ary.last.bytesize + assert_equal random_bytes, ary.last + ensure + tf.close + end + + def test_file_to_path + random_bytes = SecureRandom.random_bytes(4096 * 32) + + tf = tempfile_create("test_file_to_path", random_bytes) + path = tf.path + + obj = Object.new + obj.singleton_class.send(:define_method, :to_path) { path } + obj.singleton_class.send(:define_method, :each) { path } # dummy, method needs to exist + + server_run { |env| [200, {}, obj] } + + data = +'' + socket = send_http("GET / HTTP/1.1\r\nHost: [::ffff:127.0.0.1]:#{@port}\r\n\r\n") + data << socket.sysread(65_536) while socket.wait_readable(0.1) + ary = data.split("\r\n\r\n", 2) + + assert_equal random_bytes.bytesize, ary.last.bytesize + assert_equal random_bytes, ary.last + ensure + tf.close + end + def test_proper_stringio_body data = nil @@ -126,12 +211,12 @@ fifteen = "1" * 15 - sock = send_http "PUT / HTTP/1.0\r\nContent-Length: 30\r\n\r\n#{fifteen}" + socket = 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 + socket << fifteen - sock.read + socket.read assert_equal "#{fifteen}#{fifteen}", data end @@ -157,14 +242,14 @@ [200, {}, [giant]] end - sock = send_http "GET / HTTP/1.0\r\n\r\n" + socket = send_http "GET / HTTP/1.0\r\n\r\n" while true - line = sock.gets + line = socket.gets break if line == "\r\n" end - out = sock.read + out = socket.read assert_equal giant.bytesize, out.bytesize end @@ -248,16 +333,15 @@ 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" + expected_data = <<~EOF.gsub("\n", "\r\n") + "\r\n" + HTTP/1.1 103 Early Hints + Link: ; rel=preload; as=style + Link: ; rel=preload + + HTTP/1.0 200 OK + X-Hello: World + Content-Length: 12 + EOF assert_equal true, @server.early_hints assert_equal expected_data, data @@ -281,7 +365,7 @@ sleep 0.1 # Expect no errors in stderr - assert @events.stderr.pos.zero?, "Server didn't swallow the connection error" + assert @log_writer.stderr.pos.zero?, "Server didn't swallow the connection error" end def test_early_hints_is_off_by_default @@ -292,23 +376,46 @@ data = send_http_and_read "HEAD / HTTP/1.0\r\n\r\n" - expected_data = (< 2} + class ArrayClose < Array + attr_reader :is_closed + def closed? + @is_closed + end - server_run do - require 'json' + def close + @is_closed = true + end + end - # will raise fatal: machine stack overflow in critical region - obj = {} - obj['cycle'] = obj - ::JSON.dump(obj) + # returns status as an array, which throws lowlevel error + def test_lowlevel_error_body_close + app_body = ArrayClose.new(['lowlevel_error']) + + server_run(log_writer: @log_writer, :force_shutdown_after => 2) do + [[0,1], {}, app_body] end - data = send_http_and_read "GET / HTTP/1.0\r\n\r\n" + data = send_http_and_sysread "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" + assert_includes data, 'HTTP/1.0 500 Internal Server Error' + assert_includes data, "Puma caught this error: undefined method `to_i' for" + assert_includes data, "Array" + refute_includes data, 'lowlevel_error' + sleep 0.1 unless ::Puma::IS_MRI + assert app_body.closed? + end + + def test_lowlevel_error_message + server_run(log_writer: @log_writer, :force_shutdown_after => 2) do + raise NoMethodError, "Oh no an error" + end + + data = send_http_and_sysread "GET / HTTP/1.0\r\n\r\n" + + # Internal Server Error + assert_includes data, "HTTP/1.0 500 #{STATUS_CODES[500]}" + assert_match(/Puma caught this error: Oh no an error.*\(NoMethodError\).*test\/test_puma_server.rb/m, data) + end + + def test_lowlevel_error_message_without_backtrace + server_run(log_writer: @log_writer, :force_shutdown_after => 2) do + raise WithoutBacktraceError.new + end + + data = send_http_and_sysread "GET / HTTP/1.1\r\n\r\n" + # Internal Server Error + assert_includes data, "HTTP/1.1 500 #{STATUS_CODES[500]}" + assert_includes data, 'Puma caught this error: no backtrace error (WithoutBacktraceError)' + assert_includes data, '' end def test_force_shutdown_error_default @@ -455,13 +596,14 @@ 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" + socket = 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) + socket << "Hello" unless socket.wait_readable(1.15) - data = sock.gets + data = socket.gets - assert_equal "HTTP/1.1 408 Request Timeout\r\n", data + # Request Timeout + assert_equal "HTTP/1.1 408 #{STATUS_CODES[408]}\r\n", data end def test_timeout_data_no_queue @@ -470,41 +612,170 @@ # https://github.com/puma/puma/issues/2574 def test_no_timeout_after_data_received - @server.first_data_timeout = 1 + @server.instance_variable_set(:@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" + socket = 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" + socket << "hello" sleep 0.5 - sock << "world" + socket << "world" sleep 0.5 - sock << "!" + socket << "!" - data = sock.gets + data = socket.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 + @server = Puma::Server.new @app, @events, {log_writer: @log_writer, queue_requests: false} test_no_timeout_after_data_received end + def test_idle_timeout_before_first_request + server_run(idle_timeout: 1) + + sleep 1.15 + + assert @server.shutting_down? + + assert_raises Errno::ECONNREFUSED do + send_http "POST / HTTP/1.1\r\nHost: test.com\r\nContent-Type: text/plain\r\nContent-Length: 12\r\n\r\n" + end + end + + def test_idle_timeout_before_first_request_data + server_run(idle_timeout: 1) + + socket = send_http "POST / HTTP/1.1\r\nHost: test.com\r\nContent-Type: text/plain\r\nContent-Length: 12\r\n\r\n" + + sleep 1.15 + + socket << "hello world!" + + data = socket.gets + + assert_equal "HTTP/1.1 200 OK\r\n", data + end + + def test_idle_timeout_between_first_request_data + server_run(idle_timeout: 1) + + socket = send_http "POST / HTTP/1.1\r\nHost: test.com\r\nContent-Type: text/plain\r\nContent-Length: 12\r\n\r\n" + + socket << "hello" + + sleep 1.15 + + socket << " world!" + + data = socket.gets + + assert_equal "HTTP/1.1 200 OK\r\n", data + end + + def test_idle_timeout_after_first_request + server_run(idle_timeout: 1) + + socket = send_http "POST / HTTP/1.1\r\nHost: test.com\r\nContent-Type: text/plain\r\nContent-Length: 12\r\n\r\n" + + socket << "hello world!" + + data = socket.gets + + assert_equal "HTTP/1.1 200 OK\r\n", data + + sleep 1.15 + + assert @server.shutting_down? + + assert socket.wait_readable(1), 'Unexpected timeout' + assert_raises Errno::ECONNREFUSED do + send_http "POST / HTTP/1.1\r\nHost: test.com\r\nContent-Type: text/plain\r\nContent-Length: 12\r\n\r\n" + end + end + + def test_idle_timeout_between_request_data + server_run(idle_timeout: 1) + + socket = send_http "POST / HTTP/1.1\r\nHost: test.com\r\nContent-Type: text/plain\r\nContent-Length: 12\r\n\r\n" + + socket << "hello world!" + + data = socket.gets + + assert_equal "HTTP/1.1 200 OK\r\n", data + + sleep 0.5 + + socket = send_http "POST / HTTP/1.1\r\nHost: test.com\r\nContent-Type: text/plain\r\nContent-Length: 12\r\n\r\n" + + socket << "hello" + + sleep 1.15 + + socket << " world!" + + data = socket.gets + + assert_equal "HTTP/1.1 200 OK\r\n", data + + sleep 1.15 + + assert @server.shutting_down? + + assert socket.wait_readable(1), 'Unexpected timeout' + assert_raises Errno::ECONNREFUSED do + send_http "POST / HTTP/1.1\r\nHost: test.com\r\nContent-Type: text/plain\r\nContent-Length: 12\r\n\r\n" + end + end + + def test_idle_timeout_between_requests + server_run(idle_timeout: 1) + + socket = send_http "POST / HTTP/1.1\r\nHost: test.com\r\nContent-Type: text/plain\r\nContent-Length: 12\r\n\r\n" + + socket << "hello world!" + + data = socket.gets + + assert_equal "HTTP/1.1 200 OK\r\n", data + + sleep 0.5 + + socket = send_http "POST / HTTP/1.1\r\nHost: test.com\r\nContent-Type: text/plain\r\nContent-Length: 12\r\n\r\n" + + socket << "hello world!" + + data = socket.gets + + assert_equal "HTTP/1.1 200 OK\r\n", data + + sleep 1.15 + + assert @server.shutting_down? + + assert socket.wait_readable(1), 'Unexpected timeout' + assert_raises Errno::ECONNREFUSED do + send_http "POST / HTTP/1.1\r\nHost: test.com\r\nContent-Type: text/plain\r\nContent-Length: 12\r\n\r\n" + end + 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" + socket = send_http "GET / HTTP/1.1\r\nConnection: Keep-Alive\r\n\r\n" - h = header sock + h = header socket - body = sock.gets + body = socket.gets assert_equal ["HTTP/1.1 200 OK", "Content-Type: plain/text", "Content-Length: 6"], h assert_equal "hello\n", body - sock.close + socket.close end def test_http_11_close_with_body @@ -518,31 +789,33 @@ 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" + socket = send_http "GET / HTTP/1.1\r\nConnection: Keep-Alive\r\n\r\n" - h = header sock + h = header socket - assert_equal ["HTTP/1.1 204 No Content"], h + # No Content + assert_equal ["HTTP/1.1 204 #{STATUS_CODES[204]}"], 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" + socket = send_http "GET / HTTP/1.1\r\nConnection: close\r\n\r\n" - h = header sock + h = header socket - assert_equal ["HTTP/1.1 204 No Content", "Connection: close"], h + # No Content + assert_equal ["HTTP/1.1 204 #{STATUS_CODES[204]}", "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" + socket = send_http "GET / HTTP/1.0\r\nConnection: Keep-Alive\r\n\r\n" - h = header sock + h = header socket - body = sock.gets + body = socket.gets assert_equal ["HTTP/1.0 200 OK", "Content-Type: plain/text", "Connection: Keep-Alive", "Content-Length: 6"], h assert_equal "hello\n", body @@ -556,29 +829,12 @@ 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" + socket = send_http "GET / HTTP/1.0\r\nConnection: Keep-Alive\r\n\r\n" - h = header sock + h = header socket assert_equal ["HTTP/1.0 204 No Content", "Connection: Keep-Alive"], h end @@ -627,7 +883,7 @@ [200, {}, [""]] } - header = "GET / HTTP/1.1\r\nConnection: close\r\nTransfer-Encoding: chunked\r\n\r\n" + header = "GET / HTTP/1.1\r\nConnection: close\r\nContent-Length: 200\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. @@ -648,6 +904,20 @@ end end + def test_large_chunked_request_header + server_run(environment: :production) { |env| + [200, {}, [""]] + } + + max_chunk_header_size = Puma::Client::MAX_CHUNK_HEADER_SIZE + header = "GET / HTTP/1.1\r\nConnection: close\r\nContent-Length: 200\r\nTransfer-Encoding: chunked\r\n\r\n" + socket = send_http "#{header}1;t#{'x' * (max_chunk_header_size + 2)}" + + data = socket.read + + assert_match "HTTP/1.1 400 Bad Request\r\nConnection: close\r\nContent-Length: 0\r\n\r\n", data + end + def test_chunked_request_pause_before_value body = nil content_length = nil @@ -657,12 +927,12 @@ [200, {}, [""]] } - sock = send_http "GET / HTTP/1.1\r\nConnection: close\r\nTransfer-Encoding: chunked\r\n\r\n1\r\n" + socket = 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" + socket << "h\r\n4\r\nello\r\n0\r\n\r\n" - data = sock.read + data = socket.read assert_equal "HTTP/1.1 200 OK\r\nConnection: close\r\nContent-Length: 0\r\n\r\n", data assert_equal "hello", body @@ -678,12 +948,12 @@ [200, {}, [""]] } - sock = send_http "GET / HTTP/1.1\r\nConnection: close\r\nTransfer-Encoding: chunked\r\n\r\n1\r\nh\r\n" + socket = 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" + socket << "4\r\nello\r\n0\r\n\r\n" - data = sock.read + data = socket.read assert_equal "HTTP/1.1 200 OK\r\nConnection: close\r\nContent-Length: 0\r\n\r\n", data assert_equal "hello", body @@ -699,12 +969,12 @@ [200, {}, [""]] } - sock = send_http "GET / HTTP/1.1\r\nConnection: close\r\nTransfer-Encoding: chunked\r\n\r\n1\r" + socket = 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" + socket << "\nh\r\n4\r\nello\r\n0\r\n\r\n" - data = sock.read + data = socket.read assert_equal "HTTP/1.1 200 OK\r\nConnection: close\r\nContent-Length: 0\r\n\r\n", data assert_equal "hello", body @@ -720,12 +990,12 @@ [200, {}, [""]] } - sock = send_http "GET / HTTP/1.1\r\nConnection: close\r\nTransfer-Encoding: chunked\r\n\r\n1" + socket = 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" + socket << "\r\nh\r\n4\r\nello\r\n0\r\n\r\n" - data = sock.read + data = socket.read assert_equal "HTTP/1.1 200 OK\r\nConnection: close\r\nContent-Length: 0\r\n\r\n", data assert_equal "hello", body @@ -741,12 +1011,12 @@ [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" + socket = 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" + socket << "llo\r\n0\r\n\r\n" - data = sock.read + data = socket.read assert_equal "HTTP/1.1 200 OK\r\nConnection: close\r\nContent-Length: 0\r\n\r\n", data assert_equal "hello", body @@ -766,17 +1036,17 @@ 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" + socket = 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] + socket << chunked_body[0..-10] sleep 0.1 - sock << chunked_body[-9..-1] + socket << chunked_body[-9..-1] - data = sock.read + data = socket.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 @@ -792,13 +1062,13 @@ [200, {}, [""]] } - sock = send_http "PUT /path HTTP/1.1\r\nConnection: close\r\nTransfer-Encoding: chunked\r\n\r\n5\r\nhello\r" + socket = 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" + socket << "\n0\r\n\r\n" - data = sock.read + data = socket.read assert_equal "HTTP/1.1 200 OK\r\nConnection: close\r\nContent-Length: 0\r\n\r\n", data assert_equal 'hello', body @@ -814,13 +1084,13 @@ [200, {}, [""]] } - sock = send_http "PUT /path HTTP/1.1\r\nConnection: close\r\nTransfer-Encoding: chunked\r\n\r\n5\r\nhello" + socket = 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" + socket << "\r\n0\r\n\r\n" - data = sock.read + data = socket.read assert_equal "HTTP/1.1 200 OK\r\nConnection: close\r\nContent-Length: 0\r\n\r\n", data assert_equal 'hello', body @@ -852,15 +1122,15 @@ [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" + socket = 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 + h = header socket assert_equal ["HTTP/1.1 200 OK", "Content-Length: 0"], h assert_equal "hello", body assert_equal "5", content_length - sock.close + socket.close end def test_chunked_keep_alive_two_back_to_back @@ -872,35 +1142,36 @@ [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" + socket = 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" + socket << "\r" sleep 0.1 - sock << "\n" + socket << "\n" last_crlf_written = true end - h = header(sock) + h = header(socket) assert_equal ["HTTP/1.1 200 OK", "Content-Length: 0"], h assert_equal "hello", body assert_equal "5", content_length + sleep 0.05 if TRUFFLE 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" + socket << "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) + h = header(socket) assert_equal ["HTTP/1.1 200 OK", "Content-Length: 0"], h assert_equal "goodbye", body assert_equal "7", content_length - sock.close + socket.close end def test_chunked_keep_alive_two_back_to_back_with_set_remote_address @@ -914,25 +1185,25 @@ [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" + socket = 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 + h = header socket 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" + socket << "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) + h = header(socket) 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 + socket.close end def test_chunked_encoding @@ -965,7 +1236,7 @@ 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 + assert_equal "HTTP/1.0 200 OK\r\nX-Empty-Header: \r\nContent-Length: 0\r\n\r\n", data end def test_request_body_wait @@ -975,12 +1246,13 @@ [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" + socket = 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" + socket << "ello" - sock.gets + socket.gets + assert request_body_wait.is_a?(Float) # 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 @@ -993,11 +1265,11 @@ [204, {}, []] } - sock = send_http "GET / HTTP/1.1\r\nConnection: close\r\nTransfer-Encoding: chunked\r\n\r\n1\r\nh\r\n" + socket = 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" + socket << "4\r\nello\r\n0\r\n\r\n" - sock.gets + socket.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. @@ -1076,12 +1348,12 @@ # 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 + 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 + 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"]] @@ -1089,7 +1361,7 @@ assert_does_not_allow_http_injection(app, early_hints: true) end - define_method("test_prevent_content_length_injection_#{suffix}") do + 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 @@ -1139,9 +1411,9 @@ 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) + s2 << "\r\n" unless s2.wait_readable(0.2) - assert IO.select([s2], nil, nil, 10), 'timeout waiting for response' + assert s2.wait_readable(10), 'timeout waiting for response' s2_result = begin s2.gets rescue Errno::ECONNABORTED, Errno::ECONNRESET @@ -1175,59 +1447,59 @@ 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) + socket = send_http "GET / HTTP/1.1\r\n\r\n" + assert_equal ["HTTP/1.1 200 OK", "Content-Length: 0"], header(socket) - 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) + socket << "GET / HTTP/1.1\r\nConnection: close\r\n\r\n" + assert_equal ["HTTP/1.1 200 OK", "Connection: close", "Content-Length: 0"], header(socket) - sock.close + socket.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) + socket = 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(socket) - sock << "GET / HTTP/1.0\r\n\r\n" - assert_equal ["HTTP/1.0 200 OK", "Content-Length: 0"], header(sock) - sock.close + socket << "GET / HTTP/1.0\r\n\r\n" + assert_equal ["HTTP/1.0 200 OK", "Content-Length: 0"], header(socket) + socket.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 + socket = send_http "GET / HTTP/1.1\r\n\r\n" + assert_equal ["HTTP/1.1 200 OK", "Connection: close", "Content-Length: 0"], header(socket) + socket.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 + socket = send_http "GET / HTTP/1.0\r\n\r\n" + assert_equal ["HTTP/1.0 200 OK", "Content-Length: 0"], header(socket) + socket.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 + io.singleton_class.send :define_method, :accept_nonblock do accept_old.call.close raise error end - io.stub(:accept_nonblock, accept_stub) do - @server.run - new_connection - sleep 0.01 - end + + @server.run + new_connection + sleep 0.01 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" + refute_empty @log_writer.stderr.string, "Expected EMFILE error not logged" end # Retryable errors such as ECONNABORTED should be silently swallowed by accept loop. @@ -1235,7 +1507,7 @@ # 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 + assert_empty @log_writer.stderr.string end # see https://github.com/puma/puma/issues/2390 @@ -1243,46 +1515,46 @@ # def test_client_quick_close_no_lowlevel_error_handler_call handler = ->(err, env, status) { - @events.stdout.write "LLEH #{err.message}" + @log_writer.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" + socket = TCPSocket.new @host, @port + socket.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 + resp = socket.sysread 256 + socket.close assert_match 'Hello World', resp sleep 0.5 - assert_empty @events.stdout.string + assert_empty @log_writer.stdout.string # valid req, close - sock = TCPSocket.new @host, @port - sock.syswrite "GET / HTTP/1.0\r\n\r\n" - sock.close + socket = TCPSocket.new @host, @port + socket.syswrite "GET / HTTP/1.0\r\n\r\n" + socket.close sleep 0.5 - assert_empty @events.stdout.string + assert_empty @log_writer.stdout.string # invalid req, close - sock = TCPSocket.new @host, @port - sock.syswrite "GET / HTTP" - sock.close + socket = TCPSocket.new @host, @port + socket.syswrite "GET / HTTP" + socket.close sleep 0.5 - assert_empty @events.stdout.string + assert_empty @log_writer.stdout.string end def test_idle_connections_closed_immediately_on_shutdown server_run - sock = new_connection + socket = 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 socket.wait_readable(1), 'Unexpected timeout' assert_raises EOFError do - sock.read_nonblock(256) + socket.read_nonblock(256) end end @@ -1308,7 +1580,7 @@ def test_custom_io_selector backend = NIO::Selector.backends.first - @server = Puma::Server.new @app, @events, {:io_selector_backend => backend} + @server = Puma::Server.new @app, @events, {log_writer: @log_writer, :io_selector_backend => backend} @server.run selector = @server.instance_variable_get(:@reactor).instance_variable_get(:@selector) @@ -1330,7 +1602,11 @@ bad = 0 connections.each do |s| begin - assert_match 'DONE', s.read + if s.wait_readable(1) and drain # JRuby may hang on read with drain is false + assert_match 'DONE', s.read + else + bad += 1 + end rescue Errno::ECONNRESET bad += 1 end @@ -1346,23 +1622,320 @@ test_drain_on_shutdown false end - def test_rack_url_scheme_dflt - server_run + def test_remote_address_header + server_run(remote_address: :header, remote_address_header: 'HTTP_X_REMOTE_IP') do |env| + [200, {}, [env['REMOTE_ADDR']]] + end + remote_addr = send_http_and_read("GET / HTTP/1.1\r\nX-Remote-IP: 1.2.3.4\r\n\r\n").split("\r\n").last + assert_equal '1.2.3.4', remote_addr - data = send_http_and_read "GET / HTTP/1.0\r\n\r\n" - assert_equal "http", data.split("\r\n").last + # TODO: it would be great to test a connection from a non-localhost IP, but we can't really do that. For + # now, at least test that it doesn't return garbage. + remote_addr = send_http_and_sysread("GET / HTTP/1.1\r\n\r\n").split("\r\n").last + assert_equal @host, remote_addr + end + + def get_chunk_times + body = +'' + times = [] + Net::HTTP.start @host, @port do |http| + req = Net::HTTP::Get.new '/' + http.request req do |resp| + resp.read_body do |chunk| + next if chunk.empty? + body << chunk + times << Process.clock_gettime(Process::CLOCK_MONOTONIC) + end + + end + end + [body, times] 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 + # see https://github.com/sinatra/sinatra/blob/master/examples/stream.ru + def test_streaming_enum_body_1 + str = "Hello Puma World" + body_len = str.bytesize * 3 - data = send_http_and_read "GET / HTTP/1.0\r\n\r\n" - assert_equal "user", data.split("\r\n").last + server_run do |env| + hdrs = {} + hdrs['Content-Type'] = "text; charset=utf-8" + + body = Enumerator.new do |yielder| + yielder << str + sleep 0.5 + yielder << str + sleep 1.5 + yielder << str + end + [200, hdrs, body] + end + + resp_body, times = get_chunk_times + assert_equal body_len, resp_body.bytesize + assert_equal str * 3, resp_body + assert times[1] - times[0] > 0.4 + assert times[1] - times[0] < 1 + assert times[2] - times[1] > 1 + end + + # similar to a longer running app passing its output thru an enum body + # example - https://github.com/dentarg/testssl.web + def test_streaming_enum_body_2 + str = "Hello Puma World" + loops = 10 + body_len = str.bytesize * loops + + server_run do |env| + hdrs = {} + hdrs['Content-Type'] = "text; charset=utf-8" + + body = Enumerator.new do |yielder| + loops.times do |i| + sleep 0.15 unless i.zero? + yielder << str + end + end + [200, hdrs, body] + end + resp_body, times = get_chunk_times + assert_equal body_len, resp_body.bytesize + assert_equal str * loops, resp_body + assert_operator times.last - times.first, :>, 1.0 + end + + def test_empty_body_array_content_length_0 + server_run { |env| [404, {'Content-Length' => '0'}, []] } + + resp = send_http_and_sysread "GET / HTTP/1.1\r\n\r\n" + # Not Found + assert_equal "HTTP/1.1 404 #{STATUS_CODES[404]}\r\nContent-Length: 0\r\n\r\n", resp + end + + def test_empty_body_array_no_content_length + server_run { |env| [404, {}, []] } + + resp = send_http_and_sysread "GET / HTTP/1.1\r\n\r\n" + # Not Found + assert_equal "HTTP/1.1 404 #{STATUS_CODES[404]}\r\nContent-Length: 0\r\n\r\n", resp + end + + def test_empty_body_enum + server_run { |env| [404, {}, [].to_enum] } + + resp = send_http_and_sysread "GET / HTTP/1.1\r\n\r\n" + # Not Found + assert_equal "HTTP/1.1 404 #{STATUS_CODES[404]}\r\nTransfer-Encoding: chunked\r\n\r\n0\r\n\r\n", resp + end + + def test_form_data_encoding_windows_bom + req_body = nil + + str = "──── Hello,World,From,Puma ────\r\n" + + file_contents = str * 5_500 # req body is > 256 kB + + file_bytesize = file_contents.bytesize + 3 # 3 = BOM byte size + + fio = Tempfile.create 'win_bom_utf8_' + + temp_file_path = fio.path + fio.close + + File.open temp_file_path, "wb:UTF-8" do |f| + f.write "\xEF\xBB\xBF#{file_contents}" + end + + server_run do |env| + req_body = env['rack.input'].read + [200, {}, [req_body]] + end + + cmd = "curl -H 'transfer-encoding: chunked' --form data=@#{temp_file_path} http://127.0.0.1:#{@port}/" + + out_r, _, _ = spawn_cmd cmd + + out_r.wait_readable 3 + + form_file_data = req_body.split("\r\n\r\n", 2)[1].sub(/\r\n----\S+\r\n\z/, '') + + assert_equal file_bytesize, form_file_data.bytesize + assert_equal out_r.read.bytesize, req_body.bytesize + end + + def test_form_data_encoding_windows + req_body = nil + + str = "──── Hello,World,From,Puma ────\r\n" + + file_contents = str * 5_500 # req body is > 256 kB + + file_bytesize = file_contents.bytesize + + fio = tempfile_create 'win_utf8_', file_contents + + temp_file_path = fio.path + fio.close + + server_run do |env| + req_body = env['rack.input'].read + [200, {}, [req_body]] + end + + cmd = "curl -H 'transfer-encoding: chunked' --form data=@#{temp_file_path} http://127.0.0.1:#{@port}/" + + out_r, _, _ = spawn_cmd cmd + + out_r.wait_readable 3 + + form_file_data = req_body.split("\r\n\r\n", 2)[1].sub(/\r\n----\S+\r\n\z/, '') + + assert_equal file_bytesize, form_file_data.bytesize + assert_equal out_r.read.bytesize, req_body.bytesize + end + + def test_supported_http_methods_match + server_run(supported_http_methods: ['PROPFIND', 'PROPPATCH']) do |env| + body = [env['REQUEST_METHOD']] + [200, {}, body] + end + resp = send_http_and_read "PROPFIND / HTTP/1.0\r\n\r\n" + assert_match 'PROPFIND', resp + end + + def test_supported_http_methods_no_match + server_run(supported_http_methods: ['PROPFIND', 'PROPPATCH']) do |env| + body = [env['REQUEST_METHOD']] + [200, {}, body] + end + resp = send_http_and_read "GET / HTTP/1.0\r\n\r\n" + assert_match 'Not Implemented', resp + end + + def test_supported_http_methods_accept_all + server_run(supported_http_methods: :any) do |env| + body = [env['REQUEST_METHOD']] + [200, {}, body] + end + resp = send_http_and_read "YOUR_SPECIAL_METHOD / HTTP/1.0\r\n\r\n" + assert_match 'YOUR_SPECIAL_METHOD', resp + end + + def test_supported_http_methods_empty + server_run(supported_http_methods: []) do |env| + body = [env['REQUEST_METHOD']] + [200, {}, body] + end + resp = send_http_and_read "GET / HTTP/1.0\r\n\r\n" + assert_match(/\AHTTP\/1\.0 501 Not Implemented/, resp) + end + + + def spawn_cmd(env = {}, cmd) + opts = {} + + out_r, out_w = IO.pipe + opts[:out] = out_w + + err_r, err_w = IO.pipe + opts[:err] = err_w + + out_r.binmode + err_r.binmode + + pid = spawn(env, cmd, opts) + [out_w, err_w].each(&:close) + [out_r, err_r, pid] + end + + def test_lowlevel_error_handler_response + options = { + lowlevel_error_handler: ->(_error) do + [500, {}, ["something wrong happened"]] + end + } + broken_app = ->(_env) { [200, nil, []] } + + server_run(**options, &broken_app) + + data = send_http_and_read "GET / HTTP/1.1\r\n\r\n" + + assert_match(/something wrong happened/, data) + end + + def test_cl_empty_string + server_run do |env| + [200, {}, [""]] + end + + empty_cl_request = <<~REQ.gsub("\n", "\r\n") + GET / HTTP/1.1 + Host: localhost + Content-Length: + + GET / HTTP/1.1 + Host: localhost + + REQ + + data = send_http_and_read empty_cl_request + assert_operator data, :start_with?, 'HTTP/1.1 400 Bad Request' + end + + def test_crlf_trailer_smuggle + server_run do |env| + [200, {}, [""]] + end + + smuggled_payload = <<~REQ.gsub("\n", "\r\n") + GET / HTTP/1.1 + Transfer-Encoding: chunked + Host: whatever + + 0 + X:POST / HTTP/1.1 + Host: whatever + + GET / HTTP/1.1 + Host: whatever + + REQ + + data = send_http_and_read smuggled_payload + assert_equal 2, data.scan("HTTP/1.1 200 OK").size + end + + # test to check if content-length is ignored when 'transfer-encoding: chunked' + # is used. See also test_large_chunked_request + def test_cl_and_te_smuggle + body = nil + server_run { |env| + body = env['rack.input'].read + [200, {}, [""]] + } + + req = <<~REQ.gsub("\n", "\r\n") + POST /search HTTP/1.1 + Host: vulnerable-website.com + Content-Type: application/x-www-form-urlencoded + Content-Length: 4 + Transfer-Encoding: chunked + + 7b + GET /404 HTTP/1.1 + Host: vulnerable-website.com + Content-Type: application/x-www-form-urlencoded + Content-Length: 144 + + x= + 0 + + REQ + + data = send_http_and_read req + + assert_includes body, "GET /404 HTTP/1.1\r\n" + assert_includes body, "Content-Length: 144\r\n" + assert_equal 1, data.scan("HTTP/1.1 200 OK").size end end diff -Nru puma-5.6.5/test/test_puma_server_hijack.rb puma-6.4.2/test/test_puma_server_hijack.rb --- puma-5.6.5/test/test_puma_server_hijack.rb 1970-01-01 00:00:00.000000000 +0000 +++ puma-6.4.2/test/test_puma_server_hijack.rb 2024-01-08 05:53:42.000000000 +0000 @@ -0,0 +1,223 @@ +require_relative "helper" +require "puma/events" +require "puma/server" +require "net/http" +require "nio" + +require "rack" +require "rack/body_proxy" + +# Tests check both the proper passing of the socket to the app, and also calling +# of `body.close` on the response body. Rack spec is unclear as to whether +# calling close is expected. +# +# The sleep statements may not be needed for local CI, but are needed +# for use with GitHub Actions... + +class TestPumaServerHijack < Minitest::Test + parallelize_me! + + def setup + @host = "127.0.0.1" + + @ios = [] + + @app = ->(env) { [200, {}, [env['rack.url_scheme']]] } + + @log_writer = Puma::LogWriter.strings + @events = Puma::Events.new + end + + def teardown + return if skipped? + @server.stop(true) + assert_empty @log_writer.stdout.string + assert_empty @log_writer.stderr.string + + # Errno::EBADF raised on macOS + @ios.each do |io| + begin + io.close if io.respond_to?(:close) && !io.closed? + File.unlink io.path if io.is_a? File + rescue Errno::EBADF + ensure + io = nil + end + end + end + + def server_run(**options, &block) + options[:log_writer] ||= @log_writer + options[:min_threads] ||= 1 + @server = Puma::Server.new block || @app, @events, options + @port = (@server.add_tcp_listener @host, 0).addr[1] + @server.run + end + + # only for shorter bodies! + def send_http_and_sysread(req) + send_http(req).sysread 2_048 + end + + def send_http_and_read(req) + send_http(req).read + end + + def send_http(req) + t = new_connection + t.syswrite req + t + end + + def new_connection + TCPSocket.new(@host, @port).tap {|sock| @ios << sock} + end + + # Full hijack does not return headers + def test_full_hijack_body_close + @body_closed = false + server_run do |env| + io = env['rack.hijack'].call + io.syswrite 'Server listening' + io.wait_readable 2 + io.syswrite io.sysread(256) + body = ::Rack::BodyProxy.new([]) { @body_closed = true } + [200, {}, body] + end + + sock = send_http "GET / HTTP/1.1\r\n\r\n" + + sock.wait_readable 2 + assert_equal "Server listening", sock.sysread(256) + + sock.syswrite "this should echo" + assert_equal "this should echo", sock.sysread(256) + Thread.pass + sleep 0.001 # intermittent failure, may need to increase in CI + assert @body_closed, "Reponse body must be closed" + end + + def test_101_body + headers = { + 'Upgrade' => 'websocket', + 'Connection' => 'Upgrade', + 'Sec-WebSocket-Accept' => 's3pPLMBiTxaQ9kYGzzhZRbK+xOo=', + 'Sec-WebSocket-Protocol' => 'chat' + } + + body = -> (io) { + # below for TruffleRuby error with io.sysread + # Read Errno::EAGAIN: Resource temporarily unavailable + io.wait_readable 0.1 + io.syswrite io.sysread(256) + io.close + } + + server_run do |env| + [101, headers, body] + end + + sock = send_http "GET / HTTP/1.1\r\n\r\n" + resp = sock.sysread 1_024 + echo_msg = "This should echo..." + sock.syswrite echo_msg + + assert_includes resp, 'Connection: Upgrade' + assert_equal echo_msg, sock.sysread(256) + end + + def test_101_header + headers = { + 'Upgrade' => 'websocket', + 'Connection' => 'Upgrade', + 'Sec-WebSocket-Accept' => 's3pPLMBiTxaQ9kYGzzhZRbK+xOo=', + 'Sec-WebSocket-Protocol' => 'chat', + 'rack.hijack' => -> (io) { + # below for TruffleRuby error with io.sysread + # Read Errno::EAGAIN: Resource temporarily unavailable + io.wait_readable 0.1 + io.syswrite io.sysread(256) + io.close + } + } + + server_run do |env| + [101, headers, []] + end + + sock = send_http "GET / HTTP/1.1\r\n\r\n" + resp = sock.sysread 1_024 + echo_msg = "This should echo..." + sock.syswrite echo_msg + + assert_includes resp, 'Connection: Upgrade' + assert_equal echo_msg, sock.sysread(256) + end + + def test_http_10_header_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 + + # using sysread may only receive part of the response + 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_partial_hijack_body_closes_body + skip 'Not supported with Rack 1.x' if Rack.release.start_with? '1.' + @available = true + hdrs = { 'Content-Type' => 'text/plain' } + body = ::Rack::BodyProxy.new(HIJACK_LAMBDA) { @available = true } + partial_hijack_closes_body(hdrs, body) + end + + def test_partial_hijack_header_closes_body_correct_precedence + skip 'Not supported with Rack 1.x' if Rack.release.start_with? '1.' + @available = true + incorrect_lambda = ->(io) { + io.syswrite 'incorrect body.call' + io.close + } + hdrs = { 'Content-Type' => 'text/plain', 'rack.hijack' => HIJACK_LAMBDA} + body = ::Rack::BodyProxy.new(incorrect_lambda) { @available = true } + partial_hijack_closes_body(hdrs, body) + end + + HIJACK_LAMBDA = ->(io) { + io.syswrite 'hijacked' + io.close + } + + def partial_hijack_closes_body(hdrs, body) + server_run do + if @available + @available = false + [200, hdrs, body] + else + [500, { 'Content-Type' => 'text/plain' }, ['incorrect']] + end + end + + sock1 = send_http "GET / HTTP/1.1\r\n\r\n" + sleep (Puma::IS_WINDOWS || !Puma::IS_MRI ? 0.3 : 0.1) + resp1 = sock1.sysread 1_024 + + sleep 0.01 # time for close block to be called ? + + sock2 = send_http "GET / HTTP/1.1\r\n\r\n" + sleep (Puma::IS_WINDOWS || !Puma::IS_MRI ? 0.3 : 0.1) + resp2 = sock2.sysread 1_024 + + assert_operator resp1, :end_with?, 'hijacked' + assert_operator resp2, :end_with?, 'hijacked' + end +end diff -Nru puma-5.6.5/test/test_puma_server_ssl.rb puma-6.4.2/test/test_puma_server_ssl.rb --- puma-5.6.5/test/test_puma_server_ssl.rb 2022-08-22 23:33:14.000000000 +0000 +++ puma-6.4.2/test/test_puma_server_ssl.rb 2024-01-08 05:53:42.000000000 +0000 @@ -6,39 +6,45 @@ if ::Puma::HAS_SSL require "puma/minissl" - require "net/http" + require_relative "helpers/test_puma/puma_socket" - # 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}", "" + if ENV['PUMA_TEST_DEBUG'] + 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 end class TestPumaServerSSL < Minitest::Test parallelize_me! + + include TestPuma + include TestPuma::PumaSocket + + PROTOCOL_USE_MIN_MAX = + OpenSSL::SSL::SSLContext.private_instance_methods(false).include?(:set_minmax_proto_version) + + OPENSSL_3 = OpenSSL::OPENSSL_LIBRARY_VERSION.match?(/OpenSSL 3\.\d\.\d/) + def setup - @http = nil @server = nil end def teardown - @http.finish if @http && @http.started? - @server.stop(true) if @server + @server&.stop true 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 @@ -55,28 +61,16 @@ 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] + @log_writer = SSLLogWriterHelper.new STDOUT, STDERR + @server = Puma::Server.new app, nil, {log_writer: @log_writer} + @port = (@server.add_ssl_listener HOST, 0, ctx).addr[1] + @bind_port = @port @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 + assert_equal "https", send_http_read_resp_body(ctx: new_ctx) end def test_request_wont_block_thread @@ -84,10 +78,9 @@ # 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" + @bind_port = @server.connected_ports[0] + + socket = send_http "HEAD", ctx: new_ctx sleep 0.1 # Capture the amount of threads being used after connecting and being idle @@ -105,122 +98,107 @@ start_server giant = "x" * 2056610 - @server.app = proc do - [200, {}, [giant]] - end + @server.app = proc { [200, {}, [giant]] } - body = nil - @http.start do - req = Net::HTTP::Get.new "/" - @http.request(req) do |rep| - body = rep.body - end - end + body = send_http_read_resp_body(ctx: new_ctx) 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') + @server.app = proc { |env| [200, {}, [env['rack.url_scheme'], "\n", env['rack.input'].read]] } - @http.request(req) do |rep| - body = rep.body - end + req = "POST / HTTP/1.1\r\nContent-Type: text/plain\r\nContent-Length: 7\r\n\r\na=1&b=2" - end + body = send_http_read_resp_body req, ctx: new_ctx - assert_equal "https", body + assert_equal "https\na=1&b=2", 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 + + assert_raises(OpenSSL::SSL::SSLError) do + send_http_read_resp_body ctx: new_ctx { |c| c.ssl_version = :SSLv3 } 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 + assert_match(msg, @log_writer.error.message) if @log_writer.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 + assert_raises(OpenSSL::SSL::SSLError) do + send_http_read_resp_body ctx: new_ctx { |c| + if PROTOCOL_USE_MIN_MAX + c.max_version = :TLS1 + else + c.ssl_version = :TLSv1 + 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 + assert_match(msg, @log_writer.error.message) if @log_writer.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 + assert_raises(OpenSSL::SSL::SSLError) do + send_http_read_response ctx: new_ctx { |c| + if PROTOCOL_USE_MIN_MAX + c.max_version = :TLS1_1 + else + c.ssl_version = :TLSv1_1 + 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 + assert_match(msg, @log_writer.error.message) if @log_writer.error end end + def test_tls_v1_3 + skip("TLSv1.3 protocol can not be set") unless OpenSSL::SSL::SSLContext.instance_methods(false).include?(:min_version=) + + start_server + + body = send_http_read_resp_body ctx: new_ctx { |c| + if PROTOCOL_USE_MIN_MAX + c.min_version = :TLS1_3 + else + c.ssl_version = :TLSv1_3 + end + } + + assert_equal "https", body + 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 } + assert_raises(Errno::ECONNREFUSED, EOFError, Timeout::Error) do + body_http = send_http_read_resp_body timeout: 4 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 + body_https = send_http_read_resp_body ctx: new_ctx end tcp.join ssl.join - http.finish sleep 1.0 assert_nil body_http @@ -229,7 +207,7 @@ 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" + assert busy_threads.zero?, "Our connection wasn't dropped" end unless Puma.jruby? @@ -277,6 +255,9 @@ class TestPumaServerSSLClient < Minitest::Test parallelize_me! unless ::Puma.jruby? + include TestPuma + include TestPuma::PumaSocket + CERT_PATH = File.expand_path "../examples/puma/client-certs", __dir__ # Context can be shared, may help with JRuby @@ -292,124 +273,299 @@ 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" + def assert_ssl_client_error_match(error, subject: nil, context: CTX, &blk) 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 + log_writer = SSLLogWriterHelper.new STDOUT, STDERR + server = Puma::Server.new app, nil, {log_writer: log_writer} + server.add_ssl_listener LOCALHOST, port, context host_addrs = server.binder.ios.map { |io| io.to_io.addr[2] } + @bind_port = server.connected_ports[0] server.run - http = Net::HTTP.new host, server.connected_ports[0] - http.use_ssl = true - http.verify_mode = OpenSSL::SSL::VERIFY_NONE + ctx = OpenSSL::SSL::SSLContext.new + yield ctx - yield http + expected_errors = [ + EOFError, + IOError, + OpenSSL::SSL::SSLError, + Errno::ECONNABORTED, + Errno::ECONNRESET + ] 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 + send_http_read_resp_body host: LOCALHOST, ctx: ctx + rescue *expected_errors => e + client_error = e 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 + assert_equal !!error, !!client_error, client_error + if error && !error.eql?(true) + assert_match error, log_writer.error.message + assert_includes host_addrs, log_writer.addr end + assert_equal subject, log_writer.cert.subject.to_s if subject ensure - server.stop(true) if server + server&.stop true end def test_verify_fail_if_no_client_cert - assert_ssl_client_error_match 'peer did not return a certificate' do |http| + error = Puma.jruby? ? /Empty client certificate chain/ : 'peer did not return a certificate' + assert_ssl_client_error_match(error) do |client_ctx| # 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| + error = Puma.jruby? ? /No trusted certificate found/ : /self[- ]signed certificate in certificate chain/ + cert_subject = Puma.jruby? ? '/DC=net/DC=puma/CN=localhost' : '/DC=net/DC=puma/CN=CAU' + assert_ssl_client_error_match(error, subject: cert_subject) do |client_ctx| 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" + client_ctx.key = OpenSSL::PKey::RSA.new File.read(key) + client_ctx.cert = OpenSSL::X509::Certificate.new File.read(crt) + client_ctx.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| + error = Puma.jruby? ? /NotAfter:/ : 'certificate has expired' + assert_ssl_client_error_match(error, subject: '/DC=net/DC=puma/CN=localhost') do |client_ctx| 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" + client_ctx.key = OpenSSL::PKey::RSA.new File.read(key) + client_ctx.cert = OpenSSL::X509::Certificate.new File.read(crt) + client_ctx.ca_file = "#{CERT_PATH}/ca.crt" end end def test_verify_client_cert - assert_ssl_client_error_match(nil) do |http| + assert_ssl_client_error_match(false) do |client_ctx| 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 + client_ctx.key = OpenSSL::PKey::RSA.new File.read(key) + client_ctx.cert = OpenSSL::X509::Certificate.new File.read(crt) + client_ctx.ca_file = "#{CERT_PATH}/ca.crt" + client_ctx.verify_mode = OpenSSL::SSL::VERIFY_PEER end end + + def test_verify_client_cert_with_truststore + ctx = Puma::MiniSSL::Context.new + ctx.keystore = "#{CERT_PATH}/server.p12" + ctx.keystore_type = 'pkcs12' + ctx.keystore_pass = 'jruby_puma' + ctx.truststore = "#{CERT_PATH}/ca_store.p12" + ctx.truststore_type = 'pkcs12' + ctx.truststore_pass = 'jruby_puma' + ctx.verify_mode = Puma::MiniSSL::VERIFY_PEER + + assert_ssl_client_error_match(false, context: ctx) do |client_ctx| + key = "#{CERT_PATH}/client.key" + crt = "#{CERT_PATH}/client.crt" + client_ctx.key = OpenSSL::PKey::RSA.new File.read(key) + client_ctx.cert = OpenSSL::X509::Certificate.new File.read(crt) + client_ctx.ca_file = "#{CERT_PATH}/ca.crt" + client_ctx.verify_mode = OpenSSL::SSL::VERIFY_PEER + end + end if Puma.jruby? + + def test_verify_client_cert_without_truststore + ctx = Puma::MiniSSL::Context.new + ctx.keystore = "#{CERT_PATH}/server.p12" + ctx.keystore_type = 'pkcs12' + ctx.keystore_pass = 'jruby_puma' + ctx.truststore = "#{CERT_PATH}/unknown_ca_store.p12" + ctx.truststore_type = 'pkcs12' + ctx.truststore_pass = 'jruby_puma' + ctx.verify_mode = Puma::MiniSSL::VERIFY_PEER + + assert_ssl_client_error_match(true, context: ctx) do |client_ctx| + key = "#{CERT_PATH}/client.key" + crt = "#{CERT_PATH}/client.crt" + client_ctx.key = OpenSSL::PKey::RSA.new File.read(key) + client_ctx.cert = OpenSSL::X509::Certificate.new File.read(crt) + client_ctx.ca_file = "#{CERT_PATH}/ca.crt" + client_ctx.verify_mode = OpenSSL::SSL::VERIFY_PEER + end + end if Puma.jruby? + + def test_allows_using_default_truststore + ctx = Puma::MiniSSL::Context.new + ctx.keystore = "#{CERT_PATH}/server.p12" + ctx.keystore_type = 'pkcs12' + ctx.keystore_pass = 'jruby_puma' + ctx.truststore = :default + # NOTE: a little hard to test - we're at least asserting that setting :default does not raise errors + ctx.verify_mode = Puma::MiniSSL::VERIFY_NONE + + assert_ssl_client_error_match(false, context: ctx) do |client_ctx| + key = "#{CERT_PATH}/client.key" + crt = "#{CERT_PATH}/client.crt" + client_ctx.key = OpenSSL::PKey::RSA.new File.read(key) + client_ctx.cert = OpenSSL::X509::Certificate.new File.read(crt) + client_ctx.ca_file = "#{CERT_PATH}/ca.crt" + client_ctx.verify_mode = OpenSSL::SSL::VERIFY_PEER + end + end if Puma.jruby? + + def test_allows_to_specify_cipher_suites_and_protocols + ctx = CTX.dup + ctx.cipher_suites = [ 'TLS_RSA_WITH_AES_128_GCM_SHA256' ] + ctx.protocols = 'TLSv1.2' + + assert_ssl_client_error_match(false, context: ctx) do |client_ctx| + key = "#{CERT_PATH}/client.key" + crt = "#{CERT_PATH}/client.crt" + client_ctx.key = OpenSSL::PKey::RSA.new File.read(key) + client_ctx.cert = OpenSSL::X509::Certificate.new File.read(crt) + client_ctx.ca_file = "#{CERT_PATH}/ca.crt" + client_ctx.verify_mode = OpenSSL::SSL::VERIFY_PEER + + client_ctx.ssl_version = :TLSv1_2 + client_ctx.ciphers = [ 'TLS_RSA_WITH_AES_128_GCM_SHA256' ] + end + end if Puma.jruby? + + def test_fails_when_no_cipher_suites_in_common + ctx = CTX.dup + ctx.cipher_suites = [ 'TLS_RSA_WITH_AES_128_GCM_SHA256' ] + ctx.protocols = 'TLSv1.2' + + assert_ssl_client_error_match(/no cipher suites in common/, context: ctx) do |client_ctx| + key = "#{CERT_PATH}/client.key" + crt = "#{CERT_PATH}/client.crt" + client_ctx.key = OpenSSL::PKey::RSA.new File.read(key) + client_ctx.cert = OpenSSL::X509::Certificate.new File.read(crt) + client_ctx.ca_file = "#{CERT_PATH}/ca.crt" + client_ctx.verify_mode = OpenSSL::SSL::VERIFY_PEER + + client_ctx.ssl_version = :TLSv1_2 + client_ctx.ciphers = [ 'TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384' ] + end + end if Puma.jruby? + + def test_verify_client_cert_with_truststore_without_pass + ctx = Puma::MiniSSL::Context.new + ctx.keystore = "#{CERT_PATH}/server.p12" + ctx.keystore_type = 'pkcs12' + ctx.keystore_pass = 'jruby_puma' + ctx.truststore = "#{CERT_PATH}/ca_store.jks" # cert entry can be read without password + ctx.truststore_type = 'jks' + ctx.verify_mode = Puma::MiniSSL::VERIFY_PEER + + assert_ssl_client_error_match(false, context: ctx) do |client_ctx| + key = "#{CERT_PATH}/client.key" + crt = "#{CERT_PATH}/client.crt" + client_ctx.key = OpenSSL::PKey::RSA.new File.read(key) + client_ctx.cert = OpenSSL::X509::Certificate.new File.read(crt) + client_ctx.ca_file = "#{CERT_PATH}/ca.crt" + client_ctx.verify_mode = OpenSSL::SSL::VERIFY_PEER + end + end if Puma.jruby? + end if ::Puma::HAS_SSL class TestPumaServerSSLWithCertPemAndKeyPem < Minitest::Test + include TestPuma + include TestPuma::PumaSocket + 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") - } + ctx = Puma::MiniSSL::Context.new + 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 + log_writer = SSLLogWriterHelper.new STDOUT, STDERR + server = Puma::Server.new app, nil, {log_writer: log_writer} + server.add_ssl_listener LOCALHOST, 0, ctx + @bind_port = server.connected_ports[0] 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 + send_http_read_resp_body host: LOCALHOST, ctx: new_ctx { |c| + c.ca_file = "#{CERT_PATH}/ca.crt" + c.verify_mode = OpenSSL::SSL::VERIFY_PEER + } 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 + server&.stop true end end if ::Puma::HAS_SSL && !Puma::IS_JRUBY + +# +# Test certificate chain support, The certs and the whole certificate chain for +# this tests are located in ../examples/puma/chain_cert and were generated with +# the following commands: +# +# bundle exec ruby ../examples/puma/chain_cert/generate_chain_test.rb +# +class TestPumaSSLCertChain < Minitest::Test + include TestPuma + include TestPuma::PumaSocket + + CHAIN_DIR = File.expand_path '../examples/puma/chain_cert', __dir__ + + # OpenSSL::X509::Name#to_utf8 only available in Ruby 2.5 and later + USE_TO_UTFT8 = OpenSSL::X509::Name.instance_methods(false).include? :to_utf8 + + def cert_chain(&blk) + app = lambda { |env| [200, {}, [env['rack.url_scheme']]] } + + @log_writer = SSLLogWriterHelper.new STDOUT, STDERR + @server = Puma::Server.new app, nil, {log_writer: @log_writer} + + mini_ctx = Puma::MiniSSL::Context.new + mini_ctx.key = "#{CHAIN_DIR}/cert.key" + yield mini_ctx + + @bind_port = (@server.add_ssl_listener HOST, 0, mini_ctx).addr[1] + @server.run + + socket = new_socket ctx: new_ctx + + subj_chain = socket.peer_cert_chain.map(&:subject) + subj_map = USE_TO_UTFT8 ? + subj_chain.map { |subj| subj.to_utf8[/CN=(.+ - )?([^,]+)/,2] } : + subj_chain.map { |subj| subj.to_s(OpenSSL::X509::Name::RFC2253)[/CN=(.+ - )?([^,]+)/,2] } + + @server&.stop true + + assert_equal ['test.puma.localhost', 'intermediate.puma.localhost', 'ca.puma.localhost'], subj_map + end + + def test_single_cert_file_with_ca + cert_chain { |mini_ctx| + mini_ctx.cert = "#{CHAIN_DIR}/cert.crt" + mini_ctx.ca = "#{CHAIN_DIR}/ca_chain.pem" + } + end + + def test_chain_cert_file_without_ca + cert_chain { |mini_ctx| mini_ctx.cert = "#{CHAIN_DIR}/cert_chain.pem" } + end + + def test_single_cert_string_with_ca + cert_chain { |mini_ctx| + mini_ctx.cert_pem = File.read "#{CHAIN_DIR}/cert.crt" + mini_ctx.ca = "#{CHAIN_DIR}/ca_chain.pem" + } + end + + def test_chain_cert_string_without_ca + cert_chain { |mini_ctx| mini_ctx.cert_pem = File.read "#{CHAIN_DIR}/cert_chain.pem" } + end +end if ::Puma::HAS_SSL && !::Puma::IS_JRUBY diff -Nru puma-5.6.5/test/test_pumactl.rb puma-6.4.2/test/test_pumactl.rb --- puma-5.6.5/test/test_pumactl.rb 2022-08-22 23:33:14.000000000 +0000 +++ puma-6.4.2/test/test_pumactl.rb 2024-01-08 05:53:42.000000000 +0000 @@ -14,7 +14,11 @@ end def wait_booted - line = @wait.gets until line =~ /Use Ctrl-C to stop/ + @server_log = +'' + begin + line = @wait.gets + @server_log << line + end until line&.include?('Use Ctrl-C to stop') end def teardown @@ -43,106 +47,107 @@ 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") + assert_equal "t3-pid", control_cli.instance_variable_get(:@pidfile) + ensure 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') + 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") + 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') + 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') + 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') + 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') + 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') + 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') + 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") + 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") + 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") + 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") + assert_equal "test", control_cli.instance_variable_get(:@environment) end end def test_environment_specific_config_file_exist - port = 6002 + port = UniquePort.call 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") + 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") + assert_equal production_config_file, control_cli.instance_variable_get(:@config_file) end end end def test_default_config_file_exist - port = 6001 + port = UniquePort.call 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") + 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") + assert_equal development_config_file, control_cli.instance_variable_get(:@config_file) end end end @@ -154,7 +159,7 @@ ] control_cli = Puma::ControlCLI.new opts, @ready, @ready - assert_equal 'none', control_cli.instance_variable_get("@control_auth_token") + assert_equal 'none', control_cli.instance_variable_get(:@control_auth_token) end def test_control_url_and_status @@ -165,7 +170,7 @@ opts = [ "--control-url", url, "--control-token", "ctrl", - "--config-file", "test/config/app.rb", + "--config-file", "test/config/app.rb" ] control_cli = Puma::ControlCLI.new (opts + ["start"]), @ready, @ready @@ -173,13 +178,14 @@ control_cli.run end - wait_booted + wait_booted # read server log - s = TCPSocket.new host, 9292 + bind_port = @server_log[/Listening on http:.+:(\d+)$/, 1].to_i + s = TCPSocket.new host, bind_port s << "GET / HTTP/1.0\r\n\r\n" body = s.read - assert_match "200 OK", body - assert_match "embedded app", body + assert_includes body, "200 OK" + assert_includes body, "embedded app" assert_command_cli_output opts + ["status"], "Puma is started" assert_command_cli_output opts + ["stop"], "Command stop sent success" @@ -204,7 +210,7 @@ "--pid", "1234" ] cmd = Puma::ControlCLI::NO_REQ_COMMANDS.first - log = ''.dup + log = +'' control_cli = Puma::ControlCLI.new (opts + [cmd]), @ready, @ready def control_cli.send_signal @@ -223,17 +229,16 @@ refute_includes log, 'send_request' end - def test_control_ssl + def control_ssl(host) skip_unless :ssl - - host = "127.0.0.1" - port = UniquePort.call + ip = host&.start_with?('[') ? host[1..-2] : host + port = UniquePort.call(ip) url = "ssl://#{host}:#{port}?#{ssl_query}" opts = [ "--control-url", url, "--control-token", "ctrl", - "--config-file", "test/config/app.rb", + "--config-file", "test/config/app.rb" ] control_cli = Puma::ControlCLI.new (opts + ["start"]), @ready, @ready @@ -249,6 +254,17 @@ assert_kind_of Thread, t.join, "server didn't stop" end + + def test_control_ssl_ipv4 + skip_unless :ssl + control_ssl '127.0.0.1' + end + + def test_control_ssl_ipv6 + skip_unless :ssl + control_ssl '[::1]' + end + def test_control_aunix skip_unless :aunix @@ -257,7 +273,30 @@ opts = [ "--control-url", url, "--control-token", "ctrl", - "--config-file", "test/config/app.rb", + "--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_ipv6 + port = UniquePort.call '::1' + url = "tcp://[::1]:#{port}" + + opts = [ + "--control-url", url, + "--control-token", "ctrl", + "--config-file", "test/config/app.rb" ] control_cli = Puma::ControlCLI.new (opts + ["start"]), @ready, @ready @@ -276,25 +315,37 @@ 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 + @rd, @wr = IO.pipe + cmd = Puma::ControlCLI.new(options, @wr, @wr) + begin + cmd.run + rescue SystemExit + end + @wr.close + if String === expected_out + assert_includes @rd.read, expected_out + else + assert_match expected_out, @rd.read end - assert_match expected_out, out + ensure + @rd.close 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 + @rd, @wr = IO.pipe - assert_equal(response.status, 1) + response = assert_raises(SystemExit) do + Puma::ControlCLI.new(options, @wr, @wr).run end + @wr.close - assert_match expected_out, out + assert_equal(response.status, 1) + if String === expected_out + assert_includes @rd.read, expected_out + else + assert_match expected_out, @rd.read + end + ensure + @rd.close end end diff -Nru puma-5.6.5/test/test_rack_handler.rb puma-6.4.2/test/test_rack_handler.rb --- puma-5.6.5/test/test_rack_handler.rb 2022-08-22 23:33:14.000000000 +0000 +++ puma-6.4.2/test/test_rack_handler.rb 2024-01-08 05:53:42.000000000 +0000 @@ -1,270 +1,332 @@ require_relative "helper" -require "rack/handler/puma" +# Most tests check that ::Rack::Handler::Puma works by itself +# RackUp#test_bin runs Puma using the rackup bin file +module TestRackUp + require "rack/handler/puma" + require "puma/events" + + class TestOnBootedHandler < Minitest::Test + def app + Proc.new {|env| @input = env; [200, {}, ["hello world"]]} + end -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 + # `Verbose: true` is included for `NameError`, + # see https://github.com/puma/puma/pull/3118 + def test_on_booted + on_booted = false + events = Puma::Events.new + events.on_booted do + on_booted = true + end -class TestPathHandler < Minitest::Test - def app - Proc.new {|env| @input = env; [200, {}, ["hello world"]]} - end + launcher = nil + thread = Thread.new do + Rack::Handler::Puma.run(app, events: events, Verbose: true, Silent: true) do |l| + launcher = l + end + end - def setup - @input = nil - end + # Wait for launcher to boot + Timeout.timeout(10) do + sleep 0.5 until launcher + end + sleep 1.5 unless Puma::IS_MRI - def in_handler(app, options = {}) - options[:Port] ||= 0 - options[:Silent] = true + launcher.stop + thread.join - @launcher = nil - thread = Thread.new do - Rack::Handler::Puma.run(app, **options) do |s, p| - @launcher = s - end + assert_equal on_booted, true end + end - # Wait for launcher to boot - Timeout.timeout(10) do - sleep 0.5 until @launcher + class TestPathHandler < Minitest::Test + def app + Proc.new {|env| @input = env; [200, {}, ["hello world"]]} end - sleep 1.5 unless Puma::IS_MRI - yield @launcher - ensure - @launcher.stop if @launcher - thread.join if thread - end + def setup + @input = nil + 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"]) + 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 + thread&.join end - end -end -class TestUserSuppliedOptionsPortIsSet < Minitest::Test - def setup - @options = {} - @options[:user_supplied_options] = [:Port] + 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 - 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 + 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] + assert_equal ["tcp://0.0.0.0:#{user_port}"], conf.options[:binds] + end end end end -end -class TestUserSuppliedOptionsHostIsSet < Minitest::Test - def setup - @options = {} - @options[:user_supplied_options] = [:Host] - 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 + 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 + 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 + 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] + assert_equal ["tcp://[::1]:9292"], conf.options[:binds] + end end -end -class TestUserSuppliedOptionsIsEmpty < Minitest::Test - def setup - @options = {} - @options[:user_supplied_options] = [] - 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 + 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] + assert_equal ["tcp://0.0.0.0:#{file_port}"], conf.options[:binds] + end 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 + 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] + assert_equal ["tcp://localhost:#{file_port}"], conf.options[:binds] + end 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 + 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] + assert_equal ["tcp://1.2.3.4:#{file_port}"], conf.options[:binds] + end end end end -end -class TestUserSuppliedOptionsIsNotPresent < Minitest::Test - def setup - @options = {} - 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 + 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 + assert_equal ["tcp://0.0.0.0:9292"], conf.options[:binds] + end - def test_config_wins_over_default - file_port = 6001 + 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}" } + 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 + conf = ::Rack::Handler::Puma.config(->{}, @options) + conf.load - assert_equal ["tcp://0.0.0.0:#{file_port}"], conf.options[:binds] + assert_equal ["tcp://0.0.0.0:#{file_port}"], conf.options[:binds] + end 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 + 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 + 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 + 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 + 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 + 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] + assert_equal ["tcp://0.0.0.0:#{user_port}"], conf.options[:binds] + end end end - end - def test_default_log_request_when_no_config_file - conf = Rack::Handler::Puma.config(->{}, @options) - conf.load + 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 + assert_equal false, conf.options[:log_requests] + end - def test_file_log_requests_wins_over_default_config - file_log_requests_config = true + def test_file_log_requests_wins_over_default_config + file_log_requests_config = true - @options[:config_files] = [ - 'test/config/t1_conf.rb' - ] + @options[:config_files] = [ + 'test/config/t1_conf.rb' + ] - conf = Rack::Handler::Puma.config(->{}, @options) - conf.load + conf = ::Rack::Handler::Puma.config(->{}, @options) + conf.load - assert_equal file_log_requests_config, conf.options[:log_requests] - end + 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' + ] - def test_user_log_requests_wins_over_file_config - user_log_requests_config = false + conf = ::Rack::Handler::Puma.config(->{}, @options) + conf.load - @options[:log_requests] = user_log_requests_config - @options[:config_files] = [ - 'test/config/t1_conf.rb' - ] + assert_equal user_log_requests_config, conf.options[:log_requests] + end + end - conf = Rack::Handler::Puma.config(->{}, @options) - conf.load + # Run using IO.popen so we don't load Rack and/or Rackup in the main process + class RackUp < Minitest::Test + def setup + FileUtils.copy_file 'test/rackup/hello.ru', 'config.ru' + end - assert_equal user_log_requests_config, conf.options[:log_requests] + def teardown + FileUtils.rm 'config.ru' + end + + def test_bin + pid = nil + # JRuby & TruffleRuby take a long time using IO.popen + skip_unless :mri + io = IO.popen "rackup -p 0" + io.wait_readable 2 + sleep 0.7 + log = io.sysread 2_048 + pid = log[/PID: (\d+)/, 1] || io.pid + assert_includes log, 'Puma version' + assert_includes log, 'Use Ctrl-C to stop' + ensure + if pid + if Puma::IS_WINDOWS + `taskkill /F /PID #{pid}` + else + `kill -s KILL #{pid}` + end + end + end end end diff -Nru puma-5.6.5/test/test_rack_server.rb puma-6.4.2/test/test_rack_server.rb --- puma-5.6.5/test/test_rack_server.rb 2022-08-22 23:33:14.000000000 +0000 +++ puma-6.4.2/test/test_rack_server.rb 2024-01-08 05:53:42.000000000 +0000 @@ -2,11 +2,29 @@ require_relative "helper" require "net/http" -require "rack" +# don't load Rack, as it autoloads everything +begin + require "rack/body_proxy" + require "rack/lint" + require "rack/version" + require "rack/common_logger" +rescue LoadError # Rack 1.6 + require "rack" +end + +# Rack::Chunked is loaded by Rack v2, needs to be required by Rack 3.0, +# and is removed in Rack 3.1 +require "rack/chunked" if Rack.release.start_with? '3.0' + +require "nio" class TestRackServer < Minitest::Test parallelize_me! + HOST = '127.0.0.1' + + STR_1KB = "──#{SecureRandom.hex 507}─\n".freeze + class ErrorChecker def initialize(app) @app = app @@ -27,17 +45,21 @@ class ServerLint < Rack::Lint def call(env) - check_env env + if Rack.release < '3' + check_env env + else + Wrapper.new(@app, env).check_environment env + end @app.call(env) end end def setup - @simple = lambda { |env| [200, { "X-Header" => "Works" }, ["Hello"]] } + @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}" + @port = (@server.add_tcp_listener HOST, 0).addr[1] + @tcp = "http://#{HOST}:#{@port}" @stopped = false end @@ -50,6 +72,12 @@ @server.stop(true) unless @stopped end + def header_hash(socket) + t = socket.readline("\r\n\r\n").split("\r\n") + t.shift; t.map! { |line| line.split(/:\s?/) } + t.to_h + end + def test_lint @checker = ErrorChecker.new ServerLint.new(@simple) @server.app = @checker @@ -116,16 +144,12 @@ @server.run - socket = TCPSocket.open "127.0.0.1", @port + socket = TCPSocket.open HOST, @port socket.puts "GET /test HTTP/1.1\r\n" socket.puts "Connection: Keep-Alive\r\n" socket.puts "\r\n" - headers = socket.readline("\r\n\r\n") - .split("\r\n") - .drop(1) - .map { |line| line.split(/:\s?/) } - .to_h + headers = header_hash socket content_length = headers["Content-Length"].to_i real_response_body = socket.read(content_length) @@ -154,6 +178,49 @@ stop end + def test_rack_body_proxy + closed = false + body = Rack::BodyProxy.new(["Hello"]) { closed = true } + + @server.app = lambda { |env| [200, { "X-Header" => "Works" }, body] } + + @server.run + + hit(["#{@tcp}/test"]) + + stop + + assert_equal true, closed + end + + def test_rack_body_proxy_content_length + str_ary = %w[0123456789 0123456789 0123456789 0123456789] + str_ary_bytes = str_ary.to_ary.inject(0) { |sum, el| sum + el.bytesize } + + body = Rack::BodyProxy.new(str_ary) { } + + @server.app = lambda { |env| [200, { "X-Header" => "Works" }, body] } + + @server.run + + socket = TCPSocket.open HOST, @port + socket.puts "GET /test HTTP/1.1\r\n" + socket.puts "Connection: Keep-Alive\r\n" + socket.puts "\r\n" + + headers = header_hash socket + + socket.close + + stop + + if Rack.release.start_with? '1.' + assert_equal "chunked", headers["Transfer-Encoding"] + else + assert_equal str_ary_bytes, headers["Content-Length"].to_i + end + end + def test_common_logger log = StringIO.new @@ -169,4 +236,38 @@ assert_match %r!GET /test HTTP/1\.1!, log.string end + + def test_rack_chunked_array1 + body = [STR_1KB] + app = lambda { |env| [200, { 'content-type' => 'text/plain; charset=utf-8' }, body] } + rack_app = Rack::Chunked.new app + @server.app = rack_app + @server.run + + resp = Net::HTTP.get_response URI(@tcp) + assert_equal 'chunked', resp['transfer-encoding'] + assert_equal STR_1KB, resp.body.force_encoding(Encoding::UTF_8) + end if Rack.release < '3.1' + + def test_rack_chunked_array10 + body = Array.new 10, STR_1KB + app = lambda { |env| [200, { 'content-type' => 'text/plain; charset=utf-8' }, body] } + rack_app = Rack::Chunked.new app + @server.app = rack_app + @server.run + + resp = Net::HTTP.get_response URI(@tcp) + assert_equal 'chunked', resp['transfer-encoding'] + assert_equal STR_1KB * 10, resp.body.force_encoding(Encoding::UTF_8) + end if Rack.release < '3.1' + + def test_puma_enum + body = Array.new(10, STR_1KB).to_enum + @server.app = lambda { |env| [200, { 'content-type' => 'text/plain; charset=utf-8' }, body] } + @server.run + + resp = Net::HTTP.get_response URI(@tcp) + assert_equal 'chunked', resp['transfer-encoding'] + assert_equal STR_1KB * 10, resp.body.force_encoding(Encoding::UTF_8) + end end diff -Nru puma-5.6.5/test/test_redirect_io.rb puma-6.4.2/test/test_redirect_io.rb --- puma-5.6.5/test/test_redirect_io.rb 2022-08-22 23:33:14.000000000 +0000 +++ puma-6.4.2/test/test_redirect_io.rb 2024-01-08 05:53:42.000000000 +0000 @@ -1,9 +1,13 @@ +# frozen_string_literal: true + require_relative "helper" require_relative "helpers/integration" class TestRedirectIO < TestIntegration parallelize_me! + FILE_STR = 'puma startup' + def setup skip_unless_signal_exist? :HUP super @@ -13,6 +17,12 @@ @err_file = Tempfile.new('puma-err') @out_file_path = @out_file.path @err_file_path = @err_file.path + + @cli_args = ['--redirect-stdout', @out_file_path, + '--redirect-stderr', @err_file_path, + 'test/rackup/hello.ru' + ] + end def teardown @@ -30,56 +40,17 @@ 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) + cli_server @cli_args.join ' ' - wait_until_file_has_content @err_file_path - assert_match 'puma startup', File.read(@err_file_path) + rotate_check_logs 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 + cli_server (['-w', '1'] + @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) + rotate_check_logs end private @@ -88,6 +59,7 @@ # 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 @@ -95,14 +67,35 @@ File.new(@err_file_path, File::CREAT).close end - def wait_until_file_has_content(path) + def rotate_check_logs + assert_file_contents @out_file_path + assert_file_contents @err_file_path + + log_rotate_output_files + + Process.kill :HUP, @pid + + assert_file_contents @out_file_path + assert_file_contents @err_file_path + end + + def assert_file_contents(path, include = FILE_STR) + retries = 0 + retries_max = 50 # 5 seconds File.open(path) do |file| begin file.read_nonblock 1 file.seek 0 + assert_includes file.read, include, + "File #{File.basename(path)} does not include #{include}" rescue EOFError sleep 0.1 - retry + retries += 1 + if retries < retries_max + retry + else + flunk 'File read took too long' + end end end end diff -Nru puma-5.6.5/test/test_request_invalid.rb puma-6.4.2/test/test_request_invalid.rb --- puma-5.6.5/test/test_request_invalid.rb 2022-08-22 23:33:14.000000000 +0000 +++ puma-6.4.2/test/test_request_invalid.rb 2024-01-08 05:53:42.000000000 +0000 @@ -1,5 +1,4 @@ require_relative "helper" -require "puma/events" # These tests check for invalid request headers and metadata. # Content-Length, Transfer-Encoding, and chunked body size @@ -25,7 +24,7 @@ # this app should never be called, used for debugging app = ->(env) { - body = ''.dup + body = +'' env.each do |k,v| body << "#{k} = #{v}\n" if k == 'rack.input' @@ -35,8 +34,8 @@ [200, {}, [body]] } - events = Puma::Events.strings - @server = Puma::Server.new app, events + @log_writer = Puma::LogWriter.strings + @server = Puma::Server.new app, nil, {log_writer: @log_writer} @port = (@server.add_tcp_listener @host, 0).addr[1] @server.run sleep 0.15 if Puma.jruby? diff -Nru puma-5.6.5/test/test_response_header.rb puma-6.4.2/test/test_response_header.rb --- puma-5.6.5/test/test_response_header.rb 2022-08-22 23:33:14.000000000 +0000 +++ puma-6.4.2/test/test_response_header.rb 2024-01-08 05:53:42.000000000 +0000 @@ -1,10 +1,14 @@ require_relative "helper" require "puma/events" require "net/http" +require "nio" class TestResponseHeader < Minitest::Test parallelize_me! + # this file has limited response length, so 10kB works. + CLIENT_SYSREAD_LENGTH = 10_240 + def setup @host = "127.0.0.1" @@ -12,8 +16,8 @@ @app = ->(env) { [200, {}, [env['rack.url_scheme']]] } - @events = Puma::Events.strings - @server = Puma::Server.new @app, @events + @log_writer = Puma::LogWriter.strings + @server = Puma::Server.new @app, ::Puma::Events.new, {log_writer: @log_writer, min_threads: 1} end def teardown @@ -24,12 +28,12 @@ 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.instance_variable_set(:@early_hints, true) if early_hints @server.run end def send_http_and_read(req) - send_http(req).read + send_http(req).sysread CLIENT_SYSREAD_LENGTH end def send_http(req) @@ -45,7 +49,7 @@ 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) + assert_match(/Puma caught this error/, data) end # The header must respond to each @@ -53,7 +57,7 @@ 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) + assert_match(/Puma caught this error/, data) end # The values of the header must be Strings @@ -79,7 +83,11 @@ 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) + if opts[:early_hints] + refute_includes data, "HTTP/1.1 103 Early Hints" + end + + refute_includes data, "#{name}: #{value}" end # The header must not contain a Status key. @@ -92,7 +100,7 @@ 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) + assert_match(/HTTP\/1.0 200 OK\r\nTeapot-Status: Boiling\r\nContent-Length: 0\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. @@ -105,7 +113,7 @@ 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) + assert_match(/HTTP\/1.0 200 OK\r\nRacket: Bouncy\r\nContent-Length: 0\r\n\r\n/, data) end # testing header key must conform rfc token specification @@ -141,4 +149,12 @@ refute_match("X-header: First\000 line\r\nX-header: Second Lin\037e\r\n", data) end + + def test_header_value_array + server_run app: ->(env) { [200, {'set-cookie' => ['z=1', 'a=2']}, ['Hello']] } + data = send_http_and_read "GET / HTTP/1.1\r\n\r\n" + + resp = "HTTP/1.1 200 OK\r\nset-cookie: z=1\r\nset-cookie: a=2\r\nContent-Length: 5\r\n\r\n" + assert_includes data, resp + end end diff -Nru puma-5.6.5/test/test_thread_pool.rb puma-6.4.2/test/test_thread_pool.rb --- puma-5.6.5/test/test_thread_pool.rb 2022-08-22 23:33:14.000000000 +0000 +++ puma-6.4.2/test/test_thread_pool.rb 2024-01-08 05:53:42.000000000 +0000 @@ -10,12 +10,20 @@ def new_pool(min, max, &block) block = proc { } unless block - @pool = Puma::ThreadPool.new('tst', min, max, &block) + options = { + min_threads: min, + max_threads: max + } + @pool = Puma::ThreadPool.new('tst', options, &block) end def mutex_pool(min, max, &block) block = proc { } unless block - @pool = MutexPool.new('tst', min, max, &block) + options = { + min_threads: min, + max_threads: max + } + @pool = MutexPool.new('tst', options, &block) end # Wraps ThreadPool work in mutex for better concurrency control. @@ -27,7 +35,7 @@ work = [work] unless work.is_a?(Array) with_mutex do work.each {|arg| super arg} - yield if block_given? + yield if block @not_full.wait(@mutex) end end @@ -109,6 +117,26 @@ assert_equal 3, pool.backlog end + def test_thread_start_hook + started = Queue.new + options = { + min_threads: 0, + max_threads: 1, + before_thread_start: [ + proc do + started << 1 + end + ] + } + block = proc { } + pool = MutexPool.new('tst', options, &block) + + pool << 1 + + assert_equal 1, pool.spawned + assert_equal 1, started.length + end + def test_trim pool = mutex_pool(0, 1) @@ -159,6 +187,29 @@ assert_equal 0, pool.trim_requested end + def test_trim_thread_exit_hook + exited = Queue.new + options = { + min_threads: 0, + max_threads: 1, + before_thread_exit: [ + proc do + exited << 1 + end + ] + } + block = proc { } + pool = MutexPool.new('tst', options, &block) + + pool << 1 + + assert_equal 1, pool.spawned + + pool.trim + assert_equal 0, pool.spawned + assert_equal 1, exited.length + end + def test_autotrim pool = mutex_pool(1, 2) @@ -184,7 +235,7 @@ Thread.current[:foo] = :hai } - pool.clean_thread_locals = true + pool.instance_variable_set :@clean_thread_locals, true pool << [1] * n diff -Nru puma-5.6.5/test/test_url_map.rb puma-6.4.2/test/test_url_map.rb --- puma-5.6.5/test/test_url_map.rb 1970-01-01 00:00:00.000000000 +0000 +++ puma-6.4.2/test/test_url_map.rb 2024-01-08 05:53:42.000000000 +0000 @@ -0,0 +1,24 @@ +require_relative "helper" +require_relative "helpers/integration" + +class TestURLMap < TestIntegration + + def teardown + return if skipped? + super + end + + # make sure the mapping defined in url_map_test/config.ru works + def test_basic_url_mapping + skip_if :jruby + env = { "BUNDLE_GEMFILE" => "#{__dir__}/url_map_test/Gemfile" } + Dir.chdir("#{__dir__}/url_map_test") do + cli_server set_pumactl_args, env: env + end + connection = connect("/ok") + # Puma 6.2.2 and below will time out here with Ruby v3.3 + # see https://github.com/puma/puma/pull/3165 + body = read_body(connection, 1) + assert_equal("OK", body) + end +end diff -Nru puma-5.6.5/test/test_web_server.rb puma-6.4.2/test/test_web_server.rb --- puma-5.6.5/test/test_web_server.rb 2022-08-22 23:33:14.000000000 +0000 +++ puma-6.4.2/test/test_web_server.rb 2024-01-08 05:53:42.000000000 +0000 @@ -23,7 +23,7 @@ def setup @tester = TestHandler.new - @server = Puma::Server.new @tester, Puma::Events.strings + @server = Puma::Server.new @tester, nil, {log_writer: Puma::LogWriter.strings} @port = (@server.add_tcp_listener "127.0.0.1", 0).addr[1] @tcp = "http://127.0.0.1:#{@port}" @server.run @@ -64,6 +64,12 @@ socket.close end + def test_bad_path + socket = do_test("GET : HTTP/1.1\r\n\r\n", 3) + assert_match "HTTP/1.1 400 Bad Request\r\n\r\n", 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 @@ -79,6 +85,20 @@ socket.close end + def test_supported_http_method + socket = do_test("PATCH www.zedshaw.com:443 HTTP/1.1\r\nConnection: close\r\n\r\n", 100) + response = socket.read + assert_match "hello", response + socket.close + end + + def test_nonexistent_http_method + socket = do_test("FOOBARBAZ www.zedshaw.com:443 HTTP/1.1\r\nConnection: close\r\n\r\n", 100) + response = socket.read + assert_match "Not Implemented", response + socket.close + end + private def do_test(string, chunk) diff -Nru puma-5.6.5/test/test_worker_gem_independence.rb puma-6.4.2/test/test_worker_gem_independence.rb --- puma-5.6.5/test/test_worker_gem_independence.rb 2022-08-22 23:33:14.000000000 +0000 +++ puma-6.4.2/test/test_worker_gem_independence.rb 2024-01-08 05:53:42.000000000 +0000 @@ -103,7 +103,7 @@ initial_reply = read_body(connection) assert_equal old_version, initial_reply - before_restart.call if before_restart + before_restart&.call set_release_symlink File.expand_path(new_app_dir, __dir__) Dir.chdir(current_release_symlink) do diff -Nru puma-5.6.5/test/url_map_test/Gemfile puma-6.4.2/test/url_map_test/Gemfile --- puma-5.6.5/test/url_map_test/Gemfile 1970-01-01 00:00:00.000000000 +0000 +++ puma-6.4.2/test/url_map_test/Gemfile 2024-01-08 05:53:42.000000000 +0000 @@ -0,0 +1 @@ +gem 'puma', path: '../..' diff -Nru puma-5.6.5/test/url_map_test/config.ru puma-6.4.2/test/url_map_test/config.ru --- puma-5.6.5/test/url_map_test/config.ru 1970-01-01 00:00:00.000000000 +0000 +++ puma-6.4.2/test/url_map_test/config.ru 2024-01-08 05:53:42.000000000 +0000 @@ -0,0 +1,9 @@ +map "/ok" do + run ->(env) { + if Object.const_defined?(:Rack) && ::Rack.const_defined?(:URLMap) + [200, {}, ["::Rack::URLMap is loaded"]] + else + [200, {}, ["OK"]] + end + } +end diff -Nru puma-5.6.5/tools/Dockerfile puma-6.4.2/tools/Dockerfile --- puma-5.6.5/tools/Dockerfile 2022-08-22 23:33:14.000000000 +0000 +++ puma-6.4.2/tools/Dockerfile 2024-01-08 05:53:42.000000000 +0000 @@ -1,6 +1,6 @@ # Use this Dockerfile to create minimal reproductions of issues -FROM ruby:3.1 +FROM ruby:3.2 # throw errors if Gemfile has been modified since Gemfile.lock RUN bundle config --global frozen 1 @@ -8,7 +8,7 @@ WORKDIR /usr/src/app COPY . . -RUN gem install bundler + RUN bundle install RUN bundle exec rake compile