Why Chrome Crashes the Rust Book’s Web Server

6 min read Original article ↗

Ludi Rehak

Chapter 20 of the Rust book gives a step-by-step guide to building a simple web server. As the final project, it exercises many Rust concepts taught in the book. The server is hosted at localhost:7878 and returns plain HTML without external resources, distributing work across multiple threads.

The story should end here, but there’s a problem. The code, as copied from the book, panics when requests are made from a Chrome browser. Requests made from curl or Firefox work fine.

My repo has a patched web server that fixes the panic. I’ve shared it for the benefit of those who might encounter this error. I’ve also investigated why Chrome, but not Firefox, causes the server to crash. Let’s dive into the logs of the patched server to learn more.

Firefox

For every page load, Firefox makes a single TCP connection. The server accepts the connection and hands it off to one of its workers. The worker then writes a response, and closes the connection. (In this simple HTTP 1.x server, the connection is not persisted beyond a single request-response cycle and cache headers aren’t set.) Latencies are in the hundreds of microseconds. Logs show a consistent pattern.

First request

Connection 1 established
Worker 0 got a job; executing.
Worker 0 finished job in 163.958µs

Second request

Connection 2 established
Worker 1 got a job; executing.
Worker 1 finished job in 257.75µs

Third request

Connection 3 established
Worker 2 got a job; executing.
Worker 2 finished job in 250µs

Now compare that to Chrome.

Chrome

First request

Connection 1 established
Worker 0 got a job; executing.
Worker 0 finished job in 308.75µs
Connection 2 established
Connection 3 established
Worker 2 got a job; executing.
Worker 3 got a job; executing.

Chrome opens 3 connections for a single page load! Worker 0 serves the response, satisfying the request and closing its connection. Workers 2 and 3 each block on their own connection. They wait to read data from their sockets, but Chrome hasn’t sent anything.

Second request

Worker 2 finished job in 51.4565555s
Connection 4 established
Worker 1 got a job; executing.

I refresh the page, creating a request that is served by Worker 2. The logs show it spent 51 seconds “executing”, which seems preposterously slow, but most of that time was spent waiting on Chrome to send data, not doing actual work. The 51-second duration includes the time elapsed between the two requests. The connection that my second request was sent on was established before I even made the request.

Worker 3 still blocks. Worker 1 now blocks too, both waiting for requests from the future.

Third Requ….suddenly, more logs appear before I even get the chance to make a third request

Nothing to read from the stream.
Nothing to read from the stream.
Worker 1 finished job in 84.703008792s
Worker 3 finished job in 136.160955625s

Workers 1 and 3 have finally unblocked. Chrome closed both of their connections after a timeout, without sending data.

Why does Chrome open extra connections?

A Chrome browser optimization referred to as “TCP pre-connect” or “speculative connections” or “preconnections” opens a TCP connection to a server before a request is made. By opening connections early, Chrome can begin the setup tasks of DNS resolution and TCP handshake ahead of time. Once the connection is established, any future HTTP requests to the same server are sent without delay. This lowers the perceived latency from the user’s perspective, whose clock starts ticking when she initiates a request.

Preconnections sit idle in the browser’s socket pool, ready to go should the user make a request with a matching {scheme, server, port}. If the user never does, then they are eventually closed by the browser, without having sent any data.

The browser chooses which sites to establish preconnections to based on the user’s browsing history. If Chrome has learned that a user tends to visit many pages from a site in quick succession, it will open multiple preconnections to it. I found that Chrome would establish 0, 1, or 2 preconnections to localhost:7878, depending on how frequently I had visited the page.

Closing Preconnections Manually

The chrome://net-internals/#sockets page in the Chrome browser has a button to Close idle sockets. It closes all preconnections opened by the browser, as well as other persistent connections not currently sending data.

Press enter or click to view image in full size

Other Chrome Browser Optimizations

Preconnections are just one of the many optimizations of the Chrome browser. It may only do the DNS resolution part ahead of time. It may also go as far as prerendering an entire page. As of 2013, other optimizations were to:

  • Pre-resolve hostnames appearing in links on the current page
  • Prefetch a page as soon as the user hovers her mouse over a link or triggers a “button down” event
  • Prefetch a page as soon as the user types in a few letters in the URL bar. For example, typing ama loads amazon.com. See chrome://predictors/ for your personalized predictions
  • Open connections based on past navigation (as explained here) and resource request data.

The Panic from the Book

Back to the book. Here’s the line that panics:

fn handle_connection(mut stream: TcpStream) {
let buf_reader = BufReader::new(&mut stream);
let request_line = buf_reader.lines().next().unwrap().unwrap(); // panic!

With this message:

thread '<unnamed>' panicked at src/main.rs:30:50:
called `Option::unwrap()` on a `None` value

What happened is that a preconnection timed out. The browser closed it and unblocked the read, without sending data. That means

buf_reader.lines().next() returns None

and

buf_reader.lines().next().unwrap() panics.

One fix is to handle the None case by using a match statement to cover all possible Options returned by next().

fn handle_connection(mut stream: TcpStream) {
let buf_reader = BufReader::new(&mut stream);
let request_line_result = buf_reader.lines().next();
let request_line: String;
match request_line_result {
Some(Ok(line)) => {
request_line = line;
}
Some(Err(e)) => {
eprintln!("Error reading: {}", e);
return;
}
None => {
eprintln!("Nothing to read from the stream.");
return;
}
}

This bug might have gone unnoticed during tests with Chrome because it needs three conditions to reproduce:

  1. The user is signed into Chrome
  2. Enough page visits from the user that Chrome will open preconnections
  3. Enough time for the preconnections to time out

The book is an excellent resource for learning Rust, and I’m grateful to its authors. An improvement to the example web server would be to handle the case where a client connects without sending data. That way, the server won’t crash when Chrome closes preconnections. Or perhaps add a note to use Chrome in Incognito mode, to prevent it from opening preconnections in the first place.