Skip to content
Open
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
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -25,3 +25,5 @@ compile_commands.json

# IDE
.idea/
playground/kotlin/server.jar
*.jar
11 changes: 11 additions & 0 deletions ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -194,6 +194,17 @@ calls `ioma_next_run` to run the rest of the chain and the endpoint, then may in
response on the way out. Not calling `next` short-circuits (auth, cache). With no middleware
registered the dispatch is a direct function call.

### Foreign handlers

`ioma_route_ffi` registers a plain C function pointer instead of an `ioma_handler`, for handlers
that live in another runtime; the Kotlin example in `playground/kotlin` hands it a Panama upcall
stub. A managed runtime cannot run on a coroutine's 64 KB stack (the JVM bounds-checks and walks
the thread's real stack), so these handlers do not run there. The coroutine flattens the request
into a small fixed-layout struct and calls `await_call`: it parks, the loop runs the handler right
where it resumed the coroutine, on the worker's own thread stack, then resumes the coroutine with
the filled-in reply. Two extra switches per request; a Kotlin handler measures within a few percent
of a native one.

---

## 6. One keep-alive request, end to end
Expand Down
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,9 @@ with `ioma_text`, `ioma_json`, `ioma_bytes` or `ioma_textf`. Read a request head
`ioma_header_get`, add a response header with `ioma_header_set`. Register endpoints with
`ioma_route` (exact method and path), optional middleware with `ioma_use`, a fallback with
`ioma_default`, then call `ioma_run` with a worker count (zero means one per core) and a port.
`playground/hello/main.c` is a complete example.
`playground/hello/main.c` is a complete example. Handlers can also live in another language:
`ioma_route_ffi` takes a plain C function pointer and calls it on the worker's own thread stack, and
`playground/kotlin` drives libioma from Kotlin/JVM through Panama that way.

## Tests and limits

Expand Down
33 changes: 33 additions & 0 deletions include/http.h
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,39 @@ void ioma_default(ioma_handler fn);
/* Register global middleware; it runs on every request in the order added, wrapping the handler. */
void ioma_use(ioma_mw mw);

/* ── foreign handlers ──────────────────────────────────────────────────────────────────── */

/* A request as a foreign handler sees it: ten 8-byte fields, no padding, so a binding reads it
* with fixed offsets (see playground/kotlin). Strings are not NUL-terminated. `req` is the full
* request, for ioma_header_get through a call back into the library. */
typedef struct ioma_ffi_request {
const char *method; size_t method_len;
const char *path; size_t path_len;
const char *query; size_t query_len;
const char *body; size_t body_len;
char *scratch; size_t scratch_cap; /* write the reply body here */
const ioma_request *req;
} ioma_ffi_request;

/* The reply a foreign handler fills in: 32 bytes, no padding. body must stay valid until the
* reply is sent - the scratch buffer above, or memory the binding keeps for the program's life.
* Preset before the call: status 200, everything else zero. */
typedef struct ioma_ffi_response {
int status;
int close; /* nonzero: close after this reply */
const char *content_type; /* NUL-terminated; NULL means text/plain */
const void *body;
size_t body_len;
} ioma_ffi_response;

typedef void (*ioma_ffi_handler)(const ioma_ffi_request *req, ioma_ffi_response *res, void *userdata);

/* Register a foreign handler. Unlike an ioma_handler it runs on the worker's own thread stack,
* not on the connection's coroutine, so a managed runtime (a JVM through Panama, ...) may be
* called from it. It must not block: every connection on that worker waits while it runs.
* Middleware applies to it like to any endpoint. */
void ioma_route_ffi(const char *method, const char *path, ioma_ffi_handler fn, void *userdata);

/* ── run ───────────────────────────────────────────────────────────────────────────────── */

/* Start `workers` proactor threads (one per core) serving HTTP on `port`, and block until
Expand Down
7 changes: 7 additions & 0 deletions include/proactor.h
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,8 @@ struct proactor {
uint64_t accepted;
conn_t *conn_free; /* recycled conn_t objects, reused on accept */
unsigned conn_free_count;
void (*call_fn)(void *); /* a coroutine's pending await_call, or NULL */
void *call_arg;
};

