Skip to content
C.W.K.
Lesson 02 of 05 · published

One Request per Connection

~16 min · http, parsing, network-framework, protocol-design

Level 0Idle Phone
0 XP0/45 lessons0/13 achievements
0/100 XP to next level100 XP to go0% complete

The smallest HTTP the caller needs

My door speaks to exactly one client, and I know which one: Oriel's Python, using the standard library's http.client. Oriel's _MuxHTTP is an http.client.HTTPConnection whose connect() is overridden to open a usbmuxd socket to the phone by its UDID instead of dialing an address. Everything above the socket is stock Python. So the door does not need to be a web server. It needs to be the smallest HTTP that this client sends.

The doc comment on HTTPRequest writes that contract down in four parts: one request per connection, a Content-Length body and never a chunked one, Connection: close on every answer, and anything else "refused with the reason, never guessed at". The whole implementation is HTTPWire.swift, 189 lines with no dependency beyond Foundation.

A parser that only says three things

HTTPParser.parse (first block) is a pure function of the bytes a connection has delivered so far, and it answers one of three things. .incomplete means read more. .complete carries the request. .invalid carries a WireError with a sentence. Nothing is kept between calls: the door appends each new chunk to its buffer and asks again from the start. That sounds wasteful until you look at what the second call does. The head ends within the first few hundred bytes, the search for the blank line stops there, and the body is only counted until enough of it has arrived.

Read the guards in order, because each one names a failure in its own words:

  • A head with no blank line yet is incomplete, unless it is already over 16 KB, which is tooLarge.
  • A head that is not UTF-8 is refused.
  • The request line must split into three parts and the third must start with HTTP/1..
  • Every header line needs a colon. Names are lowercased, values trimmed, and a repeated name keeps its last value.
  • A Transfer-Encoding other than identity is refused: "chunked bodies are not spoken here; send Content-Length".
  • Content-Length must be a non-negative integer and at most 32 MB. The constant's comment gives the size its reason: a minute and a half of speech is about 1.5 MB as MP3, "so 32 MB is room, not a target".

Only then is the target split into a path and a query, percent-decoded, so GET /status?x=1&y=a%20b becomes the path /status and the query ["x": "1", "y": "a b"]. That exact case is a test, readsAGetWithQuery.

An answer that always closes

HTTPResponse.serialized() (second block) writes the bytes, and it sets two headers itself, whatever the route asked for: Content-Length from the body and Connection: close. Header names go out sorted, so the same answer is always the same bytes, which is what lets answersCloseWithLength compare a whole response as a string. Any CR or LF inside a header value becomes a space. That matters because some values come from the world: a photo's facts ride the X-Cordelia-Photo header as JSON, and aHeaderValueCannotSplitTheHead proves that a value carrying \r\nInjected: yes cannot add a header of its own.

Refusals share one shape too. HTTPResponse.refuse(status, reason) is JSON with ok false and the reason under error, written with sorted keys. The next lesson is about those reasons.

The life of one connection

The app's BodyServer owns the socket, and its receive (third block) is the loop. A new connection is first checked against the home network (the next track's subject), then started on the server's own dispatch queue, never the main actor. Each read takes up to 1 MiB and hands the whole buffer to the parser. An incomplete request at the end of the stream is dropped. An invalid one gets 413 when it was too large and 400 otherwise, with the parser's sentence as the reason. A complete one is routed in a Task, and the answer is sent and the connection cancelled once Network reports the bytes processed.

A timer guards the arrival. A connection that has not delivered a whole request within 30 s (idleSeconds) is cancelled. The word that matters there is arrival, and it was learned the hard way, as the war story below tells. A small Arrival box carries one flag, whole, set the moment the parser says complete, and the idle cut checks it first.

Why not keep the connection open

Keep-alive saves a handshake per request, and here the handshake is a usbmuxd connect on a cable or a tunnel connect on the home Wi-Fi, both cheap at Oriel's pace (a status probe every 10 s). What it would cost is state: which request a byte belongs to, when an idle connection may close, what a half-read body means for the next request. With one request per connection, a failure belongs to exactly one request, a long answer (a line being played, Oriel's long poll of up to 25 s) holds only its own connection, and every other request opens another. The door even uses its own wire to test itself: every 20 s it sends GET /hello to its own loopback port, reads the first bytes of the answer for HTTP/1.1 200, and reopens the listener after two misses in a row.

Code

