diff --git a/frameworks/swoole/swoole.php b/frameworks/swoole/swoole.php index 1ae575f57..73e7033e0 100644 --- a/frameworks/swoole/swoole.php +++ b/frameworks/swoole/swoole.php @@ -5,6 +5,7 @@ use Swoole\Http\Response; require __DIR__ . '/PostgreSQL.php'; +require __DIR__ . '/SQLite.php'; $dataset = json_decode(file_get_contents('/data/dataset.json'), true); @@ -43,6 +44,7 @@ $http->on('workerStart', function (Server $server, int $workerId) { PostgreSQL::init(); + SQLite::init(); }); $http->on('request', function (Request $request, Response $response) use ($dataset, $files) { @@ -94,6 +96,14 @@ return; } + if ($path === '/sqlite-db') { + $response->header['Content-Type'] = 'application/json'; + $min = (int)($request->get['min'] ?? 10); + $max = (int)($request->get['max'] ?? 50); + $response->end(SQLite::query($min, $max)); + return; + } + if (str_starts_with($path, '/static/')) { if (isset($files[$path])) { $f = $files[$path]; diff --git a/frameworks/true-async-server/Dockerfile b/frameworks/true-async-server/Dockerfile new file mode 100644 index 000000000..46e3518c1 --- /dev/null +++ b/frameworks/true-async-server/Dockerfile @@ -0,0 +1,17 @@ +FROM trueasync/php-true-async:0.7.0-alpha.12-php8.6 + +RUN printf '%s\n' \ + 'opcache.jit=1255' \ + 'opcache.jit_buffer_size=128M' \ + 'opcache.memory_consumption=256' \ + 'opcache.max_accelerated_files=10000' \ + 'opcache.validate_timestamps=0' \ + 'memory_limit=1024M' \ + > /etc/php.d/99-arena.ini + +WORKDIR /app +COPY entry.php PostgreSQL.php SQLite.php /app/ + +EXPOSE 8080 8443 + +CMD ["php", "/app/entry.php"] diff --git a/frameworks/true-async-server/PostgreSQL.php b/frameworks/true-async-server/PostgreSQL.php new file mode 100644 index 000000000..22199f4f4 --- /dev/null +++ b/frameworks/true-async-server/PostgreSQL.php @@ -0,0 +1,99 @@ + PDO::ERRMODE_EXCEPTION, + PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC, + PDO::ATTR_EMULATE_PREPARES => false, + PDO::ATTR_POOL_ENABLED => true, + PDO::ATTR_POOL_MIN => $minConn, + PDO::ATTR_POOL_MAX => $maxConn, + PDO::ATTR_POOL_STMT_CACHE_SIZE => 32, + ] + ); + + self::$available = true; + } + + public static function query(float $min, float $max, int $limit = 50): string + { + if (!self::$available) { + self::init(); + if (!self::$available) { + return '{"items":[],"count":0}'; + } + } + + try { + $stmt = self::$pdo->prepare(self::SQL); + $stmt->execute([$min, $max, $limit]); + $rows = []; + while ($row = $stmt->fetch()) { + $rows[] = [ + 'id' => $row['id'], + 'name' => $row['name'], + 'category' => $row['category'], + 'price' => $row['price'], + 'quantity' => $row['quantity'], + 'active' => (bool)$row['active'], + 'tags' => json_decode($row['tags'], true), + 'rating' => [ + 'score' => $row['rating_score'], + 'count' => $row['rating_count'], + ], + ]; + } + return json_encode( + ['items' => $rows, 'count' => count($rows)], + JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES + ); + } catch (\Throwable) { + return '{"items":[],"count":0}'; + } + } +} diff --git a/frameworks/true-async-server/README.md b/frameworks/true-async-server/README.md new file mode 100644 index 000000000..edefbbb1f --- /dev/null +++ b/frameworks/true-async-server/README.md @@ -0,0 +1,219 @@ +# true-async-server + +[TrueAsync Server](https://github.com/true-async/server) — a native PHP +extension that runs an HTTP/1.1 + HTTP/2 + HTTP/3 server inside the PHP +process. No FastCGI, no separate Caddy / FrankenPHP / nginx in front. +Everything (accept, parse, dispatch to PHP handler, response) happens on +the TrueAsync coroutine event loop in the same OS thread that owns the +connection. + +- **Source:** +- **Engine:** TrueAsync (PHP fork — ) +- **Tier:** `tuned` +- **Image:** `trueasync/php-true-async:0.7.0-alpha.5-php8.6` + +### Related repositories + +| Repo | Purpose | +|------|---------| +| [`true-async/server`](https://github.com/true-async/server) | This extension — the HTTP/1+2+3 server itself (source of `true_async_server.so`) | +| [`true-async/php-src`](https://github.com/true-async/php-src) | PHP 8.6 fork with the TrueAsync coroutine API in core | +| [`true-async/php-async`](https://github.com/true-async/php-async) | `ext/async` — coroutines, `ThreadPool`, `spawn`, PDO connection pool | +| [`true-async/releases`](https://github.com/true-async/releases) | Release pipeline: builds Docker images and Windows ZIPs from the three repos above | +| [`true-async/frankenphp`](https://github.com/true-async/frankenphp) | TrueAsync fork of FrankenPHP (separate framework entry, not used here) | +| [`true-async/xdebug`](https://github.com/true-async/xdebug) | Xdebug fork patched for the TrueAsync runtime | +| [Docker Hub `trueasync/php-true-async`](https://hub.docker.com/r/trueasync/php-true-async) | Pre-built images consumed by this framework's `Dockerfile` | + +## How it works + +### One process, N event-loop threads + +A single PHP process is launched. The main thread reads the dataset +into shared read-only memory, constructs an `HttpServer` from +`HttpServerConfig`, mounts a `StaticHandler` for `/static/`, and +registers a single PHP callback via `addHttpHandler`. Static files +themselves are served from C — the PHP callback never sees them. + +The main thread then submits an `Async\ThreadPool` job for each CPU +(`N = available_parallelism()`, overridable via `WORKERS=…`). The job +body just calls `$server->start()` on a thread-transferred copy of the +server object. The transfer copies the registered callbacks and the +listener configuration — there is no shared mutable state between +threads. + +Each thread runs its own libuv event loop. There is no master/worker +split: every thread accepts, parses, executes the handler, writes the +response, and recycles the connection. `SO_REUSEPORT` lets all +threads bind the same TCP/UDP ports — the kernel hashes incoming SYNs +across the listening sockets so connections distribute evenly. + +### Protocol detection happens once per connection + +When bytes first arrive on a plain-TCP listener, a small detector +inspects the first 8+ bytes: + +- starts with `PRI ` → HTTP/2 cleartext (h2c) — route into nghttp2. +- starts with an HTTP/1.1 method byte (`G`, `P`, `D`, `H`, `O`, `C`, `T`) + → HTTP/1.1 — route into the llhttp parser. +- otherwise after 24 bytes → reject as `400 Bad Request` (or h2 + `BAD_CLIENT_MAGIC`). + +For TLS listeners, ALPN does the work during the handshake — the server +advertises `[h2, http/1.1]` and the client picks. ALPN result decides +which strategy is installed; no first-byte sniff happens after the TLS +handshake. + +For UDP listeners (HTTP/3), packets go directly to the QUIC stack; +ALPN inside the QUIC TLS handshake selects `h3` or fails. + +Once the strategy is installed it stays for the lifetime of the +connection — no per-request re-detection. + +### Coroutine per request, not per connection + +The accept loop pulls one connection. The chosen protocol strategy reads +bytes off the socket and assembles requests: + +- HTTP/1.1 — one request at a time, possibly pipelined; for each parsed + request a fresh PHP coroutine is spawned and given `(HttpRequest, + HttpResponse)`. +- HTTP/2 / HTTP/3 — every stream is its own request; each opened stream + spawns a coroutine. Streams on the same connection run truly in + parallel within the event loop, multiplexed onto the wire by nghttp2 / + nghttp3. + +The coroutine runs the user callback. When the callback awaits I/O +(database, file, sleep), the coroutine yields back to the event loop, +which immediately serves another stream / request. There is no +`pthread_create` per request and no thread pool dispatch; coroutines are +stack-switched in userland. + +When the callback returns, `HttpResponse` is committed to the wire +(buffered or streamed depending on whether the handler called `send()`), +the coroutine is disposed, and its arena (`conn_arena`) is reset for the +next request on the same connection. + +### Bailout firewall + +If the user callback hits a fatal (E_ERROR, OOM, exception during +shutdown) and triggers `zend_bailout`, the protocol strategy catches it +at the request boundary: + +- emits a 500 on the failing request, +- logs the PHP cause via SAPI's error pipeline, +- on glibc, dumps the C-level stack via `backtrace(3)` for postmortem, +- keeps the listener and other in-flight requests alive. + +This is what makes a single-process server safe to run user PHP code +that may legitimately fatal — one bad handler doesn't take the listener +down. + +### Compression pipeline + +The response writer transparently compresses bodies that opt in +(`HttpResponse` does not call `setNoCompression()`, MIME is on the +whitelist, body ≥ 1 KiB threshold) when the client's `Accept-Encoding` +allows it. Negotiation is RFC 9110 §12.5.3 (q-values, `identity;q=0`, +`*;q=0`). Encoding runs on streamed chunks, not buffered, so chunked H1 +and H2 DATA frames stay efficient. Inbound `Content-Encoding: gzip` +request bodies are decoded transparently with an anti-zip-bomb cap. The +encoder is zlib-ng when available, system zlib otherwise. + +`entry.php` enables this middleware via +`HttpServerConfig::setCompressionEnabled(true)`, so the `/json/*` +responses are transparently compressed when the client advertises +`Accept-Encoding: br|gzip` — that's what powers the `json-comp` +profile. + +### What `entry.php` actually contains + +A `StaticHandler` mount for `/static/` plus a flat PHP dispatcher: + +```php +$server->addStaticHandler( + (new StaticHandler('/static/', '/data/static')) + ->enablePrecompressed('br', 'gzip') + ->setEtagEnabled(true) + ->setOpenFileCache(1024, 60) +); + +$server->addHttpHandler(static function ($req, $res) use ($dataset, $datasetCount) { + $path = $req->getPath(); + + if ($path === '/baseline11' || $path === '/baseline2') { ... sum ... } + if ($path === '/pipeline') { ... 'ok' ... } + if (str_starts_with($path, '/json/')) { ... slice + json_encode ... } + if ($path === '/upload') { ... awaitBody ... } + /* /static/* is served by StaticHandler above; anything else → 404 */ +}); +``` + +Order is by request frequency under the validation suite; `/baseline11` +goes first because it's the hottest endpoint across `baseline`, +`pipelined`, and `limited-conn` profiles. + +## Listeners (in `entry.php`) + +| Port | Protocol | Used by profile | +|------|----------|----------------| +| 8080 | h1 cleartext | `baseline`, `pipelined`, `limited-conn`, `json`, `upload` | +| 8081 | h1 + TLS | `json-tls` | +| 8443 | h1 + h2 + TLS (ALPN) | `baseline-h2` | + +## Subscribed profiles + +``` +baseline, pipelined, limited-conn, json, json-comp, json-tls, +upload, static, static-h2, baseline-h2 +``` + +All ten pass the HttpArena validation suite (39/39 checks) on the +published image. + +`static` / `static-h2` are served by the server's built-in C +`StaticHandler` (`addStaticHandler` in `entry.php`), which does +sendfile + per-request precompressed sidecar (`.br` / `.gz`) selection +and an open-file cache — no PHP-level buffering. + +`json-comp` uses the server's transparent compression middleware +(`setCompressionEnabled(true)` on the config), which negotiates +brotli / gzip from `Accept-Encoding` automatically. + +## Not yet subscribed (work-in-progress) + +- `baseline-h2c`, `json-h2c` — HttpArena requires port 8082 to refuse + HTTP/1.1, but `protocol_mask` in TrueAsync Server is currently + per-server, not per-listener. Per-listener mask is on the roadmap. +- `async-db`, `crud`, `api-4`, `api-16`, `fortunes` — DB-backed; we ship + a PostgreSQL adapter (`PostgreSQL.php` via `pdo-async` connection + pool) but haven't validated the full suite yet. +- `baseline-h3`, `static-h3`, `gateway-h3` — HTTP/3 listener + (`addHttp3Listener`) is in the server but not yet enabled in + `entry.php`. + +The full feature roadmap lives in +[`FUTURES.md`](https://github.com/true-async/server/blob/main/FUTURES.md) +on the server repo. + +## Running locally + +```bash +./scripts/validate.sh true-async-server +./scripts/benchmark.sh true-async-server baseline-h2 +./scripts/benchmark-lite.sh true-async-server baseline-h2 +``` + +`benchmark-lite.sh` defaults `H2THREADS=nproc/2` so it's friendly to +laptops; `benchmark.sh` is the leaderboard configuration (64 threads on +dedicated hardware). + +## Local development build + +`build.sh` and `Dockerfile.local` exist for testing un-tagged commits of +`true-async/server` against this framework: they copy a host-built +`php` binary and `true_async_server.so` over the upstream alpha image. +Not used in CI. + +## Maintainers + +- [@EdmondDantes](https://github.com/EdmondDantes) diff --git a/frameworks/true-async-server/SQLite.php b/frameworks/true-async-server/SQLite.php new file mode 100644 index 000000000..3ce449180 --- /dev/null +++ b/frameworks/true-async-server/SQLite.php @@ -0,0 +1,64 @@ +prepare( + 'SELECT id, name, category, price, quantity, active, tags, rating_score, rating_count ' + . 'FROM items WHERE price BETWEEN ? AND ? LIMIT 50' + ); + self::$available = true; + } + + public static function query(float $min, float $max, int $limit = 50): string + { + if (!self::$available) { + self::init(); + if (!self::$available) { + return '{"items":[],"count":0}'; + } + } + + self::$stmt->bindValue(1, $min, SQLITE3_FLOAT); + self::$stmt->bindValue(2, $max, SQLITE3_FLOAT); + $result = self::$stmt->execute(); + + $rows = []; + while ($row = $result->fetchArray(SQLITE3_ASSOC)) { + $rows[] = [ + 'id' => $row['id'], + 'name' => $row['name'], + 'category' => $row['category'], + 'price' => $row['price'], + 'quantity' => $row['quantity'], + 'active' => (bool)$row['active'], + 'tags' => json_decode($row['tags'], true), + 'rating' => [ + 'score' => $row['rating_score'], + 'count' => $row['rating_count'], + ], + ]; + } + return json_encode( + ['items' => $rows, 'count' => count($rows)], + JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES + ); + } +} diff --git a/frameworks/true-async-server/docker-compose.yml b/frameworks/true-async-server/docker-compose.yml new file mode 100644 index 000000000..d003ec6e4 --- /dev/null +++ b/frameworks/true-async-server/docker-compose.yml @@ -0,0 +1,55 @@ +services: + postgres: + image: postgres:18 + command: ["-c", "max_connections=256"] + environment: + POSTGRES_USER: bench + POSTGRES_PASSWORD: bench + POSTGRES_DB: benchmark + volumes: + - ${DATA_DIR:-../../data}/pgdb-seed.sql:/docker-entrypoint-initdb.d/seed.sql:ro + healthcheck: + test: ["CMD-SHELL", "pg_isready -U bench -d benchmark && psql -U bench -d benchmark -tAc 'SELECT 1 FROM items LIMIT 1' | grep -q 1"] + interval: 2s + timeout: 10s + retries: 30 + networks: [arena] + + server: + build: + context: . + depends_on: + postgres: + condition: service_healthy + environment: + DATABASE_URL: postgres://bench:bench@postgres:5432/benchmark + DATABASE_MAX_CONN: "256" + volumes: + - ${DATA_DIR:-../../data}/dataset.json:/data/dataset.json:ro + - ${DATA_DIR:-../../data}/benchmark.db:/data/benchmark.db:ro + - ${DATA_DIR:-../../data}/static:/data/static:ro + - ${CERTS_DIR:-../../certs}:/certs:ro + healthcheck: + test: ["CMD", "curl", "-sf", "http://127.0.0.1:8080/pipeline"] + interval: 2s + timeout: 5s + retries: 20 + networks: [arena] + + validator: + build: + context: ./test + depends_on: + server: + condition: service_healthy + environment: + SERVER: server + HTTP_PORT: "8080" + HTTPS_PORT: "8443" + volumes: + - ${CERTS_DIR:-../../certs}:/certs:ro + networks: [arena] + +networks: + arena: + driver: bridge diff --git a/frameworks/true-async-server/entry.php b/frameworks/true-async-server/entry.php new file mode 100644 index 000000000..acac09d51 --- /dev/null +++ b/frameworks/true-async-server/entry.php @@ -0,0 +1,192 @@ +start() on the same + * transferred object; SO_REUSEPORT lets the kernel load-balance + * accept()s across all threads. + * - Each worker has its own PDO connection pool (ext-async PDO::ATTR_POOL_*). + * + * Override worker count with WORKERS env var. + */ + +use TrueAsync\HttpServer; +use TrueAsync\HttpServerConfig; +use TrueAsync\HttpRequest; +use TrueAsync\HttpResponse; +use TrueAsync\StaticHandler; +use Async\ThreadPool; +use function Async\spawn; +use function Async\await_all_or_fail; +use function Async\available_parallelism; + +require __DIR__ . '/PostgreSQL.php'; +require __DIR__ . '/SQLite.php'; + +// --- Preload at process start (read once, transferred to all workers) --- + +$datasetRaw = json_decode(file_get_contents('/data/dataset.json'), true); +$datasetCount = count($datasetRaw); + +$staticDir = '/data/static'; + +// --- Runtime knobs --- + +$port = (int)(getenv('PORT') ?: 8080); +$tlsPort = (int)(getenv('TLS_PORT') ?: 8443); +$workers = (int)(getenv('WORKERS') ?: 0); +if ($workers <= 0) { + $workers = available_parallelism(); +} + +$certPath = '/certs/server.crt'; +$keyPath = '/certs/server.key'; +$tlsAvailable = is_readable($certPath) && is_readable($keyPath); + +// --- Step 1: build the server (one instance, transferred into each thread) --- + +$config = (new HttpServerConfig()) + ->addListener('0.0.0.0', $port) + ->setBacklog(2048) + ->setMaxBodySize(32 * 1024 * 1024) + // Transparent gzip/brotli middleware — needed for the json-comp profile. + ->setCompressionEnabled(true); + +if ($tlsAvailable) { + // 8443: h2 + h1 over TLS (ALPN). 8081: h1 over TLS for the json-tls profile. + $config + ->addListener('0.0.0.0', $tlsPort, true) + ->addListener('0.0.0.0', 8081, true) + ->setCertificate($certPath) + ->setPrivateKey($keyPath); +} + +$server = new HttpServer($config); + +// Static-file delivery from C (sendfile + precompressed sidecar selection). +// Powers the `static` and `static-h2` profiles. +if (is_dir($staticDir)) { + $server->addStaticHandler( + (new StaticHandler('/static/', $staticDir)) + ->enablePrecompressed('br', 'gzip') + ->setEtagEnabled(true) + ->setOpenFileCache(1024, 60) + ); +} + +$server->addHttpHandler( + static function (HttpRequest $request, HttpResponse $response) + use ($datasetRaw, $datasetCount): void + { + $path = $request->getPath(); + + // Hottest endpoint in the suite (baseline + pipelined + limited-conn) — check first. + if ($path === '/baseline11' || $path === '/baseline2') { + $sum = array_sum($request->getQuery()); + if ($request->getMethod() === 'POST') { + $sum += (int)$request->awaitBody()->getBody(); + } + $response->setStatusCode(200) + ->setHeader('Content-Type', 'text/plain') + ->setBody((string)$sum); + return; + } + + if ($path === '/pipeline') { + $response->setStatusCode(200) + ->setHeader('Content-Type', 'text/plain') + ->setBody('ok'); + return; + } + + if (str_starts_with($path, '/json/')) { + $tail = substr($path, 6); + if ($tail !== '' && ctype_digit($tail)) { + $query = $request->getQuery(); + $count = min((int)$tail, $datasetCount); + $mult = (int)($query['m'] ?? 1); + if ($mult === 0) $mult = 1; + $items = []; + for ($i = 0; $i < $count; $i++) { + $item = $datasetRaw[$i]; + $item['total'] = $item['price'] * $item['quantity'] * $mult; + $items[] = $item; + } + $response->setStatusCode(200) + ->setHeader('Content-Type', 'application/json') + ->setBody(json_encode( + ['items' => $items, 'count' => $count], + JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES + )); + return; + } + } + + if ($path === '/upload') { + $response->setStatusCode(200) + ->setHeader('Content-Type', 'text/plain') + ->setBody((string)strlen($request->awaitBody()->getBody())); + return; + } + + if ($path === '/async-db') { + $query = $request->getQuery(); + $min = (float)($query['min'] ?? 10); + $max = (float)($query['max'] ?? 50); + $limit = max(1, min(50, (int)($query['limit'] ?? 50))); + $response->setStatusCode(200) + ->setHeader('Content-Type', 'application/json') + ->setBody(PostgreSQL::query($min, $max, $limit)); + return; + } + + if ($path === '/sqlite-db') { + $query = $request->getQuery(); + $min = (float)($query['min'] ?? 10); + $max = (float)($query['max'] ?? 50); + $limit = max(1, min(50, (int)($query['limit'] ?? 50))); + $response->setStatusCode(200) + ->setHeader('Content-Type', 'application/json') + ->setBody(SQLite::query($min, $max, $limit)); + return; + } + + // /static/* is handled by the StaticHandler registered above; + // anything reaching here under /static/ missed the file → 404. + + $response->setStatusCode(404) + ->setHeader('Content-Type', 'text/plain') + ->setBody('404 Not Found'); + } +); + +// --- Step 2: launch pool, run $server->start() in every thread --- + +fprintf( + STDERR, + "[true-async-server] %d workers · :%d%s · pid %d\n", + $workers, + $port, + $tlsAvailable ? " · tls :{$tlsPort}" : '', + getmypid() +); + +$pool = new ThreadPool($workers); +$futures = []; +for ($i = 0; $i < $workers; $i++) { + // Each worker thread has its own PHP environment — re-require class files. + $futures[] = $pool->submit(static function () use ($server): void { + require __DIR__ . '/PostgreSQL.php'; + require __DIR__ . '/SQLite.php'; + $server->start(); + }); +} + +// Wait until all workers finish (i.e. until the process is stopped). +spawn(static fn() => await_all_or_fail($futures)); diff --git a/frameworks/true-async-server/meta.json b/frameworks/true-async-server/meta.json new file mode 100644 index 000000000..8e5d97e9a --- /dev/null +++ b/frameworks/true-async-server/meta.json @@ -0,0 +1,23 @@ +{ + "display_name": "true-async-server", + "language": "PHP", + "type": "tuned", + "engine": "true-async-server", + "description": "Native PHP HTTP server built on the TrueAsync coroutine engine. Each connection runs as a cooperative coroutine; one OS thread per CPU listens via SO_REUSEPORT inside a single PHP process. PDO connection pool, OPcache JIT, no Caddy / FrankenPHP / FastCGI in front.", + "repo": "https://github.com/true-async/server", + "enabled": true, + "maintainers": ["EdmondDantes"], + "tests": [ + "baseline", + "pipelined", + "limited-conn", + "json", + "json-comp", + "json-tls", + "upload", + "static", + "static-h2", + "baseline-h2", + "async-db" + ] +} diff --git a/frameworks/true-async-server/test/Dockerfile b/frameworks/true-async-server/test/Dockerfile new file mode 100644 index 000000000..f92336db7 --- /dev/null +++ b/frameworks/true-async-server/test/Dockerfile @@ -0,0 +1,11 @@ +FROM python:3.12-slim + +# curl with HTTP/2 support (Debian bookworm libcurl includes nghttp2) +RUN apt-get update && apt-get install -y --no-install-recommends \ + curl \ + && rm -rf /var/lib/apt/lists/* + +COPY validate.sh /validate.sh +RUN chmod +x /validate.sh + +ENTRYPOINT ["/validate.sh"] diff --git a/frameworks/true-async-server/test/validate.sh b/frameworks/true-async-server/test/validate.sh new file mode 100644 index 000000000..349dc9dea --- /dev/null +++ b/frameworks/true-async-server/test/validate.sh @@ -0,0 +1,343 @@ +#!/usr/bin/env bash +# Validation suite for true-async-server — runs inside the 'validator' service. +# +# Environment (set by docker-compose.yml): +# SERVER hostname of the server service (default: server) +# HTTP_PORT plain HTTP port (default: 8080) +# HTTPS_PORT TLS port for HTTP/2 + TLS (default: 8443) + +set -uo pipefail # -e intentionally omitted — arithmetic would kill the script + +SERVER="${SERVER:-server}" +HTTP_PORT="${HTTP_PORT:-8080}" +HTTPS_PORT="${HTTPS_PORT:-8443}" + +H1="http://${SERVER}:${HTTP_PORT}" +TLS="https://${SERVER}:${HTTPS_PORT}" + +PASS=0 +FAIL=0 + +# ── helpers ────────────────────────────────────────────────────────────────── + +ok() { echo " PASS [$1]"; PASS=$((PASS + 1)); } +fail() { echo " FAIL [$1]: $2"; FAIL=$((FAIL + 1)); } + +# check