Wrap a Ruby script that speaks stdio in a daemon so that you only pay the startup overhead once.
Inspired by and largely stolen from fohte/rubocop-daemon.
A Ruby script that involves some expensive setup:
class App
def initialize
sleep(1) # simulate slow startup; load dependencies, etc
end
def shout(msg)
msg.upcase # simulate something useful
end
end
app = App.new # slow; does the same thing each run
puts app.shout(ARGV.join(" ")) # fast; does something different each run
If we're calling this script frequently, we don't want to pay the cost of App.new
each time.
$ time ruby example.rb hello
HELLO
real 0m1.089s # <- slow
$ time ruby example.rb there
THERE
real 0m1.089s # <- slow
Instead, IO Daemonizer can wrap the setup step and the run step separately:
require "io_daemonizer"
class App
# ...
end
IODaemonizer.wrap(
port: 6872,
setup: -> do
@app = App.new # slow
end,
run: ->(args) do # ARGV will be passed to run block as `args`
puts @app.shout(args.join(" ")) # fast
end
)
Now, when we call our script the first time it will perform the expensive setup step once and store the state in a background process. Subsequent calls will run in a new process that communicates with the daemon over a TCP socket. Any stdio communication in the daemon will be forwarded back to the client:
$ time ruby example.rb hello
HELLO
real 0m1.088s # <- still slow...
$ time ruby example.rb there
THERE
real 0m0.067s # <- fast!
IO Daemonizer is packaged as a gem, but it has no dependencies outside the core library and you may find it more convenient to include io_daemonizer.rb
in your project directly. It needs to be loaded with each call to your script, so the less overhead the better.
This is the only method you need to call in your script. It parses the command-line args and handles starting / stopping the daemon and forwarding commands to it.
IODaemonizer::wrap
takes the following parameters:
The port that you want your daemon to run on. Make sure to use a different port for each script.
A lambda which contains the one-time setup steps in your script. Any instance variables set in this step will be available to the run step.
In this context, ARGV will be equal to the arguments that are present during server initialization.
A lambda which accepts a single parameter containing the command-line args forwarded from the client process (eg, run: ->(args) { ... }
). Make sure to include the argument or you'll get the following error: #<ArgumentError: wrong number of arguments (given 1, expected 0)>
.
Both the setup and run blocks are stored in variables inside the daemon, and all execution is done within the daemon's scope - the client only passes its ARGV
over the socket to the daemon and prints any response. If you reference ARGV
directly in your run block it will not work as expected since it will be the daemon's ARGV
that gets evaluated:
IODaemonizer.wrap(
port: 6872,
setup: -> { @app = App.new },
run: ->(args) do
# DO NOT USE ARGV HERE - use args instead, which is how the daemon passes
# the arguments it receives over the socket to the run block
puts @app.shout(ARGV.join(" "))
end
)
# $ ruby example.rb start
# => starting server...
# $ ruby example.rb hello
# START
Also note that the daemon's scope is persisted across runs of your script. So, for example, the following would print a new number each run:
IODaemonizer.wrap(
port: 6872,
setup: -> { @count = 0 },
run: ->(args) { puts @count += 1 }
)
Attempt to start the server automatically if it's not already running. If set to false
, you will need to manually start your script's daemon with ruby example.rb start
before calling it.
Call your script with start
or stop
as the first argument to control the daemon process.
The daemon will start synchronously (ie, it will wait until the setup step completes to send itself to the background and return).
Once the daemon is running, you can call your script as normal.
- proof of concept
- basic docs
- license
-
v.1
: gemify -
v.2
: not sure what happened to v.2 -
v.3
: support stdin -
v.4
: pass port as argument -
v.5
: autostart server -
v.6
: write stderr to stderr -
v.7
: optimize streaming json parser 🚀 - command to get server status
- command to restart the server
- configurable timeouts
- automatically restart server after configurable interval
- logs?
- support first argument that sends remaining as literal args (eg, so that you can pass "stop" to the daemon instead of stopping it)
- optional async startup
- support io more generically
- pids?
- use dynamic port 0 (need pid or some other way to find running daemon)
- usage instructions (-h)?
- forward exit code to client script?