Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions frameworks/httpbeast/Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
FROM nimlang/nim:2.2.4 AS build

WORKDIR /app
# zippy is the gzip codec for json-comp: httpbeast ships no compression, so
# the entry brings its own.
RUN nimble install -y zippy@0.10.19

# httpbeast 0.4.2 is the server under test, pinned to the commit nimble
# resolves for that version (there is no v0.4.2 tag upstream). It is cloned
# instead of nimble-installed because a patch is applied first: upstream reads
# a request body by Content-Length only, so a chunked request is handed to the
# handler before its body arrived and with the header end position clobbered.
# httpbeast-chunked.patch makes the read loop wait for the zero size last
# chunk and keep the header end stable; the raw chunked bytes are then decoded
# by hand in main.nim.
RUN git clone https://github.com/dom96/httpbeast.git /httpbeast \
&& git -C /httpbeast checkout 75008aaba497479df4023d52880086deae6385e3
COPY httpbeast-chunked.patch /httpbeast/
RUN git -C /httpbeast apply httpbeast-chunked.patch

COPY main.nim .
RUN nim c -d:release --mm:orc --threads:on --opt:speed --path:/httpbeast/src -o:/app/server main.nim

# nimlang/nim:2.2.4 is Debian trixie and the binary links nothing but glibc,
# so the runtime image is the same distro with no compiler in it.
FROM debian:trixie-slim
COPY --from=build /app/server /server
EXPOSE 8080
CMD ["/server"]
40 changes: 40 additions & 0 deletions frameworks/httpbeast/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
# httpbeast

HttpBeast, the epoll HTTP server that Jester and Prologue are built on, called
directly.

## Stack

- **Language:** Nim 2.2.4
- **Framework:** HttpBeast 0.4.2, gzip from zippy 0.10.19
- **Build:** `nimlang/nim:2.2.4`, `nim c -d:release --mm:orc --threads:on`

## Endpoints

| Endpoint | Method | Description |
|----------|--------|-------------|
| `/pipeline` | GET | Returns `ok` (plain text) |
| `/baseline11` | GET | Sums query parameter values |
| `/baseline11` | POST | Sums query parameters + request body |
| `/json/{count}?m=N` | GET | First `count` dataset items with `total = price * quantity * m` |
| `/upload` | POST | Returns the byte count of the body |

## Notes

- One httpbeast thread per available core, each with its own SO_REUSEPORT
listener, so there is no primary process to fan requests out
- The thread count comes from the cgroup CPU quota when there is one, and from
the host CPU count otherwise, the same way koa's `getCPUCount` does it
- Routing is a hand-written match on the request target, because httpbeast has
no router
- JSON is written by hand from a per-item prefix built at startup, so a request
only appends `total` and the closing brace
- Compression is gzip from zippy, negotiated per request on `Accept-Encoding`.
This is what makes the mode `tuned`: httpbeast has no compression middleware
- The dataset is read once before the threads start; a missing file leaves an
empty list instead of failing the boot
- httpbeast reads a request body by `Content-Length` only and would answer a
chunked request before the body arrived. The build applies
`httpbeast-chunked.patch` to the pinned source: the read loop waits for the
zero size last chunk and keeps the header end position stable. The handler
then gets the raw chunked bytes and `main.nim` decodes them by hand
73 changes: 73 additions & 0 deletions frameworks/httpbeast/httpbeast-chunked.patch
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
diff --git a/src/httpbeast.nim b/src/httpbeast.nim

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

xD We can't merge this, merge this patch on the main repo

index af1dc26..3d398b5 100644
--- a/src/httpbeast.nim
+++ b/src/httpbeast.nim
@@ -197,12 +197,54 @@ proc slowHeadersCheck(data: ptr Data): bool =

data.headersFinishPos = -1

