From 248e08a06853238b52497108664baaa6a36781b4 Mon Sep 17 00:00:00 2001 From: Eric Proulx Date: Tue, 3 Nov 2026 00:40:32 +0100 Subject: [PATCH] Run Grape behind Falcon in CI Add gemfiles/falcon.gemfile and spec/integration/falcon, run on Ruby 3.3, 3.4 and 4.0. The specs start a real Falcon server in-process on a free port and send it requests with async-http's client: - 30 concurrent requests, which pause in a before filter, an after filter, a custom validator inside array elements and the route block, each keep their own params, endpoint instance variables and validation error index. - error! inside an Async task the route waits on responds with its status, with and without rescue_from :all (#3023). - stream sends each chunk of an Enumerator before the endpoint produces the next. Each spec fails under a matching break: a per-thread ParamScopeTracker, one endpoint shared by every request, error! throwing instead of raising, Halt as an Exception rather than a StandardError, and a stream body read into an Array. A client disconnect or a server stop doesn't cancel a running endpoint: async-http's servers run the app under defer_stop, so there is no cancellation to spec. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/test.yml | 11 +- CHANGELOG.md | 1 + gemfiles/falcon.gemfile | 5 + spec/integration/falcon/falcon_spec.rb | 181 +++++++++++++++++++++++++ 4 files changed, 197 insertions(+), 1 deletion(-) create mode 100644 gemfiles/falcon.gemfile create mode 100644 spec/integration/falcon/falcon_spec.rb diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 0f7b3ef6a..920ef3cc6 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -30,7 +30,7 @@ jobs: - gemfiles/rack_3_2.gemfile - gemfiles/rails_8_0.gemfile - gemfiles/rails_8_1.gemfile - specs: ['spec --exclude-pattern=spec/integration/{grape_entity,grape_swagger,hashie,dry_validation,multi_*}/*_spec.rb'] + specs: ['spec --exclude-pattern=spec/integration/{grape_entity,grape_swagger,hashie,dry_validation,multi_*,falcon}/*_spec.rb'] include: - ruby: '4.0' gemfile: gemfiles/grape_entity.gemfile @@ -56,6 +56,15 @@ jobs: - ruby: '4.0' gemfile: gemfiles/multi_xml_0_8.gemfile specs: 'spec/integration/multi_xml' + - ruby: '3.3' + gemfile: gemfiles/falcon.gemfile + specs: 'spec/integration/falcon' + - ruby: '3.4' + gemfile: gemfiles/falcon.gemfile + specs: 'spec/integration/falcon' + - ruby: '4.0' + gemfile: gemfiles/falcon.gemfile + specs: 'spec/integration/falcon' runs-on: ubuntu-latest env: BUNDLE_GEMFILE: ${{ github.workspace }}/${{ matrix.gemfile }} diff --git a/CHANGELOG.md b/CHANGELOG.md index 135b7803b..f0cd9ebd3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ * [#3019](https://github.com/ruby-grape/grape/pull/3019): Add `API.eager_load!` to compile an API and load its I18n translations at boot - [@ericproulx](https://github.com/ericproulx). * [#3022](https://github.com/ruby-grape/grape/pull/3022): Compile the APIs mounted in a Rails application's routes at boot when it eager loads - [@ericproulx](https://github.com/ericproulx). * [#3052](https://github.com/ruby-grape/grape/pull/3052): Add `Grape::ErrorFormatter::ProblemJson` to answer errors as RFC 9457 problem details - [@ericproulx](https://github.com/ericproulx). +* [#3053](https://github.com/ruby-grape/grape/pull/3053): Run Grape behind Falcon in CI - [@ericproulx](https://github.com/ericproulx). * [#3054](https://github.com/ruby-grape/grape/pull/3054): Add `Grape.config.allow_undeclared_params`, answering 400 to undeclared params when `false`, the Grape 5.0 default - [@ericproulx](https://github.com/ericproulx). * [#3056](https://github.com/ruby-grape/grape/pull/3056): Drop support for Rails 7.2 and require ActiveSupport 8.0 - [@ericproulx](https://github.com/ericproulx). * [#3062](https://github.com/ruby-grape/grape/pull/3062): Use the reusable Danger workflow with a separate Gemfile - [@dblock](https://github.com/dblock). diff --git a/gemfiles/falcon.gemfile b/gemfiles/falcon.gemfile new file mode 100644 index 000000000..d0a012707 --- /dev/null +++ b/gemfiles/falcon.gemfile @@ -0,0 +1,5 @@ +# frozen_string_literal: true + +gem 'falcon' + +eval_gemfile '../Gemfile' diff --git a/spec/integration/falcon/falcon_spec.rb b/spec/integration/falcon/falcon_spec.rb new file mode 100644 index 000000000..1c8de0420 --- /dev/null +++ b/spec/integration/falcon/falcon_spec.rb @@ -0,0 +1,181 @@ +# frozen_string_literal: true + +if defined?(Falcon) + require 'async/http/client' + require 'async/http/endpoint' +end + +# Grape behind a real Falcon server. Every request runs in a fiber on the same +# thread, and requests interleave whenever an endpoint waits, so state Grape +# kept per thread or on a shared object would leak from one request into the +# next. +describe 'Falcon', if: defined?(Falcon) do + # Serves +app+ from Falcon on a free local port and yields a client + # connected to it. The timeout turns a request that never completes into a + # failure rather than a hung build. + def serve(app) + Sync do |task| + bound = Async::HTTP::Endpoint.parse('http://127.0.0.1:0').bound + server_task = task.async { falcon(app, bound).run } + client = client_for(bound) + task.with_timeout(5) { yield client } + ensure + client&.close + server_task&.stop + bound&.close + end + end + + # Falcon's response cache would answer a repeated GET without Grape. + def falcon(app, bound) + Falcon::Server.new(Falcon::Server.rack_middleware(app, cache: false), bound, protocol: Async::HTTP::Protocol::HTTP, scheme: 'http') + end + + def client_for(bound) + port = bound.sockets.first.to_io.local_address.ip_port + Async::HTTP::Client.new(Async::HTTP::Endpoint.parse("http://127.0.0.1:#{port}")) + end + + describe 'concurrent requests' do + let(:pausing_validator) do + Class.new(Grape::Validations::Validators::Base) do + def validate_param!(attr_name, params) + sleep(0) + return unless params[attr_name] == 'bad' + + raise Grape::Exceptions::Validation.new(params: [@scope.full_name(attr_name)], message: 'is bad') + end + end + end + + before do + stub_const('PausingValidator', pausing_validator) + Grape::Validations.register(PausingValidator) + end + + after { Grape::Validations.deregister(:pausing) } + + it 'keeps each request to its own params, endpoint and validation errors' do + in_flight = 0 + most_in_flight = 0 + app = Class.new(Grape::API) do + format :json + before do + in_flight += 1 + most_in_flight = [most_in_flight, in_flight].max + @id = params[:id] + sleep(0) + end + + after do + sleep(0) + header 'X-Id', @id.to_s + end + + finally { in_flight -= 1 } + params do + requires :id, type: Integer + # The pause inside an element is what exposes a shared index + # tracker: the error names the element by its index. + requires :items, type: Array do + requires :value, type: String, pausing: true + end + end + post('/items') do + sleep(0) + { id: params[:id], before: @id } + end + end + + ids = (0...30).to_a + responses = serve(app) do |client| + ids.map do |id| + items = Array.new(4) { { value: 'ok' } } + items[id % 4][:value] = 'bad' if id.odd? + Async do + response = client.post('/items', [%w[content-type application/json]], { id:, items: }.to_json) + [response.status, response.headers['x-id']&.first, response.read] + end + end.map(&:wait) + end + + expect(responses).to eq( + ids.map do |id| + next [400, nil, { error: "items[#{id % 4}][value] is bad" }.to_json] if id.odd? + + [201, id.to_s, { id:, before: id }.to_json] + end + ) + expect(most_in_flight).to be > 1 + end + end + + describe 'error! in a task the route waits on' do + it 'responds with its status' do + app = Class.new(Grape::API) do + format :json + get('/') do + Async do + # A lookup that takes a moment, so the route is already waiting + # when error! raises; async logs a failure nobody waits on. + sleep(0.01) + error!('out of stock', 409) + end.wait + end + end + + response = serve(app) { |client| client.get('/').then { |r| [r.status, r.read] } } + + expect(response).to eq([409, { error: 'out of stock' }.to_json]) + end + + it 'responds with its status under rescue_from :all' do + app = Class.new(Grape::API) do + format :json + rescue_from :all + get('/') do + Async do + # A lookup that takes a moment, so the route is already waiting + # when error! raises; async logs a failure nobody waits on. + sleep(0.01) + error!('out of stock', 409) + end.wait + end + end + + response = serve(app) { |client| client.get('/').then { |r| [r.status, r.read] } } + + expect(response).to eq([409, { error: 'out of stock' }.to_json]) + end + end + + describe 'stream' do + it 'sends each chunk before the endpoint produces the next' do + received = Async::Queue.new + acknowledged = [] + app = Class.new(Grape::API) do + get('/') do + # After each chunk the endpoint waits for the client to have it. A + # body read in full before it is sent gets no acknowledgement, and + # the timeout keeps that from hanging the endpoint. + stream(Enumerator.new do |yielder| + 3.times do |index| + yielder << "chunk #{index}\n" + acknowledged << received.dequeue(timeout: 1) + end + end) + end + end + + chunks = serve(app) do |client| + client.get('/').body.to_enum(:each).map do |chunk| + received.enqueue(chunk) + chunk + end + end + + expect(chunks).to eq(["chunk 0\n", "chunk 1\n", "chunk 2\n"]) + expect(acknowledged).to eq(chunks) + end + end +end