Skip to content
Draft
161 changes: 161 additions & 0 deletions scripts/kernel-floor.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,161 @@
import { expect, test } from "bun:test";
import { readFileSync } from "node:fs";
import { join, resolve } from "node:path";

// Guard for goceleris/celeris#891 items 14 and 28 (the kernel floors and the
// release stamp). The io_uring engine refuses every kernel before 5.19, because
// its cancels use the IORING_ASYNC_CANCEL flags that release added (celeris
// internal/engine/iouring, requireAsyncCancelFlags, celeris#682, #872), so the
// io_uring floor is 5.19. The epoll floor is 5.10 as celeris README.md states it.
// introduction.md names the current release; it is RELEASE below from the v1.6.0
// tag on. Until that tag, the docs PR that changes these lines must not merge.
// The same file keeps the claims that review of the PR corrected: SQPOLL (no tier
// uses it, celeris#377), when Base and Optional run, what a low RLIMIT_MEMLOCK
// does to an explicit IOUring engine, and what Adaptive needs before it uses io_uring.
const ROOT = resolve(import.meta.dir, "..");
const DOCS = join(ROOT, "src", "content", "docs");
// RELEASE is the release stamp introduction.md must carry. It is deliberate that
// `bun test` fails after a version bump until this constant is edited too: the guard
// exists to force that edit, so the stamp and the text cannot drift apart. The epoll
// floor (5.10) is written in the PRESENT patterns and the header above; change them together.
const RELEASE = "1.6.0";

function read(file: string): string {
return readFileSync(file, "utf8");
}

function docPages(): string[] {
return [
"configuration.md",
"core-concepts.md",
"deployment.md",
"engines.md",
"getting-started.md",
"introduction.md",
"performance.md",
];
}

const docs = Object.fromEntries(docPages().map((f) => [f, read(join(DOCS, f))]));
const astro = read(join(ROOT, "src", "pages", "index.astro"));

// Phrases that carried the old floors (io_uring 5.10+, epoll 3.10+, the 5.12
// tier sentence, and the LTS framing) or the old release stamp. Each one is the
// exact text that stood on origin/main when this guard was written.
const STALE: string[] = [
"(Linux 5.10+; needs",
"Linux 3.10+",
"3.10+",
"(kernel 5.10+, richer",
"Completion-based asynchronous I/O on Linux **5.10+**",
"| `Base` | 5.10+",
"A 5.12",
"Pre-5.10",
"pre-5.10",
"below 5.10",
"LTS-stable baseline",
"LTS-stable io_uring floor",
"known-good 5.10+ kernel",
"(≥5.10, ideally",
"`io_uring` engine needs kernel **5.10+**",
"only io_uring needs 5.10+",
"Kernel 5.10+",
"kernel is below 5.10",
"v1.5.8",
"`1.5.8`",
// Corrected after review: SQPOLL is never selected by any tier (celeris#377).
"with SQPOLL and",
"Adds SQPOLL and zero-copy send",
"is consulted for SQPoll on some kernels",
// Corrected after review: a healthy 6.x kernel runs the High strategy; Optional
// needs 6.0+ AND a failed provided-buffers probe. Only that probe drops a tier.
"a 6.0 or newer kernel adds",
"only when a `High` feature probe fails",
// Corrected after review: a low RLIMIT_MEMLOCK caps the workers (down to one); an
// explicit IOUring engine fails only if not even one ring fits.
"an explicit IOUring engine fails to start)",
"an explicit `IOUring` engine fails to start instead",
"under a forced `IOUring`\n it fails to start",
"io_uring setup\n fails",
// Corrected after review: Adaptive uses io_uring only from the 6.1+ fast tier
// (or 6.10+), so "epoll below 5.19" understates where it stays on epoll.
"Adaptive runs epoll on kernels below 5.19",
"Adaptive runs epoll on them",
"Adaptive runs\nepoll.",
"Adaptive runs epoll there",
// Corrected after review: the transparent fallback holds for Adaptive only.
"must pass or Celeris transparently",
"Celeris does **not** crash",
];