/* The worker thread's whole life: ring, buffers, listener, loop until *stop, teardown. */
Expand All @@ -104,3 +106,8 @@ void proactor_spawn(proactor_t *p, void (*fn)(void *), void *arg);
* when the completion arrives. */
int await_recv(conn_t *c, void *buf, size_t len); /* >0 bytes, 0 peer closed, <0 -errno */
int await_send(conn_t *c, const void *buf, size_t len); /* len when all sent, else -errno */

/* Run fn(arg) on the worker's own thread stack and return once it has. For code that must not run
* on a coroutine stack: a managed runtime's upcall, anything that bounds-checks the stack. Two
* extra switches, nothing else. */
void await_call(proactor_t *p, void (*fn)(void *arg), void *arg);
14 changes: 14 additions & 0 deletions playground/kotlin/App.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
// App.kt - the example: the endpoints in Kotlin, the I/O in C. ./run.sh builds and runs it.

import kotlin.system.exitProcess

fun main(args: Array<String>) {
val server = ioma(lib = args.getOrElse(0) { "../../libioma.so" }) {
get("/") { _, reply -> reply.text("hello from kotlin\n") }
get("/baseline11") { req, reply -> reply.text(req.query.sumOfValues()) }
post("/baseline11") { req, reply -> reply.text(req.query.sumOfValues() + req.body.leadingInt()) }
}
println("kotlin handlers registered, handing the loop to libioma")
exitProcess(server.run(workers = System.getenv("IOMA_WORKERS")?.toIntOrNull() ?: 0,
port = System.getenv("IOMA_PORT")?.toIntOrNull() ?: 8080))
}
124 changes: 124 additions & 0 deletions playground/kotlin/Ioma.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
// Ioma.kt - a small Kotlin binding over libioma's foreign-handler ABI (include/http.h).
//
// One C-callable dispatcher serves every route: libioma passes the route's index back as userdata
// and the dispatcher invokes the matching Kotlin lambda. It reuses one Request and one Reply per
// worker thread, so a request allocates nothing on the JVM side. Handlers run on the worker's own
// thread stack and must not block.

import java.lang.foreign.Arena
import java.lang.foreign.FunctionDescriptor
import java.lang.foreign.Linker
import java.lang.foreign.MemorySegment
import java.lang.foreign.SymbolLookup
import java.lang.foreign.ValueLayout.ADDRESS
import java.lang.foreign.ValueLayout.JAVA_BYTE
import java.lang.foreign.ValueLayout.JAVA_INT
import java.lang.foreign.ValueLayout.JAVA_LONG
import java.lang.invoke.MethodHandles
import java.lang.invoke.MethodType

typealias Handler = (Request, Reply) -> Unit

/** A request: zero-copy views into libioma's read buffer, valid only while the handler runs. */
class Request internal constructor() {
internal var raw: MemorySegment = MemorySegment.NULL
val method: MemorySegment get() = view(0)
val path: MemorySegment get() = view(16)
val query: MemorySegment get() = view(32)
val body: MemorySegment get() = view(48)
private fun view(off: Long) = raw.get(ADDRESS, off).reinterpret(raw.get(JAVA_LONG, off + 8))
}

/** The reply. Bodies are written into libioma's per-request scratch buffer; status defaults to 200. */
class Reply internal constructor() {
internal var raw: MemorySegment = MemorySegment.NULL
internal var scratch: MemorySegment = MemorySegment.NULL
var status: Int
get() = raw.get(JAVA_INT, 0)
set(v) = raw.set(JAVA_INT, 0, v)

fun text(s: String) { // ASCII
var i = 0L
for (ch in s) scratch.set(JAVA_BYTE, i++, ch.code.toByte())
body(i)
}
fun text(n: Long) = body(scratch.putLong(n))
private fun body(len: Long) { raw.set(ADDRESS, 16, scratch); raw.set(JAVA_LONG, 24, len) }
}

