ruby

Rack for Ruby: Socket Hijacking

Ayush Newatia

Ayush Newatia on

Rack for Ruby: Socket Hijacking

In the first part of this series, we set up a basic Rack app, learned how to process a request and send a response.

In this post, we'll take over connections from Rack and hold persistent connections to enable pathways such as WebSockets.

First, though, let's look at how an HTTP connection actually works.

HTTP Connections

As this diagram shows, a TCP socket is opened, and a request is sent to a server. The server responds and closes the connection. All communication is in plain text.

HTTP sequence diagram

Using a technique called socket hijacking, we can take control of a socket from Rack when a request comes in. Rack offers two techniques for socket hijacking:

  • Partial hijack: Rack sends the HTTP response headers and hands over the connection to the application.
  • Full hijack: Rack simply hands over the connection to the client without writing anything to the socket.

Partial Hijacking

This is how you do a partial hijack:

Ruby
class App def call(env) body = proc do |stream| 5.times do stream.write "#{Time.now}\n\n" sleep 1 end ensure stream.close end [200, { "content-type" => "text/plain", "rack.hijack" => body }, []] end end

rack.hijack is a Rack header, set in the same Hash as the HTTP response headers. Rack will look for such headers and process them as per the specification, instead of writing them to the HTTP response.

Run the above app and curl to it. You'll see that it writes the time at one-second intervals.

Shell
$ curl -i localhost:9292

Full Hijacking

This is how you'd do a full hijack:

Ruby
class App def call(env) headers = [ "HTTP/1.1 200 OK", "Content-Type: text/plain" ] stream = env["rack.hijack"].call stream.write(headers.map { |header| header + "\r\n" }.join) stream.write("\r\n") stream.flush begin 5.times do stream.write "#{Time.now}\n\n" sleep 1 end ensure stream.close end [-1, {}, []] end end

In this case, we call the proc passed to us using the rack.hijack key, instead of setting one ourselves in the response. This gives us complete control over the socket. At the end, we return an array with the status -1 only because Rack expects an array to be returned. The contents of this array are ignored since we've taken over the socket.

This is a bad practice, rife with gotchas and weird behavior. Don't do it. Samuel Williams, who is a maintainer of Rack, recommends against it as well.

Streaming Bodies in Rack for Ruby

While full hijacking is a terrible idea, partial hijacking is a useful tool. But it still feels hacky, so Rack 3 formally adopted that approach into the spec by introducing the concept of streaming bodies.

Ruby
class App def call(env) body = proc do |stream| 5.times do stream.write "#{Time.now}\n\n" sleep 1 end ensure stream.close end [200, { "content-type" => "text/plain" }, body] end end

Here we provide a block as the response body rather than an array. Rack keeps the connection open until the block finishes executing.

There's a huge gotcha here when using Puma. Puma is a multi-threaded server that assigns a thread to each incoming request. We're taking over the socket from Rack, but we're still tying up a Puma thread as long as the connection is open.

Puma concurrency can be configured, but threads are limited, and tying one up for long periods is not a good idea. Let's see this in action first.

Shell
$ bundle exec puma -w 1 -t 1:1

In two separate terminal windows, run the following command at the same time:

Shell
$ curl localhost:9292

One request is immediately served, but the other is held until the first one completes. This is because we started Puma with a single worker and single thread, meaning it can only serve a single request at a time.

We can get around this by creating our own thread.

Ruby
class App def call(env) body = proc do |stream| Thread.new do 5.times do stream.write "#{Time.now}\n\n" sleep 1 end ensure stream.close end end [200, { "content-type" => "text/plain" }, body] end end

Now if you try the above experiment again, you'll see both curl requests are served concurrently because they don't tie up a Puma thread.

Once again, I must warn against this approach, unless you know what you're doing. These demonstrations are largely academic, as systems programming is a deep and complex topic.

Falcon Web Server

Since the threading problem is specific to the Puma web server, let's look at another option: Falcon. This is a new, highly concurrent Rack-compliant web server built on the async gem. It uses Ruby Fibers instead of Threads, which are cheaper to create and have much lower overhead.

The async gem hooks into all Ruby I/O and other waiting operations, such as sleep, and uses these to switch between different Fibers (ensuring a program is never held up doing nothing).

Revert your app to the previous version where we're not spawning a new thread:

Ruby
class App def call(env) body = proc do |stream| 5.times do stream.write "#{Time.now}\n\n" sleep 1 end ensure stream.close end [200, { "content-type" => "text/plain" }, body] end end

Then remove Puma and install Falcon.

Shell
$ bundle remove puma $ bundle add falcon

Run the Falcon server. We need to explicitly bind it because it only serves https traffic by default.

Shell
$ bundle exec falcon serve -n 1 -b http://localhost:9292

The server only uses a single thread, which you can confirm with the command below. You'll need to grab your specific pid from Falcon's logs.

Shell
$ top -pid <pid> -stats pid,th

The thread count printed by the above command will be 2 because the MRI uses a thread internally.

Try the earlier experiment again and run two curl requests simultaneously.

Shell
$ curl localhost:9292

You'll see they're both served at the same time, thanks to Ruby Fibers!

Falcon is relatively new. Ruby Fibers were only introduced in Ruby 3.0. Since Falcon is Rack-compliant, it can be used with Rails too, but the docs recommend using it with v7.1 or newer only. As such, it's a bit risky to use Falcon in production but it's a very exciting development in the Ruby world, in my opinion. I can't wait to see its progress in the next few years.

We've now learned how to create persistent connections in Rack and how to run them without blocking other requests, but the use cases so far have been academic and contrived. In the next and final part of this series, we'll examine how we can use this technique in a practical way.

Until then, happy coding!

P.S. If you'd like to read Ruby Magic posts as soon as they get off the press, subscribe to our Ruby Magic newsletter and never miss a single post!

Ayush Newatia

Ayush Newatia

Guest author Ayush is a freelance Ruby and Rails developer. He's the author of The Rails and Hotwire Codex and part of the Bridgetown core team. He also runs a privacy focused mailing list app called Scattergun.

All articles by Ayush Newatia

Become our next author!

Find out more

AppSignal monitors your apps

AppSignal provides insights for Ruby, Rails, Elixir, Phoenix, Node.js, Express and many other frameworks and libraries. We are located in beautiful Amsterdam. We love stroopwafels. If you do too, let us know. We might send you some!

Discover AppSignal
AppSignal monitors your apps