// Phrases that carry the v1.6.0 floors. Each must occur in the named page.
const PRESENT: [string, RegExp][] = [
["configuration.md", /`celeris\.IOUring`\s*\|\s*Linux 5\.19\+/],
["core-concepts.md", /`celeris\.IOUring` \| Linux 5\.19\+/],
["engines.md", /\*\*IOUring\*\*\s*\|[^\n]*Linux 5\.19\+/],
["engines.md", /on Linux \*\*5\.19\+\*\*/],
["engines.md", /\| `Base`\s+\| 5\.19\+/],
["engines.md", /known-good 5\.19\+ kernel/],
["engines.md", /\(5\.19 or later, ideally 6\.0 or later/],
["engines.md", /On a Linux kernel before 5\.19 io_uring is\s+not available/],
["getting-started.md", /`io_uring` engine needs kernel \*\*5\.19\+\*\*; the `epoll` engine and `Adaptive` need kernel \*\*5\.10\+\*\*, and Adaptive uses io_uring only on a 6\.1\+ kernel/],
["engines.md", /\*\*Adaptive\*\* \(default\) \| `celeris\.Adaptive`\s+\| Linux 5\.10\+/],
["deployment.md", /\*\*Kernel 5\.19\+\*\*/],
["deployment.md", /it needs Linux 5\.10\+ and works out of the\s+box/],
["deployment.md", /The kernel is below 5\.19/],
["performance.md", /\(Linux 5\.19\+; needs `RLIMIT_MEMLOCK`\)/],
["introduction.md", /tiered \*\*io_uring\*\* engine \(kernel 5\.19\+, with zero-copy send\s+on 6\.0\+\)/],
["engines.md", /Under Adaptive the\s+server runs epoll; an explicit `IOUring` engine returns the error/],
["engines.md", /`Optional` is chosen only on 6\.0\+ when the\s+provided-buffers probe has failed/],
["engines.md", /when the provided-buffers probe fails on a 5\.19 kernel/],
["engines.md", /Adaptive uses io_uring only on a 6\.1\+ kernel/],
["engines.md", /runs on fewer workers, down to one, and fails only when not even one ring fits/],
["deployment.md", /runs on fewer workers, and fails only when not even one ring fits/],
["deployment.md", /Under Adaptive, Celeris falls back to epoll; an explicit `IOUring` engine returns the error/],
["getting-started.md", /Adaptive uses io_uring only on a 6\.1\+ kernel/],
["introduction.md", new RegExp(`\\*\\*\\[\`v${RELEASE.replaceAll(".", "\\.")}\`\\]`)],
["introduction.md", new RegExp(`\\(currently \`${RELEASE.replaceAll(".", "\\.")}\`\\)`)],
];

// Every table row that names the io_uring engine and gives a Linux floor states 5.19+, and no 5.10.
// Feature-matrix rows (no `Linux` cell) are not floor claims and are not checked.
const IOURING_ROW = /^\|\s*(`(celeris\.)?IOUring`|\*\*IOUring\*\*)/;

test("every table row for the io_uring engine states the 5.19 floor", () => {
const bad: string[] = [];
for (const [file, text] of Object.entries(docs)) {
for (const line of text.split("\n")) {
if (IOURING_ROW.test(line) && line.includes("Linux") && (!line.includes("5.19+") || line.includes("5.10"))) {
bad.push(`${file}: ${line.trim()}`);
}
}
}
expect(bad).toEqual([]);
});

test("no docs page carries a stale kernel floor or the old release stamp", () => {
const hits: string[] = [];
for (const [file, text] of Object.entries(docs)) {
for (const phrase of STALE) {
if (text.includes(phrase)) hits.push(`${file}: ${phrase}`);
}
}
expect(hits).toEqual([]);
});

test("each docs page states the v1.6.0 io_uring floor and epoll floor where it names them", () => {
const missing = PRESENT.filter(([file, re]) => !re.test(docs[file] ?? "")).map(([file, re]) => `${file}: ${re}`);
expect(missing).toEqual([]);
});

test("index.astro engine cards: io_uring is Linux 5.19+ and epoll is Linux 5.10+", () => {
const tagOf = (name: string) => astro.match(new RegExp(`name: "${name}",\\s*tag: "([^"]+)"`))?.[1];
expect(tagOf("io_uring")).toBe("Linux 5.19+");
expect(tagOf("epoll")).toBe("Linux 5.10+");
});

test("introduction.md states the release on both lines, and both name the same version", () => {
const intro = docs["introduction.md"];
expect(intro).toContain(`**[\`v${RELEASE}\`]`);
expect(intro).toContain(`(currently \`${RELEASE}\`)`);
});
2 changes: 1 addition & 1 deletion src/content/docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -108,7 +108,7 @@ The I/O engine that drives the accept/read/write loop. The native engines are
| ------------------- | ------------- | --------------------------------------------------- |
| `celeris.Adaptive` | Linux only | **Default on Linux.** Starts on epoll, promotes connections to io_uring under load |
| `celeris.Epoll` | Linux only | Edge-triggered epoll |
| `celeris.IOUring` | Linux 5.10+ | io_uring async I/O |
| `celeris.IOUring` | Linux 5.19+ | io_uring async I/O |
| `celeris.Std` | any platform | **Default off-Linux.** Go `net/http` server |

Defined in `celeris/config.go` (`EngineType`). The zero value resolves to `Adaptive` on
Expand Down
2 changes: 1 addition & 1 deletion src/content/docs/core-concepts.md
Original file line number Diff line number Diff line change
Expand Up @@ -264,7 +264,7 @@ they differ only in how they talk to the kernel.
| --- | --- | --- |
| `celeris.Adaptive` (default on Linux) | Linux | Dynamically switches between epoll and io_uring based on load |
| `celeris.Epoll` | Linux | Edge-triggered `epoll` |
| `celeris.IOUring` | Linux 5.10+ | `io_uring` async I/O |
| `celeris.IOUring` | Linux 5.19+ | `io_uring` async I/O |
| `celeris.Std` | All platforms | Go's `net/http` server |

The default engine is `Adaptive` on Linux and `Std` elsewhere (e.g. macOS dev
Expand Down
27 changes: 14 additions & 13 deletions src/content/docs/deployment.md
Original file line number Diff line number Diff line change
Expand Up @@ -406,10 +406,9 @@ s := celeris.New(celeris.Config{
### Running io_uring in a container

io_uring is frequently **disabled by the platform** in containers — not by Celeris.
There are **two independent gates**, and *both* must pass or Celeris transparently
falls back to epoll. This is an optimisation, not a fix-or-fail: epoll is at
There are **two independent gates**, and *both* must pass for io_uring. Under Adaptive, Celeris falls back to epoll; an explicit `IOUring` engine returns the error when the syscalls are blocked (Gate 1) and runs on fewer workers when memlock is low (Gate 2). The Adaptive fallback is an optimisation, not a fix-or-fail: epoll is at
throughput parity, so a container that can't use io_uring still runs at full speed.
When io_uring setup is denied, Celeris does **not** crash — the probe's
When io_uring setup is denied, the probe's
`io_uring_setup` call returns an error, the io_uring tier is left unselected, and the
adaptive engine runs on epoll (`celeris/internal/probe/probe.go` (`ProbeWith`)). Confirm which
engine you actually got at runtime with `Server.EngineInfo()` (see
Expand All @@ -426,7 +425,7 @@ of container-escape exploits — the same reasoning that led Google to turn it o
across ChromeOS, Android, and its production fleet, and containerd to block it
earlier. So on any modern Docker/Kubernetes setup io_uring is **off unless you
opt back in**. A blocked `io_uring_setup` surfaces as `EPERM`, and Celeris drops to
epoll.
epoll under Adaptive.

The blunt, **dev-only** way is to disable seccomp filtering entirely:

Expand Down Expand Up @@ -484,12 +483,14 @@ ulimits:

Other io_uring prerequisites (checked by the startup io_uring probe):

- **Kernel 5.10+** — Celeris's LTS-stable io_uring floor; older kernels fall through
to epoll (`celeris/internal/probe/probe.go` (`ProbeWith`)).
- **`CAP_SYS_NICE`** is consulted for SQPoll on some kernels
(`celeris/internal/probe/probe_linux.go` (`checkCapSysNiceLinux`)); not required for the basic io_uring path.
- **Kernel 5.19+** — the io_uring engine refuses older kernels: its cancels use the
`IORING_ASYNC_CANCEL` flags that 5.19 added (celeris#682), which older kernels lack; Adaptive stays on epoll on them
(`celeris/internal/engine/iouring/engine.go` (`requireAsyncCancelFlags`)).
- **`CAP_SYS_NICE`** is not a prerequisite: the probe reads it only as a fallback for an
SQPOLL flag that no tier uses ([celeris#377](https://github.com/goceleris/celeris/issues/377);
`celeris/internal/probe/probe_linux.go` (`checkCapSysNiceLinux`)).

You do not need to do anything special for epoll; it works on Linux 3.10+ out of the
You do not need to do anything special for epoll; it needs Linux 5.10+ and works out of the
box. On macOS and Windows the engine is `std` (Go `net/http`).

### SO_REUSEPORT and multiple workers
Expand All @@ -511,7 +512,7 @@ After=network.target

[Service]
ExecStart=/usr/local/bin/myservice
# Raise locked memory for io_uring (otherwise the engine falls back to epoll).
# Raise locked memory for io_uring (otherwise Adaptive falls back to epoll; an explicit IOUring engine runs on fewer workers, and fails only when not even one ring fits).
LimitMEMLOCK=infinity
# Plenty of file descriptors for high connection counts.
LimitNOFILE=1048576
Expand Down Expand Up @@ -718,9 +719,9 @@ From the `X-Forwarded-For` chain, walked right-to-left and filtered against
middleware). It is only trustworthy when `TrustedProxies` is set.

**Why is my server using epoll when I asked for io_uring?**
The kernel is below 5.10, `RLIMIT_MEMLOCK` is too low, or the `io_uring_setup`
syscall is blocked by a sandbox. Celeris probes capabilities and falls back to epoll
(at throughput parity). See [Engines](/docs/engines).
The kernel is below 5.19 (or rejects the async-cancel flags), `RLIMIT_MEMLOCK` is too low, or the `io_uring_setup`
syscall is blocked by a sandbox. Under Adaptive, Celeris probes capabilities and falls back to epoll
(at throughput parity). An explicit `IOUring` engine returns the error instead when the kernel or the syscall check fails; with a low `RLIMIT_MEMLOCK` it runs on fewer workers, down to one, and fails only when not even one ring fits. See [Engines](/docs/engines).

**What status do the probes return?**
`200 {"status":"ok"}` when the checker passes, `503 {"status":"unavailable"}` when
Expand Down
Loading
Loading