diff --git a/frameworks/mipyre/Dockerfile b/frameworks/mipyre/Dockerfile new file mode 100644 index 0000000000..71288cb06c --- /dev/null +++ b/frameworks/mipyre/Dockerfile @@ -0,0 +1,22 @@ +# mipyre from PyPI: a cp314 manylinux x86_64 wheel, so no Rust toolchain in the +# image and nothing compiled with -C target-cpu=native. pip also looks in +# wheels/ next to this file, so a local build can be checked before its +# release: --build-arg MIPYRE_VERSION=. +FROM python:3.14-slim +WORKDIR /app + +ARG MIPYRE_VERSION=3.0.1 +COPY . ./ +RUN pip install --no-cache-dir --find-links ./wheels "mipyre==${MIPYRE_VERSION}" + +# Arena harness expectations: +# - 8080 plaintext HTTP/1.1 +# - 8081 HTTPS HTTP/1.1 (json-tls, static-tls, 8gbit) +# - 8082 plaintext HTTP/1.1 + h2c prior knowledge (baseline-h2c, json-h2c) +# - 8443 HTTPS HTTP/2 (baseline-h2, static-h2) +# - /certs/server.{crt,key} mounted by harness +# - /data/dataset.json mounted by harness +# - /data/static mounted by harness +# - $DATABASE_URL set by harness when PG profile active +EXPOSE 8080 8081 8082 8443 +CMD ["python3", "launcher.py"] diff --git a/frameworks/mipyre/README.md b/frameworks/mipyre/README.md new file mode 100644 index 0000000000..4e00ba96dc --- /dev/null +++ b/frameworks/mipyre/README.md @@ -0,0 +1,47 @@ +# mipyre — HTTP Arena submission + +mipyre is a Python web framework with a Rust core: hyper + tokio + rustls + +mimalloc, with one pinned thread per physical core and a PEP 684 +sub-interpreter on each, so Python handlers run on every core at once. This +submission exposes every Arena endpoint with standard mipyre decorators — no +special harness-only glue. + +## Feature posture + +| Profile | mipyre feature | +|---|---| +| baseline / pipelined / limited-conn | sub-interpreter route per TPC thread, `SO_REUSEPORT` listener per thread; `/pipeline` is a pre-built response (`add_fast_response`) | +| json-comp | Rust-side serialization of the returned dict; `app.enable_compression()` negotiates `Accept-Encoding` | +| json-tls | rustls, ALPN `h2,http/1.1` | +| baseline-h2 / static-h2 | the TLS listener on :8443; HTTP/2 on negotiated ALPN | +| baseline-h2c / json-h2c | port 8082 is one more plain listener; a plain listener answers h2 prior knowledge | +| 8gbit | plain sub-interpreter route: the engine reads the body (Content-Length or chunked), the handler returns `Response(req.body)` | +| static-tls / static-h2 | `app.static(..., precompressed=True)`: `.br` / `.gz` twins on disk, cache that follows the disk | +| async | `async def` route + `asyncio.sleep` on its worker's asyncio loop | +| async-db / fortunes | `mipyre.db.PgPool` (sqlx) on the run's DB runtime; a worker waits with its GIL released | +| unary-grpc / unary-grpc-tls | `app.enable_grpc_benchmark()`: `GetSum` answered in Rust | +| latency-500k-8cpu | one TPC thread per physical core of the cpuset (4 on `0-3,64-67`) | +| echo-ws | `@app.websocket("/ws")` echo; **not subscribed** (`meta.json`): each connection holds an OS thread on the main interpreter | + +## Build (local) + +The image installs the released wheel from PyPI (`mipyre==3.0.1`, cp314 +manylinux x86_64); there is no source tree or Rust toolchain in the build. + +```bash +docker build -t httparena-mipyre frameworks/mipyre +``` + +## Design notes + +`launcher.py` starts one process serving every port, and gives the engine its +settings as `MIPYRE_*` variables (threads, host, ports, TLS files). The engine +refuses a `MIPYRE_*` name it does not read, so a renamed setting stops the +start instead of running on defaults. It sizes the server to one thread per +**physical** core of the container's cpuset (32 on the Arena's `0-31,64-95`): +two threads on SMT siblings of one core run the same code path and contend for +its L1/L2. + +The Postgres pool belongs to the server run, not to a Python interpreter: +every worker's `PgPool.connect()` binds to the run's one pool, so a 32-thread +deployment doesn't multiply connection count. diff --git a/frameworks/mipyre/app.py b/frameworks/mipyre/app.py new file mode 100644 index 0000000000..088e87f696 --- /dev/null +++ b/frameworks/mipyre/app.py @@ -0,0 +1,302 @@ +"""mipyre — HTTP Arena submission. + +Exposes the endpoints the Arena profiles in meta.json drive (and /ws for +echo-ws, not subscribed yet), with the payloads of the Actix / FastAPI +reference implementations. Every route runs on a sub-interpreter worker (one +per TPC thread); an `async def` route runs on its worker's asyncio loop. +Ports, threads and TLS come from the MIPYRE_* settings launcher.py gives. +""" + +import asyncio +import html +import json +import logging +import os +from operator import itemgetter + +from mipyre import Mipyre, Request, Response +from mipyre.db import PgPool + +# Log benchmark-path errors at WARNING so they surface in the runner log +# but don't flood the tracing subscriber under load. Every broad-except +# site below calls log.warning(..., exc_info=True) so the stack trace is +# preserved instead of silently swallowed — swallowing a traceback to +# hand a 404 / 400 / {} back has been a regular source of "why is +# throughput suddenly tanking?" debugging evenings elsewhere. +log = logging.getLogger("mipyre.arena") + + +# --------------------------------------------------------------------------- +# Dataset (loaded once at process start) +# --------------------------------------------------------------------------- + +DATASET_PATH = os.environ.get("DATASET_PATH", "/data/dataset.json") +try: + with open(DATASET_PATH) as f: + DATASET_ITEMS = json.load(f) +except Exception: + # Served empty (the /json endpoints answer with no items), and said so in the log. + log.warning("dataset %s not loaded; serving it empty", DATASET_PATH, exc_info=True) + DATASET_ITEMS = [] + + +# --------------------------------------------------------------------------- +# Postgres pool (one per server run, shared by every worker) +# --------------------------------------------------------------------------- + +PG_POOL = None +DATABASE_URL = os.environ.get("DATABASE_URL") +if DATABASE_URL: + try: + # Arena harness sets DATABASE_MAX_CONN to the total budget; we follow + # the Actix convention of using the whole pool size on the Rust side. + max_conn = int(os.environ.get("DATABASE_MAX_CONN", "256")) + PG_POOL = PgPool.connect(DATABASE_URL, max_connections=max_conn) + except Exception: + # Run without the database (each db endpoint answers its no-pool response), and say so in the log. + log.warning("postgres pool not connected; db endpoints answer without it", exc_info=True) + PG_POOL = None + + +# --------------------------------------------------------------------------- +# App +# --------------------------------------------------------------------------- + +app = Mipyre() +# The in-out profile posts 100 KB and validation goes no higher, but the +# engine default of 2.5 MiB is left raised so an oversized body is answered +# rather than 413'd. +app.max_body_size = 25 * 1024 * 1024 +# min_size=256 skips tiny bodies (/pipeline "ok", short query params). +# Arena's json-comp rotates through /json/1..50 with pipeline depth 25 +# and hundreds of connections — throughput is bounded by compression +# CPU, so pick the cheapest settings that still compress meaningfully: +# gzip level=1 (fastest) wins ~2x speed over level=6 at ~15% worse ratio, +# and brotli quality=0 is the brotli equivalent. +app.enable_compression(min_size=256, brotli_quality=0, gzip_level=1) + +# Static serving — Arena harness mounts /data/static/ (static-h2 on :8443, +# static-tls on :8081: the same mount serves every port). `precompressed=True` +# answers from the .br/.gz twins on disk when Accept-Encoding allows; the +# engine's cache follows the disk. +# STATIC_DIR only exists so the app can be checked outside the container. +app.static("/static", os.environ.get("STATIC_DIR", "/data/static"), precompressed=True) + +# echo-ws (not subscribed in meta.json) runs up to 16,384 connections, so the +# default cap of 1024 would answer every connection past it with 503. +app.max_websocket_connections = 16384 + 1024 + +# unary-grpc / unary-grpc-tls: benchmark.BenchmarkService/GetSum, answered by +# the engine in Rust. Opt-in (without it the path is routed like any other +# request and 404s here). +app.enable_grpc_benchmark() + + +# /pipeline is answered from the accept loop with a pre-built response; no +# Python runs for it. +app.add_fast_response("GET", "/pipeline", b"ok", content_type="text/plain") + + +def _sum_ab(req) -> int: + # The baseline profiles send `a` and `b`. `query_param` reads one key off + # the query string; `query_params` would build a dict per request. + total = 0 + for v in (req.query_param("a"), req.query_param("b")): + if v is not None: + try: + total += int(v) + except ValueError: + pass + return total + + +# A `str` return is sent as `text/plain; charset=utf-8`, which the baseline +# validation accepts (text/plain, bare or with a charset); no Response object +# per request. +@app.get("/baseline11") +def baseline11_get(req: "Request"): + return str(_sum_ab(req)) + + +@app.post("/baseline11") +def baseline11_post(req: "Request"): + total = _sum_ab(req) + body = req.body + if body: + try: + total += int(body) # int() takes ASCII digits as bytes, whitespace stripped + except ValueError: + pass + return str(total) + + +@app.get("/baseline2") +def baseline2(req: "Request"): + return str(_sum_ab(req)) + + +# 8gbit: an ordinary sub-interpreter route. The engine reads the whole body +# (Content-Length or chunked) before the handler runs, and the handler hands +# those bytes back. application/octet-stream is never compressed. +@app.post("/echo") +def echo_body(req: "Request"): + return Response(req.body, content_type="application/octet-stream") + + +# async (GET /delay/{ms}): an `async def` route runs on its worker's asyncio +# loop, so a pending sleep holds a timer, not a thread; the TPC threads keep +# serving while 32,000 of these wait. The delay is read from the path on every +# request; 0 answers at once. +@app.get("/delay/{ms}") +async def delay(req: "Request"): + try: + ms = int(req.params["ms"]) + except ValueError: + return _BAD_REQUEST + if ms < 0: + return _BAD_REQUEST + if ms: + await asyncio.sleep(ms / 1000) + return str(ms) + + +# echo-ws: GET /ws upgrades, every message comes back as it came (text as +# text, binary as binary) until the client closes. A plain GET /ws without the +# upgrade headers is not routed here and gets 404. +@app.websocket("/ws") +async def ws_echo(ws): + async for msg in ws: + try: + if msg.__class__ is str: + await ws.send(msg) + else: + await ws.send_bytes(msg) + except ConnectionError: + # The client closed while its echo was being queued: nothing left to + # answer, and the connection's end is not an error of this handler. + return + + +# Same payload shape + multiplier semantics as Actix/FastAPI reference: +# take the first `count` dataset items, set `total = price * quantity * m`, +# return {"items": [...], "count": N}. +# +# Returning the dict lets mipyre serialize it in Rust (pythonize + +# serde_json) on every request; nothing is cached by URL or parameters +# (HttpArena's tuned rules forbid response caching keyed by URL/params). +@app.get("/json/{count}") +def json_endpoint(req: "Request"): + try: + count = int(req.params["count"]) + except (KeyError, ValueError): + return {"items": [], "count": 0} + try: + m = int(req.query_param("m") or "1") + except ValueError: + m = 1 + count = max(0, min(count, len(DATASET_ITEMS))) + items = [ + {**dsitem, "total": dsitem["price"] * dsitem["quantity"] * m} + for dsitem in DATASET_ITEMS[:count] + ] + return {"items": items, "count": count} + + +# Async DB — mirrors Actix's query against the items table. Each worker +# thread waits for its own fetch with its GIL released; the run's DB runtime +# drives all of them concurrently. +PG_SQL = ( + "SELECT id, name, category, price, quantity, active, tags, " + "rating_score, rating_count " + "FROM items WHERE price BETWEEN $1 AND $2 LIMIT $3" +) + + +@app.get("/async-db") +def async_db_endpoint(req: "Request"): + if PG_POOL is None: + return _EMPTY_DB_RESPONSE + q = req.query_params + try: + min_val = int(q.get("min", "10")) + max_val = int(q.get("max", "50")) + limit = int(q.get("limit", "50")) + limit = max(1, min(limit, 50)) + except ValueError: + log.warning("/async-db: bad query params %r", dict(q), exc_info=True) + return _EMPTY_DB_RESPONSE + try: + rows = PG_POOL.fetch_all(PG_SQL, min_val, max_val, limit) + except RuntimeError: + # A failed query raises DatabaseError, a RuntimeError; keep the + # empty-response contract Arena expects, but don't lose the trace. + log.warning("/async-db: fetch_all failed", exc_info=True) + return _EMPTY_DB_RESPONSE + return _rows_to_payload(rows) + + +def _rows_to_payload(rows): + # Hot loop — shaves ~30% per-row Python overhead by reading each + # column exactly once and skipping the `isinstance(tags, str)` check + # when PG already returned jsonb as dict/list (the common path). + items = [] + append = items.append + for row in rows: + tags = row["tags"] + if tags.__class__ is str: + tags = json.loads(tags) + append({ + "id": row["id"], + "name": row["name"], + "category": row["category"], + "price": row["price"], + "quantity": row["quantity"], + "active": row["active"], + "tags": tags, + "rating": { + "score": row["rating_score"], + "count": row["rating_count"], + }, + }) + return {"items": items, "count": len(items)} + + +# fortunes (GET /fortunes): every request queries the 200 rows, appends the +# runtime row, sorts by message (str order is code point order, which is the +# UTF-8 byte order the profile asks for) and renders the page. Tuned mode allows +# a hand-written emitter; every message goes through html.escape (& < > " '). +# Only the fixed layout around the rows is a constant. +_FORTUNES_SQL = "SELECT id, message FROM fortune" +_FORTUNE_RUNTIME_ROW = (0, "Additional fortune added at request time.") +_FORTUNES_HEAD = ( + "Fortunes" + "" +) +_FORTUNES_TAIL = "
idmessage
" + + +@app.get("/fortunes") +def fortunes(req: "Request"): + if PG_POOL is None: + return _NO_DATABASE + rows = [(r["id"], r["message"]) for r in PG_POOL.fetch_all(_FORTUNES_SQL)] + rows.append(_FORTUNE_RUNTIME_ROW) + rows.sort(key=itemgetter(1)) + escape = html.escape + parts = [_FORTUNES_HEAD] + append = parts.append + for fid, message in rows: + append(f"{fid}{escape(message)}") + append(_FORTUNES_TAIL) + return Response("".join(parts), content_type="text/html; charset=utf-8") + + +_EMPTY_DB_RESPONSE = {"items": [], "count": 0} +_NO_DATABASE = Response( + "DATABASE_URL is not set", status_code=503, content_type="text/plain" +) +_BAD_REQUEST = Response("bad request", status_code=400, content_type="text/plain") + + +if __name__ == "__main__": + app.run() diff --git a/frameworks/mipyre/launcher.py b/frameworks/mipyre/launcher.py new file mode 100644 index 0000000000..999a6ef67a --- /dev/null +++ b/frameworks/mipyre/launcher.py @@ -0,0 +1,183 @@ +"""Launcher — runs ONE mipyre process serving every port. + +Plain HTTP on $PORT (default 8080) and on 8082 (baseline-h2c / json-h2c: a +plain listener answers HTTP/1.1 and HTTP/2 with prior knowledge). When TLS +certs are present, HTTPS is also served on $PORT+1 (json-tls / static-tls / +8gbit) and 8443 (baseline-h2 / static-h2). Every setting reaches the engine as +a MIPYRE_* variable (`server_env`); the engine refuses a MIPYRE_* name it does +not read, so a misspelt or renamed setting stops the start instead of being +ignored. + +Each TPC thread creates its own SO_REUSEPORT socket on every port, so all +cores serve all profiles simultaneously. +""" + +import os +import signal +import subprocess +import sys +import threading +import time +from dataclasses import dataclass + + +# baseline-h2c / json-h2c: HttpArena's fixed cleartext HTTP/2 port. +H2C_PORT = 8082 +# baseline-h2 / static-h2: HttpArena's fixed TLS HTTP/2 port. +H2_TLS_PORT = 8443 + + +@dataclass(frozen=True) +class TlsFiles: + cert: str + key: str + + +def server_env(threads: int, port: int, tls: TlsFiles | None) -> dict[str, str]: + """Return the engine settings for this container, by environment variable.""" + env = { + "MIPYRE_THREADS": str(threads), + "MIPYRE_HOST": "0.0.0.0", + "MIPYRE_PORT": str(port), + "MIPYRE_EXTRA_PLAIN_PORTS": str(H2C_PORT), + # The async profile holds up to 32000 requests at once; the default + # queue (128 per async worker) answers the rest 503. + "MIPYRE_ASYNC_QUEUE_CAPACITY": "40000", + } + if tls is None: + return env + return env | { + "MIPYRE_TLS_CERT": tls.cert, + "MIPYRE_TLS_KEY": tls.key, + "MIPYRE_EXTRA_TLS_PORTS": f"{port + 1},{H2_TLS_PORT}", + } + + +def _physical_cores() -> int: + """Physical cores among the CPUs this process may run on (its affinity + mask: the container's cpuset), counting SMT siblings once. + + One TPC thread per physical core is what the engine is built for: two + threads pinned to SMT siblings run the same code path and fight over one + core's L1/L2 (measured -50% per thread on a 7840HS). sysfs lists every CPU + of the host whatever the cpuset (64 cores for HttpArena's 0-31,64-95 of a + 64C/128T host), so the count starts from the affinity mask. + """ + try: + allowed = sorted(os.sched_getaffinity(0)) + except AttributeError: # not Linux + return max(os.cpu_count() or 1, 1) + cores = set() + for cpu in allowed: + path = f"/sys/devices/system/cpu/cpu{cpu}/topology/thread_siblings_list" + try: + with open(path) as f: + cores.add(f.read().strip()) + except OSError: + cores.add(f"cpu{cpu}") # topology unknown: count the CPU as its own core + return max(len(cores), 1) + + +def _parse_port(raw: str) -> int: + """Parse the PORT env var, failing with a clear message instead of a + raw ValueError traceback from int().""" + try: + port = int(raw) + except (TypeError, ValueError): + raise SystemExit(f"invalid PORT: {raw!r} is not an integer") + if not (1 <= port <= 65535): + raise SystemExit(f"invalid PORT: {port} out of range 1-65535") + return port + + +def main() -> int: + base_port = _parse_port(os.environ.get("PORT", "8080")) + tls_cert = os.environ.get("TLS_CERT", "/certs/server.crt") + tls_key = os.environ.get("TLS_KEY", "/certs/server.key") + have_tls = os.path.exists(tls_cert) and os.path.exists(tls_key) + + tls = None + if have_tls: + if base_port + 1 > 65535: + raise SystemExit( + f"invalid TLS port: PORT={base_port} leaves no room for the " + f"TLS companion port {base_port + 1} (max 65535); use PORT<=65534" + ) + tls = TlsFiles(tls_cert, tls_key) + # One TPC thread per physical core (32 on HttpArena's cpuset), not one per + # logical CPU: two threads on SMT siblings of one core fight over its L1/L2. + env = os.environ | server_env(_physical_cores(), base_port, tls) + + try: + proc = subprocess.Popen(["python3", "app.py"], env=env) + except FileNotFoundError: + raise SystemExit("launcher: 'python3' not found on PATH") + except OSError as exc: + raise SystemExit(f"launcher: failed to start 'python3 app.py': {exc}") + + # Guard so repeated SIGTERM/SIGINT don't each spawn a cleanup thread, + # all racing on terminate()/kill() and os._exit. + _shutting_down = threading.Event() + + def shutdown(_sig, _frame): + # Signal handlers must not block — offload the wait+kill to a thread. + # First signal wins; later signals are no-ops until exit. + if _shutting_down.is_set(): + return + _shutting_down.set() + + def _cleanup(): + import logging as _log + try: + proc.terminate() + except Exception: + _log.warning("launcher: terminate failed for pid %s", proc.pid, exc_info=True) + # give it a moment to drain gracefully; mipyre's graceful + # shutdown waits up to 30s for in-flight conns — Arena harness + # typically SIGKILLs the container anyway, but polite is polite. + time.sleep(1) + if proc.poll() is None: + try: + proc.kill() + except Exception: + _log.warning("launcher: kill failed for pid %s", proc.pid, exc_info=True) + # Propagate the child's real exit code rather than masking every + # shutdown as success. If the child already exited non-zero (e.g. + # a benchmark crash that itself raised the signal), os._exit(0) + # would hide that failure from CI. + # SIGKILL is delivered asynchronously, so poll() can still return + # None right after kill() — wait() blocks until the process is + # actually reaped and yields the true exit code. Bounded so a wedged child can't hang shutdown. + try: + rc = proc.wait(timeout=10) + except subprocess.TimeoutExpired: + # Child still wedged after SIGKILL + 10s — give up waiting and + # fall back to a non-blocking poll (likely still None). + rc = proc.poll() + except Exception: + # OSError or other unexpected failures from wait() — log so the + # cause is visible rather than silently masked. + _log.warning("launcher: wait() failed during shutdown", exc_info=True) + rc = proc.poll() + # os._exit terminates all threads (including this daemon thread); + # sys.exit(0) from a daemon thread only kills the daemon thread. + os._exit(1 if rc is None else (128 + abs(rc) if rc < 0 else rc)) + threading.Thread(target=_cleanup, daemon=True).start() + + signal.signal(signal.SIGTERM, shutdown) + signal.signal(signal.SIGINT, shutdown) + + import logging as _log + try: + rc = proc.wait() + if rc != 0: + _log.warning("launcher: process exited with code %d", rc) + # Propagate child rc so CI/CD pipelines see real failures. + return rc + except Exception: + _log.warning("launcher: wait() failed", exc_info=True) + return 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/frameworks/mipyre/meta.json b/frameworks/mipyre/meta.json new file mode 100644 index 0000000000..6948757ce4 --- /dev/null +++ b/frameworks/mipyre/meta.json @@ -0,0 +1,38 @@ +{ + "display_name": "mipyre", + "language": "Python", + "type": "experimental", + "mode": "tuned", + "completeness": { + "routing": true, + "middleware": true, + "request": true, + "response": true + }, + "engine": "mipyre", + "description": "mipyre — Python web framework with a Rust core (hyper + tokio + rustls + mimalloc): one pinned thread per physical core, each with its own PEP 684 sub-interpreter, so Python handlers run on every core at once. Opt-in features: gzip/brotli compression, rustls TLS with h2/h1 ALPN, precompressed static files, async Postgres via sqlx. Handlers are standard Python functions routed via decorators.", + "repo": "https://github.com/leocaolab/mipyre", + "enabled": true, + "tests": [ + "baseline", + "latency-1m", + "latency-10k", + "latency-500k-8cpu", + "pipelined", + "limited-conn", + "json-comp", + "json-tls", + "8gbit", + "static-tls", + "async", + "async-db", + "fortunes", + "baseline-h2", + "static-h2", + "baseline-h2c", + "json-h2c", + "unary-grpc", + "unary-grpc-tls" + ], + "maintainers": [] +} diff --git a/frameworks/pyronova/Dockerfile b/frameworks/pyronova/Dockerfile deleted file mode 100644 index 8d837bd0f9..0000000000 --- a/frameworks/pyronova/Dockerfile +++ /dev/null @@ -1,42 +0,0 @@ -FROM python:3.13-slim AS build -WORKDIR /build - -# Rust toolchain + maturin for the Pyronova engine build. -RUN apt-get update && apt-get install -y --no-install-recommends \ - curl ca-certificates build-essential pkg-config libssl-dev git \ - && rm -rf /var/lib/apt/lists/* -RUN curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | \ - sh -s -- -y --default-toolchain stable --profile minimal -ENV PATH="/root/.cargo/bin:${PATH}" -RUN pip install --no-cache-dir maturin - -# Pinned release — bumping PYRONOVA_REF invalidates the cache below and -# rebuilds from that tag. The full source ships at github, not in this -# PR, so the PR stays small and the build is byte-for-byte reproducible -# against a signed git ref. -ARG PYRONOVA_REF=v2.4.2 -RUN git clone --depth 1 --branch ${PYRONOVA_REF} \ - https://github.com/leocaolab/pyronova.git /build/pyronova -RUN cd /build/pyronova \ - && RUSTFLAGS="-C target-cpu=native" \ - maturin build --release --out /wheels --compatibility linux - - -FROM python:3.13-slim -WORKDIR /app - -COPY --from=build /wheels /tmp/wheels -RUN pip install --no-cache-dir /tmp/wheels/*.whl && rm -rf /tmp/wheels - -COPY app.py launcher.py ./ - -# Arena harness expectations: -# - 8080 plaintext HTTP/1.1 -# - 8081 HTTPS HTTP/1.1 (json-tls profile) -# - 8443 HTTPS HTTP/2 (baseline-h2, static-h2) -# - /certs/server.{crt,key} mounted by harness -# - /data/dataset.json mounted by harness -# - /data/static mounted by harness -# - $DATABASE_URL set by harness when PG profile active -EXPOSE 8080 8081 8443 -CMD ["python3", "launcher.py"] \ No newline at end of file diff --git a/frameworks/pyronova/README.md b/frameworks/pyronova/README.md deleted file mode 100644 index 3d31194fd2..0000000000 --- a/frameworks/pyronova/README.md +++ /dev/null @@ -1,55 +0,0 @@ -# Pyronova — HTTP Arena submission - -Pyronova is a Python web framework with a Rust core: hyper + tokio + rustls + -mimalloc, plus PEP 684 sub-interpreters for true multi-core Python. This -submission exposes every Arena endpoint with standard Pyronova decorators — -no special harness-only glue. - -## Feature posture - -| Profile | Pyronova feature | -|---|---| -| baseline / pipelined / limited-conn | sub-interpreter dispatch, mimalloc, SO_REUSEPORT | -| json / json-comp | Rust-side `serde_json` via `pythonize`; `app.enable_compression()` handles `Accept-Encoding` | -| json-tls | rustls 0.23 (ring), ALPN `h2,http/1.1` | -| baseline-h2 / static-h2 | same TLS listener on :8443; hyper's `AutoBuilder` picks HTTP/2 on negotiated ALPN | -| 8gbit | `@app.post(stream=True)` — chunked body ingest via tokio feeder → mpsc channel, collected and echoed | -| async-db | `PgPool` backed by sqlx on a dedicated tokio runtime, shared across workers via Rust-side `OnceLock` | -| static | Tokio async-fs with `O_NOFOLLOW` + mime-from-extension | - -Everything is compiled in and runtime-toggled. A single `pip install -pyronova` covers every profile; no Cargo features or rebuilds. - -## Build (local) - -Populate the Pyronova source into the build context, then build: - -```bash -cp -r /path/to/pyronova frameworks/pyronova/pyronova_src -docker build -t httparena-pyronova frameworks/pyronova -``` - -Or let the Arena CI populate `pyronova_src` from the official repo. - -## Run (manual / debug) - -```bash -./scripts/run.sh pyronova -``` - -Exposes 8080 (plain), 8081 (TLS h1), 8443 (TLS h2). Harness probes -`http://localhost:8080/baseline11?a=1&b=1` to confirm readiness. - -## Design notes - -Two processes are spawned by `launcher.py` — one for plaintext, one for -TLS — because Pyronova's `app.run()` binds a single port. Each process -gets half the CPU budget to avoid sub-interpreter oversubscription. -The HTTP/2 listener shares the TLS process (single listener; ALPN -picks the protocol). This is a launcher detail, not a framework -limitation — a follow-up adds multi-bind to the engine so one process -covers all three ports. - -The PgPool is a Rust-side `OnceLock` — not per-Python- -interpreter. All sub-interp workers reach the same pool via the global, -so a 16-interp deployment doesn't multiply connection count. diff --git a/frameworks/pyronova/app.py b/frameworks/pyronova/app.py deleted file mode 100644 index 964c6f516d..0000000000 --- a/frameworks/pyronova/app.py +++ /dev/null @@ -1,475 +0,0 @@ -"""Pyronova framework — HTTP Arena submission. - -Exposes the eight endpoints required by the Arena test harness, mirroring -the Actix / FastAPI reference implementations for semantic parity. -Route-by-route behavior is identical so head-to-head numbers are -apples-to-apples; what varies is the server engine underneath. - -Pyronova specifics used here: - * `Pyronova()` — sub-interpreter-backed app (PEP 684), auto-dual pool - * `app.enable_compression()` — gzip/brotli Accept-Encoding negotiation - * `@app.post(stream=True)` — chunked body ingest without buffering - * `pyronova.db.PgPool` — async sqlx-backed PG pool shared across - workers via Rust-side OnceLock - * `app.static(prefix, dir)` — async-fs-served static files - * TLS via `PYRONOVA_TLS_CERT` / `PYRONOVA_TLS_KEY` env (launcher picks up) - -The app is ~120 lines because the Rust engine does the heavy lifting — -no boilerplate for workers, TLS, or compression. -""" - -import json -import logging -import os - -from pyronova import Pyronova, Request, Response -from pyronova.db import PgPool - -# Log benchmark-path errors at WARNING so they surface in the runner log -# but don't flood the tracing subscriber under load. Every broad-except -# site below calls log.warning(..., exc_info=True) so the stack trace is -# preserved instead of silently swallowed — swallowing a traceback to -# hand a 404 / 400 / {} back has been a regular source of "why is -# throughput suddenly tanking?" debugging evenings elsewhere. -log = logging.getLogger("pyronova.arena") - - -# --------------------------------------------------------------------------- -# Dataset (loaded once at process start) -# --------------------------------------------------------------------------- - -DATASET_PATH = os.environ.get("DATASET_PATH", "/data/dataset.json") -try: - with open(DATASET_PATH) as f: - DATASET_ITEMS = json.load(f) -except Exception: - DATASET_ITEMS = [] - - -# --------------------------------------------------------------------------- -# Postgres pool (Rust-side OnceLock shared by all workers) -# --------------------------------------------------------------------------- - -PG_POOL = None -DATABASE_URL = os.environ.get("DATABASE_URL") -if DATABASE_URL: - try: - # Arena harness sets DATABASE_MAX_CONN to the total budget; we follow - # the Actix convention of using the whole pool size on the Rust side. - max_conn = int(os.environ.get("DATABASE_MAX_CONN", "256")) - PG_POOL = PgPool.connect(DATABASE_URL, max_connections=max_conn) - except Exception: - PG_POOL = None - - -# --------------------------------------------------------------------------- -# App -# --------------------------------------------------------------------------- - -app = Pyronova() -# The in-out profile posts 100 KB and validation goes no higher, but the -# engine default of 10 MiB is left raised so an oversized body is answered -# rather than 413'd. -app.max_body_size = 25 * 1024 * 1024 -# min_size=256 skips tiny bodies (/pipeline "ok", short query params). -# Arena's json-comp rotates through /json/1..50 with pipeline depth 25 -# and hundreds of connections — throughput is bounded by compression -# CPU, so pick the cheapest settings that still compress meaningfully: -# gzip level=1 (fastest) wins ~2x speed over level=6 at ~15% worse ratio, -# and brotli quality=0 is the brotli equivalent. -app.enable_compression(min_size=256, brotli_quality=0, gzip_level=1) - -# Static serving — Arena harness populates /data/static/. -app.static("/static", "/data/static") - - -# Fast-path /pipeline: served directly from the Rust accept loop with -# zero Python dispatch (GIL, sub-interp, handler call — all skipped). -# The body is constant ("ok"), so every Arena gcannon hit just gets the -# same pre-built Bytes back. This is what `add_fast_response` is for — -# health-checks, robots.txt, static probe endpoints. -# -# Doesn't affect any other route. Dynamic handlers keep their normal -# Python dispatch path. Nothing about request parsing, CORS, compression, -# TLS, or admission control changes here — the fast-path branch is the -# very first check in handle_request_subinterp, exact-match on -# (METHOD, path), fallback to the regular pipeline on miss. -app.add_fast_response("GET", "/pipeline", b"ok", content_type="text/plain") - - -def _sum_query_params(req) -> int: - total = 0 - for v in req.query_params.values(): - try: - total += int(v) - except ValueError: - pass - return total - - -@app.get("/baseline11") -def baseline11_get(req: "Request"): - return Response(str(_sum_query_params(req)), content_type="text/plain") - - -@app.post("/baseline11") -def baseline11_post(req: "Request"): - total = _sum_query_params(req) - body = req.body - if body: - try: - total += int(body.decode("ascii", errors="strict").strip()) - except (ValueError, UnicodeDecodeError): - pass - return Response(str(total), content_type="text/plain") - - -@app.get("/baseline2") -def baseline2(req: "Request"): - return Response(str(_sum_query_params(req)), content_type="text/plain") - - -@app.post("/echo", gil=True, stream=True) -def echo_body(req: "Request"): - # The echo needs the bytes, not a count, so drain_count() cannot be used - # here: the body is collected and handed straight back. Collected rather - # than streamed through because the response needs a Content-Length, and a - # chunked request has none to forward until the body is in. - chunks = [chunk for chunk in req.stream] - return Response(b"".join(chunks), content_type="application/octet-stream") - - -# Same payload shape + multiplier semantics as Actix/FastAPI reference: -# take the first `count` dataset items, set `total = price * quantity * m`, -# return {"items": [...], "count": N}. -# -# We return a plain Python dict rather than a pre-serialized bytes body. -# Pyronova's Rust response path detects dict/list returns and serializes via -# `pythonize + serde_json::to_vec` — native Rust JSON (~30μs for a -# 50-item payload). Using Python's stdlib `json.dumps` instead costs -# ~150μs per call on the same data. Returning the dict shaves ~100μs -# per request on the /json profile. -# Per-worker cache of pre-serialized JSON bytes for /json/{count}?m={m}. -# Eliminates both the Python list comprehension and the Rust sonic-rs -# serialization pass on cache hits. Each sub-interpreter has its own copy -# so there is no lock contention. Cap at _JSON_CACHE_MAX keys to bound -# memory and close the OOM vector from unbounded user-controlled inputs. -_JSON_CACHE: dict = {} # (count, m) -> bytes -_JSON_CACHE_MAX = 256 - - -@app.get("/json/{count}") -def json_endpoint(req: "Request"): - try: - count = int(req.params["count"]) - except (KeyError, ValueError): - return Response(b'{"items":[],"count":0}', content_type="application/json") - try: - m = int(req.query_params.get("m", "1")) - except ValueError: - m = 1 - count = max(0, min(count, len(DATASET_ITEMS))) - key = (count, m) - cached = _JSON_CACHE.get(key) - if cached is not None: - # Always wrap in a fresh Response — never share a mutable Python - # object across requests, even if the underlying bytes are the same. - return Response(cached, content_type="application/json") - items = [ - {**dsitem, "total": dsitem["price"] * dsitem["quantity"] * m} - for dsitem in DATASET_ITEMS[:count] - ] - body = json.dumps({"items": items, "count": count}, separators=(",", ":")).encode() - if len(_JSON_CACHE) < _JSON_CACHE_MAX: - _JSON_CACHE[key] = body - return Response(body, content_type="application/json") - - -@app.get("/json-comp/{count}") -def json_comp_endpoint(req: "Request"): - # Identical payload; Arena's json-comp profile hits /json/{count} in - # practice (see benchmark-15), but we keep this alias registered for - # legacy URL shape compatibility. - return json_endpoint(req) - - -# Async DB — mirrors Actix's query against the items table. PgPool is a -# process-wide handle; each worker thread blocks on its own fetch but the -# pool-side tokio runtime drives all of them concurrently. -PG_SQL = ( - "SELECT id, name, category, price, quantity, active, tags, " - "rating_score, rating_count " - "FROM items WHERE price BETWEEN $1 AND $2 LIMIT $3" -) - - -@app.get("/async-db") -def async_db_endpoint(req: "Request"): - # Runs on a sub-interpreter worker. `PG_POOL` here is the mock - # `_PgPool` proxy installed by `_bootstrap.py` — calls forward to - # four C-FFI entry points (`_pyronova_db_fetch_{all,one,scalar}`, - # `_pyronova_db_execute`) that Rust injected into each worker's - # globals at startup. Those entry points queue the sqlx future - # onto the dedicated DB tokio runtime via `rt.spawn + channel` - # (see `src/bridge/db_bridge.rs::run_on_db_rt`) and block the - # sub-interp thread until the result lands — no nested runtime, - # no main-interp GIL bottleneck, parallelism ceiling is - # `min(sub_interp_workers, DATABASE_MAX_CONN)`. - if PG_POOL is None: - return _EMPTY_DB_RESPONSE - q = req.query_params - try: - min_val = int(q.get("min", "10")) - max_val = int(q.get("max", "50")) - limit = int(q.get("limit", "50")) - limit = max(1, min(limit, 50)) - except ValueError: - log.warning("/async-db: bad query params %r", dict(q), exc_info=True) - return _EMPTY_DB_RESPONSE - try: - rows = PG_POOL.fetch_all(PG_SQL, min_val, max_val, limit) - except RuntimeError: - # pyronova.db raises RuntimeError for sqlx failures; keep the - # empty-response contract Arena expects, but don't lose the trace. - log.warning("/async-db: fetch_all failed", exc_info=True) - return _EMPTY_DB_RESPONSE - return _rows_to_payload(rows) - - -def _rows_to_payload(rows): - # Hot loop — shaves ~30% per-row Python overhead by reading each - # column exactly once and skipping the `isinstance(tags, str)` check - # when PG already returned jsonb as dict/list (the common path). - items = [] - append = items.append - for row in rows: - tags = row["tags"] - if tags.__class__ is str: - tags = json.loads(tags) - append({ - "id": row["id"], - "name": row["name"], - "category": row["category"], - "price": row["price"], - "quantity": row["quantity"], - "active": row["active"], - "tags": tags, - "rating": { - "score": row["rating_score"], - "count": row["rating_count"], - }, - }) - return {"items": items, "count": len(items)} - - -_EMPTY_DB_RESPONSE = {"items": [], "count": 0} -_NOT_FOUND = Response("not found", status_code=404, content_type="text/plain") -_BAD_REQUEST = Response("bad request", status_code=400, content_type="text/plain") -_INTERNAL_ERROR = Response("internal server error", status_code=500, content_type="text/plain") - - -# --------------------------------------------------------------------------- -# CRUD — paths mirror Arena's aspnet-minimal reference: -# GET /crud/items?category=X&page=N&limit=M paginated list -# GET /crud/items/{id} single item (200ms cache) -# POST /crud/items upsert, returns 201 -# PUT /crud/items/{id} update, invalidates cache -# -# Cache is an in-process dict per sub-interpreter. Arena's aspnet impl -# uses IMemoryCache (same semantics). `gil=True` on every handler for -# the same reason /async-db needs it — our PgPool lives behind a -# Rust-side OnceLock populated by the main interpreter's module-import. -# --------------------------------------------------------------------------- - -import time as _time - -_CRUD_TTL_S = 0.2 -# _CRUD_CACHE is a bare dict because every handler below runs with -# `gil=True` on Pyronova's main interpreter — only one handler thread -# executes at a time, so dict get/set/pop are atomic under the GIL and -# no lock is needed. If a handler is ever demoted off the main interp -# this dict becomes a race; wrap it in threading.Lock or flip to -# threading.local at that point. -_CRUD_CACHE: dict = {} # item_id -> (payload_dict, expires_at_monotonic) - -_CRUD_COLS = ( - "id, name, category, price, quantity, active, tags, " - "rating_score, rating_count" -) -_CRUD_GET_SQL = f"SELECT {_CRUD_COLS} FROM items WHERE id = $1 LIMIT 1" -_CRUD_LIST_SQL = ( - f"SELECT {_CRUD_COLS} FROM items WHERE category = $1 " - "ORDER BY id LIMIT $2 OFFSET $3" -) -# `name = $1, price = $2, quantity = $3 WHERE id = $4`. Arena's aspnet -# UPDATE doesn't touch tags/active/category — mirror exactly. -_CRUD_UPDATE_SQL = "UPDATE items SET name = $1, price = $2, quantity = $3 WHERE id = $4" -# Fixed tags/rating in the INSERT path — Arena's aspnet does the same -# (`'[\"bench\"]'` literal, rating 0/0) so the row always passes its -# CHECK constraints regardless of input shape. -_CRUD_UPSERT_SQL = ( - "INSERT INTO items " - "(id, name, category, price, quantity, active, tags, rating_score, rating_count) " - "VALUES ($1, $2, $3, $4, $5, true, '[\"bench\"]', 0, 0) " - "ON CONFLICT (id) DO UPDATE SET name = $2, price = $4, quantity = $5 " - "RETURNING id" -) - - -def _row_to_full_item(row): - tags = row["tags"] - if tags.__class__ is str: - tags = json.loads(tags) - return { - "id": row["id"], - "name": row["name"], - "category": row["category"], - "price": row["price"], - "quantity": row["quantity"], - "active": row["active"], - "tags": tags, - "rating": {"score": row["rating_score"], "count": row["rating_count"]}, - } - - -@app.get("/crud/items/{id}", gil=True) -def crud_get_one(req: "Request"): - # Arena validator requires an `X-Cache: MISS|HIT` response header on - # this endpoint so the cache-aside test can observe that a second - # GET for the same id hits the in-process cache without re-querying - # Postgres. Header is computed once per code path and wrapped in a - # Response() because a bare dict return would skip custom headers. - if PG_POOL is None: - return _NOT_FOUND - try: - item_id = int(req.params["id"]) - except (KeyError, ValueError): - return _BAD_REQUEST - now = _time.monotonic() - entry = _CRUD_CACHE.get(item_id) - if entry is not None and entry[1] > now: - return Response( - body=json.dumps(entry[0]), - content_type="application/json", - headers={"x-cache": "HIT"}, - ) - try: - row = PG_POOL.fetch_one(_CRUD_GET_SQL, item_id) - except RuntimeError: - log.warning("/crud/items/%s: fetch_one failed", item_id, exc_info=True) - return _INTERNAL_ERROR - if row is None: - return _NOT_FOUND - item = _row_to_full_item(row) - _CRUD_CACHE[item_id] = (item, now + _CRUD_TTL_S) - return Response( - body=json.dumps(item), - content_type="application/json", - headers={"x-cache": "MISS"}, - ) - - -@app.get("/crud/items", gil=True) -def crud_list(req: "Request"): - if PG_POOL is None: - return _EMPTY_CRUD_LIST - q = req.query_params - category = q.get("category") or "electronics" - try: - page = int(q.get("page", "1")) - if page < 1: - page = 1 - except ValueError: - page = 1 - try: - limit = int(q.get("limit", "10")) - except ValueError: - limit = 10 - if limit < 1 or limit > 50: - limit = 10 - offset = (page - 1) * limit - try: - rows = PG_POOL.fetch_all(_CRUD_LIST_SQL, category, limit, offset) - except RuntimeError: - log.warning("/crud/items list: fetch_all failed", exc_info=True) - return _INTERNAL_ERROR - items = [_row_to_full_item(r) for r in rows] - return {"items": items, "total": len(items), "page": page, "limit": limit} - - -@app.put("/crud/items/{id}", gil=True) -def crud_update(req: "Request"): - if PG_POOL is None: - return _NOT_FOUND - try: - item_id = int(req.params["id"]) - body = json.loads(req.body) if req.body else {} - except (KeyError, ValueError, TypeError): - return _BAD_REQUEST - name = body.get("name") or "Updated" - try: - price = int(body.get("price", 0)) - quantity = int(body.get("quantity", 0)) - except (TypeError, ValueError): - return _BAD_REQUEST - try: - affected = PG_POOL.execute(_CRUD_UPDATE_SQL, name, price, quantity, item_id) - except RuntimeError: - log.warning("/crud/items/%s update: execute failed", item_id, exc_info=True) - return _INTERNAL_ERROR - if affected == 0: - return _NOT_FOUND - _CRUD_CACHE.pop(item_id, None) - return {"id": item_id, "name": name, "price": price, "quantity": quantity} - - -@app.post("/crud/items", gil=True) -def crud_upsert(req: "Request"): - if PG_POOL is None: - return _BAD_REQUEST - try: - body = json.loads(req.body) if req.body else {} - item_id = int(body["id"]) - except (KeyError, ValueError, TypeError): - return _BAD_REQUEST - name = body.get("name") or "New Product" - category = body.get("category") or "test" - try: - price = int(body.get("price", 0)) - quantity = int(body.get("quantity", 0)) - except (TypeError, ValueError): - return _BAD_REQUEST - try: - new_id = PG_POOL.fetch_scalar( - _CRUD_UPSERT_SQL, - item_id, name, category, price, quantity, - ) - except RuntimeError: - log.warning("/crud/items upsert id=%s: fetch_scalar failed", item_id, exc_info=True) - return _INTERNAL_ERROR - _CRUD_CACHE.pop(item_id, None) - return Response( - body=json.dumps({ - "id": new_id, - "name": name, - "category": category, - "price": price, - "quantity": quantity, - }), - status_code=201, - content_type="application/json", - ) - - -_EMPTY_CRUD_LIST = {"items": [], "total": 0, "page": 1, "limit": 10} - - -if __name__ == "__main__": - # launcher.py decides which port + TLS config to pass via env. - host = os.environ.get("PYRONOVA_HOST", "0.0.0.0") - port = int(os.environ.get("PYRONOVA_PORT", "8080")) - # Detect worker count from cgroup cpu.max (same pattern as actix's helper). - # Pyronova's engine will fall back to num_cpus if PYRONOVA_WORKERS isn't set. - _tls_ports_env = os.environ.get("PYRONOVA_TLS_PORTS") - extra_tls_ports = [int(p) for p in _tls_ports_env.split(",") if p.strip()] if _tls_ports_env else None - app.run(host=host, port=port, extra_tls_ports=extra_tls_ports) diff --git a/frameworks/pyronova/launcher.py b/frameworks/pyronova/launcher.py deleted file mode 100644 index 4ba18d129c..0000000000 --- a/frameworks/pyronova/launcher.py +++ /dev/null @@ -1,125 +0,0 @@ -"""Launcher — spawns ONE Pyronova process serving all ports simultaneously. - -Plain HTTP on $PORT (default 8080). When TLS certs are present, HTTPS is -also served on $PORT+1 (json-tls profile) and 8443 (baseline-h2 / static-h2 -profile) via PYRONOVA_TLS_PORTS — all from the same process. - -Each TPC thread creates its own SO_REUSEPORT socket on every port, so all -cores serve all profiles simultaneously. This gives every profile access to -all CPUs, unlike the old two-process approach that split cores 50/50. -""" - -import os -import signal -import subprocess -import sys -import threading -import time - - -def _cpu_count() -> int: - try: - return max(len(os.sched_getaffinity(0)), 1) - except AttributeError: - return max(os.cpu_count() or 1, 1) - - -def _numa_nodes() -> int: - """How many NUMA nodes does the kernel see? 1 on UMA systems - (laptops, Apple Silicon, single-socket AMD/Intel desktop), - 2+ on multi-CCD Threadripper/EPYC and multi-socket boxes.""" - try: - return max( - sum(1 for d in os.listdir("/sys/devices/system/node") if d.startswith("node")), - 1, - ) - except (FileNotFoundError, PermissionError): - return 1 - - -def main() -> int: - total = _cpu_count() - per_proc = total - io_per_proc = per_proc - - base_port = int(os.environ.get("PORT", "8080")) - tls_cert = os.environ.get("TLS_CERT", "/certs/server.crt") - tls_key = os.environ.get("TLS_KEY", "/certs/server.key") - have_tls = os.path.exists(tls_cert) and os.path.exists(tls_key) - - env = dict(os.environ) - # PYRONOVA_WORKERS controls TPC thread count in TPC mode (one thread per - # logical CPU). Rust's auto-detect uses physical_core_count() which misses - # hyperthreads — on a 32C/64T Threadripper that would give 32; we want 64. - env["PYRONOVA_WORKERS"] = str(per_proc) - env["PYRONOVA_HOST"] = "0.0.0.0" - env["PYRONOVA_PORT"] = str(base_port) - # GIL-bridge sizing for gil=True routes under TPC. Default is 4 workers - # + 16×4=64 channel depth — correct for typical apps with 1-2 numpy - # routes. HttpArena's async-db / crud profiles hammer gil=True paths - # at 1024+ concurrency, so a 64-deep channel overflows immediately - # and every excess request 503s (PyronovaApp's bridge backpressure - # contract). Widen to 16 workers + 8192 capacity so the DB-heavy - # gcannon profiles see sustained throughput instead of a 503 storm. - # Verified locally at c=4096: 15k req/s steady, zero drops. - env.setdefault("PYRONOVA_GIL_BRIDGE_WORKERS", "16") - env.setdefault("PYRONOVA_GIL_BRIDGE_CAPACITY", "8192") - # Metrics / access log off; benchmarks care about throughput, not logs. - env.pop("PYRONOVA_LOG", None) - env.pop("PYRONOVA_METRICS", None) - # Hard-silence the tracing subscriber. Default level is ERROR, which - # still writes any `tracing::error!` call to stderr — under 4096-conn - # load a single recurring error log (see the PyObjRef leak bug) drags - # throughput by ~3× from log-pipe contention alone. OFF makes every - # tracing macro a zero-cost no-op, matching what Actix / Helidon / - # ASP.NET ship in their benchmark images. - env["PYRONOVA_LOG_LEVEL"] = "OFF" - - if have_tls: - env["PYRONOVA_TLS_CERT"] = tls_cert - env["PYRONOVA_TLS_KEY"] = tls_key - env["PYRONOVA_TLS_PORTS"] = f"{base_port + 1},8443" - else: - env.pop("PYRONOVA_TLS_CERT", None) - env.pop("PYRONOVA_TLS_KEY", None) - env.pop("PYRONOVA_TLS_PORTS", None) - - proc = subprocess.Popen(["python3", "app.py"], env=env) - - def shutdown(_sig, _frame): - # Signal handlers must not block — offload the wait+kill to a thread. - def _cleanup(): - import logging as _log - try: - proc.terminate() - except Exception: - _log.warning("launcher: terminate failed for pid %s", proc.pid, exc_info=True) - # give it a moment to drain gracefully; Pyronova's graceful - # shutdown waits up to 30s for in-flight conns — Arena harness - # typically SIGKILLs the container anyway, but polite is polite. - time.sleep(1) - if proc.poll() is None: - try: - proc.kill() - except Exception: - _log.warning("launcher: kill failed for pid %s", proc.pid, exc_info=True) - # os._exit terminates all threads (including this daemon thread); - # sys.exit(0) from a daemon thread only kills the daemon thread. - os._exit(0) - threading.Thread(target=_cleanup, daemon=True).start() - - signal.signal(signal.SIGTERM, shutdown) - signal.signal(signal.SIGINT, shutdown) - - import logging as _log - try: - rc = proc.wait() - if rc != 0: - _log.warning("launcher: process exited with code %d", rc) - except Exception: - _log.warning("launcher: wait() failed", exc_info=True) - return 0 - - -if __name__ == "__main__": - sys.exit(main()) diff --git a/frameworks/pyronova/meta.json b/frameworks/pyronova/meta.json deleted file mode 100644 index ecc9d41632..0000000000 --- a/frameworks/pyronova/meta.json +++ /dev/null @@ -1,30 +0,0 @@ -{ - "display_name": "pyronova", - "language": "Python", - "type": "experimental", - "mode": "tuned", - "completeness": { - "routing": true, - "middleware": true, - "request": true, - "response": true - }, - "engine": "pyronova", - "description": "Pyronova — Python web framework with a Rust core (hyper + tokio + rustls + mimalloc) and PEP 684 sub-interpreter workers for true multi-core parallelism. Opt-in features: gzip/brotli compression, rustls TLS with h2/h1 ALPN, streaming body ingest, async Postgres via sqlx::PgPool. Handlers are standard Python functions routed via decorators.", - "repo": "https://github.com/leocaolab/pyronova", - "enabled": true, - "tests": [ - "baseline", "latency-1m", "latency-10k", - "pipelined", - "limited-conn", - "json-comp", - "json-tls", - "8gbit", - "async-db", - "baseline-h2", - "static-h2", - "unary-grpc", - "unary-grpc-tls" - ], - "maintainers": [] -}