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: 1 addition & 1 deletion frameworks/neton/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ Kotlin Multiplatform compiled to a native executable, on the hyper4k engine
The framework is consumed from Maven Central as a single coordinate:

```kotlin
implementation("com.netonstream:neton:1.0.0-beta7")
implementation("com.netonstream:neton:1.0.0-beta13")
```

That one dependency carries core, logging, HTTP, routing and the hyper4k engine,
Expand Down
12 changes: 10 additions & 2 deletions frameworks/neton/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -10,13 +10,19 @@ repositories {
// One published coordinate. `neton` brings core + logging + http + routing and
// the hyper4k engine, so the arena entry builds from Maven exactly like any
// other application would — no source checkout, no composite build.
val netonVersion = "1.0.0-beta7"
val netonVersion = "1.0.0-beta15"

kotlin {
// The arena builds linuxX64; macosArm64 is here so the endpoints can be
// exercised on a developer machine.
listOf(macosArm64(), linuxX64(), linuxArm64()).forEach { target ->
target.binaries.executable { entryPoint = "main" }
target.binaries.executable {
entryPoint = "main"
// hyper4k and sqlx4k (neton-database) are each a Rust static library, so
// both bundle the Rust runtime — linking both defines rust_eh_personality
// (and friends) twice. The copies are identical; take the first.
linkerOpts("--allow-multiple-definition")
}
}

sourceSets {
Expand All @@ -27,6 +33,8 @@ kotlin {
dependsOn(commonMain.get())
dependencies {
implementation("com.netonstream:neton:$netonVersion")
// async-db / fortunes: async Postgres via sqlx4k.
implementation("com.netonstream:neton-database:$netonVersion")
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.11.0")
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.11.0")
// Encoding straight into a byte buffer, rather than to a String the
Expand Down
5 changes: 3 additions & 2 deletions frameworks/neton/config/application.conf
Original file line number Diff line number Diff line change
Expand Up @@ -37,8 +37,9 @@ timeout = 0
# 524,288 clears the peak with room to spare. The counter itself costs nothing;
# the memory belongs to the requests actually in flight, and the box has 251 GiB.
maxConnections = 524288
# No response compression is implemented, so nothing claims to do it.
enableCompression = false
# Dynamic gzip response compression (beta15+): compressible responses are gzipped
# when the client sends Accept-Encoding. Required for json-comp.
enableCompression = true

[logging]
# Measured runs must not spend request time on log lines.
Expand Down
7 changes: 7 additions & 0 deletions frameworks/neton/config/database.conf
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
# Postgres sidecar the arena starts for the DB profiles (async-db / fortunes).
# Host networking, so the framework reaches it on localhost:5432. Fixed
# credentials/db name are set by the harness (validate.sh / benchmark.sh).
[default]
driver = "POSTGRESQL"
uri = "postgresql://bench:bench@localhost:5432/benchmark"
debug = false
13 changes: 11 additions & 2 deletions frameworks/neton/meta.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
"request": true,
"response": true
},
"description": "Neton, a Kotlin Multiplatform framework compiled to a native executable. Routing, middleware, request parsing and the response pipeline are the framework's; the hyper4k engine (Tokio + Hyper 1.x, linked in as a Rust static library) only does protocol and transport. Serves HTTP/1.1 on 8080 and HTTP/2 cleartext on 8082 from one route table.",
"description": "Neton, a Kotlin Multiplatform framework compiled to a native executable. Routing, middleware, request parsing and the response pipeline are the framework's; the hyper4k engine (Tokio + Hyper 1.x, linked in as a Rust static library) only does protocol and transport. Serves HTTP/1.1 on 8080, HTTP/2 cleartext on 8082, HTTP/1.1+TLS on 8081 and HTTP/2+TLS on 8443 (ALPN) from one route table; static files and TLS terminate in the framework.",
"repo": "https://github.com/netonframework/neton",
"enabled": true,
"tests": [
Expand All @@ -20,8 +20,17 @@
"async",
"latency-1m",
"latency-10k",
"latency-500k-8cpu",
"baseline-h2c",
"json-h2c"
"json-h2c",
"json-comp",
"json-tls",
"8gbit",
"static-tls",
"baseline-h2",
"static-h2",
"async-db",
"fortunes"
],
"maintainers": [
"zoujiaqing"
Expand Down
197 changes: 183 additions & 14 deletions frameworks/neton/src/nativeMain/kotlin/Main.kt
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import kotlin.native.runtime.GC
import kotlin.native.runtime.NativeRuntimeApi
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.withTimeout
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
Expand All @@ -24,8 +26,12 @@ import neton.core.config.readConfigFile
import neton.core.http.HttpContext
import neton.core.http.HttpStatus
import neton.core.http.adapter.HttpServerConfig
import neton.database.database
import neton.database.dbContext
import neton.http.http
import neton.http.hyper4k.Hyper4kHttpAdapter
import neton.http.static.staticFiles
import neton.core.http.adapter.TlsSettings
import neton.routing.*

/**
Expand All @@ -42,8 +48,10 @@ import neton.routing.*
* same socket by prior knowledge, so the second listener is a second adapter
* over the same frozen context, not a second application.
*
* Not subscribed (see meta.json): the TLS profiles — the engine terminates no
* TLS today — plus json-comp, which needs gzip/br response compression.
* Not subscribed (see meta.json): the profiles that need capabilities the engine
* does not have yet — HTTP/3, gRPC, WebSocket — and the multi-service DB profiles
* (async-db, fortunes, production-stack). json-comp is served here: the framework
* gzip-compresses compressible responses when the client sends Accept-Encoding.
*/

/**
Expand All @@ -57,6 +65,16 @@ import neton.routing.*
*/
private const val H1_PORT = 8080
private val H2C_PORT = getEnv("ARENA_H2C_PORT")?.toIntOrNull() ?: 8082
// TLS listeners. 8081 serves the HTTP/1.1 + TLS profiles (json-tls, static-tls,
// 8gbit, tls); 8443 serves the HTTP/2 + TLS ones (baseline-h2, static-h2) via
// ALPN. Certificates are mounted read-only at /certs by the harness. Overridable
// so a dev machine need not hold the harness ports or certs.
private val H1TLS_PORT = getEnv("ARENA_H1TLS_PORT")?.toIntOrNull() ?: 8081
private val H2TLS_PORT = getEnv("ARENA_H2TLS_PORT")?.toIntOrNull() ?: 8443
private val CERT_PATH = getEnv("ARENA_CERT") ?: "/certs/server.crt"
private val KEY_PATH = getEnv("ARENA_KEY") ?: "/certs/server.key"
private val STATIC_DIR = getEnv("ARENA_STATIC") ?: "/data/static"
private val TLS_ENABLED = readConfigFile(CERT_PATH) != null && readConfigFile(KEY_PATH) != null

/**
* Mounted read-only by the harness: -v data/dataset.json:/data/dataset.json:ro.
Expand Down Expand Up @@ -103,6 +121,13 @@ fun main(args: Array<String>) {
port = H1_PORT
}

// async-db / fortunes need Postgres. Gated so the baseline A/B can run the
// exact same binary with the DB out of the picture (ARENA_DB=0): the plain
// profiles never touch the pool, and this proves it costs them nothing.
if (getEnv("ARENA_DB") != "0") {
database { }
}

routing {
get("/baseline11") { it.writeSum() }
post("/baseline11") { it.writeSum(withBody = true) }
Expand All @@ -113,9 +138,38 @@ fun main(args: Array<String>) {
get("/pipeline") { it.response.text("ok") }
get("/delay/{ms}") { it.writeDelay() }
get("/json/{count}") { it.writeItems(items) }

// async-db: async Postgres sequential scan (no index on price) →
// {count, items:[{..., active:bool, tags:[...], rating:{score,count}}]}.
get("/async-db") { it.writeDbItems() }

// fortunes: TechEmpower template benchmark — all fortune rows + one
// runtime row, sorted by message, rendered as escaped HTML.
get("/fortunes") { it.writeFortunes() }

// 8gbit: read the posted body through the standard API and write it
// back verbatim — not from Content-Length, so chunked echoes too.
post("/echo") { it.echoBody() }

// static-tls / static-h2: serve the mounted files with pre-compressed
// .br/.gz variants selected off Accept-Encoding by the framework.
staticFiles("/static", STATIC_DIR) { precompressed = true }
}

onReady { startH2cListener(this) }
onReady {
// Each listener is awaited to its bind before READY returns, so the
// harness never probes a TLS port that is not up yet. A listener that
// fails to bind fails the launch rather than leaving a silent gap.
check(startListener(this, H2C_PORT, null)) { "h2c listener failed to bind on $H2C_PORT" }
if (TLS_ENABLED) {
check(startListener(this, H1TLS_PORT, TlsSettings(CERT_PATH, KEY_PATH, listOf("http/1.1")))) {
"h1+TLS listener failed to bind on $H1TLS_PORT"
}
check(startListener(this, H2TLS_PORT, TlsSettings(CERT_PATH, KEY_PATH, listOf("h2", "http/1.1")))) {
"h2+TLS listener failed to bind on $H2TLS_PORT"
}
}
}
}
}

Expand Down Expand Up @@ -159,6 +213,94 @@ private suspend fun HttpContext.writeItems(items: ArenaItems) {
response.write(items.render(request.pathParam("count"), request.queryParam("m")))
}

/**
* /async-db?min=&max=&limit=: rows from Postgres selected by price range. There is
* no index on price, so this is a sequential scan — the point of the profile. The
* body is built straight to bytes; `tags` is a JSONB column whose text is already a
* valid JSON array, so it is embedded verbatim.
*/
private suspend fun HttpContext.writeDbItems() {
val min = request.queryParam("min")?.toIntOrNull() ?: 0
val max = request.queryParam("max")?.toIntOrNull() ?: Int.MAX_VALUE
val limit = (request.queryParam("limit")?.toIntOrNull() ?: 1).coerceIn(0, 1000)
val rows = dbContext().fetchAll(
"SELECT id, name, category, price, quantity, active, tags, rating_score, rating_count " +
"FROM items WHERE price BETWEEN :min AND :max LIMIT :limit",
mapOf("min" to min, "max" to max, "limit" to limit),
)
val sb = StringBuilder(64 + rows.size * 160)
sb.append("{\"count\":").append(rows.size).append(",\"items\":[")
for (i in rows.indices) {
val r = rows[i]
if (i > 0) sb.append(',')
sb.append("{\"id\":").append(r.int("id"))
sb.append(",\"name\":"); appendJsonString(sb, r.string("name"))
sb.append(",\"category\":"); appendJsonString(sb, r.string("category"))
sb.append(",\"price\":").append(r.int("price"))
sb.append(",\"quantity\":").append(r.int("quantity"))
sb.append(",\"active\":").append(r.boolean("active"))
sb.append(",\"tags\":").append(r.string("tags"))
sb.append(",\"rating\":{\"score\":").append(r.int("rating_score"))
sb.append(",\"count\":").append(r.int("rating_count")).append("}}")
}
sb.append("]}")
response.contentType = "application/json; charset=utf-8"
response.write(sb.toString().encodeToByteArray())
}

/**
* /fortunes: every row of the fortune table plus one row injected at request time,
* sorted by message, rendered as an HTML table with each message HTML-escaped
* (the seeded row 11 carries a raw <script> that must come out as &lt;script&gt;).
*/
private suspend fun HttpContext.writeFortunes() {
val rows = dbContext().fetchAll("SELECT id, message FROM fortune", emptyMap())
val fortunes = ArrayList<Pair<Int, String>>(rows.size + 1)
for (r in rows) fortunes.add(r.int("id") to r.string("message"))
fortunes.add(0 to "Additional fortune added at request time.")
fortunes.sortBy { it.second }
val sb = StringBuilder(24576)
sb.append("<!DOCTYPE html><html><head><title>Fortunes</title></head><body><table>")
sb.append("<tr><th>id</th><th>message</th></tr>")
for ((id, msg) in fortunes) {
sb.append("<tr><td>").append(id).append("</td><td>")
appendHtmlEscaped(sb, msg)
sb.append("</td></tr>")
}
sb.append("</table></body></html>")
response.contentType = "text/html; charset=utf-8"
response.write(sb.toString().encodeToByteArray())
}

/** HTML-escape row text so user content cannot break out of the table cell. */
private fun appendHtmlEscaped(sb: StringBuilder, s: String) {
for (c in s) when (c) {
'<' -> sb.append("&lt;")
'>' -> sb.append("&gt;")
'&' -> sb.append("&amp;")
'"' -> sb.append("&quot;")
'\'' -> sb.append("&#39;")
else -> sb.append(c)
}
}

/** Minimal JSON string emitter for the DB text columns (name/category). */
private fun appendJsonString(sb: StringBuilder, value: String) {
sb.append('"')
for (c in value) {
when {
c == '"' -> sb.append("\\\"")
c == '\\' -> sb.append("\\\\")
c == '\n' -> sb.append("\\n")
c == '\r' -> sb.append("\\r")
c == '\t' -> sb.append("\\t")
c.code < 0x20 -> sb.append("\\u").append(c.code.toString(16).padStart(4, '0'))
else -> sb.append(c)
}
}
sb.append('"')
}

/**
* The dataset behind `/json/{count}?m=M`: the first `count` items of
* /data/dataset.json, field for field, each with total = price * quantity * m.
Expand Down Expand Up @@ -265,20 +407,47 @@ private class ArenaItems(private val source: List<SourceItem>) {
* Same frozen context, so both listeners serve an identical route table and
* hyper4k negotiates HTTP/1.1 or HTTP/2 per connection on either of them.
*/
private fun startH2cListener(application: KotlinApplication) {
/** /echo: hand back exactly the bytes that arrived. */
private suspend fun HttpContext.echoBody() {
val body = request.body()
response.contentType = "application/octet-stream"
response.write(body)
}

/**
* Brings up a TLS listener sharing the frozen route table. [alpn] is the server's
* preference order: `["http/1.1"]` on 8081, `["h2","http/1.1"]` on 8443, so ALPN
* chooses the protocol per connection.
*/
/**
* Brings up one listener sharing the frozen route table and returns only once it
* has bound (or failed). [tls] null serves cleartext; non-null terminates TLS
* with the given ALPN. The serve loop runs for the process lifetime on its own
* scope; this function returns as soon as the bind is confirmed so READY can gate
* on every listener being up.
*/
private suspend fun startListener(
application: KotlinApplication,
port: Int,
tls: TlsSettings?,
): Boolean {
val context = application.get<NetonContext>()
// Copy the config the framework already resolved from application.conf and
// change only the port. Spelling the fields out again here meant the h2c
// listener silently ignored the file: the h1 listener ran with the
// configured timeout and connection ceiling while this one kept whatever
// was hard-coded, so the two listeners were never the same server and no
// config-level experiment could reach the h2c profiles.
val adapter = Hyper4kHttpAdapter(
context.get(HttpServerConfig::class).copy(port = H2C_PORT),
context.get(HttpServerConfig::class).copy(port = port, tls = tls),
)
// start() holds the listener open for the process lifetime and never
// returns, so it cannot run on the framework's own start path.
val bound = CompletableDeferred<Unit>()
CoroutineScope(SupervisorJob() + Dispatchers.Default).launch {
adapter.start(context, null)
try {
adapter.start(context) { bound.complete(Unit) }
} catch (e: Throwable) {
bound.completeExceptionally(e)
}
}
return try {
withTimeout(10_000) { bound.await() }
true
} catch (_: Throwable) {
false
}
}

2 changes: 1 addition & 1 deletion site/data/frameworks.json
Original file line number Diff line number Diff line change
Expand Up @@ -1192,7 +1192,7 @@
},
"neton": {
"dir": "neton",
"description": "Neton, a Kotlin Multiplatform framework compiled to a native executable. Routing, middleware, request parsing and the response pipeline are the framework's; the hyper4k engine (Tokio + Hyper 1.x, linked in as a Rust static library) only does protocol and transport. Serves HTTP/1.1 on 8080 and HTTP/2 cleartext on 8082 from one route table.",
"description": "Neton, a Kotlin Multiplatform framework compiled to a native executable. Routing, middleware, request parsing and the response pipeline are the framework's; the hyper4k engine (Tokio + Hyper 1.x, linked in as a Rust static library) only does protocol and transport. Serves HTTP/1.1 on 8080, HTTP/2 cleartext on 8082, HTTP/1.1+TLS on 8081 and HTTP/2+TLS on 8443 (ALPN) from one route table; static files and TLS terminate in the framework.",
"repo": "https://github.com/netonframework/neton",
"type": "emerging",
"engine": "hyper",
Expand Down
Loading