summaryrefslogtreecommitdiff
path: root/code/spyglass/lib
diff options
context:
space:
mode:
Diffstat (limited to 'code/spyglass/lib')
-rw-r--r--code/spyglass/lib/spyglass.rb53
-rw-r--r--code/spyglass/lib/spyglass/configurator.rb31
-rw-r--r--code/spyglass/lib/spyglass/logging.rb25
-rw-r--r--code/spyglass/lib/spyglass/lookout.rb83
-rw-r--r--code/spyglass/lib/spyglass/master.rb102
-rw-r--r--code/spyglass/lib/spyglass/server.rb17
-rw-r--r--code/spyglass/lib/spyglass/worker.rb93
7 files changed, 404 insertions, 0 deletions
diff --git a/code/spyglass/lib/spyglass.rb b/code/spyglass/lib/spyglass.rb
new file mode 100644
index 0000000..0e3f9ba
--- /dev/null
+++ b/code/spyglass/lib/spyglass.rb
@@ -0,0 +1,53 @@
+# Spyglass
+# ========
+#
+# This is Spyglass, a Rack web server that rides on Unix designed to be simple and teach
+# others about Unix programming.
+#
+# It's namesake comes from the fact that when it boots up it's nothing more than a lone socket
+# keeping a lookout for incoming connections.
+#
+# When a connection comes in it spins up a Master
+# process which preforks some workers to actually handle http requests. If the Master process is
+# left idle long enough it will shut itself (and it's workers) down and go back to just a lone
+# listening socket, on the lookout for incoming connections.
+#
+# Components
+# ==========
+#
+# * [Server](server.html) gets the ball rolling.
+# The role of Server is pretty minimal. It opens the initial listening TCP socket,
+# then passes that socket onto the Lookout. The Lookout will actually handle reading
+# from the socket.
+#
+# * [Lookout](lookout.html) keeps a watch and notifies others when a connection
+# comes in.
+# The Lookout is a pretty 'dumb' object. All that it does is listen for incoming
+# connections on the socket it's given. Once it receives a connection it does a fork(2)
+# and invokes a Master process. The Master process actually handles the connection.
+#
+# * [Master](master.html) loads the application and babysits worker processes
+# that actually talk to clients.
+# The role of the Master class is to create and babysit worker processes
+# that will actually handle web requests. The Master itself doesn't know
+# anything about http, etc. it just knows how to manage processes.
+#
+# * [Worker](worker.html) parses HTTP, calls the app, and writes back to the client.
+require 'singleton'
+require 'socket'
+require 'stringio'
+
+require 'rack/server'
+require 'rack/builder'
+
+require 'spyglass_parser'
+require 'spyglass/configurator'
+require 'spyglass/logging'
+require 'spyglass/server'
+require 'spyglass/lookout'
+require 'spyglass/master'
+require 'spyglass/worker'
+
+module Spyglass
+ Version = '0.1.1'
+end
diff --git a/code/spyglass/lib/spyglass/configurator.rb b/code/spyglass/lib/spyglass/configurator.rb
new file mode 100644
index 0000000..6c1e0a7
--- /dev/null
+++ b/code/spyglass/lib/spyglass/configurator.rb
@@ -0,0 +1,31 @@
+module Spyglass
+ class Configurator
+ # A hash of key => default
+ OPTIONS = {
+ :port => 4222,
+ :host => '0.0.0.0',
+ :workers => 2,
+ :timeout => 30,
+ :config_ru_path => 'config.ru',
+ :verbose => false,
+ :vverbose => false
+ }
+
+ class << self
+ OPTIONS.each do |key, default|
+ # attr_writer key
+
+ define_method(key) do |*args|
+ arg = args.shift
+ if arg
+ instance_variable_set("@#{key}", arg)
+ else
+ instance_variable_get("@#{key}") || default
+ end
+ end
+ end
+ end
+ end
+
+ Config = Configurator
+end
diff --git a/code/spyglass/lib/spyglass/logging.rb b/code/spyglass/lib/spyglass/logging.rb
new file mode 100644
index 0000000..0819ea2
--- /dev/null
+++ b/code/spyglass/lib/spyglass/logging.rb
@@ -0,0 +1,25 @@
+module Spyglass
+ module Logging
+ def out(message)
+ $stdout.puts preamble + message
+ end
+
+ def err(message)
+ $stderr.puts preamble + message
+ end
+
+ def verbose(message)
+ return unless Config.verbose
+ out(message)
+ end
+
+ def vverbose(message)
+ return unless Config.vverbose
+ out(message)
+ end
+
+ def preamble
+ "[#{Process.pid}] [#{self.class.name}] "
+ end
+ end
+end
diff --git a/code/spyglass/lib/spyglass/lookout.rb b/code/spyglass/lib/spyglass/lookout.rb
new file mode 100644
index 0000000..a24e34a
--- /dev/null
+++ b/code/spyglass/lib/spyglass/lookout.rb
@@ -0,0 +1,83 @@
+module Spyglass
+ class Lookout
+ include Singleton, Logging
+
+ # This method is the main entry point for the Lookout class. It takes
+ # a socket object.
+ def start(socket)
+ trap_signals
+
+ # The Lookout doesn't know anything about the app itself, so there's
+ # no app related setup to do here.
+ loop do
+ # Accepts a new connection on our socket. This class won't actually
+ # do anything interesting with this connection, it will pass it down
+ # to the `Master` class created below to do the actual request handling.
+ conn = socket.accept
+ out "Received incoming connection"
+
+ # In this block the Lookout forks a new process and invokes a Master,
+ # passing along the socket it received and the connection it accepted
+ # above.
+ @master_pid = fork do
+ master = Master.new(conn, socket)
+ master.start
+ end
+
+ # The Lookout can now close its handle on the client socket. This doesn't
+ # translate to the socket being closed on the clients end because the
+ # forked Master process also has a handle on the same socket. Since this
+ # handle is now cleaned up it's up to the Master process to ensure that
+ # its handle gets cleaned up.
+ conn.close
+ # Now this process blocks until the Master process exits. The Master process
+ # will only exit once traffic is slow enough that it has reached its timeout
+ # without receiving any new connections.
+ Process.waitpid(@master_pid)
+
+ # The interaction of fork(2)/waitpid(2) above deserve some explanation.
+
+ # ### Why fork(2)? Why not just spin up the Master?
+ # The whole point of the Lookout process is to be very lean. The only resource
+ # that it initializes is the listening socket for the server. It doesn't load
+ # any of your application into memory, so its resource footprint is very small.
+
+ # The reason that it does a fork(2) before invoking the Master is because once
+ # the Master times out we want the Lookout process to remain lean when accepting
+ # the next connection.
+
+ # If it were to load the application code without forking
+ # then there would be no (simple) way for it to later unload the application code.
+
+ # By doing a fork(2), then waiting for the Master process to exit, that guarantees
+ # that all resources (notably memory usage) that were in use by the Master process
+ # will be reclaimed by the kernel.
+
+ # ### Who knows what your app will demand!
+ # While handling requests your app may require lots of memory. Containing this in a
+ # child process, and exiting that process, is the easiest way to ensure that memory
+ # bloat isn't shared with our simple parent process.
+
+ # This allows our Lookout process will to go back around
+ # the loop with nothing more than it started with, just a listening socket.
+
+ # The fork(2)/waitpid(2) approach requires little code to implement, and pushes
+ # responsibility down to the kernel to track resource usage and nicely clean up
+ # the Master process when it's finished.
+ end
+ end
+
+ def trap_signals
+ [:INT, :QUIT].each do |sig|
+ trap(sig) {
+ begin
+ Process.kill(sig, @master_pid) if @master_pid
+ rescue Errno::ESRCH
+ end
+ exit
+ }
+ end
+ end
+ end
+end
+
diff --git a/code/spyglass/lib/spyglass/master.rb b/code/spyglass/lib/spyglass/master.rb
new file mode 100644
index 0000000..84341f1
--- /dev/null
+++ b/code/spyglass/lib/spyglass/master.rb
@@ -0,0 +1,102 @@
+module Spyglass
+ class Master
+ include Logging
+
+ def initialize(connection, socket)
+ @connection, @socket = connection, socket
+ @worker_pids = []
+
+ # The Master shares this pipe with each of its worker processes. It
+ # passes the writable end down to each spawned worker while it listens
+ # on the readable end. Each worker will write to the pipe each time
+ # it accepts a new connection. If The Master doesn't get anything on
+ # the pipe before `Config.timeout` elapses then it kills its workers
+ # and exits.
+ @readable_pipe, @writable_pipe = IO.pipe
+ end
+
+ # This method starts the Master. It enters an infinite loop where it creates
+ # processes to handle web requests and ensures that they stay active. It takes
+ # a connection as an argument from the Lookout instance. A Master will only
+ # be started when a connection is received by the Lookout.
+ def start
+ trap_signals
+
+ load_app
+ out "Loaded the app"
+
+ # The first worker we spawn has to handle the connection that was already
+ # passed to us.
+ spawn_worker(@connection)
+ # The Master can now close its handle on the client socket since the
+ # forked worker also got a handle on the same socket. Since this one
+ # is now closed it's up to the Worker process to close its handle when
+ # it's done. At that point the client connection will perceive that
+ # it's been closed on their end.
+ @connection.close
+
+ # We spawn the rest of the workers.
+ (Config.workers - 1).times { spawn_worker }
+ out "Spawned #{Config.workers} workers. Babysitting now..."
+
+ loop do
+ if timed_out?(IO.select([@readable_pipe], nil, nil, Config.timeout))
+ out "Timed out after #{Config.timeout} s. Exiting."
+
+ kill_workers(:QUIT)
+ exit
+ else
+ # Clear the data on the pipe so it doesn't appear to be readable
+ # next time around the loop.
+ @readable_pipe.read_nonblock 1
+ end
+ end
+ end
+
+ def timed_out?(select_result)
+ !select_result
+ end
+
+ def spawn_worker(connection = nil)
+ @worker_pids << fork { Worker.new(@socket, @app, @writable_pipe, connection).start }
+ end
+
+ def trap_signals
+ # The QUIT signal triggers a graceful shutdown. The master shuts down
+ # immediately and lets each worker finish the request they are currently
+ # processing.
+ trap(:QUIT) do
+ verbose "Received QUIT"
+
+ kill_workers(:QUIT)
+ exit
+ end
+
+ trap(:CHLD) do
+ dead_worker = Process.wait
+ @worker_pids.delete(dead_worker)
+
+ @worker_pids.each do |wpid|
+ begin
+ dead_worker = Process.waitpid(wpid, Process::WNOHANG)
+ @worker_pids.delete(dead_worker)
+ rescue Errno::ECHILD
+ end
+ end
+
+ spawn_worker
+ end
+ end
+
+ def kill_workers(sig)
+ @worker_pids.each do |wpid|
+ Process.kill(sig, wpid)
+ end
+ end
+
+ def load_app
+ @app, options = Rack::Builder.parse_file(Config.config_ru_path)
+ end
+ end
+end
+
diff --git a/code/spyglass/lib/spyglass/server.rb b/code/spyglass/lib/spyglass/server.rb
new file mode 100644
index 0000000..cb97ff1
--- /dev/null
+++ b/code/spyglass/lib/spyglass/server.rb
@@ -0,0 +1,17 @@
+module Spyglass
+
+ class Server
+ include Singleton
+ include Logging
+
+ def start
+ # Opens the main listening socket for the server. Now the server is responsive to
+ # incoming connections.
+ sock = TCPServer.open(Config.host, Config.port)
+ out "Listening on port #{Config.host}:#{Config.port}"
+
+ Lookout.instance.start(sock)
+ end
+ end
+end
+
diff --git a/code/spyglass/lib/spyglass/worker.rb b/code/spyglass/lib/spyglass/worker.rb
new file mode 100644
index 0000000..7d4adda
--- /dev/null
+++ b/code/spyglass/lib/spyglass/worker.rb
@@ -0,0 +1,93 @@
+require 'time'
+require 'rack/utils'
+
+# Worker
+# ======
+#
+module Spyglass
+ class Worker
+ include Logging
+
+ def initialize(socket, app, writable_pipe, connection = nil)
+ @socket, @app, @writable_pipe = socket, app, writable_pipe
+ @parser = Spyglass::HttpParser.new
+
+ handle_connection(connection) if connection
+ end
+
+ def start
+ trap_signals
+
+ loop do
+ handle_connection @socket.accept
+ end
+ end
+
+ def handle_connection(conn)
+ verbose "Received connection"
+ # This notifies our Master that we have received a connection, expiring
+ # it's `IO.select` and preventing it from timing out.
+ @writable_pipe.write_nonblock('.')
+
+ # This clears any state that the http parser has lying around
+ # from the last connection that was handled.
+ @parser.reset
+
+ # The Rack spec requires that 'rack.input' be encoded as ASCII-8BIT.
+ empty_body = ''
+ empty_body.encode!(Encoding::ASCII_8BIT) if empty_body.respond_to?(:encode!)
+
+ # The Rack spec requires that the env contain certain keys before being
+ # passed to the app. These are the keys that aren't provided by each
+ # incoming request, server-specific stuff.
+ env = {
+ 'rack.input' => StringIO.new(empty_body),
+ 'rack.multithread' => false,
+ 'rack.multiprocess' => true,
+ 'rack.run_once' => false,
+ 'rack.errors' => STDERR,
+ 'rack.version' => [1, 0]
+ }
+
+ # This reads data in from the client connection. We'll read up to
+ # 10000 bytes at the moment.
+ data = conn.readpartial(10000)
+ # Here we pass the data and the env into the http parser. It parses
+ # the raw http request data and updates the env with all of the data
+ # it can withdraw.
+ @parser.execute(env, data, 0)
+
+ # Call the Rack app, goes all the way down the rabbit hole and back again.
+ status, headers, body = @app.call(env)
+
+ # These are the default headers we always include in a response. We
+ # only speak HTTP 1.1 and we always close the client connection. At
+ # the monment keepalive is not supported.
+ head = "HTTP/1.1 #{status}\r\n" \
+ "Date: #{Time.now.httpdate}\r\n" \
+ "Status: #{Rack::Utils::HTTP_STATUS_CODES[status]}\r\n" \
+ "Connection: close\r\n"
+
+ headers.each do |k,v|
+ head << "#{k}: #{v}\r\n"
+ end
+ conn.write "#{head}\r\n"
+
+ body.each { |chunk| conn.write chunk }
+ body.close if body.respond_to?(:close)
+ # Since keepalive is not supported we can close the client connection
+ # immediately after writing the body.
+ conn.close
+
+ verbose "Closed connection"
+ end
+
+ def trap_signals
+ trap(:QUIT) do
+ out "Received QUIT"
+ exit
+ end
+ end
+ end
+end
+