diff --git a/README.md b/README.md index 3f9b5c3..817fa07 100644 --- a/README.md +++ b/README.md @@ -1,68 +1,95 @@ # SmallServer -SmallServer is a SmallOS-native web framework in early development. It provides -a bounded HTTP/1.1 server, static async routing for GET, POST, PUT, PATCH, and -DELETE, and explicit escape hatches for blocking and asyncio-native libraries. +SmallServer is a small, SmallOS-native HTTP framework for Python 3.10+. It +serves bounded HTTP/1.1 requests and optional cleartext prior-knowledge HTTP/2, +supports exact and timeout-bounded regex routes, optional RFC 6455 WebSockets, +and provides explicit lifecycle and third-party execution controls. + +```python +from smallserver import Response, SmallServer + +app = SmallServer() -## Current scope -The current package provides an HTTP/1.1 baseline over a SmallOS runtime. It can: +@app.get("/health") +async def health(request): + return Response.json({"status": "ok"}) -- register static async routes for GET, POST, PUT, PATCH, and DELETE; -- dispatch an already-created `Request` to a handler; -- return deterministic `Response` values, including HTTP/1.1 bytes; -- return 404 for an unknown path and 405 with `Allow` for a known path using - the wrong method. -- bind a non-blocking TCP listener, accept bounded concurrent connections, and - wait for read/write readiness through SmallOS; -- parse one `Content-Length` HTTP/1.1 request per connection and close after - its response. -Keep-alive/pipelining, TLS, path parameters, and HTTP/2 are not implemented yet. +if __name__ == "__main__": + app.listen(host="127.0.0.1", port=8000) +``` -## Install for development +Install the canonical SmallOS master dependency, the package, and test tools: -```bash +```console python3 -m pip install -r requirements.txt -python3 -m pip install -e . -python3 -m unittest discover -s tests -v +python3 -m pip install -e '.[test]' +python3 demo.py ``` -SmallOS is installed from the canonical `master` branch in `requirements.txt`. -It owns scheduling, socket readiness, and foreign execution adapters. +Application code can use blocking `app.listen()` without importing SmallOS. +Advanced applications can supply their own runtime, schedule the server without +starting it, and own execution adapters for blocking or asyncio-native +libraries. -## Run the demo +## Optional protocol and routing extras -The included demo starts a task API at `http://127.0.0.1:8000`. Common -application code does not need to import or configure SmallOS. +HTTP/2 uses the bounded hyper-h2 4.x integration: -```bash -python3 -m pip install -r requirements.txt -python3 demo.py +```console +python3 -m pip install -e '.[http2]' ``` -Leave the process running and exercise GET, POST, PUT, PATCH, and DELETE from a -browser or HTTP client. Press Ctrl-C for deterministic cleanup without a -traceback. The static `/tasks` path is intentional: path parameters arrive -with a later milestone. +Select `protocol="http2"` on `listen()` or `serve()`. The implementation +supports cleartext prior knowledge, multiplexed stream handlers, bounded flow +control, and GOAWAY. HTTP/2 TLS/ALPN remains deferred until SmallOS exposes a +server-side TLS kernel capability. -## Bind a server +Timeout-bounded regex routes require the optional matching engine: -Create the application and call blocking `listen()`. It lazily creates a -SmallOS runtime with the Unix kernel, while SmallOS remains the scheduler and -owner of socket readiness. `port=0` asks the operating system for an available -port, which is useful in tests and local tooling. +```console +python3 -m pip install -e '.[regex-routes]' +``` + +Static lookup remains dependency-free and takes precedence over regex routes. +Regex routes use full-path matching, run in registration order, and expose only +named captures through immutable `request.path_params`: ```python -from smallserver import Response, SmallServer +@app.get_regex(r"/users/(?P[0-9]+)") +async def get_user(request): + return Response.json({"user_id": request.path_params["user_id"]}) +``` -app = SmallServer() +Pattern, path, capture, and matching-time limits are configurable with +`RegexRouteConfig`. On HTTP/1.1 and HTTP/2 listeners, a match timeout becomes a +sanitized 500 response; direct `dispatch()` raises `RouteMatchTimeout`. An +optional network-listener `route_error_observer` receives only an immutable `RouteErrorEvent` +with an opaque route ID and category; it never receives the request target, +headers, body, traceback, or exception graph. -@app.get("/health") -async def health(request): - return Response.json({"status": "ok"}) +WebSockets use the optional wsproto integration: -app.listen(host="127.0.0.1", port=8000) +```console +python3 -m pip install -e '.[websocket]' +``` + +WebSocket Upgrade routes use a separate static route table, so an ordinary +`GET` and a WebSocket route can coexist at one path: + +```python +from smallserver import WebSocket + + +@app.websocket("/echo", origins={"https://app.example.com"}) +async def echo(websocket: WebSocket) -> None: + await websocket.accept() + async for message in websocket: + if message.is_text: + await websocket.send_text(message.text) + else: + await websocket.send_bytes(message.bytes) ``` ### Configure the managed SmallOS runtime @@ -92,248 +119,46 @@ app.listen(host="127.0.0.1", port=8000, config=config) This bridge is only for SmallServer-owned runtimes. If you supply `runtime=`, configure it directly with `SmallOS(config=...)`; SmallServer rejects `managed_runtime` rather than mutating caller-owned scheduler state. -`task_capacity` must reserve at least `max_connections + 2` task slots for the -listener and shutdown-control tasks, and both server task priorities must be -below `priority_levels`. - -Managed `listen()` blocks and catches Ctrl-C after closing its listener, wakeup -channel, connections, and server tasks. It returns the closed `ServerHandle`, -whose cached `address` and `port` remain available for diagnostics. Each -current connection accepts one request and sends a `Connection: close` -response. - -If the runtime exits normally but cleanup is incomplete, `listen()` raises -`ServerFinalizationError`; retain it and call `retry_cleanup()` until it -succeeds. If runtime startup raises while cleanup is incomplete, ordinary -failures are wrapped by `ServerStartupError`; `KeyboardInterrupt` and -`SystemExit` keep their identity and expose that cleanup owner as `__cause__`. -Until cleanup succeeds, the application rejects another listener invocation. - -## Advanced runtime control - -Supply a configured runtime when the application needs to coordinate other -SmallOS tasks. A supplied runtime is never reconfigured or destroyed, and -`start=False` schedules the server without starting it: - -```python -from SmallPackage import SmallOS, Unix -from smallserver import Response, SmallServer - -runtime = SmallOS().setKernel(Unix()) -app = SmallServer() - -@app.get("/health") -async def health(request): - return Response.json({"status": "ok"}) - -server = app.listen(runtime=runtime, start=False) -try: - runtime.start() -finally: - server.finalize() -``` - -On a kernel with `supports_wakeup_channel() == True`, call `server.close()` -from another thread or client-control path to request scheduler-safe shutdown. -`Unix` provides this cross-thread wakeup capability. - -Constrained kernels may support TCP servers without supporting a thread-safe -wakeup channel. On those kernels, `server.close()` raises instead of mutating -runtime state from an unsafe context. A currently running SmallOS task can use -`await server.close_from_task(task)` to close on the scheduler thread. The -handle's `finished` property becomes true only after the listener, every -connection, and the wakeup channel have closed successfully; `cleanup_errors` -reports close failures that remain available for a later scheduler-side retry. - -If startup fails and the kernel also fails to release an acquired listener or -wakeup resource, `serve()` raises `ServerStartupError`. Its `primary_error` -preserves the startup failure and `cleanup_errors` reports the outstanding -cleanup attempts without exposing kernel handles. Keep the exception and call -`retry_cleanup()` (or `finalize()`) until it returns `True`; later calls remain -safe and return `True`. `KeyboardInterrupt` and `SystemExit` are always -re-raised as the identical exception; when rollback is incomplete, their -`__cause__` is the `ServerStartupError` cleanup owner. Abandoning an incomplete -startup error performs one best-effort cleanup retry and emits a -`ResourceWarning` if resources remain owned. - -`max_connections` bounds every connection stream still owned by the server, -including streams retained after a failed close. At capacity the listener -blocks on a SmallOS scheduler signal without polling or accepting another -connection; releasing capacity signals the listener. Any connection close -failure is fatal and stops further acceptance while retaining the stream for -an explicit shutdown-cleanup retry. - -Each current connection accepts one request and sends a `Connection: close` -response. - -`app.serve(runtime, ...)` remains the equivalent schedule-and-return -compatibility API. `listen(runtime=runtime, start=True)` starts the supplied -runtime exactly once and finalizes only server-owned resources when it exits; -the runtime itself still belongs to the caller. - -While the scheduler is running on a kernel with a wakeup channel, -`server.close()` is the thread-safe shutdown signal. Kernels without that -capability must call `await server.close_from_task(task)` from their currently -running SmallOS task. After a manually started scheduler has already exited or -failed, `server.finalize()` is the idempotent owner-thread cleanup operation on -either kind of kernel. - -Execution adapters are likewise application-owned. Construct and close them -around the runtime lifecycle rather than expecting managed `listen()` to -create or stop adapter threads or asyncio loops. See -[`examples/manual_runtime.py`](examples/manual_runtime.py) for the complete -manual shape. - -Only one listener invocation can be active on an application at a time. Once -its handle reports `finished`, all retained cleanup has completed and the same -application can listen again. A failed cleanup attempt keeps the invocation -reserved until a later successful retry. - -## Define routes - -Use one decorator for each supported method. Handlers receive an immutable -`Request` and must return a `Response`. - -```python -from smallserver import Request, Response, SmallServer - -app = SmallServer() - -@app.get("/health") -async def health(request: Request) -> Response: - return Response.json({"status": "ok"}) - -@app.post("/widgets") -async def create_widget(request: Request) -> Response: - # request.body is always bytes. - return Response.json({"created": True}, status=201) - -@app.put("/widgets") -async def replace_widgets(request: Request) -> Response: - return Response.text("replaced") - -@app.patch("/widgets") -async def patch_widgets(request: Request) -> Response: - return Response.text("updated") - -@app.delete("/widgets") -async def delete_widgets(request: Request) -> Response: - return Response(status=204) -``` - -Route paths are static in this release. Path parameters and richer lifecycle -hooks are deferred; the current `ServerHandle` provides explicit shutdown. - -## Dispatch a request - -The listener creates requests and calls `dispatch()`. The same boundary is -useful in application tests: - -```python -request = Request( - method="GET", - path="/health", - headers={"Accept": "application/json"}, -) - -response = await app.dispatch(request) -assert response.status == 200 -assert response.body == b'{"status":"ok"}' -assert response.headers["content-type"] == "application/json" -``` - -For a path that is registered but does not accept the request method, -`dispatch()` returns a 405 response and an `Allow` header. An unknown path -returns 404. - -## Build responses - -`Response.text()` encodes UTF-8 text and supplies a text content type. -`Response.json()` emits compact UTF-8 JSON. The response serializer adds an -accurate `Content-Length` header when one was not supplied. - -```python -response = Response.text("hello", headers={"X-Request-ID": "abc123"}) -wire_bytes = response.to_http1() - -# b"HTTP/1.1 200 OK\\r\\nContent-Length: 5..." -``` - -Header names and values are validated: duplicate names (case-insensitively), -forbidden control characters, and values outside the HTTP/1.1 Latin-1 wire -range are rejected. - -## Expected application errors - -Raise `HTTPError` inside a handler when an expected client-facing response is -clearer than constructing it inline: - -```python -from smallserver import HTTPError - -@app.delete("/widgets") -async def delete_widget(request: Request) -> Response: - raise HTTPError(413, "request is too large") -``` - -`dispatch()` turns this into a text response with status 413. Unexpected -exceptions are intentionally left visible for the future SmallOS server's -runtime error handling. - -## Third-party blocking and asyncio libraries - -SmallServer delegates foreign execution to SmallOS's bounded adapters. The -application creates those adapters explicitly and can group them in an -`AdapterRegistry` for naming and deterministic shutdown: - -```python -from SmallPackage.adapters.asyncio_loop import AsyncioAdapter -from SmallPackage.adapters.threads import ThreadAdapter -from smallserver import AdapterRegistry, Response - -async def fetch_records(rows): - # Construct loop-affine clients inside the adapter-owned event loop. - async with make_async_client() as client: - return await client.fetch(rows) - -with AdapterRegistry( - database=ThreadAdapter(max_workers=1, max_pending=8), - async_sdk=AsyncioAdapter(max_pending=32), -) as services: - - @app.get("/records") - async def records(request): - rows = await services.call("database", repository.list_records) - result = await services.call("async_sdk", fetch_records, rows) - return Response.json(result) - - server = app.serve(runtime, host="127.0.0.1", port=8000) - runtime.start() -``` - -Use one thread worker for thread-affine resources such as a single SQLite -connection. `AsyncioAdapter` owns one persistent event loop and must receive an -async callable, not a task or future created on another loop. - -Adapter errors remain visible to handlers. `http_error_from_adapter()` is an -opt-in, detail-sanitizing translation: capacity/unavailable failures become -503 and protocol/execution failures become 500. - -Run the standard-library adapter example with: - -```bash -python3 examples/adapters_demo.py -``` - -## Development relationship - -SmallOS's canonical upstream is [MikiEEE/SmallOS](https://github.com/MikiEEE/SmallOS). During initial development, install the canonical `master` branch: - -```bash -python3 -m pip install -r requirements.txt -``` - -SmallOS's normalized distribution name is currently unavailable for public package installation; SmallServer must not claim a PyPI dependency until that is resolved. +For HTTP/1.1, `task_capacity` must reserve at least `max_connections + 2` task +slots for the listener and shutdown-control tasks, and both server task +priorities must be below `priority_levels`. Configuring a regex route-error +observer adds one dedicated SmallOS task, so that mode requires at least +`max_connections + 3` slots. HTTP/2 needs additional headroom for its bounded +connection-control and stream-handler tasks. + +## Current boundaries + +HTTP/1.1 serves one request per connection. Keep-alive, pipelining, TLS, +automatic path templates, WebSocket compression, RFC 8441 WebSockets over +HTTP/2, HTTP/1.1 h2c upgrade, and automatic protocol detection are not +implemented. Regex routes are an explicit optional route form, not automatic +path templates. + +## Documentation + +- [Guide index](guide/index.md) +- [Getting started](guide/getting-started.md) +- [Routing](guide/routing.md) +- [WebSockets](guide/websockets.md) +- [Requests and responses](guide/requests-and-responses.md) +- [Runtime and lifecycle](guide/runtime-lifecycle.md) +- [Configuration](guide/configuration.md) +- [Cleartext HTTP/2](guide/http2.md) +- [Third-party adapters](guide/adapters.md) +- [Errors and observability](guide/errors-observability.md) +- [Platforms and kernels](guide/platforms-kernels.md) +- [API reference](guide/api-reference.md) +- [Protocol roadmap](guide/protocol-roadmap.md) +- [Development](guide/development.md) + +See [`demo.py`](demo.py) for all five supported HTTP methods and a WebSocket route, +[`examples/http2_prior_knowledge.py`](examples/http2_prior_knowledge.py) for +HTTP/2, [`examples/manual_runtime.py`](examples/manual_runtime.py) for +caller-owned SmallOS startup, +[`examples/websocket_echo.py`](examples/websocket_echo.py) for bounded WebSocket +echo handling, and +[`examples/adapters_demo.py`](examples/adapters_demo.py) for blocking and +asyncio escape hatches. ## Releases @@ -342,3 +167,6 @@ Release pull requests merge from `develop` into `main` with a new GitHub release containing checked wheel and source archives. See [`RELEASING.md`](RELEASING.md) for the complete process and the current reason PyPI publication remains disabled. + +SmallServer is early-stage software. Review the documented limits and lifecycle +contract before deploying it outside controlled environments. diff --git a/examples/http2_prior_knowledge.py b/examples/http2_prior_knowledge.py new file mode 100644 index 0000000..a029a39 --- /dev/null +++ b/examples/http2_prior_knowledge.py @@ -0,0 +1,27 @@ +"""Run SmallServer's cleartext prior-knowledge HTTP/2 demo.""" + +from smallserver import HTTP2Config, Response, SmallServer + + +app = SmallServer() + + +@app.get("/health") +async def health(request): + return Response.json({"status": "ok", "protocol": request.version}) + + +@app.post("/echo") +async def echo(request): + return Response(body=request.body, headers={"Content-Type": "application/octet-stream"}) + + +if __name__ == "__main__": + print("HTTP/2 prior-knowledge server: http://127.0.0.1:8000") + print("Try: curl --http2-prior-knowledge http://127.0.0.1:8000/health") + app.listen( + host="127.0.0.1", + port=8000, + protocol="http2", + http2_config=HTTP2Config(max_concurrent_streams=32), + ) diff --git a/guide/adapters.md b/guide/adapters.md new file mode 100644 index 0000000..2bc91b1 --- /dev/null +++ b/guide/adapters.md @@ -0,0 +1,49 @@ +# Third-party adapters + +SmallServer handlers run on the SmallOS scheduler and must not call blocking +functions or drive a second event loop directly. SmallOS supplies explicit +escape hatches: + +- `ThreadAdapter` for blocking or thread-affine callables; +- `AsyncioAdapter` for coroutine-based libraries on a persistent asyncio loop. + +The application creates, bounds, and shuts down these adapters. SmallServer +does not create adapter workers as a side effect of `listen()`. + +```python +from SmallPackage.adapters.errors import AdapterError +from SmallPackage.adapters.threads import ThreadAdapter +from smallserver import AdapterRegistry, Response, http_error_from_adapter + +services = AdapterRegistry(blocking=ThreadAdapter(max_workers=2, max_pending=8)) + + +async def handler(request): + try: + result = await services.call("blocking", str.upper, "smallserver") + except AdapterError as exc: + raise http_error_from_adapter(exc) + return Response.text(result) + + +services.shutdown() +``` + +In a real application, keep the registry alive around the complete runtime +lifecycle; do not shut it down immediately after defining a handler. The +context manager calls `shutdown()` automatically and cancels pending adapter +work when its body exits with an exception. + +`AdapterRegistry` accepts named, user-created adapters that provide `call()` +and `shutdown()`. It rejects duplicate names and duplicate adapter objects, +delegates calls, exposes stable registration order through `names()` and +`items()`, and shuts adapters down in reverse registration order. + +`http_error_from_adapter()` intentionally sanitizes adapter failures: +capacity, unavailable, closed, and cancelled conditions become generic 503 +responses; other adapter failures become a generic 500. Log internal causes in +application-controlled telemetry if needed, but do not expose them to clients. + +See [`examples/adapters_demo.py`](../examples/adapters_demo.py) for a runnable +SQLite and asyncio example and [`examples/manual_runtime.py`](../examples/manual_runtime.py) +for the surrounding runtime lifecycle. diff --git a/guide/api-reference.md b/guide/api-reference.md new file mode 100644 index 0000000..eae0fd4 --- /dev/null +++ b/guide/api-reference.md @@ -0,0 +1,111 @@ +# API reference + +This page summarizes the public names exported by `smallserver`. Signatures +omit overload detail where prose is clearer. + +## Application + +### `SmallServer(regex_config=None, *, route_error_observer=None, websocket_config=None)` + +- `get(path)`, `post(path)`, `put(path)`, `patch(path)`, `delete(path)` — route + decorators for one supported method. +- `route(path, methods)` — atomic multi-method route decorator. +- `get_regex`, `post_regex`, `put_regex`, `patch_regex`, `delete_regex`, and + `route_regex(pattern, methods)` — optional timeout-bounded regex decorators. +- `websocket(path, *, origins=None, subprotocols=())` — static HTTP/1.1 + WebSocket Upgrade decorator. +- `async dispatch(request)` — dispatch an existing `Request`. +- `listen(host="127.0.0.1", port=8000, config=None, *, protocol="http1",` + `http2_config=None, runtime=None, start=None)` + — managed blocking lifecycle or caller-owned scheduling/startup. +- `serve(runtime, host="127.0.0.1", port=8000, config=None, *,` + `protocol="http1", http2_config=None)` — schedule against a caller-owned + runtime and return immediately. + +## HTTP values + +### `Headers(values=None)` + +Immutable, case-insensitive mapping with `items()` and `get()`. + +### `Request(method, path, headers, body=b"", version="HTTP/1.1")` + +Frozen request value with validated method, path, headers, and byte body, plus +`raw_target`, `query_string`, immutable `path_params`, and `route_pattern`. + +### `Response(status=200, body=b"", headers=Headers())` + +Frozen response value. `Response.text()`, `Response.json()`, and `to_http1()` +provide common construction and serialization paths. + +## Server lifecycle + +### `ServerConfig(...)` + +Frozen finite-limit configuration. See [Configuration](configuration.md). + +### `ManagedRuntimeConfig(...)` + +Frozen SmallOS settings used only when `listen()` creates the runtime. A +caller-supplied runtime retains its own configuration. + +### `HTTP2Config(...)` + +Optional cleartext HTTP/2 stream, buffer, frame-batch, and timeout limits. See +[Cleartext HTTP/2](http2.md). Constructing a listener with `protocol="http2"` +requires the `smallserver[http2]` extra. + +### `ServerHandle` + +Read-only properties: `address`, `port`, `closed`, `failure`, `finished`, +`cleanup_errors`, `owned_connection_count`, `dropped_route_error_events`, and +`route_observer_failures`. + +Operations: `close()`, `async close_from_task(task)`, and `finalize()`. + +### `RegexRouteConfig(...)` + +Finite optional-regex limits. `RouteErrorEvent`, `RouteMatchTimeout`, +`RoutePathTooLarge`, and `RegexRoutesUnavailable` describe its bounded error +surface. Runtime regex matching requires `smallserver[regex-routes]`. + +## WebSockets + +- `WebSocketConfig` — finite connection, frame, message, mailbox, and deadline + limits. +- `WebSocket` — `accept`, `reject`, receive/send methods, `ping`, `close`, + iteration, `request`, and negotiated `subprotocol`. +- `WebSocketMessage` — complete typed text or binary message. +- `WebSocketDisconnect`, `WebSocketStateError`, and `WebSocketCapacityError` — + application-visible lifecycle and capacity outcomes. +- `WebSocketUnavailable` — the optional `wsproto` engine is missing. + +See [WebSockets](websockets.md) for handshake and completion semantics. + +## Adapters + +### `AdapterRegistry(**adapters)` + +Methods: `register`, `get`, `call`, `names`, `items`, and `shutdown`. It also +implements a context manager and exposes `closed`. + +### `http_error_from_adapter(exc)` + +Convert a SmallOS `AdapterError` to a sanitized `HTTPError`. + +### `AdapterShutdownError` + +Raised after registry shutdown attempts every adapter but one or more fail. +Its `failures` tuple contains `(name, exception)` entries. + +## Errors + +- `HTTPError(status, detail="")` +- `ServerConfigurationError` +- `ServerStartupError` +- `ServerFinalizationError` + +Cleanup errors expose `cleanup_errors`, `cleanup_complete`, `retry_cleanup()`, +and `finalize()`. `ServerStartupError` additionally exposes `primary_error`. + +Public typing information is shipped through `smallserver/py.typed`. diff --git a/guide/configuration.md b/guide/configuration.md new file mode 100644 index 0000000..0fe4bf6 --- /dev/null +++ b/guide/configuration.md @@ -0,0 +1,85 @@ +# Configuration + +Pass a `ServerConfig` to `listen()` or `serve()` to tune finite listener, +parser, and scheduling limits. + +```python +from smallserver import ManagedRuntimeConfig, ServerConfig, SmallServer + +app = SmallServer() +config = ServerConfig( + max_connections=50, + max_header_bytes=16 * 1024, + max_header_count=64, + max_body_bytes=512 * 1024, + receive_chunk_bytes=8 * 1024, + listener_priority=1, + connection_priority=2, + accept_batch_size=16, + max_request_target_bytes=8 * 1024, + max_route_error_events=16, + managed_runtime=ManagedRuntimeConfig(task_capacity=256), +) +``` + +| Setting | Default | Purpose | +| --- | ---: | --- | +| `max_connections` | 100 | Maximum connection streams still owned by the server. | +| `max_header_bytes` | 16 KiB | Maximum HTTP/1.1 request-head bytes. | +| `max_header_count` | 100 | Maximum number of request header fields. | +| `max_body_bytes` | 1 MiB | Maximum `Content-Length` and buffered request body. | +| `receive_chunk_bytes` | 8 KiB | Bytes requested from the transport per read. | +| `listener_priority` | 1 | SmallOS listener and close-watcher task priority. | +| `connection_priority` | 2 | SmallOS connection-task priority. | +| `accept_batch_size` | 16 | Accepts before the listener explicitly yields. | +| `max_request_target_bytes` | 8 KiB | Maximum HTTP/1.1 origin-form request target. | +| `max_route_error_events` | 16 | Bounded sanitized regex-timeout observer queue. | +| `managed_runtime` | `None` | Optional SmallOS settings used only when `listen()` creates the runtime. | + +Every numeric `ServerConfig` field must be a positive integer; booleans are +rejected. `managed_runtime` must be `None` or a `ManagedRuntimeConfig`. The +public port must be an integer from 0 through 65535. `port=0` delegates port +selection to the kernel. + +At connection capacity, the listener waits on a scheduler signal instead of +accepting and discarding more streams. Connections whose close failed still +count against the limit because the server continues to own them. A close +failure is fatal to further acceptance and remains visible for cleanup retry. + +Limits are per `ServerHandle`. They bound HTTP input and framework-owned +connections, but they do not limit memory allocated by your handlers, response +bodies, adapter queues, or downstream libraries; configure those separately. + +## Managed runtime configuration + +`ManagedRuntimeConfig` controls the SmallOS instance created by blocking +`app.listen()` when no runtime is supplied. It exposes `task_capacity`, +`priority_levels`, `io_buffer_length`, `eternal_watchers`, and immutable +per-client `client_defaults`. Caller-owned runtimes must be configured directly; +SmallServer rejects `ServerConfig(managed_runtime=...)` when `runtime=` is +provided. + +The managed task capacity must cover `max_connections + 2` for HTTP/1.1's +listener and shutdown-control tasks. Configuring `route_error_observer` adds +one dedicated task, raising that floor to `max_connections + 3`. HTTP/2 also +creates bounded connection-control and stream-handler tasks, so configure +additional capacity from the selected `HTTP2Config` concurrency limits. + +## WebSocket configuration + +Pass `websocket_config=WebSocketConfig(...)` to `SmallServer`. Its finite +limits cover accepted WebSocket connections, frames, messages, inbound and +outbound queues, write chunks, handshake/write/close/idle deadlines, and +ping/pong liveness. See [WebSockets](websockets.md) for the full boundary. + +## HTTP/2 configuration + +Pass `protocol="http2"` and an optional `HTTP2Config` for cleartext +prior-knowledge HTTP/2. Its finite limits cover streams, decoded and compressed +headers, request and response buffers, generated control output, frame size, +reader frame batches, handshake time, and idle time. See +[Cleartext HTTP/2](http2.md) for the complete protocol boundary. + +`RegexRouteConfig` separately bounds optional regex pattern length, route and +capture counts, path bytes, per-match time, and total matching time. All time +limits must be finite positive numbers. diff --git a/guide/development.md b/guide/development.md new file mode 100644 index 0000000..bb0f035 --- /dev/null +++ b/guide/development.md @@ -0,0 +1,68 @@ +# Development + +## Set up + +Use Python 3.10 or newer and install the canonical SmallOS master checkout plus +SmallServer in editable mode: + +```console +python3 -m pip install -r requirements.txt +python3 -m pip install -e '.[test]' +``` + +For reproducible validation, put the canonical SmallOS checkout at the front of +`PYTHONPATH` rather than relying on an unrelated installed package named +`SmallPackage`. + +## Validate + +```console +python3 -m unittest discover -s tests -v +python3 -m compileall -q smallserver demo.py examples tests +git diff --check +``` + +The suite covers routing, HTTP values and parsing, adapters, lifecycle failure +ownership, kernel transport behavior, and real loopback serving when the local +environment permits binds. Documentation tests verify the tracked guide set, +relative Markdown links, and Python code-block syntax. + +In a separate clean environment, verify the lazy optional-dependency boundary +without installing the test or HTTP/2 extras: + +```console +python3 -m pip install -r requirements.txt +python3 -m pip install -e . +python3 -m unittest tests.test_http2 -v +python3 -m unittest tests.test_regex_routing -v +python3 -m unittest tests.test_websocket -v +``` + +The dependency-contract tests run and optional interoperability cases skip +cleanly; importing and testing ordinary HTTP/1.1 must require neither +hyper-h2, regex, nor wsproto. + +Run the examples when their platform requirements are available: + +```console +python3 demo.py +python3 examples/adapters_demo.py +python3 examples/manual_runtime.py +python3 examples/websocket_echo.py +``` + +The two network examples block until shutdown. `adapters_demo.py` completes on +its own and demonstrates SQLite thread affinity and a persistent asyncio loop. + +## Contribution boundaries + +- Keep framework networking behind SmallOS kernel abstractions. +- Preserve finite parsing, connection, and adapter limits. +- Keep the HTTP core independent of `asyncio`. +- Add lifecycle tests for partial acquisition and cleanup failure paths. +- Update the README and focused guide page when a public API changes. +- Extend [Protocol roadmap](protocol-roadmap.md) docs on the feature branch that + implements a protocol; do not describe planned APIs as present. + +The ignored `docs/` and `skills/` trees support local agent workflows. Public, +versioned user documentation belongs in `README.md` and `guide/`. diff --git a/guide/errors-observability.md b/guide/errors-observability.md new file mode 100644 index 0000000..8db44d9 --- /dev/null +++ b/guide/errors-observability.md @@ -0,0 +1,64 @@ +# Errors and observability + +SmallServer separates expected HTTP responses, configuration mistakes, +runtime failures, and incomplete cleanup ownership. + +## Handler-facing errors + +Raise `HTTPError(status, detail)` for an expected 4xx or 5xx response. Status +must be between 400 and 599. The detail becomes a plain-text response; do not +put secrets or raw downstream exceptions in it. + +The network server converts ordinary handler exceptions into a generic 500. +`app.dispatch()` only catches `HTTPError`, so direct dispatch in tests preserves +programming errors. + +On HTTP/1.1 and HTTP/2 listeners, a regex match timeout becomes a generic 500. +A direct `await app.dispatch(request)` instead raises `RouteMatchTimeout`. If a +network listener has an observer configured, SmallServer attempts to enqueue at +most one immutable, traceback-free `RouteErrorEvent` containing only an opaque +route ID and category. Delivery is bounded and scheduler-local, so saturation, +signal failure, or shutdown may drop the event. Dropped events and observer +callback failures are reported by the corresponding `ServerHandle` counters. + +Ordinary WebSocket handler failures are converted to a sanitized 1011 Close +frame after an accepted handshake. `KeyboardInterrupt` and `SystemExit` +preserve their identity. Protocol, capacity, deadline, and disconnect outcomes +use the typed WebSocket exceptions documented in [WebSockets](websockets.md). + +## Configuration errors + +`ServerConfigurationError` reports a runtime or kernel capability that cannot +support the requested lifecycle. Type and value mistakes generally raise +`TypeError` or `ValueError` before binding. + +## Startup and finalization ownership + +`ServerStartupError` means startup failed and one or more acquired resources +could not yet be released. Its `primary_error` is the original failure; +`cleanup_errors` contains the current cleanup failures. Retain the exception +and call `retry_cleanup()` or `finalize()` until it returns `True`. + +`ServerFinalizationError` means a started runtime returned normally but server +cleanup remains incomplete. It exposes the same `cleanup_errors`, +`cleanup_complete`, `retry_cleanup()`, and `finalize()` contract. + +`KeyboardInterrupt` and `SystemExit` retain their identity. If rollback is +incomplete, their `__cause__` is the `ServerStartupError` cleanup owner. An +abandoned incomplete cleanup error makes one best-effort retry and emits a +`ResourceWarning` if ownership remains. + +## ServerHandle state + +Observe these stable properties: + +- `address` and `port`: cached bind result; +- `closed`: shutdown has been requested; +- `finished`: all server-owned cleanup is complete; +- `failure`: first fatal listener or connection-cleanup failure, if any; +- `cleanup_errors`: current failures for still-owned resources; +- `owned_connection_count`: active and retained connection streams. + +SmallServer does not provide a logging backend, metrics registry, or tracing +system. Applications should report sanitized handle state and +their own handler/adapter telemetry without reaching into private attributes. diff --git a/guide/getting-started.md b/guide/getting-started.md new file mode 100644 index 0000000..1871378 --- /dev/null +++ b/guide/getting-started.md @@ -0,0 +1,63 @@ +# Getting started + +## Requirements + +SmallServer requires Python 3.10 or newer. During development, +`requirements.txt` installs SmallOS from the canonical GitHub `master` branch; +SmallServer itself declares no package-index runtime dependency yet. + +```console +python3 -m pip install -r requirements.txt +python3 -m pip install -e . +``` + +The first command needs Git and network access. Pin the SmallOS revision in +your own deployment lock or build process if reproducibility matters. + +## Create an application + +```python +from smallserver import Request, Response, SmallServer + +app = SmallServer() + + +@app.get("/health") +async def health(request: Request) -> Response: + return Response.json({"status": "ok"}) + + +if __name__ == "__main__": + app.listen(host="127.0.0.1", port=8000) +``` + +Run the file and request the exact path: + +```console +curl -i http://127.0.0.1:8000/health +``` + +`listen()` creates a SmallOS runtime with its Unix kernel, blocks while that +runtime runs, and handles Ctrl-C by cleaning up server-owned resources. Normal +application code does not need to import SmallOS. + +Use `port=0` when a test or tool needs the kernel to choose an available port. +Because managed `listen()` blocks, inspect the returned handle only after the +runtime has stopped. For access to the bound port while the server is running, +use [caller-owned runtime mode](runtime-lifecycle.md#caller-owned-runtime). + +## Try the task demo + +[`demo.py`](../demo.py) implements GET, POST, PUT, PATCH, and DELETE on the +static `/tasks` route: + +```console +python3 demo.py +curl -i http://127.0.0.1:8000/tasks +curl -i -X POST -H 'Content-Type: application/json' \ + --data '{"title":"read the guide"}' http://127.0.0.1:8000/tasks +``` + +Every current HTTP/1.1 connection serves one request and closes after the +response. See [Routing](routing.md) and [Configuration](configuration.md) before +building a larger application. diff --git a/guide/http2.md b/guide/http2.md new file mode 100644 index 0000000..f426355 --- /dev/null +++ b/guide/http2.md @@ -0,0 +1,66 @@ +# Cleartext HTTP/2 + +SmallServer can serve HTTP/2 with cleartext prior knowledge. The feature uses +hyper-h2 as a lazy, optional sans-I/O protocol engine while SmallOS continues +to own task scheduling and all network readiness. + +## Install and run + +```bash +python3 -m pip install -r requirements.txt +python3 -m pip install -e '.[http2]' +python3 examples/http2_prior_knowledge.py +``` + +In another terminal: + +```bash +curl --http2-prior-knowledge http://127.0.0.1:8000/health +curl --http2-prior-knowledge --data-binary hello http://127.0.0.1:8000/echo +``` + +Select HTTP/2 with `protocol="http2"` on either `listen()` or `serve()`. +HTTP/1.1 remains the default and never imports hyper-h2. A missing or +incompatible optional dependency is rejected before SmallServer binds a port. + +## Concurrency and limits + +Each TCP connection has one protocol state and one writer task. Complete +request streams are dispatched in separate SmallOS tasks, so one stream can +wait on a bounded execution adapter while unrelated streams complete. The +single writer preserves frame ordering and observes peer flow-control windows. + +`HTTP2Config` bounds concurrent streams, decoded and compressed header sizes, +per-stream and per-connection request buffering, response buffering, and frame +size. `max_control_output_bytes` bounds generated SETTINGS/PING acknowledgments, +and `reader_frame_batch_size` forces a cooperative yield during continuously +readable frame floods. Compressed header-block limits are enforced from the +frame header before payload buffering. Completed request bodies remain charged +to the connection budget while their handler is running. `handshake_timeout` +bounds receipt of the client preface. `idle_timeout` is the maximum interval +without inbound connection bytes or frames; outbound-only response progress +does not reset it. Both timeouts use SmallOS scheduler timers. Requests and +responses use the same immutable `Request`, `Headers`, and `Response` values as +HTTP/1.1. The request version is `"HTTP/2"`. + +Peer stream resets cancel the associated handler task without stopping other +streams. Protocol/resource violations reset the affected stream when possible. +An ordinary response-write failure closes only that client connection; a +kernel close failure remains server-owned, stops acceptance, and is exposed +through `ServerHandle.failure` and `cleanup_errors` for retry. +Connection shutdown emits GOAWAY and then releases the connection through the +SmallOS kernel transport. If a writer is already blocked on kernel +writability, a lower-priority scheduler task force-closes the stream after the +writer's graceful scheduling opportunity so shutdown remains bounded. + +## Current protocol boundary + +Only cleartext prior knowledge is supported. SmallServer does not implement an +HTTP/1.1 `Upgrade: h2c` transition and does not infer the protocol from bytes. +Configure one listener for one protocol. + +TLS with ALPN `h2` is deferred because SmallOS does not currently expose a +server-side TLS kernel capability. SmallServer intentionally does not import +or call platform `ssl` or `socket` APIs to work around that missing boundary. +When the kernel gains that capability, TLS/ALPN negotiation can be added +without changing route handlers or response values. diff --git a/guide/index.md b/guide/index.md new file mode 100644 index 0000000..9e8b2f4 --- /dev/null +++ b/guide/index.md @@ -0,0 +1,31 @@ +# SmallServer guide + +This guide documents the integrated lifecycle, regex routing, WebSockets, and +HTTP/2 feature set. Start with the managed server path, then open the focused +page for the part you are changing. + +## Learn SmallServer + +1. [Getting started](getting-started.md) — install, create an app, and run it. +2. [Routing](routing.md) — exact and optional regex paths, captures, methods, 404, and 405 behavior. +3. [WebSockets](websockets.md) — HTTP/1.1 Upgrade, messages, and deadlines. +4. [Requests and responses](requests-and-responses.md) — immutable HTTP values. +5. [Runtime and lifecycle](runtime-lifecycle.md) — managed and caller-owned modes. +6. [Configuration](configuration.md) — finite parser and connection limits. +7. [Cleartext HTTP/2](http2.md) — optional prior-knowledge multiplexing and limits. + +## Integrate and operate + +- [Third-party adapters](adapters.md) +- [Errors and observability](errors-observability.md) +- [Platforms and kernels](platforms-kernels.md) +- [API reference](api-reference.md) + +## Project direction + +- [Protocol roadmap](protocol-roadmap.md) +- [Development](development.md) + +This branch supports bounded HTTP/1.1, optional timeout-bounded regex routing, +RFC 6455 WebSocket Upgrade, and optional cleartext prior-knowledge HTTP/2. +TLS/ALPN, h2c upgrade, and RFC 8441 remain outside the current boundary. diff --git a/guide/platforms-kernels.md b/guide/platforms-kernels.md new file mode 100644 index 0000000..e29f0df --- /dev/null +++ b/guide/platforms-kernels.md @@ -0,0 +1,40 @@ +# Platforms and kernels + +SmallServer delegates networking, readiness, task registration, and task +cancellation to SmallOS. Production framework modules do not import Python's +`socket` module directly; kernel-owned transport handles remain opaque to the +application. + +## Desktop default + +Managed `app.listen()` lazily imports `SmallOS` and the `Unix` kernel, configures +that runtime, and starts it. If the dependency or Unix kernel is unavailable, +it raises `ServerConfigurationError` and asks the caller to provide a suitable +runtime. + +The canonical SmallOS dependency is installed from GitHub `master` by +`requirements.txt`. Python package metadata intentionally has no runtime +dependency until SmallOS has an unambiguous published distribution contract. + +## Custom and constrained kernels + +A supplied runtime must expose a configured `kernel` plus callable `fork`, +`resume_task`, and `cancel_task` operations. Starting it through SmallServer +also requires `start`. + +The kernel must satisfy SmallOS's network capability contract for listeners, +streams, readiness, retry direction, addresses, and cleanup. Capability checks +occur before SmallServer binds a listener. + +A wakeup channel is optional: + +- with one, `ServerHandle.close()` can notify the scheduler from another thread; +- without one, external `close()` raises and a running task must call + `await handle.close_from_task(task)`; +- after a caller-owned scheduler exits, `handle.finalize()` is the owner-thread + cleanup path on either kind of kernel. + +Do not infer that a MicroPython-like platform supports managed Unix mode or a +thread-safe wakeup just because it can accept TCP connections. Supply the +platform runtime explicitly and test its real capability surface and cleanup +behavior. diff --git a/guide/protocol-roadmap.md b/guide/protocol-roadmap.md new file mode 100644 index 0000000..8d548d5 --- /dev/null +++ b/guide/protocol-roadmap.md @@ -0,0 +1,37 @@ +# Protocol and feature roadmap + +This guide documents bounded HTTP/1.1, optional cleartext prior-knowledge +HTTP/2, RFC 6455 WebSockets, exact and optional regex routes, shared HTTP +values, explicit SmallOS lifecycle control, and application-owned execution +adapters. + +Feature branches extend this foundation independently. Until such a branch is +merged into the branch you install, its API is not available. + +## Routing extensions + +The optional regex-routing extra provides timeout-bounded full-path matching +and named captured path parameters while preserving exact static-route +precedence. It does not provide automatic path templates or decoding. + +## WebSocket server + +Optional RFC 6455 server support is available over HTTP/1.1 Upgrade using +SmallOS-native transport ownership and bounded protocol state. See +[WebSockets](websockets.md). TLS, compression, and RFC 8441 WebSockets over +HTTP/2 remain separate concerns. + +## HTTP/2 server + +HTTP/2 is available as an optional cleartext prior-knowledge server using the +hyper-h2 4.x sans-I/O stack. See [Cleartext HTTP/2](http2.md) for dependency +installation, stream concurrency, flow control, protocol limits, GOAWAY, and +graceful shutdown. TLS/ALPN remains deferred until SmallOS exposes a +server-side TLS kernel capability; h2c upgrade and protocol autodetection are +not supported. + +## Existing HTTP/1.1 limits + +Keep-alive, pipelining, TLS termination, automatic protocol detection, h2c +upgrade, middleware/ASGI compatibility, and automatic request-data decoding are +not implemented here. Treat future items as direction, not a compatibility promise. diff --git a/guide/requests-and-responses.md b/guide/requests-and-responses.md new file mode 100644 index 0000000..49a3ab3 --- /dev/null +++ b/guide/requests-and-responses.md @@ -0,0 +1,65 @@ +# Requests and responses + +`Request`, `Response`, and `Headers` are immutable value objects shared by the +router and server. + +## Request + +A handler receives: + +- `method`: a valid HTTP token; +- `path`: the request target, beginning with `/`; +- `headers`: a case-insensitive `Headers` mapping; +- `body`: complete request bytes; +- `version`: `HTTP/1.1` or `HTTP/2`, selected by the listener protocol. + +The HTTP/1.1 parser accepts one origin-form request framed by zero or one +`Content-Length` header. It rejects transfer encoding, multiple content lengths, +missing `Host`, invalid targets, oversized input, and pipelined bytes. It does +not decode JSON, forms, query parameters, or text for you. + +```python +import json + +from smallserver import HTTPError, Request, Response + + +async def create(request: Request) -> Response: + try: + value = json.loads(request.body.decode("utf-8")) + except (UnicodeDecodeError, json.JSONDecodeError) as exc: + raise HTTPError(400, "body must be valid JSON") from exc + return Response.json({"received": value}, status=201) +``` + +## Headers + +Header lookup is case-insensitive while iteration preserves the originally +provided spelling. Names must be HTTP tokens; values cannot contain control +characters other than horizontal tab or characters outside Latin-1. Duplicate +names are rejected after case folding. + +```python +from smallserver import Headers + +headers = Headers({"Content-Type": "application/json"}) +assert headers["content-type"] == "application/json" +``` + +## Response + +Construct `Response(status, body, headers)`, or use `Response.text()` and +`Response.json()`. Bodies must already be `bytes`. An explicit `Content-Length` +must exactly match the body; otherwise construction fails. The HTTP/1.1 server +adds a length when absent and sends `Connection: close`. + +```python +from smallserver import Response + +plain = Response.text("ready") +created = Response.json({"id": "1"}, status=201) +empty = Response(status=204) +``` + +`Response.to_http1()` is available for deterministic serialization and tests. +Applications normally return the value and let SmallServer write it. diff --git a/guide/routing.md b/guide/routing.md new file mode 100644 index 0000000..21c0ea7 --- /dev/null +++ b/guide/routing.md @@ -0,0 +1,54 @@ +# Routing + +SmallServer gives exact static routes precedence, then evaluates optional +timeout-bounded regex routes in registration order. Register static routes +with `get`, `post`, `put`, `patch`, `delete`, or `route`. + +```python +from smallserver import Response, SmallServer + +app = SmallServer() + + +@app.route("/status", methods=("GET", "POST")) +async def status(request): + return Response.text(request.method) +``` + +Methods passed to `route` are normalized to uppercase and duplicates are +removed. Registration rejects an empty method set, unsupported methods, +non-callable handlers, duplicate method/path pairs, and paths that do not start +with `/`. A failed multi-method registration does not partially add a route. + +## Dispatch behavior + +- An exact method/path match runs its async handler. +- A known path with the wrong method returns 405 and a sorted `Allow` header. +- An unknown path returns 404. +- A handler must return an awaitable whose result is a `Response`. +- Raising `HTTPError` produces the requested 4xx or 5xx response. + +An ordinary handler exception becomes a generic 500 when the network server +invokes it. A direct call to `await app.dispatch(request)` preserves ordinary +exceptions for tests and embedding code. + +## Request targets and regex routes + +Routing uses `request.path`; the undecoded query remains in +`request.query_string`, and `request.raw_target` preserves both. Install +`smallserver[regex-routes]` to register full-path expressions: + +```python +@app.get_regex(r"/items/(?P[0-9]+)") +async def item(request): + return Response.json({"id": request.path_params["item_id"]}) +``` + +Only named captures are exposed, as an immutable mapping. Patterns, paths, +route counts, captures, individual matches, and total matching time are +bounded by `RegexRouteConfig`. This is an explicit regex API, not automatic +`/items/{id}` template parsing or percent decoding. + +WebSocket Upgrade routes are registered separately with `app.websocket()`. +They are exact-path HTTP/1.1 routes and may coexist with an ordinary `GET` at +the same path. See [WebSockets](websockets.md). diff --git a/guide/runtime-lifecycle.md b/guide/runtime-lifecycle.md new file mode 100644 index 0000000..348d5b9 --- /dev/null +++ b/guide/runtime-lifecycle.md @@ -0,0 +1,80 @@ +# Runtime and lifecycle + +SmallOS always owns scheduling and I/O readiness. SmallServer offers one +managed mode for normal applications and explicit modes for applications that +coordinate other SmallOS tasks. + +Only one listener invocation may be active on a `SmallServer` instance. The +instance can be reused after its handle is fully finished. + +## Managed runtime + +```python +from smallserver import Response, SmallServer + +app = SmallServer() + + +@app.get("/") +async def index(request): + return Response.text("hello") + + +app.listen(host="127.0.0.1", port=8000) +``` + +With no `runtime`, `listen()` lazily creates a configured `SmallOS` with the +Unix kernel, starts it, blocks until shutdown, and finalizes server-owned +resources. `ServerConfig.managed_runtime` accepts a `ManagedRuntimeConfig` for +scheduler capacity, priority, I/O-buffer, watcher, and client-default settings. +In this managed mode, Ctrl-C is consumed after successful cleanup and the +closed `ServerHandle` is returned. + +## Caller-owned runtime + +Supply a configured runtime to schedule the listener without starting it: + +```python +from SmallPackage import SmallOS, Unix +from smallserver import Response, SmallServer + +runtime = SmallOS().setKernel(Unix()) +app = SmallServer() + + +@app.get("/health") +async def health(request): + return Response.json({"status": "ok"}) + + +handle = app.listen(runtime=runtime, start=False, port=0) +print(handle.address) +try: + runtime.start() +finally: + handle.finalize() +``` + +With a supplied runtime, `start=False` is the default. `app.serve(runtime, ...)` +is the equivalent schedule-and-return compatibility API. Passing `start=True` +starts the supplied runtime once; the caller still owns that runtime. + +## Shutdown operations + +- `handle.close()` requests shutdown from outside the scheduler when the kernel + provides a wakeup channel. Unix supports this path. +- `await handle.close_from_task(task)` shuts down from the currently running + SmallOS task and is required on kernels without a wakeup channel. +- `handle.finalize()` performs idempotent owner-thread cleanup after a manually + started scheduler has exited or failed. + +`closed` means shutdown was requested. `finished` is stronger: the listener, +wakeup channel, connections, and retained cleanup work have all completed. +Failed closes remain owned and appear in `cleanup_errors`; call the appropriate +cleanup operation again from a safe context. + +`address` and `port` are cached and remain readable after close. `failure` +reports the first fatal listener or connection-cleanup failure. + +See [Errors and observability](errors-observability.md) for incomplete startup +and finalization transactions. diff --git a/guide/websockets.md b/guide/websockets.md new file mode 100644 index 0000000..5fe7b48 --- /dev/null +++ b/guide/websockets.md @@ -0,0 +1,70 @@ +# WebSockets + +Install the optional protocol engine before serving WebSocket routes: + +```bash +python3 -m pip install -e '.[websocket]' +``` + +WebSocket routes use HTTP/1.1 Upgrade while SmallOS continues to own task +scheduling and socket readiness. A normal `GET` route may use the same path; +requests without Upgrade headers remain ordinary HTTP requests. + +```python +from smallserver import SmallServer, WebSocket + +app = SmallServer() + +@app.websocket( + "/chat", + origins={"https://app.example.com"}, + subprotocols=("chat.v1",), +) +async def chat(socket: WebSocket) -> None: + await socket.accept(subprotocol="chat.v1") + async for message in socket: + if message.is_text: + await socket.send_text(message.text) + else: + await socket.send_bytes(message.bytes) + +app.listen() +``` + +The application must explicitly call `accept()` or `reject()` before using +message operations. Returning without either decision sends a sanitized 403. +Text, binary, fragmented messages, Ping/Pong, and Close are supported. Queue, +frame, message, connection, handshake, idle, Pong, write, and close limits are +finite and configurable through `WebSocketConfig`. + +Only one application Ping may await a Pong at a time. The timeout is armed +before the frame is written, and only a Pong with the matching payload clears +it. Handshake, idle, Pong, and close deadlines also bound cleanup when a peer +stops reading; expired connections cancel handler work owned by that +connection. + +An origin allowlist is strongly recommended when browser credentials or +cookies are involved. A selected subprotocol must have been offered by the +client and allowed by the route. Outbound saturation raises +`WebSocketCapacityError`. + +Direct calls to `receive()`, `receive_text()`, or `receive_bytes()` raise +`WebSocketDisconnect` after already queued messages have been delivered when +the peer or application closes the connection. `async for message in socket` +instead treats that disconnect as normal iteration completion. Server shutdown +and expired handshake, idle, Pong, write, or close deadlines may cancel the +connection handler to guarantee bounded cleanup, so application resource +cleanup belongs in the handler's `finally` block. + +Send calls complete after the serialized frame bytes have been flushed through +the connection writer. They do not mean the peer application has processed the +message. + +This release does not implement `wss://` termination, compression, custom +extensions, or RFC 8441 WebSockets over HTTP/2. Put TLS at a trusted reverse +proxy until SmallServer gains a native TLS boundary. + +The runnable [`websocket_echo.py`](../examples/websocket_echo.py) accepts +clients without requiring a subprotocol. The `/chat` example above separately +demonstrates explicit negotiation: a client must offer `chat.v1` before the +handler may select it. diff --git a/pyproject.toml b/pyproject.toml index 30ab6ee..f0c3481 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,7 +5,7 @@ build-backend = "setuptools.build_meta" [project] name = "smallserver" version = "0.1.0" -description = "A SmallOS-native HTTP/1.1 web framework" +description = "A SmallOS-native HTTP/1.1 and HTTP/2 web framework" readme = "README.md" requires-python = ">=3.10" license = "MIT" @@ -14,6 +14,7 @@ dependencies = [] [project.optional-dependencies] dev = ["build>=1.2"] +http2 = ["h2>=4,<5"] regex-routes = ["regex>=2023.10.3,<2027"] websocket = ["wsproto>=1.2,<2"] test = [ diff --git a/smallserver/__init__.py b/smallserver/__init__.py index b297939..4fb8ae0 100644 --- a/smallserver/__init__.py +++ b/smallserver/__init__.py @@ -10,6 +10,7 @@ ServerStartupError, ) from .http import Headers, Request, Response +from .http2 import HTTP2Config from .routing import ( RegexRouteConfig, RegexRoutesUnavailable, @@ -48,6 +49,7 @@ def __getattr__(name: str) -> Any: "AdapterShutdownError", "Headers", "HTTPError", + "HTTP2Config", "ManagedRuntimeConfig", "RegexRouteConfig", "RegexRoutesUnavailable", diff --git a/smallserver/app.py b/smallserver/app.py index 68ffb1f..6109fe8 100644 --- a/smallserver/app.py +++ b/smallserver/app.py @@ -25,6 +25,7 @@ _CleanupTransaction, ) from .http import Request, Response +from .http2 import HTTP2Config, H2Protocol, require_http2 from .routing import ( RegexRouteConfig, RouteErrorEvent, @@ -56,6 +57,40 @@ Handler = Callable[[Request], Awaitable[Response]] WebSocketHandler = Callable[[WebSocket], Awaitable[None]] RouteErrorObserver = Callable[[RouteErrorEvent], None] +_METHODS = frozenset({"GET", "POST", "PUT", "PATCH", "DELETE"}) +_HTTP2_WRITER_SIGNAL = 30 +_HTTP2_SHUTDOWN_SIGNAL = 29 + + +class _H2ConnectionState: + def __init__(self, protocol: H2Protocol) -> None: + self.protocol = protocol + self.writer_task: Any = None + self.shutdown_task: Any = None + self.watchdog_task: Any = None + self.handlers: dict[int, Any] = {} + self.closing = False + self.shutdown_requested = False + self.close_error_code = 0 + self.activity_epoch = 0 + self.failure: BaseException | None = None + + def wake_writer(self) -> None: + writer = self.writer_task + if writer is not None and not getattr(writer, "done", False): + if writer.acceptSignal(_HTTP2_WRITER_SIGNAL) != 0: + raise RuntimeError("HTTP/2 writer signal failed") + + def request_shutdown(self) -> None: + self.shutdown_requested = True + self.wake_writer() + shutdown_task = self.shutdown_task + if shutdown_task is not None and not getattr(shutdown_task, "done", False): + if shutdown_task.acceptSignal(_HTTP2_SHUTDOWN_SIGNAL) != 0: + raise RuntimeError("HTTP/2 shutdown signal failed") + + def mark_activity(self) -> None: + self.activity_epoch += 1 class _NoThreadLock: @@ -160,6 +195,8 @@ def __init__( if route_error_observer is not None and not callable(route_error_observer): raise TypeError("route_error_observer must be callable or None") self._router = Router(regex_config) + # Preserve the original private static-route map for existing tooling + # while the Router owns all registration and lookup behavior. self._routes = self._router._static self._route_error_observer = route_error_observer if websocket_config is not None and not isinstance( @@ -299,6 +336,9 @@ def serve( host: str = "127.0.0.1", port: int = 8000, config: ServerConfig | None = None, + *, + protocol: str = "http1", + http2_config: HTTP2Config | None = None, ) -> ServerHandle: """Bind a TCP listener and schedule SmallOS listener/control tasks. @@ -309,7 +349,9 @@ def serve( """ config = self._resolve_server_config(config, managed=False) self._validate_runtime(runtime, require_start=False) - return self._bind_and_schedule(runtime, host, port, config) + return self._bind_and_schedule( + runtime, host, port, config, protocol, http2_config + ) @overload def listen( @@ -318,6 +360,8 @@ def listen( port: int = 8000, config: ServerConfig | None = None, *, + protocol: str = "http1", + http2_config: HTTP2Config | None = None, runtime: None = None, start: Literal[True] | None = None, ) -> ServerHandle: ... @@ -329,6 +373,8 @@ def listen( port: int = 8000, config: ServerConfig | None = None, *, + protocol: str = "http1", + http2_config: HTTP2Config | None = None, runtime: _RuntimeLike, start: Literal[False] | None = None, ) -> ServerHandle: ... @@ -340,6 +386,8 @@ def listen( port: int = 8000, config: ServerConfig | None = None, *, + protocol: str = "http1", + http2_config: HTTP2Config | None = None, runtime: _StartableRuntime, start: bool, ) -> ServerHandle: ... @@ -350,6 +398,8 @@ def listen( port: int = 8000, config: ServerConfig | None = None, *, + protocol: str = "http1", + http2_config: HTTP2Config | None = None, runtime: _RuntimeLike | None = None, start: bool | None = None, ) -> ServerHandle: @@ -371,7 +421,9 @@ def listen( config.managed_runtime or ManagedRuntimeConfig() ) self._validate_runtime(runtime, require_start=should_start) - handle = self._bind_and_schedule(runtime, host, port, config) + handle = self._bind_and_schedule( + runtime, host, port, config, protocol, http2_config + ) if not should_start: return handle primary_error: BaseException | None = None @@ -447,6 +499,8 @@ def _bind_and_schedule( host: str, port: int, config: ServerConfig, + protocol: str, + http2_config: HTTP2Config | None, ) -> ServerHandle: """Shared validated bind-and-schedule core for ``serve`` and ``listen``.""" from SmallPackage import SmallTask @@ -455,6 +509,14 @@ def _bind_and_schedule( raise ValueError("host must be a non-empty string") if type(port) is not int or not 0 <= port <= 65535: raise ValueError("port must be an integer between 0 and 65535") + if protocol not in {"http1", "http2"}: + raise ValueError("protocol must be 'http1' or 'http2'") + if http2_config is not None and not isinstance(http2_config, HTTP2Config): + raise TypeError("http2_config must be an HTTP2Config or None") + if protocol == "http1" and http2_config is not None: + raise ValueError("http2_config requires protocol='http2'") + if protocol == "http2": + require_http2() marker = self._reserve_invocation() def release_marker() -> None: @@ -522,6 +584,8 @@ def release(completed: ServerHandle) -> None: wakeup, config, on_finalized=release, + protocol=protocol, + protocol_config=http2_config or HTTP2Config(), route_observer_channel=observer_channel, ) except BaseException as primary_error: @@ -642,9 +706,14 @@ async def _accept_loop(self, task: Any, handle: ServerHandle) -> None: connection_task: Any = None try: + routine = ( + self._http2_connection_loop + if handle._protocol == "http2" + else self._connection_loop + ) connection_task = SmallTask( handle._config.connection_priority, - self._connection_loop, + routine, args=(handle, client), name="smallserver-connection", ) @@ -743,11 +812,18 @@ async def _connection_loop( primary_error = exc raise finally: - observer_channel = handle._route_observer_channel - if route_error_event is not None and observer_channel is not None: - observer_channel.enqueue(route_error_event, task) + if route_error_event is not None: + self._enqueue_route_error(handle, route_error_event, task) handle._connection_finished(task, client, primary_error) + @staticmethod + def _enqueue_route_error( + handle: ServerHandle, event: RouteErrorEvent, task: Any + ) -> None: + observer_channel = handle._route_observer_channel + if observer_channel is not None: + observer_channel.enqueue(event, task) + async def _dispatch_websocket( self, task: Any, @@ -793,3 +869,263 @@ async def _send_response( headers["Connection"] = "close" payload = Response(response.status, response.body, headers).to_http1() await handle._transport.send_all(task, client, payload) + + async def _http2_connection_loop( + self, task: Any, handle: ServerHandle, client: TransportHandle + ) -> None: + from SmallPackage import SmallTask + + protocol: H2Protocol | None = None + state: _H2ConnectionState | None = None + primary_error: BaseException | None = None + try: + protocol = H2Protocol(handle._protocol_config) + state = _H2ConnectionState(protocol) + await handle._transport.send_all(task, client, protocol.initiate()) + writer = SmallTask( + handle._config.connection_priority, + self._http2_writer_loop, + args=(handle, client, state), + name="smallserver-http2-writer", + ) + state.writer_task = writer + shutdown_task = SmallTask( + handle._config.connection_priority + 1, + self._http2_shutdown_enforcer, + args=(handle, client, state), + name="smallserver-http2-shutdown-enforcer", + ) + state.shutdown_task = shutdown_task + watchdog = SmallTask( + handle._config.connection_priority + 1, + self._http2_watchdog, + args=(handle, client, state), + name="smallserver-http2-watchdog", + ) + state.watchdog_task = watchdog + child_tasks = [writer, shutdown_task, watchdog] + handle._owned_tasks.extend(child_tasks) + handle._runtime.fork(child_tasks) + handle._graceful_connections.add(id(client)) + handle._graceful_closers[id(client)] = state.request_shutdown + reader_batches = 0 + while not handle.closed and not protocol.remote_closed: + chunk = await handle._transport.recv( + task, client, handle._config.receive_chunk_bytes + ) + if not chunk: + break + state.mark_activity() + next_data = chunk + while True: + try: + ready = protocol.receive_data(next_data) + except Exception as protocol_error: + primary_error = protocol_error + state.close_error_code = 1 + break + for stream_id in protocol.take_cancelled_streams(): + handler = state.handlers.pop(stream_id, None) + if handler is not None: + handle._cancel_or_retain_task(handler) + for item in ready: + if not protocol.is_stream_active(item.stream_id): + continue + handler = SmallTask( + handle._config.connection_priority, + self._http2_handler, + args=(handle, state, item.stream_id, item.request), + name="smallserver-http2-stream-{}".format(item.stream_id), + ) + state.handlers[item.stream_id] = handler + handle._owned_tasks.append(handler) + handle._runtime.fork(handler) + state.wake_writer() + reader_batches += 1 + if protocol.has_pending_input: + await task.yield_now() + reader_batches = 0 + next_data = b"" + continue + if reader_batches >= protocol.config.reader_frame_batch_size: + await task.yield_now() + reader_batches = 0 + break + if primary_error is not None: + break + except Exception as exc: + primary_error = exc + except BaseException as exc: + primary_error = exc + raise + finally: + if primary_error is None and state is not None: + primary_error = state.failure + if state is not None: + state.closing = True + for handler in tuple(state.handlers.values()): + handle._cancel_or_retain_task(handler) + state.handlers.clear() + for child in ( + state.writer_task, + state.shutdown_task, + state.watchdog_task, + ): + if child is not None and child is not task: + handle._cancel_or_retain_task(child) + if child in handle._owned_tasks: + handle._owned_tasks.remove(child) + if protocol is not None and ( + primary_error is None or isinstance(primary_error, Exception) + ): + try: + goaway = protocol.close( + state.close_error_code if state is not None else 1 + ) + if goaway and not client.closed: + await handle._transport.send_all(task, client, goaway) + except BaseException: + pass + handle._connection_finished(task, client, primary_error) + + async def _http2_handler( + self, + task: Any, + handle: ServerHandle, + state: _H2ConnectionState, + stream_id: int, + request: Request, + ) -> None: + response_queued = False + try: + try: + response = await self.dispatch(request) + except RouteMatchTimeout as exc: + self._enqueue_route_error( + handle, + RouteErrorEvent( + route_id=exc.route_id, + category="route_match_timeout", + ), + task, + ) + response = Response.text("internal server error", status=500) + except Exception: + response = Response.text("internal server error", status=500) + state.protocol.queue_response(stream_id, response) + response_queued = True + state.wake_writer() + finally: + if not response_queued: + state.protocol.drop_stream(stream_id) + state.handlers.pop(stream_id, None) + if task in handle._owned_tasks: + handle._owned_tasks.remove(task) + + async def _http2_writer_loop( + self, + task: Any, + handle: ServerHandle, + client: TransportHandle, + state: _H2ConnectionState, + ) -> None: + try: + while not state.closing: + await task.wait_signal(_HTTP2_WRITER_SIGNAL) + if state.shutdown_requested: + state.closing = True + payload = state.protocol.close() + if payload: + await handle._transport.send_all(task, client, payload) + handle._force_connection_close(client, task) + return + while not state.closing: + payload = state.protocol.flush() + if not payload: + break + await handle._transport.send_all(task, client, payload) + except Exception as error: + state.failure = error + state.closing = True + handle._force_connection_close(client, task, error) + raise + finally: + if task in handle._owned_tasks: + handle._owned_tasks.remove(task) + + async def _http2_shutdown_enforcer( + self, + task: Any, + handle: ServerHandle, + client: TransportHandle, + state: _H2ConnectionState, + ) -> None: + try: + await task.wait_signal(_HTTP2_SHUTDOWN_SIGNAL) + await task.yield_now() + if client.closed: + return + state.closing = True + handle._force_connection_close(client, task, state.failure) + finally: + if task in handle._owned_tasks: + handle._owned_tasks.remove(task) + + async def _http2_watchdog( + self, + task: Any, + handle: ServerHandle, + client: TransportHandle, + state: _H2ConnectionState, + ) -> None: + config = state.protocol.config + try: + handshake_elapsed = 0.0 + while not state.protocol.preface_received and not state.closing: + interval = min(1.0, config.handshake_timeout - handshake_elapsed) + await task.sleep(interval) + handshake_elapsed += interval + if handshake_elapsed >= config.handshake_timeout: + self._http2_force_close( + task, + handle, + client, + state, + TimeoutError("HTTP/2 client preface timed out"), + ) + return + + observed_epoch = state.activity_epoch + idle_elapsed = 0.0 + while not state.closing: + interval = min(1.0, config.idle_timeout - idle_elapsed) + await task.sleep(interval) + if observed_epoch != state.activity_epoch: + observed_epoch = state.activity_epoch + idle_elapsed = 0.0 + continue + idle_elapsed += interval + if idle_elapsed >= config.idle_timeout: + self._http2_force_close( + task, + handle, + client, + state, + TimeoutError("HTTP/2 connection was idle too long"), + ) + return + finally: + if task in handle._owned_tasks: + handle._owned_tasks.remove(task) + + @staticmethod + def _http2_force_close( + task: Any, + handle: ServerHandle, + client: TransportHandle, + state: _H2ConnectionState, + error: BaseException, + ) -> None: + state.failure = error + state.closing = True + handle._force_connection_close(client, task, error) diff --git a/smallserver/http2.py b/smallserver/http2.py new file mode 100644 index 0000000..87ce226 --- /dev/null +++ b/smallserver/http2.py @@ -0,0 +1,702 @@ +"""Lazy, bounded HTTP/2 protocol state built on optional hyper-h2.""" + +from __future__ import annotations + +from dataclasses import dataclass +import math +from typing import Any + +from .errors import ServerConfigurationError +from .http import Headers, Request, Response + + +HTTP2_CLIENT_PREFACE = b"PRI * HTTP/2.0\r\n\r\nSM\r\n\r\n" + + +@dataclass(frozen=True) +class HTTP2Config: + """Finite protocol and buffering limits for HTTP/2 connections.""" + + max_concurrent_streams: int = 100 + max_header_count: int = 100 + max_header_bytes: int = 16 * 1024 + max_compressed_header_bytes: int = 16 * 1024 + max_body_bytes: int = 1024 * 1024 + max_connection_buffer_bytes: int = 4 * 1024 * 1024 + max_pending_output_bytes: int = 4 * 1024 * 1024 + max_response_body_bytes: int = 2 * 1024 * 1024 + max_control_output_bytes: int = 64 * 1024 + max_frame_size: int = 16 * 1024 + reader_frame_batch_size: int = 32 + handshake_timeout: float = 10.0 + idle_timeout: float = 60.0 + + def __post_init__(self) -> None: + integer_fields = { + name: value + for name, value in self.__dict__.items() + if name not in {"handshake_timeout", "idle_timeout"} + } + for name, value in integer_fields.items(): + if type(value) is not int or value <= 0: + raise ValueError("{} must be a positive integer".format(name)) + for name in ("handshake_timeout", "idle_timeout"): + value = getattr(self, name) + if ( + not isinstance(value, (int, float)) + or isinstance(value, bool) + or not math.isfinite(value) + or value <= 0 + ): + raise ValueError("{} must be a positive number".format(name)) + if not 16_384 <= self.max_frame_size <= 16_777_215: + raise ValueError("max_frame_size must be between 16384 and 16777215") + if self.max_body_bytes > self.max_connection_buffer_bytes: + raise ValueError( + "max_body_bytes cannot exceed max_connection_buffer_bytes" + ) + if self.max_response_body_bytes > self.max_pending_output_bytes: + raise ValueError( + "max_response_body_bytes cannot exceed max_pending_output_bytes" + ) + if self.max_control_output_bytes > self.max_pending_output_bytes: + raise ValueError( + "max_control_output_bytes cannot exceed max_pending_output_bytes" + ) + if self.max_control_output_bytes < 51: + raise ValueError( + "max_control_output_bytes must allow initial HTTP/2 settings" + ) + + +@dataclass(frozen=True) +class H2ReadyRequest: + """A complete request stream ready for application dispatch.""" + + stream_id: int + request: Request + + +@dataclass +class _InboundStream: + method: str + path: str + headers: Headers + body: bytearray | bytes + expected_content_length: int | None + dispatched: bool = False + + +@dataclass +class _OutboundStream: + body: bytes + offset: int = 0 + + +class _FrameBudget: + """Split complete frames and enforce wire-level allocation bounds.""" + + def __init__(self, config: HTTP2Config) -> None: + self._config = config + self._preface = bytearray() + self._header = bytearray() + self._payload = bytearray() + self._frame_length = 0 + self._frame_type = 0 + self._frame_flags = 0 + self._frame_stream = 0 + self._preface_received = False + self._header_stream: int | None = None + self._header_bytes = 0 + self._ready: list[bytes] = [] + + def feed(self, data: bytes) -> None: + view = memoryview(data) + offset = 0 + if not self._preface_received: + needed = len(HTTP2_CLIENT_PREFACE) - len(self._preface) + take = min(needed, len(view)) + self._preface.extend(view[:take]) + offset += take + if bytes(self._preface) != HTTP2_CLIENT_PREFACE[: len(self._preface)]: + raise ValueError("invalid HTTP/2 client preface") + if len(self._preface) < len(HTTP2_CLIENT_PREFACE): + return + self._ready.append(bytes(self._preface)) + self._preface.clear() + self._preface_received = True + + while offset < len(view): + if len(self._header) < 9: + take = min(9 - len(self._header), len(view) - offset) + self._header.extend(view[offset : offset + take]) + offset += take + if len(self._header) < 9: + return + self._start_frame() + if self._frame_length == 0: + self._finish_frame() + continue + take = min( + self._frame_length - len(self._payload), + len(view) - offset, + ) + self._payload.extend(view[offset : offset + take]) + offset += take + if len(self._payload) == self._frame_length: + self._finish_frame() + + def _start_frame(self) -> None: + length = int.from_bytes(self._header[:3], "big") + if length > self._config.max_frame_size: + raise ValueError("HTTP/2 frame exceeds configured maximum") + self._frame_length = length + self._frame_type = self._header[3] + self._frame_flags = self._header[4] + self._frame_stream = int.from_bytes(self._header[5:9], "big") & 0x7FFFFFFF + if self._frame_type == 0x1: + if self._header_stream is not None: + raise ValueError("interleaved HTTP/2 header blocks are invalid") + self._header_stream = self._frame_stream + self._header_bytes = length + elif self._frame_type == 0x9: + if self._header_stream != self._frame_stream: + raise ValueError("invalid HTTP/2 continuation stream") + self._header_bytes += length + if self._header_bytes > self._config.max_compressed_header_bytes: + raise ValueError("HTTP/2 compressed header block is too large") + + def _finish_frame(self) -> None: + self._ready.append(bytes(self._header + self._payload)) + if self._frame_type in (0x1, 0x9) and self._frame_flags & 0x4: + self._header_stream = None + self._header_bytes = 0 + self._header.clear() + self._payload.clear() + self._frame_length = 0 + + def take(self, limit: int) -> tuple[bytes, ...]: + chunks = self._ready[:limit] + del self._ready[:limit] + return tuple(chunks) + + @property + def has_ready_frames(self) -> bool: + return bool(self._ready) + + @property + def preface_received(self) -> bool: + return self._preface_received + + +def require_http2() -> None: + """Fail clearly without importing hyper-h2 on HTTP/1.1 paths.""" + try: + import h2 # type: ignore[import-not-found] + from h2.config import H2Configuration # noqa: F401 + from h2.connection import H2Connection # noqa: F401 + from h2.errors import ErrorCodes # noqa: F401 + from h2.events import ( # noqa: F401 + ConnectionTerminated, + DataReceived, + RemoteSettingsChanged, + RequestReceived, + StreamEnded, + StreamReset, + TrailersReceived, + WindowUpdated, + ) + from h2.settings import SettingCodes # noqa: F401 + except (ImportError, AttributeError) as exc: + raise ServerConfigurationError( + "HTTP/2 requires a complete hyper-h2 4.x installation; " + "install smallserver[http2]" + ) from exc + version = getattr(h2, "__version__", "") + if not isinstance(version, str) or not version.startswith("4."): + raise ServerConfigurationError("HTTP/2 requires hyper-h2 version 4.x") + if not callable(getattr(H2Connection, "_begin_new_stream", None)): + raise ServerConfigurationError( + "installed hyper-h2 4.x lacks required stream validation support" + ) + + +class H2Protocol: + """One connection's sans-I/O HTTP/2 and bounded stream state.""" + + _FORBIDDEN_HEADERS = { + "connection", + "keep-alive", + "proxy-connection", + "transfer-encoding", + "upgrade", + } + + def __init__(self, config: HTTP2Config | None = None) -> None: + require_http2() + from h2.config import H2Configuration # type: ignore[import-not-found] + from h2.connection import H2Connection # type: ignore[import-not-found] + from h2.errors import ErrorCodes # type: ignore[import-not-found] + from h2.events import ( # type: ignore[import-not-found] + ConnectionTerminated, + DataReceived, + RemoteSettingsChanged, + RequestReceived, + StreamEnded, + StreamReset, + TrailersReceived, + WindowUpdated, + ) + from h2.settings import SettingCodes # type: ignore[import-not-found] + + self.config = config or HTTP2Config() + h2_config = H2Configuration( + client_side=False, + header_encoding="utf-8", + validate_inbound_headers=True, + normalize_inbound_headers=False, + ) + class _SmallServerH2Connection(H2Connection): + def _begin_new_stream(self, stream_id: Any, allowed_ids: Any) -> Any: + stream = super()._begin_new_stream(stream_id, allowed_ids) + initializer = getattr(stream, "_initialize_content_length", None) + if not callable(initializer): + raise ServerConfigurationError( + "installed hyper-h2 4.x lacks required stream " + "validation support" + ) + # hyper-h2 treats content-length mismatch as connection-fatal. + # SmallServer owns this check so malformed request metadata can + # remain a stream-scoped error as required by RFC 9113. + stream._initialize_content_length = lambda headers: None + return stream + + self.connection = _SmallServerH2Connection(config=h2_config) + self.connection.local_settings[SettingCodes.MAX_CONCURRENT_STREAMS] = ( + self.config.max_concurrent_streams + ) + self.connection.local_settings[SettingCodes.MAX_HEADER_LIST_SIZE] = ( + self.config.max_header_bytes + ) + self.connection.local_settings[SettingCodes.MAX_FRAME_SIZE] = ( + self.config.max_frame_size + ) + self._events = { + "request": RequestReceived, + "data": DataReceived, + "ended": StreamEnded, + "reset": StreamReset, + "trailers": TrailersReceived, + "window": WindowUpdated, + "settings": RemoteSettingsChanged, + "terminated": ConnectionTerminated, + } + self._error_codes = ErrorCodes + self._frames = _FrameBudget(self.config) + self._inbound: dict[int, _InboundStream] = {} + self._active_streams: set[int] = set() + self._outbound: dict[int, _OutboundStream] = {} + self._commands: list[tuple[str, int, Response | None]] = [] + self._buffered_request_bytes = 0 + self._pending_output_bytes = 0 + self._control_output = bytearray() + self._cancelled_streams: list[int] = [] + self._ready_requests: list[H2ReadyRequest] = [] + self.last_processed_stream_id = 0 + self.remote_closed = False + self.local_closed = False + + @property + def active_stream_count(self) -> int: + return len(self._active_streams) + + @property + def pending_output_bytes(self) -> int: + return self._pending_output_bytes + len(self._control_output) + + @property + def buffered_request_bytes(self) -> int: + return self._buffered_request_bytes + + @property + def preface_received(self) -> bool: + return self._frames.preface_received + + @property + def has_pending_input(self) -> bool: + return self._frames.has_ready_frames + + def initiate(self) -> bytes: + self.connection.initiate_connection() + output = self.connection.data_to_send() + if len(output) > self.config.max_control_output_bytes: + raise ValueError("HTTP/2 control output exceeds configured maximum") + self._validate_wire_output(len(output), 0) + return output + + def receive_data(self, data: bytes) -> tuple[H2ReadyRequest, ...]: + self._frames.feed(data) + for wire_chunk in self._frames.take(self.config.reader_frame_batch_size): + events = self.connection.receive_data(wire_chunk) + for event in events: + if isinstance(event, self._events["request"]): + self._request_received(event.stream_id, event.headers) + elif isinstance(event, self._events["data"]): + self._data_received( + event.stream_id, event.data, event.flow_controlled_length + ) + elif isinstance(event, self._events["ended"]): + completed = self._stream_ended(event.stream_id) + if completed is not None: + self._ready_requests.append(completed) + elif isinstance(event, self._events["reset"]): + self._cancelled_streams.append(event.stream_id) + self.drop_stream(event.stream_id) + elif isinstance(event, self._events["trailers"]): + self._reset_stream(event.stream_id, self._error_codes.PROTOCOL_ERROR) + elif isinstance(event, self._events["terminated"]): + self.remote_closed = True + elif isinstance( + event, (self._events["window"], self._events["settings"]) + ): + pass + self._capture_control_output() + if self._frames.has_ready_frames: + return () + cancelled = set(self._cancelled_streams) + ready = tuple( + item + for item in self._ready_requests + if item.stream_id not in cancelled + and item.stream_id in self._active_streams + ) + self._ready_requests.clear() + return ready + + def is_stream_active(self, stream_id: int) -> bool: + return stream_id in self._active_streams + + def _capture_control_output(self) -> None: + produced = self.connection.data_to_send() + next_control_size = len(self._control_output) + len(produced) + if ( + next_control_size > self.config.max_control_output_bytes + or next_control_size + self._pending_output_bytes + > self.config.max_pending_output_bytes + ): + raise ValueError("HTTP/2 control output exceeds configured maximum") + self._control_output.extend(produced) + + def _validate_wire_output(self, new_bytes: int, already_buffered: int) -> None: + if ( + new_bytes + already_buffered + self._pending_output_bytes + > self.config.max_pending_output_bytes + ): + raise ValueError("HTTP/2 generated output exceeds configured maximum") + + def take_cancelled_streams(self) -> tuple[int, ...]: + """Return peer-reset stream ids exactly once.""" + cancelled, self._cancelled_streams = self._cancelled_streams, [] + return tuple(cancelled) + + def _request_received(self, stream_id: int, raw_headers: Any) -> None: + if len(self._active_streams) >= self.config.max_concurrent_streams: + self._reset_stream(stream_id, self._error_codes.REFUSED_STREAM) + return + try: + method, path, headers, content_length = self._decode_request_headers( + raw_headers + ) + except (TypeError, ValueError): + self._reset_stream(stream_id, self._error_codes.PROTOCOL_ERROR) + return + self._active_streams.add(stream_id) + self._inbound[stream_id] = _InboundStream( + method, path, headers, bytearray(), content_length + ) + + def _decode_request_headers( + self, raw_headers: Any + ) -> tuple[str, str, Headers, int | None]: + if len(raw_headers) > self.config.max_header_count: + raise ValueError("too many HTTP/2 request headers") + decoded_size = 0 + pseudo: dict[str, str] = {} + regular: list[tuple[str, str]] = [] + cookies: list[str] = [] + seen_regular = False + for name, value in raw_headers: + if not isinstance(name, str) or not isinstance(value, str): + raise TypeError("HTTP/2 headers must decode to text") + decoded_size += len(name.encode("utf-8")) + len(value.encode("utf-8")) + 32 + if decoded_size > self.config.max_header_bytes: + raise ValueError("HTTP/2 decoded headers are too large") + if name.startswith(":"): + if seen_regular or name in pseudo: + raise ValueError("invalid HTTP/2 pseudo-header ordering") + pseudo[name] = value + continue + seen_regular = True + lowered = name.lower() + if name != lowered or lowered in self._FORBIDDEN_HEADERS: + raise ValueError("forbidden HTTP/2 request header") + if lowered == "te" and value.lower() != "trailers": + raise ValueError("invalid HTTP/2 TE header") + if lowered == "cookie": + cookies.append(value) + else: + regular.append((name, value)) + allowed = {":method", ":scheme", ":authority", ":path"} + if set(pseudo) - allowed: + raise ValueError("unknown HTTP/2 pseudo-header") + if pseudo.get(":method") == "CONNECT": + raise ValueError("HTTP/2 CONNECT is not supported") + if not all(pseudo.get(name) for name in (":method", ":scheme", ":path")): + raise ValueError("missing required HTTP/2 pseudo-header") + method = pseudo[":method"] + path = pseudo[":path"] + if not method or any( + not ( + character.isascii() + and ( + character.isalnum() + or character in "!#$%&'*+-.^_`|~" + ) + ) + for character in method + ): + raise ValueError("invalid HTTP/2 method") + if ( + not path.startswith("/") + or "#" in path + or any(not 0x21 <= ord(character) <= 0x7E for character in path) + ): + raise ValueError("invalid HTTP/2 origin-form path") + authority = pseudo.get(":authority") + existing_host = any(name == "host" for name, _value in regular) + if authority and existing_host: + raise ValueError("HTTP/2 authority and host must not both be supplied") + if not authority and not existing_host: + raise ValueError("HTTP/2 requests require :authority or host") + if authority: + regular.append(("host", authority)) + if cookies: + regular.append(("cookie", "; ".join(cookies))) + headers = Headers(regular) + content_length: int | None = None + raw_length = headers.get("content-length") + if raw_length is not None: + if not raw_length.isascii() or not raw_length.isdecimal(): + raise ValueError("invalid HTTP/2 content-length") + content_length = int(raw_length) + if content_length > self.config.max_body_bytes: + raise ValueError("HTTP/2 content-length exceeds configured maximum") + return method, path, headers, content_length + + def _data_received(self, stream_id: int, data: bytes, flow_length: int) -> None: + self.connection.acknowledge_received_data(flow_length, stream_id) + stream = self._inbound.get(stream_id) + if stream is None: + return + next_stream_size = len(stream.body) + len(data) + next_connection_size = self._buffered_request_bytes + len(data) + if ( + next_stream_size > self.config.max_body_bytes + or ( + stream.expected_content_length is not None + and next_stream_size > stream.expected_content_length + ) + or next_connection_size > self.config.max_connection_buffer_bytes + ): + self._reset_stream(stream_id, self._error_codes.ENHANCE_YOUR_CALM) + return + if not isinstance(stream.body, bytearray): + self._reset_stream(stream_id, self._error_codes.STREAM_CLOSED) + return + stream.body.extend(data) + self._buffered_request_bytes = next_connection_size + + def _stream_ended(self, stream_id: int) -> H2ReadyRequest | None: + stream = self._inbound.get(stream_id) + if stream is None: + return None + if ( + stream.expected_content_length is not None + and len(stream.body) != stream.expected_content_length + ): + self._reset_stream(stream_id, self._error_codes.PROTOCOL_ERROR) + return None + stream.dispatched = True + self.last_processed_stream_id = max(self.last_processed_stream_id, stream_id) + body = bytes(stream.body) + stream.body = body + request = Request( + stream.method, + stream.path, + stream.headers, + body, + "HTTP/2", + ) + return H2ReadyRequest(stream_id, request) + + def queue_response(self, stream_id: int, response: Response) -> bool: + if stream_id not in self._active_streams: + return False + self._release_inbound(stream_id) + body_size = len(response.body) + if ( + body_size > self.config.max_response_body_bytes + or len(self._control_output) + self._pending_output_bytes + body_size + > self.config.max_pending_output_bytes + ): + if not any(command[1] == stream_id for command in self._commands): + self._commands.append(("reset", stream_id, None)) + return False + if len(self._commands) >= self.config.max_concurrent_streams * 2: + self._reset_stream(stream_id, self._error_codes.ENHANCE_YOUR_CALM) + return False + self._pending_output_bytes += body_size + self._commands.append(("response", stream_id, response)) + return True + + def flush(self) -> bytes: + commands, self._commands = self._commands, [] + for operation, stream_id, response in commands: + if operation == "reset": + self._reset_stream(stream_id, self._error_codes.ENHANCE_YOUR_CALM) + self._capture_control_output() + continue + assert response is not None + self._start_response(stream_id, response) + self._capture_control_output() + + output = bytearray(self._control_output) + self._control_output.clear() + + for stream_id, outbound in tuple(self._outbound.items()): + remaining = len(outbound.body) - outbound.offset + if remaining <= 0: + self._outbound.pop(stream_id, None) + self._active_streams.discard(stream_id) + continue + try: + window = self.connection.local_flow_control_window(stream_id) + except Exception: + self.drop_stream(stream_id) + continue + chunk_size = min( + remaining, + max(0, window), + self.connection.max_outbound_frame_size, + ) + if chunk_size <= 0: + continue + end_stream = chunk_size == remaining + chunk = memoryview(outbound.body)[ + outbound.offset : outbound.offset + chunk_size + ] + try: + self.connection.send_data(stream_id, chunk, end_stream=end_stream) + except Exception: + self.drop_stream(stream_id) + continue + outbound.offset += chunk_size + self._pending_output_bytes -= chunk_size + generated = self.connection.data_to_send() + self._validate_wire_output(len(generated), len(output)) + output.extend(generated) + if end_stream: + self._outbound.pop(stream_id, None) + self._active_streams.discard(stream_id) + generated = self.connection.data_to_send() + self._validate_wire_output(len(generated), len(output)) + output.extend(generated) + return bytes(output) + + def _start_response(self, stream_id: int, response: Response) -> None: + headers: list[tuple[str, str]] = [(":status", str(response.status))] + forbidden = self._FORBIDDEN_HEADERS | {"te"} + for name, value in response.headers.items(): + lowered = name.lower() + if lowered in forbidden: + continue + headers.append((lowered, value)) + if response.headers.get("content-length") is None: + headers.append(("content-length", str(len(response.body)))) + header_size = sum( + len(name.encode("utf-8")) + len(value.encode("utf-8")) + 32 + for name, value in headers + ) + if header_size > self.config.max_header_bytes: + self._pending_output_bytes -= len(response.body) + self._reset_stream(stream_id, self._error_codes.INTERNAL_ERROR) + return + try: + self.connection.send_headers( + stream_id, headers, end_stream=not response.body + ) + except Exception: + self._pending_output_bytes -= len(response.body) + self.drop_stream(stream_id) + return + if response.body: + self._outbound[stream_id] = _OutboundStream(response.body) + else: + self._active_streams.discard(stream_id) + + def drop_stream(self, stream_id: int) -> None: + self._release_inbound(stream_id) + self._ready_requests = [ + item for item in self._ready_requests if item.stream_id != stream_id + ] + outbound = self._outbound.pop(stream_id, None) + if outbound is not None: + self._pending_output_bytes -= len(outbound.body) - outbound.offset + kept: list[tuple[str, int, Response | None]] = [] + for command in self._commands: + if command[1] == stream_id and command[2] is not None: + self._pending_output_bytes -= len(command[2].body) + else: + kept.append(command) + self._commands = kept + self._active_streams.discard(stream_id) + + def _release_inbound(self, stream_id: int) -> None: + inbound = self._inbound.pop(stream_id, None) + if inbound is not None: + self._buffered_request_bytes -= len(inbound.body) + + def _reset_stream(self, stream_id: int, error_code: Any) -> None: + try: + self.connection.reset_stream(stream_id, error_code=error_code) + except Exception: + pass + self.drop_stream(stream_id) + + def close(self, error_code: int = 0) -> bytes: + if not self.local_closed: + self.local_closed = True + try: + self.connection.close_connection( + error_code=error_code, + last_stream_id=self.last_processed_stream_id, + ) + except Exception: + pass + self._inbound.clear() + self._outbound.clear() + self._commands.clear() + self._ready_requests.clear() + self._active_streams.clear() + self._buffered_request_bytes = 0 + self._pending_output_bytes = 0 + control = bytes(self._control_output) + self._control_output.clear() + generated = self.connection.data_to_send() + try: + self._validate_wire_output(len(generated), len(control)) + if len(control) + len(generated) > self.config.max_control_output_bytes: + raise ValueError("HTTP/2 control output exceeds configured maximum") + except ValueError: + return b"" + return control + generated diff --git a/smallserver/server.py b/smallserver/server.py index cc87f74..9f8b791 100644 --- a/smallserver/server.py +++ b/smallserver/server.py @@ -242,6 +242,8 @@ def __init__( wakeup: WakeupChannel | None, config: ServerConfig, on_finalized: Callable[[ServerHandle], None] | None = None, + protocol: str = "http1", + protocol_config: Any = None, route_observer_channel: RouteObserverChannel | None = None, ) -> None: self._runtime = runtime @@ -249,6 +251,8 @@ def __init__( self._listener = listener self._wakeup = wakeup self._config = config + self._protocol = protocol + self._protocol_config = protocol_config self._route_observer_channel = route_observer_channel self._address = transport.local_address(listener) self._on_finalized = on_finalized @@ -268,6 +272,8 @@ def __init__( self._pending_task_cancellations: dict[int, Any] = {} self._websocket_states: dict[int, Any] = {} self._capacity_waiting = False + self._graceful_connections: set[int] = set() + self._graceful_closers: dict[int, Callable[[], None]] = {} @property def address(self) -> tuple[str, int]: @@ -448,6 +454,7 @@ def _finish_close( self._cancelled_task_ids.add(identity) for identity, (connection, task) in list(self._connections.items()): + graceful_requested = False websocket_owned = identity in self._websocket_states if owner_thread: if ( @@ -459,15 +466,29 @@ def _finish_close( if self._cancel_or_retain_task(task): self._cancelled_task_ids.add(id(task)) elif task is not current_task: - try: - self._runtime.resume_task(task) - except BaseException: - pass + closer = self._graceful_closers.get(identity) + if closer is not None: + try: + closer() + graceful_requested = True + except BaseException: + try: + self._runtime.resume_task(task) + except BaseException: + pass + else: + try: + self._runtime.resume_task(task) + except BaseException: + pass if websocket_owned: # The live coordinator owns its bounded Close handshake # and releases the stream through _connection_finished(). - continue - if task is not current_task: + graceful_requested = True + if ( + task is not current_task + and not (not owner_thread and graceful_requested) + ): self._connections.pop(identity, None) self._close_or_retain(connection, current_task) @@ -516,12 +537,25 @@ def _close_or_retain( self._closing_connections.pop(identity, None) self._cleanup_errors.pop("connection:{}".format(identity), None) return True - self._closing_connections[identity] = connection + if identity not in self._connections: + self._closing_connections[identity] = connection error = connection.close_error or RuntimeError("kernel connection close failed") self._cleanup_errors["connection:{}".format(identity)] = error self._connection_close_failed(error, task, primary_error) return False + def _force_connection_close( + self, + connection: TransportHandle, + task: Any = None, + primary_error: BaseException | None = None, + ) -> bool: + """Stop graceful handling and close through retryable ownership.""" + identity = id(connection) + self._graceful_connections.discard(identity) + self._graceful_closers.pop(identity, None) + return self._close_or_retain(connection, task, primary_error) + def _connection_close_failed( self, error: BaseException, @@ -580,6 +614,8 @@ def _connection_finished( """Release a completed connection without losing failed-close ownership.""" previous_count = self.owned_connection_count entry = self._connections.pop(id(connection), None) + self._graceful_connections.discard(id(connection)) + self._graceful_closers.pop(id(connection), None) owned_task = entry[1] if entry is not None else task if owned_task in self._owned_tasks: self._owned_tasks.remove(owned_task) diff --git a/tests/test_documentation.py b/tests/test_documentation.py new file mode 100644 index 0000000..8a4120e --- /dev/null +++ b/tests/test_documentation.py @@ -0,0 +1,79 @@ +import re +import unittest +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[1] +GUIDE_FILES = { + "index.md", + "getting-started.md", + "routing.md", + "requests-and-responses.md", + "runtime-lifecycle.md", + "configuration.md", + "http2.md", + "websockets.md", + "adapters.md", + "errors-observability.md", + "platforms-kernels.md", + "api-reference.md", + "protocol-roadmap.md", + "development.md", +} +MARKDOWN_LINK = re.compile(r"(? {}".format(document.relative_to(ROOT), target)) + continue + if separator: + headings = { + heading_slug(value) + for value in HEADING.findall(destination.read_text()) + } + if fragment not in headings: + failures.append( + "{} -> {} (missing heading)".format( + document.relative_to(ROOT), target + ) + ) + self.assertEqual(failures, []) + + def test_python_code_blocks_compile(self): + failures = [] + for document in self._documents(): + for position, source in enumerate(PYTHON_BLOCK.findall(document.read_text()), 1): + try: + compile(source, "{}:block{}".format(document, position), "exec") + except SyntaxError as exc: + failures.append(str(exc)) + self.assertEqual(failures, []) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_http2.py b/tests/test_http2.py new file mode 100644 index 0000000..8907d77 --- /dev/null +++ b/tests/test_http2.py @@ -0,0 +1,1264 @@ +import builtins +import asyncio +import importlib.util +import socket +import threading +import unittest +from unittest.mock import patch + +try: + from h2.config import H2Configuration + from h2.connection import H2Connection + from h2.events import ( + ConnectionTerminated, + DataReceived, + ResponseReceived, + StreamEnded, + StreamReset, + ) +except ImportError: + H2_AVAILABLE = False +else: + H2_AVAILABLE = True + +from SmallPackage import SmallOS, Unix + +from smallserver import ( + HTTP2Config, + Headers, + RegexRouteConfig, + Request, + Response, + RouteErrorEvent, + RouteMatchTimeout, + SmallServer, +) +from smallserver.app import _H2ConnectionState +from smallserver._transport import KernelTransport, TransportHandle +from smallserver.errors import ServerConfigurationError +from smallserver.http2 import H2Protocol, _FrameBudget +from smallserver.server import ( + RouteObserverChannel, + ServerConfig, + ServerHandle, + run_route_observer, +) +from tests.kernel_fakes import FakeKernel, OpaqueHandle + + +class HTTP2OptionalDependencyTests(unittest.TestCase): + def test_dependency_is_lazy_and_missing_extra_is_actionable(self): + original = builtins.__import__ + + def reject_h2(name, *args, **kwargs): + if name == "h2" or name.startswith("h2."): + raise ImportError("missing") + return original(name, *args, **kwargs) + + with patch("builtins.__import__", side_effect=reject_h2): + with self.assertRaisesRegex(ServerConfigurationError, "smallserver\\[http2\\]"): + H2Protocol() + + def test_incomplete_extra_is_rejected_during_preflight(self): + original = builtins.__import__ + + def reject_events(name, *args, **kwargs): + if name == "h2.events": + raise ImportError("broken events module") + return original(name, *args, **kwargs) + + with patch("builtins.__import__", side_effect=reject_events): + with self.assertRaisesRegex(ServerConfigurationError, "complete hyper-h2"): + H2Protocol() + + def test_timeout_configuration_is_finite_and_positive(self): + for values in ( + {"handshake_timeout": 0}, + {"idle_timeout": -1}, + {"idle_timeout": True}, + {"handshake_timeout": float("nan")}, + {"handshake_timeout": float("inf")}, + {"idle_timeout": float("-inf")}, + ): + with self.subTest(values=values): + with self.assertRaises(ValueError): + HTTP2Config(**values) + + def test_dependency_preflight_happens_before_address_resolution(self): + class Runtime: + def __init__(self): + self.kernel = FakeKernel() + + def fork(self, tasks): + return None + + def resume_task(self, task): + return None + + def cancel_task(self, task): + return None + + runtime = Runtime() + with patch( + "smallserver.app.require_http2", + side_effect=ServerConfigurationError("broken HTTP/2 dependency"), + ): + with self.assertRaisesRegex(ServerConfigurationError, "broken"): + SmallServer().serve(runtime, protocol="http2") + self.assertFalse( + any(call[0] == "resolve_passive_address" for call in runtime.kernel.calls) + ) + + def test_h2_force_close_failure_retains_one_owner_until_retry(self): + class Runtime: + def resume_task(self, task): + return None + + def cancel_task(self, task): + return None + + kernel = FakeKernel() + transport = KernelTransport(kernel) + listener = transport.open_listener("127.0.0.1", 0, 2) + wakeup = transport.create_wakeup_channel() + handle = ServerHandle(Runtime(), transport, listener, wakeup, ServerConfig()) + raw_client = OpaqueHandle("h2-client") + client = TransportHandle(raw_client) + reader_task = object() + writer_error = RuntimeError("writer failed") + handle._connections[id(client)] = (client, reader_task) + handle._graceful_connections.add(id(client)) + handle._graceful_closers[id(client)] = lambda: None + kernel.close_failures[id(raw_client)] = 2 + + self.assertFalse( + handle._force_connection_close(client, object(), writer_error) + ) + self.assertEqual(handle.owned_connection_count, 1) + self.assertEqual(len(handle.cleanup_errors), 1) + self.assertIs(handle.failure, writer_error) + + handle._finish_close() + self.assertFalse(handle.finished) + self.assertEqual(handle.owned_connection_count, 1) + handle._finish_close() + self.assertTrue(handle.finished) + self.assertEqual(handle.owned_connection_count, 0) + + +@unittest.skipUnless(H2_AVAILABLE, "install the smallserver[test] HTTP/2 extra") +class HTTP2ProtocolTests(unittest.TestCase): + def _pair(self, config=None): + client = H2Connection( + config=H2Configuration(client_side=True, header_encoding="utf-8") + ) + server = H2Protocol(config) + client.initiate_connection() + server_bytes = server.initiate() + server.receive_data(client.data_to_send()) + client.receive_data(server_bytes + server.flush()) + return client, server + + def test_prior_knowledge_request_uses_shared_values_and_response(self): + client, server = self._pair() + client.send_headers( + 1, + [ + (":method", "POST"), + (":scheme", "http"), + (":authority", "localhost"), + (":path", "/echo"), + ("content-type", "text/plain"), + ], + ) + client.send_data(1, b"hello", end_stream=True) + ready = server.receive_data(client.data_to_send()) + self.assertEqual(len(ready), 1) + self.assertEqual(ready[0].request.version, "HTTP/2") + self.assertEqual(ready[0].request.body, b"hello") + self.assertEqual(ready[0].request.headers["host"], "localhost") + + self.assertTrue(server.queue_response(1, Response.text("world"))) + events = client.receive_data(server.flush()) + self.assertTrue(any(isinstance(event, ResponseReceived) for event in events)) + self.assertEqual( + b"".join(event.data for event in events if isinstance(event, DataReceived)), + b"world", + ) + self.assertTrue(any(isinstance(event, StreamEnded) for event in events)) + + def test_multiplexed_streams_can_finish_out_of_order(self): + client, server = self._pair() + for stream_id, path in ((1, "/slow"), (3, "/fast")): + client.send_headers( + stream_id, + [ + (":method", "GET"), + (":scheme", "http"), + (":authority", "localhost"), + (":path", path), + ], + end_stream=True, + ) + ready = server.receive_data(client.data_to_send()) + self.assertEqual([item.stream_id for item in ready], [1, 3]) + server.queue_response(3, Response.text("fast")) + first = client.receive_data(server.flush()) + self.assertTrue(any(isinstance(event, StreamEnded) and event.stream_id == 3 for event in first)) + server.queue_response(1, Response.text("slow")) + second = client.receive_data(server.flush()) + self.assertTrue(any(isinstance(event, StreamEnded) and event.stream_id == 1 for event in second)) + + def test_request_and_response_limits_reset_streams_without_unbounded_buffers(self): + config = HTTP2Config( + max_body_bytes=4, + max_connection_buffer_bytes=8, + max_response_body_bytes=4, + max_pending_output_bytes=128, + max_control_output_bytes=64, + ) + client, server = self._pair(config) + client.send_headers( + 1, + [ + (":method", "POST"), + (":scheme", "http"), + (":authority", "localhost"), + (":path", "/"), + ], + ) + client.send_data(1, b"12345", end_stream=True) + self.assertEqual(server.receive_data(client.data_to_send()), ()) + client.receive_data(server.flush()) + self.assertEqual(server.active_stream_count, 0) + self.assertEqual(server.pending_output_bytes, 0) + + def test_malformed_preface_is_a_connection_error(self): + server = H2Protocol() + server.initiate() + with self.assertRaisesRegex(ValueError, "client preface"): + server.receive_data(b"NOT HTTP/2") + + def test_peer_reset_is_reported_once_for_handler_cancellation(self): + client, server = self._pair() + client.send_headers( + 1, + [ + (":method", "POST"), + (":scheme", "http"), + (":authority", "localhost"), + (":path", "/work"), + ], + ) + server.receive_data(client.data_to_send()) + client.reset_stream(1) + server.receive_data(client.data_to_send()) + self.assertEqual(server.take_cancelled_streams(), (1,)) + self.assertEqual(server.take_cancelled_streams(), ()) + + def test_same_batch_end_then_reset_drops_ready_request_but_keeps_sibling(self): + client, server = self._pair(HTTP2Config(reader_frame_batch_size=1)) + client.send_headers( + 1, + [ + (":method", "GET"), + (":scheme", "http"), + (":authority", "localhost"), + (":path", "/cancelled"), + ], + end_stream=True, + ) + client.reset_stream(1) + client.send_headers( + 3, + [ + (":method", "GET"), + (":scheme", "http"), + (":authority", "localhost"), + (":path", "/healthy"), + ], + end_stream=True, + ) + ready = server.receive_data(client.data_to_send()) + self.assertEqual(ready, ()) + while server.has_pending_input: + ready = server.receive_data(b"") + self.assertEqual([item.stream_id for item in ready], [3]) + self.assertEqual(server.take_cancelled_streams(), (1,)) + self.assertFalse(server.is_stream_active(1)) + self.assertTrue(server.is_stream_active(3)) + + def test_completed_slow_handler_body_remains_in_connection_budget(self): + config = HTTP2Config( + max_body_bytes=4, + max_connection_buffer_bytes=6, + ) + client, server = self._pair(config) + for stream_id, body in ((1, b"1234"), (3, b"5678")): + client.send_headers( + stream_id, + [ + (":method", "POST"), + (":scheme", "http"), + (":authority", "localhost"), + (":path", "/slow"), + ("content-length", "4"), + ], + ) + client.send_data(stream_id, body, end_stream=True) + ready = server.receive_data(client.data_to_send()) + if stream_id == 1: + self.assertEqual([item.stream_id for item in ready], [1]) + self.assertEqual(server.buffered_request_bytes, 4) + else: + self.assertEqual(ready, ()) + events = client.receive_data(server.flush()) + self.assertTrue( + any(isinstance(event, StreamReset) and event.stream_id == 3 for event in events) + ) + self.assertEqual(server.buffered_request_bytes, 4) + server.queue_response(1, Response.text("done")) + self.assertEqual(server.buffered_request_bytes, 0) + + def test_completed_body_has_one_retained_payload_object(self): + client, server = self._pair() + client.send_headers( + 1, + [ + (":method", "POST"), + (":scheme", "http"), + (":authority", "localhost"), + (":path", "/slow"), + ], + ) + client.send_data(1, b"retained", end_stream=True) + ready = server.receive_data(client.data_to_send()) + retained = server._inbound[1].body + self.assertIsInstance(retained, bytes) + self.assertIs(retained, ready[0].request.body) + + def test_bad_stream_metadata_resets_only_that_stream(self): + client, server = self._pair() + client.send_headers( + 1, + [ + (":method", "POST"), + (":scheme", "http"), + (":authority", "localhost"), + (":path", "/bad"), + ("content-length", "2"), + ], + ) + client.send_data(1, b"x", end_stream=True) + client.send_headers( + 3, + [ + (":method", "GET"), + (":scheme", "http"), + (":authority", "localhost"), + (":path", "/good"), + ], + end_stream=True, + ) + ready = server.receive_data(client.data_to_send()) + self.assertEqual([item.stream_id for item in ready], [3]) + events = client.receive_data(server.flush()) + self.assertTrue( + any(isinstance(event, StreamReset) and event.stream_id == 1 for event in events) + ) + + def test_invalid_method_and_origin_form_are_stream_errors(self): + client, server = self._pair() + client.config.validate_outbound_headers = False + for stream_id, method, path in ( + (1, "BAD METHOD", "/bad"), + (3, "GET", "/bad#fragment"), + ): + client.send_headers( + stream_id, + [ + (":method", method), + (":scheme", "http"), + (":authority", "localhost"), + (":path", path), + ], + end_stream=True, + ) + client.send_headers( + 5, + [ + (":method", "GET"), + (":scheme", "http"), + (":authority", "localhost"), + (":path", "/good"), + ], + end_stream=True, + ) + ready = server.receive_data(client.data_to_send()) + self.assertEqual([item.stream_id for item in ready], [5]) + events = client.receive_data(server.flush()) + self.assertEqual( + {event.stream_id for event in events if isinstance(event, StreamReset)}, + {1, 3}, + ) + + def test_invalid_content_length_is_a_stream_error(self): + client, server = self._pair() + client.send_headers( + 1, + [ + (":method", "POST"), + (":scheme", "http"), + (":authority", "localhost"), + (":path", "/bad"), + ("content-length", "-1"), + ], + end_stream=True, + ) + client.send_headers( + 3, + [ + (":method", "GET"), + (":scheme", "http"), + (":authority", "localhost"), + (":path", "/good"), + ], + end_stream=True, + ) + ready = server.receive_data(client.data_to_send()) + self.assertEqual([item.stream_id for item in ready], [3]) + events = client.receive_data(server.flush()) + self.assertTrue( + any(isinstance(event, StreamReset) and event.stream_id == 1 for event in events) + ) + + def test_control_output_is_bounded_and_frames_are_processed_in_batches(self): + config = HTTP2Config( + reader_frame_batch_size=2, + max_control_output_bytes=64, + ) + client, server = self._pair(config) + for value in range(6): + client.ping(value.to_bytes(8, "big")) + server.receive_data(client.data_to_send()) + self.assertTrue(server.has_pending_input) + batches = 1 + while server.has_pending_input: + client.receive_data(server.flush()) + server.receive_data(b"") + batches += 1 + client.receive_data(server.flush()) + self.assertGreaterEqual(batches, 3) + self.assertEqual(server.pending_output_bytes, 0) + + limited_client, limited_server = self._pair( + HTTP2Config(max_control_output_bytes=52) + ) + for value in range(4): + limited_client.ping(value.to_bytes(8, "big")) + with self.assertRaisesRegex(ValueError, "control output"): + limited_server.receive_data(limited_client.data_to_send()) + + def test_compressed_header_budget_rejects_declared_size_before_payload(self): + budget = _FrameBudget(HTTP2Config(max_compressed_header_bytes=4)) + budget.feed(b"PRI * HTTP/2.0\r\n\r\nSM\r\n\r\n") + header = b"\x00\x00\x05" + b"\x01\x04" + b"\x00\x00\x00\x01" + with self.assertRaisesRegex(ValueError, "compressed header"): + budget.feed(header) + self.assertEqual(len(budget._payload), 0) + + def test_response_headers_and_command_resets_obey_output_budget(self): + header_client, header_server = self._pair( + HTTP2Config( + max_pending_output_bytes=64, + max_control_output_bytes=64, + max_response_body_bytes=1, + ) + ) + header_client.send_headers( + 1, + [ + (":method", "GET"), + (":scheme", "http"), + (":authority", "localhost"), + (":path", "/"), + ], + end_stream=True, + ) + header_server.receive_data(header_client.data_to_send()) + header_server.flush() + header_server.queue_response( + 1, + Response(headers={"x-large": "abcdefghijklmnopqrstuvwxyz" * 8}), + ) + with self.assertRaisesRegex(ValueError, "control output"): + header_server.flush() + + reset_client, reset_server = self._pair( + HTTP2Config( + max_pending_output_bytes=52, + max_control_output_bytes=52, + max_response_body_bytes=1, + ) + ) + reset_client.send_headers( + 1, + [ + (":method", "GET"), + (":scheme", "http"), + (":authority", "localhost"), + (":path", "/"), + ], + end_stream=True, + ) + for value in range(3): + reset_client.ping(value.to_bytes(8, "big")) + reset_server.receive_data(reset_client.data_to_send()) + reset_server.queue_response(1, Response(body=b"xx")) + with self.assertRaisesRegex(ValueError, "control output"): + reset_server.flush() + + @unittest.skipUnless( + importlib.util.find_spec("regex") is not None, + "install the smallserver[test] regex extra", + ) + def test_regex_timeout_observer_is_opaque_once_and_sibling_survives(self): + secret = "/private-target-should-not-escape" + app = SmallServer() + + @app.get_regex(r"/private-target-(?P.*)") + async def timed_route(request): + return Response.text("must not run") + + @app.get("/healthy") + async def healthy(request): + return Response.text("healthy") + + class ObserverTask: + done = False + + @staticmethod + def getID(): + return 17 + + @staticmethod + def acceptSignal(signal): + return 0 + + class HandlerTask: + def sendSignal(self, task_id, signal): + return 0 + + class Protocol: + def __init__(self): + self.responses = {} + + def queue_response(self, stream_id, response): + self.responses[stream_id] = response + + def drop_stream(self, stream_id): + raise AssertionError("completed streams must not be dropped") + + observed = [] + + def observe(event): + observed.append(event) + channel.stop() + + channel = RouteObserverChannel(observe, max_events=4) + channel.bind(ObserverTask()) + task = HandlerTask() + handle = type( + "Handle", + (), + {"_route_observer_channel": channel, "_owned_tasks": [task]}, + )() + protocol = Protocol() + state = _H2ConnectionState(protocol) + state.handlers = {1: task, 3: task} + hostile = Request("GET", secret, Headers(), version="HTTP/2") + sibling = Request("GET", "/healthy", Headers(), version="HTTP/2") + + with patch.object( + app._router, + "_match", + side_effect=RouteMatchTimeout("regex-route-1"), + ): + asyncio.run(app._http2_handler(task, handle, state, 1, hostile)) + asyncio.run(app._http2_handler(task, handle, state, 3, sibling)) + asyncio.run(run_route_observer(ObserverTask(), channel)) + + self.assertEqual(protocol.responses[1].status, 500) + self.assertEqual(protocol.responses[3].body, b"healthy") + self.assertEqual( + observed, + [RouteErrorEvent("regex-route-1", "route_match_timeout")], + ) + event_graph = repr(observed[0]) + self.assertNotIn(secret, event_graph) + self.assertFalse(hasattr(observed[0], "__traceback__")) + + +@unittest.skipUnless(H2_AVAILABLE, "install the smallserver[test] HTTP/2 extra") +class HTTP2ServerIntegrationTests(unittest.TestCase): + _pair = HTTP2ProtocolTests._pair + + @unittest.skipUnless( + importlib.util.find_spec("regex") is not None, + "install the smallserver[test] regex extra", + ) + def test_regex_timeout_is_observed_once_without_harming_other_streams(self): + runtime = SmallOS().setKernel(Unix()) + observed = [] + observer_finished = threading.Event() + + def observe(event): + observed.append(event) + observer_finished.set() + raise RuntimeError("intentional observer failure") + + app = SmallServer( + RegexRouteConfig(match_timeout=0.001, total_match_timeout=0.005), + route_error_observer=observe, + ) + + @app.post_regex(r"/(a+)+$") + async def expensive(request): + return Response.text("must not run") + + @app.get("/healthy") + async def healthy(request): + return Response.text("healthy") + + try: + server = app.serve( + runtime, host="127.0.0.1", port=0, protocol="http2" + ) + except PermissionError: + self.skipTest("the current sandbox does not permit loopback TCP binds") + + hostile_path = "/" + "a" * 5000 + "!" + authorization_secret = "Bearer h2-private-authorization" + body_secret = b"h2-private-body" + statuses = {} + bodies = {1: bytearray(), 3: bytearray(), 5: bytearray()} + errors = [] + + def client_work(): + try: + client = H2Connection( + config=H2Configuration( + client_side=True, header_encoding="utf-8" + ) + ) + client.initiate_connection() + with socket.create_connection( + ("127.0.0.1", server.port), timeout=3 + ) as connection: + connection.sendall(client.data_to_send()) + client.send_headers( + 1, + [ + (":method", "POST"), + (":scheme", "http"), + (":authority", "localhost"), + (":path", hostile_path), + ("authorization", authorization_secret), + ("content-length", str(len(body_secret))), + ], + ) + client.send_data(1, body_secret, end_stream=True) + client.send_headers( + 3, + [ + (":method", "GET"), + (":scheme", "http"), + (":authority", "localhost"), + (":path", "/healthy"), + ], + end_stream=True, + ) + connection.sendall(client.data_to_send()) + ended = set() + while not {1, 3}.issubset(ended): + data = connection.recv(65535) + if not data: + raise RuntimeError("HTTP/2 connection ended before sibling response") + for event in client.receive_data(data): + if isinstance(event, ResponseReceived): + statuses[event.stream_id] = dict(event.headers)[":status"] + elif isinstance(event, DataReceived): + bodies[event.stream_id].extend(event.data) + client.acknowledge_received_data( + event.flow_controlled_length, event.stream_id + ) + elif isinstance(event, StreamEnded): + ended.add(event.stream_id) + pending = client.data_to_send() + if pending: + connection.sendall(pending) + + client.send_headers( + 5, + [ + (":method", "GET"), + (":scheme", "http"), + (":authority", "localhost"), + (":path", "/healthy"), + ], + end_stream=True, + ) + connection.sendall(client.data_to_send()) + while 5 not in ended: + data = connection.recv(65535) + if not data: + raise RuntimeError("HTTP/2 connection ended before later response") + for event in client.receive_data(data): + if isinstance(event, ResponseReceived): + statuses[event.stream_id] = dict(event.headers)[":status"] + elif isinstance(event, DataReceived): + bodies[event.stream_id].extend(event.data) + client.acknowledge_received_data( + event.flow_controlled_length, event.stream_id + ) + elif isinstance(event, StreamEnded): + ended.add(event.stream_id) + pending = client.data_to_send() + if pending: + connection.sendall(pending) + + if not observer_finished.wait(2): + raise TimeoutError("route observer did not run") + server.close() + while connection.recv(65535): + pass + except BaseException as exc: + errors.append(exc) + try: + server.close() + except BaseException: + pass + + worker = threading.Thread(target=client_work, daemon=True) + worker.start() + runtime.start() + worker.join(timeout=4) + + self.assertFalse(worker.is_alive()) + self.assertEqual(errors, []) + self.assertEqual(statuses, {1: "500", 3: "200", 5: "200"}) + self.assertEqual(bytes(bodies[3]), b"healthy") + self.assertEqual(bytes(bodies[5]), b"healthy") + self.assertNotIn(hostile_path.encode("ascii"), bytes(bodies[1])) + self.assertEqual( + observed, + [RouteErrorEvent("regex-route-1", "route_match_timeout")], + ) + self.assertEqual( + vars(observed[0]), + {"route_id": "regex-route-1", "category": "route_match_timeout"}, + ) + for secret in (hostile_path, authorization_secret, body_secret.decode("ascii")): + self.assertNotIn(secret, repr(observed[0])) + self.assertFalse(hasattr(observed[0], "__traceback__")) + self.assertEqual(server.route_observer_failures, 1) + self.assertEqual(server.dropped_route_error_events, 0) + self.assertTrue(server.finished) + self.assertIsNone(server.failure) + self.assertEqual(server.owned_connection_count, 0) + self.assertEqual(runtime.ioReadWaiters, {}) + self.assertEqual(runtime.ioWriteWaiters, {}) + channel = server._route_observer_channel + self.assertIsNotNone(channel) + assert channel is not None + self.assertIsNone(channel.task) + self.assertEqual(list(channel.events), []) + + def test_prior_knowledge_multiplexing_and_graceful_goaway(self): + runtime = SmallOS().setKernel(Unix()) + app = SmallServer() + + @app.get("/one") + async def one(request): + return Response.text("one") + + @app.get("/two") + async def two(request): + return Response.text("two") + + try: + server = app.serve( + runtime, host="127.0.0.1", port=0, protocol="http2" + ) + except PermissionError: + self.skipTest("the current sandbox does not permit loopback TCP binds") + + bodies = {1: bytearray(), 3: bytearray()} + ended = set() + terminated = [] + errors = [] + + def client_work(): + try: + client = H2Connection( + config=H2Configuration( + client_side=True, header_encoding="utf-8" + ) + ) + client.initiate_connection() + with socket.create_connection( + ("127.0.0.1", server.port), timeout=3 + ) as connection: + connection.sendall(client.data_to_send()) + for stream_id, path in ((1, "/one"), (3, "/two")): + client.send_headers( + stream_id, + [ + (":method", "GET"), + (":scheme", "http"), + (":authority", "localhost"), + (":path", path), + ], + end_stream=True, + ) + connection.sendall(client.data_to_send()) + while len(ended) < 2: + data = connection.recv(65535) + if not data: + raise RuntimeError("HTTP/2 connection ended early") + for event in client.receive_data(data): + if isinstance(event, DataReceived): + bodies[event.stream_id].extend(event.data) + client.acknowledge_received_data( + event.flow_controlled_length, event.stream_id + ) + elif isinstance(event, StreamEnded): + ended.add(event.stream_id) + pending = client.data_to_send() + if pending: + connection.sendall(pending) + server.close() + while True: + data = connection.recv(65535) + if not data: + break + terminated.extend( + event + for event in client.receive_data(data) + if isinstance(event, ConnectionTerminated) + ) + except BaseException as exc: + errors.append(exc) + try: + server.close() + except BaseException: + pass + + worker = threading.Thread(target=client_work, daemon=True) + worker.start() + runtime.start() + worker.join(timeout=3) + self.assertFalse(worker.is_alive()) + self.assertEqual(errors, []) + self.assertEqual(bytes(bodies[1]), b"one") + self.assertEqual(bytes(bodies[3]), b"two") + self.assertTrue(terminated) + self.assertTrue(server.finished) + self.assertIsNone(server.failure) + + def test_same_batch_reset_never_spawns_cancelled_handler(self): + runtime = SmallOS().setKernel(Unix()) + app = SmallServer() + called = [] + + @app.get("/cancelled") + async def cancelled(request): + called.append("cancelled") + return Response.text("wrong") + + @app.get("/healthy") + async def healthy(request): + called.append("healthy") + return Response.text("ok") + + try: + server = app.serve( + runtime, host="127.0.0.1", port=0, protocol="http2" + ) + except PermissionError: + self.skipTest("the current sandbox does not permit loopback TCP binds") + errors = [] + healthy_body = bytearray() + + def client_work(): + try: + client = H2Connection(config=H2Configuration(client_side=True)) + client.initiate_connection() + with socket.create_connection( + ("127.0.0.1", server.port), timeout=3 + ) as connection: + connection.sendall(client.data_to_send()) + client.send_headers( + 1, + [ + (":method", "GET"), + (":scheme", "http"), + (":authority", "localhost"), + (":path", "/cancelled"), + ], + end_stream=True, + ) + client.reset_stream(1) + client.send_headers( + 3, + [ + (":method", "GET"), + (":scheme", "http"), + (":authority", "localhost"), + (":path", "/healthy"), + ], + end_stream=True, + ) + connection.sendall(client.data_to_send()) + ended = False + while not ended: + for event in client.receive_data(connection.recv(65535)): + if isinstance(event, DataReceived) and event.stream_id == 3: + healthy_body.extend(event.data) + elif isinstance(event, StreamEnded) and event.stream_id == 3: + ended = True + server.close() + while connection.recv(65535): + pass + except BaseException as exc: + errors.append(exc) + try: + server.close() + except BaseException: + pass + + worker = threading.Thread(target=client_work, daemon=True) + worker.start() + runtime.start() + worker.join(timeout=3) + self.assertFalse(worker.is_alive()) + self.assertEqual(errors, []) + self.assertEqual(called, ["healthy"]) + self.assertEqual(bytes(healthy_body), b"ok") + self.assertEqual(server.owned_connection_count, 0) + + def test_large_response_respects_flow_control(self): + body = b"x" * 100_000 + config = HTTP2Config( + max_response_body_bytes=len(body), + max_pending_output_bytes=len(body) + 64 * 1024, + ) + client, server = self._pair(config) + client.send_headers( + 1, + [ + (":method", "GET"), + (":scheme", "http"), + (":authority", "localhost"), + (":path", "/"), + ], + end_stream=True, + ) + server.receive_data(client.data_to_send()) + server.queue_response(1, Response(body=body)) + received = bytearray() + ended = False + for _ in range(20): + events = client.receive_data(server.flush()) + for event in events: + if isinstance(event, DataReceived): + received.extend(event.data) + client.acknowledge_received_data( + event.flow_controlled_length, event.stream_id + ) + elif isinstance(event, StreamEnded): + ended = True + updates = client.data_to_send() + if updates: + server.receive_data(updates) + if ended: + break + self.assertTrue(ended) + self.assertEqual(bytes(received), body) + self.assertEqual(server.pending_output_bytes, 0) + + def test_writer_send_failure_closes_only_client_and_listener_stays_healthy(self): + runtime = SmallOS().setKernel(Unix()) + app = SmallServer() + + @app.get("/fail") + async def fail(request): + return Response.text("response") + + try: + server = app.serve( + runtime, host="127.0.0.1", port=0, protocol="http2" + ) + except PermissionError: + self.skipTest("the current sandbox does not permit loopback TCP binds") + original_transport = server._transport + failure_injected = False + + class FailingWriterTransport: + def __getattr__(self, name): + return getattr(original_transport, name) + + async def send_all(self, task, stream, data): + nonlocal failure_injected + if ( + getattr(task, "name", "") == "smallserver-http2-writer" + and not failure_injected + ): + failure_injected = True + raise RuntimeError("injected HTTP/2 writer failure") + await original_transport.send_all(task, stream, data) + + server._transport = FailingWriterTransport() + errors = [] + + def client_work(): + try: + responses = [] + for _attempt in range(2): + client = H2Connection(config=H2Configuration(client_side=True)) + client.initiate_connection() + body = bytearray() + with socket.create_connection( + ("127.0.0.1", server.port), timeout=3 + ) as connection: + connection.sendall(client.data_to_send()) + client.send_headers( + 1, + [ + (":method", "GET"), + (":scheme", "http"), + (":authority", "localhost"), + (":path", "/fail"), + ], + end_stream=True, + ) + connection.sendall(client.data_to_send()) + ended = False + while not ended: + data = connection.recv(65535) + if not data: + break + for event in client.receive_data(data): + if isinstance(event, DataReceived): + body.extend(event.data) + elif isinstance(event, StreamEnded): + ended = True + responses.append(bytes(body)) + self.assertEqual(responses, [b"", b"response"]) + server.close() + except BaseException as exc: + errors.append(exc) + try: + server.close() + except BaseException: + pass + + worker = threading.Thread(target=client_work, daemon=True) + worker.start() + runtime.start() + worker.join(timeout=3) + self.assertFalse(worker.is_alive()) + self.assertEqual(errors, []) + self.assertTrue(failure_injected) + self.assertIsNone(server.failure) + self.assertEqual(server.owned_connection_count, 0) + self.assertTrue(server.finished) + + def test_shutdown_force_closes_a_blocked_writer(self): + runtime = SmallOS().setKernel(Unix()) + app = SmallServer() + + @app.get("/blocked") + async def blocked(request): + return Response.text("response") + + try: + server = app.serve( + runtime, host="127.0.0.1", port=0, protocol="http2" + ) + except PermissionError: + self.skipTest("the current sandbox does not permit loopback TCP binds") + original_transport = server._transport + writer_blocked = threading.Event() + + class BlockingWriterTransport: + def __getattr__(self, name): + return getattr(original_transport, name) + + async def send_all(self, task, stream, data): + if getattr(task, "name", "") == "smallserver-http2-writer": + writer_blocked.set() + await task.wait_signal(28) + return + await original_transport.send_all(task, stream, data) + + server._transport = BlockingWriterTransport() + errors = [] + + def client_work(): + try: + client = H2Connection(config=H2Configuration(client_side=True)) + client.initiate_connection() + with socket.create_connection( + ("127.0.0.1", server.port), timeout=3 + ) as connection: + connection.sendall(client.data_to_send()) + client.send_headers( + 1, + [ + (":method", "GET"), + (":scheme", "http"), + (":authority", "localhost"), + (":path", "/blocked"), + ], + end_stream=True, + ) + connection.sendall(client.data_to_send()) + if not writer_blocked.wait(2): + raise TimeoutError("writer did not enter its blocked wait") + server.close() + while connection.recv(65535): + pass + except BaseException as exc: + errors.append(exc) + try: + server.close() + except BaseException: + pass + + worker = threading.Thread(target=client_work, daemon=True) + worker.start() + runtime.start() + worker.join(timeout=3) + self.assertFalse(worker.is_alive()) + self.assertEqual(errors, []) + self.assertTrue(server.finished) + self.assertEqual(server.owned_connection_count, 0) + + def test_protocol_construction_failure_releases_accepted_connection(self): + runtime = SmallOS().setKernel(Unix()) + app = SmallServer() + try: + with patch( + "smallserver.app.H2Protocol", + side_effect=RuntimeError("injected constructor failure"), + ): + server = app.serve( + runtime, host="127.0.0.1", port=0, protocol="http2" + ) + + def client_work(): + with socket.create_connection( + ("127.0.0.1", server.port), timeout=3 + ) as connection: + while connection.recv(1024): + pass + server.close() + + worker = threading.Thread(target=client_work, daemon=True) + worker.start() + runtime.start() + worker.join(timeout=3) + except PermissionError: + self.skipTest("the current sandbox does not permit loopback TCP binds") + self.assertFalse(worker.is_alive()) + self.assertTrue(server.finished) + self.assertEqual(server.owned_connection_count, 0) + + def test_handshake_timeout_closes_silent_client_and_releases_capacity(self): + runtime = SmallOS().setKernel(Unix()) + app = SmallServer() + try: + server = app.serve( + runtime, + host="127.0.0.1", + port=0, + protocol="http2", + http2_config=HTTP2Config(handshake_timeout=0.01), + ) + except PermissionError: + self.skipTest("the current sandbox does not permit loopback TCP binds") + errors = [] + + def client_work(): + try: + with socket.create_connection( + ("127.0.0.1", server.port), timeout=3 + ) as connection: + while connection.recv(1024): + pass + server.close() + except BaseException as exc: + errors.append(exc) + try: + server.close() + except BaseException: + pass + + worker = threading.Thread(target=client_work, daemon=True) + worker.start() + runtime.start() + worker.join(timeout=3) + self.assertFalse(worker.is_alive()) + self.assertEqual(errors, []) + self.assertEqual(server.owned_connection_count, 0) + self.assertTrue(server.finished) + + def test_idle_timeout_closes_prefaced_client_and_releases_capacity(self): + runtime = SmallOS().setKernel(Unix()) + app = SmallServer() + try: + server = app.serve( + runtime, + host="127.0.0.1", + port=0, + protocol="http2", + http2_config=HTTP2Config( + handshake_timeout=1, + idle_timeout=0.01, + ), + ) + except PermissionError: + self.skipTest("the current sandbox does not permit loopback TCP binds") + errors = [] + + def client_work(): + try: + client = H2Connection(config=H2Configuration(client_side=True)) + client.initiate_connection() + with socket.create_connection( + ("127.0.0.1", server.port), timeout=3 + ) as connection: + connection.sendall(client.data_to_send()) + while connection.recv(1024): + pass + server.close() + except BaseException as exc: + errors.append(exc) + try: + server.close() + except BaseException: + pass + + worker = threading.Thread(target=client_work, daemon=True) + worker.start() + runtime.start() + worker.join(timeout=3) + self.assertFalse(worker.is_alive()) + self.assertEqual(errors, []) + self.assertEqual(server.owned_connection_count, 0) + self.assertTrue(server.finished) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_server.py b/tests/test_server.py index 57e0f21..92fd9ed 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -51,22 +51,6 @@ def test_requires_host_and_rejects_invalid_origin_form(self) -> None: with self.assertRaisesRegex(HTTPParseError, "origin-form"): self.parser().feed(b"GET /items#fragment HTTP/1.1\r\nHost: localhost\r\n\r\n") - def test_splits_query_without_decoding_or_normalizing_path(self) -> None: - request = self.parser().feed( - b"GET /items/a%2Fb?tag=x%20y HTTP/1.1\r\nHost: localhost\r\n\r\n" - ) - self.assertIsNotNone(request) - assert request is not None - self.assertEqual(request.raw_target, "/items/a%2Fb?tag=x%20y") - self.assertEqual(request.path, "/items/a%2Fb") - self.assertEqual(request.query_string, "tag=x%20y") - - def test_enforces_request_target_limit_independently(self) -> None: - parser = HTTPRequestParser(256, 2, 32, max_request_target_bytes=8) - with self.assertRaisesRegex(HTTPParseError, "request target") as raised: - parser.feed(b"GET /12345678 HTTP/1.1\r\nHost: x\r\n\r\n") - self.assertEqual(raised.exception.status, 414) - def test_config_rejects_unbounded_limits(self) -> None: with self.assertRaisesRegex(ValueError, "max_connections"): ServerConfig(max_connections=0) @@ -221,6 +205,41 @@ def resume_task(self, task) -> None: ], ) + def test_route_observer_channel_is_bounded_and_stop_wakes_task(self) -> None: + class ObserverTask: + done = False + signals = [] + + @staticmethod + def getID() -> int: + return 9 + + def acceptSignal(self, signal) -> int: + self.signals.append(signal) + return 0 + + class SourceTask: + signals = [] + + def sendSignal(self, task_id, signal) -> int: + self.signals.append((task_id, signal)) + return 0 + + observer_task = ObserverTask() + source_task = SourceTask() + channel = RouteObserverChannel(lambda event: None, max_events=1) + channel.bind(observer_task) + event = RouteErrorEvent("regex-route-1", "route_match_timeout") + + self.assertTrue(channel.enqueue(event, source_task)) + self.assertFalse(channel.enqueue(event, source_task)) + channel.stop() + + self.assertEqual(source_task.signals, [(9, 31)]) + self.assertEqual(observer_task.signals, [31]) + self.assertEqual(channel.dropped, 2) + self.assertEqual(list(channel.events), []) + def test_serve_closes_kernel_resources_when_task_construction_fails(self) -> None: from SmallPackage import SmallTask as RealSmallTask diff --git a/tests/test_websocket.py b/tests/test_websocket.py index 9755560..3edd8c0 100644 --- a/tests/test_websocket.py +++ b/tests/test_websocket.py @@ -1126,8 +1126,14 @@ def client_work() -> None: response = b"" while b"\r\n\r\n" not in response: response += stream.recv(4096) + _, _, websocket_data = response.partition(b"\r\n\r\n") client = api.Connection(api.ConnectionType.CLIENT) - events = _receive_events(stream, client, api.CloseConnection) + events = _receive_events( + stream, + client, + api.CloseConnection, + initial_data=websocket_data, + ) close_events.extend(events) close_event = next( event @@ -1155,11 +1161,13 @@ def client_work() -> None: self.assertTrue(server.finished) -def _receive_events(stream, connection, event_type): +def _receive_events(stream, connection, event_type, *, initial_data=b""): deadline = time.monotonic() + 3 received = [] + pending = initial_data while time.monotonic() < deadline: - data = stream.recv(4096) + data = pending or stream.recv(4096) + pending = b"" if not data: return received connection.receive_data(data)