class Ioma(libPath: String) {
private val linker = Linker.nativeLinker()
private val arena = Arena.global() // libioma keeps these pointers for its whole life
private val lib = SymbolLookup.libraryLookup(libPath, arena)
private val routeFfi = linker.downcallHandle(lib.find("ioma_route_ffi").orElseThrow(),
FunctionDescriptor.ofVoid(ADDRESS, ADDRESS, ADDRESS, ADDRESS))
private val runFn = linker.downcallHandle(lib.find("ioma_run").orElseThrow(),
FunctionDescriptor.of(JAVA_INT, JAVA_INT, JAVA_INT))
private val handlers = ArrayList<Handler>()
private val request = ThreadLocal.withInitial { Request() }
private val reply = ThreadLocal.withInitial { Reply() }
private val dispatcher: MemorySegment = linker.upcallStub(
MethodHandles.lookup().findVirtual(Ioma::class.java, "dispatch", MethodType.methodType(
Void.TYPE, MemorySegment::class.java, MemorySegment::class.java, MemorySegment::class.java)).bindTo(this),
FunctionDescriptor.ofVoid(ADDRESS, ADDRESS, ADDRESS), arena)

fun route(method: String, path: String, handler: Handler) {
handlers += handler
routeFfi.invokeWithArguments(arena.allocateFrom(method), arena.allocateFrom(path), dispatcher,
MemorySegment.ofAddress((handlers.size - 1).toLong()))
}
fun get(path: String, handler: Handler) = route("GET", path, handler)
fun post(path: String, handler: Handler) = route("POST", path, handler)

/** Blocks until SIGINT/SIGTERM. workers <= 0 means one per core. */
fun run(workers: Int = 0, port: Int = 8080): Int = runFn.invokeWithArguments(workers, port) as Int

/** libioma calls this on a worker's thread stack for every foreign route. */
fun dispatch(req: MemorySegment, res: MemorySegment, userdata: MemorySegment) {
val r = request.get()
r.raw = req.reinterpret(88)
val y = reply.get()
y.raw = res.reinterpret(32)
y.scratch = r.raw.get(ADDRESS, 64).reinterpret(r.raw.get(JAVA_LONG, 72))
handlers[userdata.address().toInt()](r, y)
}
}

fun ioma(lib: String, routes: Ioma.() -> Unit): Ioma = Ioma(lib).apply(routes)

// ── byte helpers, so handlers never turn C memory into Strings ────────────────────────────────

/** Sum of the integer values in "a=13&b=42". */
fun MemorySegment.sumOfValues(): Long {
val n = byteSize(); var sum = 0L; var i = 0L
while (i < n) {
while (i < n && get(JAVA_BYTE, i) != '='.code.toByte()) i++
if (i >= n) break
i++
var v = 0L; var any = false
while (i < n && get(JAVA_BYTE, i) != '&'.code.toByte()) {
val d = get(JAVA_BYTE, i) - 48
if (d in 0..9) { v = v * 10 + d; any = true }
i++
}
if (any) sum += v
if (i < n) i++
}
return sum
}

/** The integer a body like "20" starts with. */
fun MemorySegment.leadingInt(): Long {
var v = 0L; var i = 0L
while (i < byteSize()) { val d = get(JAVA_BYTE, i) - 48; if (d !in 0..9) break; v = v * 10 + d; i++ }
return v
}

/** Decimal digits of v at the start of this segment; returns how many. */
internal fun MemorySegment.putLong(v: Long): Long {
var x = if (v < 0) 0 else v; var len = 0L
do { len++; x /= 10 } while (x != 0L)
x = if (v < 0) 0 else v; var i = len - 1
do { set(JAVA_BYTE, i--, (48 + x % 10).toByte()); x /= 10 } while (x != 0L)
return len
}
25 changes: 25 additions & 0 deletions playground/kotlin/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
# Kotlin on libioma

A spike: libioma does the I/O in C, the endpoints are Kotlin.

