Build the app to a single native binary with graalpy's 'standalone native' command (Dockerfile.native). granian/rloop cannot be bundled into a standalone binary, so the executable serves the app over ASGI with Hypercorn via native/entrypoint.py, which also works around GraalPy standalone quirks (socket.listen, str response bodies, signal handling). Adds docker-compose.native.yml, a -native CI job, and README docs.
101 lines
3.3 KiB
Python
101 lines
3.3 KiB
Python
"""Entry point for the GraalPy native standalone executable.
|
|
|
|
Serves the pyfconfig ASGI app with Hypercorn. ``graalpy -m standalone native``
|
|
(see ``Dockerfile.native``) bundles this file, the virtual environment and the
|
|
GraalPy runtime into a single self-contained executable.
|
|
|
|
granian and rloop are not used here: they are native extensions that cannot be
|
|
bundled into a standalone binary, and granian spawns its workers through
|
|
``multiprocessing``. Hypercorn is pure Python and runs in-process.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import os
|
|
import signal
|
|
import socket
|
|
from types import FrameType
|
|
from typing import Any, Callable, Optional
|
|
|
|
from hypercorn.asyncio import serve
|
|
from hypercorn.config import Config
|
|
|
|
from pyfconfig.app import app
|
|
|
|
|
|
def _patch_socket_listen() -> None:
|
|
"""Make ``socket.listen`` retry with a blocking socket.
|
|
|
|
In a GraalPy standalone binary the POSIX layer runs on GraalPy's Java
|
|
backend, where asyncio's ``sock.listen()`` on the non-blocking server
|
|
socket raises ``OSError(95, "Operation not supported on transport
|
|
endpoint")``. Listening on a temporarily blocking socket succeeds.
|
|
"""
|
|
original: Callable[..., None] = socket.socket.listen
|
|
|
|
def listen(self: socket.socket, backlog: int = socket.SOMAXCONN) -> None:
|
|
try:
|
|
original(self, backlog)
|
|
except OSError as exc:
|
|
if exc.errno != 95:
|
|
raise
|
|
blocking = self.getblocking()
|
|
self.setblocking(True)
|
|
try:
|
|
original(self, backlog)
|
|
finally:
|
|
self.setblocking(blocking)
|
|
|
|
socket.socket.listen = listen # type: ignore[method-assign]
|
|
|
|
|
|
async def _asgi(scope: Any, receive: Any, send: Any) -> None:
|
|
"""Serve the app, coercing ``str`` response bodies to ``bytes``.
|
|
|
|
kaya's ASGI adapter sends ``''`` (a ``str``) for empty and streamed bodies
|
|
(see ``AsgiContext.send_empty`` / ``stream_body``). The ASGI spec requires
|
|
``bytes``, and Hypercorn rejects a ``str`` body, so normalize it here.
|
|
"""
|
|
|
|
async def _send(message: Any) -> None:
|
|
body = message.get("body")
|
|
if message.get("type") == "http.response.body" and isinstance(body, str):
|
|
message = dict(message, body=body.encode())
|
|
await send(message)
|
|
|
|
await app(scope, receive, _send)
|
|
|
|
|
|
async def _serve() -> None:
|
|
config = Config()
|
|
config.bind = [f"{os.environ.get('HOST', '0.0.0.0')}:{os.environ.get('PORT', '8080')}"]
|
|
|
|
stop = asyncio.Event()
|
|
loop = asyncio.get_running_loop()
|
|
|
|
def _shutdown(signum: int, frame: Optional[FrameType]) -> None:
|
|
loop.call_soon_threadsafe(stop.set)
|
|
|
|
for name in ("SIGINT", "SIGTERM"):
|
|
sig: Any = getattr(signal, name, None)
|
|
if sig is not None:
|
|
signal.signal(sig, _shutdown)
|
|
|
|
# A custom shutdown_trigger keeps Hypercorn from installing its own signal
|
|
# handlers (loop.add_signal_handler is unreliable in the standalone runtime).
|
|
await serve(_asgi, config, shutdown_trigger=stop.wait)
|
|
|
|
|
|
def main() -> None:
|
|
_patch_socket_listen()
|
|
try:
|
|
asyncio.run(_serve())
|
|
except asyncio.CancelledError:
|
|
# Hypercorn cancels the serve task while shutting down after SIGTERM;
|
|
# treat that as a clean exit.
|
|
pass
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|