Skip to content

Commit

Permalink
Allow passing a configuration to Builder, and use it when building
Browse files Browse the repository at this point in the history
This adds Rack::Builder::Config, which stores information on the
server's configuration, such as whether it is multithreaded or
supports reentrancy.

Middleware can use the configuration by defining a rackup method
in addition to a new method.  If the rackup method is defined,
it is called instead of the new method, with the configuration
as the first argument and with the remaining arguments and block
the same as what would be passed to new.

In cases where the server's configuration indicates the middleware
is not needed, the middleware rackup method can be just return the
app itself.

To handle the very rare case where a middleware would want to
delegate to other middleware in certain server configurations, and
doesn't know whether the other middleware supports the rackup
method or not, The configuration object supports a rackup method,
which will call rackup on the middleware if defined, or new
otherwise.  So if middleware A wants to use external middleware B
in a certain server configuration, middleware A's rackup method
could be something like:

  def self.rackup(config, app)
    if config.multithread?
      config.rackup(MiddlewareB, app)
    else
      new(app)
    end
  end

The configuration rackup method is also used internally to implement
the builder.

This readds Rack::Lock, using the rackup method, which only uses
the Rack::Lock middleware if the configuration indicates the
server is multithreaded.

The advantage of this approach is that it doesn't require exposing
the entire builder API to the middleware, it only exposes the
server configuration, which is all the middleware should need to
appropriately configure itself.
  • Loading branch information
jeremyevans committed Nov 14, 2020
1 parent 7a446d2 commit b67c697
Show file tree
Hide file tree
Showing 4 changed files with 98 additions and 12 deletions.
1 change: 1 addition & 0 deletions lib/rack.rb
Expand Up @@ -91,6 +91,7 @@ module Rack
autoload :Handler, "rack/handler"
autoload :Head, "rack/head"
autoload :Lint, "rack/lint"
autoload :Lock, "rack/lock"
autoload :Logger, "rack/logger"
autoload :MediaType, "rack/media_type"
autoload :MethodOverride, "rack/method_override"
Expand Down
57 changes: 45 additions & 12 deletions lib/rack/builder.rb
Expand Up @@ -31,6 +31,30 @@ module Rack
# You can use +map+ to construct a Rack::URLMap in a convenient way.

class Builder
# Config stores settings on what the server supports, such as whether it
# is multithreaded.
class Config
def initialize(multithread: true, reentrant: multithread)
@multithread = multithread
@reentrant = reentrant
end

# Whether the application server will invoke the application from multiple threads. Implies {reentrant?}.
def multithread?; @multithread; end

# Re-entrancy is a feature of event-driven servers which may perform non-blocking operations. When
# an operation blocks, that particular request may yield and another request may enter the application stack.
def reentrant?; @reentrant; end

def rackup(middleware, *args, &block)
if middleware.respond_to?(:rackup)
middleware.rackup(self, *args, &block)
else
middleware.new(*args, &block)
end
end
ruby2_keywords(:rackup) if respond_to?(:ruby2_keywords, true)
end

# https://stackoverflow.com/questions/2223882/whats-the-difference-between-utf-8-and-utf-8-without-bom
UTF_8_BOM = '\xef\xbb\xbf'
Expand Down Expand Up @@ -59,9 +83,9 @@ class Builder
# # requires ./my_app.rb, which should be in the
# # process's current directory. After requiring,
# # assumes MyApp constant contains Rack application
def self.parse_file(path)
def self.parse_file(path, config: nil)
if path.end_with?('.ru')
return self.load_file(path)
return self.load_file(path, config: config)
else
require path
return Object.const_get(::File.basename(path, '.rb').split('_').map(&:capitalize).join(''))
Expand All @@ -83,7 +107,7 @@ def self.parse_file(path)
# use Rack::ContentLength
# require './app.rb'
# run App
def self.load_file(path)
def self.load_file(path, config: nil)
config = ::File.read(path)
config.slice!(/\A#{UTF_8_BOM}/) if config.encoding == Encoding::UTF_8

Expand All @@ -93,25 +117,33 @@ def self.load_file(path)

config.sub!(/^__END__\n.*\Z/m, '')

return new_from_string(config, path)
return new_from_string(config, path, config: config)
end

# Evaluate the given +builder_script+ string in the context of
# a Rack::Builder block, returning a Rack application.
def self.new_from_string(builder_script, file = "(rackup)")
# We want to build a variant of TOPLEVEL_BINDING with self as a Rack::Builder instance.
# We cannot use instance_eval(String) as that would resolve constants differently.
binding, builder = TOPLEVEL_BINDING.eval('Rack::Builder.new.instance_eval { [binding, self] }')
eval builder_script, binding, file
def self.new_from_string(builder_script, file = "(rackup)", config: nil)
builder = self.new(config: config)

# Create a top level scope with self as the builder instance:
binding = TOPLEVEL_BINDING.eval('->(builder){builder.instance_eval{binding}}').call(builder)

eval(builder_script, binding, file)

return builder.to_app
end

# Initialize a new Rack::Builder instance. +default_app+ specifies the
# default application if +run+ is not called later. If a block
# is given, it is evaluted in the context of the instance.
def initialize(default_app = nil, &block)
@use, @map, @run, @warmup, @freeze_app = [], nil, default_app, nil, false
def initialize(default_app = nil, config: nil, &block)
@use = []
@map = nil
@run = default_app
@warmup = nil
@freeze_app = false
@config = config || Config.new

instance_eval(&block) if block_given?
end

Expand Down Expand Up @@ -145,7 +177,8 @@ def use(middleware, *args, &block)
mapping, @map = @map, nil
@use << proc { |app| generate_map(app, mapping) }
end
@use << proc { |app| middleware.new(app, *args, &block) }

@use << proc { |app| @config.rackup(middleware, app, *args, &block) }
end
ruby2_keywords(:use) if respond_to?(:ruby2_keywords, true)

Expand Down
27 changes: 27 additions & 0 deletions lib/rack/lock.rb
@@ -0,0 +1,27 @@
# frozen_string_literal: true

require 'logger'

module Rack
# Sets up rack.logger to write to rack.errors stream
class Lock
def initialize(app)
@app = app
@mutex = ::Thread::Mutex.new
end

def call(env)
@mutex.synchronize do
@app.call(env)
end
end

def self.rackup(config, app)
if config.multithread?
new(app)
else
app
end
end
end
end
25 changes: 25 additions & 0 deletions test/spec_lock.rb
@@ -0,0 +1,25 @@
# frozen_string_literal: true

require_relative 'helper'

describe Rack::Lock do
it "constructs lock when builder is multithreaded" do
x = Object.new
builder = Rack::Builder.new(config: Rack::Builder::Config.new(multithread: true)) do
use Rack::Lock
run x
end

builder.to_app.must_be_kind_of Rack::Lock
end

it "ignores lock when builder is not multithreaded" do
x = Object.new
builder = Rack::Builder.new(config: Rack::Builder::Config.new(multithread: false)) do
use Rack::Lock
run x
end

builder.to_app.must_be_same_as x
end
end

0 comments on commit b67c697

Please sign in to comment.