`App.kt` is the program: a few routes with lambdas, then hand the main thread to libioma.
`Ioma.kt` is the binding that keeps Panama out of the app: it loads `libioma.so` through
`java.lang.foreign` (final since JDK 22), registers one C-callable dispatcher for every route with
`ioma_route_ffi`, and gives each handler a `Request` (zero-copy views of the query, body and so on)
and a `Reply` (writes the body into libioma's scratch buffer). One `Request` and one `Reply` are
reused per worker thread, so a request allocates nothing. libioma calls the dispatcher on each
worker's own thread stack, never on a coroutine stack, which is what makes calling a JVM from it
safe.

Run `./run.sh` with `kotlinc` and a JDK 22+ on PATH (tested on JDK 26.0.2.1). It serves `/` and `GET`/`POST /baseline11`
on port 8080. The handlers read the request and write the reply body straight in C memory, so the
hot path allocates nothing on the JVM side.

Rules for a handler: never block, keep it short (every connection on that worker waits while it
runs), and keep any body it returns in memory that outlives the call (the request's scratch
buffer, or a global arena). Garbage-collector pauses show up as latency; use a low-pause collector
such as ZGC for anything latency-sensitive.

Measured on 4 pinned cores with the HttpArena baseline load: the Kotlin handler serves about 1.18M
requests per second, against about 1.26M for the same handler written in C on the coroutine stack.
The difference is the hop to the thread stack; the JVM upcall itself is not measurable.
8 changes: 8 additions & 0 deletions playground/kotlin/run.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
#!/bin/sh
# Build libioma.so, compile the example against JDK 22+ (java.lang.foreign is final there), run it.
# Needs kotlinc and a JDK 22+ on PATH (or set JAVA_HOME); tested on JDK 26.0.2.1. IOMA_WORKERS / IOMA_PORT override the defaults.
set -e
cd "$(dirname "$0")"
make -s -C ../.. lib
kotlinc Ioma.kt App.kt -include-runtime -d server.jar
exec java --enable-native-access=ALL-UNNAMED -jar server.jar ../../libioma.so
10 changes: 9 additions & 1 deletion src/conn.c
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,7 @@ static void wake_reader(conn_t *c)
coro_t *w = c->waiter;
if (w) {
c->waiter = NULL;
coro_resume(w);
run_coro(c->p, w);
}
}

Expand Down Expand Up @@ -286,3 +286,11 @@ int await_send(conn_t *c, const void *buf, size_t len)
}
return (int)len;
}

/* Hand fn(arg) to the loop and park; run_coro executes it on the thread stack and resumes us. */
void await_call(proactor_t *p, void (*fn)(void *), void *arg)
{
p->call_fn = fn;
p->call_arg = arg;
coro_yield();
}
18 changes: 17 additions & 1 deletion src/internal.h
Original file line number Diff line number Diff line change
Expand Up @@ -121,9 +121,25 @@ void ioma__conn_pool_drain(proactor_t *p);
/* ── HTTP (http.c, router.c) ───────────────────────────────────────────────────────────── */

void ioma__serve(conn_t *c); /* the per-connection HTTP loop */
ioma_handler ioma__match(const ioma_request *req);
ioma_response ioma__dispatch(ioma_request *req);

/* ── running coroutines ────────────────────────────────────────────────────────────────── */

/* Resume a coroutine and service any await_call it makes: the call runs right here, on the
* thread's own stack, and the coroutine is resumed again with the result. Every resume of a
* connection coroutine goes through this. */
static inline void run_coro(proactor_t *p, coro_t *c)
{
coro_resume(c);
while (p->call_fn) {
void (*fn)(void *) = p->call_fn;
void *arg = p->call_arg;
p->call_fn = NULL;
fn(arg);
coro_resume(c);
}
}

/* ── ASCII helpers ─────────────────────────────────────────────────────────────────────── */

/* Fold A-Z to a-z; every other byte unchanged. */
Expand Down
4 changes: 2 additions & 2 deletions src/proactor.c
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ static void dispatch(proactor_t *p, struct io_uring_cqe *cqe)
op->res = cqe->res;
op->flags = cqe->flags;
trace("[w%d] op res=%d flags=%#x\n", p->id, cqe->res, cqe->flags);
coro_resume(op->waiter); /* to its next await; op may be gone after */
run_coro(p, op->waiter); /* to its next await; op may be gone after */
break;
}
case TAG_RECV:
Expand Down Expand Up @@ -94,7 +94,7 @@ static void run_ready(proactor_t *p)
if (!p->ready_head)
p->ready_tail = NULL;
c->next = NULL;
coro_resume(c);
run_coro(p, c);
}
}

Expand Down
Loading