cwkCordelia, CordeliaKit/Sources/CordeliaKit/HTTPWire.swift: the parser, incomplete, complete or invalid with a reason (verbatim, lines 62-107)·swift
  public static func parse(_ buffer: Data, maxBody: Int = maxBodyBytes) -> Outcome {
    let separator = Data("\r\n\r\n".utf8)
    guard let end = buffer.range(of: separator) else {
      return buffer.count > maxHeaderBytes ? .invalid(.tooLarge("the request head is over \(maxHeaderBytes) bytes")) : .incomplete
    }
    guard end.lowerBound <= maxHeaderBytes else {
      return .invalid(.tooLarge("the request head is over \(maxHeaderBytes) bytes"))
    }
    guard let head = String(data: buffer[buffer.startIndex..<end.lowerBound], encoding: .utf8) else {
      return .invalid(.badRequest("the request head is not UTF-8"))
    }
    var lines = head.components(separatedBy: "\r\n")
    let requestLine = lines.removeFirst().split(separator: " ", omittingEmptySubsequences: true)
    guard requestLine.count == 3, requestLine[2].hasPrefix("HTTP/1.") else {
      return .invalid(.badRequest("the request line is not METHOD PATH HTTP/1.x"))
    }
    var headers: [String: String] = [:]
    for line in lines where !line.isEmpty {
      guard let colon = line.firstIndex(of: ":") else {
        return .invalid(.badRequest("a header line has no colon"))
      }
      let name = line[line.startIndex..<colon].trimmingCharacters(in: .whitespaces).lowercased()
      let value = line[line.index(after: colon)...].trimmingCharacters(in: .whitespaces)
      headers[name] = value
    }
    if let encoding = headers["transfer-encoding"], encoding.lowercased() != "identity" {
      return .invalid(.badRequest("chunked bodies are not spoken here; send Content-Length"))
    }
    var length = 0
    if let raw = headers["content-length"] {
      guard let parsed = Int(raw), parsed >= 0 else {
        return .invalid(.badRequest("Content-Length is not a number"))
      }
      length = parsed
    }
    guard length <= maxBody else {
      return .invalid(.tooLarge("the body is \(length) bytes, over \(maxBody)"))
    }
    let bodyStart = end.upperBound
    guard buffer.count - (bodyStart - buffer.startIndex) >= length else { return .incomplete }
    let body = Data(buffer[bodyStart..<(bodyStart + length)])
    let target = String(requestLine[1])
    let (path, query) = splitTarget(target)
    return .complete(HTTPRequest(method: String(requestLine[0]).uppercased(), path: path, query: query,
                                 headers: headers, body: body))
  }
cwkCordelia, CordeliaKit/Sources/CordeliaKit/HTTPWire.swift: every answer carries its length, closes, and cannot be split (verbatim, lines 173-188)·swift
  /// The bytes on the wire.
  public func serialized() -> Data {
    var head = "HTTP/1.1 \(status) \(Self.reasonPhrase(status))\r\n"
    var all = headers
    all["Content-Length"] = String(body.count)
    all["Connection"] = "close"
    for name in all.keys.sorted() {
      // A header value never carries a line break: it would split the head.
      let value = all[name]!.replacingOccurrences(of: "\r", with: " ").replacingOccurrences(of: "\n", with: " ")
      head += "\(name): \(value)\r\n"
    }
    head += "\r\n"
    var out = Data(head.utf8)
    out.append(body)
    return out
  }
cwkCordelia, clients/apple/CordeliaMobile/Sources/Body/BodyServer.swift: one connection read until the parser decides (verbatim, lines 212-239)·swift
  private func receive(_ connection: NWConnection, _ buffer: Data, _ arrival: Arrival) {
    connection.receive(minimumIncompleteLength: 1, maximumLength: 1 << 20) { [weak self] data, _, complete, error in
      guard let self else { return }
      var buffer = buffer
      if let data { buffer.append(data) }
      switch HTTPParser.parse(buffer) {
      case .incomplete:
        if complete || error != nil {
          connection.cancel()
        } else {
          self.receive(connection, buffer, arrival)
        }
      case .invalid(let wire):
        if case .tooLarge = wire {
          self.send(connection, .refuse(413, wire.reason))
        } else {
          self.send(connection, .refuse(400, wire.reason))
        }
      case .complete(let request):
        arrival.whole = true
        let router = self.router
        Task {
          let answer = await router.handle(request)
          self.send(connection, answer)
        }
      }
    }
  }

External links

Exercise

Write an HTTP/1.1 request parser as a pure function from the bytes received so far to one of three outcomes: incomplete, complete with the request, or invalid with a sentence. Support only Content-Length bodies, cap the head and the body, and lowercase header names. Then write a serializer that always adds Content-Length and Connection: close and replaces CR and LF in header values. Test the four edge cases this lesson names: a head that has not ended, a body still arriving, a chunked request, and a header value carrying a line break.
Hint
Feed the parser the same request cut at every byte position in a loop: every prefix shorter than the whole request must be incomplete, and the whole request must be complete. That one loop catches most off-by-one errors in the body length check.

Progress

Progress is local-only — sign in to sync across devices.
Spotted a bug or have feedback on this page?Report an Issue

Comments 0

🔔 Reply notifications (sign in)
Sign in — Please sign in to comment.

No comments yet — be the first.