+proc hasChunkedEncoding(data: ptr Data): bool =
+ # True when the header block declares Transfer-Encoding: chunked.
+ let lower = data.data[0 ..< data.headersFinishPos].toLowerAscii()
+ let te = lower.find("\c\ltransfer-encoding:")
+ if te == -1: return false
+ var lineEnd = lower.find("\c\l", te + 2)
+ if lineEnd == -1: lineEnd = lower.len
+ return lower[te ..< lineEnd].contains("chunked")
+
+proc chunkedBodyComplete(data: ptr Data): bool =
+ # Walks the chunks buffered so far. The body is complete once the zero
+ # size last chunk and its final CRLF are in the buffer. Trailers are not
+ # supported, an empty trailer section is all the benchmark traffic has.
+ var i = data.headersFinishPos
+ while true:
+ var size = 0
+ var digits = 0
+ while i < data.data.len:
+ case data.data[i]
+ of '0'..'9': size = size * 16 + (ord(data.data[i]) - ord('0'))
+ of 'a'..'f': size = size * 16 + (ord(data.data[i]) - ord('a') + 10)
+ of 'A'..'F': size = size * 16 + (ord(data.data[i]) - ord('A') + 10)
+ else: break
+ inc digits
+ inc i
+ if digits == 0 or digits > 15: return false
+ # The rest of the size line (chunk extensions) up to its CRLF.
+ while i < data.data.len and data.data[i] != '\l': inc i
+ if i >= data.data.len: return false
+ inc i
+ if size == 0:
+ # Zero chunk seen, the terminating CRLF must be buffered too.
+ return data.data.len >= i + 2
+ # The chunk data and its CRLF must be buffered before the next size line.
+ i += size + 2
+ if i > data.data.len: return false
+
proc bodyInTransit(data: ptr Data): bool =
assert methodNeedsBody(data), "Calling bodyInTransit now is inefficient."
assert data.headersFinished

if data.headersFinishPos == -1: return false

+ if hasChunkedEncoding(data):
+ # There is no Content-Length, the body ends at the zero size chunk.
+ # The handler gets the raw chunked bytes and decodes them itself.
+ return not chunkedBodyComplete(data)
+
var trueLen = parseContentLength(data.data, start=0)

let bodyLen = data.data.len - data.headersFinishPos
@@ -267,7 +309,12 @@ proc processEvents(selector: Selector[Data],
data.data.setLen(origLen + ret)
for i in 0 ..< ret: data.data[origLen+i] = buf[i]

- if data.data.len >= 4 and fastHeadersCheck(data) or slowHeadersCheck(data):
+ # A method with a body must take the slow check: the fast one only
+ # looks at the buffer tail, so the CRLFCRLF ending a chunked body
+ # would move headersFinishPos past the body.
+ if data.data.len >= 4 and
+ (if methodNeedsBody(data): slowHeadersCheck(data)
+ else: fastHeadersCheck(data)):
# First line and headers for request received.
data.headersFinished = true
when not defined(release):
227 changes: 227 additions & 0 deletions frameworks/httpbeast/main.nim
Original file line number Diff line number Diff line change
@@ -0,0 +1,227 @@
## HttpArena entry for HttpBeast, the epoll HTTP server behind most of the Nim
## web stack (Jester and Prologue both run on it).
##
## HttpBeast gives one event loop per thread, each with its own SO_REUSEPORT
## listener, so the thread count is the CPU count and there is no cluster
## primary. Routing, JSON and gzip are written here because the server has
## none of them: it hands over a parsed request and takes back a response.

import std/[asyncdispatch, options, os, strutils, json, cpuinfo]
import httpbeast
import zippy

const
hdrText = "Content-Type: text/plain"
hdrJson = "Content-Type: application/json"
hdrJsonGzip = "Content-Type: application/json\c\LContent-Encoding: gzip"

type Dataset = object
## prefix[i] is the item JSON up to `"total":`, factor[i] is price*quantity.
## A request only appends total and the closing brace.
prefix: seq[string]
factor: seq[int]

proc loadDataset(): Dataset =
let path = getEnv("DATASET_PATH", "/data/dataset.json")
var doc: JsonNode
try:
doc = parseJson(readFile(path))
except CatchableError:
return # no dataset file: serve an empty list instead of dying
if doc.kind != JArray:
return
for item in doc:
if item.kind != JObject:
continue
var prefix = $item # compact, and keeps the file's field order
prefix.setLen(prefix.len - 1) # drop the closing brace
prefix.add(",\"total\":")
result.prefix.add(prefix)
result.factor.add(item{"price"}.getInt * item{"quantity"}.getInt)

# Read once, before any worker thread exists, and never written again.
let dataset = loadDataset()

proc parseIntIn(s: string, first, last: int, value: var int): bool =
## Leading integer of s[first ..< last]. Never raises: a parameter that is
## not a number is skipped, the same way the other entries skip a NaN.
var i = first
var negative = false
if i < last and (s[i] == '-' or s[i] == '+'):
negative = s[i] == '-'
inc i
var digits = 0
var n = 0
while i < last and s[i] in '0'..'9':
if digits < 18:
n = n * 10 + (ord(s[i]) - ord('0'))
inc digits
inc i
if digits == 0 or digits > 18:
return false
value = if negative: -n else: n
true

iterator queryPairs(target: string, qmark: int): (int, int, int) =
## Yields (nameStart, nameEnd, valueEnd) for every `name=value` in the query.
if qmark >= 0:
var i = qmark + 1
while i < target.len:
var stop = i
while stop < target.len and target[stop] != '&': inc stop
var eq = i
while eq < stop and target[eq] != '=': inc eq
if eq < stop:
yield (i, eq, stop)
i = stop + 1

proc sumQuery(target: string, qmark: int): int =
for (nameStart, eq, stop) in queryPairs(target, qmark):
var v = 0
if parseIntIn(target, eq + 1, stop, v):
result += v

proc queryInt(target: string, qmark: int, name: string, value: var int): bool =
for (nameStart, eq, stop) in queryPairs(target, qmark):
if eq - nameStart == name.len and target[nameStart ..< eq] == name:
return parseIntIn(target, eq + 1, stop, value)

proc jsonBody(count, m: int): string {.gcsafe.} =
# The cast is the dataset read: it is built before run() starts a thread and
# never written afterwards, which Nim cannot prove on a global.
{.cast(gcsafe).}:
var n = count
if n < 0: n = 0
if n > dataset.prefix.len: n = dataset.prefix.len
var size = 24
for i in 0 ..< n:
size += dataset.prefix[i].len + 24
result = newStringOfCap(size)
result.add("{\"items\":[")
for i in 0 ..< n:
if i > 0: result.add(',')
result.add(dataset.prefix[i])
result.addInt(dataset.factor[i] * m)
result.add('}')
result.add("],\"count\":")
result.addInt(n)
result.add('}')

proc acceptsGzip(req: Request): bool =
let headers = req.headers
if headers.isNone:
return false
let accepted: string = headers.get().getOrDefault("accept-encoding")
result = accepted.toLowerAscii().contains("gzip")

proc isChunked(req: Request): bool =
let headers = req.headers
if headers.isNone:
return false
let te: string = headers.get().getOrDefault("transfer-encoding")
result = te.toLowerAscii().contains("chunked")

proc dechunk(raw: string): string =
## Chunked transfer decoding by hand: hex size line, that many bytes, CRLF,
## until the zero size chunk. httpbeast only reads a body by Content-Length,
## so the build patches it (httpbeast-chunked.patch) to buffer a chunked
## payload until the last chunk and hand it over raw, decoding happens here.
var i = 0
while i < raw.len:
var size = 0
var digits = 0
while i < raw.len:
case raw[i]
of '0'..'9': size = size * 16 + (ord(raw[i]) - ord('0'))
of 'a'..'f': size = size * 16 + (ord(raw[i]) - ord('a') + 10)
of 'A'..'F': size = size * 16 + (ord(raw[i]) - ord('A') + 10)
else: break
inc digits
inc i
if digits == 0 or digits > 15: break
# rest of the size line (chunk extensions) and its CRLF
while i < raw.len and raw[i] != '\l': inc i
inc i
if size == 0: break
if size > raw.len - i: size = raw.len - i # truncated payload
if size > 0: result.add(raw[i ..< i + size])
i += size + 2 # chunk data and its CRLF

proc requestBody(req: Request): string =
let body = req.body
if body.isNone:
return ""
if isChunked(req): dechunk(body.get()) else: body.get()

proc onRequest(req: Request): Future[void] {.gcsafe.} =
let httpMethod = req.httpMethod
if httpMethod.isNone:
return
let target = req.path.get("")
var qmark = -1
for i in 0 ..< target.len:
if target[i] == '?':
qmark = i
break
let route = if qmark < 0: target else: target[0 ..< qmark]

case httpMethod.get()
of HttpGet:
if route == "/pipeline":
req.send(Http200, "ok", hdrText)
elif route == "/baseline11":
req.send(Http200, $sumQuery(target, qmark), hdrText)
elif route.len > 6 and route.startsWith("/json/"):
var count = 0
discard parseIntIn(route, 6, route.len, count)
var m = 0
if not queryInt(target, qmark, "m", m) or m == 0:
m = 1
let body = jsonBody(count, m)
# json-comp: httpbeast has no compression middleware, so gzip is done
# here with zippy, only when the client asked for it.
if acceptsGzip(req):
req.send(Http200, compress(body, DefaultCompression, dfGzip), hdrJsonGzip)
else:
req.send(Http200, body, hdrJson)
else:
req.send(Http404)
of HttpPost:
if route == "/baseline11":
var total = sumQuery(target, qmark)
let text = requestBody(req).strip()
var n = 0
if parseIntIn(text, 0, text.len, n):
total += n
req.send(Http200, $total, hdrText)
elif route == "/upload":
req.send(Http200, $requestBody(req).len, hdrText)
else:
req.send(Http404)
else:
req.send(Http404)

proc cpuCount(): int =
## Same shape as koa's getCPUCount: the cgroup quota when there is one,
## the host CPU count otherwise.
try:
let parts = readFile("/sys/fs/cgroup/cpu.max").strip().split(' ')
if parts.len == 2 and parts[0] != "max":
let n = parseInt(parts[0]) div parseInt(parts[1])
if n >= 1: return n
except CatchableError:
discard
try:
let quota = parseInt(readFile("/sys/fs/cgroup/cpu/cpu.cfs_quota_us").strip())
let period = parseInt(readFile("/sys/fs/cgroup/cpu/cpu.cfs_period_us").strip())
if quota > 0 and period > 0 and quota div period >= 1:
return quota div period
except CatchableError:
discard
result = countProcessors()
if result < 1:
result = 1

when isMainModule:
run(onRequest, initSettings(port = Port(8080), bindAddr = "0.0.0.0",
numThreads = cpuCount()))
19 changes: 19 additions & 0 deletions frameworks/httpbeast/meta.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
{
"display_name": "httpbeast",
"language": "Nim",
"type": "flagship",
"mode": "tuned",
"engine": "httpbeast",
"description": "HttpBeast 0.4.2 called directly, the epoll server Jester and Prologue are both built on. One event loop per core, each thread with its own SO_REUSEPORT listener, no primary process. Routing, JSON and gzip are written in the entry because the server ships none of them: json-comp gzips through zippy on Accept-Encoding, which is what makes the mode tuned. Nim 2.2.4, -d:release with ORC.",
"repo": "https://github.com/dom96/httpbeast",
"enabled": true,
"tests": [
"baseline",
"pipelined",
"limited-conn",
"json",
"json-comp",
"upload"
],
"maintainers": []
}
Loading