From e6187815a73b6e869d4e611caaed7cabc031dba3 Mon Sep 17 00:00:00 2001 From: MikiEEE <35975462+MikiEEE@users.noreply.github.com> Date: Fri, 21 Aug 2026 23:20:28 -0500 Subject: [PATCH 01/53] feat: route server networking through SmallOS kernel --- smallserver/_transport.py | 224 +++++++++++++++++++++++++++++++++ smallserver/app.py | 133 ++++++++++---------- smallserver/server.py | 62 ++++----- tests/__init__.py | 1 + tests/kernel_fakes.py | 135 ++++++++++++++++++++ tests/test_kernel_transport.py | 201 +++++++++++++++++++++++++++++ tests/test_server.py | 52 ++++---- 7 files changed, 689 insertions(+), 119 deletions(-) create mode 100644 smallserver/_transport.py create mode 100644 tests/__init__.py create mode 100644 tests/kernel_fakes.py create mode 100644 tests/test_kernel_transport.py diff --git a/smallserver/_transport.py b/smallserver/_transport.py new file mode 100644 index 0000000..e1a9f3b --- /dev/null +++ b/smallserver/_transport.py @@ -0,0 +1,224 @@ +"""Private SmallOS kernel transport boundary for server networking.""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any, Protocol + + +class WakeupChannelLike(Protocol): + """Opaque scheduler wakeup channel supplied by the active kernel.""" + + @property + def wait_object(self) -> object: ... + + def notify(self) -> None: ... + + def drain(self) -> None: ... + + def close(self) -> None: ... + + +class KernelLike(Protocol): + """SmallOS networking surface consumed by SmallServer.""" + + def supports_tcp_server(self) -> bool: ... + + def supports_wakeup_channel(self) -> bool: ... + + def resolve_passive_address(self, host: str, port: int) -> object: ... + + def socket_open(self, address_info: object) -> object: ... + + def socket_setblocking(self, stream: object, flag: bool) -> None: ... + + def socket_set_reuse_address(self, stream: object, enabled: bool) -> None: ... + + def socket_bind(self, stream: object, address: object) -> None: ... + + def socket_listen(self, stream: object, backlog: int) -> None: ... + + def socket_accept(self, listener: object) -> tuple[object, object]: ... + + def socket_local_address(self, stream: object) -> object: ... + + def socket_peer_address(self, stream: object) -> object | None: ... + + def socket_recv(self, stream: object, size: int) -> bytes: ... + + def socket_send(self, stream: object, data: bytes) -> int: ... + + def socket_close(self, stream: object) -> None: ... + + def socket_needs_read(self, exc: BaseException) -> bool: ... + + def socket_needs_write(self, exc: BaseException) -> bool: ... + + def create_wakeup_channel(self) -> WakeupChannelLike: ... + + +@dataclass(frozen=True) +class AcceptedConnection: + """An accepted opaque stream plus kernel-provided peer metadata.""" + + stream: object + peer_address: object | None + + +class KernelTransport: + """Adapt SmallServer operations to one active SmallOS kernel.""" + + _REQUIRED_METHODS = ( + "resolve_passive_address", + "socket_open", + "socket_setblocking", + "socket_set_reuse_address", + "socket_bind", + "socket_listen", + "socket_accept", + "socket_local_address", + "socket_peer_address", + "socket_recv", + "socket_send", + "socket_close", + "socket_needs_read", + "socket_needs_write", + "create_wakeup_channel", + ) + + def __init__(self, kernel: KernelLike) -> None: + if kernel is None: + raise RuntimeError("SmallServer requires a runtime with an active kernel") + if not callable(getattr(kernel, "supports_tcp_server", None)): + raise TypeError("the active kernel does not implement the TCP server contract") + if not kernel.supports_tcp_server(): + raise NotImplementedError("the active kernel does not support TCP servers") + supports_wakeup = getattr(kernel, "supports_wakeup_channel", None) + if callable(supports_wakeup) and not supports_wakeup(): + raise NotImplementedError("the active kernel does not support wakeup channels") + missing = [name for name in self._REQUIRED_METHODS if not callable(getattr(kernel, name, None))] + if missing: + raise TypeError( + "the active kernel is missing TCP server operations: {}".format( + ", ".join(missing) + ) + ) + self._kernel = kernel + # Retain closed objects as well as their identities. This prevents an id + # from being reused during the transport lifetime and keeps close() + # idempotent even for unhashable opaque handles. + self._closed: dict[int, object] = {} + + def open_listener( + self, + host: str, + port: int, + backlog: int, + reuse_address: bool = True, + ) -> object: + address_info = self._kernel.resolve_passive_address(host, port) + listener = self._kernel.socket_open(address_info) + try: + self._kernel.socket_set_reuse_address(listener, reuse_address) + self._kernel.socket_bind(listener, address_info) + self._kernel.socket_listen(listener, backlog) + self._kernel.socket_setblocking(listener, False) + except Exception: + self.close(listener) + raise + return listener + + async def accept(self, task: Any, listener: object) -> AcceptedConnection: + while True: + try: + stream, address = self._kernel.socket_accept(listener) + break + except Exception as exc: + if self._kernel.socket_needs_read(exc): + await task.wait_readable(listener) + continue + if self._kernel.socket_needs_write(exc): + await task.wait_writable(listener) + continue + raise + try: + self._kernel.socket_setblocking(stream, False) + peer = self._kernel.socket_peer_address(stream) + except Exception: + self.close(stream) + raise + return AcceptedConnection(stream, peer if peer is not None else address) + + async def recv(self, task: Any, stream: object, size: int) -> bytes: + while True: + try: + data = self._kernel.socket_recv(stream, size) + except Exception as exc: + if self._kernel.socket_needs_read(exc): + await task.wait_readable(stream) + continue + if self._kernel.socket_needs_write(exc): + await task.wait_writable(stream) + continue + raise + if not isinstance(data, bytes): + raise TypeError("the active kernel returned non-bytes socket data") + return data + + async def send_all(self, task: Any, stream: object, data: bytes) -> None: + offset = 0 + while offset < len(data): + try: + sent = self._kernel.socket_send(stream, data[offset:]) + except Exception as exc: + if self._kernel.socket_needs_write(exc): + await task.wait_writable(stream) + continue + if self._kernel.socket_needs_read(exc): + await task.wait_readable(stream) + continue + raise + if type(sent) is not int: + raise TypeError("the active kernel returned an invalid socket send count") + if sent <= 0: + return + if sent > len(data) - offset: + raise RuntimeError("the active kernel returned an oversized socket send count") + offset += sent + + def local_address(self, listener: object) -> tuple[str, int]: + address = self._kernel.socket_local_address(listener) + if not isinstance(address, (tuple, list)) or len(address) < 2: + raise ValueError("the active kernel returned an invalid local address") + host, port = address[:2] + if type(port) is not int or not 0 <= port <= 65535: + raise ValueError("the active kernel returned an invalid local port") + return str(host), port + + def close(self, handle: object) -> None: + identity = id(handle) + previous = self._closed.get(identity) + if previous is handle: + return + self._closed[identity] = handle + self._kernel.socket_close(handle) + + def close_safely(self, handle: object) -> None: + try: + self.close(handle) + except Exception: + pass + + def create_wakeup_channel(self) -> WakeupChannelLike: + channel = self._kernel.create_wakeup_channel() + missing = [ + name for name in ("notify", "drain", "close") if not callable(getattr(channel, name, None)) + ] + if missing or not hasattr(channel, "wait_object"): + try: + close = getattr(channel, "close", None) + if callable(close): + close() + finally: + raise TypeError("the active kernel returned an invalid wakeup channel") + return channel diff --git a/smallserver/app.py b/smallserver/app.py index 416542f..c7e28f8 100644 --- a/smallserver/app.py +++ b/smallserver/app.py @@ -4,9 +4,9 @@ import inspect from collections.abc import Awaitable, Callable, Iterable -import socket from typing import Any +from ._transport import KernelTransport from .errors import HTTPError from .http import Request, Response from .server import HTTPParseError, HTTPRequestParser, ServerConfig, ServerHandle @@ -77,31 +77,31 @@ def serve( if not isinstance(port, int) or not 0 <= port <= 65535: raise ValueError("port must be an integer between 0 and 65535") config = config or ServerConfig() - listener = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + transport = KernelTransport(getattr(runtime, "kernel", None)) + listener = transport.open_listener(host, port, config.max_connections) try: - listener.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) - listener.bind((host, port)) - listener.listen(config.max_connections) - listener.setblocking(False) - except BaseException: - listener.close() + wakeup = transport.create_wakeup_channel() + except Exception: + transport.close_safely(listener) raise - handle = ServerHandle(runtime, listener, config) - listener_task = SmallTask( - config.listener_priority, - self._accept_loop, - args=(handle,), - name="smallserver-listener", - ) - close_task = SmallTask( - config.listener_priority, - self._close_watcher, - args=(handle,), - name="smallserver-close-watcher", - ) - handle._listener_task = listener_task - tasks = (listener_task, close_task) + handle = ServerHandle(runtime, transport, listener, wakeup, config) + tasks: tuple[Any, ...] = () try: + listener_task = SmallTask( + config.listener_priority, + self._accept_loop, + args=(handle,), + name="smallserver-listener", + ) + tasks = (listener_task,) + close_task = SmallTask( + config.listener_priority, + self._close_watcher, + args=(handle,), + name="smallserver-close-watcher", + ) + handle._listener_task = listener_task + tasks = (listener_task, close_task) runtime.fork(list(tasks)) except BaseException: handle._abort_startup(tasks) @@ -129,42 +129,48 @@ async def dispatch(self, request: Request) -> Response: async def _accept_loop(self, task: Any, handle: ServerHandle) -> None: while not handle.closed: - await task.wait_readable(handle._listener) - if handle.closed: + try: + accepted = await handle._transport.accept(task, handle._listener) + except Exception: return - while not handle.closed: - try: - client, _ = handle._listener.accept() - except BlockingIOError: - break - except OSError: - return - client.setblocking(False) - if len(handle._connections) >= handle._config.max_connections: - client.close() - continue - from SmallPackage import SmallTask + client = accepted.stream + if handle.closed or len(handle._connections) >= handle._config.max_connections: + handle._transport.close_safely(client) + continue + from SmallPackage import SmallTask + connection_task: Any = None + try: connection_task = SmallTask( handle._config.connection_priority, self._connection_loop, args=(handle, client), name="smallserver-connection", ) - handle._connections[client] = connection_task + handle._connections[id(client)] = (client, connection_task) runtime = handle._runtime runtime.fork(connection_task) + except Exception: + handle._connections.pop(id(client), None) + if connection_task is not None: + cancel_task = getattr(handle._runtime, "cancel_task", None) + if callable(cancel_task): + try: + cancel_task(connection_task) + except Exception: + pass + handle._transport.close_safely(client) + continue async def _close_watcher(self, task: Any, handle: ServerHandle) -> None: - await task.wait_readable(handle._wake_read) + await task.wait_readable(handle._wakeup.wait_object) try: - while handle._wake_read.recv(1024): - pass - except (BlockingIOError, OSError): + handle._wakeup.drain() + except Exception: pass handle._finish_close() - async def _connection_loop(self, task: Any, handle: ServerHandle, client: socket.socket) -> None: + async def _connection_loop(self, task: Any, handle: ServerHandle, client: object) -> None: parser = HTTPRequestParser( handle._config.max_header_bytes, handle._config.max_header_count, @@ -173,18 +179,19 @@ async def _connection_loop(self, task: Any, handle: ServerHandle, client: socket try: while not handle.closed: try: - chunk = client.recv(handle._config.receive_chunk_bytes) - except BlockingIOError: - await task.wait_readable(client) - continue - except OSError: + chunk = await handle._transport.recv( + task, client, handle._config.receive_chunk_bytes + ) + except Exception: return if not chunk: return try: request = parser.feed(chunk) except HTTPParseError as exc: - await self._send_response(task, client, Response.text(exc.detail, status=exc.status)) + await self._send_response( + task, handle, client, Response.text(exc.detail, status=exc.status) + ) return if request is None: continue @@ -192,26 +199,20 @@ async def _connection_loop(self, task: Any, handle: ServerHandle, client: socket response = await self.dispatch(request) except Exception: response = Response.text("internal server error", status=500) - await self._send_response(task, client, response) + await self._send_response(task, handle, client, response) return finally: - handle._connections.pop(client, None) - try: - client.close() - except OSError: - pass + handle._connections.pop(id(client), None) + handle._transport.close_safely(client) - async def _send_response(self, task: Any, client: socket.socket, response: Response) -> None: + async def _send_response( + self, + task: Any, + handle: ServerHandle, + client: object, + response: Response, + ) -> None: headers = {name: value for name, value in response.headers.items() if name.lower() != "connection"} headers["Connection"] = "close" payload = Response(response.status, response.body, headers).to_http1() - offset = 0 - while offset < len(payload): - try: - sent = client.send(payload[offset:]) - except BlockingIOError: - await task.wait_writable(client) - continue - if sent <= 0: - return - offset += sent + await handle._transport.send_all(task, client, payload) diff --git a/smallserver/server.py b/smallserver/server.py index 09701fe..1a83670 100644 --- a/smallserver/server.py +++ b/smallserver/server.py @@ -3,9 +3,9 @@ from __future__ import annotations from dataclasses import dataclass -import socket from typing import Any +from ._transport import KernelTransport, WakeupChannelLike from .http import Headers, Request, Response @@ -118,21 +118,27 @@ def __post_init__(self) -> None: class ServerHandle: """A bound listener and its cooperative shutdown signal.""" - def __init__(self, runtime: Any, listener: socket.socket, config: ServerConfig) -> None: + def __init__( + self, + runtime: Any, + transport: KernelTransport, + listener: object, + wakeup: WakeupChannelLike, + config: ServerConfig, + ) -> None: self._runtime = runtime + self._transport = transport self._listener = listener + self._wakeup = wakeup self._config = config - self._wake_read, self._wake_write = socket.socketpair() - self._wake_read.setblocking(False) - self._wake_write.setblocking(False) self._closed = False + self._finished = False self._listener_task: Any = None - self._connections: dict[socket.socket, Any] = {} + self._connections: dict[int, tuple[object, Any]] = {} @property def address(self) -> tuple[str, int]: - host, port = self._listener.getsockname()[:2] - return str(host), int(port) + return self._transport.local_address(self._listener) @property def port(self) -> int: @@ -148,21 +154,31 @@ def close(self) -> None: return self._closed = True try: - self._wake_write.send(b"x") - except (BlockingIOError, OSError): + self._wakeup.notify() + except Exception: pass def _finish_close(self) -> None: - for task in list(self._connections.values()): - self._runtime.resume_task(task) + if self._finished: + return + self._finished = True + for connection, task in list(self._connections.values()): + try: + self._runtime.resume_task(task) + except Exception: + pass + self._transport.close_safely(connection) self._connections.clear() if self._listener_task is not None: - self._runtime.resume_task(self._listener_task) - for sock in (self._listener, self._wake_read, self._wake_write): try: - sock.close() - except OSError: + self._runtime.resume_task(self._listener_task) + except Exception: pass + self._transport.close_safely(self._listener) + try: + self._wakeup.close() + except Exception: + pass def _abort_startup(self, tasks: tuple[Any, ...]) -> None: """Release bound resources after task registration fails.""" @@ -174,16 +190,4 @@ def _abort_startup(self, tasks: tuple[Any, ...]) -> None: cancel_task(task) except BaseException: pass - for sock in (self._listener, self._wake_read, self._wake_write): - try: - sock.close() - except OSError: - pass - - -async def _wait_readable(task: Any, sock: socket.socket) -> None: - await task.wait_readable(sock) - - -async def _wait_writable(task: Any, sock: socket.socket) -> None: - await task.wait_writable(sock) + self._finish_close() diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..91ad212 --- /dev/null +++ b/tests/__init__.py @@ -0,0 +1 @@ +"""SmallServer test support package.""" diff --git a/tests/kernel_fakes.py b/tests/kernel_fakes.py new file mode 100644 index 0000000..a1c64da --- /dev/null +++ b/tests/kernel_fakes.py @@ -0,0 +1,135 @@ +from __future__ import annotations + +from dataclasses import dataclass + + +class NeedsRead(Exception): + pass + + +class NeedsWrite(Exception): + pass + + +@dataclass +class OpaqueHandle: + """Intentionally unhashable stand-in for a backend-owned resource.""" + + name: str + + +class FakeWakeupChannel: + def __init__(self) -> None: + self.wait_object = OpaqueHandle("wakeup-wait") + self.notify_calls = 0 + self.drain_calls = 0 + self.close_calls = 0 + + def notify(self) -> None: + self.notify_calls += 1 + + def drain(self) -> None: + self.drain_calls += 1 + + def close(self) -> None: + self.close_calls += 1 + + +class FakeKernel: + def __init__(self, supported: bool = True, wakeup_supported: bool = True) -> None: + self.supported = supported + self.wakeup_supported = wakeup_supported + self.address_info = object() + self.listener = OpaqueHandle("listener") + self.wakeup = FakeWakeupChannel() + self.calls: list[tuple] = [] + self.closed: list[OpaqueHandle] = [] + self.accept_results: list[object] = [] + self.recv_results: dict[int, list[object]] = {} + self.send_results: dict[int, list[object]] = {} + self.sent: dict[int, list[bytes]] = {} + self.peer_addresses: dict[int, object | None] = {} + self.fail_operation: str | None = None + + def supports_tcp_server(self) -> bool: + self.calls.append(("supports_tcp_server",)) + return self.supported + + def supports_wakeup_channel(self) -> bool: + self.calls.append(("supports_wakeup_channel",)) + return self.wakeup_supported + + def resolve_passive_address(self, host: str, port: int) -> object: + self.calls.append(("resolve_passive_address", host, port)) + return self.address_info + + def socket_open(self, address_info: object) -> object: + self.calls.append(("socket_open", address_info)) + return self.listener + + def _maybe_fail(self, operation: str) -> None: + if self.fail_operation == operation: + raise RuntimeError("{} failed".format(operation)) + + def socket_setblocking(self, stream: object, flag: bool) -> None: + self.calls.append(("socket_setblocking", stream, flag)) + self._maybe_fail("setblocking") + + def socket_set_reuse_address(self, stream: object, enabled: bool) -> None: + self.calls.append(("socket_set_reuse_address", stream, enabled)) + self._maybe_fail("reuse") + + def socket_bind(self, stream: object, address: object) -> None: + self.calls.append(("socket_bind", stream, address)) + self._maybe_fail("bind") + + def socket_listen(self, stream: object, backlog: int) -> None: + self.calls.append(("socket_listen", stream, backlog)) + self._maybe_fail("listen") + + def socket_accept(self, listener: object) -> tuple[object, object]: + self.calls.append(("socket_accept", listener)) + if not self.accept_results: + raise NeedsRead() + result = self.accept_results.pop(0) + if isinstance(result, BaseException): + raise result + return result # type: ignore[return-value] + + def socket_local_address(self, stream: object) -> object: + self.calls.append(("socket_local_address", stream)) + return ("127.0.0.1", 43210) + + def socket_peer_address(self, stream: object) -> object | None: + self.calls.append(("socket_peer_address", stream)) + return self.peer_addresses.get(id(stream)) + + def socket_recv(self, stream: object, size: int) -> bytes: + self.calls.append(("socket_recv", stream, size)) + result = self.recv_results[id(stream)].pop(0) + if isinstance(result, BaseException): + raise result + return result # type: ignore[return-value] + + def socket_send(self, stream: object, data: bytes) -> int: + self.calls.append(("socket_send", stream, data)) + self.sent.setdefault(id(stream), []).append(data) + results = self.send_results.get(id(stream)) + result = results.pop(0) if results else len(data) + if isinstance(result, BaseException): + raise result + return result # type: ignore[return-value] + + def socket_close(self, stream: object) -> None: + self.calls.append(("socket_close", stream)) + self.closed.append(stream) # type: ignore[arg-type] + + def socket_needs_read(self, exc: BaseException) -> bool: + return isinstance(exc, NeedsRead) + + def socket_needs_write(self, exc: BaseException) -> bool: + return isinstance(exc, NeedsWrite) + + def create_wakeup_channel(self) -> FakeWakeupChannel: + self.calls.append(("create_wakeup_channel",)) + return self.wakeup diff --git a/tests/test_kernel_transport.py b/tests/test_kernel_transport.py new file mode 100644 index 0000000..2d2c8dc --- /dev/null +++ b/tests/test_kernel_transport.py @@ -0,0 +1,201 @@ +import ast +from pathlib import Path +import unittest + +from smallserver import Response, SmallServer +from smallserver._transport import KernelTransport +from smallserver.server import ServerConfig, ServerHandle + +from tests.kernel_fakes import FakeKernel, NeedsRead, NeedsWrite, OpaqueHandle + + +def run_immediate(coroutine): + try: + while True: + coroutine.send(None) + except StopIteration as exc: + return exc.value + + +class FakeTask: + def __init__(self) -> None: + self.waits: list[tuple[str, object]] = [] + + async def wait_readable(self, handle: object) -> None: + self.waits.append(("read", handle)) + + async def wait_writable(self, handle: object) -> None: + self.waits.append(("write", handle)) + + +class KernelTransportTests(unittest.TestCase): + def test_capability_failure_happens_before_address_resolution(self) -> None: + kernel = FakeKernel(supported=False) + with self.assertRaisesRegex(NotImplementedError, "does not support"): + KernelTransport(kernel) + self.assertEqual(kernel.calls, [("supports_tcp_server",)]) + + def test_wakeup_capability_failure_happens_before_address_resolution(self) -> None: + kernel = FakeKernel(wakeup_supported=False) + with self.assertRaisesRegex(NotImplementedError, "wakeup channels"): + KernelTransport(kernel) + self.assertEqual( + kernel.calls, + [("supports_tcp_server",), ("supports_wakeup_channel",)], + ) + + def test_incomplete_contract_fails_before_address_resolution(self) -> None: + kernel = FakeKernel() + kernel.socket_bind = None # type: ignore[assignment] + with self.assertRaisesRegex(TypeError, "socket_bind"): + KernelTransport(kernel) + self.assertEqual( + kernel.calls, + [("supports_tcp_server",), ("supports_wakeup_channel",)], + ) + + def test_listener_uses_one_opaque_address_record_and_rolls_back_failure(self) -> None: + kernel = FakeKernel() + kernel.fail_operation = "listen" + transport = KernelTransport(kernel) + with self.assertRaisesRegex(RuntimeError, "listen failed"): + transport.open_listener("0.0.0.0", 0, 7) + open_call = next(call for call in kernel.calls if call[0] == "socket_open") + bind_call = next(call for call in kernel.calls if call[0] == "socket_bind") + self.assertIs(open_call[1], kernel.address_info) + self.assertIs(bind_call[2], kernel.address_info) + self.assertEqual(kernel.closed, [kernel.listener]) + + def test_accept_and_stream_operations_honor_both_retry_directions(self) -> None: + kernel = FakeKernel() + transport = KernelTransport(kernel) + client = OpaqueHandle("client") + fallback_peer = ("192.0.2.4", 80) + kernel.accept_results = [NeedsRead(), NeedsWrite(), (client, fallback_peer)] + kernel.recv_results[id(client)] = [NeedsWrite(), NeedsRead(), b"request"] + kernel.send_results[id(client)] = [NeedsRead(), NeedsWrite(), 2, 3] + task = FakeTask() + + accepted = run_immediate(transport.accept(task, kernel.listener)) + received = run_immediate(transport.recv(task, client, 16)) + run_immediate(transport.send_all(task, client, b"reply")) + + self.assertIs(accepted.stream, client) + self.assertEqual(accepted.peer_address, fallback_peer) + self.assertEqual(received, b"request") + self.assertEqual( + [mode for mode, _ in task.waits], + ["read", "write", "write", "read", "read", "write"], + ) + self.assertEqual(kernel.sent[id(client)][-1], b"ply") + + def test_accept_configuration_failure_closes_the_new_stream_once(self) -> None: + kernel = FakeKernel() + transport = KernelTransport(kernel) + client = OpaqueHandle("client") + kernel.accept_results = [(client, ("127.0.0.1", 1))] + kernel.fail_operation = "setblocking" + with self.assertRaisesRegex(RuntimeError, "setblocking failed"): + run_immediate(transport.accept(FakeTask(), kernel.listener)) + transport.close_safely(client) + self.assertEqual(kernel.closed, [client]) + + def test_connection_registration_failure_cancels_task_and_closes_stream(self) -> None: + class Runtime: + def __init__(self) -> None: + self.cancelled = [] + + def fork(self, task) -> None: + raise RuntimeError("capacity") + + def cancel_task(self, task) -> None: + self.cancelled.append(task) + task.cancel() + + kernel = FakeKernel() + transport = KernelTransport(kernel) + listener = transport.open_listener("127.0.0.1", 0, 3) + handle = ServerHandle( + Runtime(), transport, listener, transport.create_wakeup_channel(), ServerConfig() + ) + client = OpaqueHandle("client") + kernel.accept_results = [(client, ("127.0.0.1", 1)), RuntimeError("listener failed")] + + run_immediate(SmallServer()._accept_loop(FakeTask(), handle)) + + self.assertEqual(len(handle._runtime.cancelled), 1) + self.assertEqual(kernel.closed, [client]) + self.assertEqual(handle._connections, {}) + + def test_server_handle_signals_and_releases_each_resource_once(self) -> None: + class Runtime: + def __init__(self) -> None: + self.resumed = [] + + def resume_task(self, task) -> None: + self.resumed.append(task) + raise RuntimeError("resume failed") + + kernel = FakeKernel() + transport = KernelTransport(kernel) + listener = transport.open_listener("127.0.0.1", 0, 3) + wakeup = transport.create_wakeup_channel() + runtime = Runtime() + handle = ServerHandle(runtime, transport, listener, wakeup, ServerConfig()) + connection = OpaqueHandle("connection") + connection_task = object() + listener_task = object() + handle._connections[id(connection)] = (connection, connection_task) + handle._listener_task = listener_task + + handle.close() + handle.close() + handle._finish_close() + handle._finish_close() + transport.close_safely(connection) + + self.assertEqual(wakeup.notify_calls, 1) + self.assertEqual(wakeup.close_calls, 1) + self.assertEqual(runtime.resumed, [connection_task, listener_task]) + self.assertEqual(kernel.closed, [connection, listener]) + + def test_fake_kernel_connection_preserves_http_response_bytes(self) -> None: + class Runtime: + def resume_task(self, task) -> None: + pass + + kernel = FakeKernel() + transport = KernelTransport(kernel) + listener = transport.open_listener("127.0.0.1", 0, 3) + handle = ServerHandle( + Runtime(), transport, listener, transport.create_wakeup_channel(), ServerConfig() + ) + client = OpaqueHandle("client") + kernel.recv_results[id(client)] = [b"GET /health HTTP/1.1\r\nHost: localhost\r\n\r\n"] + app = SmallServer() + + @app.get("/health") + async def health(request): + return Response.json({"status": "ok"}) + + run_immediate(app._connection_loop(FakeTask(), handle, client)) + + self.assertEqual( + kernel.sent[id(client)][0], + b"HTTP/1.1 200 OK\r\nContent-Length: 15\r\nContent-Type: application/json\r\n" + b"Connection: close\r\n\r\n{\"status\":\"ok\"}", + ) + self.assertEqual(kernel.closed, [client]) + + def test_production_modules_do_not_import_platform_networking(self) -> None: + forbidden = {"socket", "select", "selectors", "ssl"} + package = Path(__file__).parents[1] / "smallserver" + found = [] + for path in package.glob("*.py"): + tree = ast.parse(path.read_text(encoding="utf-8"), filename=str(path)) + for node in ast.walk(tree): + if isinstance(node, ast.Import): + found.extend((path.name, alias.name) for alias in node.names if alias.name in forbidden) + elif isinstance(node, ast.ImportFrom) and node.module in forbidden: + found.append((path.name, node.module)) + self.assertEqual(found, []) diff --git a/tests/test_server.py b/tests/test_server.py index bf6e193..7927f27 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -4,6 +4,8 @@ from smallserver import SmallServer from smallserver.server import HTTPParseError, HTTPRequestParser, ServerConfig +from tests.kernel_fakes import FakeKernel + class HTTPRequestParserTests(unittest.TestCase): def parser(self) -> HTTPRequestParser: @@ -41,38 +43,40 @@ def test_config_rejects_unbounded_limits(self) -> None: with self.assertRaisesRegex(ValueError, "max_connections"): ServerConfig(max_connections=True) - def test_serve_closes_bound_socket_when_runtime_fork_fails(self) -> None: - class Listener: - closed = False - - def setsockopt(self, *args) -> None: - pass + def test_serve_closes_kernel_resources_when_runtime_fork_fails(self) -> None: + class Runtime: + def __init__(self) -> None: + self.kernel = FakeKernel() + self.cancelled = 0 - def bind(self, address) -> None: - pass + def fork(self, tasks) -> None: + raise RuntimeError("no task capacity") - def listen(self, backlog) -> None: - pass + def cancel_task(self, task) -> None: + self.cancelled += 1 + task.coro.close() - def setblocking(self, blocking) -> None: + def resume_task(self, task) -> None: pass - def close(self) -> None: - self.closed = True + runtime = Runtime() + with self.assertRaisesRegex(RuntimeError, "capacity"): + SmallServer().serve(runtime) + self.assertEqual(runtime.cancelled, 2) + self.assertEqual([handle.name for handle in runtime.kernel.closed], ["listener"]) + self.assertEqual(runtime.kernel.wakeup.close_calls, 1) + def test_serve_closes_kernel_resources_when_task_construction_fails(self) -> None: class Runtime: - cancelled = 0 - - def fork(self, tasks) -> None: - raise RuntimeError("no task capacity") + def __init__(self) -> None: + self.kernel = FakeKernel() - def cancel_task(self, task) -> None: - self.cancelled += 1 + def resume_task(self, task) -> None: + pass - listener = Listener() runtime = Runtime() - with patch("smallserver.app.socket.socket", return_value=listener): - with self.assertRaisesRegex(RuntimeError, "capacity"): + with patch("SmallPackage.SmallTask", side_effect=RuntimeError("task failed")): + with self.assertRaisesRegex(RuntimeError, "task failed"): SmallServer().serve(runtime) - self.assertTrue(listener.closed) - self.assertEqual(runtime.cancelled, 2) + self.assertEqual([handle.name for handle in runtime.kernel.closed], ["listener"]) + self.assertEqual(runtime.kernel.wakeup.close_calls, 1) From 08c4d6e52196c32a1e164242395f71d4807298cd Mon Sep 17 00:00:00 2001 From: MikiEEE <35975462+MikiEEE@users.noreply.github.com> Date: Fri, 21 Aug 2026 23:48:22 -0500 Subject: [PATCH 02/53] fix: harden kernel transport lifecycle --- smallserver/_transport.py | 199 ++++++++++++++++---------- smallserver/app.py | 92 ++++++------ smallserver/server.py | 92 ++++++++---- tests/kernel_fakes.py | 42 ++++++ tests/test_kernel_transport.py | 249 +++++++++++++++++++++++++++++---- tests/test_server.py | 19 ++- tests/test_server_runtime.py | 54 +++++++ 7 files changed, 577 insertions(+), 170 deletions(-) diff --git a/smallserver/_transport.py b/smallserver/_transport.py index e1a9f3b..a1de0e1 100644 --- a/smallserver/_transport.py +++ b/smallserver/_transport.py @@ -46,25 +46,54 @@ def socket_peer_address(self, stream: object) -> object | None: ... def socket_recv(self, stream: object, size: int) -> bytes: ... - def socket_send(self, stream: object, data: bytes) -> int: ... + def socket_send(self, stream: object, data: object) -> int: ... def socket_close(self, stream: object) -> None: ... - def socket_needs_read(self, exc: BaseException) -> bool: ... + def socket_retry_mode(self, exc: BaseException, operation: str) -> str | None: ... - def socket_needs_write(self, exc: BaseException) -> bool: ... + def validate_io_wait_object(self, obj: object) -> tuple[bool, BaseException | None]: ... def create_wakeup_channel(self) -> WakeupChannelLike: ... +@dataclass(eq=False) +class TransportHandle: + """Own one kernel stream and its bounded, retryable close state.""" + + raw: object + closed: bool = False + + @dataclass(frozen=True) class AcceptedConnection: """An accepted opaque stream plus kernel-provided peer metadata.""" - stream: object + stream: TransportHandle peer_address: object | None +@dataclass(eq=False) +class WakeupChannel: + """Validated wake channel with a cached readiness object.""" + + raw: WakeupChannelLike + wait_object: object + closed: bool = False + + def notify(self) -> None: + self.raw.notify() + + def drain(self) -> None: + self.raw.drain() + + def close(self) -> None: + if self.closed: + return + self.raw.close() + self.closed = True + + class KernelTransport: """Adapt SmallServer operations to one active SmallOS kernel.""" @@ -81,9 +110,7 @@ class KernelTransport: "socket_recv", "socket_send", "socket_close", - "socket_needs_read", - "socket_needs_write", - "create_wakeup_channel", + "socket_retry_mode", ) def __init__(self, kernel: KernelLike) -> None: @@ -94,9 +121,10 @@ def __init__(self, kernel: KernelLike) -> None: if not kernel.supports_tcp_server(): raise NotImplementedError("the active kernel does not support TCP servers") supports_wakeup = getattr(kernel, "supports_wakeup_channel", None) - if callable(supports_wakeup) and not supports_wakeup(): - raise NotImplementedError("the active kernel does not support wakeup channels") + wakeup_supported = bool(callable(supports_wakeup) and supports_wakeup()) missing = [name for name in self._REQUIRED_METHODS if not callable(getattr(kernel, name, None))] + if wakeup_supported and not callable(getattr(kernel, "create_wakeup_channel", None)): + missing.append("create_wakeup_channel") if missing: raise TypeError( "the active kernel is missing TCP server operations: {}".format( @@ -104,10 +132,7 @@ def __init__(self, kernel: KernelLike) -> None: ) ) self._kernel = kernel - # Retain closed objects as well as their identities. This prevents an id - # from being reused during the transport lifetime and keeps close() - # idempotent even for unhashable opaque handles. - self._closed: dict[int, object] = {} + self.supports_wakeup_channel = wakeup_supported def open_listener( self, @@ -115,79 +140,87 @@ def open_listener( port: int, backlog: int, reuse_address: bool = True, - ) -> object: + ) -> TransportHandle: address_info = self._kernel.resolve_passive_address(host, port) - listener = self._kernel.socket_open(address_info) + listener = TransportHandle(self._kernel.socket_open(address_info)) try: - self._kernel.socket_set_reuse_address(listener, reuse_address) - self._kernel.socket_bind(listener, address_info) - self._kernel.socket_listen(listener, backlog) - self._kernel.socket_setblocking(listener, False) - except Exception: - self.close(listener) + self._kernel.socket_set_reuse_address(listener.raw, reuse_address) + self._kernel.socket_bind(listener.raw, address_info) + self._kernel.socket_listen(listener.raw, backlog) + self._kernel.socket_setblocking(listener.raw, False) + except BaseException: + try: + self.close(listener) + except BaseException: + pass raise return listener - async def accept(self, task: Any, listener: object) -> AcceptedConnection: + async def accept(self, task: Any, listener: TransportHandle) -> AcceptedConnection: while True: try: - stream, address = self._kernel.socket_accept(listener) + raw_stream, address = self._kernel.socket_accept(listener.raw) break - except Exception as exc: - if self._kernel.socket_needs_read(exc): - await task.wait_readable(listener) - continue - if self._kernel.socket_needs_write(exc): - await task.wait_writable(listener) - continue - raise + except BaseException as exc: + await self._wait_for_retry(task, listener, exc, "accept") + stream = TransportHandle(raw_stream) try: - self._kernel.socket_setblocking(stream, False) - peer = self._kernel.socket_peer_address(stream) - except Exception: - self.close(stream) + self._kernel.socket_setblocking(stream.raw, False) + peer = self._kernel.socket_peer_address(stream.raw) + except BaseException: + try: + self.close(stream) + except BaseException: + pass raise return AcceptedConnection(stream, peer if peer is not None else address) - async def recv(self, task: Any, stream: object, size: int) -> bytes: + async def recv(self, task: Any, stream: TransportHandle, size: int) -> bytes: while True: try: - data = self._kernel.socket_recv(stream, size) - except Exception as exc: - if self._kernel.socket_needs_read(exc): - await task.wait_readable(stream) - continue - if self._kernel.socket_needs_write(exc): - await task.wait_writable(stream) - continue - raise + data = self._kernel.socket_recv(stream.raw, size) + except BaseException as exc: + await self._wait_for_retry(task, stream, exc, "recv") + continue if not isinstance(data, bytes): raise TypeError("the active kernel returned non-bytes socket data") return data - async def send_all(self, task: Any, stream: object, data: bytes) -> None: + async def send_all(self, task: Any, stream: TransportHandle, data: bytes) -> None: offset = 0 + view = memoryview(data) while offset < len(data): try: - sent = self._kernel.socket_send(stream, data[offset:]) - except Exception as exc: - if self._kernel.socket_needs_write(exc): - await task.wait_writable(stream) - continue - if self._kernel.socket_needs_read(exc): - await task.wait_readable(stream) - continue - raise + sent = self._kernel.socket_send(stream.raw, view[offset:]) + except BaseException as exc: + await self._wait_for_retry(task, stream, exc, "send") + continue if type(sent) is not int: raise TypeError("the active kernel returned an invalid socket send count") if sent <= 0: - return + raise ConnectionError("the active kernel made no forward progress sending data") if sent > len(data) - offset: raise RuntimeError("the active kernel returned an oversized socket send count") offset += sent - def local_address(self, listener: object) -> tuple[str, int]: - address = self._kernel.socket_local_address(listener) + async def _wait_for_retry( + self, + task: Any, + stream: TransportHandle, + exc: BaseException, + operation: str, + ) -> None: + mode = self._kernel.socket_retry_mode(exc, operation) + if mode == "read": + await task.wait_readable(stream.raw) + return + if mode == "write": + await task.wait_writable(stream.raw) + return + raise exc + + def local_address(self, listener: TransportHandle) -> tuple[str, int]: + address = self._kernel.socket_local_address(listener.raw) if not isinstance(address, (tuple, list)) or len(address) < 2: raise ValueError("the active kernel returned an invalid local address") host, port = address[:2] @@ -195,30 +228,44 @@ def local_address(self, listener: object) -> tuple[str, int]: raise ValueError("the active kernel returned an invalid local port") return str(host), port - def close(self, handle: object) -> None: - identity = id(handle) - previous = self._closed.get(identity) - if previous is handle: + def close(self, handle: TransportHandle) -> None: + if handle.closed: return - self._closed[identity] = handle - self._kernel.socket_close(handle) + self._kernel.socket_close(handle.raw) + handle.closed = True - def close_safely(self, handle: object) -> None: + def close_safely(self, handle: TransportHandle) -> None: try: self.close(handle) - except Exception: + except BaseException: pass - def create_wakeup_channel(self) -> WakeupChannelLike: - channel = self._kernel.create_wakeup_channel() - missing = [ - name for name in ("notify", "drain", "close") if not callable(getattr(channel, name, None)) - ] - if missing or not hasattr(channel, "wait_object"): + def create_wakeup_channel(self) -> WakeupChannel | None: + if not self.supports_wakeup_channel: + return None + raw_channel = self._kernel.create_wakeup_channel() + try: + missing = [ + name + for name in ("notify", "drain", "close") + if not callable(getattr(raw_channel, name, None)) + ] + if missing: + raise TypeError("the active kernel returned an invalid wakeup channel") + wait_object = raw_channel.wait_object + validator = getattr(self._kernel, "validate_io_wait_object", None) + if callable(validator): + valid, validation_error = validator(wait_object) + if not valid: + if validation_error is not None: + raise validation_error + raise ValueError("the active kernel returned an invalid wakeup wait object") + return WakeupChannel(raw_channel, wait_object) + except BaseException: try: - close = getattr(channel, "close", None) + close = getattr(raw_channel, "close", None) if callable(close): close() - finally: - raise TypeError("the active kernel returned an invalid wakeup channel") - return channel + except BaseException: + pass + raise diff --git a/smallserver/app.py b/smallserver/app.py index c7e28f8..09ab362 100644 --- a/smallserver/app.py +++ b/smallserver/app.py @@ -6,7 +6,7 @@ from collections.abc import Awaitable, Callable, Iterable from typing import Any -from ._transport import KernelTransport +from ._transport import KernelTransport, TransportHandle from .errors import HTTPError from .http import Request, Response from .server import HTTPParseError, HTTPRequestParser, ServerConfig, ServerHandle @@ -81,7 +81,7 @@ def serve( listener = transport.open_listener(host, port, config.max_connections) try: wakeup = transport.create_wakeup_channel() - except Exception: + except BaseException: transport.close_safely(listener) raise handle = ServerHandle(runtime, transport, listener, wakeup, config) @@ -94,14 +94,15 @@ def serve( name="smallserver-listener", ) tasks = (listener_task,) - close_task = SmallTask( - config.listener_priority, - self._close_watcher, - args=(handle,), - name="smallserver-close-watcher", - ) handle._listener_task = listener_task - tasks = (listener_task, close_task) + if wakeup is not None: + close_task = SmallTask( + config.listener_priority, + self._close_watcher, + args=(handle,), + name="smallserver-close-watcher", + ) + tasks = (listener_task, close_task) runtime.fork(list(tasks)) except BaseException: handle._abort_startup(tasks) @@ -128,49 +129,58 @@ async def dispatch(self, request: Request) -> Response: return Response.text(exc.detail or "HTTP {}".format(exc.status), status=exc.status) async def _accept_loop(self, task: Any, handle: ServerHandle) -> None: + accepted_in_batch = 0 while not handle.closed: try: accepted = await handle._transport.accept(task, handle._listener) - except Exception: - return + except Exception as exc: + if handle.closed: + return + handle._listener_failed(exc, task) + raise client = accepted.stream + accepted_in_batch += 1 if handle.closed or len(handle._connections) >= handle._config.max_connections: handle._transport.close_safely(client) - continue - from SmallPackage import SmallTask + else: + from SmallPackage import SmallTask - connection_task: Any = None - try: - connection_task = SmallTask( - handle._config.connection_priority, - self._connection_loop, - args=(handle, client), - name="smallserver-connection", - ) - handle._connections[id(client)] = (client, connection_task) - runtime = handle._runtime - runtime.fork(connection_task) - except Exception: - handle._connections.pop(id(client), None) - if connection_task is not None: - cancel_task = getattr(handle._runtime, "cancel_task", None) - if callable(cancel_task): - try: - cancel_task(connection_task) - except Exception: - pass - handle._transport.close_safely(client) - continue + connection_task: Any = None + try: + connection_task = SmallTask( + handle._config.connection_priority, + self._connection_loop, + args=(handle, client), + name="smallserver-connection", + ) + handle._connections[id(client)] = (client, connection_task) + runtime = handle._runtime + runtime.fork(connection_task) + except Exception: + handle._connections.pop(id(client), None) + if connection_task is not None: + cancel_task = getattr(handle._runtime, "cancel_task", None) + if callable(cancel_task): + try: + cancel_task(connection_task) + except Exception: + pass + handle._transport.close_safely(client) + if accepted_in_batch >= handle._config.accept_batch_size: + accepted_in_batch = 0 + await task.yield_now() async def _close_watcher(self, task: Any, handle: ServerHandle) -> None: - await task.wait_readable(handle._wakeup.wait_object) try: + assert handle._wakeup is not None + await task.wait_readable(handle._wakeup.wait_object) handle._wakeup.drain() - except Exception: - pass - handle._finish_close() + finally: + handle._finish_close(current_task=task) - async def _connection_loop(self, task: Any, handle: ServerHandle, client: object) -> None: + async def _connection_loop( + self, task: Any, handle: ServerHandle, client: TransportHandle + ) -> None: parser = HTTPRequestParser( handle._config.max_header_bytes, handle._config.max_header_count, @@ -209,7 +219,7 @@ async def _send_response( self, task: Any, handle: ServerHandle, - client: object, + client: TransportHandle, response: Response, ) -> None: headers = {name: value for name, value in response.headers.items() if name.lower() != "connection"} diff --git a/smallserver/server.py b/smallserver/server.py index 1a83670..5061974 100644 --- a/smallserver/server.py +++ b/smallserver/server.py @@ -5,7 +5,7 @@ from dataclasses import dataclass from typing import Any -from ._transport import KernelTransport, WakeupChannelLike +from ._transport import KernelTransport, TransportHandle, WakeupChannel from .http import Headers, Request, Response @@ -108,6 +108,7 @@ class ServerConfig: receive_chunk_bytes: int = 8 * 1024 listener_priority: int = 1 connection_priority: int = 2 + accept_batch_size: int = 16 def __post_init__(self) -> None: for name, value in self.__dict__.items(): @@ -122,8 +123,8 @@ def __init__( self, runtime: Any, transport: KernelTransport, - listener: object, - wakeup: WakeupChannelLike, + listener: TransportHandle, + wakeup: WakeupChannel | None, config: ServerConfig, ) -> None: self._runtime = runtime @@ -131,10 +132,12 @@ def __init__( self._listener = listener self._wakeup = wakeup self._config = config - self._closed = False + self._close_requested = False + self._notification_sent = False self._finished = False + self._failure: BaseException | None = None self._listener_task: Any = None - self._connections: dict[int, tuple[object, Any]] = {} + self._connections: dict[int, tuple[TransportHandle, Any]] = {} @property def address(self) -> tuple[str, int]: @@ -146,46 +149,83 @@ def port(self) -> int: @property def closed(self) -> bool: - return self._closed + return self._close_requested + + @property + def failure(self) -> BaseException | None: + """Return the fatal listener failure that initiated shutdown, if any.""" + return self._failure def close(self) -> None: - """Request shutdown safely from any thread without closing live FDs there.""" - if self._closed: + """Request external shutdown through a kernel wakeup channel.""" + if self._finished: return - self._closed = True - try: - self._wakeup.notify() - except Exception: - pass + if self._wakeup is None: + raise RuntimeError( + "this kernel cannot close a server from outside its scheduler; " + "use await server.close_from_task(task)" + ) + self._close_requested = True + if self._notification_sent: + return + # A nonconforming channel may raise. Keep the server unfinished so the + # caller can retry notification instead of turning close() into a no-op. + self._wakeup.notify() + self._notification_sent = True + + async def close_from_task(self, task: Any) -> None: + """Close on the scheduler thread when no external wake channel exists.""" + if getattr(self._runtime, "cursor", None) is not task: + raise RuntimeError("close_from_task() requires the currently running SmallOS task") + if self._finished: + return + self._close_requested = True + self._finish_close(current_task=task) + + def _listener_failed(self, exc: BaseException, task: Any) -> None: + """Record a fatal accept failure and make shutdown observable.""" + self._failure = exc + self._close_requested = True + if self._wakeup is not None: + try: + self._wakeup.notify() + self._notification_sent = True + return + except BaseException: + pass + self._finish_close(current_task=task) - def _finish_close(self) -> None: + def _finish_close(self, current_task: Any = None) -> None: if self._finished: return + self._close_requested = True self._finished = True - for connection, task in list(self._connections.values()): + if self._wakeup is not None: try: - self._runtime.resume_task(task) - except Exception: + self._wakeup.close() + except BaseException: pass - self._transport.close_safely(connection) + for connection, task in list(self._connections.values()): + if task is not current_task: + try: + self._runtime.resume_task(task) + except BaseException: + pass + self._transport.close_safely(connection) self._connections.clear() - if self._listener_task is not None: + if self._listener_task is not None and self._listener_task is not current_task: try: self._runtime.resume_task(self._listener_task) - except Exception: + except BaseException: pass self._transport.close_safely(self._listener) - try: - self._wakeup.close() - except Exception: - pass def _abort_startup(self, tasks: tuple[Any, ...]) -> None: """Release bound resources after task registration fails.""" - self._closed = True + self._close_requested = True cancel_task = getattr(self._runtime, "cancel_task", None) if callable(cancel_task): - for task in tasks: + for task in reversed(tasks): try: cancel_task(task) except BaseException: diff --git a/tests/kernel_fakes.py b/tests/kernel_fakes.py index a1c64da..386bd3a 100644 --- a/tests/kernel_fakes.py +++ b/tests/kernel_fakes.py @@ -11,6 +11,18 @@ class NeedsWrite(Exception): pass +class TLSWantRead(NeedsRead): + pass + + +class TLSWantWrite(NeedsWrite): + pass + + +class WouldBlock(BlockingIOError): + pass + + @dataclass class OpaqueHandle: """Intentionally unhashable stand-in for a backend-owned resource.""" @@ -24,12 +36,19 @@ def __init__(self) -> None: self.notify_calls = 0 self.drain_calls = 0 self.close_calls = 0 + self.notify_failures = 0 + self.drain_error: BaseException | None = None def notify(self) -> None: self.notify_calls += 1 + if self.notify_failures: + self.notify_failures -= 1 + raise RuntimeError("notify failed") def drain(self) -> None: self.drain_calls += 1 + if self.drain_error is not None: + raise self.drain_error def close(self) -> None: self.close_calls += 1 @@ -50,6 +69,9 @@ def __init__(self, supported: bool = True, wakeup_supported: bool = True) -> Non self.sent: dict[int, list[bytes]] = {} self.peer_addresses: dict[int, object | None] = {} self.fail_operation: str | None = None + self.operation_errors: dict[str, BaseException] = {} + self.close_failures: dict[int, int] = {} + self.invalid_wait_objects: set[int] = set() def supports_tcp_server(self) -> bool: self.calls.append(("supports_tcp_server",)) @@ -68,6 +90,8 @@ def socket_open(self, address_info: object) -> object: return self.listener def _maybe_fail(self, operation: str) -> None: + if operation in self.operation_errors: + raise self.operation_errors[operation] if self.fail_operation == operation: raise RuntimeError("{} failed".format(operation)) @@ -122,6 +146,10 @@ def socket_send(self, stream: object, data: bytes) -> int: def socket_close(self, stream: object) -> None: self.calls.append(("socket_close", stream)) + failures = self.close_failures.get(id(stream), 0) + if failures: + self.close_failures[id(stream)] = failures - 1 + raise RuntimeError("close failed") self.closed.append(stream) # type: ignore[arg-type] def socket_needs_read(self, exc: BaseException) -> bool: @@ -130,6 +158,20 @@ def socket_needs_read(self, exc: BaseException) -> bool: def socket_needs_write(self, exc: BaseException) -> bool: return isinstance(exc, NeedsWrite) + def socket_retry_mode(self, exc: BaseException, operation: str) -> str | None: + if isinstance(exc, NeedsRead): + return "read" + if isinstance(exc, NeedsWrite): + return "write" + if isinstance(exc, WouldBlock): + return "write" if operation == "send" else "read" + return None + + def validate_io_wait_object(self, obj: object) -> tuple[bool, BaseException | None]: + if id(obj) in self.invalid_wait_objects: + return False, ValueError("invalid wait object") + return True, None + def create_wakeup_channel(self) -> FakeWakeupChannel: self.calls.append(("create_wakeup_channel",)) return self.wakeup diff --git a/tests/test_kernel_transport.py b/tests/test_kernel_transport.py index 2d2c8dc..33f2dfe 100644 --- a/tests/test_kernel_transport.py +++ b/tests/test_kernel_transport.py @@ -3,10 +3,18 @@ import unittest from smallserver import Response, SmallServer -from smallserver._transport import KernelTransport +from smallserver._transport import KernelTransport, TransportHandle from smallserver.server import ServerConfig, ServerHandle -from tests.kernel_fakes import FakeKernel, NeedsRead, NeedsWrite, OpaqueHandle +from tests.kernel_fakes import ( + FakeKernel, + NeedsRead, + NeedsWrite, + OpaqueHandle, + TLSWantRead, + TLSWantWrite, + WouldBlock, +) def run_immediate(coroutine): @@ -20,6 +28,7 @@ def run_immediate(coroutine): class FakeTask: def __init__(self) -> None: self.waits: list[tuple[str, object]] = [] + self.yields = 0 async def wait_readable(self, handle: object) -> None: self.waits.append(("read", handle)) @@ -27,6 +36,9 @@ async def wait_readable(self, handle: object) -> None: async def wait_writable(self, handle: object) -> None: self.waits.append(("write", handle)) + async def yield_now(self) -> None: + self.yields += 1 + class KernelTransportTests(unittest.TestCase): def test_capability_failure_happens_before_address_resolution(self) -> None: @@ -35,14 +47,11 @@ def test_capability_failure_happens_before_address_resolution(self) -> None: KernelTransport(kernel) self.assertEqual(kernel.calls, [("supports_tcp_server",)]) - def test_wakeup_capability_failure_happens_before_address_resolution(self) -> None: + def test_kernel_without_wakeup_support_retains_scheduler_close_path(self) -> None: kernel = FakeKernel(wakeup_supported=False) - with self.assertRaisesRegex(NotImplementedError, "wakeup channels"): - KernelTransport(kernel) - self.assertEqual( - kernel.calls, - [("supports_tcp_server",), ("supports_wakeup_channel",)], - ) + transport = KernelTransport(kernel) + self.assertFalse(transport.supports_wakeup_channel) + self.assertIsNone(transport.create_wakeup_channel()) def test_incomplete_contract_fails_before_address_resolution(self) -> None: kernel = FakeKernel() @@ -66,6 +75,19 @@ def test_listener_uses_one_opaque_address_record_and_rolls_back_failure(self) -> self.assertIs(bind_call[2], kernel.address_info) self.assertEqual(kernel.closed, [kernel.listener]) + def test_listener_rollback_catches_base_exception_and_preserves_primary_error(self) -> None: + class FatalSetup(BaseException): + pass + + kernel = FakeKernel() + primary = FatalSetup("setup interrupted") + kernel.operation_errors["listen"] = primary + kernel.close_failures[id(kernel.listener)] = 1 + transport = KernelTransport(kernel) + with self.assertRaises(FatalSetup) as raised: + transport.open_listener("127.0.0.1", 0, 1) + self.assertIs(raised.exception, primary) + def test_accept_and_stream_operations_honor_both_retry_directions(self) -> None: kernel = FakeKernel() transport = KernelTransport(kernel) @@ -76,11 +98,12 @@ def test_accept_and_stream_operations_honor_both_retry_directions(self) -> None: kernel.send_results[id(client)] = [NeedsRead(), NeedsWrite(), 2, 3] task = FakeTask() - accepted = run_immediate(transport.accept(task, kernel.listener)) - received = run_immediate(transport.recv(task, client, 16)) - run_immediate(transport.send_all(task, client, b"reply")) + listener = TransportHandle(kernel.listener) + accepted = run_immediate(transport.accept(task, listener)) + received = run_immediate(transport.recv(task, accepted.stream, 16)) + run_immediate(transport.send_all(task, accepted.stream, b"reply")) - self.assertIs(accepted.stream, client) + self.assertIs(accepted.stream.raw, client) self.assertEqual(accepted.peer_address, fallback_peer) self.assertEqual(received, b"request") self.assertEqual( @@ -88,6 +111,26 @@ def test_accept_and_stream_operations_honor_both_retry_directions(self) -> None: ["read", "write", "write", "read", "read", "write"], ) self.assertEqual(kernel.sent[id(client)][-1], b"ply") + self.assertTrue(all(isinstance(part, memoryview) for part in kernel.sent[id(client)])) + + def test_operation_aware_retry_maps_eagain_and_tls_opposite_directions(self) -> None: + kernel = FakeKernel() + transport = KernelTransport(kernel) + listener = TransportHandle(kernel.listener) + client = OpaqueHandle("client") + kernel.accept_results = [WouldBlock(11, "EAGAIN"), (client, None)] + kernel.recv_results[id(client)] = [WouldBlock(11, "EAGAIN"), TLSWantWrite(), b"ok"] + kernel.send_results[id(client)] = [WouldBlock(11, "EAGAIN"), TLSWantRead(), 2] + task = FakeTask() + + accepted = run_immediate(transport.accept(task, listener)) + run_immediate(transport.recv(task, accepted.stream, 2)) + run_immediate(transport.send_all(task, accepted.stream, b"ok")) + + self.assertEqual( + [mode for mode, _ in task.waits], + ["read", "read", "write", "write", "read"], + ) def test_accept_configuration_failure_closes_the_new_stream_once(self) -> None: kernel = FakeKernel() @@ -96,10 +139,21 @@ def test_accept_configuration_failure_closes_the_new_stream_once(self) -> None: kernel.accept_results = [(client, ("127.0.0.1", 1))] kernel.fail_operation = "setblocking" with self.assertRaisesRegex(RuntimeError, "setblocking failed"): - run_immediate(transport.accept(FakeTask(), kernel.listener)) - transport.close_safely(client) + run_immediate(transport.accept(FakeTask(), TransportHandle(kernel.listener))) self.assertEqual(kernel.closed, [client]) + def test_send_requires_forward_progress(self) -> None: + for result in (0, -1): + with self.subTest(result=result): + kernel = FakeKernel() + transport = KernelTransport(kernel) + client = OpaqueHandle("client") + kernel.send_results[id(client)] = [result] + with self.assertRaisesRegex(ConnectionError, "forward progress"): + run_immediate( + transport.send_all(FakeTask(), TransportHandle(client), b"payload") + ) + def test_connection_registration_failure_cancels_task_and_closes_stream(self) -> None: class Runtime: def __init__(self) -> None: @@ -118,14 +172,56 @@ def cancel_task(self, task) -> None: handle = ServerHandle( Runtime(), transport, listener, transport.create_wakeup_channel(), ServerConfig() ) - client = OpaqueHandle("client") - kernel.accept_results = [(client, ("127.0.0.1", 1)), RuntimeError("listener failed")] + clients = [OpaqueHandle("client-{}".format(index)) for index in range(4)] + kernel.accept_results = [ + *((client, ("127.0.0.1", 1)) for client in clients), + RuntimeError("listener failed"), + ] + task = FakeTask() + handle._config = ServerConfig(accept_batch_size=2) - run_immediate(SmallServer()._accept_loop(FakeTask(), handle)) + with self.assertRaisesRegex(RuntimeError, "listener failed"): + run_immediate(SmallServer()._accept_loop(task, handle)) - self.assertEqual(len(handle._runtime.cancelled), 1) - self.assertEqual(kernel.closed, [client]) + self.assertEqual(len(handle._runtime.cancelled), 4) + self.assertEqual(kernel.closed, clients) self.assertEqual(handle._connections, {}) + self.assertEqual(task.yields, 2) + self.assertIsInstance(handle.failure, RuntimeError) + run_immediate(SmallServer()._close_watcher(FakeTask(), handle)) + self.assertEqual(kernel.closed, clients + [listener.raw]) + + def test_full_capacity_accepts_are_batched_and_yield_fairly(self) -> None: + class Runtime: + def resume_task(self, task) -> None: + pass + + kernel = FakeKernel() + transport = KernelTransport(kernel) + listener = transport.open_listener("127.0.0.1", 0, 1) + handle = ServerHandle( + Runtime(), + transport, + listener, + transport.create_wakeup_channel(), + ServerConfig(max_connections=1, accept_batch_size=2), + ) + occupied = TransportHandle(OpaqueHandle("occupied")) + handle._connections[id(occupied)] = (occupied, object()) + clients = [OpaqueHandle("overflow-{}".format(index)) for index in range(4)] + kernel.accept_results = [ + *((client, None) for client in clients), + RuntimeError("listener failed"), + ] + task = FakeTask() + + with self.assertRaisesRegex(RuntimeError, "listener failed"): + run_immediate(SmallServer()._accept_loop(task, handle)) + + self.assertEqual(task.yields, 2) + self.assertEqual(kernel.closed, clients) + run_immediate(SmallServer()._close_watcher(FakeTask(), handle)) + self.assertEqual(kernel.closed, clients + [occupied.raw, listener.raw]) def test_server_handle_signals_and_releases_each_resource_once(self) -> None: class Runtime: @@ -145,19 +241,119 @@ def resume_task(self, task) -> None: connection = OpaqueHandle("connection") connection_task = object() listener_task = object() - handle._connections[id(connection)] = (connection, connection_task) + owned_connection = TransportHandle(connection) + handle._connections[id(owned_connection)] = (owned_connection, connection_task) handle._listener_task = listener_task handle.close() handle.close() handle._finish_close() handle._finish_close() - transport.close_safely(connection) + transport.close_safely(owned_connection) - self.assertEqual(wakeup.notify_calls, 1) - self.assertEqual(wakeup.close_calls, 1) + self.assertEqual(kernel.wakeup.notify_calls, 1) + self.assertEqual(kernel.wakeup.close_calls, 1) self.assertEqual(runtime.resumed, [connection_task, listener_task]) - self.assertEqual(kernel.closed, [connection, listener]) + self.assertEqual(kernel.closed, [connection, listener.raw]) + + def test_notification_failure_can_be_retried_until_close_watcher_finishes(self) -> None: + class Runtime: + def resume_task(self, task) -> None: + pass + + kernel = FakeKernel() + transport = KernelTransport(kernel) + handle = ServerHandle( + Runtime(), + transport, + transport.open_listener("127.0.0.1", 0, 1), + transport.create_wakeup_channel(), + ServerConfig(), + ) + kernel.wakeup.notify_failures = 1 + with self.assertRaisesRegex(RuntimeError, "notify failed"): + handle.close() + self.assertTrue(handle.closed) + handle.close() + self.assertEqual(kernel.wakeup.notify_calls, 2) + kernel.wakeup.drain_error = RuntimeError("drain failed") + with self.assertRaisesRegex(RuntimeError, "drain failed"): + run_immediate(SmallServer()._close_watcher(FakeTask(), handle)) + self.assertEqual(kernel.closed, [kernel.listener]) + self.assertEqual(kernel.wakeup.close_calls, 1) + + def test_invalid_or_failing_wakeup_wait_object_closes_acquired_channel(self) -> None: + class FailingWaitChannel: + close_calls = 0 + + @property + def wait_object(self): + raise RuntimeError("wait object failed") + + def notify(self): + pass + + def drain(self): + pass + + def close(self): + self.close_calls += 1 + + for property_failure in (True, False): + with self.subTest(property_failure=property_failure): + kernel = FakeKernel() + channel = FailingWaitChannel() if property_failure else kernel.wakeup + kernel.create_wakeup_channel = lambda: channel # type: ignore[method-assign] + if not property_failure: + kernel.invalid_wait_objects.add(id(channel.wait_object)) + transport = KernelTransport(kernel) + with self.assertRaisesRegex((RuntimeError, ValueError), "wait object|invalid"): + transport.create_wakeup_channel() + self.assertEqual(channel.close_calls, 1) + + def test_no_wakeup_kernel_requires_explicit_scheduler_close(self) -> None: + class Runtime: + def __init__(self) -> None: + self.kernel = FakeKernel(wakeup_supported=False) + self.cursor = object() + self.resumed = [] + self.forked = [] + + def fork(self, tasks) -> None: + self.forked.extend(tasks if isinstance(tasks, list) else [tasks]) + + def resume_task(self, task) -> None: + self.resumed.append(task) + + runtime = Runtime() + handle = SmallServer().serve(runtime, host="0.0.0.0", port=8080) + self.assertEqual(len(runtime.forked), 1) + self.assertIsNone(handle._wakeup) + + with self.assertRaisesRegex(RuntimeError, "outside its scheduler"): + handle.close() + self.assertFalse(handle.closed) + with self.assertRaisesRegex(RuntimeError, "currently running"): + run_immediate(handle.close_from_task(object())) + run_immediate(handle.close_from_task(runtime.cursor)) + self.assertTrue(handle.closed) + self.assertEqual(runtime.kernel.closed, [runtime.kernel.listener]) + + def test_close_state_is_per_handle_and_failed_close_can_be_retried(self) -> None: + kernel = FakeKernel() + transport = KernelTransport(kernel) + raw = OpaqueHandle("connection") + handle = TransportHandle(raw) + kernel.close_failures[id(raw)] = 1 + with self.assertRaisesRegex(RuntimeError, "close failed"): + transport.close(handle) + self.assertFalse(handle.closed) + transport.close(handle) + transport.close(handle) + self.assertTrue(handle.closed) + close_calls = [call for call in kernel.calls if call[:2] == ("socket_close", raw)] + self.assertEqual(len(close_calls), 2) + self.assertFalse(hasattr(transport, "_closed")) def test_fake_kernel_connection_preserves_http_response_bytes(self) -> None: class Runtime: @@ -178,7 +374,8 @@ def resume_task(self, task) -> None: async def health(request): return Response.json({"status": "ok"}) - run_immediate(app._connection_loop(FakeTask(), handle, client)) + owned_client = TransportHandle(client) + run_immediate(app._connection_loop(FakeTask(), handle, owned_client)) self.assertEqual( kernel.sent[id(client)][0], diff --git a/tests/test_server.py b/tests/test_server.py index 7927f27..c1e0c88 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -67,16 +67,33 @@ def resume_task(self, task) -> None: self.assertEqual(runtime.kernel.wakeup.close_calls, 1) def test_serve_closes_kernel_resources_when_task_construction_fails(self) -> None: + from SmallPackage import SmallTask as RealSmallTask + class Runtime: def __init__(self) -> None: self.kernel = FakeKernel() + self.cancelled = [] def resume_task(self, task) -> None: pass + def cancel_task(self, task) -> None: + self.cancelled.append(task) + task.cancel() + + calls = 0 + + def construct_task(*args, **kwargs): + nonlocal calls + calls += 1 + if calls == 2: + raise RuntimeError("task failed") + return RealSmallTask(*args, **kwargs) + runtime = Runtime() - with patch("SmallPackage.SmallTask", side_effect=RuntimeError("task failed")): + with patch("SmallPackage.SmallTask", side_effect=construct_task): with self.assertRaisesRegex(RuntimeError, "task failed"): SmallServer().serve(runtime) + self.assertEqual(len(runtime.cancelled), 1) self.assertEqual([handle.name for handle in runtime.kernel.closed], ["listener"]) self.assertEqual(runtime.kernel.wakeup.close_calls, 1) diff --git a/tests/test_server_runtime.py b/tests/test_server_runtime.py index 2bfa26b..e34ef9e 100644 --- a/tests/test_server_runtime.py +++ b/tests/test_server_runtime.py @@ -63,6 +63,10 @@ def client() -> None: b"".join(received), b"HTTP/1.1 200 OK\r\nContent-Length: 15\r\nContent-Type: application/json\r\nConnection: close\r\n\r\n{\"status\":\"ok\"}", ) + self.assertEqual(runtime.ioReadWaiters, {}) + self.assertEqual(runtime.ioWriteWaiters, {}) + self.assertIsNone(runtime._io_wait_set) + self.assertTrue(server._listener.closed) def test_blocking_adapter_does_not_block_unrelated_connection(self) -> None: runtime = SmallOS().setKernel(Unix()) @@ -127,3 +131,53 @@ def fast_client() -> None: self.assertEqual(errors, []) self.assertIn(b"\r\n\r\nfast done", responses["fast"]) self.assertIn(b"\r\n\r\nslow done", responses["slow"]) + + def test_slow_client_does_not_block_a_complete_request(self) -> None: + runtime = SmallOS().setKernel(Unix()) + app = SmallServer() + + @app.get("/fast") + async def fast(request): + return Response.text("fast") + + try: + server = app.serve(runtime, host="127.0.0.1", port=0) + except PermissionError: + self.skipTest("the current sandbox does not permit loopback TCP binds") + + received: list[bytes] = [] + errors: list[BaseException] = [] + + def clients() -> None: + slow = None + try: + slow = socket.create_connection(("127.0.0.1", server.port), timeout=2) + slow.sendall(b"GET /slow HTTP/1.1\r\nHost: local") + with socket.create_connection(("127.0.0.1", server.port), timeout=2) as fast_client: + fast_client.sendall(b"GET /fast HTTP/1.1\r\nHost: localhost\r\n\r\n") + while True: + chunk = fast_client.recv(4096) + if not chunk: + break + received.append(chunk) + except BaseException as exc: + errors.append(exc) + finally: + if slow is not None: + slow.close() + server.close() + + worker = threading.Thread(target=clients, daemon=True) + worker.start() + runtime.start() + worker.join(timeout=2) + + self.assertFalse(worker.is_alive()) + self.assertEqual(errors, []) + self.assertEqual( + b"".join(received), + b"HTTP/1.1 200 OK\r\nContent-Length: 4\r\nContent-Type: text/plain; charset=utf-8\r\n" + b"Connection: close\r\n\r\nfast", + ) + self.assertEqual(runtime.ioReadWaiters, {}) + self.assertEqual(runtime.ioWriteWaiters, {}) From 2b27c95a2734b8e2d9444936e5986a83b1b8f102 Mon Sep 17 00:00:00 2001 From: MikiEEE <35975462+MikiEEE@users.noreply.github.com> Date: Sat, 22 Aug 2026 00:03:12 -0500 Subject: [PATCH 03/53] fix: retain resources after cleanup failures --- README.md | 17 +++++- smallserver/_transport.py | 22 +++++-- smallserver/app.py | 14 ++--- smallserver/server.py | 96 ++++++++++++++++++++++++++---- tests/kernel_fakes.py | 4 ++ tests/test_kernel_transport.py | 104 +++++++++++++++++++++++++++++++-- 6 files changed, 229 insertions(+), 28 deletions(-) diff --git a/README.md b/README.md index 37cbe90..c775e64 100644 --- a/README.md +++ b/README.md @@ -65,9 +65,20 @@ server = app.serve(runtime, host="127.0.0.1", port=8000) runtime.start() ``` -Call `server.close()` from another thread or client-control path to request a -scheduler-safe shutdown. Each current connection accepts one request and sends -a `Connection: close` response. +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. + +Each current connection accepts one request and sends a `Connection: close` +response. ## Define routes diff --git a/smallserver/_transport.py b/smallserver/_transport.py index a1de0e1..0ac42e6 100644 --- a/smallserver/_transport.py +++ b/smallserver/_transport.py @@ -63,6 +63,7 @@ class TransportHandle: raw: object closed: bool = False + close_error: BaseException | None = None @dataclass(frozen=True) @@ -80,6 +81,7 @@ class WakeupChannel: raw: WakeupChannelLike wait_object: object closed: bool = False + close_error: BaseException | None = None def notify(self) -> None: self.raw.notify() @@ -90,8 +92,13 @@ def drain(self) -> None: def close(self) -> None: if self.closed: return - self.raw.close() + try: + self.raw.close() + except BaseException as exc: + self.close_error = exc + raise self.closed = True + self.close_error = None class KernelTransport: @@ -231,14 +238,21 @@ def local_address(self, listener: TransportHandle) -> tuple[str, int]: def close(self, handle: TransportHandle) -> None: if handle.closed: return - self._kernel.socket_close(handle.raw) + try: + self._kernel.socket_close(handle.raw) + except BaseException as exc: + handle.close_error = exc + raise handle.closed = True + handle.close_error = None - def close_safely(self, handle: TransportHandle) -> None: + def close_safely(self, handle: TransportHandle) -> bool: + """Attempt terminal close and report whether the handle is now closed.""" try: self.close(handle) except BaseException: - pass + return False + return True def create_wakeup_channel(self) -> WakeupChannel | None: if not self.supports_wakeup_channel: diff --git a/smallserver/app.py b/smallserver/app.py index 09ab362..ba508b6 100644 --- a/smallserver/app.py +++ b/smallserver/app.py @@ -66,9 +66,10 @@ def serve( ) -> ServerHandle: """Bind a TCP listener and schedule SmallOS listener/control tasks. - The caller owns ``runtime.start()``. ``ServerHandle.close()`` is safe - from a client or another thread and wakes the scheduler without - directly mutating SmallOS task state there. + The caller owns ``runtime.start()``. On kernels with a wakeup channel, + ``ServerHandle.close()`` is safe from another thread. Constrained + kernels use ``await ServerHandle.close_from_task(task)`` on the + scheduler thread instead. """ from SmallPackage import SmallTask @@ -141,7 +142,7 @@ async def _accept_loop(self, task: Any, handle: ServerHandle) -> None: client = accepted.stream accepted_in_batch += 1 if handle.closed or len(handle._connections) >= handle._config.max_connections: - handle._transport.close_safely(client) + handle._close_or_retain(client) else: from SmallPackage import SmallTask @@ -165,7 +166,7 @@ async def _accept_loop(self, task: Any, handle: ServerHandle) -> None: cancel_task(connection_task) except Exception: pass - handle._transport.close_safely(client) + handle._close_or_retain(client) if accepted_in_batch >= handle._config.accept_batch_size: accepted_in_batch = 0 await task.yield_now() @@ -212,8 +213,7 @@ async def _connection_loop( await self._send_response(task, handle, client, response) return finally: - handle._connections.pop(id(client), None) - handle._transport.close_safely(client) + handle._connection_finished(task, client) async def _send_response( self, diff --git a/smallserver/server.py b/smallserver/server.py index 5061974..ea5dab1 100644 --- a/smallserver/server.py +++ b/smallserver/server.py @@ -134,10 +134,14 @@ def __init__( self._config = config self._close_requested = False self._notification_sent = False + self._finalization_attempted = False self._finished = False self._failure: BaseException | None = None + self._cleanup_errors: dict[str, BaseException] = {} self._listener_task: Any = None + self._listener_resumed = False self._connections: dict[int, tuple[TransportHandle, Any]] = {} + self._closing_connections: dict[int, TransportHandle] = {} @property def address(self) -> tuple[str, int]: @@ -156,6 +160,16 @@ def failure(self) -> BaseException | None: """Return the fatal listener failure that initiated shutdown, if any.""" return self._failure + @property + def finished(self) -> bool: + """Whether every kernel-owned server resource closed successfully.""" + return self._finished + + @property + def cleanup_errors(self) -> tuple[BaseException, ...]: + """Latest close failures for resources still owned by this server.""" + return tuple(self._cleanup_errors.values()) + def close(self) -> None: """Request external shutdown through a kernel wakeup channel.""" if self._finished: @@ -167,6 +181,11 @@ def close(self) -> None: ) self._close_requested = True if self._notification_sent: + if self._finalization_attempted and not self._finished: + raise RuntimeError( + "shutdown cleanup is incomplete; retry it from the scheduler " + "with await server.close_from_task(task)" + ) return # A nonconforming channel may raise. Keep the server unfinished so the # caller can retry notification instead of turning close() into a no-op. @@ -174,12 +193,13 @@ def close(self) -> None: self._notification_sent = True async def close_from_task(self, task: Any) -> None: - """Close on the scheduler thread when no external wake channel exists.""" + """Close or retry incomplete cleanup on the SmallOS scheduler thread.""" if getattr(self._runtime, "cursor", None) is not task: raise RuntimeError("close_from_task() requires the currently running SmallOS task") if self._finished: return self._close_requested = True + self._finalization_attempted = True self._finish_close(current_task=task) def _listener_failed(self, exc: BaseException, task: Any) -> None: @@ -199,26 +219,82 @@ def _finish_close(self, current_task: Any = None) -> None: if self._finished: return self._close_requested = True - self._finished = True - if self._wakeup is not None: + self._finalization_attempted = True + if self._wakeup is not None and not self._wakeup.closed: try: self._wakeup.close() - except BaseException: - pass - for connection, task in list(self._connections.values()): + except BaseException as exc: + self._cleanup_errors["wakeup"] = exc + else: + self._cleanup_errors.pop("wakeup", None) + + # Retry resources retained by an earlier close failure once per + # finalization attempt. Newly failed active connections remain owned + # for the next attempt rather than being retried in a tight loop. + for identity, connection in list(self._closing_connections.items()): + if self._transport.close_safely(connection): + self._closing_connections.pop(identity, None) + self._cleanup_errors.pop("connection:{}".format(identity), None) + else: + error = connection.close_error or RuntimeError( + "kernel connection close failed" + ) + self._cleanup_errors["connection:{}".format(identity)] = error + + for identity, (connection, task) in list(self._connections.items()): if task is not current_task: try: self._runtime.resume_task(task) except BaseException: pass - self._transport.close_safely(connection) - self._connections.clear() - if self._listener_task is not None and self._listener_task is not current_task: + self._connections.pop(identity, None) + self._close_or_retain(connection) + if ( + self._listener_task is not None + and self._listener_task is not current_task + and not self._listener_resumed + ): try: self._runtime.resume_task(self._listener_task) except BaseException: pass - self._transport.close_safely(self._listener) + self._listener_resumed = True + if not self._listener.closed: + try: + self._transport.close(self._listener) + except BaseException as exc: + self._cleanup_errors["listener"] = exc + else: + self._cleanup_errors.pop("listener", None) + self._update_finished() + + def _close_or_retain(self, connection: TransportHandle) -> None: + identity = id(connection) + if self._transport.close_safely(connection): + self._closing_connections.pop(identity, None) + self._cleanup_errors.pop("connection:{}".format(identity), None) + return + self._closing_connections[identity] = connection + error = connection.close_error or RuntimeError("kernel connection close failed") + self._cleanup_errors["connection:{}".format(identity)] = error + + def _connection_finished(self, task: Any, connection: TransportHandle) -> None: + """Release a completed connection without losing failed-close ownership.""" + self._connections.pop(id(connection), None) + self._close_or_retain(connection) + self._update_finished() + + def _update_finished(self) -> None: + wakeup_closed = self._wakeup is None or self._wakeup.closed + self._finished = bool( + self._close_requested + and wakeup_closed + and self._listener.closed + and not self._connections + and not self._closing_connections + ) + if self._finished: + self._cleanup_errors.clear() def _abort_startup(self, tasks: tuple[Any, ...]) -> None: """Release bound resources after task registration fails.""" diff --git a/tests/kernel_fakes.py b/tests/kernel_fakes.py index 386bd3a..70d59d9 100644 --- a/tests/kernel_fakes.py +++ b/tests/kernel_fakes.py @@ -36,6 +36,7 @@ def __init__(self) -> None: self.notify_calls = 0 self.drain_calls = 0 self.close_calls = 0 + self.close_failures = 0 self.notify_failures = 0 self.drain_error: BaseException | None = None @@ -52,6 +53,9 @@ def drain(self) -> None: def close(self) -> None: self.close_calls += 1 + if self.close_failures: + self.close_failures -= 1 + raise RuntimeError("wakeup close failed") class FakeKernel: diff --git a/tests/test_kernel_transport.py b/tests/test_kernel_transport.py index 33f2dfe..65f0c60 100644 --- a/tests/test_kernel_transport.py +++ b/tests/test_kernel_transport.py @@ -282,6 +282,101 @@ def resume_task(self, task) -> None: self.assertEqual(kernel.closed, [kernel.listener]) self.assertEqual(kernel.wakeup.close_calls, 1) + def test_failed_connection_finally_retains_ownership_until_shutdown_retry(self) -> None: + class Runtime: + def resume_task(self, task) -> None: + pass + + kernel = FakeKernel() + transport = KernelTransport(kernel) + listener = transport.open_listener("127.0.0.1", 0, 1) + handle = ServerHandle( + Runtime(), transport, listener, transport.create_wakeup_channel(), ServerConfig() + ) + client = OpaqueHandle("client") + owned_client = TransportHandle(client) + task = FakeTask() + handle._connections[id(owned_client)] = (owned_client, task) + kernel.recv_results[id(client)] = [b""] + kernel.close_failures[id(client)] = 1 + + run_immediate(SmallServer()._connection_loop(task, handle, owned_client)) + + self.assertFalse(owned_client.closed) + self.assertIn(id(owned_client), handle._closing_connections) + self.assertEqual(len(handle.cleanup_errors), 1) + handle.close() + run_immediate(SmallServer()._close_watcher(FakeTask(), handle)) + self.assertTrue(owned_client.closed) + self.assertTrue(handle.finished) + self.assertEqual(handle.cleanup_errors, ()) + self.assertEqual(kernel.closed, [client, listener.raw]) + + def test_connection_cleanup_failure_does_not_replace_primary_error(self) -> None: + class Runtime: + def resume_task(self, task) -> None: + pass + + kernel = FakeKernel() + transport = KernelTransport(kernel) + listener = transport.open_listener("127.0.0.1", 0, 1) + handle = ServerHandle( + Runtime(), transport, listener, transport.create_wakeup_channel(), ServerConfig() + ) + client = OpaqueHandle("client") + owned_client = TransportHandle(client) + task = FakeTask() + primary = RuntimeError("send failed") + kernel.recv_results[id(client)] = [b"GET /missing HTTP/1.1\r\nHost: localhost\r\n\r\n"] + kernel.send_results[id(client)] = [primary] + kernel.close_failures[id(client)] = 1 + + with self.assertRaises(RuntimeError) as raised: + run_immediate(SmallServer()._connection_loop(task, handle, owned_client)) + + self.assertIs(raised.exception, primary) + self.assertIn(id(owned_client), handle._closing_connections) + self.assertIs(handle.cleanup_errors[0], owned_client.close_error) + + def test_finalization_retries_listener_and_wakeup_close_failures(self) -> None: + class Runtime: + def __init__(self) -> None: + self.cursor = object() + + def resume_task(self, task) -> None: + pass + + kernel = FakeKernel() + transport = KernelTransport(kernel) + listener = transport.open_listener("127.0.0.1", 0, 1) + wakeup = transport.create_wakeup_channel() + assert wakeup is not None + runtime = Runtime() + handle = ServerHandle(runtime, transport, listener, wakeup, ServerConfig()) + kernel.close_failures[id(listener.raw)] = 1 + kernel.wakeup.close_failures = 1 + + handle.close() + handle._finish_close() + + self.assertFalse(handle.finished) + self.assertFalse(listener.closed) + self.assertFalse(wakeup.closed) + self.assertEqual(len(handle.cleanup_errors), 2) + with self.assertRaisesRegex(RuntimeError, "cleanup is incomplete"): + handle.close() + run_immediate(handle.close_from_task(runtime.cursor)) + handle._finish_close() + self.assertTrue(handle.finished) + self.assertTrue(listener.closed) + self.assertTrue(wakeup.closed) + self.assertEqual(handle.cleanup_errors, ()) + self.assertEqual(kernel.wakeup.close_calls, 2) + listener_close_calls = [ + call for call in kernel.calls if call[:2] == ("socket_close", listener.raw) + ] + self.assertEqual(len(listener_close_calls), 2) + def test_invalid_or_failing_wakeup_wait_object_closes_acquired_channel(self) -> None: class FailingWaitChannel: close_calls = 0 @@ -345,12 +440,13 @@ def test_close_state_is_per_handle_and_failed_close_can_be_retried(self) -> None raw = OpaqueHandle("connection") handle = TransportHandle(raw) kernel.close_failures[id(raw)] = 1 - with self.assertRaisesRegex(RuntimeError, "close failed"): - transport.close(handle) + self.assertFalse(transport.close_safely(handle)) self.assertFalse(handle.closed) - transport.close(handle) - transport.close(handle) + self.assertIsInstance(handle.close_error, RuntimeError) + self.assertTrue(transport.close_safely(handle)) + self.assertTrue(transport.close_safely(handle)) self.assertTrue(handle.closed) + self.assertIsNone(handle.close_error) close_calls = [call for call in kernel.calls if call[:2] == ("socket_close", raw)] self.assertEqual(len(close_calls), 2) self.assertFalse(hasattr(transport, "_closed")) From 76e39375cbf783fc80ecb5c7202560f03fdc73f5 Mon Sep 17 00:00:00 2001 From: MikiEEE <35975462+MikiEEE@users.noreply.github.com> Date: Fri, 21 Aug 2026 23:17:33 -0500 Subject: [PATCH 04/53] feat: add timeout-bounded regex routing --- README.md | 58 +++++++- benchmarks/route_benchmark.py | 50 +++++++ pyproject.toml | 1 + smallserver/__init__.py | 4 + smallserver/app.py | 60 +++++--- smallserver/http.py | 44 +++++- smallserver/routing.py | 265 ++++++++++++++++++++++++++++++++++ smallserver/server.py | 33 ++++- tests/test_http.py | 26 ++++ tests/test_regex_routing.py | 231 +++++++++++++++++++++++++++++ tests/test_routing.py | 16 ++ tests/test_server.py | 18 +++ tests/test_server_runtime.py | 37 +++++ 13 files changed, 810 insertions(+), 33 deletions(-) create mode 100644 benchmarks/route_benchmark.py create mode 100644 smallserver/routing.py create mode 100644 tests/test_regex_routing.py diff --git a/README.md b/README.md index 37cbe90..0992a4e 100644 --- a/README.md +++ b/README.md @@ -1,14 +1,17 @@ # 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. +a bounded HTTP/1.1 server and async routing for GET, POST, PUT, PATCH, and +DELETE. Static routes are built in, timeout-bounded regular-expression routes +are available through an optional dependency, and explicit escape hatches +support blocking and asyncio-native libraries. ## Current scope The current package provides an HTTP/1.1 baseline over a SmallOS runtime. It can: -- register static async routes for GET, POST, PUT, PATCH, and DELETE; +- register static or optional regular-expression 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 @@ -18,7 +21,8 @@ The current package provides an HTTP/1.1 baseline over a SmallOS runtime. It can - 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. +Keep-alive/pipelining, TLS, automatic path templates, and HTTP/2 are not +implemented yet. ## Install for development @@ -28,6 +32,14 @@ python3 -m pip install -e . python3 -m unittest discover -s tests -v ``` +Install the optional matching engine when an application uses raw regex routes: + +```bash +python3 -m pip install -e '.[regex-routes]' +``` + +Static routing neither imports nor requires that dependency. + SmallOS is installed from the canonical `master` branch in `requirements.txt`. It owns scheduling, socket readiness, and foreign execution adapters. @@ -101,8 +113,42 @@ 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. +Static route lookup is dictionary-based and always takes precedence over a +regex route for the same method and path. Richer lifecycle hooks are deferred; +the current `ServerHandle` provides explicit shutdown. + +## Define regular-expression routes + +Regex routes use full-path matching and run in registration order after static +lookup. Only named captures become immutable `request.path_params`; an optional +group that did not participate is omitted. + +```python +@app.get_regex(r"/users/(?P[0-9]+)") +async def get_user(request: Request) -> Response: + return Response.json({"user_id": request.path_params["user_id"]}) + +@app.route_regex( + r"/articles/(?P[a-z0-9]+(?:-[a-z0-9]+)*)", + methods=("GET", "PATCH"), +) +async def article(request: Request) -> Response: + return Response.json({"slug": request.path_params["slug"]}) +``` + +Patterns must begin with a literal `/` and do not need `^` or `$`. They are +trusted application configuration, but paths are hostile input: SmallServer +bounds pattern length, route count, named captures, path bytes, each match, and +the total matching time. Prefer unambiguous repetition and narrow character +classes even with these deadlines. A timeout raises `RouteMatchTimeout` with an +opaque route ID and becomes a sanitized 500 response on the network path. + +Requests retain the exact ASCII origin-form target in `request.raw_target`. +Routing uses `request.path`, which excludes the query string; +`request.query_string` contains the raw text after `?`. Neither paths nor named +captures are percent-decoded, so `/files/a%2Fb` remains distinct from +`/files/a/b`. `request.route_pattern` identifies the selected static path or +regex pattern. ## Dispatch a request diff --git a/benchmarks/route_benchmark.py b/benchmarks/route_benchmark.py new file mode 100644 index 0000000..29bb1b2 --- /dev/null +++ b/benchmarks/route_benchmark.py @@ -0,0 +1,50 @@ +"""Small routing microbenchmark; run with ``python benchmarks/route_benchmark.py``.""" + +from __future__ import annotations + +import asyncio +import importlib.util +from pathlib import Path +import sys +import time + +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) + +from smallserver import Headers, RegexRouteConfig, Request, Response, RouteMatchTimeout, SmallServer + + +async def benchmark() -> None: + app = SmallServer() + + @app.get("/health") + async def health(request): + return Response() + + request = Request("GET", "/health", Headers()) + iterations = 25_000 + started = time.perf_counter() + for _ in range(iterations): + await app.dispatch(request) + static_elapsed = time.perf_counter() - started + print("static: {:.0f} dispatches/second".format(iterations / static_elapsed)) + + if importlib.util.find_spec("regex") is None: + print("regex: skipped (install smallserver[regex-routes])") + return + + bounded = SmallServer(RegexRouteConfig(match_timeout=0.002, total_match_timeout=0.005)) + + @bounded.get_regex(r"/(a+)+$") + async def hostile(request): + return Response() + + started = time.perf_counter() + try: + await bounded.dispatch(Request("GET", "/" + "a" * 5000 + "!", Headers())) + except RouteMatchTimeout: + pass + print("worst-case regex timeout: {:.4f}s".format(time.perf_counter() - started)) + + +if __name__ == "__main__": + asyncio.run(benchmark()) diff --git a/pyproject.toml b/pyproject.toml index 337debe..d78894a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -14,6 +14,7 @@ dependencies = [] [project.optional-dependencies] dev = ["build>=1.2"] +regex-routes = ["regex>=2023.10.3"] [tool.setuptools.packages.find] include = ["smallserver*"] diff --git a/smallserver/__init__.py b/smallserver/__init__.py index a421318..e470f75 100644 --- a/smallserver/__init__.py +++ b/smallserver/__init__.py @@ -4,6 +4,7 @@ from .adapters import AdapterRegistry, AdapterShutdownError, http_error_from_adapter from .errors import HTTPError from .http import Headers, Request, Response +from .routing import RegexRouteConfig, RegexRoutesUnavailable, RouteMatchTimeout from .server import ServerConfig, ServerHandle __all__ = [ @@ -11,8 +12,11 @@ "AdapterShutdownError", "Headers", "HTTPError", + "RegexRouteConfig", + "RegexRoutesUnavailable", "Request", "Response", + "RouteMatchTimeout", "ServerConfig", "ServerHandle", "SmallServer", diff --git a/smallserver/app.py b/smallserver/app.py index 416542f..aabb735 100644 --- a/smallserver/app.py +++ b/smallserver/app.py @@ -4,40 +4,35 @@ import inspect from collections.abc import Awaitable, Callable, Iterable +from dataclasses import replace import socket from typing import Any from .errors import HTTPError from .http import Request, Response +from .routing import RegexRouteConfig, Router from .server import HTTPParseError, HTTPRequestParser, ServerConfig, ServerHandle Handler = Callable[[Request], Awaitable[Response]] -_METHODS = frozenset({"GET", "POST", "PUT", "PATCH", "DELETE"}) class SmallServer: """Register static HTTP routes and dispatch requests to async handlers.""" - def __init__(self) -> None: - self._routes: dict[tuple[str, str], Handler] = {} + def __init__(self, regex_config: RegexRouteConfig | None = None) -> None: + self._router = Router(regex_config) def route(self, path: str, methods: Iterable[str]) -> Callable[[Handler], Handler]: if not isinstance(path, str) or not path.startswith("/"): raise ValueError("route path must start with '/'") - normalized = tuple(dict.fromkeys(method.upper() for method in methods)) - if not normalized or any(method not in _METHODS for method in normalized): - raise ValueError("routes must use one or more supported HTTP methods") + if "?" in path or "#" in path: + raise ValueError("route path must not contain a query string or fragment") + normalized = self._router.normalize_methods(methods) def register(handler: Handler) -> Handler: if not callable(handler): raise TypeError("route handler must be callable") - keys = [(method, path) for method in normalized] - for method, key_path in keys: - key = (method, key_path) - if key in self._routes: - raise ValueError("route already registered: {} {}".format(method, path)) - for key in keys: - self._routes[key] = handler + self._router.add_static(path, normalized, handler) return handler return register @@ -57,6 +52,33 @@ def patch(self, path: str) -> Callable[[Handler], Handler]: def delete(self, path: str) -> Callable[[Handler], Handler]: return self.route(path, ("DELETE",)) + def route_regex(self, pattern: str, methods: Iterable[str]) -> Callable[[Handler], Handler]: + """Register a timeout-bounded full-path regular-expression route.""" + normalized = self._router.normalize_methods(methods) + + def register(handler: Handler) -> Handler: + if not callable(handler): + raise TypeError("route handler must be callable") + self._router.add_regex(pattern, normalized, handler) + return handler + + return register + + def get_regex(self, pattern: str) -> Callable[[Handler], Handler]: + return self.route_regex(pattern, ("GET",)) + + def post_regex(self, pattern: str) -> Callable[[Handler], Handler]: + return self.route_regex(pattern, ("POST",)) + + def put_regex(self, pattern: str) -> Callable[[Handler], Handler]: + return self.route_regex(pattern, ("PUT",)) + + def patch_regex(self, pattern: str) -> Callable[[Handler], Handler]: + return self.route_regex(pattern, ("PATCH",)) + + def delete_regex(self, pattern: str) -> Callable[[Handler], Handler]: + return self.route_regex(pattern, ("DELETE",)) + def serve( self, runtime: Any, @@ -110,12 +132,13 @@ def serve( async def dispatch(self, request: Request) -> Response: """Run a registered handler or return a deterministic HTTP response.""" - handler = self._routes.get((request.method.upper(), request.path)) - if handler is None: - allowed = sorted(method for method, path in self._routes if path == request.path) - if allowed: - return Response.text("method not allowed", status=405, headers={"Allow": ", ".join(allowed)}) + match = self._router.resolve(request.method, request.path) + if match.handler is None: + if match.allowed_methods: + return Response.text("method not allowed", status=405, headers={"Allow": ", ".join(match.allowed_methods)}) return Response.text("not found", status=404) + handler = match.handler + request = replace(request, path_params=match.path_params, route_pattern=match.route_pattern) try: result = handler(request) if not inspect.isawaitable(result): @@ -169,6 +192,7 @@ async def _connection_loop(self, task: Any, handle: ServerHandle, client: socket handle._config.max_header_bytes, handle._config.max_header_count, handle._config.max_body_bytes, + handle._config.max_request_target_bytes, ) try: while not handle.closed: diff --git a/smallserver/http.py b/smallserver/http.py index 42b43c8..f90afbd 100644 --- a/smallserver/http.py +++ b/smallserver/http.py @@ -3,14 +3,25 @@ from __future__ import annotations from collections.abc import Iterable, Iterator, Mapping -from dataclasses import dataclass +from dataclasses import dataclass, field import json import re from types import MappingProxyType from typing import Any _TOKEN = re.compile(r"^[!#$%&'*+.^_`|~0-9A-Za-z-]+$") -_REASONS = {200: "OK", 201: "Created", 204: "No Content", 400: "Bad Request", 404: "Not Found", 405: "Method Not Allowed", 413: "Payload Too Large", 500: "Internal Server Error", 503: "Service Unavailable"} +_REASONS = { + 200: "OK", + 201: "Created", + 204: "No Content", + 400: "Bad Request", + 404: "Not Found", + 405: "Method Not Allowed", + 413: "Payload Too Large", + 414: "URI Too Long", + 500: "Internal Server Error", + 503: "Service Unavailable", +} class Headers(Mapping[str, str]): @@ -60,16 +71,45 @@ class Request: headers: Headers body: bytes = b"" version: str = "HTTP/1.1" + raw_target: str | None = None + query_string: str = "" + path_params: Mapping[str, str] = field(default_factory=dict) + route_pattern: str | None = None def __post_init__(self) -> None: if not _TOKEN.fullmatch(self.method): raise ValueError("invalid HTTP method") + if not isinstance(self.query_string, str): + raise TypeError("query_string must be a string") + if self.raw_target is None and "?" not in self.path and self.query_string: + raw_target = self.path + "?" + self.query_string + else: + raw_target = self.path if self.raw_target is None else self.raw_target + if not isinstance(raw_target, str) or not raw_target.startswith("/"): + raise ValueError("request path/target must start with '/'") + target_path, separator, target_query = raw_target.partition("?") + if self.raw_target is None: + if self.query_string and self.query_string != (target_query if separator else ""): + raise ValueError("request target fields are inconsistent") + object.__setattr__(self, "path", target_path) + object.__setattr__(self, "query_string", target_query if separator else "") + object.__setattr__(self, "raw_target", raw_target) + elif self.path != target_path or self.query_string != (target_query if separator else ""): + raise ValueError("request target fields are inconsistent") if not self.path.startswith("/"): raise ValueError("request path must start with '/'") if not isinstance(self.headers, Headers): object.__setattr__(self, "headers", Headers(self.headers)) if not isinstance(self.body, bytes): raise TypeError("request body must be bytes") + if not isinstance(self.path_params, Mapping): + raise TypeError("path_params must be a mapping") + params = dict(self.path_params) + if any(not isinstance(name, str) or not isinstance(value, str) for name, value in params.items()): + raise TypeError("path_params must map strings to strings") + object.__setattr__(self, "path_params", MappingProxyType(params)) + if self.route_pattern is not None and not isinstance(self.route_pattern, str): + raise TypeError("route_pattern must be a string or None") @dataclass(frozen=True) diff --git a/smallserver/routing.py b/smallserver/routing.py new file mode 100644 index 0000000..a6c9eff --- /dev/null +++ b/smallserver/routing.py @@ -0,0 +1,265 @@ +"""Deterministic static and timeout-bounded regular-expression routing.""" + +from __future__ import annotations + +from collections.abc import Awaitable, Callable, Iterable, Mapping +from dataclasses import dataclass +import importlib +import math +import time +from types import MappingProxyType +from typing import Any + +from .http import Request, Response + +Handler = Callable[[Request], Awaitable[Response]] +SUPPORTED_METHODS = frozenset({"GET", "POST", "PUT", "PATCH", "DELETE"}) + + +class RegexRoutesUnavailable(RuntimeError): + """Raised when regex routes are used without their optional dependency.""" + + +class RouteMatchTimeout(RuntimeError): + """Raised when a bounded regex route match exceeds its deadline.""" + + def __init__(self, route_id: str) -> None: + self.route_id = route_id + super().__init__("regular-expression route matching timed out ({})".format(route_id)) + + +@dataclass(frozen=True) +class RegexRouteConfig: + """Finite limits applied to regex registration and hostile request paths.""" + + max_path_bytes: int = 8 * 1024 + max_pattern_length: int = 1024 + max_routes: int = 100 + match_timeout: float = 0.01 + total_match_timeout: float = 0.05 + max_named_captures: int = 20 + + def __post_init__(self) -> None: + for name in ("max_path_bytes", "max_pattern_length", "max_routes", "max_named_captures"): + value = getattr(self, name) + if type(value) is not int or value <= 0: + raise ValueError("{} must be a positive integer".format(name)) + for name in ("match_timeout", "total_match_timeout"): + value = getattr(self, name) + if type(value) not in (int, float) or not math.isfinite(value) or value <= 0: + raise ValueError("{} must be a finite positive number".format(name)) + + +@dataclass(frozen=True) +class RouteMatch: + handler: Handler | None + path_params: Mapping[str, str] + route_pattern: str | None + allowed_methods: tuple[str, ...] = () + + +@dataclass(frozen=True) +class _RegexRoute: + route_id: str + pattern: str + compiled: Any + handlers: Mapping[str, Handler] + literal_prefix: str + + +class Router: + """Resolve static and ordered regex routes without slowing static lookup.""" + + def __init__(self, regex_config: RegexRouteConfig | None = None) -> None: + self._static: dict[tuple[str, str], Handler] = {} + self._regex: list[_RegexRoute] = [] + self._regex_by_pattern: dict[str, int] = {} + self.regex_config = regex_config or RegexRouteConfig() + + @staticmethod + def normalize_methods(methods: Iterable[str]) -> tuple[str, ...]: + try: + normalized = tuple(dict.fromkeys(method.upper() for method in methods)) + except AttributeError as exc: + raise ValueError("routes must use one or more supported HTTP methods") from exc + if not normalized or any(method not in SUPPORTED_METHODS for method in normalized): + raise ValueError("routes must use one or more supported HTTP methods") + return normalized + + def add_static(self, path: str, methods: tuple[str, ...], handler: Handler) -> None: + keys = [(method, path) for method in methods] + for key in keys: + if key in self._static: + raise ValueError("route already registered: {} {}".format(key[0], path)) + for key in keys: + self._static[key] = handler + + def add_regex(self, pattern: str, methods: tuple[str, ...], handler: Handler) -> None: + if not isinstance(pattern, str): + raise TypeError("regex route pattern must be a string") + existing_index = self._regex_by_pattern.get(pattern) + if existing_index is not None: + existing = self._regex[existing_index] + duplicate = next((method for method in methods if method in existing.handlers), None) + if duplicate is not None: + raise ValueError("regex route already registered: {}".format(duplicate)) + handlers = dict(existing.handlers) + handlers.update((method, handler) for method in methods) + self._regex[existing_index] = _RegexRoute( + existing.route_id, + existing.pattern, + existing.compiled, + MappingProxyType(handlers), + existing.literal_prefix, + ) + return + + if len(self._regex) >= self.regex_config.max_routes: + raise ValueError("maximum registered regex routes exceeded") + compiled = self._compile(pattern) + route = _RegexRoute( + "regex-route-{}".format(len(self._regex) + 1), + pattern, + compiled, + MappingProxyType({method: handler for method in methods}), + _literal_prefix(pattern), + ) + self._regex_by_pattern[pattern] = len(self._regex) + self._regex.append(route) + + def resolve(self, method: str, path: str) -> RouteMatch: + method = method.upper() + static = self._static.get((method, path)) + if static is not None: + return RouteMatch(static, MappingProxyType({}), path) + + if not self._regex: + allowed = tuple(sorted(method for method, registered_path in self._static if registered_path == path)) + return RouteMatch(None, MappingProxyType({}), None, allowed) + self._validate_path(path) + deadline = time.monotonic() + self.regex_config.total_match_timeout + cached: dict[int, Any] = {} + for index, route in enumerate(self._regex): + if method not in route.handlers: + continue + match = self._match(route, path, deadline) + cached[index] = match + if match is not None: + return RouteMatch( + route.handlers[method], + MappingProxyType(_captures(match)), + route.pattern, + ) + + allowed = {registered_method for registered_method, registered_path in self._static if registered_path == path} + for index, route in enumerate(self._regex): + match = cached.get(index) + if index not in cached: + match = self._match(route, path, deadline) + if match is not None: + allowed.update(route.handlers) + return RouteMatch(None, MappingProxyType({}), None, tuple(sorted(allowed))) + + def _compile(self, pattern: str) -> Any: + if not isinstance(pattern, str): + raise TypeError("regex route pattern must be a string") + if not pattern.startswith("/"): + raise ValueError("regex route pattern must start with a literal '/'") + if len(pattern) > self.regex_config.max_pattern_length: + raise ValueError("regex route pattern is too long") + names = _named_group_names(pattern) + if len(names) != len(set(names)): + raise ValueError("regex route pattern contains duplicate named groups") + if len(names) > self.regex_config.max_named_captures: + raise ValueError("regex route pattern has too many named captures") + try: + engine = importlib.import_module("regex") + except ImportError as exc: + raise RegexRoutesUnavailable( + "regular-expression routes require 'smallserver[regex-routes]'" + ) from exc + try: + compiled = engine.compile(pattern) + except Exception: + raise ValueError("invalid regex route pattern") from None + try: + empty_match = compiled.fullmatch("", timeout=self.regex_config.match_timeout) + except TimeoutError as exc: + raise ValueError("regex route pattern validation timed out") from exc + if empty_match is not None: + raise ValueError("regex route pattern must not match an empty path") + return compiled + + def _validate_path(self, path: str) -> None: + try: + size = len(path.encode("ascii")) + except UnicodeEncodeError as exc: + raise ValueError("request path must contain ASCII characters only") from exc + if size > self.regex_config.max_path_bytes: + raise ValueError("request path is too large for routing") + + def _match(self, route: _RegexRoute, path: str, deadline: float) -> Any: + remaining = deadline - time.monotonic() + if remaining <= 0: + raise RouteMatchTimeout(route.route_id) + if len(route.literal_prefix) > 1 and not path.startswith(route.literal_prefix): + return None + timeout = min(float(self.regex_config.match_timeout), remaining) + try: + return route.compiled.fullmatch(path, timeout=timeout) + except TimeoutError as exc: + raise RouteMatchTimeout(route.route_id) from exc + + +def _captures(match: Any) -> dict[str, str]: + return {name: value for name, value in match.groupdict().items() if value is not None} + + +def _literal_prefix(pattern: str) -> str: + """Return only the leading literals that are safe to use as a rejection index.""" + special = frozenset(".[](){}*+?|^$\\") + end = 0 + while end < len(pattern) and pattern[end] not in special: + end += 1 + return pattern[:end] + + +def _named_group_names(pattern: str) -> list[str]: + """Find named-group declarations while ignoring escapes and character classes.""" + names: list[str] = [] + escaped = False + in_class = False + index = 0 + while index < len(pattern): + character = pattern[index] + if escaped: + escaped = False + index += 1 + continue + if character == "\\": + escaped = True + index += 1 + continue + if character == "[": + in_class = True + index += 1 + continue + if character == "]" and in_class: + in_class = False + index += 1 + continue + marker_length = 0 + if not in_class and pattern.startswith("(?P<", index): + marker_length = 4 + elif not in_class and pattern.startswith("(?<", index): + next_character = pattern[index + 3 : index + 4] + if next_character not in ("=", "!"): + marker_length = 3 + if marker_length: + end = pattern.find(">", index + marker_length) + if end >= 0: + names.append(pattern[index + marker_length : end]) + index = end + 1 + continue + index += 1 + return names diff --git a/smallserver/server.py b/smallserver/server.py index 09701fe..a3510d6 100644 --- a/smallserver/server.py +++ b/smallserver/server.py @@ -21,10 +21,17 @@ def __init__(self, status: int, detail: str) -> None: class HTTPRequestParser: """Incrementally parse one bounded HTTP/1.1 request with Content-Length.""" - def __init__(self, max_header_bytes: int, max_header_count: int, max_body_bytes: int) -> None: + def __init__( + self, + max_header_bytes: int, + max_header_count: int, + max_body_bytes: int, + max_request_target_bytes: int = 8 * 1024, + ) -> None: self._max_header_bytes = max_header_bytes self._max_header_count = max_header_count self._max_body_bytes = max_body_bytes + self._max_request_target_bytes = max_request_target_bytes self._buffer = bytearray() self._request_head: tuple[str, str, Headers, int] | None = None @@ -42,13 +49,22 @@ def feed(self, data: bytes) -> Request | None: self._request_head = self._parse_head(bytes(self._buffer[:marker])) del self._buffer[:header_length] - method, path, headers, content_length = self._request_head + method, raw_target, headers, content_length = self._request_head if len(self._buffer) > content_length: raise HTTPParseError(400, "pipelined requests are not supported") if len(self._buffer) < content_length: return None try: - return Request(method, path, headers, bytes(self._buffer), "HTTP/1.1") + path, separator, query_string = raw_target.partition("?") + return Request( + method, + path, + headers, + bytes(self._buffer), + "HTTP/1.1", + raw_target=raw_target, + query_string=query_string if separator else "", + ) except ValueError as exc: raise HTTPParseError(400, str(exc)) from exc @@ -59,10 +75,12 @@ def _parse_head(self, raw: bytes) -> tuple[str, str, Headers, int]: raise HTTPParseError(400, "request headers are not valid bytes") from exc if not lines or len(lines[0].split(" ")) != 3: raise HTTPParseError(400, "malformed request line") - method, path, version = lines[0].split(" ") - if version != "HTTP/1.1" or not path.startswith("/"): + method, raw_target, version = lines[0].split(" ") + if version != "HTTP/1.1" or not raw_target.startswith("/"): raise HTTPParseError(400, "only origin-form HTTP/1.1 requests are supported") - if "#" in path or any(not 0x21 <= ord(character) <= 0x7E for character in path): + if len(raw_target.encode("iso-8859-1")) > self._max_request_target_bytes: + raise HTTPParseError(414, "request target is too large") + if "#" in raw_target or any(not 0x21 <= ord(character) <= 0x7E for character in raw_target): raise HTTPParseError(400, "request target is not valid origin-form") if len(lines) - 1 > self._max_header_count: raise HTTPParseError(413, "too many request headers") @@ -94,7 +112,7 @@ def _parse_head(self, raw: bytes) -> tuple[str, str, Headers, int]: raise HTTPParseError(413, "request body is too large") if not headers.get("host"): raise HTTPParseError(400, "HTTP/1.1 requests require a Host header") - return method, path, headers, length + return method, raw_target, headers, length @dataclass(frozen=True) @@ -105,6 +123,7 @@ class ServerConfig: max_header_bytes: int = 16 * 1024 max_header_count: int = 100 max_body_bytes: int = 1024 * 1024 + max_request_target_bytes: int = 8 * 1024 receive_chunk_bytes: int = 8 * 1024 listener_priority: int = 1 connection_priority: int = 2 diff --git a/tests/test_http.py b/tests/test_http.py index 841d1ee..95679a6 100644 --- a/tests/test_http.py +++ b/tests/test_http.py @@ -1,4 +1,5 @@ import unittest +from types import MappingProxyType from smallserver import Headers, Request, Response @@ -31,3 +32,28 @@ def test_header_values_match_the_wire_encoding(self) -> None: with self.subTest(invalid=invalid): with self.assertRaisesRegex(ValueError, "header value"): Headers({"X-Test": invalid}) + + def test_request_splits_raw_target_and_keeps_captures_immutable(self) -> None: + request = Request( + "GET", + "/items?tag=a%2Fb&empty=", + Headers(), + path_params={"item_id": "a%2Fb"}, + route_pattern=r"/items/(?P[^/]+)", + ) + self.assertEqual(request.raw_target, "/items?tag=a%2Fb&empty=") + self.assertEqual(request.path, "/items") + self.assertEqual(request.query_string, "tag=a%2Fb&empty=") + self.assertIsInstance(request.path_params, MappingProxyType) + with self.assertRaises(TypeError): + request.path_params["item_id"] = "changed" # type: ignore[index] + + def test_request_rejects_inconsistent_explicit_target_fields(self) -> None: + with self.assertRaisesRegex(ValueError, "inconsistent"): + Request("GET", "/one", Headers(), raw_target="/two") + + def test_request_can_be_built_from_separate_path_and_query(self) -> None: + request = Request("GET", "/items", Headers(), query_string="page=2") + self.assertEqual(request.raw_target, "/items?page=2") + self.assertEqual(request.path, "/items") + self.assertEqual(request.query_string, "page=2") diff --git a/tests/test_regex_routing.py b/tests/test_regex_routing.py new file mode 100644 index 0000000..fba24b4 --- /dev/null +++ b/tests/test_regex_routing.py @@ -0,0 +1,231 @@ +import importlib.util +import time +import unittest +from unittest.mock import patch + +from smallserver import ( + Headers, + RegexRouteConfig, + RegexRoutesUnavailable, + Request, + Response, + RouteMatchTimeout, + SmallServer, +) + + +HAS_REGEX = importlib.util.find_spec("regex") is not None + + +class OptionalRegexDependencyTests(unittest.TestCase): + def test_static_routes_do_not_import_optional_engine(self) -> None: + app = SmallServer() + + async def handler(request): + return Response() + + with patch("smallserver.routing.importlib.import_module", side_effect=AssertionError("imported")): + app.get("/health")(handler) + + def test_registration_explains_missing_extra(self) -> None: + app = SmallServer() + + async def handler(request): + return Response() + + with patch("smallserver.routing.importlib.import_module", side_effect=ImportError): + with self.assertRaisesRegex(RegexRoutesUnavailable, r"smallserver\[regex-routes\]"): + app.get_regex(r"/users/[0-9]+")(handler) + + +@unittest.skipUnless(HAS_REGEX, "regex-routes extra is not installed") +class RegexRoutingTests(unittest.IsolatedAsyncioTestCase): + async def test_named_captures_are_immutable_and_optional_groups_are_omitted(self) -> None: + app = SmallServer() + seen = [] + + @app.get_regex(r"/users/(?P[0-9]+)(?:/(?P
[a-z]+))?") + async def user(request): + seen.append(request) + return Response.json(dict(request.path_params)) + + response = await app.dispatch(Request("GET", "/users/42?debug=1", Headers())) + self.assertEqual(response.body, b'{"user_id":"42"}') + self.assertEqual(seen[0].raw_target, "/users/42?debug=1") + self.assertEqual(seen[0].query_string, "debug=1") + self.assertEqual(seen[0].route_pattern, r"/users/(?P[0-9]+)(?:/(?P
[a-z]+))?") + with self.assertRaises(TypeError): + seen[0].path_params["user_id"] = "1" # type: ignore[index] + await app.dispatch(Request("GET", "/users/43/profile", Headers())) + self.assertEqual(dict(seen[0].path_params), {"user_id": "42"}) + self.assertEqual(dict(seen[1].path_params), {"user_id": "43", "section": "profile"}) + + async def test_captures_preserve_percent_encoded_octets(self) -> None: + app = SmallServer() + + @app.get_regex(r"/files/(?P[^/]+)") + async def file(request): + return Response.text(request.path_params["name"]) + + response = await app.dispatch(Request("GET", "/files/a%2Fb", Headers())) + self.assertEqual(response.body, b"a%2Fb") + + async def test_static_precedence_and_regex_registration_order(self) -> None: + app = SmallServer() + + @app.get_regex(r"/items/(?P.+)") + async def broad(request): + return Response.text("broad") + + @app.get_regex(r"/items/(?P[0-9]+)") + async def narrow(request): + return Response.text("narrow") + + @app.get("/items/7") + async def exact(request): + return Response.text("static") + + self.assertEqual((await app.dispatch(Request("GET", "/items/7", Headers()))).body, b"static") + self.assertEqual((await app.dispatch(Request("GET", "/items/8", Headers()))).body, b"broad") + + async def test_method_first_matching_and_sorted_allow(self) -> None: + app = SmallServer() + + @app.post("/records/1") + async def static_post(request): + return Response.text("post") + + @app.delete_regex(r"/records/(?P[0-9]+)") + async def regex_delete(request): + return Response.text("delete") + + @app.get_regex(r"/records/(?P.+)") + async def regex_get(request): + return Response.text("get") + + self.assertEqual((await app.dispatch(Request("GET", "/records/1", Headers()))).body, b"get") + response = await app.dispatch(Request("PATCH", "/records/1", Headers())) + self.assertEqual(response.status, 405) + self.assertEqual(response.headers["allow"], "DELETE, GET, POST") + + async def test_all_regex_method_decorators_dispatch(self) -> None: + app = SmallServer() + for method in ("get", "post", "put", "patch", "delete"): + decorator = getattr(app, method + "_regex") + + @decorator("/" + method + r"/(?P[0-9]+)") + async def handler(request, expected=method): + return Response.text(expected + request.path_params["value"]) + + for method in ("get", "post", "put", "patch", "delete"): + response = await app.dispatch(Request(method.upper(), "/" + method + "/2", Headers())) + self.assertEqual(response.body, (method + "2").encode()) + + async def test_same_pattern_disjoint_methods_merge_and_duplicates_are_atomic(self) -> None: + app = SmallServer() + + async def first(request): + return Response.text("first") + + async def second(request): + return Response.text("second") + + app.get_regex(r"/merged/(?P[0-9]+)")(first) + app.post_regex(r"/merged/(?P[0-9]+)")(second) + with self.assertRaisesRegex(ValueError, "already registered"): + app.route_regex(r"/merged/(?P[0-9]+)", ("PATCH", "GET"))(second) + self.assertEqual((await app.dispatch(Request("PATCH", "/merged/1", Headers()))).status, 405) + self.assertEqual((await app.dispatch(Request("POST", "/merged/1", Headers()))).body, b"second") + + def test_registration_limits_and_invalid_patterns(self) -> None: + async def handler(request): + return Response() + + cases = ( + (r"items/[0-9]+", "literal '/'"), + (r"/[", "invalid"), + (r"/(?Px)(?Py)", "duplicate"), + ) + for pattern, message in cases: + with self.subTest(pattern=pattern): + with self.assertRaisesRegex((ValueError, TypeError), message): + SmallServer().get_regex(pattern)(handler) + + with self.assertRaisesRegex(ValueError, "too long"): + SmallServer(RegexRouteConfig(max_pattern_length=4)).get_regex("/long")(handler) + with self.assertRaisesRegex(ValueError, "too many"): + SmallServer(RegexRouteConfig(max_named_captures=1)).get_regex( + r"/(?Px)(?Py)" + )(handler) + limited = SmallServer(RegexRouteConfig(max_routes=1)) + limited.get_regex(r"/one")(handler) + with self.assertRaisesRegex(ValueError, "maximum"): + limited.get_regex(r"/two")(handler) + + async def test_catastrophic_backtracking_is_bounded_and_path_is_not_disclosed(self) -> None: + app = SmallServer(RegexRouteConfig(match_timeout=0.001, total_match_timeout=0.005)) + + @app.get_regex(r"/(a+)+$") + async def handler(request): + return Response() + + hostile_path = "/" + "a" * 5000 + "!" + started = time.monotonic() + with self.assertRaises(RouteMatchTimeout) as raised: + await app.dispatch(Request("GET", hostile_path, Headers())) + elapsed = time.monotonic() - started + self.assertLess(elapsed, 0.25) + self.assertNotIn(hostile_path, str(raised.exception)) + self.assertEqual(raised.exception.route_id, "regex-route-1") + + async def test_total_budget_bounds_many_individually_fast_misses(self) -> None: + class SlowPattern: + def fullmatch(self, value, timeout): + if not value: + return None + time.sleep(min(timeout / 2, 0.001)) + return None + + class Engine: + @staticmethod + def compile(pattern): + return SlowPattern() + + app = SmallServer( + RegexRouteConfig(max_routes=20, match_timeout=0.01, total_match_timeout=0.003) + ) + + async def handler(request): + return Response() + + with patch("smallserver.routing.importlib.import_module", return_value=Engine()): + for index in range(10): + app.get_regex("/(?:route-{}).*".format(index))(handler) + started = time.monotonic() + with self.assertRaises(RouteMatchTimeout): + await app.dispatch(Request("GET", "/route", Headers())) + self.assertLess(time.monotonic() - started, 0.05) + + async def test_manual_dispatch_path_limit_is_enforced_before_matching(self) -> None: + app = SmallServer(RegexRouteConfig(max_path_bytes=8)) + + @app.get_regex(r"/.*") + async def handler(request): + return Response() + + with self.assertRaisesRegex(ValueError, "too large"): + await app.dispatch(Request("GET", "/12345678", Headers())) + + +class RegexRouteConfigTests(unittest.TestCase): + def test_rejects_nonfinite_or_nonpositive_limits(self) -> None: + for kwargs in ( + {"max_routes": 0}, + {"max_routes": True}, + {"match_timeout": 0}, + {"match_timeout": float("inf")}, + {"total_match_timeout": float("nan")}, + ): + with self.subTest(kwargs=kwargs): + with self.assertRaises(ValueError): + RegexRouteConfig(**kwargs) diff --git a/tests/test_routing.py b/tests/test_routing.py index 19da695..d152500 100644 --- a/tests/test_routing.py +++ b/tests/test_routing.py @@ -30,6 +30,20 @@ async def items(request): self.assertEqual(response.status, 405) self.assertEqual(response.headers["allow"], "GET") + async def test_query_string_does_not_participate_in_static_matching(self) -> None: + app = SmallServer() + seen = [] + + @app.get("/items") + async def items(request): + seen.append(request) + return Response() + + response = await app.dispatch(Request("GET", "/items?tag=a%2Fb", Headers())) + self.assertEqual(response.status, 200) + self.assertEqual(seen[0].path, "/items") + self.assertEqual(seen[0].query_string, "tag=a%2Fb") + async def test_http_error_becomes_response(self) -> None: app = SmallServer() @@ -61,6 +75,8 @@ async def one(request): app.get("/one")(one) with self.assertRaisesRegex(ValueError, "supported HTTP methods"): app.route("/trace", ("TRACE",)) + with self.assertRaisesRegex(ValueError, "query string"): + app.get("/one?debug=1") async def test_failed_multi_method_registration_is_atomic(self) -> None: app = SmallServer() diff --git a/tests/test_server.py b/tests/test_server.py index bf6e193..27b1766 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -35,11 +35,29 @@ 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) with self.assertRaisesRegex(ValueError, "max_connections"): ServerConfig(max_connections=True) + with self.assertRaisesRegex(ValueError, "max_request_target_bytes"): + ServerConfig(max_request_target_bytes=0) def test_serve_closes_bound_socket_when_runtime_fork_fails(self) -> None: class Listener: diff --git a/tests/test_server_runtime.py b/tests/test_server_runtime.py index 2bfa26b..9d80992 100644 --- a/tests/test_server_runtime.py +++ b/tests/test_server_runtime.py @@ -1,3 +1,4 @@ +import importlib.util import socket import threading import unittest @@ -8,6 +9,9 @@ from smallserver import AdapterRegistry, Response, SmallServer +HAS_REGEX = importlib.util.find_spec("regex") is not None + + class SmallOSServerIntegrationTests(unittest.TestCase): def _request(self, port: int, path: str) -> bytes: with socket.create_connection(("127.0.0.1", port), timeout=3) as connection: @@ -127,3 +131,36 @@ def fast_client() -> None: self.assertEqual(errors, []) self.assertIn(b"\r\n\r\nfast done", responses["fast"]) self.assertIn(b"\r\n\r\nslow done", responses["slow"]) + + @unittest.skipUnless(HAS_REGEX, "regex-routes extra is not installed") + def test_loopback_regex_route_uses_path_without_query(self) -> None: + runtime = SmallOS().setKernel(Unix()) + app = SmallServer() + + @app.get_regex(r"/files/(?P[^/]+)") + async def file(request): + return Response.text(request.path_params["name"] + "?" + request.query_string) + + try: + server = app.serve(runtime, host="127.0.0.1", port=0) + except PermissionError: + self.skipTest("the current sandbox does not permit loopback TCP binds") + + received = [] + errors = [] + + def client() -> None: + try: + received.append(self._request(server.port, "/files/a%2Fb?download=1")) + except BaseException as exc: + errors.append(exc) + finally: + server.close() + + worker = threading.Thread(target=client, daemon=True) + worker.start() + runtime.start() + worker.join(timeout=2) + self.assertFalse(worker.is_alive()) + self.assertEqual(errors, []) + self.assertIn(b"\r\n\r\na%2Fb?download=1", b"".join(received)) From 42d32153bdee420d78c6f57d9be00f4032f47a1e Mon Sep 17 00:00:00 2001 From: MikiEEE <35975462+MikiEEE@users.noreply.github.com> Date: Fri, 21 Aug 2026 23:37:26 -0500 Subject: [PATCH 05/53] fix: harden regex routing after review --- README.md | 31 +++++++++- benchmarks/route_benchmark.py | 58 ++++++++++++++--- pyproject.toml | 2 +- smallserver/__init__.py | 3 +- smallserver/app.py | 30 ++++++++- smallserver/routing.py | 110 +++++++++++++++++++++------------ smallserver/server.py | 2 +- tests/installed_regex_smoke.py | 21 +++++++ tests/test_regex_routing.py | 89 ++++++++++++++++++++++++-- tests/test_server.py | 11 ++++ tests/test_server_runtime.py | 85 ++++++++++++++++++++++++- tests/typing/regex_routes.py | 20 ++++++ 12 files changed, 399 insertions(+), 63 deletions(-) create mode 100644 tests/installed_regex_smoke.py create mode 100644 tests/typing/regex_routes.py diff --git a/README.md b/README.md index 0992a4e..991183c 100644 --- a/README.md +++ b/README.md @@ -136,13 +136,30 @@ async def article(request: Request) -> Response: return Response.json({"slug": request.path_params["slug"]}) ``` -Patterns must begin with a literal `/` and do not need `^` or `$`. They are -trusted application configuration, but paths are hostile input: SmallServer +Patterns must begin with a literal `/` and do not need `^` or `$`. SmallServer +wraps the complete expression in a slash guard, so every top-level alternative +is constrained to an origin-form path. They are trusted application +configuration, but paths are hostile input: SmallServer bounds pattern length, route count, named captures, path bytes, each match, and the total matching time. Prefer unambiguous repetition and narrow character classes even with these deadlines. A timeout raises `RouteMatchTimeout` with an opaque route ID and becomes a sanitized 500 response on the network path. +Applications can observe that failure without receiving the hostile target: + +```python +from smallserver import RouteMatchTimeout + +def observe_route_error(error: RouteMatchTimeout) -> None: + logger.error("route matching failed: %s", error.route_id) + +app = SmallServer(route_error_observer=observe_route_error) +``` + +The synchronous observer runs once on the connection task and should return +quickly; observer failures are isolated from the response path. A path above +the configured regex-routing byte limit returns 414 before matching begins. + Requests retain the exact ASCII origin-form target in `request.raw_target`. Routing uses `request.path`, which excludes the query string; `request.query_string` contains the raw text after `?`. Neither paths nor named @@ -150,6 +167,16 @@ captures are percent-decoded, so `/files/a%2Fb` remains distinct from `/files/a/b`. `request.route_pattern` identifies the selected static path or regex pattern. +Run `python benchmarks/route_benchmark.py` for a same-process comparison of +the pre-router dictionary dispatch model and current router dispatch. Its JSON +also records configured versus observed hostile-pattern timeout when the extra +is installed; rates are machine-specific and should be compared on the same +host. + +Release validation can run `python tests/installed_regex_smoke.py` from an +environment where the built `smallserver[regex-routes]` wheel is installed. +The project requires Python 3.10 or newer. + ## Dispatch a request The listener creates requests and calls `dispatch()`. The same boundary is diff --git a/benchmarks/route_benchmark.py b/benchmarks/route_benchmark.py index 29bb1b2..a3b247e 100644 --- a/benchmarks/route_benchmark.py +++ b/benchmarks/route_benchmark.py @@ -1,9 +1,12 @@ -"""Small routing microbenchmark; run with ``python benchmarks/route_benchmark.py``.""" +"""Repeatable routing comparison; run with ``python benchmarks/route_benchmark.py``.""" from __future__ import annotations +import argparse import asyncio import importlib.util +import inspect +import json from pathlib import Path import sys import time @@ -13,7 +16,19 @@ from smallserver import Headers, RegexRouteConfig, Request, Response, RouteMatchTimeout, SmallServer -async def benchmark() -> None: +async def _legacy_dispatch(routes, request): + """Model the pre-router static dictionary dispatch for same-run comparison.""" + handler = routes[(request.method.upper(), request.path)] + result = handler(request) + if not inspect.isawaitable(result): + raise TypeError("benchmark handler must be awaitable") + response = await result + if not isinstance(response, Response): + raise TypeError("benchmark handler must return Response") + return response + + +async def benchmark(iterations: int) -> dict[str, float | int | str]: app = SmallServer() @app.get("/health") @@ -21,16 +36,30 @@ async def health(request): return Response() request = Request("GET", "/health", Headers()) - iterations = 25_000 + legacy_routes = {("GET", "/health"): health} + + started = time.perf_counter() + for _ in range(iterations): + await _legacy_dispatch(legacy_routes, request) + legacy_elapsed = time.perf_counter() - started + started = time.perf_counter() for _ in range(iterations): await app.dispatch(request) - static_elapsed = time.perf_counter() - started - print("static: {:.0f} dispatches/second".format(iterations / static_elapsed)) + router_elapsed = time.perf_counter() - started + + legacy_rate = iterations / legacy_elapsed + router_rate = iterations / router_elapsed + result: dict[str, float | int | str] = { + "iterations": iterations, + "legacy_static_dispatches_per_second": round(legacy_rate, 2), + "router_static_dispatches_per_second": round(router_rate, 2), + "router_to_legacy_ratio": round(router_rate / legacy_rate, 4), + } if importlib.util.find_spec("regex") is None: - print("regex: skipped (install smallserver[regex-routes])") - return + result["regex"] = "skipped; install smallserver[regex-routes]" + return result bounded = SmallServer(RegexRouteConfig(match_timeout=0.002, total_match_timeout=0.005)) @@ -43,8 +72,19 @@ async def hostile(request): await bounded.dispatch(Request("GET", "/" + "a" * 5000 + "!", Headers())) except RouteMatchTimeout: pass - print("worst-case regex timeout: {:.4f}s".format(time.perf_counter() - started)) + result["configured_regex_match_timeout_seconds"] = 0.002 + result["observed_worst_case_regex_seconds"] = round(time.perf_counter() - started, 6) + return result + + +def main() -> None: + parser = argparse.ArgumentParser() + parser.add_argument("--iterations", type=int, default=25_000) + arguments = parser.parse_args() + if arguments.iterations <= 0: + parser.error("--iterations must be positive") + print(json.dumps(asyncio.run(benchmark(arguments.iterations)), indent=2, sort_keys=True)) if __name__ == "__main__": - asyncio.run(benchmark()) + main() diff --git a/pyproject.toml b/pyproject.toml index d78894a..ced2d56 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -14,7 +14,7 @@ dependencies = [] [project.optional-dependencies] dev = ["build>=1.2"] -regex-routes = ["regex>=2023.10.3"] +regex-routes = ["regex>=2023.10.3,<2027"] [tool.setuptools.packages.find] include = ["smallserver*"] diff --git a/smallserver/__init__.py b/smallserver/__init__.py index e470f75..476d64e 100644 --- a/smallserver/__init__.py +++ b/smallserver/__init__.py @@ -4,7 +4,7 @@ from .adapters import AdapterRegistry, AdapterShutdownError, http_error_from_adapter from .errors import HTTPError from .http import Headers, Request, Response -from .routing import RegexRouteConfig, RegexRoutesUnavailable, RouteMatchTimeout +from .routing import RegexRouteConfig, RegexRoutesUnavailable, RouteMatchTimeout, RoutePathTooLarge from .server import ServerConfig, ServerHandle __all__ = [ @@ -17,6 +17,7 @@ "Request", "Response", "RouteMatchTimeout", + "RoutePathTooLarge", "ServerConfig", "ServerHandle", "SmallServer", diff --git a/smallserver/app.py b/smallserver/app.py index aabb735..a009025 100644 --- a/smallserver/app.py +++ b/smallserver/app.py @@ -10,17 +10,26 @@ from .errors import HTTPError from .http import Request, Response -from .routing import RegexRouteConfig, Router +from .routing import RegexRouteConfig, RouteMatchTimeout, RoutePathTooLarge, Router from .server import HTTPParseError, HTTPRequestParser, ServerConfig, ServerHandle Handler = Callable[[Request], Awaitable[Response]] +RouteErrorObserver = Callable[[RouteMatchTimeout], None] class SmallServer: """Register static HTTP routes and dispatch requests to async handlers.""" - def __init__(self, regex_config: RegexRouteConfig | None = None) -> None: + def __init__( + self, + regex_config: RegexRouteConfig | None = None, + *, + route_error_observer: RouteErrorObserver | None = None, + ) -> None: + 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) + self._route_error_observer = route_error_observer def route(self, path: str, methods: Iterable[str]) -> Callable[[Handler], Handler]: if not isinstance(path, str) or not path.startswith("/"): @@ -132,7 +141,10 @@ def serve( async def dispatch(self, request: Request) -> Response: """Run a registered handler or return a deterministic HTTP response.""" - match = self._router.resolve(request.method, request.path) + try: + match = self._router.resolve(request.method, request.path) + except RoutePathTooLarge: + return Response.text("request target is too large", status=414) if match.handler is None: if match.allowed_methods: return Response.text("method not allowed", status=405, headers={"Allow": ", ".join(match.allowed_methods)}) @@ -214,6 +226,9 @@ async def _connection_loop(self, task: Any, handle: ServerHandle, client: socket continue try: response = await self.dispatch(request) + except RouteMatchTimeout as exc: + self._observe_route_error(exc) + response = Response.text("internal server error", status=500) except Exception: response = Response.text("internal server error", status=500) await self._send_response(task, client, response) @@ -239,3 +254,12 @@ async def _send_response(self, task: Any, client: socket.socket, response: Respo if sent <= 0: return offset += sent + + def _observe_route_error(self, error: RouteMatchTimeout) -> None: + observer = self._route_error_observer + if observer is None: + return + try: + observer(error) + except Exception: + pass diff --git a/smallserver/routing.py b/smallserver/routing.py index a6c9eff..b53588d 100644 --- a/smallserver/routing.py +++ b/smallserver/routing.py @@ -6,6 +6,7 @@ from dataclasses import dataclass import importlib import math +import re import time from types import MappingProxyType from typing import Any @@ -28,6 +29,10 @@ def __init__(self, route_id: str) -> None: super().__init__("regular-expression route matching timed out ({})".format(route_id)) +class RoutePathTooLarge(RuntimeError): + """Raised before matching when a request path exceeds its routing bound.""" + + @dataclass(frozen=True) class RegexRouteConfig: """Finite limits applied to regex registration and hostile request paths.""" @@ -64,7 +69,6 @@ class _RegexRoute: pattern: str compiled: Any handlers: Mapping[str, Handler] - literal_prefix: str class Router: @@ -110,7 +114,6 @@ def add_regex(self, pattern: str, methods: tuple[str, ...], handler: Handler) -> existing.pattern, existing.compiled, MappingProxyType(handlers), - existing.literal_prefix, ) return @@ -122,7 +125,6 @@ def add_regex(self, pattern: str, methods: tuple[str, ...], handler: Handler) -> pattern, compiled, MappingProxyType({method: handler for method in methods}), - _literal_prefix(pattern), ) self._regex_by_pattern[pattern] = len(self._regex) self._regex.append(route) @@ -167,11 +169,6 @@ def _compile(self, pattern: str) -> Any: raise ValueError("regex route pattern must start with a literal '/'") if len(pattern) > self.regex_config.max_pattern_length: raise ValueError("regex route pattern is too long") - names = _named_group_names(pattern) - if len(names) != len(set(names)): - raise ValueError("regex route pattern contains duplicate named groups") - if len(names) > self.regex_config.max_named_captures: - raise ValueError("regex route pattern has too many named captures") try: engine = importlib.import_module("regex") except ImportError as exc: @@ -179,15 +176,17 @@ def _compile(self, pattern: str) -> Any: "regular-expression routes require 'smallserver[regex-routes]'" ) from exc try: - compiled = engine.compile(pattern) + compiled = engine.compile("(?=/)(?:{})".format(pattern)) except Exception: raise ValueError("invalid regex route pattern") from None - try: - empty_match = compiled.fullmatch("", timeout=self.regex_config.match_timeout) - except TimeoutError as exc: - raise ValueError("regex route pattern validation timed out") from exc - if empty_match is not None: - raise ValueError("regex route pattern must not match an empty path") + names = _named_group_names(pattern) + compiled_names = set(compiled.groupindex) + if set(names) != compiled_names: + raise ValueError("regex route named groups could not be validated") + if len(names) != len(compiled_names): + raise ValueError("regex route pattern contains duplicate named groups") + if len(compiled_names) > self.regex_config.max_named_captures: + raise ValueError("regex route pattern has too many named captures") return compiled def _validate_path(self, path: str) -> None: @@ -196,14 +195,12 @@ def _validate_path(self, path: str) -> None: except UnicodeEncodeError as exc: raise ValueError("request path must contain ASCII characters only") from exc if size > self.regex_config.max_path_bytes: - raise ValueError("request path is too large for routing") + raise RoutePathTooLarge("request path is too large for routing") def _match(self, route: _RegexRoute, path: str, deadline: float) -> Any: remaining = deadline - time.monotonic() if remaining <= 0: raise RouteMatchTimeout(route.route_id) - if len(route.literal_prefix) > 1 and not path.startswith(route.literal_prefix): - return None timeout = min(float(self.regex_config.match_timeout), remaining) try: return route.compiled.fullmatch(path, timeout=timeout) @@ -215,18 +212,10 @@ def _captures(match: Any) -> dict[str, str]: return {name: value for name, value in match.groupdict().items() if value is not None} -def _literal_prefix(pattern: str) -> str: - """Return only the leading literals that are safe to use as a rejection index.""" - special = frozenset(".[](){}*+?|^$\\") - end = 0 - while end < len(pattern) and pattern[end] not in special: - end += 1 - return pattern[:end] - - def _named_group_names(pattern: str) -> list[str]: - """Find named-group declarations while ignoring escapes and character classes.""" + """Find declarations while honoring regex comments and scoped verbose mode.""" names: list[str] = [] + verbose_stack = [False] escaped = False in_class = False index = 0 @@ -248,18 +237,59 @@ def _named_group_names(pattern: str) -> list[str]: in_class = False index += 1 continue - marker_length = 0 - if not in_class and pattern.startswith("(?P<", index): - marker_length = 4 - elif not in_class and pattern.startswith("(?<", index): - next_character = pattern[index + 3 : index + 4] - if next_character not in ("=", "!"): - marker_length = 3 - if marker_length: - end = pattern.find(">", index + marker_length) - if end >= 0: - names.append(pattern[index + marker_length : end]) - index = end + 1 + if not in_class and verbose_stack[-1] and character == "#": + newline = pattern.find("\n", index + 1) + index = len(pattern) if newline < 0 else newline + 1 + continue + if not in_class and pattern.startswith("(?#", index): + index = _comment_end(pattern, index + 3) + continue + if not in_class and character == "(": + flags = re.match(r"\(\?([A-Za-z]*)(?:-([A-Za-z]*))?([:)])", pattern[index:]) + if flags is not None: + enabled, disabled, delimiter = flags.groups() + verbose = (verbose_stack[-1] or "x" in enabled) and "x" not in (disabled or "") + index += flags.end() + if delimiter == ":": + verbose_stack.append(verbose) + else: + verbose_stack[-1] = verbose continue + marker_length = 0 + if pattern.startswith("(?P<", index): + marker_length = 4 + elif pattern.startswith("(?<", index): + next_character = pattern[index + 3 : index + 4] + if next_character not in ("=", "!"): + marker_length = 3 + if marker_length: + end = pattern.find(">", index + marker_length) + if end >= 0: + names.append(pattern[index + marker_length : end]) + verbose_stack.append(verbose_stack[-1]) + index = end + 1 + continue + verbose_stack.append(verbose_stack[-1]) + index += 1 + continue + if not in_class and character == ")": + if len(verbose_stack) > 1: + verbose_stack.pop() + index += 1 + continue index += 1 return names + + +def _comment_end(pattern: str, index: int) -> int: + escaped = False + while index < len(pattern): + character = pattern[index] + if escaped: + escaped = False + elif character == "\\": + escaped = True + elif character == ")": + return index + 1 + index += 1 + return index diff --git a/smallserver/server.py b/smallserver/server.py index a3510d6..9ef851d 100644 --- a/smallserver/server.py +++ b/smallserver/server.py @@ -123,10 +123,10 @@ class ServerConfig: max_header_bytes: int = 16 * 1024 max_header_count: int = 100 max_body_bytes: int = 1024 * 1024 - max_request_target_bytes: int = 8 * 1024 receive_chunk_bytes: int = 8 * 1024 listener_priority: int = 1 connection_priority: int = 2 + max_request_target_bytes: int = 8 * 1024 def __post_init__(self) -> None: for name, value in self.__dict__.items(): diff --git a/tests/installed_regex_smoke.py b/tests/installed_regex_smoke.py new file mode 100644 index 0000000..7689ff0 --- /dev/null +++ b/tests/installed_regex_smoke.py @@ -0,0 +1,21 @@ +"""Smoke an installed ``smallserver[regex-routes]`` package outside the source path.""" + +import asyncio + +from smallserver import Headers, Request, Response, SmallServer + + +async def main() -> None: + app = SmallServer() + + @app.get_regex(r"/users/(?P[0-9]+)") + async def user(request: Request) -> Response: + return Response.text(request.path_params["user_id"]) + + response = await app.dispatch(Request("GET", "/users/42?source=smoke", Headers())) + assert response.status == 200 + assert response.body == b"42" + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/tests/test_regex_routing.py b/tests/test_regex_routing.py index fba24b4..71c0b73 100644 --- a/tests/test_regex_routing.py +++ b/tests/test_regex_routing.py @@ -12,12 +12,17 @@ RouteMatchTimeout, SmallServer, ) +from smallserver.routing import Router HAS_REGEX = importlib.util.find_spec("regex") is not None class OptionalRegexDependencyTests(unittest.TestCase): + def test_error_observer_must_be_callable(self) -> None: + with self.assertRaisesRegex(TypeError, "route_error_observer"): + SmallServer(route_error_observer=object()) # type: ignore[arg-type] + def test_static_routes_do_not_import_optional_engine(self) -> None: app = SmallServer() @@ -88,6 +93,51 @@ async def exact(request): self.assertEqual((await app.dispatch(Request("GET", "/items/7", Headers()))).body, b"static") self.assertEqual((await app.dispatch(Request("GET", "/items/8", Headers()))).body, b"broad") + async def test_alternation_and_optional_literals_preserve_order_and_405(self) -> None: + app = SmallServer() + + @app.get_regex(r"/foo|/bar") + async def top_level(request): + return Response.text("top") + + @app.get_regex(r"/fo?bar") + async def optional(request): + return Response.text("optional") + + @app.get_regex(r"/(?:red|blue)") + async def nested(request): + return Response.text("nested") + + for path, expected in ( + ("/foo", b"top"), + ("/bar", b"top"), + ("/fbar", b"optional"), + ("/fobar", b"optional"), + ("/red", b"nested"), + ("/blue", b"nested"), + ): + with self.subTest(path=path): + self.assertEqual((await app.dispatch(Request("GET", path, Headers()))).body, expected) + response = await app.dispatch(Request("POST", "/bar", Headers())) + self.assertEqual(response.status, 405) + self.assertEqual(response.headers["allow"], "GET") + + def test_compiled_routes_are_structurally_guarded_to_slash_paths(self) -> None: + async def handler(request): + return Response() + + for pattern, slash_path, non_slash_path in ( + (r"/foo|bar", "/foo", "bar"), + (r"/?foo", "/foo", "foo"), + ): + with self.subTest(pattern=pattern): + router = Router() + router.add_regex(pattern, ("GET",), handler) + self.assertIs(router.resolve("GET", slash_path).handler, handler) + miss = router.resolve("GET", non_slash_path) + self.assertIsNone(miss.handler) + self.assertEqual(miss.allowed_methods, ()) + async def test_method_first_matching_and_sorted_allow(self) -> None: app = SmallServer() @@ -162,6 +212,19 @@ async def handler(request): with self.assertRaisesRegex(ValueError, "maximum"): limited.get_regex(r"/two")(handler) + async def test_verbose_comment_group_text_does_not_create_false_duplicate(self) -> None: + app = SmallServer() + + @app.get_regex( + "/(?x:items/ # (?Pthis is comment text)\n" + " (?P[0-9]+))" + ) + async def item(request): + return Response.text(request.path_params["item_id"]) + + response = await app.dispatch(Request("GET", "/items/42", Headers())) + self.assertEqual(response.body, b"42") + async def test_catastrophic_backtracking_is_bounded_and_path_is_not_disclosed(self) -> None: app = SmallServer(RegexRouteConfig(match_timeout=0.001, total_match_timeout=0.005)) @@ -180,9 +243,9 @@ async def handler(request): async def test_total_budget_bounds_many_individually_fast_misses(self) -> None: class SlowPattern: + groupindex = {} + def fullmatch(self, value, timeout): - if not value: - return None time.sleep(min(timeout / 2, 0.001)) return None @@ -207,14 +270,30 @@ async def handler(request): self.assertLess(time.monotonic() - started, 0.05) async def test_manual_dispatch_path_limit_is_enforced_before_matching(self) -> None: + calls = [] + + class Pattern: + groupindex = {} + + def fullmatch(self, value, timeout): + calls.append(value) + return None + + class Engine: + @staticmethod + def compile(pattern): + return Pattern() + app = SmallServer(RegexRouteConfig(max_path_bytes=8)) - @app.get_regex(r"/.*") async def handler(request): return Response() - with self.assertRaisesRegex(ValueError, "too large"): - await app.dispatch(Request("GET", "/12345678", Headers())) + with patch("smallserver.routing.importlib.import_module", return_value=Engine()): + app.get_regex(r"/.*")(handler) + response = await app.dispatch(Request("GET", "/12345678", Headers())) + self.assertEqual(response.status, 414) + self.assertEqual(calls, []) class RegexRouteConfigTests(unittest.TestCase): diff --git a/tests/test_server.py b/tests/test_server.py index 27b1766..86eef5c 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -59,6 +59,17 @@ def test_config_rejects_unbounded_limits(self) -> None: with self.assertRaisesRegex(ValueError, "max_request_target_bytes"): ServerConfig(max_request_target_bytes=0) + def test_config_preserves_legacy_positional_field_mapping(self) -> None: + config = ServerConfig(1, 2, 3, 4, 5, 6, 7) + self.assertEqual(config.max_connections, 1) + self.assertEqual(config.max_header_bytes, 2) + self.assertEqual(config.max_header_count, 3) + self.assertEqual(config.max_body_bytes, 4) + self.assertEqual(config.receive_chunk_bytes, 5) + self.assertEqual(config.listener_priority, 6) + self.assertEqual(config.connection_priority, 7) + self.assertEqual(config.max_request_target_bytes, 8 * 1024) + def test_serve_closes_bound_socket_when_runtime_fork_fails(self) -> None: class Listener: closed = False diff --git a/tests/test_server_runtime.py b/tests/test_server_runtime.py index 9d80992..1ada18c 100644 --- a/tests/test_server_runtime.py +++ b/tests/test_server_runtime.py @@ -6,7 +6,7 @@ from SmallPackage import SmallOS, Unix from SmallPackage.adapters.threads import ThreadAdapter -from smallserver import AdapterRegistry, Response, SmallServer +from smallserver import AdapterRegistry, RegexRouteConfig, Response, RouteMatchTimeout, SmallServer HAS_REGEX = importlib.util.find_spec("regex") is not None @@ -164,3 +164,86 @@ def client() -> None: self.assertFalse(worker.is_alive()) self.assertEqual(errors, []) self.assertIn(b"\r\n\r\na%2Fb?download=1", b"".join(received)) + + @unittest.skipUnless(HAS_REGEX, "regex-routes extra is not installed") + def test_regex_timeout_is_observed_once_and_does_not_stop_server(self) -> None: + runtime = SmallOS().setKernel(Unix()) + observed: list[RouteMatchTimeout] = [] + app = SmallServer( + RegexRouteConfig(match_timeout=0.001, total_match_timeout=0.005), + route_error_observer=observed.append, + ) + + @app.get_regex(r"/(a+)+$") + async def expensive(request): + return Response() + + @app.get("/health") + async def health(request): + return Response.text("healthy") + + try: + server = app.serve(runtime, host="127.0.0.1", port=0) + except PermissionError: + self.skipTest("the current sandbox does not permit loopback TCP binds") + + hostile_path = "/" + "a" * 5000 + "!" + received = [] + errors = [] + + def client() -> None: + try: + received.append(self._request(server.port, hostile_path)) + received.append(self._request(server.port, "/health")) + except BaseException as exc: + errors.append(exc) + finally: + server.close() + + worker = threading.Thread(target=client, daemon=True) + worker.start() + runtime.start() + worker.join(timeout=3) + self.assertFalse(worker.is_alive()) + self.assertEqual(errors, []) + self.assertEqual(len(observed), 1) + self.assertEqual(observed[0].route_id, "regex-route-1") + self.assertNotIn(hostile_path, str(observed[0])) + self.assertTrue(received[0].startswith(b"HTTP/1.1 500 Internal Server Error\r\n")) + self.assertNotIn(hostile_path.encode("ascii"), received[0]) + self.assertTrue(received[1].startswith(b"HTTP/1.1 200 OK\r\n")) + self.assertTrue(received[1].endswith(b"healthy")) + + @unittest.skipUnless(HAS_REGEX, "regex-routes extra is not installed") + def test_regex_path_limit_returns_414_before_matching(self) -> None: + runtime = SmallOS().setKernel(Unix()) + app = SmallServer(RegexRouteConfig(max_path_bytes=8)) + + @app.get_regex(r"/.*") + async def route(request): + return Response.text("must not run") + + try: + server = app.serve(runtime, host="127.0.0.1", port=0) + except PermissionError: + self.skipTest("the current sandbox does not permit loopback TCP binds") + + received = [] + errors = [] + + def client() -> None: + try: + received.append(self._request(server.port, "/12345678")) + except BaseException as exc: + errors.append(exc) + finally: + server.close() + + worker = threading.Thread(target=client, daemon=True) + worker.start() + runtime.start() + worker.join(timeout=2) + self.assertFalse(worker.is_alive()) + self.assertEqual(errors, []) + self.assertTrue(received[0].startswith(b"HTTP/1.1 414 URI Too Long\r\n")) + self.assertNotIn(b"must not run", received[0]) diff --git a/tests/typing/regex_routes.py b/tests/typing/regex_routes.py new file mode 100644 index 0000000..2239829 --- /dev/null +++ b/tests/typing/regex_routes.py @@ -0,0 +1,20 @@ +"""Public typing fixture for mypy/pyright and compile-only release checks.""" + +from smallserver import Request, Response, RouteMatchTimeout, SmallServer + + +def observe(error: RouteMatchTimeout) -> None: + route_id: str = error.route_id + assert route_id + + +def application() -> SmallServer: + app = SmallServer(route_error_observer=observe) + + @app.get_regex(r"/users/(?P[0-9]+)") + async def user(request: Request) -> Response: + user_id: str = request.path_params["user_id"] + pattern: str | None = request.route_pattern + return Response.json({"user_id": user_id, "pattern": pattern}) + + return app From 82c6acefa1c64676dc055409889a4f781bf13b62 Mon Sep 17 00:00:00 2001 From: MikiEEE <35975462+MikiEEE@users.noreply.github.com> Date: Fri, 21 Aug 2026 23:47:09 -0500 Subject: [PATCH 06/53] fix: isolate route events and optimize static dispatch --- README.md | 21 +++++---- benchmarks/route_benchmark.py | 67 ++++++++++++++++++++-------- smallserver/__init__.py | 9 +++- smallserver/app.py | 35 +++++++++------ smallserver/routing.py | 12 ++++++ tests/installed_regex_smoke.py | 4 +- tests/test_routing.py | 16 +++++++ tests/test_server_runtime.py | 79 +++++++++++++++++++++++++++++----- tests/typing/regex_routes.py | 6 +-- 9 files changed, 195 insertions(+), 54 deletions(-) diff --git a/README.md b/README.md index 991183c..93ffe35 100644 --- a/README.md +++ b/README.md @@ -148,30 +148,35 @@ opaque route ID and becomes a sanitized 500 response on the network path. Applications can observe that failure without receiving the hostile target: ```python -from smallserver import RouteMatchTimeout +from smallserver import RouteErrorEvent -def observe_route_error(error: RouteMatchTimeout) -> None: - logger.error("route matching failed: %s", error.route_id) +def observe_route_error(event: RouteErrorEvent) -> None: + logger.error("route matching failed: %s (%s)", event.route_id, event.category) app = SmallServer(route_error_observer=observe_route_error) ``` -The synchronous observer runs once on the connection task and should return -quickly; observer failures are isolated from the response path. A path above -the configured regex-routing byte limit returns 414 before matching begins. +The synchronous observer receives a fresh, immutable, traceback-free event +containing only an opaque route ID and category. It runs once on the connection +task and should return quickly; observer failures are isolated from the +response path. A path above the configured regex-routing byte limit returns +414 before matching begins. Requests retain the exact ASCII origin-form target in `request.raw_target`. Routing uses `request.path`, which excludes the query string; `request.query_string` contains the raw text after `?`. Neither paths nor named captures are percent-decoded, so `/files/a%2Fb` remains distinct from -`/files/a/b`. `request.route_pattern` identifies the selected static path or -regex pattern. +`/files/a/b`. `request.route_pattern` identifies a selected regex pattern; +static dispatch passes the original request through without adding route +context. Run `python benchmarks/route_benchmark.py` for a same-process comparison of the pre-router dictionary dispatch model and current router dispatch. Its JSON also records configured versus observed hostile-pattern timeout when the extra is installed; rates are machine-specific and should be compared on the same host. +Use `python benchmarks/route_benchmark.py --release` to enforce the documented +static-dispatch floor of 80% of the legacy model across repeated rounds. Release validation can run `python tests/installed_regex_smoke.py` from an environment where the built `smallserver[regex-routes]` wheel is installed. diff --git a/benchmarks/route_benchmark.py b/benchmarks/route_benchmark.py index a3b247e..81ff591 100644 --- a/benchmarks/route_benchmark.py +++ b/benchmarks/route_benchmark.py @@ -8,6 +8,7 @@ import inspect import json from pathlib import Path +import statistics import sys import time @@ -15,6 +16,8 @@ from smallserver import Headers, RegexRouteConfig, Request, Response, RouteMatchTimeout, SmallServer +STATIC_DISPATCH_RATIO_FLOOR = 0.80 + async def _legacy_dispatch(routes, request): """Model the pre-router static dictionary dispatch for same-run comparison.""" @@ -28,7 +31,14 @@ async def _legacy_dispatch(routes, request): return response -async def benchmark(iterations: int) -> dict[str, float | int | str]: +async def _measure(operation, iterations: int) -> float: + started = time.perf_counter() + for _ in range(iterations): + await operation() + return iterations / (time.perf_counter() - started) + + +async def benchmark(iterations: int, rounds: int) -> dict[str, float | int | str | bool]: app = SmallServer() @app.get("/health") @@ -38,23 +48,37 @@ async def health(request): request = Request("GET", "/health", Headers()) legacy_routes = {("GET", "/health"): health} - started = time.perf_counter() - for _ in range(iterations): - await _legacy_dispatch(legacy_routes, request) - legacy_elapsed = time.perf_counter() - started - - started = time.perf_counter() - for _ in range(iterations): - await app.dispatch(request) - router_elapsed = time.perf_counter() - started - - legacy_rate = iterations / legacy_elapsed - router_rate = iterations / router_elapsed - result: dict[str, float | int | str] = { + async def legacy_operation(): + return await _legacy_dispatch(legacy_routes, request) + + async def router_operation(): + return await app.dispatch(request) + + await _measure(legacy_operation, min(iterations, 1_000)) + await _measure(router_operation, min(iterations, 1_000)) + legacy_rates = [] + router_rates = [] + ratios = [] + for round_number in range(rounds): + if round_number % 2: + router_rate = await _measure(router_operation, iterations) + legacy_rate = await _measure(legacy_operation, iterations) + else: + legacy_rate = await _measure(legacy_operation, iterations) + router_rate = await _measure(router_operation, iterations) + legacy_rates.append(legacy_rate) + router_rates.append(router_rate) + ratios.append(router_rate / legacy_rate) + + median_ratio = statistics.median(ratios) + result: dict[str, float | int | str | bool] = { "iterations": iterations, - "legacy_static_dispatches_per_second": round(legacy_rate, 2), - "router_static_dispatches_per_second": round(router_rate, 2), - "router_to_legacy_ratio": round(router_rate / legacy_rate, 4), + "rounds": rounds, + "legacy_static_dispatches_per_second": round(statistics.median(legacy_rates), 2), + "router_static_dispatches_per_second": round(statistics.median(router_rates), 2), + "router_to_legacy_ratio": round(median_ratio, 4), + "static_dispatch_ratio_floor": STATIC_DISPATCH_RATIO_FLOOR, + "static_dispatch_floor_passed": median_ratio >= STATIC_DISPATCH_RATIO_FLOOR, } if importlib.util.find_spec("regex") is None: @@ -80,10 +104,17 @@ async def hostile(request): def main() -> None: parser = argparse.ArgumentParser() parser.add_argument("--iterations", type=int, default=25_000) + parser.add_argument("--rounds", type=int, default=5) + parser.add_argument("--release", action="store_true") arguments = parser.parse_args() if arguments.iterations <= 0: parser.error("--iterations must be positive") - print(json.dumps(asyncio.run(benchmark(arguments.iterations)), indent=2, sort_keys=True)) + if arguments.rounds <= 0: + parser.error("--rounds must be positive") + result = asyncio.run(benchmark(arguments.iterations, arguments.rounds)) + print(json.dumps(result, indent=2, sort_keys=True)) + if arguments.release and not result["static_dispatch_floor_passed"]: + raise SystemExit(1) if __name__ == "__main__": diff --git a/smallserver/__init__.py b/smallserver/__init__.py index 476d64e..2fe0c87 100644 --- a/smallserver/__init__.py +++ b/smallserver/__init__.py @@ -4,7 +4,13 @@ from .adapters import AdapterRegistry, AdapterShutdownError, http_error_from_adapter from .errors import HTTPError from .http import Headers, Request, Response -from .routing import RegexRouteConfig, RegexRoutesUnavailable, RouteMatchTimeout, RoutePathTooLarge +from .routing import ( + RegexRouteConfig, + RegexRoutesUnavailable, + RouteErrorEvent, + RouteMatchTimeout, + RoutePathTooLarge, +) from .server import ServerConfig, ServerHandle __all__ = [ @@ -16,6 +22,7 @@ "RegexRoutesUnavailable", "Request", "Response", + "RouteErrorEvent", "RouteMatchTimeout", "RoutePathTooLarge", "ServerConfig", diff --git a/smallserver/app.py b/smallserver/app.py index a009025..eee2386 100644 --- a/smallserver/app.py +++ b/smallserver/app.py @@ -10,11 +10,17 @@ from .errors import HTTPError from .http import Request, Response -from .routing import RegexRouteConfig, RouteMatchTimeout, RoutePathTooLarge, Router +from .routing import ( + RegexRouteConfig, + RouteErrorEvent, + RouteMatchTimeout, + RoutePathTooLarge, + Router, +) from .server import HTTPParseError, HTTPRequestParser, ServerConfig, ServerHandle Handler = Callable[[Request], Awaitable[Response]] -RouteErrorObserver = Callable[[RouteMatchTimeout], None] +RouteErrorObserver = Callable[[RouteErrorEvent], None] class SmallServer: @@ -141,16 +147,18 @@ def serve( async def dispatch(self, request: Request) -> Response: """Run a registered handler or return a deterministic HTTP response.""" - try: - match = self._router.resolve(request.method, request.path) - except RoutePathTooLarge: - return Response.text("request target is too large", status=414) - if match.handler is None: - if match.allowed_methods: - return Response.text("method not allowed", status=405, headers={"Allow": ", ".join(match.allowed_methods)}) - return Response.text("not found", status=404) - handler = match.handler - request = replace(request, path_params=match.path_params, route_pattern=match.route_pattern) + handler = self._router.static_handler(request.method, request.path) + if handler is None: + try: + match = self._router.resolve(request.method, request.path) + except RoutePathTooLarge: + return Response.text("request target is too large", status=414) + if match.handler is None: + if match.allowed_methods: + return Response.text("method not allowed", status=405, headers={"Allow": ", ".join(match.allowed_methods)}) + return Response.text("not found", status=404) + handler = match.handler + request = replace(request, path_params=match.path_params, route_pattern=match.route_pattern) try: result = handler(request) if not inspect.isawaitable(result): @@ -259,7 +267,8 @@ def _observe_route_error(self, error: RouteMatchTimeout) -> None: observer = self._route_error_observer if observer is None: return + event = RouteErrorEvent(route_id=error.route_id, category="route_match_timeout") try: - observer(error) + observer(event) except Exception: pass diff --git a/smallserver/routing.py b/smallserver/routing.py index b53588d..f8db1ed 100644 --- a/smallserver/routing.py +++ b/smallserver/routing.py @@ -33,6 +33,14 @@ class RoutePathTooLarge(RuntimeError): """Raised before matching when a request path exceeds its routing bound.""" +@dataclass(frozen=True) +class RouteErrorEvent: + """Traceback-free, immutable routing failure data safe for observation.""" + + route_id: str + category: str + + @dataclass(frozen=True) class RegexRouteConfig: """Finite limits applied to regex registration and hostile request paths.""" @@ -98,6 +106,10 @@ def add_static(self, path: str, methods: tuple[str, ...], handler: Handler) -> N for key in keys: self._static[key] = handler + def static_handler(self, method: str, path: str) -> Handler | None: + """Return an exact static handler without allocating match context.""" + return self._static.get((method.upper(), path)) + def add_regex(self, pattern: str, methods: tuple[str, ...], handler: Handler) -> None: if not isinstance(pattern, str): raise TypeError("regex route pattern must be a string") diff --git a/tests/installed_regex_smoke.py b/tests/installed_regex_smoke.py index 7689ff0..e439c6d 100644 --- a/tests/installed_regex_smoke.py +++ b/tests/installed_regex_smoke.py @@ -2,10 +2,12 @@ import asyncio -from smallserver import Headers, Request, Response, SmallServer +from smallserver import Headers, Request, Response, RouteErrorEvent, SmallServer async def main() -> None: + event = RouteErrorEvent("regex-route-smoke", "route_match_timeout") + assert (event.route_id, event.category) == ("regex-route-smoke", "route_match_timeout") app = SmallServer() @app.get_regex(r"/users/(?P[0-9]+)") diff --git a/tests/test_routing.py b/tests/test_routing.py index d152500..72349f6 100644 --- a/tests/test_routing.py +++ b/tests/test_routing.py @@ -44,6 +44,22 @@ async def items(request): self.assertEqual(seen[0].path, "/items") self.assertEqual(seen[0].query_string, "tag=a%2Fb") + async def test_static_dispatch_passes_original_request_without_route_context_copy(self) -> None: + app = SmallServer() + seen = [] + + @app.get("/health") + async def health(request): + seen.append(request) + return Response() + + request = Request("GET", "/health", Headers()) + response = await app.dispatch(request) + self.assertEqual(response.status, 200) + self.assertIs(seen[0], request) + self.assertIsNone(seen[0].route_pattern) + self.assertEqual(dict(seen[0].path_params), {}) + async def test_http_error_becomes_response(self) -> None: app = SmallServer() diff --git a/tests/test_server_runtime.py b/tests/test_server_runtime.py index 1ada18c..c7bbb61 100644 --- a/tests/test_server_runtime.py +++ b/tests/test_server_runtime.py @@ -1,4 +1,5 @@ import importlib.util +from dataclasses import FrozenInstanceError import socket import threading import unittest @@ -6,18 +7,23 @@ from SmallPackage import SmallOS, Unix from SmallPackage.adapters.threads import ThreadAdapter -from smallserver import AdapterRegistry, RegexRouteConfig, Response, RouteMatchTimeout, SmallServer +from smallserver import ( + AdapterRegistry, + RegexRouteConfig, + Request, + Response, + RouteErrorEvent, + SmallServer, +) HAS_REGEX = importlib.util.find_spec("regex") is not None class SmallOSServerIntegrationTests(unittest.TestCase): - def _request(self, port: int, path: str) -> bytes: + def _exchange(self, port: int, payload: bytes) -> bytes: with socket.create_connection(("127.0.0.1", port), timeout=3) as connection: - connection.sendall( - "GET {} HTTP/1.1\r\nHost: localhost\r\n\r\n".format(path).encode("ascii") - ) + connection.sendall(payload) chunks = [] while True: chunk = connection.recv(4096) @@ -25,6 +31,12 @@ def _request(self, port: int, path: str) -> bytes: return b"".join(chunks) chunks.append(chunk) + def _request(self, port: int, path: str) -> bytes: + return self._exchange( + port, + "GET {} HTTP/1.1\r\nHost: localhost\r\n\r\n".format(path).encode("ascii"), + ) + def test_loopback_server_accepts_fragmented_request_and_shuts_down(self) -> None: runtime = SmallOS().setKernel(Unix()) app = SmallServer() @@ -168,13 +180,15 @@ def client() -> None: @unittest.skipUnless(HAS_REGEX, "regex-routes extra is not installed") def test_regex_timeout_is_observed_once_and_does_not_stop_server(self) -> None: runtime = SmallOS().setKernel(Unix()) - observed: list[RouteMatchTimeout] = [] + observed: list[RouteErrorEvent] = [] app = SmallServer( RegexRouteConfig(match_timeout=0.001, total_match_timeout=0.005), route_error_observer=observed.append, ) - @app.get_regex(r"/(a+)+$") + pattern_secret = "sensitive-pattern-marker" + + @app.post_regex(r"/(a+)+$(?#sensitive-pattern-marker)") async def expensive(request): return Response() @@ -188,12 +202,20 @@ async def health(request): self.skipTest("the current sandbox does not permit loopback TCP binds") hostile_path = "/" + "a" * 5000 + "!" + authorization_secret = "Bearer sensitive-authorization-marker" + body_secret = b"sensitive-body-marker" received = [] errors = [] def client() -> None: try: - received.append(self._request(server.port, hostile_path)) + request = ( + "POST {} HTTP/1.1\r\n" + "Host: localhost\r\n" + "Authorization: {}\r\n" + "Content-Length: {}\r\n\r\n" + ).format(hostile_path, authorization_secret, len(body_secret)).encode("ascii") + received.append(self._exchange(server.port, request + body_secret)) received.append(self._request(server.port, "/health")) except BaseException as exc: errors.append(exc) @@ -207,8 +229,24 @@ def client() -> None: self.assertFalse(worker.is_alive()) self.assertEqual(errors, []) self.assertEqual(len(observed), 1) - self.assertEqual(observed[0].route_id, "regex-route-1") - self.assertNotIn(hostile_path, str(observed[0])) + event = observed[0] + self.assertEqual(event.route_id, "regex-route-1") + self.assertEqual(event.category, "route_match_timeout") + with self.assertRaises(FrozenInstanceError): + event.route_id = "changed" # type: ignore[misc] + self.assertFalse(hasattr(event, "__traceback__")) + self.assertFalse(hasattr(event, "__cause__")) + self.assertFalse(hasattr(event, "__context__")) + + reachable = _reachable_objects(event) + reachable_strings = {value for value in reachable if isinstance(value, str)} + self.assertEqual( + reachable_strings, + {"route_id", "category", "regex-route-1", "route_match_timeout"}, + ) + self.assertFalse(any(isinstance(value, Request) for value in reachable)) + for secret in (hostile_path, authorization_secret, body_secret.decode("ascii"), pattern_secret): + self.assertNotIn(secret, reachable_strings) self.assertTrue(received[0].startswith(b"HTTP/1.1 500 Internal Server Error\r\n")) self.assertNotIn(hostile_path.encode("ascii"), received[0]) self.assertTrue(received[1].startswith(b"HTTP/1.1 200 OK\r\n")) @@ -247,3 +285,24 @@ def client() -> None: self.assertEqual(errors, []) self.assertTrue(received[0].startswith(b"HTTP/1.1 414 URI Too Long\r\n")) self.assertNotIn(b"must not run", received[0]) + + +def _reachable_objects(root): + pending = [root] + seen = set() + result = [] + while pending: + value = pending.pop() + identity = id(value) + if identity in seen: + continue + seen.add(identity) + result.append(value) + if isinstance(value, dict): + pending.extend(value.keys()) + pending.extend(value.values()) + elif isinstance(value, (tuple, list, set, frozenset)): + pending.extend(value) + elif hasattr(value, "__dict__"): + pending.append(vars(value)) + return result diff --git a/tests/typing/regex_routes.py b/tests/typing/regex_routes.py index 2239829..7f66494 100644 --- a/tests/typing/regex_routes.py +++ b/tests/typing/regex_routes.py @@ -1,10 +1,10 @@ """Public typing fixture for mypy/pyright and compile-only release checks.""" -from smallserver import Request, Response, RouteMatchTimeout, SmallServer +from smallserver import Request, Response, RouteErrorEvent, SmallServer -def observe(error: RouteMatchTimeout) -> None: - route_id: str = error.route_id +def observe(event: RouteErrorEvent) -> None: + route_id: str = event.route_id assert route_id From 8f11677bcc2e888b819ce9601b020c027e1fd150 Mon Sep 17 00:00:00 2001 From: MikiEEE <35975462+MikiEEE@users.noreply.github.com> Date: Fri, 21 Aug 2026 23:57:49 -0500 Subject: [PATCH 07/53] fix: isolate regex observer execution --- README.md | 13 +++-- benchmarks/route_benchmark.py | 4 ++ smallserver/app.py | 106 +++++++++++++++++++++++++++++----- smallserver/server.py | 19 +++++- tests/test_route_benchmark.py | 35 +++++++++++ tests/test_routing.py | 22 +++++++ tests/test_server_runtime.py | 47 +++++++++++++-- 7 files changed, 222 insertions(+), 24 deletions(-) create mode 100644 tests/test_route_benchmark.py diff --git a/README.md b/README.md index 93ffe35..87b894b 100644 --- a/README.md +++ b/README.md @@ -156,11 +156,14 @@ def observe_route_error(event: RouteErrorEvent) -> None: app = SmallServer(route_error_observer=observe_route_error) ``` -The synchronous observer receives a fresh, immutable, traceback-free event -containing only an opaque route ID and category. It runs once on the connection -task and should return quickly; observer failures are isolated from the -response path. A path above the configured regex-routing byte limit returns -414 before matching begins. +The observer receives a fresh, immutable, traceback-free event containing only +an opaque route ID and category. A bounded single-worker dispatcher invokes it +outside the SmallOS/request call stack and exits when its queue drains. The +observer should return quickly; failures are isolated from responses, reported +through `threading.excepthook`, and counted by +`server.route_observer_failures`. Capacity drops are counted by +`server.dropped_route_error_events`. A path above the configured regex-routing +byte limit returns 414 before matching begins. Requests retain the exact ASCII origin-form target in `request.raw_target`. Routing uses `request.path`, which excludes the query string; diff --git a/benchmarks/route_benchmark.py b/benchmarks/route_benchmark.py index 81ff591..1efab47 100644 --- a/benchmarks/route_benchmark.py +++ b/benchmarks/route_benchmark.py @@ -111,6 +111,10 @@ def main() -> None: parser.error("--iterations must be positive") if arguments.rounds <= 0: parser.error("--rounds must be positive") + if arguments.release and arguments.iterations < 10_000: + parser.error("--release requires at least 10000 iterations") + if arguments.release and arguments.rounds < 5: + parser.error("--release requires at least 5 rounds") result = asyncio.run(benchmark(arguments.iterations, arguments.rounds)) print(json.dumps(result, indent=2, sort_keys=True)) if arguments.release and not result["static_dispatch_floor_passed"]: diff --git a/smallserver/app.py b/smallserver/app.py index eee2386..53c5d51 100644 --- a/smallserver/app.py +++ b/smallserver/app.py @@ -3,9 +3,11 @@ from __future__ import annotations import inspect +from collections import deque from collections.abc import Awaitable, Callable, Iterable from dataclasses import replace import socket +import threading from typing import Any from .errors import HTTPError @@ -23,6 +25,77 @@ RouteErrorObserver = Callable[[RouteErrorEvent], None] +class _RouteObserverDispatcher: + """Run sanitized events on one bounded, short-lived observer thread.""" + + def __init__(self, observer: RouteErrorObserver) -> None: + self._observer = observer + self._events: deque[RouteErrorEvent] = deque() + self._lock = threading.Lock() + self._worker_active = False + self._pending = 0 + self._dropped = 0 + self._failures = 0 + + @property + def dropped(self) -> int: + with self._lock: + return self._dropped + + @property + def failures(self) -> int: + with self._lock: + return self._failures + + def schedule(self, event: RouteErrorEvent, max_pending: int) -> bool: + with self._lock: + if self._pending >= max_pending: + self._dropped += 1 + return False + self._events.append(event) + self._pending += 1 + if self._worker_active: + return True + self._worker_active = True + try: + threading.Thread( + target=self._run, + name="smallserver-route-observer", + daemon=True, + ).start() + except BaseException: + self._worker_active = False + self._events.pop() + self._pending -= 1 + self._dropped += 1 + return False + return True + + def _run(self) -> None: + while True: + with self._lock: + if not self._events: + self._worker_active = False + return + event = self._events.popleft() + try: + self._observer(event) + except BaseException as exc: + with self._lock: + self._failures += 1 + try: + threading.excepthook( + threading.ExceptHookArgs( + (type(exc), exc, exc.__traceback__, threading.current_thread()) + ) + ) + except BaseException: + pass + finally: + with self._lock: + self._pending -= 1 + + class SmallServer: """Register static HTTP routes and dispatch requests to async handlers.""" @@ -35,7 +108,11 @@ 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) - self._route_error_observer = route_error_observer + self._route_observer_dispatcher = ( + _RouteObserverDispatcher(route_error_observer) + if route_error_observer is not None + else None + ) def route(self, path: str, methods: Iterable[str]) -> Callable[[Handler], Handler]: if not isinstance(path, str) or not path.startswith("/"): @@ -123,7 +200,7 @@ def serve( except BaseException: listener.close() raise - handle = ServerHandle(runtime, listener, config) + handle = ServerHandle(runtime, listener, config, self._route_observer_dispatcher) listener_task = SmallTask( config.listener_priority, self._accept_loop, @@ -148,7 +225,10 @@ def serve( async def dispatch(self, request: Request) -> Response: """Run a registered handler or return a deterministic HTTP response.""" handler = self._router.static_handler(request.method, request.path) - if handler is None: + if handler is not None: + if request.path_params or request.route_pattern is not None: + request = replace(request, path_params={}, route_pattern=None) + else: try: match = self._router.resolve(request.method, request.path) except RoutePathTooLarge: @@ -235,7 +315,15 @@ async def _connection_loop(self, task: Any, handle: ServerHandle, client: socket try: response = await self.dispatch(request) except RouteMatchTimeout as exc: - self._observe_route_error(exc) + dispatcher = self._route_observer_dispatcher + if dispatcher is not None: + dispatcher.schedule( + RouteErrorEvent( + route_id=exc.route_id, + category="route_match_timeout", + ), + handle._config.max_connections, + ) response = Response.text("internal server error", status=500) except Exception: response = Response.text("internal server error", status=500) @@ -262,13 +350,3 @@ async def _send_response(self, task: Any, client: socket.socket, response: Respo if sent <= 0: return offset += sent - - def _observe_route_error(self, error: RouteMatchTimeout) -> None: - observer = self._route_error_observer - if observer is None: - return - event = RouteErrorEvent(route_id=error.route_id, category="route_match_timeout") - try: - observer(event) - except Exception: - pass diff --git a/smallserver/server.py b/smallserver/server.py index 9ef851d..39f1bfe 100644 --- a/smallserver/server.py +++ b/smallserver/server.py @@ -137,10 +137,17 @@ def __post_init__(self) -> None: class ServerHandle: """A bound listener and its cooperative shutdown signal.""" - def __init__(self, runtime: Any, listener: socket.socket, config: ServerConfig) -> None: + def __init__( + self, + runtime: Any, + listener: socket.socket, + config: ServerConfig, + route_observer_dispatcher: Any = None, + ) -> None: self._runtime = runtime self._listener = listener self._config = config + self._route_observer_dispatcher = route_observer_dispatcher self._wake_read, self._wake_write = socket.socketpair() self._wake_read.setblocking(False) self._wake_write.setblocking(False) @@ -161,6 +168,16 @@ def port(self) -> int: def closed(self) -> bool: return self._closed + @property + def dropped_route_error_events(self) -> int: + dispatcher = self._route_observer_dispatcher + return 0 if dispatcher is None else int(dispatcher.dropped) + + @property + def route_observer_failures(self) -> int: + dispatcher = self._route_observer_dispatcher + return 0 if dispatcher is None else int(dispatcher.failures) + def close(self) -> None: """Request shutdown safely from any thread without closing live FDs there.""" if self._closed: diff --git a/tests/test_route_benchmark.py b/tests/test_route_benchmark.py new file mode 100644 index 0000000..8b0abdd --- /dev/null +++ b/tests/test_route_benchmark.py @@ -0,0 +1,35 @@ +import contextlib +import io +import sys +import unittest +from unittest.mock import patch + +from benchmarks import route_benchmark + + +class RouteBenchmarkCLITests(unittest.TestCase): + def test_release_mode_rejects_undersized_samples(self) -> None: + cases = ( + ("9999", "5", "10000 iterations"), + ("10000", "4", "5 rounds"), + ) + for iterations, rounds, message in cases: + with self.subTest(iterations=iterations, rounds=rounds): + stderr = io.StringIO() + with patch.object( + sys, + "argv", + [ + "route_benchmark.py", + "--release", + "--iterations", + iterations, + "--rounds", + rounds, + ], + ): + with contextlib.redirect_stderr(stderr): + with self.assertRaises(SystemExit) as raised: + route_benchmark.main() + self.assertEqual(raised.exception.code, 2) + self.assertIn(message, stderr.getvalue()) diff --git a/tests/test_routing.py b/tests/test_routing.py index 72349f6..e6aaed4 100644 --- a/tests/test_routing.py +++ b/tests/test_routing.py @@ -60,6 +60,28 @@ async def health(request): self.assertIsNone(seen[0].route_pattern) self.assertEqual(dict(seen[0].path_params), {}) + async def test_static_dispatch_clears_spoofed_route_context(self) -> None: + app = SmallServer() + seen = [] + + @app.get("/health") + async def health(request): + seen.append(request) + return Response() + + dirty = Request( + "GET", + "/health", + Headers(), + path_params={"spoofed": "value"}, + route_pattern="sensitive-spoofed-pattern", + ) + response = await app.dispatch(dirty) + self.assertEqual(response.status, 200) + self.assertIsNot(seen[0], dirty) + self.assertIsNone(seen[0].route_pattern) + self.assertEqual(dict(seen[0].path_params), {}) + async def test_http_error_becomes_response(self) -> None: app = SmallServer() diff --git a/tests/test_server_runtime.py b/tests/test_server_runtime.py index c7bbb61..916e9c4 100644 --- a/tests/test_server_runtime.py +++ b/tests/test_server_runtime.py @@ -1,8 +1,10 @@ import importlib.util from dataclasses import FrozenInstanceError +import inspect import socket import threading import unittest +from unittest.mock import patch from SmallPackage import SmallOS, Unix from SmallPackage.adapters.threads import ThreadAdapter @@ -13,6 +15,7 @@ Request, Response, RouteErrorEvent, + RouteMatchTimeout, SmallServer, ) @@ -181,9 +184,29 @@ def client() -> None: def test_regex_timeout_is_observed_once_and_does_not_stop_server(self) -> None: runtime = SmallOS().setKernel(Unix()) observed: list[RouteErrorEvent] = [] + observer_graph = [] + observer_finished = threading.Event() + hook_finished = threading.Event() + hook_events = [] + + def observe(event: RouteErrorEvent) -> None: + observed.append(event) + caller_locals = [] + frame = inspect.currentframe() + while frame is not None: + caller_locals.append(dict(frame.f_locals)) + frame = frame.f_back + observer_graph.extend(_reachable_objects(caller_locals)) + observer_finished.set() + raise RuntimeError("intentional observer failure") + + def observe_thread_failure(arguments) -> None: + hook_events.append(arguments) + hook_finished.set() + app = SmallServer( RegexRouteConfig(match_timeout=0.001, total_match_timeout=0.005), - route_error_observer=observed.append, + route_error_observer=observe, ) pattern_secret = "sensitive-pattern-marker" @@ -217,15 +240,20 @@ def client() -> None: ).format(hostile_path, authorization_secret, len(body_secret)).encode("ascii") received.append(self._exchange(server.port, request + body_secret)) received.append(self._request(server.port, "/health")) + if not observer_finished.wait(2): + raise TimeoutError("route observer did not run") + if not hook_finished.wait(2): + raise TimeoutError("observer failure was not reported") except BaseException as exc: errors.append(exc) finally: server.close() worker = threading.Thread(target=client, daemon=True) - worker.start() - runtime.start() - worker.join(timeout=3) + with patch("threading.excepthook", side_effect=observe_thread_failure): + worker.start() + runtime.start() + worker.join(timeout=3) self.assertFalse(worker.is_alive()) self.assertEqual(errors, []) self.assertEqual(len(observed), 1) @@ -247,6 +275,17 @@ def client() -> None: self.assertFalse(any(isinstance(value, Request) for value in reachable)) for secret in (hostile_path, authorization_secret, body_secret.decode("ascii"), pattern_secret): self.assertNotIn(secret, reachable_strings) + + caller_strings = {value for value in observer_graph if isinstance(value, str)} + self.assertFalse(any(isinstance(value, Request) for value in observer_graph)) + self.assertFalse(any(isinstance(value, RouteMatchTimeout) for value in observer_graph)) + for secret in (hostile_path, authorization_secret, body_secret.decode("ascii"), pattern_secret): + self.assertNotIn(secret, caller_strings) + self.assertNotIn(body_secret, observer_graph) + self.assertEqual(server.route_observer_failures, 1) + self.assertEqual(server.dropped_route_error_events, 0) + self.assertEqual(len(hook_events), 1) + self.assertIsInstance(hook_events[0].exc_value, RuntimeError) self.assertTrue(received[0].startswith(b"HTTP/1.1 500 Internal Server Error\r\n")) self.assertNotIn(hostile_path.encode("ascii"), received[0]) self.assertTrue(received[1].startswith(b"HTTP/1.1 200 OK\r\n")) From 5875badef4d131f6f36921fbbd895637dea9cd44 Mon Sep 17 00:00:00 2001 From: MikiEEE <35975462+MikiEEE@users.noreply.github.com> Date: Sat, 22 Aug 2026 00:12:10 -0500 Subject: [PATCH 08/53] fix: keep route observers on SmallOS --- README.md | 15 +++-- smallserver/app.py | 123 ++++++++++------------------------- smallserver/server.py | 80 +++++++++++++++++++++-- tests/test_server.py | 94 +++++++++++++++++++++++++- tests/test_server_runtime.py | 94 +++++++++++++++----------- 5 files changed, 262 insertions(+), 144 deletions(-) diff --git a/README.md b/README.md index 87b894b..8fe7a07 100644 --- a/README.md +++ b/README.md @@ -157,13 +157,14 @@ app = SmallServer(route_error_observer=observe_route_error) ``` The observer receives a fresh, immutable, traceback-free event containing only -an opaque route ID and category. A bounded single-worker dispatcher invokes it -outside the SmallOS/request call stack and exits when its queue drains. The -observer should return quickly; failures are isolated from responses, reported -through `threading.excepthook`, and counted by -`server.route_observer_failures`. Capacity drops are counted by -`server.dropped_route_error_events`. A path above the configured regex-routing -byte limit returns 414 before matching begins. +an opaque route ID and category. A bounded scheduler-local queue delivers it on +one dedicated SmallOS task, separate from the request task. The synchronous +observer must return quickly and must not block; blocking and async observers +remain an execution-adapter follow-up. Failures are isolated from responses +and counted by `server.route_observer_failures`. Capacity drops are counted by +`server.dropped_route_error_events`; tune the positive queue bound with +`ServerConfig(max_route_error_events=...)`. A path above the configured +regex-routing byte limit returns 414 before matching begins. Requests retain the exact ASCII origin-form target in `request.raw_target`. Routing uses `request.path`, which excludes the query string; diff --git a/smallserver/app.py b/smallserver/app.py index 53c5d51..952791f 100644 --- a/smallserver/app.py +++ b/smallserver/app.py @@ -3,11 +3,9 @@ from __future__ import annotations import inspect -from collections import deque from collections.abc import Awaitable, Callable, Iterable from dataclasses import replace import socket -import threading from typing import Any from .errors import HTTPError @@ -19,83 +17,19 @@ RoutePathTooLarge, Router, ) -from .server import HTTPParseError, HTTPRequestParser, ServerConfig, ServerHandle +from .server import ( + HTTPParseError, + HTTPRequestParser, + RouteObserverChannel, + ServerConfig, + ServerHandle, + run_route_observer, +) Handler = Callable[[Request], Awaitable[Response]] RouteErrorObserver = Callable[[RouteErrorEvent], None] -class _RouteObserverDispatcher: - """Run sanitized events on one bounded, short-lived observer thread.""" - - def __init__(self, observer: RouteErrorObserver) -> None: - self._observer = observer - self._events: deque[RouteErrorEvent] = deque() - self._lock = threading.Lock() - self._worker_active = False - self._pending = 0 - self._dropped = 0 - self._failures = 0 - - @property - def dropped(self) -> int: - with self._lock: - return self._dropped - - @property - def failures(self) -> int: - with self._lock: - return self._failures - - def schedule(self, event: RouteErrorEvent, max_pending: int) -> bool: - with self._lock: - if self._pending >= max_pending: - self._dropped += 1 - return False - self._events.append(event) - self._pending += 1 - if self._worker_active: - return True - self._worker_active = True - try: - threading.Thread( - target=self._run, - name="smallserver-route-observer", - daemon=True, - ).start() - except BaseException: - self._worker_active = False - self._events.pop() - self._pending -= 1 - self._dropped += 1 - return False - return True - - def _run(self) -> None: - while True: - with self._lock: - if not self._events: - self._worker_active = False - return - event = self._events.popleft() - try: - self._observer(event) - except BaseException as exc: - with self._lock: - self._failures += 1 - try: - threading.excepthook( - threading.ExceptHookArgs( - (type(exc), exc, exc.__traceback__, threading.current_thread()) - ) - ) - except BaseException: - pass - finally: - with self._lock: - self._pending -= 1 - - class SmallServer: """Register static HTTP routes and dispatch requests to async handlers.""" @@ -108,11 +42,7 @@ 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) - self._route_observer_dispatcher = ( - _RouteObserverDispatcher(route_error_observer) - if route_error_observer is not None - else None - ) + self._route_error_observer = route_error_observer def route(self, path: str, methods: Iterable[str]) -> Callable[[Handler], Handler]: if not isinstance(path, str) or not path.startswith("/"): @@ -200,7 +130,12 @@ def serve( except BaseException: listener.close() raise - handle = ServerHandle(runtime, listener, config, self._route_observer_dispatcher) + observer_channel = ( + RouteObserverChannel(self._route_error_observer, config.max_route_error_events) + if self._route_error_observer is not None + else None + ) + handle = ServerHandle(runtime, listener, config, observer_channel) listener_task = SmallTask( config.listener_priority, self._accept_loop, @@ -214,7 +149,16 @@ def serve( name="smallserver-close-watcher", ) handle._listener_task = listener_task - tasks = (listener_task, close_task) + tasks: tuple[Any, ...] = (listener_task, close_task) + if observer_channel is not None: + observer_task = SmallTask( + config.listener_priority, + run_route_observer, + args=(observer_channel,), + name="smallserver-route-observer", + ) + observer_channel.bind(observer_task) + tasks += (observer_task,) try: runtime.fork(list(tasks)) except BaseException: @@ -294,6 +238,7 @@ async def _connection_loop(self, task: Any, handle: ServerHandle, client: socket handle._config.max_body_bytes, handle._config.max_request_target_bytes, ) + route_error_event: RouteErrorEvent | None = None try: while not handle.closed: try: @@ -315,15 +260,10 @@ async def _connection_loop(self, task: Any, handle: ServerHandle, client: socket try: response = await self.dispatch(request) except RouteMatchTimeout as exc: - dispatcher = self._route_observer_dispatcher - if dispatcher is not None: - dispatcher.schedule( - RouteErrorEvent( - route_id=exc.route_id, - category="route_match_timeout", - ), - handle._config.max_connections, - ) + route_error_event = RouteErrorEvent( + route_id=exc.route_id, + category="route_match_timeout", + ) response = Response.text("internal server error", status=500) except Exception: response = Response.text("internal server error", status=500) @@ -335,6 +275,9 @@ async def _connection_loop(self, task: Any, handle: ServerHandle, client: socket client.close() except OSError: pass + 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) async def _send_response(self, task: Any, client: socket.socket, response: Response) -> None: headers = {name: value for name, value in response.headers.items() if name.lower() != "connection"} diff --git a/smallserver/server.py b/smallserver/server.py index 39f1bfe..c9570af 100644 --- a/smallserver/server.py +++ b/smallserver/server.py @@ -2,11 +2,15 @@ from __future__ import annotations +from collections import deque from dataclasses import dataclass import socket from typing import Any from .http import Headers, Request, Response +from .routing import RouteErrorEvent + +_ROUTE_OBSERVER_SIGNAL = 31 class HTTPParseError(Exception): @@ -127,6 +131,7 @@ class ServerConfig: listener_priority: int = 1 connection_priority: int = 2 max_request_target_bytes: int = 8 * 1024 + max_route_error_events: int = 16 def __post_init__(self) -> None: for name, value in self.__dict__.items(): @@ -134,6 +139,58 @@ def __post_init__(self) -> None: raise ValueError("{} must be a positive integer".format(name)) +class RouteObserverChannel: + """Bounded scheduler-local delivery state for one server invocation.""" + + def __init__(self, observer: Any, max_events: int) -> None: + self.observer = observer + self.max_events = max_events + self.events: deque[RouteErrorEvent] = deque() + self.task: Any = None + self.accepting = True + self.dropped = 0 + self.failures = 0 + + def bind(self, task: Any) -> None: + self.task = task + + def enqueue(self, event: RouteErrorEvent, source_task: Any) -> bool: + if not self.accepting or len(self.events) >= self.max_events: + self.dropped += 1 + return False + self.events.append(event) + try: + signalled = ( + self.task is not None + and source_task.sendSignal(self.task.getID(), _ROUTE_OBSERVER_SIGNAL) == 0 + ) + except BaseException: + signalled = False + if not signalled: + self.events.pop() + self.dropped += 1 + return False + return True + + def stop(self) -> None: + self.accepting = False + self.dropped += len(self.events) + self.events.clear() + + +async def run_route_observer(task: Any, channel: RouteObserverChannel) -> None: + """Drain sanitized events on a dedicated SmallOS task.""" + while channel.accepting: + while channel.events: + event = channel.events.popleft() + try: + channel.observer(event) + except BaseException: + channel.failures += 1 + if channel.accepting: + await task.wait_signal(_ROUTE_OBSERVER_SIGNAL) + + class ServerHandle: """A bound listener and its cooperative shutdown signal.""" @@ -142,12 +199,12 @@ def __init__( runtime: Any, listener: socket.socket, config: ServerConfig, - route_observer_dispatcher: Any = None, + route_observer_channel: RouteObserverChannel | None = None, ) -> None: self._runtime = runtime self._listener = listener self._config = config - self._route_observer_dispatcher = route_observer_dispatcher + self._route_observer_channel = route_observer_channel self._wake_read, self._wake_write = socket.socketpair() self._wake_read.setblocking(False) self._wake_write.setblocking(False) @@ -170,13 +227,13 @@ def closed(self) -> bool: @property def dropped_route_error_events(self) -> int: - dispatcher = self._route_observer_dispatcher - return 0 if dispatcher is None else int(dispatcher.dropped) + channel = self._route_observer_channel + return 0 if channel is None else int(channel.dropped) @property def route_observer_failures(self) -> int: - dispatcher = self._route_observer_dispatcher - return 0 if dispatcher is None else int(dispatcher.failures) + channel = self._route_observer_channel + return 0 if channel is None else int(channel.failures) def close(self) -> None: """Request shutdown safely from any thread without closing live FDs there.""" @@ -194,6 +251,12 @@ def _finish_close(self) -> None: self._connections.clear() if self._listener_task is not None: self._runtime.resume_task(self._listener_task) + channel = self._route_observer_channel + if channel is not None: + channel.stop() + if channel.task is not None: + self._runtime.cancel_task(channel.task) + channel.task = None for sock in (self._listener, self._wake_read, self._wake_write): try: sock.close() @@ -203,6 +266,9 @@ def _finish_close(self) -> None: def _abort_startup(self, tasks: tuple[Any, ...]) -> None: """Release bound resources after task registration fails.""" self._closed = True + channel = self._route_observer_channel + if channel is not None: + channel.stop() cancel_task = getattr(self._runtime, "cancel_task", None) if callable(cancel_task): for task in tasks: @@ -210,6 +276,8 @@ def _abort_startup(self, tasks: tuple[Any, ...]) -> None: cancel_task(task) except BaseException: pass + if channel is not None: + channel.task = None for sock in (self._listener, self._wake_read, self._wake_write): try: sock.close() diff --git a/tests/test_server.py b/tests/test_server.py index 86eef5c..d386393 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -1,8 +1,8 @@ import unittest from unittest.mock import patch -from smallserver import SmallServer -from smallserver.server import HTTPParseError, HTTPRequestParser, ServerConfig +from smallserver import RouteErrorEvent, SmallServer +from smallserver.server import HTTPParseError, HTTPRequestParser, RouteObserverChannel, ServerConfig class HTTPRequestParserTests(unittest.TestCase): @@ -58,6 +58,8 @@ def test_config_rejects_unbounded_limits(self) -> None: ServerConfig(max_connections=True) with self.assertRaisesRegex(ValueError, "max_request_target_bytes"): ServerConfig(max_request_target_bytes=0) + with self.assertRaisesRegex(ValueError, "max_route_error_events"): + ServerConfig(max_route_error_events=0) def test_config_preserves_legacy_positional_field_mapping(self) -> None: config = ServerConfig(1, 2, 3, 4, 5, 6, 7) @@ -69,6 +71,46 @@ def test_config_preserves_legacy_positional_field_mapping(self) -> None: self.assertEqual(config.listener_priority, 6) self.assertEqual(config.connection_priority, 7) self.assertEqual(config.max_request_target_bytes, 8 * 1024) + self.assertEqual(config.max_route_error_events, 16) + + def test_route_observer_channel_has_deterministic_capacity_and_stop(self) -> None: + class ObserverTask: + @staticmethod + def getID() -> int: + return 9 + + class SourceTask: + signals = [] + + def sendSignal(self, pid, signal) -> int: + self.signals.append((pid, signal)) + return 0 + + channel = RouteObserverChannel(lambda event: None, max_events=1) + channel.bind(ObserverTask()) + source = SourceTask() + first = RouteErrorEvent("regex-route-1", "route_match_timeout") + second = RouteErrorEvent("regex-route-2", "route_match_timeout") + self.assertTrue(channel.enqueue(first, source)) + self.assertFalse(channel.enqueue(second, source)) + self.assertEqual(list(channel.events), [first]) + self.assertEqual(channel.dropped, 1) + self.assertEqual(source.signals, [(9, 31)]) + channel.stop() + self.assertFalse(channel.accepting) + self.assertEqual(list(channel.events), []) + self.assertEqual(channel.dropped, 2) + + failing_channel = RouteObserverChannel(lambda event: None, max_events=1) + failing_channel.bind(ObserverTask()) + + class FailingSourceTask: + def sendSignal(self, pid, signal) -> int: + raise RuntimeError("signal failed") + + self.assertFalse(failing_channel.enqueue(first, FailingSourceTask())) + self.assertEqual(list(failing_channel.events), []) + self.assertEqual(failing_channel.dropped, 1) def test_serve_closes_bound_socket_when_runtime_fork_fails(self) -> None: class Listener: @@ -105,3 +147,51 @@ def cancel_task(self, task) -> None: SmallServer().serve(runtime) self.assertTrue(listener.closed) self.assertEqual(runtime.cancelled, 2) + + def test_observer_task_is_included_in_startup_rollback(self) -> None: + class Listener: + closed = False + + def setsockopt(self, *args) -> None: + pass + + def bind(self, address) -> None: + pass + + def listen(self, backlog) -> None: + pass + + def setblocking(self, blocking) -> None: + pass + + def close(self) -> None: + self.closed = True + + class Runtime: + tasks = [] + cancelled = [] + + def fork(self, tasks) -> None: + self.tasks = list(tasks) + raise RuntimeError("no task capacity") + + def cancel_task(self, task) -> None: + self.cancelled.append(task) + + listener = Listener() + runtime = Runtime() + app = SmallServer(route_error_observer=lambda event: None) + with patch("smallserver.app.socket.socket", return_value=listener): + with self.assertRaisesRegex(RuntimeError, "capacity"): + app.serve(runtime) + self.assertTrue(listener.closed) + self.assertEqual(len(runtime.tasks), 3) + self.assertEqual(runtime.cancelled, runtime.tasks) + self.assertEqual( + [task.name for task in runtime.tasks], + [ + "smallserver-listener", + "smallserver-close-watcher", + "smallserver-route-observer", + ], + ) diff --git a/tests/test_server_runtime.py b/tests/test_server_runtime.py index 916e9c4..3937cf9 100644 --- a/tests/test_server_runtime.py +++ b/tests/test_server_runtime.py @@ -4,7 +4,6 @@ import socket import threading import unittest -from unittest.mock import patch from SmallPackage import SmallOS, Unix from SmallPackage.adapters.threads import ThreadAdapter @@ -18,6 +17,7 @@ RouteMatchTimeout, SmallServer, ) +from smallserver.server import run_route_observer HAS_REGEX = importlib.util.find_spec("regex") is not None @@ -186,24 +186,22 @@ def test_regex_timeout_is_observed_once_and_does_not_stop_server(self) -> None: observed: list[RouteErrorEvent] = [] observer_graph = [] observer_finished = threading.Event() - hook_finished = threading.Event() - hook_events = [] + observer_threads = [] def observe(event: RouteErrorEvent) -> None: observed.append(event) + observer_threads.append(threading.current_thread()) caller_locals = [] frame = inspect.currentframe() while frame is not None: caller_locals.append(dict(frame.f_locals)) + if frame.f_code is run_route_observer.__code__: + break frame = frame.f_back - observer_graph.extend(_reachable_objects(caller_locals)) + observer_graph.extend(_reachable_container_values(caller_locals)) observer_finished.set() raise RuntimeError("intentional observer failure") - def observe_thread_failure(arguments) -> None: - hook_events.append(arguments) - hook_finished.set() - app = SmallServer( RegexRouteConfig(match_timeout=0.001, total_match_timeout=0.005), route_error_observer=observe, @@ -227,35 +225,24 @@ async def health(request): hostile_path = "/" + "a" * 5000 + "!" authorization_secret = "Bearer sensitive-authorization-marker" body_secret = b"sensitive-body-marker" - received = [] - errors = [] - - def client() -> None: - try: - request = ( - "POST {} HTTP/1.1\r\n" - "Host: localhost\r\n" - "Authorization: {}\r\n" - "Content-Length: {}\r\n\r\n" - ).format(hostile_path, authorization_secret, len(body_secret)).encode("ascii") - received.append(self._exchange(server.port, request + body_secret)) - received.append(self._request(server.port, "/health")) - if not observer_finished.wait(2): - raise TimeoutError("route observer did not run") - if not hook_finished.wait(2): - raise TimeoutError("observer failure was not reported") - except BaseException as exc: - errors.append(exc) - finally: - server.close() - - worker = threading.Thread(target=client, daemon=True) - with patch("threading.excepthook", side_effect=observe_thread_failure): - worker.start() - runtime.start() - worker.join(timeout=3) - self.assertFalse(worker.is_alive()) - self.assertEqual(errors, []) + runtime_thread = threading.Thread( + target=runtime.start, + name="smallos-runtime-test", + daemon=True, + ) + runtime_thread.start() + request = ( + "POST {} HTTP/1.1\r\n" + "Host: localhost\r\n" + "Authorization: {}\r\n" + "Content-Length: {}\r\n\r\n" + ).format(hostile_path, authorization_secret, len(body_secret)).encode("ascii") + received = [self._exchange(server.port, request + body_secret)] + received.append(self._request(server.port, "/health")) + self.assertTrue(observer_finished.wait(2), "route observer did not run") + server.close() + runtime_thread.join(timeout=3) + self.assertFalse(runtime_thread.is_alive()) self.assertEqual(len(observed), 1) event = observed[0] self.assertEqual(event.route_id, "regex-route-1") @@ -284,8 +271,17 @@ def client() -> None: self.assertNotIn(body_secret, observer_graph) self.assertEqual(server.route_observer_failures, 1) self.assertEqual(server.dropped_route_error_events, 0) - self.assertEqual(len(hook_events), 1) - self.assertIsInstance(hook_events[0].exc_value, RuntimeError) + self.assertEqual(observer_threads, [runtime_thread]) + self.assertNotIn( + "smallserver-route-observer", + {thread.name for thread in threading.enumerate()}, + ) + channel = server._route_observer_channel + self.assertIsNotNone(channel) + assert channel is not None + self.assertFalse(channel.accepting) + self.assertEqual(list(channel.events), []) + self.assertIsNone(channel.task) self.assertTrue(received[0].startswith(b"HTTP/1.1 500 Internal Server Error\r\n")) self.assertNotIn(hostile_path.encode("ascii"), received[0]) self.assertTrue(received[1].startswith(b"HTTP/1.1 200 OK\r\n")) @@ -345,3 +341,23 @@ def _reachable_objects(root): elif hasattr(value, "__dict__"): pending.append(vars(value)) return result + + +def _reachable_container_values(root): + """Walk frame-local containers without traversing scheduler object graphs.""" + pending = [root] + seen = set() + result = [] + while pending: + value = pending.pop() + identity = id(value) + if identity in seen: + continue + seen.add(identity) + result.append(value) + if isinstance(value, dict): + pending.extend(value.keys()) + pending.extend(value.values()) + elif isinstance(value, (tuple, list, set, frozenset)): + pending.extend(value) + return result From a476393bfc69291e9f1f96992932abce5921d5f5 Mon Sep 17 00:00:00 2001 From: MikiEEE <35975462+MikiEEE@users.noreply.github.com> Date: Sat, 22 Aug 2026 00:31:27 -0500 Subject: [PATCH 09/53] fix: bound failed connection ownership --- README.md | 13 ++++ smallserver/__init__.py | 3 +- smallserver/_transport.py | 80 +++++++++++++++----- smallserver/app.py | 133 ++++++++++++++++++++++++++++++--- smallserver/errors.py | 88 +++++++++++++++++++++- smallserver/server.py | 86 ++++++++++++++++++--- tests/test_kernel_transport.py | 114 +++++++++++++++++++++++++--- tests/test_server.py | 108 +++++++++++++++++++++++++- 8 files changed, 572 insertions(+), 53 deletions(-) diff --git a/README.md b/README.md index c775e64..537c093 100644 --- a/README.md +++ b/README.md @@ -77,6 +77,19 @@ 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`. + +`max_connections` bounds every connection stream still owned by the server, +including streams retained after a failed close. At capacity the listener +cooperatively yields without accepting another connection. 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. diff --git a/smallserver/__init__.py b/smallserver/__init__.py index a421318..bfb69c4 100644 --- a/smallserver/__init__.py +++ b/smallserver/__init__.py @@ -2,7 +2,7 @@ from .app import SmallServer from .adapters import AdapterRegistry, AdapterShutdownError, http_error_from_adapter -from .errors import HTTPError +from .errors import HTTPError, ServerStartupError from .http import Headers, Request, Response from .server import ServerConfig, ServerHandle @@ -15,6 +15,7 @@ "Response", "ServerConfig", "ServerHandle", + "ServerStartupError", "SmallServer", "http_error_from_adapter", ] diff --git a/smallserver/_transport.py b/smallserver/_transport.py index 0ac42e6..c14ce4c 100644 --- a/smallserver/_transport.py +++ b/smallserver/_transport.py @@ -5,6 +5,8 @@ from dataclasses import dataclass from typing import Any, Protocol +from .errors import _CleanupTransaction + class WakeupChannelLike(Protocol): """Opaque scheduler wakeup channel supplied by the active kernel.""" @@ -74,6 +76,21 @@ class AcceptedConnection: peer_address: object | None +class _TransportAcquisitionFailure(Exception): + """Internal ownership transfer when acquisition rollback cannot finish.""" + + def __init__( + self, + primary_error: BaseException, + transaction: _CleanupTransaction, + handle: TransportHandle | None = None, + ) -> None: + self.primary_error = primary_error + self.transaction = transaction + self.handle = handle + super().__init__("kernel resource acquisition cleanup is incomplete") + + @dataclass(eq=False) class WakeupChannel: """Validated wake channel with a cached readiness object.""" @@ -132,6 +149,8 @@ def __init__(self, kernel: KernelLike) -> None: missing = [name for name in self._REQUIRED_METHODS if not callable(getattr(kernel, name, None))] if wakeup_supported and not callable(getattr(kernel, "create_wakeup_channel", None)): missing.append("create_wakeup_channel") + if wakeup_supported and not callable(getattr(kernel, "validate_io_wait_object", None)): + missing.append("validate_io_wait_object") if missing: raise TypeError( "the active kernel is missing TCP server operations: {}".format( @@ -155,11 +174,19 @@ def open_listener( self._kernel.socket_bind(listener.raw, address_info) self._kernel.socket_listen(listener.raw, backlog) self._kernel.socket_setblocking(listener.raw, False) - except BaseException: + except BaseException as primary_error: try: self.close(listener) - except BaseException: - pass + except BaseException as cleanup_error: + transaction = _CleanupTransaction() + transaction.add( + "listener", + lambda: self.close(listener), + cleanup_error, + ) + raise _TransportAcquisitionFailure( + primary_error, transaction, listener + ) from primary_error raise return listener @@ -174,11 +201,19 @@ async def accept(self, task: Any, listener: TransportHandle) -> AcceptedConnecti try: self._kernel.socket_setblocking(stream.raw, False) peer = self._kernel.socket_peer_address(stream.raw) - except BaseException: + except BaseException as primary_error: try: self.close(stream) - except BaseException: - pass + except BaseException as cleanup_error: + transaction = _CleanupTransaction() + transaction.add( + "accepted-connection", + lambda: self.close(stream), + cleanup_error, + ) + raise _TransportAcquisitionFailure( + primary_error, transaction, stream + ) from primary_error raise return AcceptedConnection(stream, peer if peer is not None else address) @@ -267,19 +302,30 @@ def create_wakeup_channel(self) -> WakeupChannel | None: if missing: raise TypeError("the active kernel returned an invalid wakeup channel") wait_object = raw_channel.wait_object - validator = getattr(self._kernel, "validate_io_wait_object", None) - if callable(validator): - valid, validation_error = validator(wait_object) - if not valid: - if validation_error is not None: - raise validation_error - raise ValueError("the active kernel returned an invalid wakeup wait object") + validator = self._kernel.validate_io_wait_object + valid, validation_error = validator(wait_object) + if not valid: + if validation_error is not None: + raise validation_error + raise ValueError("the active kernel returned an invalid wakeup wait object") return WakeupChannel(raw_channel, wait_object) - except BaseException: + except BaseException as primary_error: try: close = getattr(raw_channel, "close", None) - if callable(close): + if not callable(close): + raise TypeError("the wakeup channel cannot be closed") + close() + except BaseException as cleanup_error: + transaction = _CleanupTransaction() + + def close_raw_channel() -> None: + close = getattr(raw_channel, "close", None) + if not callable(close): + raise RuntimeError("wakeup channel cleanup remains unavailable") close() - except BaseException: - pass + + transaction.add("wakeup", close_raw_channel, cleanup_error) + raise _TransportAcquisitionFailure( + primary_error, transaction + ) from primary_error raise diff --git a/smallserver/app.py b/smallserver/app.py index ba508b6..d82018a 100644 --- a/smallserver/app.py +++ b/smallserver/app.py @@ -6,8 +6,12 @@ from collections.abc import Awaitable, Callable, Iterable from typing import Any -from ._transport import KernelTransport, TransportHandle -from .errors import HTTPError +from ._transport import ( + KernelTransport, + TransportHandle, + _TransportAcquisitionFailure, +) +from .errors import HTTPError, ServerStartupError, _CleanupTransaction from .http import Request, Response from .server import HTTPParseError, HTTPRequestParser, ServerConfig, ServerHandle @@ -79,11 +83,35 @@ def serve( raise ValueError("port must be an integer between 0 and 65535") config = config or ServerConfig() transport = KernelTransport(getattr(runtime, "kernel", None)) - listener = transport.open_listener(host, port, config.max_connections) + try: + listener = transport.open_listener(host, port, config.max_connections) + except _TransportAcquisitionFailure as failure: + raise ServerStartupError( + failure.primary_error, failure.transaction + ) from failure.primary_error try: wakeup = transport.create_wakeup_channel() - except BaseException: - transport.close_safely(listener) + except _TransportAcquisitionFailure as failure: + try: + transport.close(listener) + except BaseException as cleanup_error: + failure.transaction.add( + "listener", lambda: transport.close(listener), cleanup_error + ) + raise ServerStartupError( + failure.primary_error, failure.transaction + ) from failure.primary_error + except BaseException as primary_error: + try: + transport.close(listener) + except BaseException as cleanup_error: + transaction = _CleanupTransaction() + transaction.add( + "listener", lambda: transport.close(listener), cleanup_error + ) + raise ServerStartupError( + primary_error, transaction + ) from primary_error raise handle = ServerHandle(runtime, transport, listener, wakeup, config) tasks: tuple[Any, ...] = () @@ -105,8 +133,67 @@ def serve( ) tasks = (listener_task, close_task) runtime.fork(list(tasks)) - except BaseException: - handle._abort_startup(tasks) + except BaseException as primary_error: + task_cleanup_failures = handle._abort_startup(tasks) + if task_cleanup_failures or not handle.finished: + transaction = _CleanupTransaction() + errors = { + name: error for name, error in handle._cleanup_errors.items() + } + cancel_task = getattr(runtime, "cancel_task", None) + for index, (task, cleanup_error) in enumerate( + task_cleanup_failures + ): + + def retry_task_cleanup(task: Any = task) -> None: + if callable(cancel_task): + cancel_task(task) + return + task_cancel = getattr(task, "cancel", None) + if not callable(task_cancel): + raise RuntimeError( + "runtime cannot cancel a startup task" + ) + task_cancel() + + transaction.add( + "task:{}".format(index), + retry_task_cleanup, + cleanup_error, + ) + if wakeup is not None and not wakeup.closed: + + def retry_wakeup_cleanup() -> None: + wakeup.close() + handle._cleanup_errors.pop("wakeup", None) + handle._update_finished() + + transaction.add( + "wakeup", + retry_wakeup_cleanup, + errors.get("wakeup"), + ) + if not listener.closed: + + def retry_listener_cleanup() -> None: + transport.close(listener) + handle._cleanup_errors.pop("listener", None) + handle._update_finished() + + transaction.add( + "listener", + retry_listener_cleanup, + errors.get("listener"), + ) + if transaction.complete: + transaction.add( + "server", + lambda: handle._finish_close(), + RuntimeError("server startup cleanup is incomplete"), + ) + raise ServerStartupError( + primary_error, transaction + ) from primary_error raise return handle @@ -132,8 +219,18 @@ async def dispatch(self, request: Request) -> Response: async def _accept_loop(self, task: Any, handle: ServerHandle) -> None: accepted_in_batch = 0 while not handle.closed: + if handle.owned_connection_count >= handle._config.max_connections: + await task.yield_now() + continue try: accepted = await handle._transport.accept(task, handle._listener) + except _TransportAcquisitionFailure as failure: + assert failure.handle is not None + handle._accepted_setup_failed( + failure.primary_error, failure.handle, task + ) + failure.transaction.transfer() + raise failure.primary_error except Exception as exc: if handle.closed: return @@ -141,8 +238,11 @@ async def _accept_loop(self, task: Any, handle: ServerHandle) -> None: raise client = accepted.stream accepted_in_batch += 1 - if handle.closed or len(handle._connections) >= handle._config.max_connections: - handle._close_or_retain(client) + if handle.closed: + if not handle._close_or_retain(client, task): + raise client.close_error or RuntimeError( + "kernel connection close failed" + ) else: from SmallPackage import SmallTask @@ -157,7 +257,7 @@ async def _accept_loop(self, task: Any, handle: ServerHandle) -> None: handle._connections[id(client)] = (client, connection_task) runtime = handle._runtime runtime.fork(connection_task) - except Exception: + except BaseException as registration_error: handle._connections.pop(id(client), None) if connection_task is not None: cancel_task = getattr(handle._runtime, "cancel_task", None) @@ -166,7 +266,12 @@ async def _accept_loop(self, task: Any, handle: ServerHandle) -> None: cancel_task(connection_task) except Exception: pass - handle._close_or_retain(client) + if not handle._close_or_retain( + client, task, registration_error + ): + raise registration_error + if not isinstance(registration_error, Exception): + raise if accepted_in_batch >= handle._config.accept_batch_size: accepted_in_batch = 0 await task.yield_now() @@ -187,6 +292,7 @@ async def _connection_loop( handle._config.max_header_count, handle._config.max_body_bytes, ) + primary_error: BaseException | None = None try: while not handle.closed: try: @@ -212,8 +318,11 @@ async def _connection_loop( response = Response.text("internal server error", status=500) await self._send_response(task, handle, client, response) return + except BaseException as exc: + primary_error = exc + raise finally: - handle._connection_finished(task, client) + handle._connection_finished(task, client, primary_error) async def _send_response( self, diff --git a/smallserver/errors.py b/smallserver/errors.py index acfe810..387abcf 100644 --- a/smallserver/errors.py +++ b/smallserver/errors.py @@ -1,7 +1,93 @@ -"""Framework-owned HTTP errors.""" +"""Framework-owned HTTP and lifecycle errors.""" from __future__ import annotations +from collections.abc import Callable + + +class _CleanupTransaction: + """Own cleanup actions until each one succeeds.""" + + def __init__(self) -> None: + self._actions: dict[str, Callable[[], None]] = {} + self._errors: dict[str, BaseException] = {} + + def add( + self, + name: str, + action: Callable[[], None], + error: BaseException | None = None, + ) -> None: + key = name + suffix = 2 + while key in self._actions: + key = "{}:{}".format(name, suffix) + suffix += 1 + self._actions[key] = action + if error is not None: + self._errors[key] = error + + def retry(self) -> tuple[BaseException, ...]: + for name, action in tuple(self._actions.items()): + try: + action() + except BaseException as exc: + self._errors[name] = exc + else: + self._actions.pop(name, None) + self._errors.pop(name, None) + return self.errors + + def transfer(self) -> None: + """Drop actions after ownership moves to another framework object.""" + self._actions.clear() + self._errors.clear() + + @property + def errors(self) -> tuple[BaseException, ...]: + return tuple(self._errors.values()) + + @property + def complete(self) -> bool: + return not self._actions + + +class ServerStartupError(RuntimeError): + """Startup failed while framework-owned resources still need cleanup. + + The exception retains ownership without exposing kernel handles. Call + :meth:`retry_cleanup` until it returns ``True``; successful cleanup is + idempotent. + """ + + def __init__( + self, + primary_error: BaseException, + transaction: _CleanupTransaction, + ) -> None: + self.primary_error = primary_error + self._transaction = transaction + super().__init__( + "SmallServer startup failed and resource cleanup is incomplete" + ) + + @property + def cleanup_errors(self) -> tuple[BaseException, ...]: + return self._transaction.errors + + @property + def cleanup_complete(self) -> bool: + return self._transaction.complete + + def retry_cleanup(self) -> bool: + """Retry every resource still owned by the failed startup.""" + self._transaction.retry() + return self._transaction.complete + + def finalize(self) -> bool: + """Alias for :meth:`retry_cleanup`.""" + return self.retry_cleanup() + class HTTPError(Exception): """An expected HTTP response raised by framework or application code.""" diff --git a/smallserver/server.py b/smallserver/server.py index ea5dab1..c8da1f8 100644 --- a/smallserver/server.py +++ b/smallserver/server.py @@ -170,6 +170,11 @@ def cleanup_errors(self) -> tuple[BaseException, ...]: """Latest close failures for resources still owned by this server.""" return tuple(self._cleanup_errors.values()) + @property + def owned_connection_count(self) -> int: + """Connections still owned, including streams awaiting close retry.""" + return len(self._connections) + len(self._closing_connections) + def close(self) -> None: """Request external shutdown through a kernel wakeup channel.""" if self._finished: @@ -204,7 +209,8 @@ async def close_from_task(self, task: Any) -> None: def _listener_failed(self, exc: BaseException, task: Any) -> None: """Record a fatal accept failure and make shutdown observable.""" - self._failure = exc + if self._failure is None: + self._failure = exc self._close_requested = True if self._wakeup is not None: try: @@ -248,7 +254,7 @@ def _finish_close(self, current_task: Any = None) -> None: except BaseException: pass self._connections.pop(identity, None) - self._close_or_retain(connection) + self._close_or_retain(connection, current_task) if ( self._listener_task is not None and self._listener_task is not current_task @@ -268,20 +274,67 @@ def _finish_close(self, current_task: Any = None) -> None: self._cleanup_errors.pop("listener", None) self._update_finished() - def _close_or_retain(self, connection: TransportHandle) -> None: + def _close_or_retain( + self, + connection: TransportHandle, + task: Any = None, + primary_error: BaseException | None = None, + ) -> bool: + """Close a connection or retain it and make the close failure fatal.""" identity = id(connection) if self._transport.close_safely(connection): self._closing_connections.pop(identity, None) self._cleanup_errors.pop("connection:{}".format(identity), None) + return True + 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 _connection_close_failed( + self, + error: BaseException, + task: Any = None, + primary_error: BaseException | None = None, + ) -> None: + if self._failure is None: + self._failure = primary_error or error + self._close_requested = True + if self._finalization_attempted: + return + if self._wakeup is not None and not self._notification_sent: + try: + self._wakeup.notify() + except BaseException: + self._finish_close(current_task=task) + return + self._notification_sent = True return + if self._wakeup is None: + self._finish_close(current_task=task) + + def _accepted_setup_failed( + self, primary_error: BaseException, connection: TransportHandle, task: Any + ) -> None: + """Take ownership of an accepted stream whose configuration rollback failed.""" + identity = id(connection) self._closing_connections[identity] = connection error = connection.close_error or RuntimeError("kernel connection close failed") self._cleanup_errors["connection:{}".format(identity)] = error + if self._failure is None: + self._failure = primary_error + self._connection_close_failed(error, task, primary_error) - def _connection_finished(self, task: Any, connection: TransportHandle) -> None: + def _connection_finished( + self, + task: Any, + connection: TransportHandle, + primary_error: BaseException | None = None, + ) -> None: """Release a completed connection without losing failed-close ownership.""" self._connections.pop(id(connection), None) - self._close_or_retain(connection) + self._close_or_retain(connection, task, primary_error) self._update_finished() def _update_finished(self) -> None: @@ -296,14 +349,25 @@ def _update_finished(self) -> None: if self._finished: self._cleanup_errors.clear() - def _abort_startup(self, tasks: tuple[Any, ...]) -> None: + def _abort_startup( + self, tasks: tuple[Any, ...] + ) -> tuple[tuple[Any, BaseException], ...]: """Release bound resources after task registration fails.""" self._close_requested = True + failures: list[tuple[Any, BaseException]] = [] cancel_task = getattr(self._runtime, "cancel_task", None) - if callable(cancel_task): - for task in reversed(tasks): - try: + for task in reversed(tasks): + try: + if callable(cancel_task): cancel_task(task) - except BaseException: - pass + else: + task_cancel = getattr(task, "cancel", None) + if not callable(task_cancel): + raise RuntimeError( + "runtime cannot cancel a startup task" + ) + task_cancel() + except BaseException as exc: + failures.append((task, exc)) self._finish_close() + return tuple(failures) diff --git a/tests/test_kernel_transport.py b/tests/test_kernel_transport.py index 65f0c60..564383e 100644 --- a/tests/test_kernel_transport.py +++ b/tests/test_kernel_transport.py @@ -63,6 +63,26 @@ def test_incomplete_contract_fails_before_address_resolution(self) -> None: [("supports_tcp_server",), ("supports_wakeup_channel",)], ) + def test_wakeup_validator_is_mandatory_before_address_resolution(self) -> None: + for validator in (None, 42): + with self.subTest(validator=validator): + kernel = FakeKernel() + kernel.validate_io_wait_object = validator # type: ignore[assignment] + with self.assertRaisesRegex(TypeError, "validate_io_wait_object"): + KernelTransport(kernel) + self.assertEqual( + kernel.calls, + [("supports_tcp_server",), ("supports_wakeup_channel",)], + ) + + def test_wakeup_validator_rejects_invalid_object(self) -> None: + kernel = FakeKernel() + kernel.invalid_wait_objects.add(id(kernel.wakeup.wait_object)) + transport = KernelTransport(kernel) + with self.assertRaisesRegex(ValueError, "invalid wait object"): + transport.create_wakeup_channel() + self.assertEqual(kernel.wakeup.close_calls, 1) + def test_listener_uses_one_opaque_address_record_and_rolls_back_failure(self) -> None: kernel = FakeKernel() kernel.fail_operation = "listen" @@ -82,11 +102,11 @@ class FatalSetup(BaseException): kernel = FakeKernel() primary = FatalSetup("setup interrupted") kernel.operation_errors["listen"] = primary - kernel.close_failures[id(kernel.listener)] = 1 transport = KernelTransport(kernel) with self.assertRaises(FatalSetup) as raised: transport.open_listener("127.0.0.1", 0, 1) self.assertIs(raised.exception, primary) + self.assertEqual(kernel.closed, [kernel.listener]) def test_accept_and_stream_operations_honor_both_retry_directions(self) -> None: kernel = FakeKernel() @@ -209,19 +229,92 @@ def resume_task(self, task) -> None: occupied = TransportHandle(OpaqueHandle("occupied")) handle._connections[id(occupied)] = (occupied, object()) clients = [OpaqueHandle("overflow-{}".format(index)) for index in range(4)] - kernel.accept_results = [ - *((client, None) for client in clients), - RuntimeError("listener failed"), - ] + kernel.accept_results = [*((client, None) for client in clients)] + + class CapacityTask(FakeTask): + async def yield_now(self) -> None: + await super().yield_now() + if self.yields == 3: + raise RuntimeError("stop capacity probe") + + task = CapacityTask() + + with self.assertRaisesRegex(RuntimeError, "stop capacity probe"): + run_immediate(SmallServer()._accept_loop(task, handle)) + + self.assertEqual(task.yields, 3) + self.assertFalse(any(call[0] == "socket_accept" for call in kernel.calls)) + self.assertEqual(handle.owned_connection_count, 1) + self.assertEqual(kernel.closed, []) + run_immediate(SmallServer()._close_watcher(FakeTask(), handle)) + self.assertEqual(kernel.closed, [occupied.raw, listener.raw]) + + def test_persistent_rejected_close_failure_is_fatal_and_bounded(self) -> None: + class Runtime: + def fork(self, task) -> None: + raise RuntimeError("task capacity") + + def cancel_task(self, task) -> None: + task.cancel() + + def resume_task(self, task) -> None: + pass + + kernel = FakeKernel() + transport = KernelTransport(kernel) + listener = transport.open_listener("127.0.0.1", 0, 2) + handle = ServerHandle( + Runtime(), + transport, + listener, + transport.create_wakeup_channel(), + ServerConfig(max_connections=2, accept_batch_size=1), + ) + clients = [OpaqueHandle("attacker-{}".format(index)) for index in range(20)] + kernel.accept_results = [*((client, None) for client in clients)] + kernel.close_failures[id(clients[0])] = 100 task = FakeTask() - with self.assertRaisesRegex(RuntimeError, "listener failed"): + with self.assertRaisesRegex(RuntimeError, "task capacity"): run_immediate(SmallServer()._accept_loop(task, handle)) - self.assertEqual(task.yields, 2) - self.assertEqual(kernel.closed, clients) + accepts = [call for call in kernel.calls if call[0] == "socket_accept"] + self.assertEqual(len(accepts), 1) + self.assertTrue(handle.closed) + self.assertEqual(str(handle.failure), "task capacity") + self.assertEqual(str(handle.cleanup_errors[0]), "close failed") + self.assertEqual(handle.owned_connection_count, 1) + self.assertLessEqual( + handle.owned_connection_count, handle._config.max_connections + ) + + def test_accepted_configuration_close_failure_transfers_to_server(self) -> None: + class Runtime: + def resume_task(self, task) -> None: + pass + + kernel = FakeKernel() + transport = KernelTransport(kernel) + listener = transport.open_listener("127.0.0.1", 0, 1) + handle = ServerHandle( + Runtime(), transport, listener, transport.create_wakeup_channel(), ServerConfig() + ) + client = OpaqueHandle("unconfigured") + kernel.accept_results = [(client, None)] + kernel.operation_errors["setblocking"] = RuntimeError("configure failed") + kernel.close_failures[id(client)] = 2 + + with self.assertRaisesRegex(RuntimeError, "configure failed"): + run_immediate(SmallServer()._accept_loop(FakeTask(), handle)) + + self.assertTrue(handle.closed) + self.assertEqual(handle.owned_connection_count, 1) + self.assertEqual(str(handle.failure), "configure failed") run_immediate(SmallServer()._close_watcher(FakeTask(), handle)) - self.assertEqual(kernel.closed, clients + [occupied.raw, listener.raw]) + self.assertFalse(handle.finished) + handle._finish_close() + self.assertTrue(handle.finished) + self.assertCountEqual(kernel.closed, [client, listener.raw]) def test_server_handle_signals_and_releases_each_resource_once(self) -> None: class Runtime: @@ -305,6 +398,8 @@ def resume_task(self, task) -> None: self.assertFalse(owned_client.closed) self.assertIn(id(owned_client), handle._closing_connections) self.assertEqual(len(handle.cleanup_errors), 1) + self.assertTrue(handle.closed) + self.assertIs(handle.failure, owned_client.close_error) handle.close() run_immediate(SmallServer()._close_watcher(FakeTask(), handle)) self.assertTrue(owned_client.closed) @@ -337,6 +432,7 @@ def resume_task(self, task) -> None: self.assertIs(raised.exception, primary) self.assertIn(id(owned_client), handle._closing_connections) self.assertIs(handle.cleanup_errors[0], owned_client.close_error) + self.assertIs(handle.failure, primary) def test_finalization_retries_listener_and_wakeup_close_failures(self) -> None: class Runtime: diff --git a/tests/test_server.py b/tests/test_server.py index c1e0c88..4705312 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -1,7 +1,7 @@ import unittest from unittest.mock import patch -from smallserver import SmallServer +from smallserver import ServerStartupError, SmallServer from smallserver.server import HTTPParseError, HTTPRequestParser, ServerConfig from tests.kernel_fakes import FakeKernel @@ -54,7 +54,7 @@ def fork(self, tasks) -> None: def cancel_task(self, task) -> None: self.cancelled += 1 - task.coro.close() + task.cancel() def resume_task(self, task) -> None: pass @@ -97,3 +97,107 @@ def construct_task(*args, **kwargs): self.assertEqual(len(runtime.cancelled), 1) self.assertEqual([handle.name for handle in runtime.kernel.closed], ["listener"]) self.assertEqual(runtime.kernel.wakeup.close_calls, 1) + + def test_listener_setup_failure_retains_owner_until_retry_succeeds(self) -> None: + class Runtime: + def __init__(self) -> None: + self.kernel = FakeKernel() + + runtime = Runtime() + primary = RuntimeError("listen setup failed") + runtime.kernel.operation_errors["listen"] = primary + runtime.kernel.close_failures[id(runtime.kernel.listener)] = 2 + + with self.assertRaises(ServerStartupError) as raised: + SmallServer().serve(runtime) + + error = raised.exception + self.assertIs(error.primary_error, primary) + self.assertEqual(len(error.cleanup_errors), 1) + self.assertFalse(error.retry_cleanup()) + self.assertTrue(error.retry_cleanup()) + self.assertTrue(error.retry_cleanup()) + self.assertTrue(error.cleanup_complete) + self.assertEqual(runtime.kernel.closed, [runtime.kernel.listener]) + + def test_wakeup_failure_retains_owner_until_retry_succeeds(self) -> None: + class Runtime: + def __init__(self) -> None: + self.kernel = FakeKernel() + + runtime = Runtime() + runtime.kernel.invalid_wait_objects.add(id(runtime.kernel.wakeup.wait_object)) + runtime.kernel.wakeup.close_failures = 2 + + with self.assertRaises(ServerStartupError) as raised: + SmallServer().serve(runtime) + + error = raised.exception + self.assertIsInstance(error.primary_error, ValueError) + self.assertEqual(runtime.kernel.closed, [runtime.kernel.listener]) + self.assertFalse(error.finalize()) + self.assertTrue(error.finalize()) + self.assertEqual(runtime.kernel.wakeup.close_calls, 3) + + def test_task_registration_failure_retains_all_server_resources(self) -> None: + class Runtime: + def __init__(self) -> None: + self.kernel = FakeKernel() + self.cancelled = [] + + def fork(self, tasks) -> None: + raise RuntimeError("no task capacity") + + def cancel_task(self, task) -> None: + self.cancelled.append(task) + task.cancel() + + def resume_task(self, task) -> None: + pass + + runtime = Runtime() + runtime.kernel.close_failures[id(runtime.kernel.listener)] = 2 + runtime.kernel.wakeup.close_failures = 2 + + with self.assertRaises(ServerStartupError) as raised: + SmallServer().serve(runtime) + + error = raised.exception + self.assertEqual(str(error.primary_error), "no task capacity") + self.assertEqual(len(error.cleanup_errors), 2) + self.assertFalse(error.retry_cleanup()) + self.assertTrue(error.retry_cleanup()) + self.assertEqual(runtime.kernel.closed, [runtime.kernel.listener]) + self.assertEqual(runtime.kernel.wakeup.close_calls, 3) + + def test_task_cancellation_failure_is_owned_until_retry(self) -> None: + class Runtime: + def __init__(self) -> None: + self.kernel = FakeKernel() + self.cancel_attempts: dict[int, int] = {} + + def fork(self, tasks) -> None: + raise RuntimeError("registration failed") + + def cancel_task(self, task) -> None: + attempts = self.cancel_attempts.get(id(task), 0) + 1 + self.cancel_attempts[id(task)] = attempts + if attempts <= 2: + raise RuntimeError("cancel failed") + task.cancel() + + def resume_task(self, task) -> None: + pass + + runtime = Runtime() + with self.assertRaises(ServerStartupError) as raised: + SmallServer().serve(runtime) + + error = raised.exception + self.assertEqual(str(error.primary_error), "registration failed") + self.assertEqual(len(error.cleanup_errors), 2) + self.assertFalse(error.retry_cleanup()) + self.assertTrue(error.retry_cleanup()) + self.assertTrue(error.cleanup_complete) + self.assertEqual(runtime.kernel.closed, [runtime.kernel.listener]) + self.assertEqual(runtime.kernel.wakeup.close_calls, 1) From d06dc14a6f7956b78d74862412353259a817ef19 Mon Sep 17 00:00:00 2001 From: MikiEEE <35975462+MikiEEE@users.noreply.github.com> Date: Sat, 22 Aug 2026 00:49:32 -0500 Subject: [PATCH 10/53] fix: make capacity and rollback lifecycle safe --- README.md | 13 ++- smallserver/app.py | 57 +++++----- smallserver/errors.py | 77 ++++++++++---- smallserver/server.py | 58 +++++++++- tests/test_kernel_transport.py | 188 ++++++++++++++++++++++++++++++--- tests/test_server.py | 98 +++++++++++++++++ 6 files changed, 422 insertions(+), 69 deletions(-) diff --git a/README.md b/README.md index 537c093..cdef33f 100644 --- a/README.md +++ b/README.md @@ -82,13 +82,18 @@ 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`. +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 -cooperatively yields without accepting another connection. Any connection -close failure is fatal and stops further acceptance while retaining the stream -for an explicit shutdown-cleanup retry. +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. diff --git a/smallserver/app.py b/smallserver/app.py index d82018a..bd5afbc 100644 --- a/smallserver/app.py +++ b/smallserver/app.py @@ -4,7 +4,7 @@ import inspect from collections.abc import Awaitable, Callable, Iterable -from typing import Any +from typing import Any, NoReturn from ._transport import ( KernelTransport, @@ -19,6 +19,15 @@ _METHODS = frozenset({"GET", "POST", "PUT", "PATCH", "DELETE"}) +def _raise_startup_cleanup( + primary_error: BaseException, transaction: _CleanupTransaction +) -> NoReturn: + cleanup_error = ServerStartupError(primary_error, transaction) + if isinstance(primary_error, (KeyboardInterrupt, SystemExit)): + raise primary_error from cleanup_error + raise cleanup_error from primary_error + + class SmallServer: """Register static HTTP routes and dispatch requests to async handlers.""" @@ -86,9 +95,7 @@ def serve( try: listener = transport.open_listener(host, port, config.max_connections) except _TransportAcquisitionFailure as failure: - raise ServerStartupError( - failure.primary_error, failure.transaction - ) from failure.primary_error + _raise_startup_cleanup(failure.primary_error, failure.transaction) try: wakeup = transport.create_wakeup_channel() except _TransportAcquisitionFailure as failure: @@ -98,9 +105,7 @@ def serve( failure.transaction.add( "listener", lambda: transport.close(listener), cleanup_error ) - raise ServerStartupError( - failure.primary_error, failure.transaction - ) from failure.primary_error + _raise_startup_cleanup(failure.primary_error, failure.transaction) except BaseException as primary_error: try: transport.close(listener) @@ -109,9 +114,7 @@ def serve( transaction.add( "listener", lambda: transport.close(listener), cleanup_error ) - raise ServerStartupError( - primary_error, transaction - ) from primary_error + _raise_startup_cleanup(primary_error, transaction) raise handle = ServerHandle(runtime, transport, listener, wakeup, config) tasks: tuple[Any, ...] = () @@ -191,9 +194,7 @@ def retry_listener_cleanup() -> None: lambda: handle._finish_close(), RuntimeError("server startup cleanup is incomplete"), ) - raise ServerStartupError( - primary_error, transaction - ) from primary_error + _raise_startup_cleanup(primary_error, transaction) raise return handle @@ -220,7 +221,7 @@ async def _accept_loop(self, task: Any, handle: ServerHandle) -> None: accepted_in_batch = 0 while not handle.closed: if handle.owned_connection_count >= handle._config.max_connections: - await task.yield_now() + await handle._wait_for_capacity(task) continue try: accepted = await handle._transport.accept(task, handle._listener) @@ -231,9 +232,7 @@ async def _accept_loop(self, task: Any, handle: ServerHandle) -> None: ) failure.transaction.transfer() raise failure.primary_error - except Exception as exc: - if handle.closed: - return + except BaseException as exc: handle._listener_failed(exc, task) raise client = accepted.stream @@ -258,20 +257,16 @@ async def _accept_loop(self, task: Any, handle: ServerHandle) -> None: runtime = handle._runtime runtime.fork(connection_task) except BaseException as registration_error: - handle._connections.pop(id(client), None) - if connection_task is not None: - cancel_task = getattr(handle._runtime, "cancel_task", None) - if callable(cancel_task): - try: - cancel_task(connection_task) - except Exception: - pass - if not handle._close_or_retain( - client, task, registration_error - ): - raise registration_error - if not isinstance(registration_error, Exception): - raise + try: + if connection_task is not None: + handle._cancel_or_retain_task(connection_task) + finally: + handle._connections.pop(id(client), None) + handle._close_or_retain( + client, task, registration_error + ) + handle._listener_failed(registration_error, task) + raise if accepted_in_batch >= handle._config.accept_batch_size: accepted_in_batch = 0 await task.yield_now() diff --git a/smallserver/errors.py b/smallserver/errors.py index 387abcf..ba41719 100644 --- a/smallserver/errors.py +++ b/smallserver/errors.py @@ -3,6 +3,20 @@ from __future__ import annotations from collections.abc import Callable +from typing import Any + +try: + from _thread import allocate_lock +except ImportError: # pragma: no cover - runtimes without threads need no lock + allocate_lock = None # type: ignore[assignment] + + +class _NoThreadLock: + def __enter__(self) -> None: + return None + + def __exit__(self, *args: object) -> None: + return None class _CleanupTransaction: @@ -11,6 +25,7 @@ class _CleanupTransaction: def __init__(self) -> None: self._actions: dict[str, Callable[[], None]] = {} self._errors: dict[str, BaseException] = {} + self._lock: Any = allocate_lock() if allocate_lock is not None else _NoThreadLock() def add( self, @@ -18,38 +33,43 @@ def add( action: Callable[[], None], error: BaseException | None = None, ) -> None: - key = name - suffix = 2 - while key in self._actions: - key = "{}:{}".format(name, suffix) - suffix += 1 - self._actions[key] = action - if error is not None: - self._errors[key] = error + with self._lock: + key = name + suffix = 2 + while key in self._actions: + key = "{}:{}".format(name, suffix) + suffix += 1 + self._actions[key] = action + if error is not None: + self._errors[key] = error def retry(self) -> tuple[BaseException, ...]: - for name, action in tuple(self._actions.items()): - try: - action() - except BaseException as exc: - self._errors[name] = exc - else: - self._actions.pop(name, None) - self._errors.pop(name, None) - return self.errors + with self._lock: + for name, action in tuple(self._actions.items()): + try: + action() + except BaseException as exc: + self._errors[name] = exc + else: + self._actions.pop(name, None) + self._errors.pop(name, None) + return tuple(self._errors.values()) def transfer(self) -> None: """Drop actions after ownership moves to another framework object.""" - self._actions.clear() - self._errors.clear() + with self._lock: + self._actions.clear() + self._errors.clear() @property def errors(self) -> tuple[BaseException, ...]: - return tuple(self._errors.values()) + with self._lock: + return tuple(self._errors.values()) @property def complete(self) -> bool: - return not self._actions + with self._lock: + return not self._actions class ServerStartupError(RuntimeError): @@ -88,6 +108,21 @@ def finalize(self) -> bool: """Alias for :meth:`retry_cleanup`.""" return self.retry_cleanup() + def __del__(self) -> None: + try: + if self.cleanup_complete or self.retry_cleanup(): + return + import warnings + + warnings.warn( + "abandoned ServerStartupError still owns resources after cleanup retry", + ResourceWarning, + stacklevel=2, + ) + except BaseException: + # Destructors must never interfere with interpreter shutdown. + return + class HTTPError(Exception): """An expected HTTP response raised by framework or application code.""" diff --git a/smallserver/server.py b/smallserver/server.py index c8da1f8..fb5be89 100644 --- a/smallserver/server.py +++ b/smallserver/server.py @@ -119,6 +119,8 @@ def __post_init__(self) -> None: class ServerHandle: """A bound listener and its cooperative shutdown signal.""" + _CAPACITY_SIGNAL = 31 + def __init__( self, runtime: Any, @@ -142,6 +144,8 @@ def __init__( self._listener_resumed = False self._connections: dict[int, tuple[TransportHandle, Any]] = {} self._closing_connections: dict[int, TransportHandle] = {} + self._pending_task_cancellations: dict[int, Any] = {} + self._capacity_waiting = False @property def address(self) -> tuple[str, int]: @@ -175,6 +179,29 @@ def owned_connection_count(self) -> int: """Connections still owned, including streams awaiting close retry.""" return len(self._connections) + len(self._closing_connections) + async def _wait_for_capacity(self, task: Any) -> None: + """Block the listener on a scheduler-native signal until capacity changes.""" + self._capacity_waiting = True + try: + await task.wait_signal(self._CAPACITY_SIGNAL) + finally: + self._capacity_waiting = False + + def _notify_capacity_released(self, previous_count: int) -> None: + if ( + not self._capacity_waiting + or previous_count < self._config.max_connections + or self.owned_connection_count >= self._config.max_connections + or self._listener_task is None + ): + return + accept_signal = getattr(self._listener_task, "acceptSignal", None) + try: + if not callable(accept_signal) or accept_signal(self._CAPACITY_SIGNAL) != 0: + raise RuntimeError("listener capacity signal failed") + except BaseException as error: + self._listener_failed(error, getattr(self._runtime, "cursor", None)) + def close(self) -> None: """Request external shutdown through a kernel wakeup channel.""" if self._finished: @@ -212,13 +239,15 @@ def _listener_failed(self, exc: BaseException, task: Any) -> None: if self._failure is None: self._failure = exc self._close_requested = True - if self._wakeup is not None: + if self._wakeup is not None and not self._notification_sent: try: self._wakeup.notify() self._notification_sent = True return except BaseException: pass + elif self._notification_sent: + return self._finish_close(current_task=task) def _finish_close(self, current_task: Any = None) -> None: @@ -247,6 +276,11 @@ def _finish_close(self, current_task: Any = None) -> None: ) self._cleanup_errors["connection:{}".format(identity)] = error + for identity, task in list(self._pending_task_cancellations.items()): + if self._cancel_or_retain_task(task): + self._pending_task_cancellations.pop(identity, None) + self._cleanup_errors.pop("task:{}".format(identity), None) + for identity, (connection, task) in list(self._connections.items()): if task is not current_task: try: @@ -326,6 +360,25 @@ def _accepted_setup_failed( self._failure = primary_error self._connection_close_failed(error, task, primary_error) + def _cancel_or_retain_task(self, task: Any) -> bool: + identity = id(task) + cancel_task = getattr(self._runtime, "cancel_task", None) + try: + if callable(cancel_task): + cancel_task(task) + else: + task_cancel = getattr(task, "cancel", None) + if not callable(task_cancel): + raise RuntimeError("runtime cannot cancel a connection task") + task_cancel() + except BaseException as exc: + self._pending_task_cancellations[identity] = task + self._cleanup_errors["task:{}".format(identity)] = exc + return False + self._pending_task_cancellations.pop(identity, None) + self._cleanup_errors.pop("task:{}".format(identity), None) + return True + def _connection_finished( self, task: Any, @@ -333,8 +386,10 @@ def _connection_finished( primary_error: BaseException | None = None, ) -> None: """Release a completed connection without losing failed-close ownership.""" + previous_count = self.owned_connection_count self._connections.pop(id(connection), None) self._close_or_retain(connection, task, primary_error) + self._notify_capacity_released(previous_count) self._update_finished() def _update_finished(self) -> None: @@ -345,6 +400,7 @@ def _update_finished(self) -> None: and self._listener.closed and not self._connections and not self._closing_connections + and not self._pending_task_cancellations ) if self._finished: self._cleanup_errors.clear() diff --git a/tests/test_kernel_transport.py b/tests/test_kernel_transport.py index 564383e..a05b1fb 100644 --- a/tests/test_kernel_transport.py +++ b/tests/test_kernel_transport.py @@ -39,6 +39,9 @@ async def wait_writable(self, handle: object) -> None: async def yield_now(self) -> None: self.yields += 1 + async def wait_signal(self, signal: int) -> None: + self.waits.append(("signal", signal)) + class KernelTransportTests(unittest.TestCase): def test_capability_failure_happens_before_address_resolution(self) -> None: @@ -200,18 +203,101 @@ def cancel_task(self, task) -> None: task = FakeTask() handle._config = ServerConfig(accept_batch_size=2) - with self.assertRaisesRegex(RuntimeError, "listener failed"): + with self.assertRaisesRegex(RuntimeError, "capacity"): run_immediate(SmallServer()._accept_loop(task, handle)) - self.assertEqual(len(handle._runtime.cancelled), 4) - self.assertEqual(kernel.closed, clients) + self.assertEqual(len(handle._runtime.cancelled), 1) + self.assertEqual(kernel.closed, clients[:1]) self.assertEqual(handle._connections, {}) - self.assertEqual(task.yields, 2) - self.assertIsInstance(handle.failure, RuntimeError) + self.assertEqual(task.yields, 0) + self.assertEqual(str(handle.failure), "capacity") + run_immediate(SmallServer()._close_watcher(FakeTask(), handle)) + self.assertEqual(kernel.closed, clients[:1] + [listener.raw]) + + def test_registration_retains_failed_task_and_stream_cleanup(self) -> None: + class CancelFailure(BaseException): + pass + + class Runtime: + def __init__(self) -> None: + self.cancel_attempts = 0 + + def fork(self, task) -> None: + raise RuntimeError("fork primary") + + def cancel_task(self, task) -> None: + self.cancel_attempts += 1 + if self.cancel_attempts <= 2: + raise CancelFailure("cancel cleanup") + task.cancel() + + def resume_task(self, task) -> None: + pass + + kernel = FakeKernel() + transport = KernelTransport(kernel) + listener = transport.open_listener("127.0.0.1", 0, 2) + runtime = Runtime() + handle = ServerHandle( + runtime, + transport, + listener, + transport.create_wakeup_channel(), + ServerConfig(max_connections=2), + ) + clients = [OpaqueHandle("first"), OpaqueHandle("must-not-accept")] + kernel.accept_results = [*((client, None) for client in clients)] + kernel.close_failures[id(clients[0])] = 2 + + with self.assertRaisesRegex(RuntimeError, "fork primary") as raised: + run_immediate(SmallServer()._accept_loop(FakeTask(), handle)) + + self.assertIs(handle.failure, raised.exception) + self.assertTrue(handle.closed) + self.assertEqual(handle.owned_connection_count, 1) + self.assertEqual(len(handle._pending_task_cancellations), 1) + self.assertEqual(len(handle.cleanup_errors), 2) + self.assertEqual( + len([call for call in kernel.calls if call[0] == "socket_accept"]), 1 + ) + run_immediate(SmallServer()._close_watcher(FakeTask(), handle)) - self.assertEqual(kernel.closed, clients + [listener.raw]) + self.assertFalse(handle.finished) + self.assertEqual(len(handle.cleanup_errors), 2) + handle._finish_close() + self.assertTrue(handle.finished) + self.assertEqual(handle.cleanup_errors, ()) + self.assertEqual(runtime.cancel_attempts, 3) + self.assertCountEqual(kernel.closed, [clients[0], listener.raw]) + + def test_accept_base_exception_is_fatal_and_re_raised_identically(self) -> None: + class FatalAccept(BaseException): + pass - def test_full_capacity_accepts_are_batched_and_yield_fairly(self) -> None: + class Runtime: + def resume_task(self, task) -> None: + pass + + kernel = FakeKernel() + transport = KernelTransport(kernel) + listener = transport.open_listener("127.0.0.1", 0, 1) + handle = ServerHandle( + Runtime(), transport, listener, transport.create_wakeup_channel(), ServerConfig() + ) + primary = FatalAccept("fatal accept") + kernel.accept_results = [primary] + + with self.assertRaises(FatalAccept) as raised: + run_immediate(SmallServer()._accept_loop(FakeTask(), handle)) + + self.assertIs(raised.exception, primary) + self.assertIs(handle.failure, primary) + self.assertTrue(handle.closed) + self.assertEqual(kernel.wakeup.notify_calls, 1) + run_immediate(SmallServer()._close_watcher(FakeTask(), handle)) + self.assertTrue(handle.finished) + + def test_full_capacity_blocks_on_scheduler_signal_without_accepting(self) -> None: class Runtime: def resume_task(self, task) -> None: pass @@ -232,23 +318,57 @@ def resume_task(self, task) -> None: kernel.accept_results = [*((client, None) for client in clients)] class CapacityTask(FakeTask): - async def yield_now(self) -> None: - await super().yield_now() - if self.yields == 3: - raise RuntimeError("stop capacity probe") + async def wait_signal(self, signal: int) -> None: + await super().wait_signal(signal) + raise RuntimeError("stop capacity probe") task = CapacityTask() with self.assertRaisesRegex(RuntimeError, "stop capacity probe"): run_immediate(SmallServer()._accept_loop(task, handle)) - self.assertEqual(task.yields, 3) + self.assertEqual(task.waits, [("signal", ServerHandle._CAPACITY_SIGNAL)]) self.assertFalse(any(call[0] == "socket_accept" for call in kernel.calls)) self.assertEqual(handle.owned_connection_count, 1) self.assertEqual(kernel.closed, []) run_immediate(SmallServer()._close_watcher(FakeTask(), handle)) self.assertEqual(kernel.closed, [occupied.raw, listener.raw]) + def test_capacity_release_signals_blocked_listener(self) -> None: + class Runtime: + def resume_task(self, task) -> None: + pass + + class ListenerTask: + def __init__(self) -> None: + self.signals = [] + + def acceptSignal(self, signal: int) -> int: + self.signals.append(signal) + return 0 + + kernel = FakeKernel() + transport = KernelTransport(kernel) + handle = ServerHandle( + Runtime(), + transport, + transport.open_listener("127.0.0.1", 0, 1), + transport.create_wakeup_channel(), + ServerConfig(max_connections=1), + ) + listener_task = ListenerTask() + handle._listener_task = listener_task + handle._capacity_waiting = True + client = TransportHandle(OpaqueHandle("capacity-holder")) + connection_task = FakeTask() + handle._connections[id(client)] = (client, connection_task) + + handle._connection_finished(connection_task, client) + + self.assertEqual(handle.owned_connection_count, 0) + self.assertEqual(listener_task.signals, [ServerHandle._CAPACITY_SIGNAL]) + self.assertEqual(kernel.closed, [client.raw]) + def test_persistent_rejected_close_failure_is_fatal_and_bounded(self) -> None: class Runtime: def fork(self, task) -> None: @@ -530,6 +650,50 @@ def resume_task(self, task) -> None: self.assertTrue(handle.closed) self.assertEqual(runtime.kernel.closed, [runtime.kernel.listener]) + def test_micropython_like_kernel_serves_and_closes_from_task(self) -> None: + class MicroRuntime: + def __init__(self) -> None: + self.kernel = FakeKernel(wakeup_supported=False) + self.cursor = FakeTask() + self.forked = [] + self.resumed = [] + + def fork(self, tasks) -> None: + self.forked.extend(tasks if isinstance(tasks, list) else [tasks]) + + def resume_task(self, task) -> None: + self.resumed.append(task) + + runtime = MicroRuntime() + app = SmallServer() + + @app.get("/micro") + async def micro(request): + return Response.text("opaque-ok") + + handle = app.serve(runtime, host="0.0.0.0", port=8080) + self.assertEqual(len(runtime.forked), 1) + self.assertIsNone(handle._wakeup) + + raw_client = OpaqueHandle("micro-client") + client = TransportHandle(raw_client) + connection_task = FakeTask() + handle._connections[id(client)] = (client, connection_task) + runtime.kernel.recv_results[id(raw_client)] = [ + b"GET /micro HTTP/1.1\r\nHost: device\r\n\r\n" + ] + + run_immediate(app._connection_loop(connection_task, handle, client)) + self.assertIn( + b"\r\n\r\nopaque-ok", bytes(runtime.kernel.sent[id(raw_client)][0]) + ) + run_immediate(handle.close_from_task(runtime.cursor)) + + self.assertTrue(handle.finished) + self.assertEqual( + runtime.kernel.closed, [raw_client, runtime.kernel.listener] + ) + def test_close_state_is_per_handle_and_failed_close_can_be_retried(self) -> None: kernel = FakeKernel() transport = KernelTransport(kernel) diff --git a/tests/test_server.py b/tests/test_server.py index 4705312..f0fc04e 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -1,7 +1,11 @@ +import gc +import threading import unittest +import warnings from unittest.mock import patch from smallserver import ServerStartupError, SmallServer +from smallserver.errors import _CleanupTransaction from smallserver.server import HTTPParseError, HTTPRequestParser, ServerConfig from tests.kernel_fakes import FakeKernel @@ -201,3 +205,97 @@ def resume_task(self, task) -> None: self.assertTrue(error.cleanup_complete) self.assertEqual(runtime.kernel.closed, [runtime.kernel.listener]) self.assertEqual(runtime.kernel.wakeup.close_calls, 1) + + def test_interrupt_identity_survives_successful_and_failed_rollback(self) -> None: + class Runtime: + def __init__(self) -> None: + self.kernel = FakeKernel() + + for interrupt in (KeyboardInterrupt("stop"), SystemExit(7)): + for close_failures in (0, 1): + with self.subTest( + interrupt=type(interrupt).__name__, + close_failures=close_failures, + ): + runtime = Runtime() + runtime.kernel.operation_errors["listen"] = interrupt + runtime.kernel.close_failures[id(runtime.kernel.listener)] = ( + close_failures + ) + with self.assertRaises(type(interrupt)) as raised: + SmallServer().serve(runtime) + self.assertIs(raised.exception, interrupt) + if close_failures: + cleanup = raised.exception.__cause__ + self.assertIsInstance(cleanup, ServerStartupError) + assert isinstance(cleanup, ServerStartupError) + self.assertIs(cleanup.primary_error, interrupt) + self.assertTrue(cleanup.retry_cleanup()) + else: + self.assertNotIsInstance( + raised.exception.__cause__, ServerStartupError + ) + + def test_abandoned_startup_error_retries_and_warns_if_incomplete(self) -> None: + transaction = _CleanupTransaction() + attempts = [] + + def fail_cleanup() -> None: + attempts.append(1) + raise RuntimeError("still owned") + + transaction.add("listener", fail_cleanup, RuntimeError("first failure")) + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter("always", ResourceWarning) + error = ServerStartupError(RuntimeError("startup"), transaction) + del error + gc.collect() + + self.assertEqual(attempts, [1]) + self.assertEqual(len(caught), 1) + self.assertIs(caught[0].category, ResourceWarning) + self.assertNotIn("listener", str(caught[0].message)) + + def test_startup_cleanup_retry_is_concurrently_idempotent(self) -> None: + transaction = _CleanupTransaction() + entered = threading.Event() + release = threading.Event() + calls = [] + + def cleanup() -> None: + calls.append(1) + entered.set() + if not release.wait(2): + raise TimeoutError("cleanup test stalled") + + transaction.add("listener", cleanup, RuntimeError("initial failure")) + error = ServerStartupError(RuntimeError("startup"), transaction) + results = [] + workers = [ + threading.Thread(target=lambda: results.append(error.retry_cleanup())) + for _ in range(2) + ] + workers[0].start() + self.assertTrue(entered.wait(1)) + workers[1].start() + release.set() + for worker in workers: + worker.join(2) + + self.assertEqual(calls, [1]) + self.assertEqual(results, [True, True]) + self.assertTrue(error.cleanup_complete) + + def test_server_startup_error_public_typing_fixture_compiles(self) -> None: + fixture = """ +from smallserver import ServerStartupError + +def finish_startup_cleanup(error: ServerStartupError) -> bool: + primary: BaseException = error.primary_error + pending: tuple[BaseException, ...] = error.cleanup_errors + return error.cleanup_complete or error.finalize() +""" + code = compile(fixture, "server_startup_error_typing.py", "exec") + namespace = {} + exec(code, namespace) + self.assertTrue(callable(namespace["finish_startup_cleanup"])) From 113e16293ba7be703aafda96a57f663317a48f03 Mon Sep 17 00:00:00 2001 From: MikiEEE <35975462+MikiEEE@users.noreply.github.com> Date: Sat, 22 Aug 2026 00:53:03 -0500 Subject: [PATCH 11/53] fix: treat shutdown accept invalidation as normal --- smallserver/app.py | 2 ++ tests/test_kernel_transport.py | 29 +++++++++++++++++++++++++++++ tests/test_server_runtime.py | 2 ++ 3 files changed, 33 insertions(+) diff --git a/smallserver/app.py b/smallserver/app.py index bd5afbc..9a912b7 100644 --- a/smallserver/app.py +++ b/smallserver/app.py @@ -233,6 +233,8 @@ async def _accept_loop(self, task: Any, handle: ServerHandle) -> None: failure.transaction.transfer() raise failure.primary_error except BaseException as exc: + if handle.closed: + return handle._listener_failed(exc, task) raise client = accepted.stream diff --git a/tests/test_kernel_transport.py b/tests/test_kernel_transport.py index a05b1fb..5ddacf2 100644 --- a/tests/test_kernel_transport.py +++ b/tests/test_kernel_transport.py @@ -297,6 +297,35 @@ def resume_task(self, task) -> None: run_immediate(SmallServer()._close_watcher(FakeTask(), handle)) self.assertTrue(handle.finished) + def test_accept_exception_after_close_is_normal_listener_exit(self) -> None: + class FatalAccept(BaseException): + pass + + class Runtime: + def resume_task(self, task) -> None: + pass + + kernel = FakeKernel() + transport = KernelTransport(kernel) + listener = transport.open_listener("127.0.0.1", 0, 1) + handle = ServerHandle( + Runtime(), transport, listener, transport.create_wakeup_channel(), ServerConfig() + ) + shutdown_exception = FatalAccept("accept invalidated by shutdown") + + def close_then_fail(raw_listener): + handle.close() + raise shutdown_exception + + kernel.socket_accept = close_then_fail # type: ignore[method-assign] + + self.assertIsNone(run_immediate(SmallServer()._accept_loop(FakeTask(), handle))) + self.assertTrue(handle.closed) + self.assertIsNone(handle.failure) + self.assertEqual(kernel.wakeup.notify_calls, 1) + run_immediate(SmallServer()._close_watcher(FakeTask(), handle)) + self.assertTrue(handle.finished) + def test_full_capacity_blocks_on_scheduler_signal_without_accepting(self) -> None: class Runtime: def resume_task(self, task) -> None: diff --git a/tests/test_server_runtime.py b/tests/test_server_runtime.py index e34ef9e..f10ad28 100644 --- a/tests/test_server_runtime.py +++ b/tests/test_server_runtime.py @@ -67,6 +67,8 @@ def client() -> None: self.assertEqual(runtime.ioWriteWaiters, {}) self.assertIsNone(runtime._io_wait_set) self.assertTrue(server._listener.closed) + self.assertIsNone(server.failure) + self.assertIsNone(server._listener_task.exception) def test_blocking_adapter_does_not_block_unrelated_connection(self) -> None: runtime = SmallOS().setKernel(Unix()) From 61e55c0f0dfab570168c856753a6ee8f075b3cb3 Mon Sep 17 00:00:00 2001 From: MikiEEE <35975462+MikiEEE@users.noreply.github.com> Date: Fri, 21 Aug 2026 23:30:23 -0500 Subject: [PATCH 12/53] feat: add managed server lifecycle --- README.md | 68 +++++++- demo.py | 49 +----- examples/manual_runtime.py | 30 ++++ smallserver/__init__.py | 20 ++- smallserver/app.py | 310 +++++++++++++++++++++++++-------- smallserver/errors.py | 25 ++- smallserver/server.py | 93 +++++++--- tests/test_kernel_transport.py | 3 + tests/test_lifecycle.py | 209 ++++++++++++++++++++++ tests/test_server.py | 21 +++ tests/test_server_runtime.py | 46 +++++ 11 files changed, 721 insertions(+), 153 deletions(-) create mode 100644 examples/manual_runtime.py create mode 100644 tests/test_lifecycle.py diff --git a/README.md b/README.md index cdef33f..4e4aa7b 100644 --- a/README.md +++ b/README.md @@ -33,22 +33,49 @@ It owns scheduling, socket readiness, and foreign execution adapters. ## Run the demo -The included demo binds an ephemeral loopback TCP port, starts the SmallOS -runtime, and uses a separate loopback client to exercise the real listener. +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. ```bash python3 -m pip install -r requirements.txt python3 demo.py ``` -It exercises POST, GET, PATCH, PUT, DELETE, and a 404 response. The static -`/tasks` path is intentional: path parameters arrive with a later milestone. +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. ## Bind a server -Create the application, bind it to a SmallOS runtime, then start that runtime. -`port=0` asks the operating system for an available port, which is useful in -tests and local tooling. +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. + +```python +from smallserver import Response, SmallServer + +app = SmallServer() + +@app.get("/health") +async def health(request): + return Response.json({"status": "ok"}) + +app.listen(host="127.0.0.1", port=8000) +``` + +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. + +## 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 @@ -61,8 +88,11 @@ app = SmallServer() async def health(request): return Response.json({"status": "ok"}) -server = app.serve(runtime, host="127.0.0.1", port=8000) -runtime.start() +server = app.listen(runtime=runtime, start=False) +try: + runtime.start() +finally: + server.finalize() ``` On a kernel with `supports_wakeup_channel() == True`, call `server.close()` @@ -98,6 +128,26 @@ 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, `server.close()` is the thread-safe shutdown +signal. After a manually started scheduler has already exited or failed, +`server.finalize()` is the idempotent owner-thread cleanup operation. + +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 diff --git a/demo.py b/demo.py index 455cca6..1a85695 100644 --- a/demo.py +++ b/demo.py @@ -1,17 +1,12 @@ -"""Run a SmallOS-backed SmallServer listener and loopback TCP client.""" +"""Run the beginner-facing SmallServer task API on localhost:8000.""" from __future__ import annotations import json -import socket -import threading - -from SmallPackage import SmallOS, Unix from smallserver import HTTPError, Request, Response, SmallServer -runtime = SmallOS().setKernel(Unix()) app = SmallServer() tasks: dict[str, dict[str, object]] = {} @@ -86,44 +81,6 @@ async def delete_task(request: Request) -> Response: return Response(status=204) -def send_request(port: int, method: str, path: str, body: object | None = None) -> bytes: - """Send one HTTP/1.1 request to the demo listener and read it to close.""" - payload = b"" if body is None else json.dumps(body).encode("utf-8") - lines = ["{} {} HTTP/1.1".format(method, path), "Host: localhost"] - if payload: - lines.extend(("Content-Type: application/json", "Content-Length: {}".format(len(payload)))) - request = ("\r\n".join(lines) + "\r\n\r\n").encode("ascii") + payload - with socket.create_connection(("127.0.0.1", port), timeout=2) as client: - client.sendall(request) - chunks = [] - while True: - chunk = client.recv(4096) - if not chunk: - return b"".join(chunks) - chunks.append(chunk) - - -def run_client(port: int, close_server) -> None: - """Exercise every implemented method from outside the SmallOS thread.""" - try: - calls = [ - ("POST", "/tasks", {"title": "Ship the first SmallServer demo"}), - ("GET", "/tasks", None), - ("PATCH", "/tasks", {"id": "1", "done": True}), - ("PUT", "/tasks", {"tasks": [{"title": "Add socket listener", "done": False}]}), - ("DELETE", "/tasks", {"id": "1"}), - ("GET", "/missing", None), - ] - for method, path, body in calls: - wire = send_request(port, method, path, body) - status_line, _, response_body = wire.partition(b"\r\n\r\n") - print("{} {} -> {} {}".format(method, path, status_line.decode(), response_body.decode())) - finally: - close_server() - - if __name__ == "__main__": - server = app.serve(runtime, host="127.0.0.1", port=0) - print("SmallServer listening on http://127.0.0.1:{}".format(server.port)) - threading.Thread(target=run_client, args=(server.port, server.close), daemon=True).start() - runtime.start() + print("SmallServer listening on http://127.0.0.1:8000") + app.listen(host="127.0.0.1", port=8000) diff --git a/examples/manual_runtime.py b/examples/manual_runtime.py new file mode 100644 index 0000000..830dc43 --- /dev/null +++ b/examples/manual_runtime.py @@ -0,0 +1,30 @@ +"""Run SmallServer with caller-owned SmallOS lifecycle control.""" + +from SmallPackage import SmallOS, Unix + +from smallserver import Response, SmallServer + + +runtime = SmallOS().setKernel(Unix()) +server = SmallServer() + + +@server.get("/health") +async def health(request): + return Response.json({"status": "ok"}) + + +if __name__ == "__main__": + # Create any execution adapters beside the runtime and close them in the + # application's own finally block. SmallServer never owns those adapters. + handle = server.listen( + host="127.0.0.1", + port=8000, + runtime=runtime, + start=False, + ) + print("SmallServer listening on http://{}:{}".format(*handle.address)) + try: + runtime.start() + finally: + handle.finalize() diff --git a/smallserver/__init__.py b/smallserver/__init__.py index bfb69c4..286312d 100644 --- a/smallserver/__init__.py +++ b/smallserver/__init__.py @@ -1,11 +1,26 @@ """SmallOS-native HTTP framework primitives.""" +from typing import TYPE_CHECKING, Any + from .app import SmallServer -from .adapters import AdapterRegistry, AdapterShutdownError, http_error_from_adapter -from .errors import HTTPError, ServerStartupError +from .errors import HTTPError, ServerConfigurationError, ServerStartupError from .http import Headers, Request, Response from .server import ServerConfig, ServerHandle +if TYPE_CHECKING: + from .adapters import AdapterRegistry, AdapterShutdownError, http_error_from_adapter + + +def __getattr__(name: str) -> Any: + """Load optional SmallOS adapter integration only when it is requested.""" + if name in {"AdapterRegistry", "AdapterShutdownError", "http_error_from_adapter"}: + from . import adapters + + value = getattr(adapters, name) + globals()[name] = value + return value + raise AttributeError("module {!r} has no attribute {!r}".format(__name__, name)) + __all__ = [ "AdapterRegistry", "AdapterShutdownError", @@ -14,6 +29,7 @@ "Request", "Response", "ServerConfig", + "ServerConfigurationError", "ServerHandle", "ServerStartupError", "SmallServer", diff --git a/smallserver/app.py b/smallserver/app.py index 9a912b7..b7e6ce3 100644 --- a/smallserver/app.py +++ b/smallserver/app.py @@ -4,14 +4,19 @@ import inspect from collections.abc import Awaitable, Callable, Iterable -from typing import Any, NoReturn +from typing import Any, Literal, NoReturn, Protocol, overload from ._transport import ( KernelTransport, TransportHandle, _TransportAcquisitionFailure, ) -from .errors import HTTPError, ServerStartupError, _CleanupTransaction +from .errors import ( + HTTPError, + ServerConfigurationError, + ServerStartupError, + _CleanupTransaction, +) from .http import Request, Response from .server import HTTPParseError, HTTPRequestParser, ServerConfig, ServerHandle @@ -20,19 +25,85 @@ def _raise_startup_cleanup( - primary_error: BaseException, transaction: _CleanupTransaction + primary_error: BaseException, + transaction: _CleanupTransaction, + on_cleanup_complete: Callable[[], None] | None = None, ) -> NoReturn: - cleanup_error = ServerStartupError(primary_error, transaction) + cleanup_error = ServerStartupError( + primary_error, transaction, on_cleanup_complete=on_cleanup_complete + ) if isinstance(primary_error, (KeyboardInterrupt, SystemExit)): raise primary_error from cleanup_error raise cleanup_error from primary_error +class _RuntimeLike(Protocol): + """SmallOS lifecycle surface used by one server invocation.""" + + kernel: object + + def fork(self, children: Any) -> Any: ... + + def start(self) -> None: ... + + def resume_task(self, task: Any) -> Any: ... + + def cancel_task(self, task: Any) -> Any: ... + + +def _default_runtime_factory() -> _RuntimeLike: + """Lazily create the supported desktop runtime for managed ``listen``.""" + try: + from SmallPackage import SmallOS, Unix + except (ImportError, AttributeError) as exc: + raise ServerConfigurationError( + "managed listen() requires SmallOS with the Unix kernel; " + "install requirements.txt or supply a configured runtime" + ) from exc + try: + return SmallOS().setKernel(Unix()) + except Exception as exc: + raise ServerConfigurationError( + "managed listen() could not create the default SmallOS Unix runtime; " + "supply a configured runtime on this platform" + ) from exc + + +class _HandleCleanupTransaction(_CleanupTransaction): + """Retry one handle finalization attempt while exposing all owned errors.""" + + def __init__(self, handle: ServerHandle) -> None: + super().__init__() + self._handle = handle + + def retry_handle_cleanup() -> None: + handle.finalize() + if not handle.finished: + error = next( + iter(handle.cleanup_errors), + RuntimeError("server finalization cleanup is incomplete"), + ) + raise error + + initial_error = next( + iter(handle.cleanup_errors), + RuntimeError("server finalization cleanup is incomplete"), + ) + self.add("server", retry_handle_cleanup, initial_error) + + @property + def errors(self) -> tuple[BaseException, ...]: + if not self._handle.finished and self._handle.cleanup_errors: + return self._handle.cleanup_errors + return super().errors + + class SmallServer: """Register static HTTP routes and dispatch requests to async handlers.""" def __init__(self) -> None: self._routes: dict[tuple[str, str], Handler] = {} + self._active_invocation: object | ServerHandle | None = None def route(self, path: str, methods: Iterable[str]) -> Callable[[Handler], Handler]: if not isinstance(path, str) or not path.startswith("/"): @@ -72,7 +143,7 @@ def delete(self, path: str) -> Callable[[Handler], Handler]: def serve( self, - runtime: Any, + runtime: _RuntimeLike, host: str = "127.0.0.1", port: int = 8000, config: ServerConfig | None = None, @@ -84,18 +155,138 @@ def serve( kernels use ``await ServerHandle.close_from_task(task)`` on the scheduler thread instead. """ + self._validate_runtime(runtime, require_start=False) + return self._bind_and_schedule(runtime, host, port, config) + + @overload + def listen( + self, + host: str = "127.0.0.1", + port: int = 8000, + config: ServerConfig | None = None, + *, + runtime: None = None, + start: Literal[True] | None = None, + ) -> ServerHandle: ... + + @overload + def listen( + self, + host: str = "127.0.0.1", + port: int = 8000, + config: ServerConfig | None = None, + *, + runtime: _RuntimeLike, + start: bool | None = None, + ) -> ServerHandle: ... + + def listen( + self, + host: str = "127.0.0.1", + port: int = 8000, + config: ServerConfig | None = None, + *, + runtime: _RuntimeLike | None = None, + start: bool | None = None, + ) -> ServerHandle: + """Bind a server and optionally run its SmallOS scheduler. + + With no runtime, this creates and owns a temporary SmallOS/Unix + runtime, blocks until shutdown, and returns the closed handle. With a + supplied runtime, scheduling without startup is the default. + """ + if start is not None and type(start) is not bool: + raise TypeError("start must be a boolean or None") + managed = runtime is None + if managed and start is False: + raise ValueError("start=False requires a caller-supplied runtime") + should_start = managed if start is None else start + if runtime is None: + runtime = _default_runtime_factory() + self._validate_runtime(runtime, require_start=should_start) + handle = self._bind_and_schedule(runtime, host, port, config) + if not should_start: + return handle + primary_error: BaseException | None = None + try: + runtime.start() + except BaseException as exc: + primary_error = exc + finally: + handle._finish_close(owner_thread=True) + if primary_error is not None: + if not handle.finished: + transaction = self._handle_cleanup_transaction(handle) + _raise_startup_cleanup(primary_error, transaction) + if managed and isinstance(primary_error, KeyboardInterrupt): + return handle + raise primary_error + return handle + + @staticmethod + def _handle_cleanup_transaction(handle: ServerHandle) -> _CleanupTransaction: + return _HandleCleanupTransaction(handle) + + def _validate_runtime(self, runtime: object, *, require_start: bool) -> None: + required = ["fork", "resume_task"] + if require_start: + required.append("start") + missing = [name for name in required if not callable(getattr(runtime, name, None))] + if missing: + raise TypeError( + "runtime is missing required operations: {}".format(", ".join(missing)) + ) + # Constructing the facade is also the pre-bind kernel capability check. + KernelTransport(getattr(runtime, "kernel", None)) + + def _bind_and_schedule( + self, + runtime: _RuntimeLike, + host: str, + port: int, + config: ServerConfig | None, + ) -> ServerHandle: + """Shared validated bind-and-schedule core for ``serve`` and ``listen``.""" from SmallPackage import SmallTask if not isinstance(host, str) or not host: raise ValueError("host must be a non-empty string") - if not isinstance(port, int) or not 0 <= port <= 65535: + if type(port) is not int or not 0 <= port <= 65535: raise ValueError("port must be an integer between 0 and 65535") - config = config or ServerConfig() - transport = KernelTransport(getattr(runtime, "kernel", None)) + if config is not None and not isinstance(config, ServerConfig): + raise TypeError("config must be a ServerConfig or None") + if self._active_invocation is not None: + raise RuntimeError("this SmallServer already has an active listener") + marker = object() + self._active_invocation = marker + + def release_marker() -> None: + if self._active_invocation is marker: + self._active_invocation = None + + def raise_acquisition_cleanup( + primary_error: BaseException, transaction: _CleanupTransaction + ) -> NoReturn: + _raise_startup_cleanup( + primary_error, + transaction, + on_cleanup_complete=release_marker, + ) + + try: + config = config or ServerConfig() + transport = KernelTransport(runtime.kernel) + except BaseException: + release_marker() + raise try: listener = transport.open_listener(host, port, config.max_connections) except _TransportAcquisitionFailure as failure: - _raise_startup_cleanup(failure.primary_error, failure.transaction) + raise_acquisition_cleanup(failure.primary_error, failure.transaction) + except BaseException: + release_marker() + raise + try: wakeup = transport.create_wakeup_channel() except _TransportAcquisitionFailure as failure: @@ -105,7 +296,7 @@ def serve( failure.transaction.add( "listener", lambda: transport.close(listener), cleanup_error ) - _raise_startup_cleanup(failure.primary_error, failure.transaction) + raise_acquisition_cleanup(failure.primary_error, failure.transaction) except BaseException as primary_error: try: transport.close(listener) @@ -114,9 +305,36 @@ def serve( transaction.add( "listener", lambda: transport.close(listener), cleanup_error ) - _raise_startup_cleanup(primary_error, transaction) + raise_acquisition_cleanup(primary_error, transaction) + release_marker() raise - handle = ServerHandle(runtime, transport, listener, wakeup, config) + + def release(completed: ServerHandle) -> None: + if self._active_invocation is completed: + self._active_invocation = None + + try: + handle = ServerHandle( + runtime, transport, listener, wakeup, config, on_finalized=release + ) + except BaseException as primary_error: + transaction = _CleanupTransaction() + if wakeup is not None: + try: + wakeup.close() + except BaseException as cleanup_error: + transaction.add("wakeup", wakeup.close, cleanup_error) + try: + transport.close(listener) + except BaseException as cleanup_error: + transaction.add( + "listener", lambda: transport.close(listener), cleanup_error + ) + if not transaction.complete: + raise_acquisition_cleanup(primary_error, transaction) + release_marker() + raise + self._active_invocation = handle tasks: tuple[Any, ...] = () try: listener_task = SmallTask( @@ -127,6 +345,7 @@ def serve( ) tasks = (listener_task,) handle._listener_task = listener_task + handle._owned_tasks.append(listener_task) if wakeup is not None: close_task = SmallTask( config.listener_priority, @@ -135,65 +354,13 @@ def serve( name="smallserver-close-watcher", ) tasks = (listener_task, close_task) + handle._close_task = close_task + handle._owned_tasks.append(close_task) runtime.fork(list(tasks)) except BaseException as primary_error: - task_cleanup_failures = handle._abort_startup(tasks) - if task_cleanup_failures or not handle.finished: - transaction = _CleanupTransaction() - errors = { - name: error for name, error in handle._cleanup_errors.items() - } - cancel_task = getattr(runtime, "cancel_task", None) - for index, (task, cleanup_error) in enumerate( - task_cleanup_failures - ): - - def retry_task_cleanup(task: Any = task) -> None: - if callable(cancel_task): - cancel_task(task) - return - task_cancel = getattr(task, "cancel", None) - if not callable(task_cancel): - raise RuntimeError( - "runtime cannot cancel a startup task" - ) - task_cancel() - - transaction.add( - "task:{}".format(index), - retry_task_cleanup, - cleanup_error, - ) - if wakeup is not None and not wakeup.closed: - - def retry_wakeup_cleanup() -> None: - wakeup.close() - handle._cleanup_errors.pop("wakeup", None) - handle._update_finished() - - transaction.add( - "wakeup", - retry_wakeup_cleanup, - errors.get("wakeup"), - ) - if not listener.closed: - - def retry_listener_cleanup() -> None: - transport.close(listener) - handle._cleanup_errors.pop("listener", None) - handle._update_finished() - - transaction.add( - "listener", - retry_listener_cleanup, - errors.get("listener"), - ) - if transaction.complete: - transaction.add( - "server", - lambda: handle._finish_close(), - RuntimeError("server startup cleanup is incomplete"), - ) + handle._abort_startup(tasks) + if not handle.finished: + transaction = self._handle_cleanup_transaction(handle) _raise_startup_cleanup(primary_error, transaction) raise return handle @@ -256,12 +423,15 @@ async def _accept_loop(self, task: Any, handle: ServerHandle) -> None: name="smallserver-connection", ) handle._connections[id(client)] = (client, connection_task) + handle._owned_tasks.append(connection_task) runtime = handle._runtime runtime.fork(connection_task) except BaseException as registration_error: try: if connection_task is not None: - handle._cancel_or_retain_task(connection_task) + if handle._cancel_or_retain_task(connection_task): + if connection_task in handle._owned_tasks: + handle._owned_tasks.remove(connection_task) finally: handle._connections.pop(id(client), None) handle._close_or_retain( diff --git a/smallserver/errors.py b/smallserver/errors.py index ba41719..a42fc84 100644 --- a/smallserver/errors.py +++ b/smallserver/errors.py @@ -84,9 +84,15 @@ def __init__( self, primary_error: BaseException, transaction: _CleanupTransaction, + on_cleanup_complete: Callable[[], None] | None = None, ) -> None: self.primary_error = primary_error self._transaction = transaction + self._on_cleanup_complete = on_cleanup_complete + self._completion_notified = False + self._completion_lock: Any = ( + allocate_lock() if allocate_lock is not None else _NoThreadLock() + ) super().__init__( "SmallServer startup failed and resource cleanup is incomplete" ) @@ -102,12 +108,25 @@ def cleanup_complete(self) -> bool: def retry_cleanup(self) -> bool: """Retry every resource still owned by the failed startup.""" self._transaction.retry() - return self._transaction.complete + complete = self._transaction.complete + if complete: + self._notify_cleanup_complete() + return complete def finalize(self) -> bool: """Alias for :meth:`retry_cleanup`.""" return self.retry_cleanup() + def _notify_cleanup_complete(self) -> None: + with self._completion_lock: + if self._completion_notified: + return + self._completion_notified = True + callback = self._on_cleanup_complete + self._on_cleanup_complete = None + if callback is not None: + callback() + def __del__(self) -> None: try: if self.cleanup_complete or self.retry_cleanup(): @@ -133,3 +152,7 @@ def __init__(self, status: int, detail: str = "") -> None: self.status = status self.detail = detail super().__init__(detail or "HTTP {}".format(status)) + + +class ServerConfigurationError(RuntimeError): + """The requested server lifecycle cannot run with the available runtime.""" diff --git a/smallserver/server.py b/smallserver/server.py index fb5be89..9ee2353 100644 --- a/smallserver/server.py +++ b/smallserver/server.py @@ -3,7 +3,7 @@ from __future__ import annotations from dataclasses import dataclass -from typing import Any +from typing import Any, Callable from ._transport import KernelTransport, TransportHandle, WakeupChannel from .http import Headers, Request, Response @@ -128,12 +128,15 @@ def __init__( listener: TransportHandle, wakeup: WakeupChannel | None, config: ServerConfig, + on_finalized: Callable[[ServerHandle], None] | None = None, ) -> None: self._runtime = runtime self._transport = transport self._listener = listener self._wakeup = wakeup self._config = config + self._address = transport.local_address(listener) + self._on_finalized = on_finalized self._close_requested = False self._notification_sent = False self._finalization_attempted = False @@ -142,6 +145,9 @@ def __init__( self._cleanup_errors: dict[str, BaseException] = {} self._listener_task: Any = None self._listener_resumed = False + self._close_task: Any = None + self._owned_tasks: list[Any] = [] + self._cancelled_task_ids: set[int] = set() self._connections: dict[int, tuple[TransportHandle, Any]] = {} self._closing_connections: dict[int, TransportHandle] = {} self._pending_task_cancellations: dict[int, Any] = {} @@ -149,7 +155,8 @@ def __init__( @property def address(self) -> tuple[str, int]: - return self._transport.local_address(self._listener) + """Return the bound address, including after the handle is closed.""" + return self._address @property def port(self) -> int: @@ -224,6 +231,14 @@ def close(self) -> None: self._wakeup.notify() self._notification_sent = True + def finalize(self) -> None: + """Release server resources after the caller-owned runtime has stopped. + + Use :meth:`close` while the scheduler is running. This method is the + manual-runtime escape hatch for a scheduler startup or exit failure. + """ + self._finish_close(owner_thread=True) + async def close_from_task(self, task: Any) -> None: """Close or retry incomplete cleanup on the SmallOS scheduler thread.""" if getattr(self._runtime, "cursor", None) is not task: @@ -250,7 +265,18 @@ def _listener_failed(self, exc: BaseException, task: Any) -> None: return self._finish_close(current_task=task) - def _finish_close(self, current_task: Any = None) -> None: + def _cancel_task(self, task: Any) -> None: + cancel_task = getattr(self._runtime, "cancel_task", None) + if callable(cancel_task): + try: + cancel_task(task) + except BaseException: + pass + + def _finish_close( + self, current_task: Any = None, *, owner_thread: bool = False + ) -> None: + """Idempotently release every resource owned by this invocation.""" if self._finished: return self._close_requested = True @@ -276,19 +302,44 @@ def _finish_close(self, current_task: Any = None) -> None: ) self._cleanup_errors["connection:{}".format(identity)] = error + retried_task_ids = set(self._pending_task_cancellations) for identity, task in list(self._pending_task_cancellations.items()): if self._cancel_or_retain_task(task): self._pending_task_cancellations.pop(identity, None) self._cleanup_errors.pop("task:{}".format(identity), None) + self._cancelled_task_ids.add(identity) for identity, (connection, task) in list(self._connections.items()): - if task is not current_task: + if owner_thread: + if ( + task is not current_task + and id(task) not in self._cancelled_task_ids + and id(task) not in retried_task_ids + ): + 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 + if task is not current_task: self._connections.pop(identity, None) self._close_or_retain(connection, current_task) + + if owner_thread: + for task in list(self._owned_tasks): + if ( + task is current_task + or id(task) in self._cancelled_task_ids + or id(task) in retried_task_ids + ): + continue + if self._cancel_or_retain_task(task): + self._cancelled_task_ids.add(id(task)) + # Owner-thread finalization has cancelled the listener task; it + # must never be resumed by a later scheduler-side cleanup retry. + self._listener_resumed = True if ( self._listener_task is not None and self._listener_task is not current_task @@ -387,13 +438,17 @@ def _connection_finished( ) -> None: """Release a completed connection without losing failed-close ownership.""" previous_count = self.owned_connection_count - self._connections.pop(id(connection), None) + entry = self._connections.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) self._close_or_retain(connection, task, primary_error) self._notify_capacity_released(previous_count) self._update_finished() def _update_finished(self) -> None: wakeup_closed = self._wakeup is None or self._wakeup.closed + was_finished = self._finished self._finished = bool( self._close_requested and wakeup_closed @@ -404,26 +459,14 @@ def _update_finished(self) -> None: ) if self._finished: self._cleanup_errors.clear() + if not was_finished: + callback = self._on_finalized + self._on_finalized = None + if callback is not None: + callback(self) - def _abort_startup( - self, tasks: tuple[Any, ...] - ) -> tuple[tuple[Any, BaseException], ...]: + def _abort_startup(self, tasks: tuple[Any, ...]) -> None: """Release bound resources after task registration fails.""" self._close_requested = True - failures: list[tuple[Any, BaseException]] = [] - cancel_task = getattr(self._runtime, "cancel_task", None) - for task in reversed(tasks): - try: - if callable(cancel_task): - cancel_task(task) - else: - task_cancel = getattr(task, "cancel", None) - if not callable(task_cancel): - raise RuntimeError( - "runtime cannot cancel a startup task" - ) - task_cancel() - except BaseException as exc: - failures.append((task, exc)) - self._finish_close() - return tuple(failures) + self._owned_tasks = list(tasks) + self._finish_close(owner_thread=True) diff --git a/tests/test_kernel_transport.py b/tests/test_kernel_transport.py index 5ddacf2..8f4955d 100644 --- a/tests/test_kernel_transport.py +++ b/tests/test_kernel_transport.py @@ -665,6 +665,9 @@ def fork(self, tasks) -> None: def resume_task(self, task) -> None: self.resumed.append(task) + def cancel_task(self, task) -> None: + pass + runtime = Runtime() handle = SmallServer().serve(runtime, host="0.0.0.0", port=8080) self.assertEqual(len(runtime.forked), 1) diff --git a/tests/test_lifecycle.py b/tests/test_lifecycle.py new file mode 100644 index 0000000..7df0170 --- /dev/null +++ b/tests/test_lifecycle.py @@ -0,0 +1,209 @@ +from __future__ import annotations + +import ast +import os +from pathlib import Path +import subprocess +import sys +import unittest +from unittest.mock import patch + +from smallserver import ServerConfigurationError, SmallServer +from smallserver._transport import TransportHandle +from smallserver.server import ServerHandle + +from tests.kernel_fakes import FakeKernel, OpaqueHandle + + +class FakeRuntime: + def __init__(self, kernel: FakeKernel | None = None) -> None: + self.kernel = kernel or FakeKernel() + self.forked: list[object] = [] + self.cancelled: list[object] = [] + self.started = 0 + self.start_error: BaseException | None = None + self.unrelated_task = object() + + def fork(self, children) -> object: + if isinstance(children, list): + self.forked.extend(children) + else: + self.forked.append(children) + return 0 + + def start(self) -> None: + self.started += 1 + if self.start_error is not None: + raise self.start_error + + def resume_task(self, task) -> int: + return 0 + + def cancel_task(self, task) -> int: + self.cancelled.append(task) + cancel = getattr(task, "cancel", None) + if callable(cancel): + cancel() + return 0 + + +class ServerLifecycleTests(unittest.TestCase): + def test_primary_demo_hides_runtime_and_registers_all_http_methods(self) -> None: + root = Path(__file__).parents[1] + demo_path = root / "demo.py" + tree = ast.parse(demo_path.read_text(encoding="utf-8"), filename=str(demo_path)) + forbidden: list[str] = [] + for node in ast.walk(tree): + if isinstance(node, ast.Import): + forbidden.extend( + alias.name + for alias in node.names + if alias.name in {"socket", "SmallPackage"} + ) + elif isinstance(node, ast.ImportFrom) and node.module in { + "socket", + "SmallPackage", + }: + forbidden.append(node.module) + self.assertEqual(forbidden, []) + + import demo + + self.assertEqual( + {method for method, path in demo.app._routes if path == "/tasks"}, + {"GET", "POST", "PUT", "PATCH", "DELETE"}, + ) + + def test_supplied_runtime_defaults_to_schedule_without_starting(self) -> None: + runtime = FakeRuntime() + handle = SmallServer().listen(runtime=runtime, port=0) + + self.assertEqual(runtime.started, 0) + self.assertEqual(len(runtime.forked), 2) + self.assertFalse(handle.closed) + handle.finalize() + + def test_supplied_runtime_start_true_starts_once_and_returns_closed_handle(self) -> None: + runtime = FakeRuntime() + handle = SmallServer().listen(runtime=runtime, start=True, port=0) + + self.assertEqual(runtime.started, 1) + self.assertTrue(handle.closed) + self.assertEqual(handle.address, ("127.0.0.1", 43210)) + self.assertEqual(runtime.cancelled, runtime.forked) + self.assertNotIn(runtime.unrelated_task, runtime.cancelled) + self.assertEqual(runtime.kernel.wakeup.close_calls, 1) + + def test_runtime_failure_finalizes_but_does_not_hide_primary_error(self) -> None: + runtime = FakeRuntime() + runtime.start_error = RuntimeError("scheduler failed") + app = SmallServer() + + with self.assertRaisesRegex(RuntimeError, "scheduler failed"): + app.listen(runtime=runtime, start=True, port=0) + + self.assertEqual(runtime.kernel.wakeup.close_calls, 1) + self.assertEqual([item.name for item in runtime.kernel.closed], ["listener"]) + # Complete cleanup releases the application for a later invocation. + next_handle = app.listen(runtime=FakeRuntime(), port=0) + next_handle.finalize() + + def test_managed_keyboard_interrupt_is_swallowed_after_cleanup(self) -> None: + runtime = FakeRuntime() + runtime.start_error = KeyboardInterrupt() + + with patch("smallserver.app._default_runtime_factory", return_value=runtime): + handle = SmallServer().listen(port=0) + + self.assertTrue(handle.closed) + self.assertEqual(runtime.started, 1) + self.assertEqual(runtime.kernel.wakeup.close_calls, 1) + + def test_supplied_runtime_keyboard_interrupt_propagates_after_cleanup(self) -> None: + runtime = FakeRuntime() + runtime.start_error = KeyboardInterrupt() + + with self.assertRaises(KeyboardInterrupt): + SmallServer().listen(runtime=runtime, start=True, port=0) + + self.assertEqual(runtime.kernel.wakeup.close_calls, 1) + self.assertEqual([item.name for item in runtime.kernel.closed], ["listener"]) + + def test_invalid_ownership_and_runtime_fail_before_binding(self) -> None: + with patch("smallserver.app._default_runtime_factory") as factory: + with self.assertRaisesRegex(ValueError, "caller-supplied"): + SmallServer().listen(start=False) + factory.assert_not_called() + + kernel = FakeKernel() + + class InvalidRuntime: + def __init__(self) -> None: + self.kernel = kernel + + with self.assertRaisesRegex(TypeError, "fork"): + SmallServer().listen(runtime=InvalidRuntime()) + self.assertEqual(kernel.calls, []) + + with self.assertRaisesRegex(TypeError, "boolean"): + SmallServer().listen(runtime=FakeRuntime(), start=1) # type: ignore[arg-type] + + def test_concurrent_invocation_is_rejected_and_sequential_reuse_succeeds(self) -> None: + app = SmallServer() + first = app.serve(FakeRuntime(), port=0) + + with self.assertRaisesRegex(RuntimeError, "active listener"): + app.serve(FakeRuntime(), port=0) + + first.finalize() + second = app.serve(FakeRuntime(), port=0) + second.finalize() + self.assertTrue(second.closed) + + def test_finalization_is_idempotent_with_live_connections(self) -> None: + runtime = FakeRuntime() + handle = SmallServer().serve(runtime, port=0) + client = TransportHandle(OpaqueHandle("live-client")) + client_task = object() + handle._connections[id(client)] = (client, client_task) + handle._owned_tasks.append(client_task) + + handle.finalize() + handle.finalize() + handle.close() + + self.assertEqual(runtime.kernel.wakeup.close_calls, 1) + self.assertEqual( + [item.name for item in runtime.kernel.closed], + ["live-client", "listener"], + ) + self.assertEqual(runtime.cancelled.count(client_task), 1) + + def test_default_runtime_configuration_failure_is_framework_owned(self) -> None: + with patch.dict(sys.modules, {"SmallPackage": None}): + with self.assertRaises(ServerConfigurationError): + from smallserver.app import _default_runtime_factory + + _default_runtime_factory() + + def test_importing_smallserver_does_not_eagerly_import_smallos(self) -> None: + root = Path(__file__).parents[1] + environment = os.environ.copy() + environment.pop("PYTHONPATH", None) + result = subprocess.run( + [ + sys.executable, + "-c", + "import sys, smallserver; print('SmallPackage' in sys.modules)", + ], + cwd=root, + env=environment, + check=True, + capture_output=True, + text=True, + ) + self.assertEqual(result.stdout.strip(), "False") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_server.py b/tests/test_server.py index f0fc04e..ce2a30c 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -78,6 +78,9 @@ def __init__(self) -> None: self.kernel = FakeKernel() self.cancelled = [] + def fork(self, tasks) -> None: + pass + def resume_task(self, task) -> None: pass @@ -107,6 +110,12 @@ class Runtime: def __init__(self) -> None: self.kernel = FakeKernel() + def fork(self, tasks) -> None: + pass + + def resume_task(self, task) -> None: + pass + runtime = Runtime() primary = RuntimeError("listen setup failed") runtime.kernel.operation_errors["listen"] = primary @@ -129,6 +138,12 @@ class Runtime: def __init__(self) -> None: self.kernel = FakeKernel() + def fork(self, tasks) -> None: + pass + + def resume_task(self, task) -> None: + pass + runtime = Runtime() runtime.kernel.invalid_wait_objects.add(id(runtime.kernel.wakeup.wait_object)) runtime.kernel.wakeup.close_failures = 2 @@ -211,6 +226,12 @@ class Runtime: def __init__(self) -> None: self.kernel = FakeKernel() + def fork(self, tasks) -> None: + pass + + def resume_task(self, task) -> None: + pass + for interrupt in (KeyboardInterrupt("stop"), SystemExit(7)): for close_failures in (0, 1): with self.subTest( diff --git a/tests/test_server_runtime.py b/tests/test_server_runtime.py index f10ad28..07d8e59 100644 --- a/tests/test_server_runtime.py +++ b/tests/test_server_runtime.py @@ -1,11 +1,13 @@ import socket import threading +import time import unittest from SmallPackage import SmallOS, Unix from SmallPackage.adapters.threads import ThreadAdapter from smallserver import AdapterRegistry, Response, SmallServer +from smallserver.server import ServerHandle class SmallOSServerIntegrationTests(unittest.TestCase): @@ -183,3 +185,47 @@ def clients() -> None: ) self.assertEqual(runtime.ioReadWaiters, {}) self.assertEqual(runtime.ioWriteWaiters, {}) + + def test_managed_listen_serves_loopback_and_returns_closed_handle(self) -> None: + app = SmallServer() + + @app.get("/health") + async def health(request): + return Response.json({"status": "ok"}) + + returned: list[object] = [] + errors: list[BaseException] = [] + + def run_server() -> None: + try: + returned.append(app.listen(host="127.0.0.1", port=0)) + except BaseException as exc: + errors.append(exc) + + worker = threading.Thread(target=run_server, daemon=True) + worker.start() + handle = None + for _ in range(200): + candidate = app._active_invocation + if isinstance(candidate, ServerHandle): + handle = candidate + break + if errors: + break + time.sleep(0.01) + if errors and isinstance(errors[0], PermissionError): + self.skipTest("the current sandbox does not permit loopback TCP binds") + self.assertEqual(errors, []) + self.assertIsNotNone(handle) + assert handle is not None + + response = self._request(handle.port, "/health") + handle.close() + worker.join(timeout=3) + + self.assertFalse(worker.is_alive()) + self.assertEqual(errors, []) + self.assertEqual(returned, [handle]) + self.assertTrue(handle.closed) + self.assertEqual(handle.port, returned[0].port) + self.assertIn(b"HTTP/1.1 200 OK", response) From f4b48c6ccab9ef5aa8f7e77facf133114d4aa748 Mon Sep 17 00:00:00 2001 From: MikiEEE <35975462+MikiEEE@users.noreply.github.com> Date: Fri, 21 Aug 2026 23:54:05 -0500 Subject: [PATCH 13/53] test: cover constrained lifecycle cleanup --- README.md | 9 ++++++--- tests/test_lifecycle.py | 19 +++++++++++++++++++ 2 files changed, 25 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 4e4aa7b..7f53390 100644 --- a/README.md +++ b/README.md @@ -133,9 +133,12 @@ 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, `server.close()` is the thread-safe shutdown -signal. After a manually started scheduler has already exited or failed, -`server.finalize()` is the idempotent owner-thread cleanup operation. +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 diff --git a/tests/test_lifecycle.py b/tests/test_lifecycle.py index 7df0170..3216ed1 100644 --- a/tests/test_lifecycle.py +++ b/tests/test_lifecycle.py @@ -83,6 +83,25 @@ def test_supplied_runtime_defaults_to_schedule_without_starting(self) -> None: self.assertFalse(handle.closed) handle.finalize() + def test_no_wakeup_runtime_can_be_owner_finalized_and_reused(self) -> None: + runtime = FakeRuntime(FakeKernel(wakeup_supported=False)) + app = SmallServer() + handle = app.listen(runtime=runtime, port=0) + + self.assertEqual(len(runtime.forked), 1) + self.assertIsNone(handle._wakeup) + with self.assertRaisesRegex(RuntimeError, "outside its scheduler"): + handle.close() + + handle.finalize() + self.assertTrue(handle.closed) + self.assertEqual(runtime.cancelled, runtime.forked) + self.assertEqual(runtime.kernel.closed, [runtime.kernel.listener]) + + next_runtime = FakeRuntime(FakeKernel(wakeup_supported=False)) + next_handle = app.listen(runtime=next_runtime, port=0) + next_handle.finalize() + def test_supplied_runtime_start_true_starts_once_and_returns_closed_handle(self) -> None: runtime = FakeRuntime() handle = SmallServer().listen(runtime=runtime, start=True, port=0) From 2c9645ae333fa9a1930167c5800f70e643cb9b9f Mon Sep 17 00:00:00 2001 From: MikiEEE <35975462+MikiEEE@users.noreply.github.com> Date: Sat, 22 Aug 2026 00:14:37 -0500 Subject: [PATCH 14/53] fix: retain lifecycle ownership through cleanup retries --- tests/test_lifecycle.py | 66 ++++++++++++++++++++++++++++++++++++++++- 1 file changed, 65 insertions(+), 1 deletion(-) diff --git a/tests/test_lifecycle.py b/tests/test_lifecycle.py index 3216ed1..b80f666 100644 --- a/tests/test_lifecycle.py +++ b/tests/test_lifecycle.py @@ -8,7 +8,7 @@ import unittest from unittest.mock import patch -from smallserver import ServerConfigurationError, SmallServer +from smallserver import ServerConfigurationError, ServerStartupError, SmallServer from smallserver._transport import TransportHandle from smallserver.server import ServerHandle @@ -179,6 +179,70 @@ def test_concurrent_invocation_is_rejected_and_sequential_reuse_succeeds(self) - second.finalize() self.assertTrue(second.closed) + def test_cleanup_failure_retains_invocation_until_retry_finishes(self) -> None: + runtime = FakeRuntime() + app = SmallServer() + handle = app.serve(runtime, port=0) + runtime.kernel.close_failures[id(runtime.kernel.listener)] = 1 + + handle.finalize() + + self.assertFalse(handle.finished) + self.assertEqual(len(handle.cleanup_errors), 1) + self.assertEqual(runtime.cancelled, runtime.forked) + with self.assertRaisesRegex(RuntimeError, "active listener"): + app.serve(FakeRuntime(), port=0) + + handle.finalize() + + self.assertTrue(handle.finished) + self.assertEqual(handle.cleanup_errors, ()) + # Cleanup retries do not cancel owned or unrelated tasks a second time. + self.assertEqual(runtime.cancelled, runtime.forked) + self.assertNotIn(runtime.unrelated_task, runtime.cancelled) + next_handle = app.serve(FakeRuntime(), port=0) + next_handle.finalize() + + def test_startup_failure_exposes_retained_handle_for_cleanup_retry(self) -> None: + class FailingForkRuntime(FakeRuntime): + def fork(self, children) -> object: + super().fork(children) + raise RuntimeError("fork failed") + + runtime = FailingForkRuntime() + runtime.kernel.close_failures[id(runtime.kernel.listener)] = 1 + app = SmallServer() + + with self.assertRaises(ServerStartupError) as raised: + app.serve(runtime, port=0) + + cleanup = raised.exception + self.assertEqual(str(cleanup.primary_error), "fork failed") + self.assertEqual(len(cleanup.cleanup_errors), 1) + with self.assertRaisesRegex(RuntimeError, "active listener"): + app.serve(FakeRuntime(), port=0) + self.assertTrue(cleanup.retry_cleanup()) + next_handle = app.serve(FakeRuntime(), port=0) + next_handle.finalize() + + def test_managed_interrupt_propagates_when_cleanup_is_incomplete(self) -> None: + runtime = FakeRuntime() + runtime.start_error = KeyboardInterrupt() + runtime.kernel.close_failures[id(runtime.kernel.listener)] = 1 + app = SmallServer() + + with patch("smallserver.app._default_runtime_factory", return_value=runtime): + with self.assertRaises(KeyboardInterrupt) as raised: + app.listen(port=0) + + cleanup = raised.exception.__cause__ + self.assertIsInstance(cleanup, ServerStartupError) + assert isinstance(cleanup, ServerStartupError) + self.assertIs(cleanup.primary_error, raised.exception) + with self.assertRaisesRegex(RuntimeError, "active listener"): + app.serve(FakeRuntime(), port=0) + self.assertTrue(cleanup.retry_cleanup()) + def test_finalization_is_idempotent_with_live_connections(self) -> None: runtime = FakeRuntime() handle = SmallServer().serve(runtime, port=0) From 7e0324e7867eb795acc0fd724c31bb158977eecf Mon Sep 17 00:00:00 2001 From: MikiEEE <35975462+MikiEEE@users.noreply.github.com> Date: Sat, 22 Aug 2026 01:08:46 -0500 Subject: [PATCH 15/53] test: cover lifecycle cleanup integration --- README.md | 7 ++++ tests/test_lifecycle.py | 81 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 88 insertions(+) diff --git a/README.md b/README.md index 7f53390..ea3658e 100644 --- a/README.md +++ b/README.md @@ -71,6 +71,13 @@ 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()` returns an +unfinished handle so the caller can inspect `cleanup_errors` and retry +`finalize()`. 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 diff --git a/tests/test_lifecycle.py b/tests/test_lifecycle.py index b80f666..c10d9a9 100644 --- a/tests/test_lifecycle.py +++ b/tests/test_lifecycle.py @@ -1,6 +1,7 @@ from __future__ import annotations import ast +from contextlib import nullcontext import os from pathlib import Path import subprocess @@ -127,6 +128,39 @@ def test_runtime_failure_finalizes_but_does_not_hide_primary_error(self) -> None next_handle = app.listen(runtime=FakeRuntime(), port=0) next_handle.finalize() + def test_runtime_failure_retains_invocation_until_cleanup_retry(self) -> None: + runtime = FakeRuntime() + primary = RuntimeError("scheduler failed") + runtime.start_error = primary + runtime.kernel.close_failures[id(runtime.kernel.listener)] = 1 + app = SmallServer() + + with self.assertRaises(ServerStartupError) as raised: + app.listen(runtime=runtime, start=True, port=0) + + cleanup = raised.exception + self.assertIs(cleanup.primary_error, primary) + with self.assertRaisesRegex(RuntimeError, "active listener"): + app.serve(FakeRuntime(), port=0) + self.assertTrue(cleanup.retry_cleanup()) + next_handle = app.serve(FakeRuntime(), port=0) + next_handle.finalize() + + def test_normal_runtime_return_exposes_unfinished_handle_for_retry(self) -> None: + runtime = FakeRuntime() + runtime.kernel.close_failures[id(runtime.kernel.listener)] = 1 + app = SmallServer() + + handle = app.listen(runtime=runtime, start=True, port=0) + + self.assertFalse(handle.finished) + with self.assertRaisesRegex(RuntimeError, "active listener"): + app.serve(FakeRuntime(), port=0) + handle.finalize() + self.assertTrue(handle.finished) + next_handle = app.serve(FakeRuntime(), port=0) + next_handle.finalize() + def test_managed_keyboard_interrupt_is_swallowed_after_cleanup(self) -> None: runtime = FakeRuntime() runtime.start_error = KeyboardInterrupt() @@ -148,6 +182,35 @@ def test_supplied_runtime_keyboard_interrupt_propagates_after_cleanup(self) -> N self.assertEqual(runtime.kernel.wakeup.close_calls, 1) self.assertEqual([item.name for item in runtime.kernel.closed], ["listener"]) + def test_system_exit_identity_survives_lifecycle_cleanup_failure(self) -> None: + for managed in (False, True): + with self.subTest(managed=managed): + runtime = FakeRuntime() + primary = SystemExit(17) + runtime.start_error = primary + runtime.kernel.close_failures[id(runtime.kernel.listener)] = 1 + app = SmallServer() + + context = ( + patch("smallserver.app._default_runtime_factory", return_value=runtime) + if managed + else nullcontext() + ) + with context: + with self.assertRaises(SystemExit) as raised: + if managed: + app.listen(port=0) + else: + app.listen(runtime=runtime, start=True, port=0) + + self.assertIs(raised.exception, primary) + cleanup = raised.exception.__cause__ + self.assertIsInstance(cleanup, ServerStartupError) + assert isinstance(cleanup, ServerStartupError) + with self.assertRaisesRegex(RuntimeError, "active listener"): + app.serve(FakeRuntime(), port=0) + self.assertTrue(cleanup.retry_cleanup()) + def test_invalid_ownership_and_runtime_fail_before_binding(self) -> None: with patch("smallserver.app._default_runtime_factory") as factory: with self.assertRaisesRegex(ValueError, "caller-supplied"): @@ -167,6 +230,24 @@ def __init__(self) -> None: with self.assertRaisesRegex(TypeError, "boolean"): SmallServer().listen(runtime=FakeRuntime(), start=1) # type: ignore[arg-type] + def test_acquisition_cleanup_retains_invocation_until_retry(self) -> None: + runtime = FakeRuntime() + primary = RuntimeError("listen setup failed") + runtime.kernel.operation_errors["listen"] = primary + runtime.kernel.close_failures[id(runtime.kernel.listener)] = 1 + app = SmallServer() + + with self.assertRaises(ServerStartupError) as raised: + app.serve(runtime, port=0) + + cleanup = raised.exception + self.assertIs(cleanup.primary_error, primary) + with self.assertRaisesRegex(RuntimeError, "active listener"): + app.serve(FakeRuntime(), port=0) + self.assertTrue(cleanup.retry_cleanup()) + next_handle = app.serve(FakeRuntime(), port=0) + next_handle.finalize() + def test_concurrent_invocation_is_rejected_and_sequential_reuse_succeeds(self) -> None: app = SmallServer() first = app.serve(FakeRuntime(), port=0) From 5b1b8aa2fe38fca16f4135f60174a3e76f303f0f Mon Sep 17 00:00:00 2001 From: MikiEEE <35975462+MikiEEE@users.noreply.github.com> Date: Sat, 22 Aug 2026 01:10:05 -0500 Subject: [PATCH 16/53] fix: type runtime lifecycle capabilities precisely --- smallserver/app.py | 27 ++++++++++++++++++++------- 1 file changed, 20 insertions(+), 7 deletions(-) diff --git a/smallserver/app.py b/smallserver/app.py index b7e6ce3..3ddc7b5 100644 --- a/smallserver/app.py +++ b/smallserver/app.py @@ -4,7 +4,7 @@ import inspect from collections.abc import Awaitable, Callable, Iterable -from typing import Any, Literal, NoReturn, Protocol, overload +from typing import Any, Literal, NoReturn, Protocol, cast, overload from ._transport import ( KernelTransport, @@ -44,14 +44,16 @@ class _RuntimeLike(Protocol): def fork(self, children: Any) -> Any: ... - def start(self) -> None: ... - def resume_task(self, task: Any) -> Any: ... - def cancel_task(self, task: Any) -> Any: ... + +class _StartableRuntime(_RuntimeLike, Protocol): + """Additional lifecycle operation required when SmallServer starts a runtime.""" + + def start(self) -> None: ... -def _default_runtime_factory() -> _RuntimeLike: +def _default_runtime_factory() -> _StartableRuntime: """Lazily create the supported desktop runtime for managed ``listen``.""" try: from SmallPackage import SmallOS, Unix @@ -177,7 +179,18 @@ def listen( config: ServerConfig | None = None, *, runtime: _RuntimeLike, - start: bool | None = None, + start: Literal[False] | None = None, + ) -> ServerHandle: ... + + @overload + def listen( + self, + host: str = "127.0.0.1", + port: int = 8000, + config: ServerConfig | None = None, + *, + runtime: _StartableRuntime, + start: bool, ) -> ServerHandle: ... def listen( @@ -209,7 +222,7 @@ def listen( return handle primary_error: BaseException | None = None try: - runtime.start() + cast(_StartableRuntime, runtime).start() except BaseException as exc: primary_error = exc finally: From a8465fa4e8d596220fdc0da3128e02b92e612186 Mon Sep 17 00:00:00 2001 From: MikiEEE <35975462+MikiEEE@users.noreply.github.com> Date: Sat, 22 Aug 2026 01:21:02 -0500 Subject: [PATCH 17/53] fix: harden lifecycle ownership and cleanup --- README.md | 6 +- demo.py | 2 +- smallserver/__init__.py | 8 +- smallserver/app.py | 57 ++++++++++--- smallserver/errors.py | 56 +++++++++--- smallserver/server.py | 19 ++--- tests/test_kernel_transport.py | 3 + tests/test_lifecycle.py | 150 +++++++++++++++++++++++++++++++-- tests/test_server.py | 9 ++ 9 files changed, 265 insertions(+), 45 deletions(-) diff --git a/README.md b/README.md index ea3658e..3c7598b 100644 --- a/README.md +++ b/README.md @@ -71,9 +71,9 @@ 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()` returns an -unfinished handle so the caller can inspect `cleanup_errors` and retry -`finalize()`. If runtime startup raises while cleanup is incomplete, ordinary +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. diff --git a/demo.py b/demo.py index 1a85695..bfa2f07 100644 --- a/demo.py +++ b/demo.py @@ -82,5 +82,5 @@ async def delete_task(request: Request) -> Response: if __name__ == "__main__": - print("SmallServer listening on http://127.0.0.1:8000") + print("Starting SmallServer on http://127.0.0.1:8000") app.listen(host="127.0.0.1", port=8000) diff --git a/smallserver/__init__.py b/smallserver/__init__.py index 286312d..6600810 100644 --- a/smallserver/__init__.py +++ b/smallserver/__init__.py @@ -3,7 +3,12 @@ from typing import TYPE_CHECKING, Any from .app import SmallServer -from .errors import HTTPError, ServerConfigurationError, ServerStartupError +from .errors import ( + HTTPError, + ServerConfigurationError, + ServerFinalizationError, + ServerStartupError, +) from .http import Headers, Request, Response from .server import ServerConfig, ServerHandle @@ -30,6 +35,7 @@ def __getattr__(name: str) -> Any: "Response", "ServerConfig", "ServerConfigurationError", + "ServerFinalizationError", "ServerHandle", "ServerStartupError", "SmallServer", diff --git a/smallserver/app.py b/smallserver/app.py index 3ddc7b5..6815502 100644 --- a/smallserver/app.py +++ b/smallserver/app.py @@ -6,6 +6,11 @@ from collections.abc import Awaitable, Callable, Iterable from typing import Any, Literal, NoReturn, Protocol, cast, overload +try: + from _thread import allocate_lock +except ImportError: # pragma: no cover - runtimes without threads cannot race + allocate_lock = None # type: ignore[assignment] + from ._transport import ( KernelTransport, TransportHandle, @@ -14,6 +19,7 @@ from .errors import ( HTTPError, ServerConfigurationError, + ServerFinalizationError, ServerStartupError, _CleanupTransaction, ) @@ -24,6 +30,14 @@ _METHODS = frozenset({"GET", "POST", "PUT", "PATCH", "DELETE"}) +class _NoThreadLock: + def __enter__(self) -> None: + return None + + def __exit__(self, *args: object) -> None: + return None + + def _raise_startup_cleanup( primary_error: BaseException, transaction: _CleanupTransaction, @@ -46,6 +60,8 @@ def fork(self, children: Any) -> Any: ... def resume_task(self, task: Any) -> Any: ... + def cancel_task(self, task: Any) -> Any: ... + class _StartableRuntime(_RuntimeLike, Protocol): """Additional lifecycle operation required when SmallServer starts a runtime.""" @@ -106,6 +122,30 @@ class SmallServer: def __init__(self) -> None: self._routes: dict[tuple[str, str], Handler] = {} self._active_invocation: object | ServerHandle | None = None + self._invocation_lock: Any = ( + allocate_lock() if allocate_lock is not None else _NoThreadLock() + ) + + def _reserve_invocation(self) -> object: + with self._invocation_lock: + if self._active_invocation is not None: + raise RuntimeError("this SmallServer already has an active listener") + marker = object() + self._active_invocation = marker + return marker + + def _replace_invocation( + self, expected: object, replacement: ServerHandle + ) -> None: + with self._invocation_lock: + if self._active_invocation is not expected: + raise RuntimeError("SmallServer listener ownership changed unexpectedly") + self._active_invocation = replacement + + def _release_invocation(self, expected: object) -> None: + with self._invocation_lock: + if self._active_invocation is expected: + self._active_invocation = None def route(self, path: str, methods: Iterable[str]) -> Callable[[Handler], Handler]: if not isinstance(path, str) or not path.startswith("/"): @@ -234,6 +274,8 @@ def listen( if managed and isinstance(primary_error, KeyboardInterrupt): return handle raise primary_error + if not handle.finished: + raise ServerFinalizationError(self._handle_cleanup_transaction(handle)) return handle @staticmethod @@ -241,7 +283,7 @@ def _handle_cleanup_transaction(handle: ServerHandle) -> _CleanupTransaction: return _HandleCleanupTransaction(handle) def _validate_runtime(self, runtime: object, *, require_start: bool) -> None: - required = ["fork", "resume_task"] + required = ["fork", "resume_task", "cancel_task"] if require_start: required.append("start") missing = [name for name in required if not callable(getattr(runtime, name, None))] @@ -268,14 +310,10 @@ def _bind_and_schedule( raise ValueError("port must be an integer between 0 and 65535") if config is not None and not isinstance(config, ServerConfig): raise TypeError("config must be a ServerConfig or None") - if self._active_invocation is not None: - raise RuntimeError("this SmallServer already has an active listener") - marker = object() - self._active_invocation = marker + marker = self._reserve_invocation() def release_marker() -> None: - if self._active_invocation is marker: - self._active_invocation = None + self._release_invocation(marker) def raise_acquisition_cleanup( primary_error: BaseException, transaction: _CleanupTransaction @@ -323,8 +361,7 @@ def raise_acquisition_cleanup( raise def release(completed: ServerHandle) -> None: - if self._active_invocation is completed: - self._active_invocation = None + self._release_invocation(completed) try: handle = ServerHandle( @@ -347,7 +384,7 @@ def release(completed: ServerHandle) -> None: raise_acquisition_cleanup(primary_error, transaction) release_marker() raise - self._active_invocation = handle + self._replace_invocation(marker, handle) tasks: tuple[Any, ...] = () try: listener_task = SmallTask( diff --git a/smallserver/errors.py b/smallserver/errors.py index a42fc84..9ececba 100644 --- a/smallserver/errors.py +++ b/smallserver/errors.py @@ -72,30 +72,24 @@ def complete(self) -> bool: return not self._actions -class ServerStartupError(RuntimeError): - """Startup failed while framework-owned resources still need cleanup. - - The exception retains ownership without exposing kernel handles. Call - :meth:`retry_cleanup` until it returns ``True``; successful cleanup is - idempotent. - """ +class _ServerCleanupError(RuntimeError): + """Framework-owned cleanup transaction exposed for explicit retry.""" def __init__( self, - primary_error: BaseException, transaction: _CleanupTransaction, + message: str, + warning_message: str, on_cleanup_complete: Callable[[], None] | None = None, ) -> None: - self.primary_error = primary_error self._transaction = transaction + self._warning_message = warning_message self._on_cleanup_complete = on_cleanup_complete self._completion_notified = False self._completion_lock: Any = ( allocate_lock() if allocate_lock is not None else _NoThreadLock() ) - super().__init__( - "SmallServer startup failed and resource cleanup is incomplete" - ) + super().__init__(message) @property def cleanup_errors(self) -> tuple[BaseException, ...]: @@ -106,7 +100,7 @@ def cleanup_complete(self) -> bool: return self._transaction.complete def retry_cleanup(self) -> bool: - """Retry every resource still owned by the failed startup.""" + """Retry every resource still owned by the failed lifecycle operation.""" self._transaction.retry() complete = self._transaction.complete if complete: @@ -134,7 +128,7 @@ def __del__(self) -> None: import warnings warnings.warn( - "abandoned ServerStartupError still owns resources after cleanup retry", + self._warning_message, ResourceWarning, stacklevel=2, ) @@ -143,6 +137,40 @@ def __del__(self) -> None: return +class ServerStartupError(_ServerCleanupError): + """Startup failed while framework-owned resources still need cleanup.""" + + def __init__( + self, + primary_error: BaseException, + transaction: _CleanupTransaction, + on_cleanup_complete: Callable[[], None] | None = None, + ) -> None: + self.primary_error = primary_error + super().__init__( + transaction, + "SmallServer startup failed and resource cleanup is incomplete", + "abandoned ServerStartupError still owns resources after cleanup retry", + on_cleanup_complete, + ) + + +class ServerFinalizationError(_ServerCleanupError): + """Runtime exit left framework-owned resources requiring cleanup retry.""" + + def __init__( + self, + transaction: _CleanupTransaction, + on_cleanup_complete: Callable[[], None] | None = None, + ) -> None: + super().__init__( + transaction, + "SmallServer runtime exited but resource cleanup is incomplete", + "abandoned ServerFinalizationError still owns resources after cleanup retry", + on_cleanup_complete, + ) + + class HTTPError(Exception): """An expected HTTP response raised by framework or application code.""" diff --git a/smallserver/server.py b/smallserver/server.py index 9ee2353..4a1cad7 100644 --- a/smallserver/server.py +++ b/smallserver/server.py @@ -302,8 +302,9 @@ def _finish_close( ) self._cleanup_errors["connection:{}".format(identity)] = error - retried_task_ids = set(self._pending_task_cancellations) + attempted_task_ids: set[int] = set() for identity, task in list(self._pending_task_cancellations.items()): + attempted_task_ids.add(identity) if self._cancel_or_retain_task(task): self._pending_task_cancellations.pop(identity, None) self._cleanup_errors.pop("task:{}".format(identity), None) @@ -314,8 +315,9 @@ def _finish_close( if ( task is not current_task and id(task) not in self._cancelled_task_ids - and id(task) not in retried_task_ids + and id(task) not in attempted_task_ids ): + attempted_task_ids.add(id(task)) if self._cancel_or_retain_task(task): self._cancelled_task_ids.add(id(task)) elif task is not current_task: @@ -332,9 +334,10 @@ def _finish_close( if ( task is current_task or id(task) in self._cancelled_task_ids - or id(task) in retried_task_ids + or id(task) in attempted_task_ids ): continue + attempted_task_ids.add(id(task)) if self._cancel_or_retain_task(task): self._cancelled_task_ids.add(id(task)) # Owner-thread finalization has cancelled the listener task; it @@ -415,13 +418,9 @@ def _cancel_or_retain_task(self, task: Any) -> bool: identity = id(task) cancel_task = getattr(self._runtime, "cancel_task", None) try: - if callable(cancel_task): - cancel_task(task) - else: - task_cancel = getattr(task, "cancel", None) - if not callable(task_cancel): - raise RuntimeError("runtime cannot cancel a connection task") - task_cancel() + if not callable(cancel_task): + raise RuntimeError("runtime cannot unregister a server task") + cancel_task(task) except BaseException as exc: self._pending_task_cancellations[identity] = task self._cleanup_errors["task:{}".format(identity)] = exc diff --git a/tests/test_kernel_transport.py b/tests/test_kernel_transport.py index 8f4955d..6d4ac0d 100644 --- a/tests/test_kernel_transport.py +++ b/tests/test_kernel_transport.py @@ -696,6 +696,9 @@ def fork(self, tasks) -> None: def resume_task(self, task) -> None: self.resumed.append(task) + def cancel_task(self, task) -> None: + pass + runtime = MicroRuntime() app = SmallServer() diff --git a/tests/test_lifecycle.py b/tests/test_lifecycle.py index c10d9a9..3c39e9d 100644 --- a/tests/test_lifecycle.py +++ b/tests/test_lifecycle.py @@ -2,14 +2,22 @@ import ast from contextlib import nullcontext +import gc import os from pathlib import Path import subprocess import sys +import threading import unittest +import warnings from unittest.mock import patch -from smallserver import ServerConfigurationError, ServerStartupError, SmallServer +from smallserver import ( + ServerConfigurationError, + ServerFinalizationError, + ServerStartupError, + SmallServer, +) from smallserver._transport import TransportHandle from smallserver.server import ServerHandle @@ -67,6 +75,9 @@ def test_primary_demo_hides_runtime_and_registers_all_http_methods(self) -> None }: forbidden.append(node.module) self.assertEqual(forbidden, []) + demo_source = demo_path.read_text(encoding="utf-8") + self.assertIn('print("Starting SmallServer', demo_source) + self.assertNotIn('print("SmallServer listening', demo_source) import demo @@ -146,18 +157,43 @@ def test_runtime_failure_retains_invocation_until_cleanup_retry(self) -> None: next_handle = app.serve(FakeRuntime(), port=0) next_handle.finalize() - def test_normal_runtime_return_exposes_unfinished_handle_for_retry(self) -> None: + def test_managed_normal_return_exposes_cleanup_owner_for_retry(self) -> None: runtime = FakeRuntime() runtime.kernel.close_failures[id(runtime.kernel.listener)] = 1 app = SmallServer() - handle = app.listen(runtime=runtime, start=True, port=0) + with patch("smallserver.app._default_runtime_factory", return_value=runtime): + with self.assertRaises(ServerFinalizationError) as raised: + app.listen(port=0) - self.assertFalse(handle.finished) + cleanup = raised.exception + self.assertFalse(cleanup.cleanup_complete) with self.assertRaisesRegex(RuntimeError, "active listener"): app.serve(FakeRuntime(), port=0) - handle.finalize() - self.assertTrue(handle.finished) + self.assertTrue(cleanup.retry_cleanup()) + next_handle = app.serve(FakeRuntime(), port=0) + next_handle.finalize() + + def test_abandoned_finalization_error_warns_and_retains_cleanup(self) -> None: + runtime = FakeRuntime() + runtime.kernel.close_failures[id(runtime.kernel.listener)] = 3 + app = SmallServer() + + with patch("smallserver.app._default_runtime_factory", return_value=runtime): + with self.assertRaises(ServerFinalizationError) as raised: + app.listen(port=0) + + transaction = raised.exception._transaction + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter("always", ResourceWarning) + raised.exception = None + gc.collect() + + self.assertEqual(len(caught), 1) + self.assertIs(caught[0].category, ResourceWarning) + self.assertIn("ServerFinalizationError", str(caught[0].message)) + self.assertEqual(len(transaction.retry()), 1) + self.assertEqual(len(transaction.retry()), 0) next_handle = app.serve(FakeRuntime(), port=0) next_handle.finalize() @@ -230,6 +266,74 @@ def __init__(self) -> None: with self.assertRaisesRegex(TypeError, "boolean"): SmallServer().listen(runtime=FakeRuntime(), start=1) # type: ignore[arg-type] + def test_runtime_without_cancel_is_rejected_without_registry_growth(self) -> None: + from SmallPackage import SmallOS + + class RuntimeWithoutCancellation: + def __init__(self) -> None: + self.inner = SmallOS() + self.kernel = FakeKernel() + + def fork(self, children) -> object: + return self.inner.fork(children) + + def resume_task(self, task) -> object: + return self.inner.resume_task(task) + + runtime = RuntimeWithoutCancellation() + before = len(runtime.inner.tasks) + + with self.assertRaisesRegex(TypeError, "cancel_task"): + SmallServer().serve(runtime, port=0) # type: ignore[arg-type] + + self.assertEqual(len(runtime.inner.tasks), before) + self.assertEqual(runtime.kernel.calls, []) + + def test_simultaneous_invocations_reserve_once_and_bind_once(self) -> None: + gate = threading.Barrier(2) + bind_calls: list[object] = [] + bind_lock = threading.Lock() + + class RacingKernel(FakeKernel): + def __init__(self) -> None: + super().__init__() + self._first_capability_check = True + + def supports_tcp_server(self) -> bool: + result = super().supports_tcp_server() + if self._first_capability_check: + self._first_capability_check = False + gate.wait(timeout=2) + return result + + def socket_bind(self, stream: object, address: object) -> None: + with bind_lock: + bind_calls.append(stream) + super().socket_bind(stream, address) + + app = SmallServer() + handles: list[ServerHandle] = [] + errors: list[BaseException] = [] + + def invoke() -> None: + try: + handles.append(app.serve(FakeRuntime(RacingKernel()), port=0)) + except BaseException as exc: + errors.append(exc) + + workers = [threading.Thread(target=invoke) for _ in range(2)] + for worker in workers: + worker.start() + for worker in workers: + worker.join(timeout=3) + + self.assertTrue(all(not worker.is_alive() for worker in workers)) + self.assertEqual(len(handles), 1) + self.assertEqual(len(bind_calls), 1) + self.assertEqual(len(errors), 1) + self.assertRegex(str(errors[0]), "active listener") + handles[0].finalize() + def test_acquisition_cleanup_retains_invocation_until_retry(self) -> None: runtime = FakeRuntime() primary = RuntimeError("listen setup failed") @@ -343,6 +447,40 @@ def test_finalization_is_idempotent_with_live_connections(self) -> None: ) self.assertEqual(runtime.cancelled.count(client_task), 1) + def test_connection_task_cancellation_is_attempted_once_per_finalize(self) -> None: + class RetryCancellationRuntime(FakeRuntime): + def __init__(self) -> None: + super().__init__() + self.attempts: dict[int, int] = {} + + def cancel_task(self, task) -> int: + identity = id(task) + self.attempts[identity] = self.attempts.get(identity, 0) + 1 + if ( + getattr(task, "fail_first_cancel", False) + and self.attempts[identity] == 1 + ): + raise RuntimeError("cancel failed") + return super().cancel_task(task) + + class ConnectionTask: + fail_first_cancel = True + + runtime = RetryCancellationRuntime() + handle = SmallServer().serve(runtime, port=0) + client = TransportHandle(OpaqueHandle("retry-client")) + connection_task = ConnectionTask() + handle._connections[id(client)] = (client, connection_task) + handle._owned_tasks.append(connection_task) + + handle.finalize() + + self.assertEqual(runtime.attempts[id(connection_task)], 1) + self.assertFalse(handle.finished) + handle.finalize() + self.assertEqual(runtime.attempts[id(connection_task)], 2) + self.assertTrue(handle.finished) + def test_default_runtime_configuration_failure_is_framework_owned(self) -> None: with patch.dict(sys.modules, {"SmallPackage": None}): with self.assertRaises(ServerConfigurationError): diff --git a/tests/test_server.py b/tests/test_server.py index ce2a30c..ad225b2 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -116,6 +116,9 @@ def fork(self, tasks) -> None: def resume_task(self, task) -> None: pass + def cancel_task(self, task) -> None: + pass + runtime = Runtime() primary = RuntimeError("listen setup failed") runtime.kernel.operation_errors["listen"] = primary @@ -144,6 +147,9 @@ def fork(self, tasks) -> None: def resume_task(self, task) -> None: pass + def cancel_task(self, task) -> None: + pass + runtime = Runtime() runtime.kernel.invalid_wait_objects.add(id(runtime.kernel.wakeup.wait_object)) runtime.kernel.wakeup.close_failures = 2 @@ -232,6 +238,9 @@ def fork(self, tasks) -> None: def resume_task(self, task) -> None: pass + def cancel_task(self, task) -> None: + pass + for interrupt in (KeyboardInterrupt("stop"), SystemExit(7)): for close_failures in (0, 1): with self.subTest( From 5cc5236e4126c6bdb546257c27176cecab9a5ce0 Mon Sep 17 00:00:00 2001 From: MikiEEE <35975462+MikiEEE@users.noreply.github.com> Date: Sat, 22 Aug 2026 01:44:47 -0500 Subject: [PATCH 18/53] feat: add bounded HTTP/2 prior-knowledge server --- pyproject.toml | 3 +- smallserver/__init__.py | 2 + smallserver/app.py | 188 +++++++++++++++- smallserver/http2.py | 484 ++++++++++++++++++++++++++++++++++++++++ smallserver/server.py | 19 +- tests/test_http2.py | 276 +++++++++++++++++++++++ 6 files changed, 965 insertions(+), 7 deletions(-) create mode 100644 smallserver/http2.py create mode 100644 tests/test_http2.py diff --git a/pyproject.toml b/pyproject.toml index 337debe..df79c26 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"] [tool.setuptools.packages.find] include = ["smallserver*"] diff --git a/smallserver/__init__.py b/smallserver/__init__.py index 6600810..b48c309 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 .server import ServerConfig, ServerHandle if TYPE_CHECKING: @@ -31,6 +32,7 @@ def __getattr__(name: str) -> Any: "AdapterShutdownError", "Headers", "HTTPError", + "HTTP2Config", "Request", "Response", "ServerConfig", diff --git a/smallserver/app.py b/smallserver/app.py index 6815502..d413545 100644 --- a/smallserver/app.py +++ b/smallserver/app.py @@ -24,10 +24,32 @@ _CleanupTransaction, ) from .http import Request, Response +from .http2 import HTTP2Config, H2Protocol, require_http2 from .server import HTTPParseError, HTTPRequestParser, ServerConfig, ServerHandle Handler = Callable[[Request], Awaitable[Response]] _METHODS = frozenset({"GET", "POST", "PUT", "PATCH", "DELETE"}) +_HTTP2_WRITER_SIGNAL = 30 + + +class _H2ConnectionState: + def __init__(self, protocol: H2Protocol) -> None: + self.protocol = protocol + self.writer_task: Any = None + self.handlers: dict[int, Any] = {} + self.closing = False + self.shutdown_requested = False + self.close_error_code = 0 + + 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() class _NoThreadLock: @@ -189,6 +211,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. @@ -198,7 +223,9 @@ def serve( scheduler thread instead. """ 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( @@ -207,6 +234,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: ... @@ -218,6 +247,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: ... @@ -229,6 +260,8 @@ def listen( port: int = 8000, config: ServerConfig | None = None, *, + protocol: str = "http1", + http2_config: HTTP2Config | None = None, runtime: _StartableRuntime, start: bool, ) -> ServerHandle: ... @@ -239,6 +272,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: @@ -257,7 +292,9 @@ def listen( if runtime is None: runtime = _default_runtime_factory() 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 @@ -300,6 +337,8 @@ def _bind_and_schedule( host: str, port: int, config: ServerConfig | None, + protocol: str, + http2_config: HTTP2Config | None, ) -> ServerHandle: """Shared validated bind-and-schedule core for ``serve`` and ``listen``.""" from SmallPackage import SmallTask @@ -310,6 +349,14 @@ def _bind_and_schedule( raise ValueError("port must be an integer between 0 and 65535") if config is not None and not isinstance(config, ServerConfig): raise TypeError("config must be a ServerConfig or None") + 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: @@ -365,7 +412,14 @@ def release(completed: ServerHandle) -> None: try: handle = ServerHandle( - runtime, transport, listener, wakeup, config, on_finalized=release + runtime, + transport, + listener, + wakeup, + config, + on_finalized=release, + protocol=protocol, + protocol_config=http2_config or HTTP2Config(), ) except BaseException as primary_error: transaction = _CleanupTransaction() @@ -466,9 +520,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", ) @@ -552,3 +611,124 @@ 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(handle._protocol_config) + state = _H2ConnectionState(protocol) + primary_error: BaseException | None = None + handle._graceful_connections.add(id(client)) + handle._graceful_closers[id(client)] = state.request_shutdown + try: + 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 + handle._owned_tasks.append(writer) + handle._runtime.fork(writer) + 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 + try: + ready = protocol.receive_data(chunk) + 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: + 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() + except Exception as exc: + primary_error = exc + except BaseException as exc: + primary_error = exc + raise + finally: + state.closing = True + for handler in tuple(state.handlers.values()): + handle._cancel_or_retain_task(handler) + state.handlers.clear() + if state.writer_task is not None: + state.wake_writer() + handle._cancel_or_retain_task(state.writer_task) + if state.writer_task in handle._owned_tasks: + handle._owned_tasks.remove(state.writer_task) + try: + goaway = protocol.close(state.close_error_code) + if goaway: + 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: + try: + try: + response = await self.dispatch(request) + except Exception: + response = Response.text("internal server error", status=500) + state.protocol.queue_response(stream_id, response) + state.wake_writer() + finally: + 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) + if not handle._transport.close_safely(client): + error = client.close_error or RuntimeError( + "kernel connection close failed" + ) + handle._connection_close_failed(error, task) + return + while not state.closing: + payload = state.protocol.flush() + if not payload: + break + await handle._transport.send_all(task, client, payload) + finally: + if task in handle._owned_tasks: + handle._owned_tasks.remove(task) diff --git a/smallserver/http2.py b/smallserver/http2.py new file mode 100644 index 0000000..49dc184 --- /dev/null +++ b/smallserver/http2.py @@ -0,0 +1,484 @@ +"""Lazy, bounded HTTP/2 protocol state built on optional hyper-h2.""" + +from __future__ import annotations + +from dataclasses import dataclass +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_frame_size: int = 16 * 1024 + + def __post_init__(self) -> None: + for name, value in self.__dict__.items(): + if type(value) is not int or value <= 0: + raise ValueError("{} must be a positive integer".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" + ) + + +@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 + + +@dataclass +class _OutboundStream: + body: bytes + offset: int = 0 + + +class _FrameBudget: + """Account compressed header blocks without implementing frame semantics.""" + + def __init__(self, config: HTTP2Config) -> None: + self._config = config + self._preface = bytearray() + self._header = bytearray() + self._remaining = 0 + self._frame_type = 0 + self._frame_stream = 0 + self._frame_flags = 0 + self._header_stream: int | None = None + self._header_bytes = 0 + + def feed(self, data: bytes) -> None: + view = memoryview(data) + offset = 0 + if len(self._preface) < len(HTTP2_CLIENT_PREFACE): + needed = len(HTTP2_CLIENT_PREFACE) - len(self._preface) + take = min(needed, len(view)) + self._preface.extend(view[:take]) + offset += take + expected = HTTP2_CLIENT_PREFACE[: len(self._preface)] + if bytes(self._preface) != expected: + raise ValueError("invalid HTTP/2 client preface") + if offset == len(view): + return + + while offset < len(view): + if self._remaining == 0: + needed = 9 - len(self._header) + take = min(needed, len(view) - offset) + self._header.extend(view[offset : offset + take]) + offset += take + if len(self._header) < 9: + return + 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_type = self._header[3] + self._frame_flags = self._header[4] + self._frame_stream = int.from_bytes(self._header[5:9], "big") & 0x7FFFFFFF + self._header.clear() + self._remaining = length + if length == 0: + self._finish_frame() + continue + + take = min(self._remaining, len(view) - offset) + if self._frame_type in (0x1, 0x9): + self._account_header_bytes(take) + self._remaining -= take + offset += take + if self._remaining == 0: + self._finish_frame() + + def _account_header_bytes(self, count: int) -> None: + if self._frame_type == 0x1 and self._header_stream is None: + self._header_stream = self._frame_stream + self._header_bytes = 0 + self._header_bytes += count + 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: + if self._frame_type in (0x1, 0x9) and self._frame_flags & 0x4: + self._header_stream = None + self._header_bytes = 0 + + +def require_http2() -> None: + """Fail clearly without importing hyper-h2 on HTTP/1.1 paths.""" + try: + import h2 # type: ignore[import-not-found] + except ImportError as exc: + raise ServerConfigurationError( + "HTTP/2 requires the optional dependency; 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") + + +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, + ) + self.connection = H2Connection(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._cancelled_streams: list[int] = [] + 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 + + def initiate(self) -> bytes: + self.connection.initiate_connection() + return self.connection.data_to_send() + + def receive_data(self, data: bytes) -> tuple[H2ReadyRequest, ...]: + self._frames.feed(data) + events = self.connection.receive_data(data) + ready: list[H2ReadyRequest] = [] + 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: + ready.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 + return tuple(ready) + + 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 = 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()) + + def _decode_request_headers(self, raw_headers: Any) -> tuple[str, str, Headers]: + 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") + 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))) + return pseudo[":method"], pseudo[":path"], Headers(regular) + + 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 next_connection_size > self.config.max_connection_buffer_bytes + ): + self._reset_stream(stream_id, self._error_codes.ENHANCE_YOUR_CALM) + return + stream.body.extend(data) + self._buffered_request_bytes = next_connection_size + + def _stream_ended(self, stream_id: int) -> H2ReadyRequest | None: + stream = self._inbound.pop(stream_id, None) + if stream is None: + return None + self._buffered_request_bytes -= len(stream.body) + self.last_processed_stream_id = max(self.last_processed_stream_id, stream_id) + request = Request( + stream.method, + stream.path, + stream.headers, + bytes(stream.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 + body_size = len(response.body) + if ( + body_size > self.config.max_response_body_bytes + or 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) + continue + assert response is not None + self._start_response(stream_id, response) + + 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 + if end_stream: + self._outbound.pop(stream_id, None) + self._active_streams.discard(stream_id) + return self.connection.data_to_send() + + 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: + inbound = self._inbound.pop(stream_id, None) + if inbound is not None: + self._buffered_request_bytes -= len(inbound.body) + 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 _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._active_streams.clear() + self._buffered_request_bytes = 0 + self._pending_output_bytes = 0 + return self.connection.data_to_send() diff --git a/smallserver/server.py b/smallserver/server.py index 4a1cad7..4c313ca 100644 --- a/smallserver/server.py +++ b/smallserver/server.py @@ -129,12 +129,16 @@ def __init__( wakeup: WakeupChannel | None, config: ServerConfig, on_finalized: Callable[[ServerHandle], None] | None = None, + protocol: str = "http1", + protocol_config: Any = None, ) -> None: self._runtime = runtime self._transport = transport self._listener = listener self._wakeup = wakeup self._config = config + self._protocol = protocol + self._protocol_config = protocol_config self._address = transport.local_address(listener) self._on_finalized = on_finalized self._close_requested = False @@ -152,6 +156,8 @@ def __init__( self._closing_connections: dict[int, TransportHandle] = {} self._pending_task_cancellations: 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]: @@ -322,10 +328,17 @@ def _finish_close( self._cancelled_task_ids.add(id(task)) elif task is not current_task: try: - self._runtime.resume_task(task) + closer = self._graceful_closers.get(identity) + if closer is not None: + closer() + else: + self._runtime.resume_task(task) except BaseException: pass - if task is not current_task: + if ( + task is not current_task + and not (not owner_thread and identity in self._graceful_connections) + ): self._connections.pop(identity, None) self._close_or_retain(connection, current_task) @@ -438,6 +451,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_http2.py b/tests/test_http2.py new file mode 100644 index 0000000..4a853cd --- /dev/null +++ b/tests/test_http2.py @@ -0,0 +1,276 @@ +import builtins +import socket +import threading +import unittest +from unittest.mock import patch + +from h2.config import H2Configuration +from h2.connection import H2Connection +from h2.events import ConnectionTerminated, DataReceived, ResponseReceived, StreamEnded + +from SmallPackage import SmallOS, Unix + +from smallserver import HTTP2Config, Response, SmallServer +from smallserver.errors import ServerConfigurationError +from smallserver.http2 import H2Protocol + + +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_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_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=8, + ) + 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(), ()) + + +class HTTP2ServerIntegrationTests(unittest.TestCase): + _pair = HTTP2ProtocolTests._pair + + 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_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), + ) + 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) + + +if __name__ == "__main__": + unittest.main() From 2b59c928d50d2d8fdb8c9218ddfa296244ddf3c3 Mon Sep 17 00:00:00 2001 From: MikiEEE <35975462+MikiEEE@users.noreply.github.com> Date: Sat, 22 Aug 2026 01:44:56 -0500 Subject: [PATCH 19/53] docs: document HTTP/2 prior-knowledge usage --- README.md | 45 ++++++++++++++++++++++++-- examples/http2_prior_knowledge.py | 27 ++++++++++++++++ guide/http2.md | 53 +++++++++++++++++++++++++++++++ 3 files changed, 122 insertions(+), 3 deletions(-) create mode 100644 examples/http2_prior_knowledge.py create mode 100644 guide/http2.md diff --git a/README.md b/README.md index 3c7598b..3dd7769 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,9 @@ # 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. +bounded HTTP/1.1 and optional cleartext HTTP/2 servers, static async routing for +GET, POST, PUT, PATCH, and DELETE, and explicit escape hatches for blocking and +asyncio-native libraries. ## Current scope @@ -18,7 +19,8 @@ The current package provides an HTTP/1.1 baseline over a SmallOS runtime. It can - 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. +HTTP/1.1 keep-alive/pipelining, TLS, and path parameters are not implemented +yet. HTTP/2 currently supports cleartext prior knowledge only. ## Install for development @@ -28,6 +30,12 @@ python3 -m pip install -e . python3 -m unittest discover -s tests -v ``` +Install the bounded optional hyper-h2 4.x integration when serving HTTP/2: + +```bash +python3 -m pip install -e '.[http2]' +``` + SmallOS is installed from the canonical `master` branch in `requirements.txt`. It owns scheduling, socket readiness, and foreign execution adapters. @@ -65,6 +73,37 @@ async def health(request): app.listen(host="127.0.0.1", port=8000) ``` +## Cleartext HTTP/2 + +HTTP/2 uses the same routes, `Request`, `Response`, and SmallOS runtime. Select +it explicitly on a listener; protocol auto-detection and h2c upgrade are not +performed: + +```python +from smallserver import HTTP2Config, Response, SmallServer + +app = SmallServer() + +@app.get("/health") +async def health(request): + return Response.json({"status": "ok", "protocol": request.version}) + +app.listen( + host="127.0.0.1", + port=8000, + protocol="http2", + http2_config=HTTP2Config(max_concurrent_streams=32), +) +``` + +Run `python3 examples/http2_prior_knowledge.py`, then use an HTTP/2-capable +client such as `curl --http2-prior-knowledge http://127.0.0.1:8000/health`. +See [the HTTP/2 guide](guide/http2.md) for limits and lifecycle behavior. + +TLS/ALPN is explicitly deferred: SmallOS does not yet expose a server-side TLS +kernel capability. SmallServer does not bypass that boundary with direct +`ssl` or `socket` access. + 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 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/http2.md b/guide/http2.md new file mode 100644 index 0000000..88747f8 --- /dev/null +++ b/guide/http2.md @@ -0,0 +1,53 @@ +# 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. 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. +Connection shutdown emits GOAWAY and then releases the connection through the +SmallOS kernel transport. + +## 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. From be3e8e960ebdc28e4375fa061fcb2903ecc9d406 Mon Sep 17 00:00:00 2001 From: MikiEEE <35975462+MikiEEE@users.noreply.github.com> Date: Sat, 22 Aug 2026 01:46:48 -0500 Subject: [PATCH 20/53] fix: retain HTTP/2 graceful cleanup fallback --- smallserver/server.py | 22 +++++++++++++++------- 1 file changed, 15 insertions(+), 7 deletions(-) diff --git a/smallserver/server.py b/smallserver/server.py index 4c313ca..7df4dc5 100644 --- a/smallserver/server.py +++ b/smallserver/server.py @@ -317,6 +317,7 @@ def _finish_close( self._cancelled_task_ids.add(identity) for identity, (connection, task) in list(self._connections.items()): + graceful_requested = False if owner_thread: if ( task is not current_task @@ -327,17 +328,24 @@ def _finish_close( if self._cancel_or_retain_task(task): self._cancelled_task_ids.add(id(task)) elif task is not current_task: - try: - closer = self._graceful_closers.get(identity) - if closer is not None: + closer = self._graceful_closers.get(identity) + if closer is not None: + try: closer() - else: + graceful_requested = True + except BaseException: + try: + self._runtime.resume_task(task) + except BaseException: + pass + else: + try: self._runtime.resume_task(task) - except BaseException: - pass + except BaseException: + pass if ( task is not current_task - and not (not owner_thread and identity in self._graceful_connections) + and not (not owner_thread and graceful_requested) ): self._connections.pop(identity, None) self._close_or_retain(connection, current_task) From ba49eec9a8ab7317608eac6c2d96d5db005ee3eb Mon Sep 17 00:00:00 2001 From: MikiEEE <35975462+MikiEEE@users.noreply.github.com> Date: Sat, 22 Aug 2026 02:00:53 -0500 Subject: [PATCH 21/53] feat: add bounded WebSocket server --- pyproject.toml | 1 + smallserver/__init__.py | 16 + smallserver/app.py | 116 +++++ smallserver/http.py | 4 + smallserver/server.py | 54 +- smallserver/websocket.py | 843 +++++++++++++++++++++++++++++++ tests/test_websocket.py | 657 ++++++++++++++++++++++++ tests/typing/websocket_routes.py | 14 + 8 files changed, 1694 insertions(+), 11 deletions(-) create mode 100644 smallserver/websocket.py create mode 100644 tests/test_websocket.py create mode 100644 tests/typing/websocket_routes.py diff --git a/pyproject.toml b/pyproject.toml index ced2d56..af3cbe6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -15,6 +15,7 @@ dependencies = [] [project.optional-dependencies] dev = ["build>=1.2"] regex-routes = ["regex>=2023.10.3,<2027"] +websocket = ["wsproto>=1.2,<2"] [tool.setuptools.packages.find] include = ["smallserver*"] diff --git a/smallserver/__init__.py b/smallserver/__init__.py index db879cb..a03bde9 100644 --- a/smallserver/__init__.py +++ b/smallserver/__init__.py @@ -18,6 +18,15 @@ RoutePathTooLarge, ) from .server import ServerConfig, ServerHandle +from .websocket import ( + WebSocket, + WebSocketCapacityError, + WebSocketConfig, + WebSocketDisconnect, + WebSocketMessage, + WebSocketStateError, + WebSocketUnavailable, +) if TYPE_CHECKING: from .adapters import AdapterRegistry, AdapterShutdownError, http_error_from_adapter @@ -51,5 +60,12 @@ def __getattr__(name: str) -> Any: "ServerHandle", "ServerStartupError", "SmallServer", + "WebSocket", + "WebSocketCapacityError", + "WebSocketConfig", + "WebSocketDisconnect", + "WebSocketMessage", + "WebSocketStateError", + "WebSocketUnavailable", "http_error_from_adapter", ] diff --git a/smallserver/app.py b/smallserver/app.py index 6c8f8a1..ea97f34 100644 --- a/smallserver/app.py +++ b/smallserver/app.py @@ -40,8 +40,19 @@ ServerHandle, run_route_observer, ) +from .websocket import ( + WebSocket, + WebSocketConfig, + WebSocketUnavailable, + _WebSocketRoute, + _WebSocketState, + _is_upgrade_attempt, + _validate_upgrade, + run_websocket_connection, +) Handler = Callable[[Request], Awaitable[Response]] +WebSocketHandler = Callable[[WebSocket], Awaitable[None]] RouteErrorObserver = Callable[[RouteErrorEvent], None] @@ -139,11 +150,18 @@ def __init__( regex_config: RegexRouteConfig | None = None, *, route_error_observer: RouteErrorObserver | None = None, + websocket_config: WebSocketConfig | None = None, ) -> None: 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) self._route_error_observer = route_error_observer + if websocket_config is not None and not isinstance( + websocket_config, WebSocketConfig + ): + raise TypeError("websocket_config must be a WebSocketConfig or None") + self._websocket_config = websocket_config or WebSocketConfig() + self._websocket_routes: dict[str, _WebSocketRoute] = {} self._active_invocation: object | ServerHandle | None = None self._invocation_lock: Any = ( allocate_lock() if allocate_lock is not None else _NoThreadLock() @@ -200,6 +218,50 @@ def patch(self, path: str) -> Callable[[Handler], Handler]: def delete(self, path: str) -> Callable[[Handler], Handler]: return self.route(path, ("DELETE",)) + def websocket( + self, + path: str, + *, + origins: Iterable[str] | None = None, + subprotocols: Iterable[str] = (), + ) -> Callable[[WebSocketHandler], WebSocketHandler]: + """Register a static HTTP/1.1 WebSocket Upgrade route.""" + if not isinstance(path, str) or not path.startswith("/"): + raise ValueError("WebSocket route path must start with '/'") + if "?" in path or "#" in path: + raise ValueError("WebSocket route path must not contain query or fragment") + if path in self._websocket_routes: + raise ValueError("WebSocket route already registered: {}".format(path)) + origin_set: frozenset[str] | None = None + if origins is not None: + if isinstance(origins, str): + raise TypeError("WebSocket origins must be an iterable of strings") + origin_set = frozenset(origins) + if any(not isinstance(origin, str) or not origin for origin in origin_set): + raise ValueError("WebSocket origins must be non-empty strings") + if isinstance(subprotocols, str): + raise TypeError("WebSocket subprotocols must be an iterable of tokens") + protocols = tuple(dict.fromkeys(subprotocols)) + if any( + not isinstance(protocol, str) + or not protocol + or any(character in protocol for character in "()<>@,;:\\\"/[]?={} \t") + for protocol in protocols + ): + raise ValueError("WebSocket subprotocols must be valid HTTP tokens") + + def register(handler: WebSocketHandler) -> WebSocketHandler: + if not callable(handler): + raise TypeError("WebSocket handler must be callable") + if path in self._websocket_routes: + raise ValueError("WebSocket route already registered: {}".format(path)) + self._websocket_routes[path] = _WebSocketRoute( + handler, origin_set, protocols + ) + return handler + + return register + def route_regex(self, pattern: str, methods: Iterable[str]) -> Callable[[Handler], Handler]: """Register a timeout-bounded full-path regular-expression route.""" normalized = self._router.normalize_methods(methods) @@ -585,6 +647,7 @@ async def _connection_loop( handle._config.max_header_count, handle._config.max_body_bytes, handle._config.max_request_target_bytes, + preserve_trailing_data=True, ) primary_error: BaseException | None = None route_error_event: RouteErrorEvent | None = None @@ -607,6 +670,25 @@ async def _connection_loop( return if request is None: continue + if _is_upgrade_attempt(request): + response = await self._dispatch_websocket( + task, + handle, + client, + request, + parser.trailing_data, + ) + if response is not None: + await self._send_response(task, handle, client, response) + return + if parser.trailing_data: + await self._send_response( + task, + handle, + client, + Response.text("pipelined requests are not supported", status=400), + ) + return try: response = await self.dispatch(request) except RouteMatchTimeout as exc: @@ -628,6 +710,40 @@ async def _connection_loop( observer_channel.enqueue(route_error_event, task) handle._connection_finished(task, client, primary_error) + async def _dispatch_websocket( + self, + task: Any, + handle: ServerHandle, + client: TransportHandle, + request: Request, + trailing_data: bytes, + ) -> Response | None: + route = self._websocket_routes.get(request.path) + if route is None: + return Response.text("not found", status=404) + invalid = _validate_upgrade(request, route) + if invalid is not None: + return invalid + if len(handle._websocket_states) >= min( + self._websocket_config.max_connections, + handle._config.max_connections, + ): + return Response.text("WebSocket capacity reached", status=503) + try: + state = _WebSocketState( + handle._runtime, + handle._transport, + client, + request, + route, + self._websocket_config, + trailing_data, + ) + except WebSocketUnavailable as exc: + return Response.text(str(exc), status=503) + await run_websocket_connection(task, state, handle) + return None + async def _send_response( self, task: Any, diff --git a/smallserver/http.py b/smallserver/http.py index f90afbd..b1b2574 100644 --- a/smallserver/http.py +++ b/smallserver/http.py @@ -11,14 +11,18 @@ _TOKEN = re.compile(r"^[!#$%&'*+.^_`|~0-9A-Za-z-]+$") _REASONS = { + 101: "Switching Protocols", 200: "OK", 201: "Created", 204: "No Content", 400: "Bad Request", + 403: "Forbidden", 404: "Not Found", 405: "Method Not Allowed", 413: "Payload Too Large", 414: "URI Too Long", + 408: "Request Timeout", + 426: "Upgrade Required", 500: "Internal Server Error", 503: "Service Unavailable", } diff --git a/smallserver/server.py b/smallserver/server.py index d48d95b..d4f2135 100644 --- a/smallserver/server.py +++ b/smallserver/server.py @@ -31,13 +31,21 @@ def __init__( max_header_count: int, max_body_bytes: int, max_request_target_bytes: int = 8 * 1024, + preserve_trailing_data: bool = False, ) -> None: self._max_header_bytes = max_header_bytes self._max_header_count = max_header_count self._max_body_bytes = max_body_bytes self._max_request_target_bytes = max_request_target_bytes + self._preserve_trailing_data = preserve_trailing_data self._buffer = bytearray() self._request_head: tuple[str, str, Headers, int] | None = None + self._trailing_data = b"" + + @property + def trailing_data(self) -> bytes: + """Bytes received after the request body for an explicit protocol handoff.""" + return self._trailing_data def feed(self, data: bytes) -> Request | None: self._buffer.extend(data) @@ -54,17 +62,19 @@ def feed(self, data: bytes) -> Request | None: del self._buffer[:header_length] method, raw_target, headers, content_length = self._request_head - if len(self._buffer) > content_length: + if len(self._buffer) > content_length and not self._preserve_trailing_data: raise HTTPParseError(400, "pipelined requests are not supported") if len(self._buffer) < content_length: return None try: path, separator, query_string = raw_target.partition("?") + body = bytes(self._buffer[:content_length]) + self._trailing_data = bytes(self._buffer[content_length:]) return Request( method, path, headers, - bytes(self._buffer), + body, "HTTP/1.1", raw_target=raw_target, query_string=query_string if separator else "", @@ -177,19 +187,26 @@ def stop(self) -> None: self.accepting = False self.dropped += len(self.events) self.events.clear() + if self.task is not None: + accept_signal = getattr(self.task, "acceptSignal", None) + if callable(accept_signal): + accept_signal(_ROUTE_OBSERVER_SIGNAL) async def run_route_observer(task: Any, channel: RouteObserverChannel) -> None: """Drain sanitized events on a dedicated SmallOS task.""" - while channel.accepting: - while channel.events: - event = channel.events.popleft() - try: - channel.observer(event) - except BaseException: - channel.failures += 1 - if channel.accepting: - await task.wait_signal(_ROUTE_OBSERVER_SIGNAL) + try: + while channel.accepting: + while channel.events: + event = channel.events.popleft() + try: + channel.observer(event) + except BaseException: + channel.failures += 1 + if channel.accepting: + await task.wait_signal(_ROUTE_OBSERVER_SIGNAL) + finally: + channel.task = None class ServerHandle: @@ -229,6 +246,7 @@ def __init__( self._connections: dict[int, tuple[TransportHandle, Any]] = {} self._closing_connections: dict[int, TransportHandle] = {} self._pending_task_cancellations: dict[int, Any] = {} + self._websocket_states: dict[int, Any] = {} self._capacity_waiting = False @property @@ -335,6 +353,10 @@ async def close_from_task(self, task: Any) -> None: return self._close_requested = True self._finalization_attempted = True + for state in tuple(self._websocket_states.values()): + request_shutdown = getattr(state, "request_shutdown", None) + if callable(request_shutdown): + request_shutdown() self._finish_close(current_task=task) def _listener_failed(self, exc: BaseException, task: Any) -> None: @@ -369,6 +391,10 @@ def _finish_close( return self._close_requested = True self._finalization_attempted = True + for state in tuple(self._websocket_states.values()): + request_shutdown = getattr(state, "request_shutdown", None) + if callable(request_shutdown): + request_shutdown() channel = self._route_observer_channel if channel is not None and channel.accepting: channel.stop() @@ -402,6 +428,7 @@ def _finish_close( self._cancelled_task_ids.add(identity) for identity, (connection, task) in list(self._connections.items()): + websocket_owned = identity in self._websocket_states if owner_thread: if ( task is not current_task @@ -416,6 +443,10 @@ def _finish_close( 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: self._connections.pop(identity, None) self._close_or_retain(connection, current_task) @@ -546,6 +577,7 @@ def _update_finished(self) -> None: and not self._connections and not self._closing_connections and not self._pending_task_cancellations + and not self._websocket_states ) if self._finished: self._cleanup_errors.clear() diff --git a/smallserver/websocket.py b/smallserver/websocket.py new file mode 100644 index 0000000..48fd91a --- /dev/null +++ b/smallserver/websocket.py @@ -0,0 +1,843 @@ +"""Bounded RFC 6455 WebSocket support driven by SmallOS tasks.""" + +from __future__ import annotations + +import base64 +from collections import deque +from dataclasses import dataclass +import hashlib +import importlib +import inspect +import math +import time +from typing import Any, AsyncIterator, Callable, Mapping + +from .http import Headers, Request, Response + +_GUID = b"258EAFA5-E914-47DA-95CA-C5AB0DC85B11" +_DECISION_SIGNAL = 24 +_INBOX_SIGNAL = 25 +_OUTBOX_SIGNAL = 26 +_ACK_SIGNAL = 27 + + +class WebSocketUnavailable(RuntimeError): + """The optional WebSocket protocol dependency is unavailable.""" + + +class WebSocketStateError(RuntimeError): + """A WebSocket operation is invalid in the current lifecycle state.""" + + +class WebSocketCapacityError(RuntimeError): + """A bounded WebSocket mailbox cannot accept another item.""" + + +class WebSocketDisconnect(Exception): + """The peer or server closed a WebSocket connection.""" + + def __init__(self, code: int = 1006, reason: str = "") -> None: + self.code = code + self.reason = reason + super().__init__("WebSocket disconnected ({})".format(code)) + + +@dataclass(frozen=True) +class WebSocketMessage: + """One complete text or binary WebSocket message.""" + + data: str | bytes + + @property + def is_text(self) -> bool: + return isinstance(self.data, str) + + @property + def is_binary(self) -> bool: + return isinstance(self.data, bytes) + + @property + def text(self) -> str: + if not isinstance(self.data, str): + raise TypeError("WebSocket message is binary") + return self.data + + @property + def bytes(self) -> bytes: + if not isinstance(self.data, bytes): + raise TypeError("WebSocket message is text") + return self.data + + +@dataclass(frozen=True) +class WebSocketConfig: + """Finite resource and lifetime limits for WebSocket connections.""" + + max_frame_payload_bytes: int = 1024 * 1024 + max_message_bytes: int = 1024 * 1024 + max_inbound_messages: int = 16 + max_inbound_bytes: int = 2 * 1024 * 1024 + max_outbound_commands: int = 16 + max_outbound_bytes: int = 2 * 1024 * 1024 + receive_chunk_bytes: int = 16 * 1024 + write_chunk_bytes: int = 16 * 1024 + max_connections: int = 100 + handshake_timeout: float = 10.0 + idle_timeout: float = 300.0 + pong_timeout: float = 10.0 + close_timeout: float = 5.0 + deadline_resolution: float = 0.05 + + def __post_init__(self) -> None: + integer_fields = ( + "max_frame_payload_bytes", + "max_message_bytes", + "max_inbound_messages", + "max_inbound_bytes", + "max_outbound_commands", + "max_outbound_bytes", + "receive_chunk_bytes", + "write_chunk_bytes", + "max_connections", + ) + for name in integer_fields: + value = getattr(self, name) + 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", + "pong_timeout", + "close_timeout", + "deadline_resolution", + ): + value = getattr(self, name) + if type(value) not in (int, float) or not math.isfinite(value) or value <= 0: + raise ValueError("{} must be a finite positive number".format(name)) + if self.max_frame_payload_bytes > self.max_message_bytes: + raise ValueError("max_frame_payload_bytes must not exceed max_message_bytes") + + +@dataclass(frozen=True) +class _WebSocketRoute: + handler: Callable[[WebSocket], Any] + origins: frozenset[str] | None + subprotocols: tuple[str, ...] + + +@dataclass +class _OutboundCommand: + event: Any + size: int + waiter: Any = None + done: bool = False + error: BaseException | None = None + + +@dataclass(frozen=True) +class _WSProtoAPI: + Connection: Any + ConnectionType: Any + TextMessage: Any + BytesMessage: Any + Ping: Any + Pong: Any + CloseConnection: Any + + +def _load_wsproto() -> _WSProtoAPI: + """Import the optional protocol engine only when an upgrade is served.""" + try: + connection = importlib.import_module("wsproto.connection") + events = importlib.import_module("wsproto.events") + except ImportError as exc: + raise WebSocketUnavailable( + "WebSocket routes require the 'smallserver[websocket]' extra" + ) from exc + return _WSProtoAPI( + connection.Connection, + connection.ConnectionType, + events.TextMessage, + events.BytesMessage, + events.Ping, + events.Pong, + events.CloseConnection, + ) + + +class _FrameGuard: + """Validate declared client frame sizes before forwarding payload bytes.""" + + def __init__(self, max_payload_bytes: int) -> None: + self._max_payload_bytes = max_payload_bytes + self._header = bytearray() + self._header_length: int | None = None + self._payload_remaining = 0 + + def feed(self, data: bytes) -> tuple[bytes, ...]: + chunks: list[bytes] = [] + offset = 0 + while offset < len(data): + if self._payload_remaining: + length = min(self._payload_remaining, len(data) - offset) + chunks.append(data[offset : offset + length]) + offset += length + self._payload_remaining -= length + if self._payload_remaining == 0: + self._header_length = None + continue + + if self._header_length is None: + needed = 2 - len(self._header) + if needed: + length = min(needed, len(data) - offset) + self._header.extend(data[offset : offset + length]) + offset += length + if len(self._header) < 2: + continue + second = self._header[1] + marker = second & 0x7F + extension = 2 if marker == 126 else 8 if marker == 127 else 0 + self._header_length = 2 + extension + (4 if second & 0x80 else 0) + + needed = self._header_length - len(self._header) + if needed: + length = min(needed, len(data) - offset) + self._header.extend(data[offset : offset + length]) + offset += length + if len(self._header) < self._header_length: + continue + + first, second = self._header[:2] + if not second & 0x80: + raise ValueError("client WebSocket frames must be masked") + marker = second & 0x7F + index = 2 + if marker == 126: + payload_length = int.from_bytes(self._header[index : index + 2], "big") + index += 2 + if payload_length < 126: + raise ValueError("non-minimal WebSocket frame length") + elif marker == 127: + payload_length = int.from_bytes(self._header[index : index + 8], "big") + index += 8 + if payload_length < 65536 or payload_length >> 63: + raise ValueError("invalid WebSocket frame length") + else: + payload_length = marker + opcode = first & 0x0F + if opcode >= 8 and (not first & 0x80 or payload_length > 125): + raise ValueError("invalid WebSocket control frame") + if payload_length > self._max_payload_bytes: + raise WebSocketCapacityError("WebSocket frame payload is too large") + chunks.append(bytes(self._header)) + self._header.clear() + self._payload_remaining = payload_length + if payload_length == 0: + self._header_length = None + return tuple(chunks) + + +class WebSocket: + """Application-facing WebSocket connection.""" + + def __init__(self, state: _WebSocketState) -> None: + self._state = state + + @property + def request(self) -> Request: + return self._state.request + + @property + def subprotocol(self) -> str | None: + return self._state.subprotocol + + async def accept( + self, + subprotocol: str | None = None, + headers: Mapping[str, str] | None = None, + ) -> None: + await self._state.accept(subprotocol, headers) + + async def reject(self, response: Response) -> None: + await self._state.reject(response) + + async def receive(self) -> WebSocketMessage: + return await self._state.receive() + + async def receive_text(self) -> str: + return (await self.receive()).text + + async def receive_bytes(self) -> bytes: + return (await self.receive()).bytes + + async def send_text(self, value: str) -> None: + if not isinstance(value, str): + raise TypeError("WebSocket text payload must be a string") + await self._state.send_message(value) + + async def send_bytes(self, value: bytes) -> None: + if not isinstance(value, bytes): + raise TypeError("WebSocket binary payload must be bytes") + await self._state.send_message(value) + + async def ping(self, payload: bytes = b"") -> None: + if not isinstance(payload, bytes) or len(payload) > 125: + raise ValueError("WebSocket Ping payload must be at most 125 bytes") + await self._state.ping(payload) + + async def close(self, code: int = 1000, reason: str = "") -> None: + await self._state.close(code, reason) + + def __aiter__(self) -> AsyncIterator[WebSocketMessage]: + return self + + async def __anext__(self) -> WebSocketMessage: + try: + return await self.receive() + except WebSocketDisconnect as exc: + raise StopAsyncIteration from exc + + +class _WebSocketState: + """One coordinator-owned WebSocket protocol and mailbox state.""" + + def __init__( + self, + runtime: Any, + transport: Any, + client: Any, + request: Request, + route: _WebSocketRoute, + config: WebSocketConfig, + trailing_data: bytes, + ) -> None: + self.runtime = runtime + self.transport = transport + self.client = client + self.request = request + self.route = route + self.config = config + self.trailing_data = trailing_data + self.api = _load_wsproto() + self.protocol: Any = None + self.coordinator_task: Any = None + self.handler_task: Any = None + self.reader_task: Any = None + self.writer_task: Any = None + self.deadline_task: Any = None + self.accepted = False + self.rejected = False + self.shutdown = False + self.peer_closed = False + self.close_sent = False + self.subprotocol: str | None = None + self.disconnect: WebSocketDisconnect | None = None + self.handler_error: BaseException | None = None + self.inbox: deque[WebSocketMessage] = deque() + self.inbox_bytes = 0 + self.outbox: deque[_OutboundCommand] = deque() + self.outbox_bytes = 0 + self.writer_busy = False + self._message_kind: type | None = None + self._message_parts: list[str] | list[bytes] = [] + self._message_bytes = 0 + self._guard = _FrameGuard(config.max_frame_payload_bytes) + self.created_at = time.monotonic() + self.last_activity = self.created_at + self.pong_deadline: float | None = None + self.close_deadline: float | None = None + self._children: list[Any] = [] + + def _current_task(self) -> Any: + task = getattr(self.runtime, "cursor", None) + if task is None: + raise WebSocketStateError("WebSocket operations require a running SmallOS task") + return task + + @staticmethod + def _signal(task: Any, signal: int) -> None: + if task is not None: + accept = getattr(task, "acceptSignal", None) + if callable(accept): + accept(signal) + + async def _send_http(self, task: Any, response: Response) -> None: + headers = { + name: value + for name, value in response.headers.items() + if name.lower() != "connection" + } + headers["Connection"] = "close" + payload = Response(response.status, response.body, headers).to_http1() + await self.transport.send_all(task, self.client, payload) + + async def accept( + self, subprotocol: str | None, headers: Mapping[str, str] | None + ) -> None: + task = self._current_task() + if self.accepted or self.rejected: + raise WebSocketStateError("WebSocket handshake is already decided") + if subprotocol is not None: + if subprotocol not in self.route.subprotocols: + raise WebSocketStateError("selected subprotocol is not allowed by the route") + if subprotocol not in _token_list(self.request.headers.get("sec-websocket-protocol")): + raise WebSocketStateError("selected subprotocol was not offered by the client") + extra = Headers(headers or {}) + forbidden = { + "connection", + "upgrade", + "sec-websocket-accept", + "sec-websocket-protocol", + "content-length", + } + if any(name.lower() in forbidden for name in extra): + raise ValueError("handshake headers contain a reserved field") + key = self.request.headers["sec-websocket-key"].encode("ascii") + accept_value = base64.b64encode(hashlib.sha1(key + _GUID).digest()).decode("ascii") + lines = [ + "HTTP/1.1 101 Switching Protocols", + "Upgrade: websocket", + "Connection: Upgrade", + "Sec-WebSocket-Accept: {}".format(accept_value), + ] + if subprotocol is not None: + lines.append("Sec-WebSocket-Protocol: {}".format(subprotocol)) + lines.extend("{}: {}".format(name, value) for name, value in extra.items()) + payload = ("\r\n".join(lines) + "\r\n\r\n").encode("latin-1") + self.protocol = self.api.Connection(self.api.ConnectionType.SERVER) + await self.transport.send_all(task, self.client, payload) + self.subprotocol = subprotocol + self.accepted = True + self.last_activity = time.monotonic() + self._signal(self.coordinator_task, _DECISION_SIGNAL) + + async def reject(self, response: Response) -> None: + task = self._current_task() + if self.accepted or self.rejected: + raise WebSocketStateError("WebSocket handshake is already decided") + if not isinstance(response, Response): + raise TypeError("reject() requires a Response") + if response.status < 300: + raise ValueError("WebSocket rejection response must have status 300 or greater") + await self._send_http(task, response) + self.rejected = True + self._signal(self.coordinator_task, _DECISION_SIGNAL) + + def _require_open(self) -> None: + if not self.accepted: + raise WebSocketStateError("WebSocket must be accepted first") + if self.shutdown or self.disconnect is not None: + raise self.disconnect or WebSocketStateError("WebSocket is closed") + + async def receive(self) -> WebSocketMessage: + task = self._current_task() + if not self.accepted: + raise WebSocketStateError("WebSocket must be accepted first") + while not self.inbox: + if self.disconnect is not None: + raise self.disconnect + await task.wait_signal(_INBOX_SIGNAL) + message = self.inbox.popleft() + self.inbox_bytes -= _message_size(message.data) + return message + + async def send_message(self, value: str | bytes) -> None: + self._require_open() + size = _message_size(value) + if size > self.config.max_message_bytes: + raise WebSocketCapacityError("WebSocket message is too large") + event = ( + self.api.TextMessage(data=value) + if isinstance(value, str) + else self.api.BytesMessage(data=value) + ) + await self._enqueue(event, size, wait=True) + + async def ping(self, payload: bytes) -> None: + self._require_open() + await self._enqueue(self.api.Ping(payload=payload), len(payload), wait=True) + self.pong_deadline = time.monotonic() + self.config.pong_timeout + + async def close(self, code: int, reason: str) -> None: + self._require_open() + if not _valid_close_code(code): + raise ValueError("invalid WebSocket close code") + if not isinstance(reason, str) or len(reason.encode("utf-8")) > 123: + raise ValueError("WebSocket close reason must be at most 123 UTF-8 bytes") + await self._enqueue( + self.api.CloseConnection(code=code, reason=reason), + 2 + len(reason.encode("utf-8")), + wait=True, + ) + self.close_sent = True + self.close_deadline = time.monotonic() + self.config.close_timeout + task = self._current_task() + while not self.peer_closed and time.monotonic() < self.close_deadline: + await task.sleep(min(self.config.deadline_resolution, self.config.close_timeout)) + if self.disconnect is None: + self.disconnect = WebSocketDisconnect(code, reason) + + async def _enqueue(self, event: Any, size: int, *, wait: bool) -> None: + if ( + len(self.outbox) >= self.config.max_outbound_commands + or self.outbox_bytes + size > self.config.max_outbound_bytes + ): + raise WebSocketCapacityError("WebSocket outbound queue is full") + waiter = self._current_task() if wait else None + command = _OutboundCommand(event, size, waiter) + self.outbox.append(command) + self.outbox_bytes += size + self._signal(self.writer_task, _OUTBOX_SIGNAL) + if wait: + while not command.done: + await waiter.wait_signal(_ACK_SIGNAL) + if command.error is not None: + raise command.error + + def _enqueue_control(self, event: Any, size: int = 0) -> bool: + if ( + len(self.outbox) >= self.config.max_outbound_commands + or self.outbox_bytes + size > self.config.max_outbound_bytes + ): + return False + self.outbox.append(_OutboundCommand(event, size)) + self.outbox_bytes += size + self._signal(self.writer_task, _OUTBOX_SIGNAL) + return True + + def _deliver_message(self, value: str | bytes) -> bool: + size = _message_size(value) + if ( + len(self.inbox) >= self.config.max_inbound_messages + or self.inbox_bytes + size > self.config.max_inbound_bytes + ): + return False + self.inbox.append(WebSocketMessage(value)) + self.inbox_bytes += size + self._signal(self.handler_task, _INBOX_SIGNAL) + return True + + def _disconnect(self, code: int, reason: str = "") -> None: + if self.disconnect is None: + self.disconnect = WebSocketDisconnect(code, reason) + self._signal(self.handler_task, _INBOX_SIGNAL) + self._signal(self.coordinator_task, _DECISION_SIGNAL) + + def request_shutdown(self, code: int = 1001) -> None: + if self.shutdown: + return + self.shutdown = True + if self.accepted and not self.close_sent and self.protocol is not None: + self._enqueue_control( + self.api.CloseConnection(code=code, reason="server shutdown"), 17 + ) + self.close_sent = True + self._disconnect(code, "server shutdown") + self._signal(self.writer_task, _OUTBOX_SIGNAL) + + +def _token_list(value: str | None) -> tuple[str, ...]: + if value is None: + return () + return tuple(token.strip() for token in value.split(",") if token.strip()) + + +def _message_size(value: str | bytes) -> int: + return len(value.encode("utf-8")) if isinstance(value, str) else len(value) + + +def _valid_close_code(code: int) -> bool: + return type(code) is int and ( + 1000 <= code <= 1014 and code not in {1004, 1005, 1006} + or 3000 <= code <= 4999 + ) + + +def _valid_websocket_key(value: str | None) -> bool: + if value is None: + return False + try: + return len(base64.b64decode(value.encode("ascii"), validate=True)) == 16 + except (ValueError, UnicodeEncodeError): + return False + + +def _is_upgrade_attempt(request: Request) -> bool: + headers = request.headers + return bool( + headers.get("upgrade") + or headers.get("sec-websocket-key") + or headers.get("sec-websocket-version") + or any(token.lower() == "upgrade" for token in _token_list(headers.get("connection"))) + ) + + +def _validate_upgrade( + request: Request, route: _WebSocketRoute +) -> Response | None: + if request.method.upper() != "GET": + return Response.text("WebSocket upgrade requires GET", status=400) + if request.version != "HTTP/1.1": + return Response.text("WebSocket upgrade requires HTTP/1.1", status=400) + if request.body: + return Response.text("WebSocket upgrade must not include a body", status=400) + if request.headers.get("upgrade", "").lower() != "websocket": + return Response.text("invalid WebSocket Upgrade header", status=400) + connection_tokens = { + token.lower() for token in _token_list(request.headers.get("connection")) + } + if "upgrade" not in connection_tokens: + return Response.text("invalid WebSocket Connection header", status=400) + if request.headers.get("sec-websocket-version") != "13": + return Response.text( + "unsupported WebSocket version", + status=426, + headers={"Sec-WebSocket-Version": "13"}, + ) + if not _valid_websocket_key(request.headers.get("sec-websocket-key")): + return Response.text("invalid WebSocket key", status=400) + origin = request.headers.get("origin") + if route.origins is not None and origin not in route.origins: + return Response.text("WebSocket origin is not allowed", status=403) + return None + + +async def run_websocket_connection( + task: Any, state: _WebSocketState, server_handle: Any +) -> None: + """Coordinate one accepted HTTP connection through its WebSocket lifetime.""" + state.coordinator_task = task + server_handle._websocket_states[id(state.client)] = state + + def spawn(routine: Any, name: str) -> Any: + child = task.spawn( + routine, + priority=server_handle._config.connection_priority, + args=(state,), + name=name, + ) + state._children.append(child) + server_handle._owned_tasks.append(child) + return child + + try: + state.handler_task = spawn(_run_handler, "smallserver-websocket-handler") + state.deadline_task = spawn( + _run_deadlines, "smallserver-websocket-deadline" + ) + while not state.accepted and not state.rejected and state.disconnect is None: + await task.wait_signal(_DECISION_SIGNAL) + if not state.accepted: + return + state.writer_task = spawn(_run_writer, "smallserver-websocket-writer") + state.reader_task = spawn(_run_reader, "smallserver-websocket-reader") + try: + await task.join(state.handler_task) + except WebSocketDisconnect: + pass + except BaseException as exc: + state.handler_error = exc + + if state.handler_error is not None and not state.close_sent: + try: + await state._enqueue( + state.api.CloseConnection(code=1011, reason="handler failed"), + 16, + wait=True, + ) + state.close_sent = True + except BaseException: + pass + elif not state.close_sent and state.disconnect is None: + try: + await state._enqueue( + state.api.CloseConnection(code=1000, reason=""), 2, wait=True + ) + state.close_sent = True + except BaseException: + pass + + deadline = time.monotonic() + state.config.close_timeout + while ( + state.outbox or state.writer_busy or not state.peer_closed + ) and time.monotonic() < deadline: + await task.sleep( + min(state.config.deadline_resolution, state.config.close_timeout) + ) + finally: + state.request_shutdown() + for child in list(state._children): + if child is task: + continue + if ( + server_handle._cancel_or_retain_task(child) + and child in server_handle._owned_tasks + ): + server_handle._owned_tasks.remove(child) + state._children.clear() + server_handle._websocket_states.pop(id(state.client), None) + + +async def _run_handler(task: Any, state: _WebSocketState) -> None: + socket = WebSocket(state) + try: + result = state.route.handler(socket) + if not inspect.isawaitable(result): + raise TypeError("WebSocket handlers must return an awaitable") + await result + except WebSocketDisconnect: + pass + except BaseException as exc: + state.handler_error = exc + finally: + if not state.accepted and not state.rejected: + response = Response.text( + "internal server error" if state.handler_error is not None else "forbidden", + status=500 if state.handler_error is not None else 403, + ) + try: + await state.reject(response) + except BaseException: + state._disconnect(1006) + state._signal(state.coordinator_task, _DECISION_SIGNAL) + + +async def _run_writer(task: Any, state: _WebSocketState) -> None: + while not state.shutdown or state.outbox: + while state.outbox: + command = state.outbox.popleft() + state.outbox_bytes -= command.size + state.writer_busy = True + try: + payload = state.protocol.send(command.event) + for offset in range(0, len(payload), state.config.write_chunk_bytes): + await state.transport.send_all( + task, + state.client, + payload[offset : offset + state.config.write_chunk_bytes], + ) + state.last_activity = time.monotonic() + except BaseException as exc: + command.error = exc + state._disconnect(1006) + finally: + state.writer_busy = False + command.done = True + state._signal(command.waiter, _ACK_SIGNAL) + if not state.shutdown: + await task.wait_signal(_OUTBOX_SIGNAL) + + +async def _run_reader(task: Any, state: _WebSocketState) -> None: + try: + if state.trailing_data: + _receive_protocol_data(state, state.trailing_data) + state.trailing_data = b"" + if _drain_protocol_events(state): + return + while not state.shutdown: + chunk = await state.transport.recv( + task, state.client, state.config.receive_chunk_bytes + ) + if not chunk: + state.protocol.receive_data(None) + _drain_protocol_events(state) + state._disconnect(1006) + return + state.last_activity = time.monotonic() + _receive_protocol_data(state, chunk) + if _drain_protocol_events(state): + return + except WebSocketCapacityError: + if state.shutdown: + return + state._enqueue_control( + state.api.CloseConnection(code=1009, reason="message too large"), 19 + ) + state.close_sent = True + state._disconnect(1009, "message too large") + except BaseException: + if state.shutdown: + return + state._enqueue_control( + state.api.CloseConnection(code=1002, reason="protocol error"), 16 + ) + state.close_sent = True + state._disconnect(1002, "protocol error") + + +def _receive_protocol_data(state: _WebSocketState, data: bytes) -> None: + for chunk in state._guard.feed(data): + state.protocol.receive_data(chunk) + + +def _drain_protocol_events(state: _WebSocketState) -> bool: + for event in state.protocol.events(): + if isinstance(event, (state.api.TextMessage, state.api.BytesMessage)): + kind = str if isinstance(event, state.api.TextMessage) else bytes + if state._message_kind is None: + state._message_kind = kind + state._message_parts = [] + state._message_bytes = 0 + if state._message_kind is not kind: + raise ValueError("WebSocket message type changed during fragmentation") + state._message_bytes += _message_size(event.data) + if state._message_bytes > state.config.max_message_bytes: + raise WebSocketCapacityError("WebSocket message is too large") + state._message_parts.append(event.data) + if event.message_finished: + value = ( + "".join(state._message_parts) + if kind is str + else b"".join(state._message_parts) + ) + state._message_kind = None + state._message_parts = [] + state._message_bytes = 0 + if not state._deliver_message(value): + raise WebSocketCapacityError("WebSocket inbound queue is full") + elif isinstance(event, state.api.Ping): + if not state._enqueue_control(event.response(), len(event.payload)): + raise WebSocketCapacityError("WebSocket outbound queue is full") + elif isinstance(event, state.api.Pong): + state.pong_deadline = None + elif isinstance(event, state.api.CloseConnection): + state.peer_closed = True + if not state.close_sent: + if not state._enqueue_control(event.response(), 2): + raise WebSocketCapacityError("WebSocket outbound queue is full") + state.close_sent = True + state._disconnect(event.code, event.reason or "") + return True + return False + + +async def _run_deadlines(task: Any, state: _WebSocketState) -> None: + while not state.shutdown: + await task.sleep(state.config.deadline_resolution) + now = time.monotonic() + if not state.accepted and not state.rejected: + if now - state.created_at >= state.config.handshake_timeout: + try: + await state.reject( + Response.text("WebSocket handshake timed out", status=408) + ) + except BaseException: + state._disconnect(1006) + return + continue + if state.accepted and now - state.last_activity >= state.config.idle_timeout: + state._enqueue_control( + state.api.CloseConnection(code=1001, reason="idle timeout"), 14 + ) + state.close_sent = True + state._disconnect(1001, "idle timeout") + return + if state.pong_deadline is not None and now >= state.pong_deadline: + state._enqueue_control( + state.api.CloseConnection(code=1002, reason="Pong timeout"), 14 + ) + state.close_sent = True + state._disconnect(1002, "Pong timeout") + return diff --git a/tests/test_websocket.py b/tests/test_websocket.py new file mode 100644 index 0000000..40e37f6 --- /dev/null +++ b/tests/test_websocket.py @@ -0,0 +1,657 @@ +from __future__ import annotations + +import base64 +import importlib.util +import socket +import threading +import time +import unittest +from unittest.mock import patch + +from SmallPackage import SmallOS, SmallTask, SmallWebSocketClient, Unix + +from smallserver import ( + Headers, + Request, + Response, + SmallServer, + WebSocket, + WebSocketCapacityError, + WebSocketConfig, + WebSocketUnavailable, +) +from smallserver.websocket import ( + _FrameGuard, + _WebSocketState, + _WebSocketRoute, + _load_wsproto, + _validate_upgrade, +) + + +HAS_WSPROTO = importlib.util.find_spec("wsproto") is not None + + +def upgrade_request(**headers: str) -> Request: + values = { + "Host": "localhost", + "Upgrade": "websocket", + "Connection": "keep-alive, Upgrade", + "Sec-WebSocket-Version": "13", + "Sec-WebSocket-Key": "dGhlIHNhbXBsZSBub25jZQ==", + } + values.update(headers) + return Request("GET", "/ws", Headers(values)) + + +async def unused_handler(socket: WebSocket) -> None: + await socket.reject(Response(status=403)) + + +class WebSocketProtocolTests(unittest.TestCase): + def test_config_rejects_unbounded_or_inconsistent_limits(self) -> None: + with self.assertRaisesRegex(ValueError, "max_message_bytes"): + WebSocketConfig(max_message_bytes=0) + with self.assertRaisesRegex(ValueError, "must not exceed"): + WebSocketConfig(max_frame_payload_bytes=2, max_message_bytes=1) + with self.assertRaisesRegex(ValueError, "idle_timeout"): + WebSocketConfig(idle_timeout=float("inf")) + + def test_registration_is_lazy_and_coexists_with_get(self) -> None: + app = SmallServer() + + @app.get("/ws") + async def ordinary(request): + return Response.text("http") + + with patch("importlib.import_module") as importer: + + @app.websocket("/ws") + async def websocket(socket): + await socket.accept() + + importer.assert_not_called() + self.assertIsNotNone(app._router.static_handler("GET", "/ws")) + self.assertIn("/ws", app._websocket_routes) + + def test_missing_optional_engine_has_clear_error(self) -> None: + real_import = __import__("importlib").import_module + + def missing(name: str): + if name.startswith("wsproto"): + raise ImportError("missing") + return real_import(name) + + with patch("importlib.import_module", side_effect=missing): + with self.assertRaisesRegex(WebSocketUnavailable, "websocket.*extra"): + _load_wsproto() + + def test_upgrade_validation_and_rfc_example_accept_inputs(self) -> None: + route = _WebSocketRoute(unused_handler, None, ("chat.v1",)) + self.assertIsNone(_validate_upgrade(upgrade_request(), route)) + response = _validate_upgrade( + upgrade_request(**{"Sec-WebSocket-Version": "12"}), route + ) + self.assertIsNotNone(response) + assert response is not None + self.assertEqual(response.status, 426) + self.assertEqual(response.headers["sec-websocket-version"], "13") + for name, value in ( + ("Upgrade", "not-websocket"), + ("Connection", "keep-alive"), + ("Sec-WebSocket-Key", base64.b64encode(b"short").decode("ascii")), + ): + with self.subTest(name=name): + self.assertEqual( + _validate_upgrade(upgrade_request(**{name: value}), route).status, + 400, + ) + + def test_origin_policy_is_explicit(self) -> None: + route = _WebSocketRoute( + unused_handler, frozenset({"https://allowed.example"}), () + ) + denied = _validate_upgrade( + upgrade_request(Origin="https://denied.example"), route + ) + self.assertIsNotNone(denied) + assert denied is not None + self.assertEqual(denied.status, 403) + self.assertIsNone( + _validate_upgrade( + upgrade_request(Origin="https://allowed.example"), route + ) + ) + + @unittest.skipUnless(HAS_WSPROTO, "websocket extra is not installed") + def test_frame_guard_bounds_declared_length_before_payload(self) -> None: + guard = _FrameGuard(max_payload_bytes=1024) + declared = b"\x82\xff" + (65537).to_bytes(8, "big") + b"mask" + with self.assertRaises(WebSocketCapacityError): + for byte in declared: + guard.feed(bytes([byte])) + self.assertLessEqual(len(guard._header), 14) + + @unittest.skipUnless(HAS_WSPROTO, "websocket extra is not installed") + def test_partial_masked_fragmented_input_and_protocol_errors(self) -> None: + api = _load_wsproto() + client = api.Connection(api.ConnectionType.CLIENT) + server = api.Connection(api.ConnectionType.SERVER) + guard = _FrameGuard(1024) + payload = client.send( + api.TextMessage(data="hel", frame_finished=True, message_finished=False) + ) + client.send( + api.TextMessage(data="lo", frame_finished=True, message_finished=True) + ) + for byte in payload: + for chunk in guard.feed(bytes([byte])): + server.receive_data(chunk) + events = list(server.events()) + self.assertEqual("".join(event.data for event in events), "hello") + self.assertTrue(events[-1].message_finished) + with self.assertRaisesRegex(ValueError, "masked"): + _FrameGuard(1024).feed(b"\x81\x01x") + + @unittest.skipUnless(HAS_WSPROTO, "websocket extra is not installed") + def test_inbound_and_outbound_mailboxes_are_bounded(self) -> None: + config = WebSocketConfig( + max_frame_payload_bytes=8, + max_message_bytes=8, + max_inbound_messages=1, + max_inbound_bytes=3, + max_outbound_commands=1, + max_outbound_bytes=3, + ) + state = _WebSocketState( + object(), + object(), + object(), + upgrade_request(), + _WebSocketRoute(unused_handler, None, ()), + config, + b"", + ) + self.assertTrue(state._deliver_message("abc")) + self.assertFalse(state._deliver_message("x")) + self.assertTrue(state._enqueue_control(state.api.Ping(payload=b"abc"), 3)) + self.assertFalse(state._enqueue_control(state.api.Ping(payload=b"x"), 1)) + + +@unittest.skipUnless(HAS_WSPROTO, "websocket extra is not installed") +class WebSocketLoopbackTests(unittest.TestCase): + def _http_exchange(self, port: int, payload: bytes) -> bytes: + with socket.create_connection(("127.0.0.1", port), timeout=3) as stream: + stream.sendall(payload) + chunks = [] + while True: + chunk = stream.recv(4096) + if not chunk: + return b"".join(chunks) + chunks.append(chunk) + + def test_wsproto_client_interoperability_and_http_coexistence(self) -> None: + api = _load_wsproto() + runtime = SmallOS().setKernel(Unix()) + app = SmallServer( + websocket_config=WebSocketConfig( + max_frame_payload_bytes=4096, + max_message_bytes=4096, + idle_timeout=5, + handshake_timeout=2, + close_timeout=1, + ) + ) + + @app.get("/ws") + async def normal_get(request): + return Response.text("ordinary-http") + + @app.websocket( + "/ws", + origins={"https://allowed.example"}, + subprotocols=("chat.v1",), + ) + async def echo(websocket: WebSocket) -> None: + await websocket.accept(subprotocol="chat.v1") + async for message in websocket: + if message.is_text: + await websocket.send_text(message.text) + else: + await websocket.send_bytes(message.bytes) + + try: + server = app.serve(runtime, host="127.0.0.1", port=0) + except PermissionError: + self.skipTest("the current sandbox does not permit loopback TCP binds") + + outcomes: list[object] = [] + errors: list[BaseException] = [] + + def client_work() -> None: + try: + ordinary = self._http_exchange( + server.port, + b"GET /ws HTTP/1.1\r\nHost: localhost\r\n\r\n", + ) + outcomes.append(ordinary) + with socket.create_connection( + ("127.0.0.1", server.port), timeout=3 + ) as stream: + stream.sendall( + b"GET /ws?room=1 HTTP/1.1\r\n" + b"Host: localhost\r\n" + b"Upgrade: websocket\r\n" + b"Connection: keep-alive, Upgrade\r\n" + b"Sec-WebSocket-Version: 13\r\n" + b"Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==\r\n" + b"Sec-WebSocket-Protocol: chat.v1\r\n" + b"Origin: https://allowed.example\r\n\r\n" + ) + response = b"" + while b"\r\n\r\n" not in response: + response += stream.recv(4096) + outcomes.append(response) + client = api.Connection(api.ConnectionType.CLIENT) + stream.sendall( + client.send( + api.TextMessage( + data="hel", + frame_finished=True, + message_finished=False, + ) + ) + + client.send( + api.TextMessage( + data="lo", + frame_finished=True, + message_finished=True, + ) + ) + ) + outcomes.extend(_receive_events(stream, client, api.TextMessage)) + stream.sendall(client.send(api.BytesMessage(data=b"binary"))) + outcomes.extend(_receive_events(stream, client, api.BytesMessage)) + stream.sendall(client.send(api.Ping(payload=b"probe"))) + outcomes.extend(_receive_events(stream, client, api.Pong)) + stream.sendall( + client.send(api.CloseConnection(code=1000, reason="done")) + ) + outcomes.extend(_receive_events(stream, client, api.CloseConnection)) + except BaseException as exc: + errors.append(exc) + finally: + server.close() + + worker = threading.Thread(target=client_work, daemon=True) + worker.start() + runtime.start() + worker.join(timeout=5) + + self.assertFalse(worker.is_alive()) + self.assertEqual(errors, []) + self.assertIn(b"ordinary-http", outcomes[0]) + self.assertIn(b"HTTP/1.1 101 Switching Protocols", outcomes[1]) + self.assertIn(b"Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=", outcomes[1]) + self.assertIn(b"Sec-WebSocket-Protocol: chat.v1", outcomes[1]) + self.assertTrue(any(getattr(event, "data", None) == "hello" for event in outcomes)) + self.assertTrue(any(getattr(event, "data", None) == b"binary" for event in outcomes)) + self.assertTrue(any(getattr(event, "payload", None) == b"probe" for event in outcomes)) + self.assertTrue( + any(getattr(event, "code", None) == 1000 for event in outcomes), + outcomes, + ) + self.assertEqual(server._websocket_states, {}) + self.assertEqual(runtime.ioReadWaiters, {}) + self.assertEqual(runtime.ioWriteWaiters, {}) + + def test_smallos_websocket_client_interoperability(self) -> None: + runtime = SmallOS().setKernel(Unix()) + app = SmallServer( + websocket_config=WebSocketConfig( + max_frame_payload_bytes=4096, + max_message_bytes=4096, + idle_timeout=5, + close_timeout=1, + ) + ) + + @app.websocket("/native", subprotocols=("smallos.v1",)) + async def echo(websocket: WebSocket) -> None: + await websocket.accept(subprotocol="smallos.v1") + message = await websocket.receive() + await websocket.send_text(message.text) + + try: + server = app.serve(runtime, host="127.0.0.1", port=0) + except PermissionError: + self.skipTest("the current sandbox does not permit loopback TCP binds") + + outcome: dict[str, object] = {} + + async def client_job(task) -> None: + client = SmallWebSocketClient( + task, + host="127.0.0.1", + port=server.port, + client_key="dGhlIHNhbXBsZSBub25jZQ==", + ) + try: + await client.connect("/native", subprotocols=("smallos.v1",)) + outcome["subprotocol"] = client.negotiated_subprotocol + await client.send_text("native-client") + outcome["message"] = await client.receive() + finally: + await client.disconnect() + server.close() + + client_task = SmallTask(2, client_job, name="smallserver-ws-client") + runtime.fork(client_task) + runtime.start() + + self.assertIsNone(client_task.exception) + self.assertEqual(outcome["subprotocol"], "smallos.v1") + self.assertEqual( + outcome["message"], {"type": "text", "data": "native-client"} + ) + self.assertTrue(server.finished) + self.assertEqual(runtime.ioReadWaiters, {}) + self.assertEqual(runtime.ioWriteWaiters, {}) + + def test_waiting_websocket_does_not_delay_unrelated_http(self) -> None: + runtime = SmallOS().setKernel(Unix()) + app = SmallServer( + websocket_config=WebSocketConfig( + max_frame_payload_bytes=4096, + max_message_bytes=4096, + idle_timeout=5, + close_timeout=1, + ) + ) + + @app.get("/fast") + async def fast(request): + return Response.text("fast") + + @app.websocket("/waiting") + async def waiting(websocket: WebSocket) -> None: + await websocket.accept() + await websocket.receive() + + try: + server = app.serve(runtime, host="127.0.0.1", port=0) + except PermissionError: + self.skipTest("the current sandbox does not permit loopback TCP binds") + + api = _load_wsproto() + outcomes: list[bytes] = [] + errors: list[BaseException] = [] + + def client_work() -> None: + try: + with socket.create_connection( + ("127.0.0.1", server.port), timeout=3 + ) as websocket_stream: + websocket_stream.sendall( + b"GET /waiting HTTP/1.1\r\nHost: localhost\r\n" + b"Upgrade: websocket\r\nConnection: Upgrade\r\n" + b"Sec-WebSocket-Version: 13\r\n" + b"Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==\r\n\r\n" + ) + response = b"" + while b"\r\n\r\n" not in response: + response += websocket_stream.recv(4096) + outcomes.append(response) + outcomes.append( + self._http_exchange( + server.port, + b"GET /fast HTTP/1.1\r\nHost: localhost\r\n\r\n", + ) + ) + client = api.Connection(api.ConnectionType.CLIENT) + websocket_stream.sendall( + client.send(api.CloseConnection(code=1000, reason="done")) + ) + _receive_events( + websocket_stream, client, api.CloseConnection + ) + except BaseException as exc: + errors.append(exc) + finally: + server.close() + + worker = threading.Thread(target=client_work, daemon=True) + worker.start() + runtime.start() + worker.join(timeout=5) + + self.assertFalse(worker.is_alive()) + self.assertEqual(errors, []) + self.assertIn(b"HTTP/1.1 101 Switching Protocols", outcomes[0]) + self.assertIn(b"fast", outcomes[1]) + self.assertTrue(server.finished) + + def test_malformed_and_oversized_frames_close_with_safe_codes(self) -> None: + runtime = SmallOS().setKernel(Unix()) + app = SmallServer( + websocket_config=WebSocketConfig( + max_frame_payload_bytes=8, + max_message_bytes=8, + idle_timeout=5, + close_timeout=1, + ) + ) + + @app.websocket("/bounded") + async def bounded(websocket: WebSocket) -> None: + await websocket.accept() + await websocket.receive() + + try: + server = app.serve(runtime, host="127.0.0.1", port=0) + except PermissionError: + self.skipTest("the current sandbox does not permit loopback TCP binds") + + api = _load_wsproto() + close_codes: list[int | None] = [] + errors: list[BaseException] = [] + + def send_bad_frame(frame: bytes) -> None: + with socket.create_connection( + ("127.0.0.1", server.port), timeout=3 + ) as stream: + stream.sendall( + b"GET /bounded HTTP/1.1\r\nHost: localhost\r\n" + b"Upgrade: websocket\r\nConnection: Upgrade\r\n" + b"Sec-WebSocket-Version: 13\r\n" + b"Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==\r\n\r\n" + ) + response = b"" + while b"\r\n\r\n" not in response: + response += stream.recv(4096) + stream.sendall(frame) + client = api.Connection(api.ConnectionType.CLIENT) + events = _receive_events(stream, client, api.CloseConnection) + close = next( + event for event in events if isinstance(event, api.CloseConnection) + ) + close_codes.append(close.code) + stream.sendall(client.send(close.response())) + + def client_work() -> None: + try: + send_bad_frame(b"\x81\x01x") + send_bad_frame(b"\x82\xfe\x00\x7emask") + except BaseException as exc: + errors.append(exc) + finally: + server.close() + + worker = threading.Thread(target=client_work, daemon=True) + worker.start() + runtime.start() + worker.join(timeout=5) + + self.assertFalse(worker.is_alive()) + self.assertEqual(errors, []) + self.assertEqual(close_codes, [1002, 1009]) + self.assertTrue(server.finished) + + def test_server_shutdown_attempts_close_and_releases_children(self) -> None: + api = _load_wsproto() + runtime = SmallOS().setKernel(Unix()) + app = SmallServer( + websocket_config=WebSocketConfig( + max_frame_payload_bytes=4096, + max_message_bytes=4096, + close_timeout=1, + idle_timeout=5, + ) + ) + + @app.websocket("/live") + async def live(websocket: WebSocket) -> None: + await websocket.accept() + await websocket.receive() + + try: + server = app.serve(runtime, host="127.0.0.1", port=0) + except PermissionError: + self.skipTest("the current sandbox does not permit loopback TCP binds") + + close_events: list[object] = [] + states: list[object] = [] + errors: list[BaseException] = [] + + def client_work() -> None: + try: + with socket.create_connection( + ("127.0.0.1", server.port), timeout=3 + ) as stream: + stream.sendall( + b"GET /live HTTP/1.1\r\nHost: localhost\r\n" + b"Upgrade: websocket\r\nConnection: Upgrade\r\n" + b"Sec-WebSocket-Version: 13\r\n" + b"Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==\r\n\r\n" + ) + response = b"" + while b"\r\n\r\n" not in response: + response += stream.recv(4096) + client = api.Connection(api.ConnectionType.CLIENT) + states.extend(server._websocket_states.values()) + server.close() + events = _receive_events(stream, client, api.CloseConnection) + close_events.extend(events) + close_event = next( + event + for event in events + if isinstance(event, api.CloseConnection) + ) + stream.sendall(client.send(close_event.response())) + except BaseException as exc: + errors.append(exc) + + worker = threading.Thread(target=client_work, daemon=True) + worker.start() + runtime.start() + worker.join(timeout=5) + + self.assertFalse(worker.is_alive()) + self.assertEqual(errors, []) + self.assertTrue( + any(getattr(event, "code", None) == 1001 for event in close_events), + ( + close_events, + states[0].disconnect if states else None, + states[0].handler_error if states else None, + ), + ) + self.assertTrue(server.finished) + self.assertEqual(server._websocket_states, {}) + self.assertEqual(runtime.ioReadWaiters, {}) + self.assertEqual(runtime.ioWriteWaiters, {}) + + def test_handler_failure_sends_sanitized_1011_close(self) -> None: + api = _load_wsproto() + runtime = SmallOS().setKernel(Unix()) + app = SmallServer( + websocket_config=WebSocketConfig( + max_frame_payload_bytes=4096, + max_message_bytes=4096, + close_timeout=1, + idle_timeout=5, + ) + ) + + @app.websocket("/fail") + async def fail(websocket: WebSocket) -> None: + await websocket.accept() + raise RuntimeError("sensitive-handler-detail") + + try: + server = app.serve(runtime, host="127.0.0.1", port=0) + except PermissionError: + self.skipTest("the current sandbox does not permit loopback TCP binds") + + close_events: list[object] = [] + errors: list[BaseException] = [] + + def client_work() -> None: + try: + with socket.create_connection( + ("127.0.0.1", server.port), timeout=3 + ) as stream: + stream.sendall( + b"GET /fail HTTP/1.1\r\nHost: localhost\r\n" + b"Upgrade: websocket\r\nConnection: Upgrade\r\n" + b"Sec-WebSocket-Version: 13\r\n" + b"Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==\r\n\r\n" + ) + response = b"" + while b"\r\n\r\n" not in response: + response += stream.recv(4096) + client = api.Connection(api.ConnectionType.CLIENT) + events = _receive_events(stream, client, api.CloseConnection) + close_events.extend(events) + close_event = next( + event + for event in events + if isinstance(event, api.CloseConnection) + ) + stream.sendall(client.send(close_event.response())) + except BaseException as exc: + errors.append(exc) + finally: + server.close() + + worker = threading.Thread(target=client_work, daemon=True) + worker.start() + runtime.start() + worker.join(timeout=5) + + self.assertFalse(worker.is_alive()) + self.assertEqual(errors, []) + failure = next( + event for event in close_events if isinstance(event, api.CloseConnection) + ) + self.assertEqual(failure.code, 1011) + self.assertNotIn("sensitive", failure.reason) + self.assertTrue(server.finished) + + +def _receive_events(stream, connection, event_type): + deadline = time.monotonic() + 3 + received = [] + while time.monotonic() < deadline: + data = stream.recv(4096) + if not data: + return received + connection.receive_data(data) + events = list(connection.events()) + received.extend(events) + if any(isinstance(event, event_type) for event in events): + return received + raise TimeoutError("expected WebSocket event was not received") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/typing/websocket_routes.py b/tests/typing/websocket_routes.py new file mode 100644 index 0000000..910ecd5 --- /dev/null +++ b/tests/typing/websocket_routes.py @@ -0,0 +1,14 @@ +from smallserver import SmallServer, WebSocket, WebSocketMessage + + +app = SmallServer() + + +@app.websocket("/chat", subprotocols=("chat.v1",)) +async def chat(socket: WebSocket) -> None: + await socket.accept(subprotocol="chat.v1") + message: WebSocketMessage = await socket.receive() + if message.is_text: + await socket.send_text(message.text) + else: + await socket.send_bytes(message.bytes) From 342a666fec2e2c131364c8ffa1713724f54ff821 Mon Sep 17 00:00:00 2001 From: MikiEEE <35975462+MikiEEE@users.noreply.github.com> Date: Sat, 22 Aug 2026 02:01:07 -0500 Subject: [PATCH 22/53] docs: add WebSocket examples and guide --- README.md | 33 +++++++++++++++++++++++- demo.py | 12 ++++++++- examples/websocket_echo.py | 28 ++++++++++++++++++++ guide/websockets.md | 52 ++++++++++++++++++++++++++++++++++++++ 4 files changed, 123 insertions(+), 2 deletions(-) create mode 100644 examples/websocket_echo.py create mode 100644 guide/websockets.md diff --git a/README.md b/README.md index 04846b4..66dfe0b 100644 --- a/README.md +++ b/README.md @@ -21,7 +21,9 @@ The current package provides an HTTP/1.1 baseline over a SmallOS runtime. It can - parse one `Content-Length` HTTP/1.1 request per connection and close after its response. -Keep-alive/pipelining, TLS, automatic path templates, and HTTP/2 are not +RFC 6455 WebSockets over HTTP/1.1 Upgrade are available through the optional +`websocket` extra. Keep-alive/pipelining, TLS, automatic path templates, +compression, RFC 8441 WebSockets over HTTP/2, and HTTP/2 itself are not implemented yet. ## Install for development @@ -40,6 +42,12 @@ python3 -m pip install -e '.[regex-routes]' Static routing neither imports nor requires that dependency. +Install the optional WebSocket protocol engine when serving WebSocket routes: + +```bash +python3 -m pip install -e '.[websocket]' +``` + SmallOS is installed from the canonical `master` branch in `requirements.txt`. It owns scheduling, socket readiness, and foreign execution adapters. @@ -206,6 +214,29 @@ Static route lookup is dictionary-based and always takes precedence over a regex route for the same method and path. Richer lifecycle hooks are deferred; the current `ServerHandle` provides explicit shutdown. +## WebSocket routes + +Register WebSockets independently from HTTP routes. The same path may also +have a normal GET handler because only an Upgrade candidate enters the +WebSocket route table. + +```python +from smallserver import WebSocket + +@app.websocket("/echo") +async def echo(socket: WebSocket) -> None: + await socket.accept() + async for message in socket: + if message.is_text: + await socket.send_text(message.text) + else: + await socket.send_bytes(message.bytes) +``` + +See [`guide/websockets.md`](guide/websockets.md) and the runnable +[`examples/websocket_echo.py`](examples/websocket_echo.py) for origin, +subprotocol, capacity, and lifecycle details. + ## Define regular-expression routes Regex routes use full-path matching and run in registration order after static diff --git a/demo.py b/demo.py index bfa2f07..1fc7613 100644 --- a/demo.py +++ b/demo.py @@ -4,7 +4,7 @@ import json -from smallserver import HTTPError, Request, Response, SmallServer +from smallserver import HTTPError, Request, Response, SmallServer, WebSocket app = SmallServer() @@ -81,6 +81,16 @@ async def delete_task(request: Request) -> Response: return Response(status=204) +@app.websocket("/ws") +async def websocket_echo(socket: WebSocket) -> None: + await socket.accept() + async for message in socket: + if message.is_text: + await socket.send_text(message.text) + else: + await socket.send_bytes(message.bytes) + + if __name__ == "__main__": print("Starting SmallServer on http://127.0.0.1:8000") app.listen(host="127.0.0.1", port=8000) diff --git a/examples/websocket_echo.py b/examples/websocket_echo.py new file mode 100644 index 0000000..01bf4d3 --- /dev/null +++ b/examples/websocket_echo.py @@ -0,0 +1,28 @@ +"""Run a bounded WebSocket echo endpoint on localhost:8000.""" + +from smallserver import SmallServer, WebSocket, WebSocketConfig + + +app = SmallServer( + websocket_config=WebSocketConfig( + max_frame_payload_bytes=64 * 1024, + max_message_bytes=256 * 1024, + max_inbound_messages=8, + max_outbound_commands=8, + ) +) + + +@app.websocket("/echo", subprotocols=("echo.v1",)) +async def echo(socket: WebSocket) -> None: + await socket.accept(subprotocol="echo.v1") + async for message in socket: + if message.is_text: + await socket.send_text(message.text) + else: + await socket.send_bytes(message.bytes) + + +if __name__ == "__main__": + print("Starting WebSocket echo server on ws://127.0.0.1:8000/echo") + app.listen(host="127.0.0.1", port=8000) diff --git a/guide/websockets.md b/guide/websockets.md new file mode 100644 index 0000000..4413e2d --- /dev/null +++ b/guide/websockets.md @@ -0,0 +1,52 @@ +# 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, and close limits are finite +and configurable through `WebSocketConfig`. + +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`; peer or server closure raises `WebSocketDisconnect` +from receive operations. + +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. From f6e2bc350e901721d1399cd64e4d9ca570410c95 Mon Sep 17 00:00:00 2001 From: MikiEEE <35975462+MikiEEE@users.noreply.github.com> Date: Sat, 22 Aug 2026 02:02:15 -0500 Subject: [PATCH 23/53] test: cover WebSocket deadlines and interoperability --- tests/test_websocket.py | 115 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 115 insertions(+) diff --git a/tests/test_websocket.py b/tests/test_websocket.py index 40e37f6..b2cc9bc 100644 --- a/tests/test_websocket.py +++ b/tests/test_websocket.py @@ -496,6 +496,121 @@ def client_work() -> None: self.assertEqual(close_codes, [1002, 1009]) self.assertTrue(server.finished) + def test_handshake_idle_and_pong_deadlines_are_bounded(self) -> None: + runtime = SmallOS().setKernel(Unix()) + app = SmallServer( + websocket_config=WebSocketConfig( + max_frame_payload_bytes=1024, + max_message_bytes=1024, + handshake_timeout=0.1, + idle_timeout=0.2, + pong_timeout=0.05, + close_timeout=0.2, + deadline_resolution=0.01, + ) + ) + + @app.websocket("/handshake-timeout") + async def handshake_timeout(websocket: WebSocket) -> None: + await runtime.cursor.sleep(1) + + @app.websocket("/idle-timeout") + async def idle_timeout(websocket: WebSocket) -> None: + await websocket.accept() + await websocket.receive() + + @app.websocket("/pong-timeout") + async def pong_timeout(websocket: WebSocket) -> None: + await websocket.accept() + await websocket.ping(b"deadline") + await websocket.receive() + + try: + server = app.serve(runtime, host="127.0.0.1", port=0) + except PermissionError: + self.skipTest("the current sandbox does not permit loopback TCP binds") + + api = _load_wsproto() + outcomes: dict[str, object] = {} + errors: list[BaseException] = [] + + def connect(path: str): + stream = socket.create_connection(("127.0.0.1", server.port), timeout=3) + stream.sendall( + "GET {} HTTP/1.1\r\nHost: localhost\r\n" + "Upgrade: websocket\r\nConnection: Upgrade\r\n" + "Sec-WebSocket-Version: 13\r\n" + "Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==\r\n\r\n".format( + path + ).encode("ascii") + ) + response = b"" + while b"\r\n\r\n" not in response: + response += stream.recv(4096) + return stream, response + + def client_work() -> None: + try: + stream, response = connect("/handshake-timeout") + outcomes["handshake"] = response + stream.close() + + stream, response = connect("/idle-timeout") + outcomes["idle_handshake"] = response + idle_client = api.Connection(api.ConnectionType.CLIENT) + idle_events = _receive_events( + stream, idle_client, api.CloseConnection + ) + idle_close = next( + event + for event in idle_events + if isinstance(event, api.CloseConnection) + ) + outcomes["idle_code"] = idle_close.code + stream.sendall(idle_client.send(idle_close.response())) + stream.close() + + stream, response = connect("/pong-timeout") + outcomes["pong_handshake"] = response + pong_client = api.Connection(api.ConnectionType.CLIENT) + ping_events = _receive_events(stream, pong_client, api.Ping) + outcomes["ping_payload"] = next( + event.payload + for event in ping_events + if isinstance(event, api.Ping) + ) + close_events = _receive_events( + stream, pong_client, api.CloseConnection + ) + pong_close = next( + event + for event in close_events + if isinstance(event, api.CloseConnection) + ) + outcomes["pong_code"] = pong_close.code + stream.sendall(pong_client.send(pong_close.response())) + stream.close() + except BaseException as exc: + errors.append(exc) + finally: + server.close() + + worker = threading.Thread(target=client_work, daemon=True) + worker.start() + runtime.start() + worker.join(timeout=5) + + self.assertFalse(worker.is_alive()) + self.assertEqual(errors, []) + self.assertIn(b"HTTP/1.1 408 Request Timeout", outcomes["handshake"]) + self.assertIn( + b"HTTP/1.1 101 Switching Protocols", outcomes["idle_handshake"] + ) + self.assertEqual(outcomes["idle_code"], 1001) + self.assertEqual(outcomes["ping_payload"], b"deadline") + self.assertEqual(outcomes["pong_code"], 1002) + self.assertTrue(server.finished) + def test_server_shutdown_attempts_close_and_releases_children(self) -> None: api = _load_wsproto() runtime = SmallOS().setKernel(Unix()) From 4285ff669afcf37355b2054c1a703f7cb3648738 Mon Sep 17 00:00:00 2001 From: MikiEEE <35975462+MikiEEE@users.noreply.github.com> Date: Sat, 22 Aug 2026 02:11:22 -0500 Subject: [PATCH 24/53] fix: harden HTTP/2 stream and lifecycle bounds --- smallserver/app.py | 183 +++++++++++++++-- smallserver/http2.py | 281 +++++++++++++++++--------- tests/test_http2.py | 472 +++++++++++++++++++++++++++++++++++++++++-- 3 files changed, 805 insertions(+), 131 deletions(-) diff --git a/smallserver/app.py b/smallserver/app.py index d413545..c039694 100644 --- a/smallserver/app.py +++ b/smallserver/app.py @@ -30,16 +30,21 @@ Handler = Callable[[Request], Awaitable[Response]] _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 @@ -50,6 +55,13 @@ def wake_writer(self) -> None: 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: @@ -617,12 +629,12 @@ async def _http2_connection_loop( ) -> None: from SmallPackage import SmallTask - protocol = H2Protocol(handle._protocol_config) - state = _H2ConnectionState(protocol) + protocol: H2Protocol | None = None + state: _H2ConnectionState | None = None primary_error: BaseException | None = None - handle._graceful_connections.add(id(client)) - handle._graceful_closers[id(client)] = state.request_shutdown try: + protocol = H2Protocol(handle._protocol_config) + state = _H2ConnectionState(protocol) await handle._transport.send_all(task, client, protocol.initiate()) writer = SmallTask( handle._config.connection_priority, @@ -631,14 +643,32 @@ async def _http2_connection_loop( name="smallserver-http2-writer", ) state.writer_task = writer - handle._owned_tasks.append(writer) - handle._runtime.fork(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 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() try: ready = protocol.receive_data(chunk) except Exception as protocol_error: @@ -666,21 +696,33 @@ async def _http2_connection_loop( primary_error = exc raise finally: - state.closing = True - for handler in tuple(state.handlers.values()): - handle._cancel_or_retain_task(handler) - state.handlers.clear() - if state.writer_task is not None: - state.wake_writer() - handle._cancel_or_retain_task(state.writer_task) - if state.writer_task in handle._owned_tasks: - handle._owned_tasks.remove(state.writer_task) - try: - goaway = protocol.close(state.close_error_code) - if goaway: - await handle._transport.send_all(task, client, goaway) - except BaseException: - pass + 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( @@ -691,14 +733,18 @@ async def _http2_handler( stream_id: int, request: Request, ) -> None: + response_queued = False try: try: response = await self.dispatch(request) 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) @@ -729,6 +775,101 @@ async def _http2_writer_loop( if not payload: break await handle._transport.send_all(task, client, payload) + except Exception as error: + state.failure = error + state.closing = True + handle._listener_failed(error, task) + if not handle._transport.close_safely(client): + close_error = client.close_error or RuntimeError( + "kernel connection close failed" + ) + handle._connection_close_failed(close_error, 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 + if not handle._transport.close_safely(client): + error = client.close_error or RuntimeError( + "kernel connection close failed" + ) + handle._connection_close_failed(error, 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 + if not handle._transport.close_safely(client): + close_error = client.close_error or RuntimeError( + "kernel connection close failed" + ) + handle._connection_close_failed(close_error, task, error) diff --git a/smallserver/http2.py b/smallserver/http2.py index 49dc184..ba5467b 100644 --- a/smallserver/http2.py +++ b/smallserver/http2.py @@ -25,11 +25,22 @@ class HTTP2Config: max_pending_output_bytes: int = 4 * 1024 * 1024 max_response_body_bytes: int = 2 * 1024 * 1024 max_frame_size: int = 16 * 1024 + handshake_timeout: float = 10.0 + idle_timeout: float = 60.0 def __post_init__(self) -> None: - for name, value in self.__dict__.items(): + 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 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: @@ -56,6 +67,8 @@ class _InboundStream: path: str headers: Headers body: bytearray + expected_content_length: int | None + dispatched: bool = False @dataclass @@ -65,86 +78,91 @@ class _OutboundStream: class _FrameBudget: - """Account compressed header blocks without implementing frame semantics.""" + """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._remaining = 0 - self._frame_type = 0 - self._frame_stream = 0 - self._frame_flags = 0 + self._buffer = bytearray() + self._preface_received = False self._header_stream: int | None = None self._header_bytes = 0 - def feed(self, data: bytes) -> None: - view = memoryview(data) - offset = 0 - if len(self._preface) < len(HTTP2_CLIENT_PREFACE): - needed = len(HTTP2_CLIENT_PREFACE) - len(self._preface) - take = min(needed, len(view)) - self._preface.extend(view[:take]) - offset += take - expected = HTTP2_CLIENT_PREFACE[: len(self._preface)] - if bytes(self._preface) != expected: + def feed(self, data: bytes) -> tuple[bytes, ...]: + self._buffer.extend(data) + chunks: list[bytes] = [] + if not self._preface_received: + prefix_length = min(len(self._buffer), len(HTTP2_CLIENT_PREFACE)) + if bytes(self._buffer[:prefix_length]) != HTTP2_CLIENT_PREFACE[:prefix_length]: raise ValueError("invalid HTTP/2 client preface") - if offset == len(view): - return - - while offset < len(view): - if self._remaining == 0: - needed = 9 - len(self._header) - take = min(needed, len(view) - offset) - self._header.extend(view[offset : offset + take]) - offset += take - if len(self._header) < 9: - return - 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_type = self._header[3] - self._frame_flags = self._header[4] - self._frame_stream = int.from_bytes(self._header[5:9], "big") & 0x7FFFFFFF - self._header.clear() - self._remaining = length - if length == 0: - self._finish_frame() - continue - - take = min(self._remaining, len(view) - offset) - if self._frame_type in (0x1, 0x9): - self._account_header_bytes(take) - self._remaining -= take - offset += take - if self._remaining == 0: - self._finish_frame() - - def _account_header_bytes(self, count: int) -> None: - if self._frame_type == 0x1 and self._header_stream is None: - self._header_stream = self._frame_stream - self._header_bytes = 0 - self._header_bytes += count - 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: - if self._frame_type in (0x1, 0x9) and self._frame_flags & 0x4: - self._header_stream = None - self._header_bytes = 0 + if len(self._buffer) < len(HTTP2_CLIENT_PREFACE): + return () + chunks.append(bytes(self._buffer[: len(HTTP2_CLIENT_PREFACE)])) + del self._buffer[: len(HTTP2_CLIENT_PREFACE)] + self._preface_received = True + + while len(self._buffer) >= 9: + length = int.from_bytes(self._buffer[:3], "big") + if length > self._config.max_frame_size: + raise ValueError("HTTP/2 frame exceeds configured maximum") + frame_length = 9 + length + if len(self._buffer) < frame_length: + break + frame_type = self._buffer[3] + flags = self._buffer[4] + stream_id = int.from_bytes(self._buffer[5:9], "big") & 0x7FFFFFFF + if frame_type == 0x1: + if self._header_stream is not None: + raise ValueError("interleaved HTTP/2 header blocks are invalid") + self._header_stream = stream_id + self._header_bytes = length + elif frame_type == 0x9: + if self._header_stream != stream_id: + 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") + if frame_type in (0x1, 0x9) and flags & 0x4: + self._header_stream = None + self._header_bytes = 0 + chunks.append(bytes(self._buffer[:frame_length])) + del self._buffer[:frame_length] + return tuple(chunks) + + @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] - except ImportError as exc: + 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 the optional dependency; install smallserver[http2]" + "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: @@ -182,7 +200,22 @@ def __init__(self, config: HTTP2Config | None = None) -> None: validate_inbound_headers=True, normalize_inbound_headers=False, ) - self.connection = H2Connection(config=h2_config) + 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 ) @@ -223,34 +256,44 @@ def active_stream_count(self) -> int: def pending_output_bytes(self) -> int: return self._pending_output_bytes + @property + def buffered_request_bytes(self) -> int: + return self._buffered_request_bytes + + @property + def preface_received(self) -> bool: + return self._frames.preface_received + def initiate(self) -> bytes: self.connection.initiate_connection() return self.connection.data_to_send() def receive_data(self, data: bytes) -> tuple[H2ReadyRequest, ...]: - self._frames.feed(data) - events = self.connection.receive_data(data) ready: list[H2ReadyRequest] = [] - 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: - ready.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 + for wire_chunk in self._frames.feed(data): + 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: + ready.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 return tuple(ready) def take_cancelled_streams(self) -> tuple[int, ...]: @@ -263,14 +306,20 @@ def _request_received(self, stream_id: int, raw_headers: Any) -> None: self._reset_stream(stream_id, self._error_codes.REFUSED_STREAM) return try: - method, path, headers = self._decode_request_headers(raw_headers) + 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()) + self._inbound[stream_id] = _InboundStream( + method, path, headers, bytearray(), content_length + ) - def _decode_request_headers(self, raw_headers: Any) -> tuple[str, str, Headers]: + 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 @@ -306,6 +355,25 @@ def _decode_request_headers(self, raw_headers: Any) -> tuple[str, str, Headers]: 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: @@ -316,7 +384,16 @@ def _decode_request_headers(self, raw_headers: Any) -> tuple[str, str, Headers]: regular.append(("host", authority)) if cookies: regular.append(("cookie", "; ".join(cookies))) - return pseudo[":method"], pseudo[":path"], Headers(regular) + 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) @@ -327,6 +404,10 @@ def _data_received(self, stream_id: int, data: bytes, flow_length: int) -> None: 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) @@ -335,10 +416,16 @@ def _data_received(self, stream_id: int, data: bytes, flow_length: int) -> None: self._buffered_request_bytes = next_connection_size def _stream_ended(self, stream_id: int) -> H2ReadyRequest | None: - stream = self._inbound.pop(stream_id, None) + stream = self._inbound.get(stream_id) if stream is None: return None - self._buffered_request_bytes -= len(stream.body) + 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) request = Request( stream.method, @@ -352,6 +439,7 @@ def _stream_ended(self, stream_id: int) -> H2ReadyRequest | None: 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 @@ -443,9 +531,7 @@ def _start_response(self, stream_id: int, response: Response) -> None: self._active_streams.discard(stream_id) def drop_stream(self, stream_id: int) -> None: - inbound = self._inbound.pop(stream_id, None) - if inbound is not None: - self._buffered_request_bytes -= len(inbound.body) + self._release_inbound(stream_id) outbound = self._outbound.pop(stream_id, None) if outbound is not None: self._pending_output_bytes -= len(outbound.body) - outbound.offset @@ -458,6 +544,11 @@ def drop_stream(self, stream_id: int) -> None: 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) diff --git a/tests/test_http2.py b/tests/test_http2.py index 4a853cd..d932fc6 100644 --- a/tests/test_http2.py +++ b/tests/test_http2.py @@ -4,29 +4,30 @@ import unittest from unittest.mock import patch -from h2.config import H2Configuration -from h2.connection import H2Connection -from h2.events import ConnectionTerminated, DataReceived, ResponseReceived, StreamEnded +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, Response, SmallServer from smallserver.errors import ServerConfigurationError from smallserver.http2 import H2Protocol +from tests.kernel_fakes import FakeKernel -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 - +class HTTP2OptionalDependencyTests(unittest.TestCase): def test_dependency_is_lazy_and_missing_extra_is_actionable(self): original = builtins.__import__ @@ -39,6 +40,67 @@ def reject_h2(name, *args, **kwargs): 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}, + ): + 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) + ) + + +@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( @@ -135,7 +197,135 @@ def test_peer_reset_is_reported_once_for_handler_cancellation(self): self.assertEqual(server.take_cancelled_streams(), (1,)) self.assertEqual(server.take_cancelled_streams(), ()) + 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_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) + ) + + +@unittest.skipUnless(H2_AVAILABLE, "install the smallserver[test] HTTP/2 extra") class HTTP2ServerIntegrationTests(unittest.TestCase): _pair = HTTP2ProtocolTests._pair @@ -271,6 +461,258 @@ def test_large_response_respects_flow_control(self): self.assertEqual(bytes(received), body) self.assertEqual(server.pending_output_bytes, 0) + def test_writer_send_failure_is_fatal_and_releases_capacity(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 + + class FailingWriterTransport: + def __getattr__(self, name): + return getattr(original_transport, name) + + async def send_all(self, task, stream, data): + if getattr(task, "name", "") == "smallserver-http2-writer": + raise RuntimeError("injected HTTP/2 writer failure") + await original_transport.send_all(task, stream, data) + + server._transport = FailingWriterTransport() + 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", "/fail"), + ], + end_stream=True, + ) + connection.sendall(client.data_to_send()) + 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.assertIsInstance(server.failure, RuntimeError) + self.assertIn("writer failure", str(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() From cf74ffcde32144d5ccf813a35c934ebc52fda961 Mon Sep 17 00:00:00 2001 From: MikiEEE <35975462+MikiEEE@users.noreply.github.com> Date: Sat, 22 Aug 2026 02:11:29 -0500 Subject: [PATCH 25/53] docs: describe HTTP/2 test and timeout contracts --- README.md | 8 ++++++-- guide/http2.md | 14 ++++++++++---- pyproject.toml | 1 + 3 files changed, 17 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 3dd7769..8913505 100644 --- a/README.md +++ b/README.md @@ -26,7 +26,7 @@ yet. HTTP/2 currently supports cleartext prior knowledge only. ```bash python3 -m pip install -r requirements.txt -python3 -m pip install -e . +python3 -m pip install -e '.[test]' python3 -m unittest discover -s tests -v ``` @@ -92,7 +92,11 @@ app.listen( host="127.0.0.1", port=8000, protocol="http2", - http2_config=HTTP2Config(max_concurrent_streams=32), + http2_config=HTTP2Config( + max_concurrent_streams=32, + handshake_timeout=10, + idle_timeout=60, + ), ) ``` diff --git a/guide/http2.md b/guide/http2.md index 88747f8..18fca33 100644 --- a/guide/http2.md +++ b/guide/http2.md @@ -8,7 +8,7 @@ to own task scheduling and all network readiness. ```bash python3 -m pip install -r requirements.txt -python3 -m pip install -e '.[http2]' +python3 -m pip install -e '.[test]' python3 examples/http2_prior_knowledge.py ``` @@ -32,13 +32,19 @@ 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. Requests and responses use the same immutable `Request`, `Headers`, and -`Response` values as HTTP/1.1. The request version is `"HTTP/2"`. +size. Completed request bodies remain charged to the connection budget while +their handler is running. `handshake_timeout` bounds receipt of the client +preface and `idle_timeout` bounds inactive established connections; both 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. Connection shutdown emits GOAWAY and then releases the connection through the -SmallOS kernel transport. +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 diff --git a/pyproject.toml b/pyproject.toml index df79c26..4f007c4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -15,6 +15,7 @@ dependencies = [] [project.optional-dependencies] dev = ["build>=1.2"] http2 = ["h2>=4,<5"] +test = ["h2>=4,<5"] [tool.setuptools.packages.find] include = ["smallserver*"] From e9b972b3d6940685513725d4fcbf96b9b9e1c974 Mon Sep 17 00:00:00 2001 From: MikiEEE <35975462+MikiEEE@users.noreply.github.com> Date: Sat, 22 Aug 2026 02:16:38 -0500 Subject: [PATCH 26/53] fix: require finite HTTP/2 timeouts --- smallserver/http2.py | 8 +++++++- tests/test_http2.py | 3 +++ 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/smallserver/http2.py b/smallserver/http2.py index ba5467b..0109a25 100644 --- a/smallserver/http2.py +++ b/smallserver/http2.py @@ -3,6 +3,7 @@ from __future__ import annotations from dataclasses import dataclass +import math from typing import Any from .errors import ServerConfigurationError @@ -39,7 +40,12 @@ def __post_init__(self) -> None: 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 value <= 0: + 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") diff --git a/tests/test_http2.py b/tests/test_http2.py index d932fc6..d0e629b 100644 --- a/tests/test_http2.py +++ b/tests/test_http2.py @@ -57,6 +57,9 @@ def test_timeout_configuration_is_finite_and_positive(self): {"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): From d7774240a4009a51fff91ae195789054f1b6f33a Mon Sep 17 00:00:00 2001 From: MikiEEE <35975462+MikiEEE@users.noreply.github.com> Date: Sat, 22 Aug 2026 02:19:41 -0500 Subject: [PATCH 27/53] docs: add comprehensive public guide --- README.md | 326 ++++---------------------------- guide/adapters.md | 49 +++++ guide/api-reference.md | 73 +++++++ guide/configuration.md | 44 +++++ guide/development.md | 52 +++++ guide/errors-observability.md | 51 +++++ guide/getting-started.md | 63 ++++++ guide/index.md | 27 +++ guide/platforms-kernels.md | 40 ++++ guide/protocol-roadmap.md | 36 ++++ guide/requests-and-responses.md | 65 +++++++ guide/routing.md | 45 +++++ guide/runtime-lifecycle.md | 78 ++++++++ tests/test_documentation.py | 77 ++++++++ 14 files changed, 741 insertions(+), 285 deletions(-) create mode 100644 guide/adapters.md create mode 100644 guide/api-reference.md create mode 100644 guide/configuration.md create mode 100644 guide/development.md create mode 100644 guide/errors-observability.md create mode 100644 guide/getting-started.md create mode 100644 guide/index.md create mode 100644 guide/platforms-kernels.md create mode 100644 guide/protocol-roadmap.md create mode 100644 guide/requests-and-responses.md create mode 100644 guide/routing.md create mode 100644 guide/runtime-lifecycle.md create mode 100644 tests/test_documentation.py diff --git a/README.md b/README.md index 3c7598b..5c66963 100644 --- a/README.md +++ b/README.md @@ -1,305 +1,61 @@ # 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. - -## Current scope - -The current package provides an HTTP/1.1 baseline over a SmallOS runtime. It can: - -- 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. - -## Install for development - -```bash -python3 -m pip install -r requirements.txt -python3 -m pip install -e . -python3 -m unittest discover -s tests -v -``` - -SmallOS is installed from the canonical `master` branch in `requirements.txt`. -It owns scheduling, socket readiness, and foreign execution adapters. - -## Run the demo - -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. - -```bash -python3 -m pip install -r requirements.txt -python3 demo.py -``` - -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. - -## Bind a server - -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. +SmallServer is a small, SmallOS-native HTTP framework for Python 3.10+. The +current base serves bounded HTTP/1.1 requests, routes exact paths to async +handlers, and provides explicit lifecycle and third-party execution controls. ```python from smallserver import Response, SmallServer app = SmallServer() -@app.get("/health") -async def health(request): - return Response.json({"status": "ok"}) - -app.listen(host="127.0.0.1", port=8000) -``` - -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..." +if __name__ == "__main__": + app.listen(host="127.0.0.1", port=8000) ``` -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: +Install the canonical SmallOS master dependency and this package, then run the +example: -```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 +```console python3 -m pip install -r requirements.txt +python3 -m pip install -e . +python3 demo.py ``` -SmallOS's normalized distribution name is currently unavailable for public package installation; SmallServer must not claim a PyPI dependency until that is resolved. +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. + +Current boundaries are intentional: one request is served per HTTP/1.1 +connection; routes are exact static paths; keep-alive, pipelining, TLS, path +parameters, WebSockets, and HTTP/2 are not part of this base. + +## Documentation + +- [Guide index](guide/index.md) +- [Getting started](guide/getting-started.md) +- [Routing](guide/routing.md) +- [Requests and responses](guide/requests-and-responses.md) +- [Runtime and lifecycle](guide/runtime-lifecycle.md) +- [Configuration](guide/configuration.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, +[`examples/manual_runtime.py`](examples/manual_runtime.py) for caller-owned +SmallOS startup, and [`examples/adapters_demo.py`](examples/adapters_demo.py) +for blocking and asyncio escape hatches. + +SmallServer is early-stage software. Review the documented limits and lifecycle +contract before deploying it outside controlled environments. 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..1bea9fe --- /dev/null +++ b/guide/api-reference.md @@ -0,0 +1,73 @@ +# API reference + +This page summarizes the public names exported by `smallserver` on the +lifecycle base. Signatures omit overload detail where prose is clearer. + +## Application + +### `SmallServer()` + +- `get(path)`, `post(path)`, `put(path)`, `patch(path)`, `delete(path)` — route + decorators for one supported method. +- `route(path, methods)` — atomic multi-method route decorator. +- `async dispatch(request)` — dispatch an existing `Request`. +- `listen(host="127.0.0.1", port=8000, 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)` — 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. + +### `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). + +### `ServerHandle` + +Read-only properties: `address`, `port`, `closed`, `failure`, `finished`, +`cleanup_errors`, and `owned_connection_count`. + +Operations: `close()`, `async close_from_task(task)`, and `finalize()`. + +## 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..2d1693a --- /dev/null +++ b/guide/configuration.md @@ -0,0 +1,44 @@ +# Configuration + +Pass a `ServerConfig` to `listen()` or `serve()` to tune finite listener, +parser, and scheduling limits. + +```python +from smallserver import 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, +) +``` + +| 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. | + +Every field must be a positive integer; booleans are rejected. 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. diff --git a/guide/development.md b/guide/development.md new file mode 100644 index 0000000..68689c3 --- /dev/null +++ b/guide/development.md @@ -0,0 +1,52 @@ +# 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 . +``` + +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. + +Run the examples when their platform requirements are available: + +```console +python3 demo.py +python3 examples/adapters_demo.py +python3 examples/manual_runtime.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..bfd51c7 --- /dev/null +++ b/guide/errors-observability.md @@ -0,0 +1,51 @@ +# 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. + +## 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 in this base. 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/index.md b/guide/index.md new file mode 100644 index 0000000..457a1f5 --- /dev/null +++ b/guide/index.md @@ -0,0 +1,27 @@ +# SmallServer guide + +This guide documents the API available on the lifecycle base. 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 paths, methods, 404, and 405 behavior. +3. [Requests and responses](requests-and-responses.md) — immutable HTTP values. +4. [Runtime and lifecycle](runtime-lifecycle.md) — managed and caller-owned modes. +5. [Configuration](configuration.md) — finite parser and connection 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) + +The base described here supports HTTP/1.1 only. Protocol feature branches add +their own documentation without changing what this base promises. 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..ebf6c7e --- /dev/null +++ b/guide/protocol-roadmap.md @@ -0,0 +1,36 @@ +# Protocol and feature roadmap + +This guide base documents the exact API at the server-lifecycle milestone: +bounded HTTP/1.1, exact static 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 regex-routing feature introduces an explicit timeout-bounded regex route +form and captured path parameters while preserving exact static-route +precedence. It is not part of this base. Base applications should continue to +register literal paths and should not assume automatic query parsing. + +## WebSocket server + +The WebSocket feature is planned as optional RFC 6455 server support over an +HTTP/1.1 Upgrade, using SmallOS-native transport ownership and bounded +protocol state. TLS, compression, and RFC 8441 WebSockets over HTTP/2 remain +separate concerns. No WebSocket API is exported by this base. + +## HTTP/2 server + +The HTTP/2 feature is planned as an optional cleartext prior-knowledge server +using the hyper-h2 4.x sans-I/O stack. Its branch is responsible for documenting +dependency installation, stream concurrency, flow control, protocol limits, +GOAWAY, and graceful shutdown. This base does not accept HTTP/2 connections and +does not export an HTTP/2 configuration type. + +## 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 this page 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..4144a70 --- /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` for the current network server. + +The base parser accepts one origin-form HTTP/1.1 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..e262a57 --- /dev/null +++ b/guide/routing.md @@ -0,0 +1,45 @@ +# Routing + +SmallServer currently matches a request method and path exactly. Register +routes with `get`, `post`, `put`, `patch`, `delete`, or the multi-method +`route` decorator. + +```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. + +## Static-path boundary + +This base does not parse path parameters or split query strings. The request +target is matched as received, so `/items` and `/items?limit=10` are different +route keys. Register stable static paths and parse only data whose format your +application explicitly controls. + +Timeout-bounded regex routes and captured parameters are being developed as an +optional route form; see the [protocol and feature roadmap](protocol-roadmap.md#routing-extensions). +Do not write base-compatible examples that assume `/items/{id}` or automatic +query parsing. diff --git a/guide/runtime-lifecycle.md b/guide/runtime-lifecycle.md new file mode 100644 index 0000000..d67611e --- /dev/null +++ b/guide/runtime-lifecycle.md @@ -0,0 +1,78 @@ +# 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 `SmallOS().setKernel(Unix())`, +starts it, blocks until shutdown, and finalizes server-owned resources. 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/tests/test_documentation.py b/tests/test_documentation.py new file mode 100644 index 0000000..bfb33af --- /dev/null +++ b/tests/test_documentation.py @@ -0,0 +1,77 @@ +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", + "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() From fafa37952655201f90c09aae46eacb19d5d4fdc1 Mon Sep 17 00:00:00 2001 From: MikiEEE <35975462+MikiEEE@users.noreply.github.com> Date: Sat, 22 Aug 2026 02:26:32 -0500 Subject: [PATCH 28/53] fix: harden WebSocket protocol lifecycle --- smallserver/app.py | 5 +- smallserver/websocket.py | 330 +++++++++++++++++++++++++++++++++------ 2 files changed, 284 insertions(+), 51 deletions(-) diff --git a/smallserver/app.py b/smallserver/app.py index ea97f34..67eb03c 100644 --- a/smallserver/app.py +++ b/smallserver/app.py @@ -46,6 +46,7 @@ WebSocketUnavailable, _WebSocketRoute, _WebSocketState, + _is_http_token, _is_upgrade_attempt, _validate_upgrade, run_websocket_connection, @@ -243,9 +244,7 @@ def websocket( raise TypeError("WebSocket subprotocols must be an iterable of tokens") protocols = tuple(dict.fromkeys(subprotocols)) if any( - not isinstance(protocol, str) - or not protocol - or any(character in protocol for character in "()<>@,;:\\\"/[]?={} \t") + not isinstance(protocol, str) or not _is_http_token(protocol) for protocol in protocols ): raise ValueError("WebSocket subprotocols must be valid HTTP tokens") diff --git a/smallserver/websocket.py b/smallserver/websocket.py index 48fd91a..9e024e3 100644 --- a/smallserver/websocket.py +++ b/smallserver/websocket.py @@ -19,6 +19,9 @@ _INBOX_SIGNAL = 25 _OUTBOX_SIGNAL = 26 _ACK_SIGNAL = 27 +_HTTP_TOKEN_CHARACTERS = frozenset( + "!#$%&'*+-.^_`|~0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz" +) class WebSocketUnavailable(RuntimeError): @@ -85,6 +88,7 @@ class WebSocketConfig: handshake_timeout: float = 10.0 idle_timeout: float = 300.0 pong_timeout: float = 10.0 + write_timeout: float = 30.0 close_timeout: float = 5.0 deadline_resolution: float = 0.05 @@ -108,6 +112,7 @@ def __post_init__(self) -> None: "handshake_timeout", "idle_timeout", "pong_timeout", + "write_timeout", "close_timeout", "deadline_resolution", ): @@ -328,25 +333,33 @@ def __init__( self.deadline_task: Any = None self.accepted = False self.rejected = False + self.handshake_state = "pending" + self.handshake_error: BaseException | None = None self.shutdown = False self.peer_closed = False self.close_sent = False self.subprotocol: str | None = None self.disconnect: WebSocketDisconnect | None = None self.handler_error: BaseException | None = None + self.fatal_error: BaseException | None = None self.inbox: deque[WebSocketMessage] = deque() self.inbox_bytes = 0 self.outbox: deque[_OutboundCommand] = deque() self.outbox_bytes = 0 self.writer_busy = False + self.active_command: _OutboundCommand | None = None self._message_kind: type | None = None - self._message_parts: list[str] | list[bytes] = [] + self._message_buffer = bytearray() self._message_bytes = 0 self._guard = _FrameGuard(config.max_frame_payload_bytes) self.created_at = time.monotonic() self.last_activity = self.created_at self.pong_deadline: float | None = None + self._ping_generation = 0 + self._pending_ping_generation: int | None = None + self._pending_ping_payload: bytes | None = None self.close_deadline: float | None = None + self.write_deadline: float | None = None self._children: list[Any] = [] def _current_task(self) -> Any: @@ -376,7 +389,7 @@ async def accept( self, subprotocol: str | None, headers: Mapping[str, str] | None ) -> None: task = self._current_task() - if self.accepted or self.rejected: + if self.handshake_state != "pending": raise WebSocketStateError("WebSocket handshake is already decided") if subprotocol is not None: if subprotocol not in self.route.subprotocols: @@ -389,6 +402,7 @@ async def accept( "upgrade", "sec-websocket-accept", "sec-websocket-protocol", + "sec-websocket-extensions", "content-length", } if any(name.lower() in forbidden for name in extra): @@ -405,24 +419,55 @@ async def accept( lines.append("Sec-WebSocket-Protocol: {}".format(subprotocol)) lines.extend("{}: {}".format(name, value) for name, value in extra.items()) payload = ("\r\n".join(lines) + "\r\n\r\n").encode("latin-1") - self.protocol = self.api.Connection(self.api.ConnectionType.SERVER) - await self.transport.send_all(task, self.client, payload) - self.subprotocol = subprotocol - self.accepted = True - self.last_activity = time.monotonic() - self._signal(self.coordinator_task, _DECISION_SIGNAL) + protocol = self.api.Connection(self.api.ConnectionType.SERVER) + self.handshake_state = "accepting" + self.protocol = protocol + try: + await self.transport.send_all(task, self.client, payload) + except GeneratorExit: + raise + except BaseException as exc: + self._fail_handshake(exc) + raise + else: + self.subprotocol = subprotocol + self.accepted = True + self.handshake_state = "accepted" + self.last_activity = time.monotonic() + self._signal(self.coordinator_task, _DECISION_SIGNAL) async def reject(self, response: Response) -> None: task = self._current_task() - if self.accepted or self.rejected: + if self.handshake_state != "pending": raise WebSocketStateError("WebSocket handshake is already decided") if not isinstance(response, Response): raise TypeError("reject() requires a Response") if response.status < 300: raise ValueError("WebSocket rejection response must have status 300 or greater") - await self._send_http(task, response) - self.rejected = True - self._signal(self.coordinator_task, _DECISION_SIGNAL) + self.handshake_state = "rejecting" + try: + await self._send_http(task, response) + except GeneratorExit: + raise + except BaseException as exc: + self._fail_handshake(exc) + raise + else: + self.rejected = True + self.handshake_state = "rejected" + self._signal(self.coordinator_task, _DECISION_SIGNAL) + + def _fail_handshake(self, error: BaseException) -> None: + if self.handshake_state == "failed": + return + self.accepted = False + self.rejected = False + self.protocol = None + self.handshake_state = "failed" + self.handshake_error = error + if isinstance(error, (KeyboardInterrupt, SystemExit)): + self.fatal_error = error + self._disconnect(1006) def _require_open(self) -> None: if not self.accepted: @@ -456,8 +501,21 @@ async def send_message(self, value: str | bytes) -> None: async def ping(self, payload: bytes) -> None: self._require_open() - await self._enqueue(self.api.Ping(payload=payload), len(payload), wait=True) + if self._pending_ping_generation is not None: + raise WebSocketStateError("a WebSocket Ping is already awaiting Pong") + self._ping_generation += 1 + generation = self._ping_generation + self._pending_ping_generation = generation + self._pending_ping_payload = payload self.pong_deadline = time.monotonic() + self.config.pong_timeout + try: + await self._enqueue( + self.api.Ping(payload=payload), len(payload), wait=True + ) + except BaseException: + if self._pending_ping_generation == generation: + self._clear_pending_ping() + raise async def close(self, code: int, reason: str) -> None: self._require_open() @@ -465,13 +523,18 @@ async def close(self, code: int, reason: str) -> None: raise ValueError("invalid WebSocket close code") if not isinstance(reason, str) or len(reason.encode("utf-8")) > 123: raise ValueError("WebSocket close reason must be at most 123 UTF-8 bytes") - await self._enqueue( - self.api.CloseConnection(code=code, reason=reason), - 2 + len(reason.encode("utf-8")), - wait=True, - ) + reason_bytes = reason.encode("utf-8") self.close_sent = True self.close_deadline = time.monotonic() + self.config.close_timeout + try: + await self._enqueue( + self.api.CloseConnection(code=code, reason=reason), + 2 + len(reason_bytes), + wait=True, + ) + except BaseException: + self._disconnect(code, reason) + raise task = self._current_task() while not self.peer_closed and time.monotonic() < self.close_deadline: await task.sleep(min(self.config.deadline_resolution, self.config.close_timeout)) @@ -495,6 +558,45 @@ async def _enqueue(self, event: Any, size: int, *, wait: bool) -> None: if command.error is not None: raise command.error + def _clear_pending_ping(self) -> None: + self._pending_ping_generation = None + self._pending_ping_payload = None + self.pong_deadline = None + + def _fail_outbound(self, error: BaseException) -> None: + commands = list(self.outbox) + self.outbox.clear() + self.outbox_bytes = 0 + if self.active_command is not None: + commands.insert(0, self.active_command) + seen: set[int] = set() + for command in commands: + if id(command) in seen: + continue + seen.add(id(command)) + command.error = error + command.done = True + self._signal(command.waiter, _ACK_SIGNAL) + + def _cancel_task(self, target: Any) -> None: + if target is None or target is getattr(self.runtime, "cursor", None): + return + try: + self.runtime.cancel_task(target) + except (KeyboardInterrupt, SystemExit) as exc: + self.fatal_error = exc + raise + except BaseException: + # ServerHandle retains ownership and retries cancellation in finalization. + pass + + def _abort_writer(self, error: BaseException) -> None: + self._fail_outbound(error) + self._cancel_task(self.writer_task) + + def _cancel_handler(self) -> None: + self._cancel_task(self.handler_task) + def _enqueue_control(self, event: Any, size: int = 0) -> bool: if ( len(self.outbox) >= self.config.max_outbound_commands @@ -533,7 +635,11 @@ def request_shutdown(self, code: int = 1001) -> None: self.api.CloseConnection(code=code, reason="server shutdown"), 17 ) self.close_sent = True - self._disconnect(code, "server shutdown") + if self.handshake_state in {"pending", "accepting", "rejecting"}: + self._fail_handshake(WebSocketDisconnect(code, "server shutdown")) + else: + self._disconnect(code, "server shutdown") + self._cancel_handler() self._signal(self.writer_task, _OUTBOX_SIGNAL) @@ -543,6 +649,14 @@ def _token_list(value: str | None) -> tuple[str, ...]: return tuple(token.strip() for token in value.split(",") if token.strip()) +def _is_http_token(value: str) -> bool: + return ( + isinstance(value, str) + and bool(value) + and all(character in _HTTP_TOKEN_CHARACTERS for character in value) + ) + + def _message_size(value: str | bytes) -> int: return len(value.encode("utf-8")) if isinstance(value, str) else len(value) @@ -597,6 +711,15 @@ def _validate_upgrade( ) if not _valid_websocket_key(request.headers.get("sec-websocket-key")): return Response.text("invalid WebSocket key", status=400) + offered_subprotocols = _token_list( + request.headers.get("sec-websocket-protocol") + ) + offered_header = request.headers.get("sec-websocket-protocol") + if offered_header is not None and ( + not offered_subprotocols + or any(not _is_http_token(protocol) for protocol in offered_subprotocols) + ): + return Response.text("invalid WebSocket subprotocol", status=400) origin = request.headers.get("origin") if route.origins is not None and origin not in route.origins: return Response.text("WebSocket origin is not allowed", status=403) @@ -626,9 +749,11 @@ def spawn(routine: Any, name: str) -> Any: state.deadline_task = spawn( _run_deadlines, "smallserver-websocket-deadline" ) - while not state.accepted and not state.rejected and state.disconnect is None: + while state.handshake_state in {"pending", "accepting", "rejecting"}: await task.wait_signal(_DECISION_SIGNAL) if not state.accepted: + if state.fatal_error is not None: + raise state.fatal_error return state.writer_task = spawn(_run_writer, "smallserver-websocket-writer") state.reader_task = spawn(_run_reader, "smallserver-websocket-reader") @@ -639,22 +764,33 @@ def spawn(routine: Any, name: str) -> Any: except BaseException as exc: state.handler_error = exc + if isinstance(state.handler_error, (KeyboardInterrupt, SystemExit)): + raise state.handler_error + if state.fatal_error is not None: + raise state.fatal_error + if state.handler_error is not None and not state.close_sent: try: + state.close_deadline = time.monotonic() + state.config.close_timeout await state._enqueue( state.api.CloseConnection(code=1011, reason="handler failed"), 16, wait=True, ) state.close_sent = True + except (KeyboardInterrupt, SystemExit): + raise except BaseException: pass elif not state.close_sent and state.disconnect is None: try: + state.close_deadline = time.monotonic() + state.config.close_timeout await state._enqueue( state.api.CloseConnection(code=1000, reason=""), 2, wait=True ) state.close_sent = True + except (KeyboardInterrupt, SystemExit): + raise except BaseException: pass @@ -688,16 +824,27 @@ async def _run_handler(task: Any, state: _WebSocketState) -> None: await result except WebSocketDisconnect: pass + except GeneratorExit: + raise + except (KeyboardInterrupt, SystemExit) as exc: + state.handler_error = exc + if state.handshake_state in {"pending", "accepting", "rejecting"}: + state._fail_handshake(exc) + raise except BaseException as exc: state.handler_error = exc finally: - if not state.accepted and not state.rejected: + if state.handshake_state == "pending": response = Response.text( "internal server error" if state.handler_error is not None else "forbidden", status=500 if state.handler_error is not None else 403, ) try: await state.reject(response) + except (KeyboardInterrupt, SystemExit) as exc: + state.handler_error = exc + state.fatal_error = exc + raise except BaseException: state._disconnect(1006) state._signal(state.coordinator_task, _DECISION_SIGNAL) @@ -709,6 +856,8 @@ async def _run_writer(task: Any, state: _WebSocketState) -> None: command = state.outbox.popleft() state.outbox_bytes -= command.size state.writer_busy = True + state.active_command = command + state.write_deadline = time.monotonic() + state.config.write_timeout try: payload = state.protocol.send(command.event) for offset in range(0, len(payload), state.config.write_chunk_bytes): @@ -718,11 +867,23 @@ async def _run_writer(task: Any, state: _WebSocketState) -> None: payload[offset : offset + state.config.write_chunk_bytes], ) state.last_activity = time.monotonic() + except GeneratorExit: + raise + except (KeyboardInterrupt, SystemExit) as exc: + command.error = exc + state.fatal_error = exc + state._disconnect(1006) + state._cancel_handler() + raise except BaseException as exc: command.error = exc state._disconnect(1006) + state._cancel_handler() finally: state.writer_busy = False + if state.active_command is command: + state.active_command = None + state.write_deadline = None command.done = True state._signal(command.waiter, _ACK_SIGNAL) if not state.shutdown: @@ -744,11 +905,14 @@ async def _run_reader(task: Any, state: _WebSocketState) -> None: state.protocol.receive_data(None) _drain_protocol_events(state) state._disconnect(1006) + state._cancel_handler() return state.last_activity = time.monotonic() _receive_protocol_data(state, chunk) if _drain_protocol_events(state): return + except GeneratorExit: + raise except WebSocketCapacityError: if state.shutdown: return @@ -757,6 +921,12 @@ async def _run_reader(task: Any, state: _WebSocketState) -> None: ) state.close_sent = True state._disconnect(1009, "message too large") + state._cancel_handler() + except (KeyboardInterrupt, SystemExit) as exc: + state.fatal_error = exc + state._disconnect(1006) + state._cancel_handler() + raise except BaseException: if state.shutdown: return @@ -765,6 +935,7 @@ async def _run_reader(task: Any, state: _WebSocketState) -> None: ) state.close_sent = True state._disconnect(1002, "protocol error") + state._cancel_handler() def _receive_protocol_data(state: _WebSocketState, data: bytes) -> None: @@ -778,22 +949,27 @@ def _drain_protocol_events(state: _WebSocketState) -> bool: kind = str if isinstance(event, state.api.TextMessage) else bytes if state._message_kind is None: state._message_kind = kind - state._message_parts = [] + state._message_buffer.clear() state._message_bytes = 0 if state._message_kind is not kind: raise ValueError("WebSocket message type changed during fragmentation") state._message_bytes += _message_size(event.data) if state._message_bytes > state.config.max_message_bytes: raise WebSocketCapacityError("WebSocket message is too large") - state._message_parts.append(event.data) + encoded = ( + event.data.encode("utf-8") + if isinstance(event.data, str) + else event.data + ) + state._message_buffer.extend(encoded) if event.message_finished: value = ( - "".join(state._message_parts) + bytes(state._message_buffer).decode("utf-8") if kind is str - else b"".join(state._message_parts) + else bytes(state._message_buffer) ) state._message_kind = None - state._message_parts = [] + state._message_buffer.clear() state._message_bytes = 0 if not state._deliver_message(value): raise WebSocketCapacityError("WebSocket inbound queue is full") @@ -801,43 +977,101 @@ def _drain_protocol_events(state: _WebSocketState) -> bool: if not state._enqueue_control(event.response(), len(event.payload)): raise WebSocketCapacityError("WebSocket outbound queue is full") elif isinstance(event, state.api.Pong): - state.pong_deadline = None + _handle_pong(state, event.payload) elif isinstance(event, state.api.CloseConnection): state.peer_closed = True + close_code = int(event.code) + disconnect_reason = event.reason or "" if not state.close_sent: - if not state._enqueue_control(event.response(), 2): + if close_code == 1002: + response = state.api.CloseConnection( + code=1002, reason="protocol error" + ) + elif close_code == 1007: + response = state.api.CloseConnection( + code=1007, reason="invalid payload" + ) + else: + response = event.response() + if not state._enqueue_control( + response, _close_event_size(response) + ): raise WebSocketCapacityError("WebSocket outbound queue is full") state.close_sent = True - state._disconnect(event.code, event.reason or "") + if close_code == 1002: + disconnect_reason = "protocol error" + elif close_code == 1007: + disconnect_reason = "invalid payload" + state._disconnect(close_code, disconnect_reason) return True return False +def _handle_pong(state: _WebSocketState, payload: bytes) -> None: + if ( + state._pending_ping_generation is not None + and payload == state._pending_ping_payload + ): + state._clear_pending_ping() + + +def _close_event_size(event: Any) -> int: + if int(event.code) == 1005: + return 0 + return 2 + len((event.reason or "").encode("utf-8")) + + async def _run_deadlines(task: Any, state: _WebSocketState) -> None: while not state.shutdown: await task.sleep(state.config.deadline_resolution) now = time.monotonic() - if not state.accepted and not state.rejected: + if state.handshake_state in {"pending", "accepting", "rejecting"}: if now - state.created_at >= state.config.handshake_timeout: - try: - await state.reject( - Response.text("WebSocket handshake timed out", status=408) - ) - except BaseException: - state._disconnect(1006) + if state.handshake_state == "pending": + try: + await state.reject( + Response.text("WebSocket handshake timed out", status=408) + ) + except (KeyboardInterrupt, SystemExit) as exc: + state.fatal_error = exc + raise + except BaseException: + state._disconnect(1006) + else: + error = WebSocketDisconnect(1006, "handshake timed out") + state._fail_handshake(error) + state._cancel_handler() return continue - if state.accepted and now - state.last_activity >= state.config.idle_timeout: - state._enqueue_control( - state.api.CloseConnection(code=1001, reason="idle timeout"), 14 - ) - state.close_sent = True - state._disconnect(1001, "idle timeout") - return if state.pong_deadline is not None and now >= state.pong_deadline: - state._enqueue_control( - state.api.CloseConnection(code=1002, reason="Pong timeout"), 14 - ) - state.close_sent = True - state._disconnect(1002, "Pong timeout") + _begin_deadline_close(state, 1002, "Pong timeout") + elif state.accepted and now - state.last_activity >= state.config.idle_timeout: + _begin_deadline_close(state, 1001, "idle timeout") + if state.write_deadline is not None and now >= state.write_deadline: + error = WebSocketDisconnect(1006, "write timed out") + state._abort_writer(error) + state._cancel_handler() + state._disconnect(error.code, error.reason) + return + if state.close_deadline is not None and now >= state.close_deadline: + error = state.disconnect or WebSocketDisconnect(1006, "close timed out") + state._abort_writer(error) + state._cancel_handler() + state._disconnect(error.code, error.reason) return + + +def _begin_deadline_close( + state: _WebSocketState, code: int, reason: str +) -> None: + if not state.close_sent and state.protocol is not None: + reason_bytes = reason.encode("utf-8") + state._enqueue_control( + state.api.CloseConnection(code=code, reason=reason), + 2 + len(reason_bytes), + ) + state.close_sent = True + if state.close_deadline is None: + state.close_deadline = time.monotonic() + state.config.close_timeout + state._disconnect(code, reason) + state._cancel_handler() From e36a1bfd7c7de3b192bd3d7c224cca0eb0645384 Mon Sep 17 00:00:00 2001 From: MikiEEE <35975462+MikiEEE@users.noreply.github.com> Date: Sat, 22 Aug 2026 02:26:40 -0500 Subject: [PATCH 29/53] test: cover WebSocket review regressions --- tests/test_websocket.py | 404 +++++++++++++++++++++++++++++++++++++++- 1 file changed, 403 insertions(+), 1 deletion(-) diff --git a/tests/test_websocket.py b/tests/test_websocket.py index b2cc9bc..9755560 100644 --- a/tests/test_websocket.py +++ b/tests/test_websocket.py @@ -9,8 +9,10 @@ from unittest.mock import patch from SmallPackage import SmallOS, SmallTask, SmallWebSocketClient, Unix +from SmallPackage.adapters.threads import ThreadAdapter from smallserver import ( + AdapterRegistry, Headers, Request, Response, @@ -18,13 +20,20 @@ WebSocket, WebSocketCapacityError, WebSocketConfig, + WebSocketDisconnect, WebSocketUnavailable, ) from smallserver.websocket import ( _FrameGuard, _WebSocketState, _WebSocketRoute, + _drain_protocol_events, + _handle_pong, _load_wsproto, + _receive_protocol_data, + _run_deadlines, + _run_handler, + _run_writer, _validate_upgrade, ) @@ -48,6 +57,55 @@ async def unused_handler(socket: WebSocket) -> None: await socket.reject(Response(status=403)) +class _RecordingTransport: + def __init__(self) -> None: + self.send_calls = 0 + self.payloads: list[bytes] = [] + + async def send_all(self, task, client, payload: bytes) -> None: + self.send_calls += 1 + self.payloads.append(payload) + + +class _BlockingTransport(_RecordingTransport): + async def send_all(self, task, client, payload: bytes) -> None: + self.send_calls += 1 + self.payloads.append(payload) + await task.wait_signal(28) + + +def _make_state( + runtime, + transport, + handler, + *, + config: WebSocketConfig | None = None, +) -> _WebSocketState: + return _WebSocketState( + runtime, + transport, + object(), + upgrade_request(), + _WebSocketRoute(handler, None, ()), + config or WebSocketConfig(), + b"", + ) + + +def _make_accepted_state( + runtime, + transport, + handler, + *, + config: WebSocketConfig | None = None, +) -> _WebSocketState: + state = _make_state(runtime, transport, handler, config=config) + state.protocol = state.api.Connection(state.api.ConnectionType.SERVER) + state.accepted = True + state.handshake_state = "accepted" + return state + + class WebSocketProtocolTests(unittest.TestCase): def test_config_rejects_unbounded_or_inconsistent_limits(self) -> None: with self.assertRaisesRegex(ValueError, "max_message_bytes"): @@ -56,6 +114,8 @@ def test_config_rejects_unbounded_or_inconsistent_limits(self) -> None: WebSocketConfig(max_frame_payload_bytes=2, max_message_bytes=1) with self.assertRaisesRegex(ValueError, "idle_timeout"): WebSocketConfig(idle_timeout=float("inf")) + with self.assertRaisesRegex(ValueError, "write_timeout"): + WebSocketConfig(write_timeout=0) def test_registration_is_lazy_and_coexists_with_get(self) -> None: app = SmallServer() @@ -123,6 +183,270 @@ def test_origin_policy_is_explicit(self) -> None: ) ) + def test_subprotocol_tokens_are_ascii(self) -> None: + app = SmallServer() + for invalid in ("chat:v1", "café", "chat v1", ""): + with self.subTest(invalid=invalid): + with self.assertRaisesRegex(ValueError, "HTTP tokens"): + app.websocket( + "/invalid-{}".format(len(app._websocket_routes)), + subprotocols=(invalid,), + ) + + route = _WebSocketRoute(unused_handler, None, ("chat.v1",)) + for offered in ("chat:v1", "café", ","): + with self.subTest(offered=offered): + invalid = _validate_upgrade( + upgrade_request(**{"Sec-WebSocket-Protocol": offered}), route + ) + self.assertIsNotNone(invalid) + assert invalid is not None + self.assertEqual(invalid.status, 400) + + @unittest.skipUnless(HAS_WSPROTO, "websocket extra is not installed") + def test_extensions_cannot_be_selected_in_accept_response(self) -> None: + runtime = SmallOS().setKernel(Unix()) + transport = _RecordingTransport() + state = _make_state(runtime, transport, unused_handler) + outcome: list[BaseException] = [] + + async def accept_with_extension(task) -> None: + try: + await WebSocket(state).accept( + headers={"Sec-WebSocket-Extensions": "permessage-deflate"} + ) + except BaseException as exc: + outcome.append(exc) + + attempt = SmallTask(2, accept_with_extension, name="extension-rejection") + runtime.fork(attempt) + runtime.start() + self.assertIsInstance(outcome[0], ValueError) + self.assertEqual(state.handshake_state, "pending") + self.assertEqual(transport.send_calls, 0) + + @unittest.skipUnless(HAS_WSPROTO, "websocket extra is not installed") + def test_atomic_handshake_timeout_is_terminal_while_send_is_blocked(self) -> None: + runtime = SmallOS().setKernel(Unix()) + transport = _BlockingTransport() + config = WebSocketConfig( + handshake_timeout=0.02, + idle_timeout=1, + close_timeout=0.02, + deadline_resolution=0.005, + ) + state = _make_state(runtime, transport, unused_handler, config=config) + outcomes: list[BaseException] = [] + + async def accept_job(task) -> None: + state.handler_task = task + try: + await WebSocket(state).accept() + except BaseException as exc: + outcomes.append(exc) + + accept_task = SmallTask(2, accept_job, name="blocked-handshake") + deadline_task = SmallTask( + 2, _run_deadlines, args=(state,), name="handshake-deadline" + ) + state.deadline_task = deadline_task + runtime.fork([accept_task, deadline_task]) + runtime.start() + + self.assertEqual(transport.send_calls, 1) + self.assertEqual(state.handshake_state, "failed") + self.assertFalse(state.accepted) + self.assertFalse(state.rejected) + self.assertIsNotNone(state.handshake_error) + + async def retry_job(task) -> None: + try: + await WebSocket(state).accept() + except BaseException as exc: + outcomes.append(exc) + + retry_task = SmallTask(2, retry_job, name="handshake-retry") + runtime.fork(retry_task) + runtime.start() + self.assertIsInstance(outcomes[-1], Exception) + self.assertIn("already decided", str(outcomes[-1])) + self.assertEqual(transport.send_calls, 1) + + pending = _make_state( + SmallOS().setKernel(Unix()), _RecordingTransport(), unused_handler + ) + pending.request_shutdown() + self.assertEqual(pending.handshake_state, "failed") + self.assertIsInstance(pending.handshake_error, WebSocketDisconnect) + + @unittest.skipUnless(HAS_WSPROTO, "websocket extra is not installed") + def test_ping_is_armed_before_send_and_only_matching_pong_clears(self) -> None: + runtime = SmallOS().setKernel(Unix()) + state = _make_accepted_state(runtime, _RecordingTransport(), unused_handler) + observations: list[tuple[object, ...]] = [] + _handle_pong(state, b"unsolicited") + self.assertIsNone(state._pending_ping_generation) + + async def immediate_pong(event, size, *, wait): + observations.append( + ( + state._pending_ping_generation, + state._pending_ping_payload, + state.pong_deadline is not None, + ) + ) + _handle_pong(state, b"wrong") + observations.append((state._pending_ping_generation,)) + _handle_pong(state, b"probe") + + state._enqueue = immediate_pong + + async def ping_job(task) -> None: + await WebSocket(state).ping(b"probe") + + ping_task = SmallTask(2, ping_job, name="fast-pong") + runtime.fork(ping_task) + runtime.start() + self.assertIsNone(ping_task.exception) + self.assertEqual(observations[0][1:], (b"probe", True)) + self.assertIsNotNone(observations[1][0]) + self.assertIsNone(state._pending_ping_generation) + self.assertIsNone(state.pong_deadline) + + @unittest.skipUnless(HAS_WSPROTO, "websocket extra is not installed") + def test_fragment_metadata_is_coalesced_and_close_reasons_are_sanitized(self) -> None: + api = _load_wsproto() + runtime = SmallOS().setKernel(Unix()) + state = _make_accepted_state(runtime, _RecordingTransport(), unused_handler) + client = api.Connection(api.ConnectionType.CLIENT) + + _receive_protocol_data( + state, + client.send( + api.TextMessage(data="a", message_finished=False) + ), + ) + _drain_protocol_events(state) + for _ in range(2048): + _receive_protocol_data( + state, + client.send(api.TextMessage(data="", message_finished=False)), + ) + _drain_protocol_events(state) + self.assertEqual(len(state._message_buffer), 1) + _receive_protocol_data( + state, + client.send(api.TextMessage(data="b", message_finished=True)), + ) + _drain_protocol_events(state) + self.assertEqual(state.inbox.popleft().text, "ab") + + peer_close_state = _make_accepted_state( + runtime, _RecordingTransport(), unused_handler + ) + peer = api.Connection(api.ConnectionType.CLIENT) + _receive_protocol_data( + peer_close_state, + peer.send(api.CloseConnection(code=1000, reason="peer detail")), + ) + self.assertTrue(_drain_protocol_events(peer_close_state)) + command = peer_close_state.outbox.popleft() + self.assertEqual(command.size, 2 + len(b"peer detail")) + self.assertEqual(command.event.reason, "peer detail") + + protocol_error_state = _make_accepted_state( + runtime, _RecordingTransport(), unused_handler + ) + _receive_protocol_data(protocol_error_state, b"\x83\x80mask") + self.assertTrue(_drain_protocol_events(protocol_error_state)) + generated = protocol_error_state.outbox.popleft() + self.assertEqual(int(generated.event.code), 1002) + self.assertEqual(generated.event.reason, "protocol error") + self.assertEqual(protocol_error_state.disconnect.reason, "protocol error") + + @unittest.skipUnless(HAS_WSPROTO, "websocket extra is not installed") + def test_slow_writer_is_interrupted_by_bounded_deadlines(self) -> None: + runtime = SmallOS().setKernel(Unix()) + config = WebSocketConfig( + idle_timeout=1, + write_timeout=0.02, + close_timeout=0.02, + pong_timeout=1, + deadline_resolution=0.005, + ) + state = _make_accepted_state( + runtime, _BlockingTransport(), unused_handler, config=config + ) + outcomes: list[str] = [] + + async def sender(task) -> None: + state.handler_task = task + try: + await WebSocket(state).send_text("blocked") + finally: + outcomes.append("sender-finished") + + sender_task = SmallTask(2, sender, name="blocked-sender") + writer_task = SmallTask(2, _run_writer, args=(state,), name="blocked-writer") + deadline_task = SmallTask(2, _run_deadlines, args=(state,), name="write-deadline") + state.writer_task = writer_task + state.deadline_task = deadline_task + started = time.monotonic() + runtime.fork([sender_task, writer_task, deadline_task]) + runtime.start() + + self.assertLess(time.monotonic() - started, 0.5) + self.assertEqual(outcomes, ["sender-finished"]) + self.assertIsNotNone(state.disconnect) + self.assertEqual(state.disconnect.code, 1006) + self.assertEqual(state.disconnect.reason, "write timed out") + self.assertEqual(state.outbox_bytes, 0) + self.assertEqual(len(state.outbox), 0) + + @unittest.skipUnless(HAS_WSPROTO, "websocket extra is not installed") + def test_critical_handler_failures_keep_identity_and_ordinary_errors_translate(self) -> None: + for critical in (KeyboardInterrupt("stop"), SystemExit(7)): + with self.subTest(critical=type(critical).__name__): + runtime = SmallOS().setKernel(Unix()) + + async def critical_handler(socket, error=critical) -> None: + raise error + + state = _make_state( + runtime, _RecordingTransport(), critical_handler + ) + handler_task = SmallTask( + 2, _run_handler, args=(state,), name="critical-handler" + ) + state.handler_task = handler_task + runtime.fork(handler_task) + try: + runtime.start() + except (KeyboardInterrupt, SystemExit) as caught: + self.assertIs(caught, critical) + else: + self.fail("critical handler exception did not escape unchanged") + self.assertIs(state.fatal_error, critical) + self.assertEqual(state.handshake_state, "failed") + + runtime = SmallOS().setKernel(Unix()) + + async def ordinary_handler(socket) -> None: + raise RuntimeError("private detail") + + transport = _RecordingTransport() + state = _make_state(runtime, transport, ordinary_handler) + handler_task = SmallTask( + 2, _run_handler, args=(state,), name="ordinary-handler" + ) + state.handler_task = handler_task + runtime.fork(handler_task) + runtime.start() + self.assertIsNone(handler_task.exception) + self.assertTrue(state.rejected) + self.assertIn(b"HTTP/1.1 500 Internal Server Error", transport.payloads[0]) + self.assertNotIn(b"private detail", transport.payloads[0]) + @unittest.skipUnless(HAS_WSPROTO, "websocket extra is not installed") def test_frame_guard_bounds_declared_length_before_payload(self) -> None: guard = _FrameGuard(max_payload_bytes=1024) @@ -517,7 +841,7 @@ async def handshake_timeout(websocket: WebSocket) -> None: @app.websocket("/idle-timeout") async def idle_timeout(websocket: WebSocket) -> None: await websocket.accept() - await websocket.receive() + await runtime.cursor.sleep(5) @app.websocket("/pong-timeout") async def pong_timeout(websocket: WebSocket) -> None: @@ -611,6 +935,84 @@ def client_work() -> None: self.assertEqual(outcomes["pong_code"], 1002) self.assertTrue(server.finished) + def test_idle_deadline_cancels_adapter_waiting_handler(self) -> None: + runtime = SmallOS().setKernel(Unix()) + release = threading.Event() + entered = threading.Event() + app = SmallServer( + websocket_config=WebSocketConfig( + idle_timeout=0.05, + close_timeout=0.1, + deadline_resolution=0.01, + ) + ) + errors: list[BaseException] = [] + + def blocking_work() -> None: + entered.set() + if not release.wait(2): + raise TimeoutError("adapter worker was not released") + + with AdapterRegistry( + blocking=ThreadAdapter(max_workers=1, max_pending=1) + ) as services: + + @app.websocket("/adapter-idle") + async def adapter_idle(websocket: WebSocket) -> None: + await websocket.accept() + await services.call("blocking", blocking_work) + + try: + server = app.serve(runtime, host="127.0.0.1", port=0) + except PermissionError: + self.skipTest("the current sandbox does not permit loopback TCP binds") + + close_codes: list[int | None] = [] + + def client_work() -> None: + try: + with socket.create_connection( + ("127.0.0.1", server.port), timeout=3 + ) as stream: + stream.sendall( + b"GET /adapter-idle HTTP/1.1\r\nHost: localhost\r\n" + b"Upgrade: websocket\r\nConnection: Upgrade\r\n" + b"Sec-WebSocket-Version: 13\r\n" + b"Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==\r\n\r\n" + ) + response = b"" + while b"\r\n\r\n" not in response: + response += stream.recv(4096) + if not entered.wait(1): + raise TimeoutError("adapter handler did not start") + api = _load_wsproto() + client = api.Connection(api.ConnectionType.CLIENT) + events = _receive_events( + stream, client, api.CloseConnection + ) + close = next( + event + for event in events + if isinstance(event, api.CloseConnection) + ) + close_codes.append(close.code) + stream.sendall(client.send(close.response())) + except BaseException as exc: + errors.append(exc) + finally: + release.set() + server.close() + + worker = threading.Thread(target=client_work, daemon=True) + worker.start() + runtime.start() + worker.join(timeout=5) + + self.assertFalse(worker.is_alive()) + self.assertEqual(errors, []) + self.assertEqual(close_codes, [1001]) + self.assertTrue(server.finished) + def test_server_shutdown_attempts_close_and_releases_children(self) -> None: api = _load_wsproto() runtime = SmallOS().setKernel(Unix()) From 0970716a4e30bb5a6e8f9bfbf2d2cf28219a64bf Mon Sep 17 00:00:00 2001 From: MikiEEE <35975462+MikiEEE@users.noreply.github.com> Date: Sat, 22 Aug 2026 02:26:46 -0500 Subject: [PATCH 30/53] docs: document WebSocket deadline semantics --- guide/websockets.md | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/guide/websockets.md b/guide/websockets.md index 4413e2d..342cc82 100644 --- a/guide/websockets.md +++ b/guide/websockets.md @@ -34,8 +34,14 @@ 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, and close limits are finite -and configurable through `WebSocketConfig`. +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 From 0a7a03d6a05434bd4bba36b98d5c3a753c5c774d Mon Sep 17 00:00:00 2001 From: MikiEEE <35975462+MikiEEE@users.noreply.github.com> Date: Sat, 22 Aug 2026 02:29:17 -0500 Subject: [PATCH 31/53] fix: bound HTTP/2 fairness and connection failures --- smallserver/app.py | 80 ++++++++++----------- smallserver/http2.py | 163 +++++++++++++++++++++++++++++++----------- smallserver/server.py | 15 +++- tests/test_http2.py | 162 ++++++++++++++++++++++++++++++++++------- 4 files changed, 309 insertions(+), 111 deletions(-) diff --git a/smallserver/app.py b/smallserver/app.py index c039694..5ff9ef6 100644 --- a/smallserver/app.py +++ b/smallserver/app.py @@ -662,6 +662,7 @@ async def _http2_connection_loop( 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 @@ -669,27 +670,41 @@ async def _http2_connection_loop( if not chunk: break state.mark_activity() - try: - ready = protocol.receive_data(chunk) - except Exception as protocol_error: - primary_error = protocol_error - state.close_error_code = 1 + 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: + 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 - 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: - 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() except Exception as exc: primary_error = exc except BaseException as exc: @@ -764,11 +779,7 @@ async def _http2_writer_loop( payload = state.protocol.close() if payload: await handle._transport.send_all(task, client, payload) - if not handle._transport.close_safely(client): - error = client.close_error or RuntimeError( - "kernel connection close failed" - ) - handle._connection_close_failed(error, task) + handle._force_connection_close(client, task) return while not state.closing: payload = state.protocol.flush() @@ -778,12 +789,7 @@ async def _http2_writer_loop( except Exception as error: state.failure = error state.closing = True - handle._listener_failed(error, task) - if not handle._transport.close_safely(client): - close_error = client.close_error or RuntimeError( - "kernel connection close failed" - ) - handle._connection_close_failed(close_error, task, error) + handle._force_connection_close(client, task, error) raise finally: if task in handle._owned_tasks: @@ -802,11 +808,7 @@ async def _http2_shutdown_enforcer( if client.closed: return state.closing = True - if not handle._transport.close_safely(client): - error = client.close_error or RuntimeError( - "kernel connection close failed" - ) - handle._connection_close_failed(error, task, state.failure) + handle._force_connection_close(client, task, state.failure) finally: if task in handle._owned_tasks: handle._owned_tasks.remove(task) @@ -868,8 +870,4 @@ def _http2_force_close( ) -> None: state.failure = error state.closing = True - if not handle._transport.close_safely(client): - close_error = client.close_error or RuntimeError( - "kernel connection close failed" - ) - handle._connection_close_failed(close_error, task, error) + handle._force_connection_close(client, task, error) diff --git a/smallserver/http2.py b/smallserver/http2.py index 0109a25..3a41fcc 100644 --- a/smallserver/http2.py +++ b/smallserver/http2.py @@ -25,7 +25,9 @@ class HTTP2Config: 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 @@ -57,6 +59,14 @@ def __post_init__(self) -> None: 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 < 9: + raise ValueError( + "max_control_output_bytes must allow one HTTP/2 control frame" + ) @dataclass(frozen=True) @@ -72,7 +82,7 @@ class _InboundStream: method: str path: str headers: Headers - body: bytearray + body: bytearray | bytes expected_content_length: int | None dispatched: bool = False @@ -88,52 +98,92 @@ class _FrameBudget: def __init__(self, config: HTTP2Config) -> None: self._config = config - self._buffer = bytearray() + 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) -> tuple[bytes, ...]: - self._buffer.extend(data) - chunks: list[bytes] = [] + def feed(self, data: bytes) -> None: + view = memoryview(data) + offset = 0 if not self._preface_received: - prefix_length = min(len(self._buffer), len(HTTP2_CLIENT_PREFACE)) - if bytes(self._buffer[:prefix_length]) != HTTP2_CLIENT_PREFACE[:prefix_length]: + 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._buffer) < len(HTTP2_CLIENT_PREFACE): - return () - chunks.append(bytes(self._buffer[: len(HTTP2_CLIENT_PREFACE)])) - del self._buffer[: len(HTTP2_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 len(self._buffer) >= 9: - length = int.from_bytes(self._buffer[:3], "big") - if length > self._config.max_frame_size: - raise ValueError("HTTP/2 frame exceeds configured maximum") - frame_length = 9 + length - if len(self._buffer) < frame_length: - break - frame_type = self._buffer[3] - flags = self._buffer[4] - stream_id = int.from_bytes(self._buffer[5:9], "big") & 0x7FFFFFFF - if frame_type == 0x1: - if self._header_stream is not None: - raise ValueError("interleaved HTTP/2 header blocks are invalid") - self._header_stream = stream_id - self._header_bytes = length - elif frame_type == 0x9: - if self._header_stream != stream_id: - 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") - if frame_type in (0x1, 0x9) and flags & 0x4: - self._header_stream = None - self._header_bytes = 0 - chunks.append(bytes(self._buffer[:frame_length])) - del self._buffer[:frame_length] + 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 @@ -249,6 +299,7 @@ def _begin_new_stream(self, stream_id: Any, allowed_ids: Any) -> Any: 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.last_processed_stream_id = 0 self.remote_closed = False @@ -260,7 +311,7 @@ def active_stream_count(self) -> int: @property def pending_output_bytes(self) -> int: - return self._pending_output_bytes + return self._pending_output_bytes + len(self._control_output) @property def buffered_request_bytes(self) -> int: @@ -270,13 +321,18 @@ def buffered_request_bytes(self) -> int: 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() return self.connection.data_to_send() def receive_data(self, data: bytes) -> tuple[H2ReadyRequest, ...]: ready: list[H2ReadyRequest] = [] - for wire_chunk in self._frames.feed(data): + 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"]): @@ -300,8 +356,20 @@ def receive_data(self, data: bytes) -> tuple[H2ReadyRequest, ...]: event, (self._events["window"], self._events["settings"]) ): pass + self._capture_control_output() return tuple(ready) + 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 take_cancelled_streams(self) -> tuple[int, ...]: """Return peer-reset stream ids exactly once.""" cancelled, self._cancelled_streams = self._cancelled_streams, [] @@ -418,6 +486,9 @@ def _data_received(self, stream_id: int, data: bytes, flow_length: int) -> None: ): 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 @@ -433,11 +504,13 @@ def _stream_ended(self, stream_id: int) -> H2ReadyRequest | None: 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, - bytes(stream.body), + body, "HTTP/2", ) return H2ReadyRequest(stream_id, request) @@ -449,7 +522,7 @@ def queue_response(self, stream_id: int, response: Response) -> bool: body_size = len(response.body) if ( body_size > self.config.max_response_body_bytes - or self._pending_output_bytes + body_size + 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): @@ -463,6 +536,8 @@ def queue_response(self, stream_id: int, response: Response) -> bool: return True def flush(self) -> bytes: + control = bytes(self._control_output) + self._control_output.clear() commands, self._commands = self._commands, [] for operation, stream_id, response in commands: if operation == "reset": @@ -503,7 +578,7 @@ def flush(self) -> bytes: if end_stream: self._outbound.pop(stream_id, None) self._active_streams.discard(stream_id) - return self.connection.data_to_send() + return control + self.connection.data_to_send() def _start_response(self, stream_id: int, response: Response) -> None: headers: list[tuple[str, str]] = [(":status", str(response.status))] @@ -578,4 +653,6 @@ def close(self, error_code: int = 0) -> bytes: self._active_streams.clear() self._buffered_request_bytes = 0 self._pending_output_bytes = 0 - return self.connection.data_to_send() + control = bytes(self._control_output) + self._control_output.clear() + return control + self.connection.data_to_send() diff --git a/smallserver/server.py b/smallserver/server.py index 7df4dc5..f2434aa 100644 --- a/smallserver/server.py +++ b/smallserver/server.py @@ -395,12 +395,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, diff --git a/tests/test_http2.py b/tests/test_http2.py index d0e629b..e8b32b3 100644 --- a/tests/test_http2.py +++ b/tests/test_http2.py @@ -22,9 +22,11 @@ from SmallPackage import SmallOS, Unix from smallserver import HTTP2Config, Response, SmallServer +from smallserver._transport import KernelTransport, TransportHandle from smallserver.errors import ServerConfigurationError -from smallserver.http2 import H2Protocol -from tests.kernel_fakes import FakeKernel +from smallserver.http2 import H2Protocol, _FrameBudget +from smallserver.server import ServerConfig, ServerHandle +from tests.kernel_fakes import FakeKernel, OpaqueHandle class HTTP2OptionalDependencyTests(unittest.TestCase): @@ -90,6 +92,42 @@ def cancel_task(self, task): 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): @@ -159,7 +197,8 @@ def test_request_and_response_limits_reset_streams_without_unbounded_buffers(sel max_body_bytes=4, max_connection_buffer_bytes=8, max_response_body_bytes=4, - max_pending_output_bytes=8, + max_pending_output_bytes=32, + max_control_output_bytes=16, ) client, server = self._pair(config) client.send_headers( @@ -232,6 +271,23 @@ def test_completed_slow_handler_body_remains_in_connection_budget(self): 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( @@ -327,6 +383,40 @@ def test_invalid_content_length_is_a_stream_error(self): 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=16) + ) + limited_client.ping(b"12345678") + 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) + @unittest.skipUnless(H2_AVAILABLE, "install the smallserver[test] HTTP/2 extra") class HTTP2ServerIntegrationTests(unittest.TestCase): @@ -464,7 +554,7 @@ def test_large_response_respects_flow_control(self): self.assertEqual(bytes(received), body) self.assertEqual(server.pending_output_bytes, 0) - def test_writer_send_failure_is_fatal_and_releases_capacity(self): + def test_writer_send_failure_closes_only_client_and_listener_stays_healthy(self): runtime = SmallOS().setKernel(Unix()) app = SmallServer() @@ -479,13 +569,19 @@ async def fail(request): 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): - if getattr(task, "name", "") == "smallserver-http2-writer": + 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) @@ -494,25 +590,39 @@ async def send_all(self, task, stream, data): 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", "/fail"), - ], - end_stream=True, - ) - connection.sendall(client.data_to_send()) - while connection.recv(65535): - pass + 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: @@ -526,8 +636,8 @@ def client_work(): worker.join(timeout=3) self.assertFalse(worker.is_alive()) self.assertEqual(errors, []) - self.assertIsInstance(server.failure, RuntimeError) - self.assertIn("writer failure", str(server.failure)) + self.assertTrue(failure_injected) + self.assertIsNone(server.failure) self.assertEqual(server.owned_connection_count, 0) self.assertTrue(server.finished) From daba8e7c436d573d7515f29932713a4abbd8339d Mon Sep 17 00:00:00 2001 From: MikiEEE <35975462+MikiEEE@users.noreply.github.com> Date: Sat, 22 Aug 2026 02:39:13 -0500 Subject: [PATCH 32/53] fix: close HTTP/2 batch and output budget gaps --- smallserver/app.py | 2 + smallserver/http2.py | 64 +++++++++++++--- tests/test_http2.py | 175 +++++++++++++++++++++++++++++++++++++++++-- 3 files changed, 226 insertions(+), 15 deletions(-) diff --git a/smallserver/app.py b/smallserver/app.py index 5ff9ef6..3fa627f 100644 --- a/smallserver/app.py +++ b/smallserver/app.py @@ -683,6 +683,8 @@ async def _http2_connection_loop( 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, diff --git a/smallserver/http2.py b/smallserver/http2.py index 3a41fcc..87ce226 100644 --- a/smallserver/http2.py +++ b/smallserver/http2.py @@ -63,9 +63,9 @@ def __post_init__(self) -> None: raise ValueError( "max_control_output_bytes cannot exceed max_pending_output_bytes" ) - if self.max_control_output_bytes < 9: + if self.max_control_output_bytes < 51: raise ValueError( - "max_control_output_bytes must allow one HTTP/2 control frame" + "max_control_output_bytes must allow initial HTTP/2 settings" ) @@ -301,6 +301,7 @@ def _begin_new_stream(self, stream_id: Any, allowed_ids: Any) -> Any: 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 @@ -327,10 +328,13 @@ def has_pending_input(self) -> bool: def initiate(self) -> bytes: self.connection.initiate_connection() - return self.connection.data_to_send() + 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, ...]: - ready: list[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) @@ -344,7 +348,7 @@ def receive_data(self, data: bytes) -> tuple[H2ReadyRequest, ...]: elif isinstance(event, self._events["ended"]): completed = self._stream_ended(event.stream_id) if completed is not None: - ready.append(completed) + self._ready_requests.append(completed) elif isinstance(event, self._events["reset"]): self._cancelled_streams.append(event.stream_id) self.drop_stream(event.stream_id) @@ -357,7 +361,20 @@ def receive_data(self, data: bytes) -> tuple[H2ReadyRequest, ...]: ): pass self._capture_control_output() - return tuple(ready) + 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() @@ -370,6 +387,13 @@ def _capture_control_output(self) -> None: 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, [] @@ -536,15 +560,18 @@ def queue_response(self, stream_id: int, response: Response) -> bool: return True def flush(self) -> bytes: - control = bytes(self._control_output) - self._control_output.clear() 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 @@ -575,10 +602,16 @@ def flush(self) -> bytes: 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) - return control + self.connection.data_to_send() + 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))] @@ -613,6 +646,9 @@ def _start_response(self, stream_id: int, response: Response) -> None: 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 @@ -650,9 +686,17 @@ def close(self, error_code: int = 0) -> bytes: 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() - return control + self.connection.data_to_send() + 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/tests/test_http2.py b/tests/test_http2.py index e8b32b3..5543226 100644 --- a/tests/test_http2.py +++ b/tests/test_http2.py @@ -197,8 +197,8 @@ def test_request_and_response_limits_reset_streams_without_unbounded_buffers(sel max_body_bytes=4, max_connection_buffer_bytes=8, max_response_body_bytes=4, - max_pending_output_bytes=32, - max_control_output_bytes=16, + max_pending_output_bytes=128, + max_control_output_bytes=64, ) client, server = self._pair(config) client.send_headers( @@ -239,6 +239,38 @@ def test_peer_reset_is_reported_once_for_handler_cancellation(self): 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, @@ -403,9 +435,10 @@ def test_control_output_is_bounded_and_frames_are_processed_in_batches(self): self.assertEqual(server.pending_output_bytes, 0) limited_client, limited_server = self._pair( - HTTP2Config(max_control_output_bytes=16) + HTTP2Config(max_control_output_bytes=52) ) - limited_client.ping(b"12345678") + 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()) @@ -417,6 +450,57 @@ def test_compressed_header_budget_rejects_declared_size_before_payload(self): 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(H2_AVAILABLE, "install the smallserver[test] HTTP/2 extra") class HTTP2ServerIntegrationTests(unittest.TestCase): @@ -514,11 +598,92 @@ def client_work(): 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), + max_pending_output_bytes=len(body) + 64 * 1024, ) client, server = self._pair(config) client.send_headers( From 58e7d7fc28a3093bc1e9779540ce8d5a908e3c0c Mon Sep 17 00:00:00 2001 From: MikiEEE <35975462+MikiEEE@users.noreply.github.com> Date: Sat, 22 Aug 2026 02:39:39 -0500 Subject: [PATCH 33/53] docs: clarify WebSocket example and closure semantics --- examples/websocket_echo.py | 4 ++-- guide/development.md | 5 +++-- guide/websockets.md | 16 ++++++++++++++-- 3 files changed, 19 insertions(+), 6 deletions(-) diff --git a/examples/websocket_echo.py b/examples/websocket_echo.py index 01bf4d3..4153dea 100644 --- a/examples/websocket_echo.py +++ b/examples/websocket_echo.py @@ -13,9 +13,9 @@ ) -@app.websocket("/echo", subprotocols=("echo.v1",)) +@app.websocket("/echo") async def echo(socket: WebSocket) -> None: - await socket.accept(subprotocol="echo.v1") + await socket.accept() async for message in socket: if message.is_text: await socket.send_text(message.text) diff --git a/guide/development.md b/guide/development.md index 12bde66..b51b5c0 100644 --- a/guide/development.md +++ b/guide/development.md @@ -41,8 +41,9 @@ 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. +The three network examples block until shutdown. `adapters_demo.py` completes +on its own and demonstrates SQLite thread affinity and a persistent asyncio +loop. ## Contribution boundaries diff --git a/guide/websockets.md b/guide/websockets.md index 342cc82..5fe7b48 100644 --- a/guide/websockets.md +++ b/guide/websockets.md @@ -46,8 +46,15 @@ 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`; peer or server closure raises `WebSocketDisconnect` -from receive operations. +`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 @@ -56,3 +63,8 @@ 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. From beb292e923127db4820fc5837d50d08d0d908572 Mon Sep 17 00:00:00 2001 From: MikiEEE <35975462+MikiEEE@users.noreply.github.com> Date: Sat, 22 Aug 2026 02:46:01 -0500 Subject: [PATCH 34/53] docs: clarify HTTP/2 install and request semantics --- guide/development.md | 14 +++++++++++++- guide/http2.md | 15 ++++++++------- guide/requests-and-responses.md | 4 ++-- 3 files changed, 23 insertions(+), 10 deletions(-) diff --git a/guide/development.md b/guide/development.md index 68689c3..f9b0fd8 100644 --- a/guide/development.md +++ b/guide/development.md @@ -7,7 +7,7 @@ SmallServer in editable mode: ```console python3 -m pip install -r requirements.txt -python3 -m pip install -e . +python3 -m pip install -e '.[test]' ``` For reproducible validation, put the canonical SmallOS checkout at the front of @@ -27,6 +27,18 @@ 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 +``` + +The dependency-contract tests run and HTTP/2 interoperability cases skip +cleanly; importing and testing HTTP/1.1 must not require hyper-h2. + Run the examples when their platform requirements are available: ```console diff --git a/guide/http2.md b/guide/http2.md index fd95c89..f426355 100644 --- a/guide/http2.md +++ b/guide/http2.md @@ -8,7 +8,7 @@ to own task scheduling and all network readiness. ```bash python3 -m pip install -r requirements.txt -python3 -m pip install -e '.[test]' +python3 -m pip install -e '.[http2]' python3 examples/http2_prior_knowledge.py ``` @@ -35,12 +35,13 @@ 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 and `idle_timeout` bounds inactive established connections; both 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"`. +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. diff --git a/guide/requests-and-responses.md b/guide/requests-and-responses.md index 4144a70..49a3ab3 100644 --- a/guide/requests-and-responses.md +++ b/guide/requests-and-responses.md @@ -11,9 +11,9 @@ A handler receives: - `path`: the request target, beginning with `/`; - `headers`: a case-insensitive `Headers` mapping; - `body`: complete request bytes; -- `version`: `HTTP/1.1` for the current network server. +- `version`: `HTTP/1.1` or `HTTP/2`, selected by the listener protocol. -The base parser accepts one origin-form HTTP/1.1 request framed by zero or one +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. From 40c261a47aaad3bc3538ea602b4a3d6e95147571 Mon Sep 17 00:00:00 2001 From: MikiEEE <35975462+MikiEEE@users.noreply.github.com> Date: Sat, 22 Aug 2026 03:09:34 -0500 Subject: [PATCH 35/53] Test regex observer lifecycle ownership --- tests/test_server.py | 79 ++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 77 insertions(+), 2 deletions(-) diff --git a/tests/test_server.py b/tests/test_server.py index ad225b2..5bb3771 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -4,9 +4,14 @@ import warnings from unittest.mock import patch -from smallserver import ServerStartupError, SmallServer +from smallserver import RouteErrorEvent, ServerStartupError, SmallServer from smallserver.errors import _CleanupTransaction -from smallserver.server import HTTPParseError, HTTPRequestParser, ServerConfig +from smallserver.server import ( + HTTPParseError, + HTTPRequestParser, + RouteObserverChannel, + ServerConfig, +) from tests.kernel_fakes import FakeKernel @@ -70,6 +75,76 @@ def resume_task(self, task) -> None: self.assertEqual([handle.name for handle in runtime.kernel.closed], ["listener"]) self.assertEqual(runtime.kernel.wakeup.close_calls, 1) + def test_observer_task_is_owned_by_startup_rollback(self) -> None: + class Runtime: + def __init__(self) -> None: + self.kernel = FakeKernel() + self.tasks = [] + self.cancelled = [] + + def fork(self, tasks) -> None: + self.tasks = list(tasks) + raise RuntimeError("no task capacity") + + def cancel_task(self, task) -> None: + self.cancelled.append(task) + task.cancel() + + def resume_task(self, task) -> None: + pass + + runtime = Runtime() + app = SmallServer(route_error_observer=lambda event: None) + with self.assertRaisesRegex(RuntimeError, "capacity"): + app.serve(runtime) + + self.assertEqual(runtime.cancelled, runtime.tasks) + self.assertEqual( + [task.name for task in runtime.tasks], + [ + "smallserver-listener", + "smallserver-close-watcher", + "smallserver-route-observer", + ], + ) + self.assertEqual([handle.name for handle in runtime.kernel.closed], ["listener"]) + self.assertEqual(runtime.kernel.wakeup.close_calls, 1) + + 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 From 884cb35533ff5d731a59adff4fdeb31dee691aff Mon Sep 17 00:00:00 2001 From: MikiEEE <35975462+MikiEEE@users.noreply.github.com> Date: Sat, 22 Aug 2026 03:20:12 -0500 Subject: [PATCH 36/53] Restore integrated regex protocol coverage --- README.md | 5 +- guide/api-reference.md | 4 +- guide/development.md | 6 +- guide/errors-observability.md | 12 +- guide/index.md | 12 +- tests/test_http2.py | 172 +++++++++++++++++++++++ tests/test_server_runtime.py | 253 +++++++++++++++++++++++++++++++++- 7 files changed, 442 insertions(+), 22 deletions(-) diff --git a/README.md b/README.md index 411e655..202f25b 100644 --- a/README.md +++ b/README.md @@ -63,8 +63,9 @@ async def get_user(request): ``` Pattern, path, capture, and matching-time limits are configurable with -`RegexRouteConfig`. A match timeout becomes a sanitized 500 response. An -optional `route_error_observer` receives only an immutable `RouteErrorEvent` +`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. diff --git a/guide/api-reference.md b/guide/api-reference.md index 4dce332..095d8d1 100644 --- a/guide/api-reference.md +++ b/guide/api-reference.md @@ -54,14 +54,14 @@ 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]`. -Operations: `close()`, `async close_from_task(task)`, and `finalize()`. - ## Adapters ### `AdapterRegistry(**adapters)` diff --git a/guide/development.md b/guide/development.md index f9b0fd8..bb8d551 100644 --- a/guide/development.md +++ b/guide/development.md @@ -34,10 +34,12 @@ without installing the test or HTTP/2 extras: 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 ``` -The dependency-contract tests run and HTTP/2 interoperability cases skip -cleanly; importing and testing HTTP/1.1 must not require hyper-h2. +The dependency-contract tests run and HTTP/2 interoperability and regex-engine +cases skip cleanly; importing and testing HTTP/1.1 must require neither +hyper-h2 nor regex. Run the examples when their platform requirements are available: diff --git a/guide/errors-observability.md b/guide/errors-observability.md index ac72a4a..7069de2 100644 --- a/guide/errors-observability.md +++ b/guide/errors-observability.md @@ -13,11 +13,13 @@ The network server converts ordinary handler exceptions into a generic 500. `app.dispatch()` only catches `HTTPError`, so direct dispatch in tests preserves programming errors. -A regex match timeout also becomes a generic 500. If configured, -`route_error_observer` receives exactly one immutable, traceback-free -`RouteErrorEvent` containing only an opaque route ID and category. Delivery is -bounded and scheduler-local; dropped events and observer callback failures are -reported by the corresponding `ServerHandle` counters. +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, `route_error_observer` receives +exactly one immutable, traceback-free `RouteErrorEvent` containing only an +opaque route ID and category. Delivery is bounded and scheduler-local; dropped +events and observer callback failures are reported by the corresponding +`ServerHandle` counters. ## Configuration errors diff --git a/guide/index.md b/guide/index.md index 1b8e8e0..7fc674a 100644 --- a/guide/index.md +++ b/guide/index.md @@ -1,12 +1,13 @@ # SmallServer guide -This guide documents the API available on the lifecycle base. Start with the -managed server path, then open the focused page for the part you are changing. +This guide documents the integrated lifecycle, regex-routing, 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 paths, methods, 404, and 405 behavior. +2. [Routing](routing.md) — exact and optional regex paths, captures, methods, 404, and 405 behavior. 3. [Requests and responses](requests-and-responses.md) — immutable HTTP values. 4. [Runtime and lifecycle](runtime-lifecycle.md) — managed and caller-owned modes. 5. [Configuration](configuration.md) — finite parser and connection limits. @@ -24,5 +25,6 @@ managed server path, then open the focused page for the part you are changing. - [Protocol roadmap](protocol-roadmap.md) - [Development](development.md) -This branch supports bounded HTTP/1.1 and optional cleartext prior-knowledge -HTTP/2. TLS/ALPN and h2c upgrade remain outside the current protocol boundary. +This branch supports bounded HTTP/1.1, optional timeout-bounded regex routing, +and optional cleartext prior-knowledge HTTP/2. TLS/ALPN and h2c upgrade remain +outside the current protocol boundary. diff --git a/tests/test_http2.py b/tests/test_http2.py index 305a05b..8907d77 100644 --- a/tests/test_http2.py +++ b/tests/test_http2.py @@ -26,6 +26,7 @@ from smallserver import ( HTTP2Config, Headers, + RegexRouteConfig, Request, Response, RouteErrorEvent, @@ -602,6 +603,177 @@ def observe(event): 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() diff --git a/tests/test_server_runtime.py b/tests/test_server_runtime.py index 07d8e59..ef01f0b 100644 --- a/tests/test_server_runtime.py +++ b/tests/test_server_runtime.py @@ -1,3 +1,6 @@ +import importlib.util +from dataclasses import FrozenInstanceError +import inspect import socket import threading import time @@ -6,16 +9,25 @@ from SmallPackage import SmallOS, Unix from SmallPackage.adapters.threads import ThreadAdapter -from smallserver import AdapterRegistry, Response, SmallServer -from smallserver.server import ServerHandle +from smallserver import ( + AdapterRegistry, + RegexRouteConfig, + Request, + Response, + RouteErrorEvent, + RouteMatchTimeout, + SmallServer, +) +from smallserver.server import ServerHandle, run_route_observer + + +HAS_REGEX = importlib.util.find_spec("regex") is not None class SmallOSServerIntegrationTests(unittest.TestCase): - def _request(self, port: int, path: str) -> bytes: + def _exchange(self, port: int, payload: bytes) -> bytes: with socket.create_connection(("127.0.0.1", port), timeout=3) as connection: - connection.sendall( - "GET {} HTTP/1.1\r\nHost: localhost\r\n\r\n".format(path).encode("ascii") - ) + connection.sendall(payload) chunks = [] while True: chunk = connection.recv(4096) @@ -23,6 +35,12 @@ def _request(self, port: int, path: str) -> bytes: return b"".join(chunks) chunks.append(chunk) + def _request(self, port: int, path: str) -> bytes: + return self._exchange( + port, + "GET {} HTTP/1.1\r\nHost: localhost\r\n\r\n".format(path).encode("ascii"), + ) + def test_loopback_server_accepts_fragmented_request_and_shuts_down(self) -> None: runtime = SmallOS().setKernel(Unix()) app = SmallServer() @@ -186,6 +204,188 @@ def clients() -> None: self.assertEqual(runtime.ioReadWaiters, {}) self.assertEqual(runtime.ioWriteWaiters, {}) + @unittest.skipUnless(HAS_REGEX, "regex-routes extra is not installed") + def test_loopback_regex_route_uses_path_without_query(self) -> None: + runtime = SmallOS().setKernel(Unix()) + app = SmallServer() + + @app.get_regex(r"/files/(?P[^/]+)") + async def file(request): + return Response.text(request.path_params["name"] + "?" + request.query_string) + + try: + server = app.serve(runtime, host="127.0.0.1", port=0) + except PermissionError: + self.skipTest("the current sandbox does not permit loopback TCP binds") + + received = [] + errors = [] + + def client() -> None: + try: + received.append(self._request(server.port, "/files/a%2Fb?download=1")) + except BaseException as exc: + errors.append(exc) + finally: + server.close() + + worker = threading.Thread(target=client, daemon=True) + worker.start() + runtime.start() + worker.join(timeout=2) + self.assertFalse(worker.is_alive()) + self.assertEqual(errors, []) + self.assertIn(b"\r\n\r\na%2Fb?download=1", b"".join(received)) + + @unittest.skipUnless(HAS_REGEX, "regex-routes extra is not installed") + def test_regex_timeout_is_observed_once_and_does_not_stop_server(self) -> None: + runtime = SmallOS().setKernel(Unix()) + observed: list[RouteErrorEvent] = [] + observer_graph = [] + observer_finished = threading.Event() + observer_threads = [] + + def observe(event: RouteErrorEvent) -> None: + observed.append(event) + observer_threads.append(threading.current_thread()) + caller_locals = [] + frame = inspect.currentframe() + while frame is not None: + caller_locals.append(dict(frame.f_locals)) + if frame.f_code is run_route_observer.__code__: + break + frame = frame.f_back + observer_graph.extend(_reachable_container_values(caller_locals)) + observer_finished.set() + raise RuntimeError("intentional observer failure") + + app = SmallServer( + RegexRouteConfig(match_timeout=0.001, total_match_timeout=0.005), + route_error_observer=observe, + ) + + pattern_secret = "sensitive-pattern-marker" + + @app.post_regex(r"/(a+)+$(?#sensitive-pattern-marker)") + async def expensive(request): + return Response() + + @app.get("/health") + async def health(request): + return Response.text("healthy") + + try: + server = app.serve(runtime, host="127.0.0.1", port=0) + except PermissionError: + self.skipTest("the current sandbox does not permit loopback TCP binds") + + hostile_path = "/" + "a" * 5000 + "!" + authorization_secret = "Bearer sensitive-authorization-marker" + body_secret = b"sensitive-body-marker" + runtime_thread = threading.Thread( + target=runtime.start, + name="smallos-runtime-test", + daemon=True, + ) + runtime_thread.start() + request = ( + "POST {} HTTP/1.1\r\n" + "Host: localhost\r\n" + "Authorization: {}\r\n" + "Content-Length: {}\r\n\r\n" + ).format(hostile_path, authorization_secret, len(body_secret)).encode("ascii") + received = [self._exchange(server.port, request + body_secret)] + received.append(self._request(server.port, "/health")) + self.assertTrue(observer_finished.wait(2), "route observer did not run") + server.close() + runtime_thread.join(timeout=3) + self.assertFalse(runtime_thread.is_alive()) + self.assertEqual(len(observed), 1) + event = observed[0] + self.assertEqual(event.route_id, "regex-route-1") + self.assertEqual(event.category, "route_match_timeout") + with self.assertRaises(FrozenInstanceError): + event.route_id = "changed" # type: ignore[misc] + self.assertFalse(hasattr(event, "__traceback__")) + self.assertFalse(hasattr(event, "__cause__")) + self.assertFalse(hasattr(event, "__context__")) + + reachable = _reachable_objects(event) + reachable_strings = {value for value in reachable if isinstance(value, str)} + self.assertEqual( + reachable_strings, + {"route_id", "category", "regex-route-1", "route_match_timeout"}, + ) + self.assertFalse(any(isinstance(value, Request) for value in reachable)) + for secret in ( + hostile_path, + authorization_secret, + body_secret.decode("ascii"), + pattern_secret, + ): + self.assertNotIn(secret, reachable_strings) + + caller_strings = {value for value in observer_graph if isinstance(value, str)} + self.assertFalse(any(isinstance(value, Request) for value in observer_graph)) + self.assertFalse( + any(isinstance(value, RouteMatchTimeout) for value in observer_graph) + ) + for secret in ( + hostile_path, + authorization_secret, + body_secret.decode("ascii"), + pattern_secret, + ): + self.assertNotIn(secret, caller_strings) + self.assertNotIn(body_secret, observer_graph) + self.assertEqual(server.route_observer_failures, 1) + self.assertEqual(server.dropped_route_error_events, 0) + self.assertEqual(observer_threads, [runtime_thread]) + channel = server._route_observer_channel + self.assertIsNotNone(channel) + assert channel is not None + self.assertFalse(channel.accepting) + self.assertEqual(list(channel.events), []) + self.assertIsNone(channel.task) + self.assertTrue(received[0].startswith(b"HTTP/1.1 500 Internal Server Error\r\n")) + self.assertNotIn(hostile_path.encode("ascii"), received[0]) + self.assertTrue(received[1].startswith(b"HTTP/1.1 200 OK\r\n")) + self.assertTrue(received[1].endswith(b"healthy")) + + @unittest.skipUnless(HAS_REGEX, "regex-routes extra is not installed") + def test_regex_path_limit_returns_414_before_matching(self) -> None: + runtime = SmallOS().setKernel(Unix()) + app = SmallServer(RegexRouteConfig(max_path_bytes=8)) + + @app.get_regex(r"/.*") + async def route(request): + return Response.text("must not run") + + try: + server = app.serve(runtime, host="127.0.0.1", port=0) + except PermissionError: + self.skipTest("the current sandbox does not permit loopback TCP binds") + + received = [] + errors = [] + + def client() -> None: + try: + received.append(self._request(server.port, "/12345678")) + except BaseException as exc: + errors.append(exc) + finally: + server.close() + + worker = threading.Thread(target=client, daemon=True) + worker.start() + runtime.start() + worker.join(timeout=2) + self.assertFalse(worker.is_alive()) + self.assertEqual(errors, []) + self.assertTrue(received[0].startswith(b"HTTP/1.1 414 URI Too Long\r\n")) + self.assertNotIn(b"must not run", received[0]) + def test_managed_listen_serves_loopback_and_returns_closed_handle(self) -> None: app = SmallServer() @@ -229,3 +429,44 @@ def run_server() -> None: self.assertTrue(handle.closed) self.assertEqual(handle.port, returned[0].port) self.assertIn(b"HTTP/1.1 200 OK", response) + + +def _reachable_objects(root): + pending = [root] + seen = set() + result = [] + while pending: + value = pending.pop() + identity = id(value) + if identity in seen: + continue + seen.add(identity) + result.append(value) + if isinstance(value, dict): + pending.extend(value.keys()) + pending.extend(value.values()) + elif isinstance(value, (tuple, list, set, frozenset)): + pending.extend(value) + elif hasattr(value, "__dict__"): + pending.append(vars(value)) + return result + + +def _reachable_container_values(root): + """Walk frame-local containers without traversing scheduler object graphs.""" + pending = [root] + seen = set() + result = [] + while pending: + value = pending.pop() + identity = id(value) + if identity in seen: + continue + seen.add(identity) + result.append(value) + if isinstance(value, dict): + pending.extend(value.keys()) + pending.extend(value.values()) + elif isinstance(value, (tuple, list, set, frozenset)): + pending.extend(value) + return result From 29d2cfd2ee1388226feafdb39a4aedd27e7fdc9e Mon Sep 17 00:00:00 2001 From: MikiEEE <35975462+MikiEEE@users.noreply.github.com> Date: Sat, 22 Aug 2026 03:22:37 -0500 Subject: [PATCH 37/53] docs: qualify bounded route event delivery --- guide/errors-observability.md | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/guide/errors-observability.md b/guide/errors-observability.md index 7069de2..4e8ae56 100644 --- a/guide/errors-observability.md +++ b/guide/errors-observability.md @@ -15,11 +15,11 @@ 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, `route_error_observer` receives -exactly one immutable, traceback-free `RouteErrorEvent` containing only an -opaque route ID and category. Delivery is bounded and scheduler-local; dropped -events and observer callback failures are reported by the corresponding -`ServerHandle` counters. +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. ## Configuration errors From 65ad88c3853f698825614ac0aa734cb52495a8e9 Mon Sep 17 00:00:00 2001 From: MikiEEE <35975462+MikiEEE@users.noreply.github.com> Date: Sat, 22 Aug 2026 03:41:51 -0500 Subject: [PATCH 38/53] feat: configure SmallServer-owned SmallOS runtimes --- README.md | 28 ++++++++++++++ smallserver/__init__.py | 2 + smallserver/app.py | 43 ++++++++++++++++++---- smallserver/runtime.py | 81 +++++++++++++++++++++++++++++++++++++++++ smallserver/server.py | 18 ++++++++- tests/test_lifecycle.py | 63 ++++++++++++++++++++++++++++++++ tests/test_server.py | 39 +++++++++++++++++++- 7 files changed, 265 insertions(+), 9 deletions(-) create mode 100644 smallserver/runtime.py diff --git a/README.md b/README.md index 3c7598b..8fbbdef 100644 --- a/README.md +++ b/README.md @@ -65,6 +65,34 @@ async def health(request): app.listen(host="127.0.0.1", port=8000) ``` +### Configure the managed SmallOS runtime + +When `listen()` creates the runtime, `ServerConfig.managed_runtime` passes the +relevant scheduler and client defaults into SmallOS before the listener binds: + +```python +from smallserver import ManagedRuntimeConfig, ServerConfig + +config = ServerConfig( + max_connections=200, + managed_runtime=ManagedRuntimeConfig( + task_capacity=512, + priority_levels=8, + io_buffer_length=2048, + eternal_watchers=False, + client_defaults={ + "http": {"max_response_size": 8 * 1024 * 1024}, + }, + ), +) + +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. + 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 diff --git a/smallserver/__init__.py b/smallserver/__init__.py index 6600810..a3f0a16 100644 --- a/smallserver/__init__.py +++ b/smallserver/__init__.py @@ -10,6 +10,7 @@ ServerStartupError, ) from .http import Headers, Request, Response +from .runtime import ManagedRuntimeConfig from .server import ServerConfig, ServerHandle if TYPE_CHECKING: @@ -31,6 +32,7 @@ def __getattr__(name: str) -> Any: "AdapterShutdownError", "Headers", "HTTPError", + "ManagedRuntimeConfig", "Request", "Response", "ServerConfig", diff --git a/smallserver/app.py b/smallserver/app.py index 6815502..23359d8 100644 --- a/smallserver/app.py +++ b/smallserver/app.py @@ -24,6 +24,7 @@ _CleanupTransaction, ) from .http import Request, Response +from .runtime import ManagedRuntimeConfig from .server import HTTPParseError, HTTPRequestParser, ServerConfig, ServerHandle Handler = Callable[[Request], Awaitable[Response]] @@ -69,7 +70,9 @@ class _StartableRuntime(_RuntimeLike, Protocol): def start(self) -> None: ... -def _default_runtime_factory() -> _StartableRuntime: +def _default_runtime_factory( + config: ManagedRuntimeConfig | None = None, +) -> _StartableRuntime: """Lazily create the supported desktop runtime for managed ``listen``.""" try: from SmallPackage import SmallOS, Unix @@ -79,7 +82,8 @@ def _default_runtime_factory() -> _StartableRuntime: "install requirements.txt or supply a configured runtime" ) from exc try: - return SmallOS().setKernel(Unix()) + runtime_config = None if config is None else config.to_smallos_config() + return SmallOS(config=runtime_config).setKernel(Unix()) except Exception as exc: raise ServerConfigurationError( "managed listen() could not create the default SmallOS Unix runtime; " @@ -197,6 +201,7 @@ def serve( kernels use ``await ServerHandle.close_from_task(task)`` on the scheduler thread instead. """ + config = self._resolve_server_config(config, managed=False) self._validate_runtime(runtime, require_start=False) return self._bind_and_schedule(runtime, host, port, config) @@ -253,9 +258,10 @@ def listen( managed = runtime is None if managed and start is False: raise ValueError("start=False requires a caller-supplied runtime") + config = self._resolve_server_config(config, managed=managed) should_start = managed if start is None else start if runtime is None: - runtime = _default_runtime_factory() + runtime = _default_runtime_factory(config.managed_runtime) self._validate_runtime(runtime, require_start=should_start) handle = self._bind_and_schedule(runtime, host, port, config) if not should_start: @@ -282,6 +288,32 @@ def listen( def _handle_cleanup_transaction(handle: ServerHandle) -> _CleanupTransaction: return _HandleCleanupTransaction(handle) + @staticmethod + def _resolve_server_config( + config: ServerConfig | None, *, managed: bool + ) -> ServerConfig: + if config is not None and not isinstance(config, ServerConfig): + raise TypeError("config must be a ServerConfig or None") + resolved = config or ServerConfig() + runtime_config = resolved.managed_runtime + if not managed and runtime_config is not None: + raise ValueError( + "managed_runtime config applies only when SmallServer creates " + "the runtime; configure a caller-supplied SmallOS directly" + ) + if managed: + priority_levels = ( + ManagedRuntimeConfig().priority_levels + if runtime_config is None + else runtime_config.priority_levels + ) + if max(resolved.listener_priority, resolved.connection_priority) >= priority_levels: + raise ValueError( + "server task priorities must be lower than managed runtime " + "priority_levels" + ) + return resolved + def _validate_runtime(self, runtime: object, *, require_start: bool) -> None: required = ["fork", "resume_task", "cancel_task"] if require_start: @@ -299,7 +331,7 @@ def _bind_and_schedule( runtime: _RuntimeLike, host: str, port: int, - config: ServerConfig | None, + config: ServerConfig, ) -> ServerHandle: """Shared validated bind-and-schedule core for ``serve`` and ``listen``.""" from SmallPackage import SmallTask @@ -308,8 +340,6 @@ 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 config is not None and not isinstance(config, ServerConfig): - raise TypeError("config must be a ServerConfig or None") marker = self._reserve_invocation() def release_marker() -> None: @@ -325,7 +355,6 @@ def raise_acquisition_cleanup( ) try: - config = config or ServerConfig() transport = KernelTransport(runtime.kernel) except BaseException: release_marker() diff --git a/smallserver/runtime.py b/smallserver/runtime.py new file mode 100644 index 0000000..239cfa4 --- /dev/null +++ b/smallserver/runtime.py @@ -0,0 +1,81 @@ +"""Configuration for SmallOS runtimes created by SmallServer.""" + +from __future__ import annotations + +from dataclasses import dataclass +from types import MappingProxyType +from typing import Mapping + + +@dataclass(frozen=True) +class ManagedRuntimeConfig: + """SmallOS settings used only when :meth:`SmallServer.listen` owns runtime creation. + + Caller-supplied runtimes keep their existing SmallOS configuration and + reject this setting rather than being mutated behind the caller's back. + """ + + task_capacity: int = 2**10 + priority_levels: int = 10 + io_buffer_length: int = 1024 + eternal_watchers: bool = False + client_defaults: Mapping[str, Mapping[str, int]] | None = None + + def __post_init__(self) -> None: + self._positive_int("task_capacity", self.task_capacity) + self._positive_int("priority_levels", self.priority_levels) + if self.priority_levels < 2: + raise ValueError("priority_levels must be at least 2") + self._non_negative_int("io_buffer_length", self.io_buffer_length) + if type(self.eternal_watchers) is not bool: + raise TypeError("eternal_watchers must be a boolean") + if self.client_defaults is None: + return + if not isinstance(self.client_defaults, Mapping): + raise TypeError("client_defaults must be a mapping or None") + normalized: dict[str, Mapping[str, int]] = {} + for section, values in self.client_defaults.items(): + if not isinstance(section, str) or not section: + raise TypeError("client_defaults section names must be non-empty strings") + if not isinstance(values, Mapping): + raise TypeError("client_defaults sections must be mappings") + section_values: dict[str, int] = {} + for name, value in values.items(): + if not isinstance(name, str) or not name: + raise TypeError("client_defaults setting names must be non-empty strings") + self._non_negative_int( + "client_defaults.{}.{}".format(section, name), value + ) + section_values[name] = value + normalized[section] = MappingProxyType(section_values) + object.__setattr__(self, "client_defaults", MappingProxyType(normalized)) + + @staticmethod + def _positive_int(name: str, value: int) -> None: + if type(value) is not int: + raise TypeError("{} must be an integer".format(name)) + if value <= 0: + raise ValueError("{} must be greater than 0".format(name)) + + @staticmethod + def _non_negative_int(name: str, value: int) -> None: + if type(value) is not int: + raise TypeError("{} must be an integer".format(name)) + if value < 0: + raise ValueError("{} must be 0 or greater".format(name)) + + def to_smallos_config(self) -> dict[str, object]: + """Return fresh plain data accepted by ``SmallOS(config=...)``.""" + client_defaults = None + if self.client_defaults is not None: + client_defaults = { + section: dict(values) + for section, values in self.client_defaults.items() + } + return { + "task_capacity": self.task_capacity, + "priority_levels": self.priority_levels, + "io_buffer_length": self.io_buffer_length, + "eternal_watchers": self.eternal_watchers, + "client_defaults": client_defaults, + } diff --git a/smallserver/server.py b/smallserver/server.py index 4a1cad7..36fdf21 100644 --- a/smallserver/server.py +++ b/smallserver/server.py @@ -7,6 +7,7 @@ from ._transport import KernelTransport, TransportHandle, WakeupChannel from .http import Headers, Request, Response +from .runtime import ManagedRuntimeConfig class HTTPParseError(Exception): @@ -109,11 +110,26 @@ class ServerConfig: listener_priority: int = 1 connection_priority: int = 2 accept_batch_size: int = 16 + managed_runtime: ManagedRuntimeConfig | None = None def __post_init__(self) -> None: - for name, value in self.__dict__.items(): + for name in ( + "max_connections", + "max_header_bytes", + "max_header_count", + "max_body_bytes", + "receive_chunk_bytes", + "listener_priority", + "connection_priority", + "accept_batch_size", + ): + value = getattr(self, name) if type(value) is not int or value <= 0: raise ValueError("{} must be a positive integer".format(name)) + if self.managed_runtime is not None and not isinstance( + self.managed_runtime, ManagedRuntimeConfig + ): + raise TypeError("managed_runtime must be a ManagedRuntimeConfig or None") class ServerHandle: diff --git a/tests/test_lifecycle.py b/tests/test_lifecycle.py index 3c39e9d..a6b792c 100644 --- a/tests/test_lifecycle.py +++ b/tests/test_lifecycle.py @@ -13,6 +13,8 @@ from unittest.mock import patch from smallserver import ( + ManagedRuntimeConfig, + ServerConfig, ServerConfigurationError, ServerFinalizationError, ServerStartupError, @@ -57,6 +59,67 @@ def cancel_task(self, task) -> int: class ServerLifecycleTests(unittest.TestCase): + def test_managed_runtime_settings_are_passed_to_the_factory(self) -> None: + runtime = FakeRuntime() + runtime_config = ManagedRuntimeConfig( + task_capacity=256, + priority_levels=5, + io_buffer_length=64, + eternal_watchers=True, + client_defaults={"http": {"max_response_size": 2048}}, + ) + server_config = ServerConfig(managed_runtime=runtime_config) + + with patch( + "smallserver.app._default_runtime_factory", return_value=runtime + ) as factory: + handle = SmallServer().listen(config=server_config, port=0) + + factory.assert_called_once_with(runtime_config) + self.assertTrue(handle.finished) + + def test_default_factory_applies_settings_to_real_smallos_config(self) -> None: + from smallserver.app import _default_runtime_factory + + config = ManagedRuntimeConfig( + task_capacity=33, + priority_levels=6, + io_buffer_length=17, + eternal_watchers=True, + client_defaults={"http": {"max_response_size": 8192}}, + ) + runtime = _default_runtime_factory(config) + + self.assertEqual(runtime.config.task_capacity, 33) + self.assertEqual(runtime.config.priority_levels, 6) + self.assertEqual(runtime.config.io_buffer_length, 17) + self.assertTrue(runtime.config.eternal_watchers) + self.assertEqual( + runtime.config.client_defaults_for("http")["max_response_size"], 8192 + ) + + def test_caller_owned_runtime_rejects_managed_runtime_settings_pre_bind(self) -> None: + runtime = FakeRuntime() + server_config = ServerConfig(managed_runtime=ManagedRuntimeConfig()) + + with self.assertRaisesRegex(ValueError, "caller-supplied SmallOS"): + SmallServer().listen(runtime=runtime, config=server_config, port=0) + with self.assertRaisesRegex(ValueError, "caller-supplied SmallOS"): + SmallServer().serve(runtime, config=server_config, port=0) + + self.assertEqual(runtime.kernel.calls, []) + self.assertEqual(runtime.forked, []) + + def test_managed_priorities_are_validated_before_runtime_creation(self) -> None: + config = ServerConfig( + connection_priority=3, + managed_runtime=ManagedRuntimeConfig(priority_levels=3), + ) + with patch("smallserver.app._default_runtime_factory") as factory: + with self.assertRaisesRegex(ValueError, "priority_levels"): + SmallServer().listen(config=config, port=0) + factory.assert_not_called() + def test_primary_demo_hides_runtime_and_registers_all_http_methods(self) -> None: root = Path(__file__).parents[1] demo_path = root / "demo.py" diff --git a/tests/test_server.py b/tests/test_server.py index ad225b2..8c5938a 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -4,7 +4,7 @@ import warnings from unittest.mock import patch -from smallserver import ServerStartupError, SmallServer +from smallserver import ManagedRuntimeConfig, ServerStartupError, SmallServer from smallserver.errors import _CleanupTransaction from smallserver.server import HTTPParseError, HTTPRequestParser, ServerConfig @@ -46,6 +46,43 @@ def test_config_rejects_unbounded_limits(self) -> None: ServerConfig(max_connections=0) with self.assertRaisesRegex(ValueError, "max_connections"): ServerConfig(max_connections=True) + with self.assertRaisesRegex(TypeError, "managed_runtime"): + ServerConfig(managed_runtime={}) # type: ignore[arg-type] + + def test_managed_runtime_config_is_validated_and_defensively_copied(self) -> None: + source = {"http": {"max_response_size": 4096}} + config = ManagedRuntimeConfig( + task_capacity=128, + priority_levels=4, + io_buffer_length=0, + eternal_watchers=True, + client_defaults=source, + ) + source["http"]["max_response_size"] = 1 + + self.assertEqual( + config.to_smallos_config(), + { + "task_capacity": 128, + "priority_levels": 4, + "io_buffer_length": 0, + "eternal_watchers": True, + "client_defaults": {"http": {"max_response_size": 4096}}, + }, + ) + with self.assertRaises(TypeError): + config.client_defaults["http"]["max_response_size"] = 1 # type: ignore[index] + + invalid_values = ( + {"task_capacity": True}, + {"priority_levels": 1}, + {"io_buffer_length": -1}, + {"eternal_watchers": 1}, + {"client_defaults": {"http": {"max_response_size": -1}}}, + ) + for values in invalid_values: + with self.subTest(values=values), self.assertRaises((TypeError, ValueError)): + ManagedRuntimeConfig(**values) # type: ignore[arg-type] def test_serve_closes_kernel_resources_when_runtime_fork_fails(self) -> None: class Runtime: From 76e165315023a3f7d8caec821aa0c351405dfdea Mon Sep 17 00:00:00 2001 From: MikiEEE <35975462+MikiEEE@users.noreply.github.com> Date: Sat, 22 Aug 2026 03:41:54 -0500 Subject: [PATCH 39/53] ci: release successful main builds --- .github/workflows/ci.yml | 61 +++++++++++++++++++++++ .github/workflows/release.yml | 94 +++++++++++++++++++++++++++++++++++ README.md | 8 +++ RELEASING.md | 49 ++++++++++++++++++ pyproject.toml | 1 + 5 files changed, 213 insertions(+) create mode 100644 .github/workflows/ci.yml create mode 100644 .github/workflows/release.yml create mode 100644 RELEASING.md diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..3ae695e --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,61 @@ +name: CI + +on: + pull_request: + branches: [develop, main] + push: + branches: [develop, main] + +permissions: + contents: read + +concurrency: + group: ci-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + test: + name: Python ${{ matrix.python-version }} + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + python-version: ["3.10", "3.12"] + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + cache: pip + - name: Install SmallOS and test dependencies + run: | + python -m pip install --upgrade pip + python -m pip install -r requirements.txt + python -m pip install -e '.[test]' + - name: Run tests + run: python -m unittest discover -s tests -v + - name: Compile sources + run: python -m compileall -q smallserver tests examples + + package: + name: Build package + runs-on: ubuntu-latest + needs: test + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + cache: pip + - name: Install release tooling + run: python -m pip install --upgrade build twine + - name: Build and inspect distributions + run: | + python -m build + python -m twine check dist/* + - uses: actions/upload-artifact@v4 + with: + name: distributions-${{ github.sha }} + path: dist/* + if-no-files-found: error + retention-days: 14 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..659bb50 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,94 @@ +name: Release + +on: + workflow_run: + workflows: [CI] + types: [completed] + branches: [main] + +permissions: + contents: write + attestations: write + id-token: write + +concurrency: + group: release-main + cancel-in-progress: true + +jobs: + release: + name: Cut GitHub release + if: >- + github.event.workflow_run.conclusion == 'success' && + github.event.workflow_run.head_branch == 'main' + runs-on: ubuntu-latest + environment: release + env: + GH_TOKEN: ${{ github.token }} + VALIDATED_SHA: ${{ github.event.workflow_run.head_sha }} + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ env.VALIDATED_SHA }} + fetch-depth: 0 + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + cache: pip + - id: main + name: Confirm the validated commit is still main + run: | + current_main="$(git ls-remote origin refs/heads/main | awk '{print $1}')" + if [ "$current_main" != "$VALIDATED_SHA" ]; then + echo "A newer main commit exists; its CI run owns the next release." + echo "current=false" >> "$GITHUB_OUTPUT" + else + echo "current=true" >> "$GITHUB_OUTPUT" + fi + - id: version + name: Read and validate the release version + if: steps.main.outputs.current == 'true' + run: | + python - <<'PY' + import os + import re + import tomllib + + with open("pyproject.toml", "rb") as handle: + version = tomllib.load(handle)["project"]["version"] + if re.fullmatch(r"[0-9]+\.[0-9]+\.[0-9]+(?:[a-zA-Z0-9.-]+)?", version) is None: + raise SystemExit("pyproject.toml contains an unsupported release version") + with open(os.environ["GITHUB_OUTPUT"], "a", encoding="utf-8") as output: + output.write("version={}\n".format(version)) + output.write("tag=v{}\n".format(version)) + PY + - name: Require a new version + if: steps.main.outputs.current == 'true' + env: + RELEASE_TAG: ${{ steps.version.outputs.tag }} + run: | + if git ls-remote --exit-code --tags origin "refs/tags/$RELEASE_TAG"; then + echo "::error::Release $RELEASE_TAG already exists. Bump project.version before merging to main." + exit 1 + fi + - name: Build release artifacts + if: steps.main.outputs.current == 'true' + run: | + python -m pip install --upgrade build twine + python -m build + python -m twine check dist/* + - name: Attest release artifacts + if: steps.main.outputs.current == 'true' + uses: actions/attest-build-provenance@v2 + with: + subject-path: dist/* + - name: Create tag and GitHub release + if: steps.main.outputs.current == 'true' + env: + RELEASE_TAG: ${{ steps.version.outputs.tag }} + run: >- + gh release create "$RELEASE_TAG" dist/* + --repo "$GITHUB_REPOSITORY" + --target "$VALIDATED_SHA" + --title "$RELEASE_TAG" + --generate-notes diff --git a/README.md b/README.md index 37cbe90..edccb63 100644 --- a/README.md +++ b/README.md @@ -214,3 +214,11 @@ 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. + +## Releases + +Release pull requests merge from `develop` into `main` with a new +`project.version`. Successful CI on that exact `main` commit creates a tagged +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. diff --git a/RELEASING.md b/RELEASING.md new file mode 100644 index 0000000..a5b67ef --- /dev/null +++ b/RELEASING.md @@ -0,0 +1,49 @@ +# Releasing SmallServer + +SmallServer cuts a GitHub release after CI succeeds for the exact commit merged +into `main`. The release contains the source distribution and wheel built from +that commit, a generated changelog, and GitHub artifact provenance. + +## Release flow + +1. Merge feature pull requests into `develop` and keep CI green. +2. Prepare a release pull request from `develop` to `main`. +3. Update `project.version` in `pyproject.toml` to a version that does not + already have a `v` tag. +4. Review user documentation and release-facing metadata in that pull request. +5. Merge it into `main`. +6. The `CI` workflow tests Python 3.10 and 3.12, compiles the source, builds the + wheel and source archive, and checks both distributions. +7. Only after that exact `main` commit succeeds, the `Release` workflow verifies + it is still the tip of `main`, requires a new version, rebuilds and attests + the distributions, creates the `v` tag, and creates the GitHub + release. + +If another commit reaches `main` first, the stale workflow exits without +releasing; the newer commit's CI run owns the release. If the version tag +already exists, release creation fails visibly and the next release pull +request must bump `project.version`. + +## Local release checks + +```bash +python3 -m pip install -r requirements.txt +python3 -m pip install -e '.[test]' +python3 -m unittest discover -s tests -v +python3 -m compileall -q smallserver tests examples +python3 -m pip install --upgrade build twine +python3 -m build +python3 -m twine check dist/* +``` + +## Publishing boundary + +The automated process creates a GitHub release; it does not publish to PyPI. +SmallServer currently installs SmallOS from its canonical Git `master` branch +through `requirements.txt`, while `pyproject.toml` intentionally has no runtime +dependency declaration. Publishing the wheel to PyPI before SmallOS has an +installable release dependency would give users an incomplete installation. + +Add PyPI trusted publishing only after SmallOS has a stable package release, +SmallServer declares that dependency in `pyproject.toml`, and an installed-wheel +test proves a clean environment receives every runtime dependency. diff --git a/pyproject.toml b/pyproject.toml index 337debe..83f35e2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -14,6 +14,7 @@ dependencies = [] [project.optional-dependencies] dev = ["build>=1.2"] +test = ["build>=1.2"] [tool.setuptools.packages.find] include = ["smallserver*"] From 2bf91fee219c0f94e07d935493cff534c3d5cc33 Mon Sep 17 00:00:00 2001 From: MikiEEE <35975462+MikiEEE@users.noreply.github.com> Date: Sat, 22 Aug 2026 03:46:20 -0500 Subject: [PATCH 40/53] fix(ci): exercise optional protocol suites --- RELEASING.md | 6 ++++-- pyproject.toml | 7 ++++++- 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/RELEASING.md b/RELEASING.md index a5b67ef..0b0124d 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -12,8 +12,10 @@ that commit, a generated changelog, and GitHub artifact provenance. already have a `v` tag. 4. Review user documentation and release-facing metadata in that pull request. 5. Merge it into `main`. -6. The `CI` workflow tests Python 3.10 and 3.12, compiles the source, builds the - wheel and source archive, and checks both distributions. +6. The `CI` workflow installs the complete test extra, exercises the core, + regex-routing, WebSocket, and HTTP/2 suites on Python 3.10 and 3.12, compiles + the source, builds the wheel and source archive, and checks both + distributions. 7. Only after that exact `main` commit succeeds, the `Release` workflow verifies it is still the tip of `main`, requires a new version, rebuilds and attests the distributions, creates the `v` tag, and creates the GitHub diff --git a/pyproject.toml b/pyproject.toml index 83f35e2..800e3cd 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -14,7 +14,12 @@ dependencies = [] [project.optional-dependencies] dev = ["build>=1.2"] -test = ["build>=1.2"] +test = [ + "build>=1.2", + "h2>=4,<5", + "regex>=2023.10.3,<2027", + "wsproto>=1.2,<2", +] [tool.setuptools.packages.find] include = ["smallserver*"] From d3e25eb30290670c4c7c78a2d4261b9aac8cc738 Mon Sep 17 00:00:00 2001 From: MikiEEE <35975462+MikiEEE@users.noreply.github.com> Date: Sat, 22 Aug 2026 03:46:11 -0500 Subject: [PATCH 41/53] fix: validate managed runtime capacity --- README.md | 3 +++ smallserver/app.py | 17 +++++++++++------ tests/test_lifecycle.py | 15 +++++++++++++++ 3 files changed, 29 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 8fbbdef..0d35c1d 100644 --- a/README.md +++ b/README.md @@ -92,6 +92,9 @@ 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`, diff --git a/smallserver/app.py b/smallserver/app.py index 23359d8..fa06b58 100644 --- a/smallserver/app.py +++ b/smallserver/app.py @@ -302,16 +302,21 @@ def _resolve_server_config( "the runtime; configure a caller-supplied SmallOS directly" ) if managed: - priority_levels = ( - ManagedRuntimeConfig().priority_levels - if runtime_config is None - else runtime_config.priority_levels - ) - if max(resolved.listener_priority, resolved.connection_priority) >= priority_levels: + effective_runtime_config = runtime_config or ManagedRuntimeConfig() + if ( + max(resolved.listener_priority, resolved.connection_priority) + >= effective_runtime_config.priority_levels + ): raise ValueError( "server task priorities must be lower than managed runtime " "priority_levels" ) + required_tasks = resolved.max_connections + 2 + if effective_runtime_config.task_capacity < required_tasks: + raise ValueError( + "managed runtime task_capacity must be at least " + "max_connections + 2 for listener and shutdown tasks" + ) return resolved def _validate_runtime(self, runtime: object, *, require_start: bool) -> None: diff --git a/tests/test_lifecycle.py b/tests/test_lifecycle.py index a6b792c..efbac59 100644 --- a/tests/test_lifecycle.py +++ b/tests/test_lifecycle.py @@ -120,6 +120,21 @@ def test_managed_priorities_are_validated_before_runtime_creation(self) -> None: SmallServer().listen(config=config, port=0) factory.assert_not_called() + insufficient = ServerConfig( + max_connections=32, + managed_runtime=ManagedRuntimeConfig(task_capacity=33), + ) + with patch("smallserver.app._default_runtime_factory") as factory: + with self.assertRaisesRegex(ValueError, r"max_connections \+ 2"): + SmallServer().listen(config=insufficient, port=0) + factory.assert_not_called() + + implicit_defaults = ServerConfig(max_connections=1023) + with patch("smallserver.app._default_runtime_factory") as factory: + with self.assertRaisesRegex(ValueError, r"max_connections \+ 2"): + SmallServer().listen(config=implicit_defaults, port=0) + factory.assert_not_called() + def test_primary_demo_hides_runtime_and_registers_all_http_methods(self) -> None: root = Path(__file__).parents[1] demo_path = root / "demo.py" From 6247d1a65c7f4efa8eae74b57892a2119914fc5e Mon Sep 17 00:00:00 2001 From: MikiEEE <35975462+MikiEEE@users.noreply.github.com> Date: Sat, 22 Aug 2026 03:47:39 -0500 Subject: [PATCH 42/53] fix(ci): serialize release publication --- .github/workflows/release.yml | 17 ++++++++++++++--- RELEASING.md | 9 +++++---- 2 files changed, 19 insertions(+), 7 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 659bb50..d40f7a9 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -13,7 +13,7 @@ permissions: concurrency: group: release-main - cancel-in-progress: true + cancel-in-progress: false jobs: release: @@ -77,13 +77,24 @@ jobs: python -m pip install --upgrade build twine python -m build python -m twine check dist/* - - name: Attest release artifacts + - id: publish + name: Confirm main is unchanged before publishing if: steps.main.outputs.current == 'true' + run: | + current_main="$(git ls-remote origin refs/heads/main | awk '{print $1}')" + if [ "$current_main" != "$VALIDATED_SHA" ]; then + echo "A newer main commit exists; its CI run owns the next release." + echo "current=false" >> "$GITHUB_OUTPUT" + else + echo "current=true" >> "$GITHUB_OUTPUT" + fi + - name: Attest release artifacts + if: steps.publish.outputs.current == 'true' uses: actions/attest-build-provenance@v2 with: subject-path: dist/* - name: Create tag and GitHub release - if: steps.main.outputs.current == 'true' + if: steps.publish.outputs.current == 'true' env: RELEASE_TAG: ${{ steps.version.outputs.tag }} run: >- diff --git a/RELEASING.md b/RELEASING.md index 0b0124d..9831f37 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -21,10 +21,11 @@ that commit, a generated changelog, and GitHub artifact provenance. the distributions, creates the `v` tag, and creates the GitHub release. -If another commit reaches `main` first, the stale workflow exits without -releasing; the newer commit's CI run owns the release. If the version tag -already exists, release creation fails visibly and the next release pull -request must bump `project.version`. +Release runs are serialized. If another commit reaches `main` before either +the initial validation check or the final pre-publish check, the stale workflow +exits without releasing; the newer commit's CI run owns the release. If the +version tag already exists, release creation fails visibly and the next release +pull request must bump `project.version`. ## Local release checks From ca0725cdf62d7c7dcedc1f28650b829a089ae5f4 Mon Sep 17 00:00:00 2001 From: MikiEEE <35975462+MikiEEE@users.noreply.github.com> Date: Sat, 22 Aug 2026 03:49:41 -0500 Subject: [PATCH 43/53] fix(ci): pin release workflow actions --- .github/workflows/ci.yml | 10 +++++----- .github/workflows/release.yml | 6 +++--- RELEASING.md | 3 +++ 3 files changed, 11 insertions(+), 8 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3ae695e..a9ccbbc 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -22,8 +22,8 @@ jobs: matrix: python-version: ["3.10", "3.12"] steps: - - uses: actions/checkout@v4 - - uses: actions/setup-python@v5 + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5 with: python-version: ${{ matrix.python-version }} cache: pip @@ -42,8 +42,8 @@ jobs: runs-on: ubuntu-latest needs: test steps: - - uses: actions/checkout@v4 - - uses: actions/setup-python@v5 + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5 with: python-version: "3.12" cache: pip @@ -53,7 +53,7 @@ jobs: run: | python -m build python -m twine check dist/* - - uses: actions/upload-artifact@v4 + - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 with: name: distributions-${{ github.sha }} path: dist/* diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index d40f7a9..a3d91be 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -27,11 +27,11 @@ jobs: GH_TOKEN: ${{ github.token }} VALIDATED_SHA: ${{ github.event.workflow_run.head_sha }} steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 with: ref: ${{ env.VALIDATED_SHA }} fetch-depth: 0 - - uses: actions/setup-python@v5 + - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5 with: python-version: "3.12" cache: pip @@ -90,7 +90,7 @@ jobs: fi - name: Attest release artifacts if: steps.publish.outputs.current == 'true' - uses: actions/attest-build-provenance@v2 + uses: actions/attest-build-provenance@96b4a1ef7235a096b17240c259729fdd70c83d45 # v2 with: subject-path: dist/* - name: Create tag and GitHub release diff --git a/RELEASING.md b/RELEASING.md index 9831f37..ed91051 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -27,6 +27,9 @@ exits without releasing; the newer commit's CI run owns the release. If the version tag already exists, release creation fails visibly and the next release pull request must bump `project.version`. +The workflows pin third-party actions to immutable commit revisions. Dependabot +or a dedicated maintenance pull request should update those pins after review. + ## Local release checks ```bash From 2fe177d714ed50696ebbc6481a1b548624685d79 Mon Sep 17 00:00:00 2001 From: MikiEEE <35975462+MikiEEE@users.noreply.github.com> Date: Sat, 22 Aug 2026 03:52:11 -0500 Subject: [PATCH 44/53] fix: support response defaults on Python 3.12 --- smallserver/http.py | 4 ++-- tests/test_http.py | 7 +++++++ 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/smallserver/http.py b/smallserver/http.py index 42b43c8..8c9dfa5 100644 --- a/smallserver/http.py +++ b/smallserver/http.py @@ -3,7 +3,7 @@ from __future__ import annotations from collections.abc import Iterable, Iterator, Mapping -from dataclasses import dataclass +from dataclasses import dataclass, field import json import re from types import MappingProxyType @@ -78,7 +78,7 @@ class Response: status: int = 200 body: bytes = b"" - headers: Headers = Headers() + headers: Headers = field(default_factory=Headers) def __post_init__(self) -> None: if not isinstance(self.status, int) or not 100 <= self.status <= 599: diff --git a/tests/test_http.py b/tests/test_http.py index 841d1ee..16a9b9a 100644 --- a/tests/test_http.py +++ b/tests/test_http.py @@ -4,6 +4,13 @@ class HTTPValueTests(unittest.TestCase): + def test_response_default_headers_use_a_factory(self) -> None: + first = Response() + second = Response() + + self.assertIsNot(first.headers, second.headers) + self.assertEqual(dict(first.headers.items()), {}) + def test_headers_are_case_insensitive_and_immutable(self) -> None: headers = Headers({"Content-Type": "text/plain"}) self.assertEqual(headers["content-type"], "text/plain") From 7cc53e4478e583ddab88b8040d6a8a90a18f5a32 Mon Sep 17 00:00:00 2001 From: MikiEEE <35975462+MikiEEE@users.noreply.github.com> Date: Sat, 22 Aug 2026 03:55:29 -0500 Subject: [PATCH 45/53] fix: keep managed runtime defaults explicit --- smallserver/app.py | 4 +++- tests/test_lifecycle.py | 10 ++++++++++ 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/smallserver/app.py b/smallserver/app.py index fa06b58..08343ff 100644 --- a/smallserver/app.py +++ b/smallserver/app.py @@ -261,7 +261,9 @@ def listen( config = self._resolve_server_config(config, managed=managed) should_start = managed if start is None else start if runtime is None: - runtime = _default_runtime_factory(config.managed_runtime) + runtime = _default_runtime_factory( + config.managed_runtime or ManagedRuntimeConfig() + ) self._validate_runtime(runtime, require_start=should_start) handle = self._bind_and_schedule(runtime, host, port, config) if not should_start: diff --git a/tests/test_lifecycle.py b/tests/test_lifecycle.py index efbac59..3f3648c 100644 --- a/tests/test_lifecycle.py +++ b/tests/test_lifecycle.py @@ -78,6 +78,16 @@ def test_managed_runtime_settings_are_passed_to_the_factory(self) -> None: factory.assert_called_once_with(runtime_config) self.assertTrue(handle.finished) + def test_managed_runtime_defaults_are_explicitly_passed_to_smallos(self) -> None: + runtime = FakeRuntime() + with patch( + "smallserver.app._default_runtime_factory", return_value=runtime + ) as factory: + handle = SmallServer().listen(port=0) + + factory.assert_called_once_with(ManagedRuntimeConfig()) + self.assertTrue(handle.finished) + def test_default_factory_applies_settings_to_real_smallos_config(self) -> None: from smallserver.app import _default_runtime_factory From c184547f67cde86a6a031de5e2daaff63a9e0348 Mon Sep 17 00:00:00 2001 From: MikiEEE <35975462+MikiEEE@users.noreply.github.com> Date: Sun, 23 Aug 2026 16:10:31 -0500 Subject: [PATCH 46/53] docs: integrate managed runtime configuration --- README.md | 11 ++++++----- guide/api-reference.md | 5 +++++ guide/configuration.md | 26 ++++++++++++++++++++++---- guide/runtime-lifecycle.md | 10 ++++++---- 4 files changed, 39 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index 1604011..d009368 100644 --- a/README.md +++ b/README.md @@ -96,11 +96,12 @@ 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`. Configuring a regex route-error observer adds one -dedicated SmallOS task, so that mode requires at least `max_connections + 3` -slots. +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 diff --git a/guide/api-reference.md b/guide/api-reference.md index 095d8d1..92f6e37 100644 --- a/guide/api-reference.md +++ b/guide/api-reference.md @@ -42,6 +42,11 @@ provide common construction and serialization paths. 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 diff --git a/guide/configuration.md b/guide/configuration.md index 0dfe9f1..a3a1e88 100644 --- a/guide/configuration.md +++ b/guide/configuration.md @@ -4,7 +4,7 @@ Pass a `ServerConfig` to `listen()` or `serve()` to tune finite listener, parser, and scheduling limits. ```python -from smallserver import ServerConfig, SmallServer +from smallserver import ManagedRuntimeConfig, ServerConfig, SmallServer app = SmallServer() config = ServerConfig( @@ -18,6 +18,7 @@ config = ServerConfig( accept_batch_size=16, max_request_target_bytes=8 * 1024, max_route_error_events=16, + managed_runtime=ManagedRuntimeConfig(task_capacity=256), ) ``` @@ -33,10 +34,12 @@ config = ServerConfig( | `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 field must be a positive integer; booleans are rejected. The public port -must be an integer from 0 through 65535. `port=0` delegates port selection to -the kernel. +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 @@ -47,6 +50,21 @@ 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. + ## HTTP/2 configuration Pass `protocol="http2"` and an optional `HTTP2Config` for cleartext diff --git a/guide/runtime-lifecycle.md b/guide/runtime-lifecycle.md index d67611e..348d5b9 100644 --- a/guide/runtime-lifecycle.md +++ b/guide/runtime-lifecycle.md @@ -23,10 +23,12 @@ async def index(request): app.listen(host="127.0.0.1", port=8000) ``` -With no `runtime`, `listen()` lazily creates `SmallOS().setKernel(Unix())`, -starts it, blocks until shutdown, and finalizes server-owned resources. In this -managed mode, Ctrl-C is consumed after successful cleanup and the closed -`ServerHandle` is returned. +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 From e292b38e713496360619789bf5896c5eaf203929 Mon Sep 17 00:00:00 2001 From: MikiEEE <35975462+MikiEEE@users.noreply.github.com> Date: Sun, 23 Aug 2026 16:13:34 -0500 Subject: [PATCH 47/53] chore: move public documentation out of WebSocket PR --- README.md | 120 +++++++++++++++----------------- guide/adapters.md | 49 ------------- guide/api-reference.md | 97 -------------------------- guide/configuration.md | 61 ---------------- guide/development.md | 59 ---------------- guide/errors-observability.md | 57 --------------- guide/getting-started.md | 66 ------------------ guide/index.md | 29 -------- guide/platforms-kernels.md | 40 ----------- guide/protocol-roadmap.md | 31 --------- guide/requests-and-responses.md | 68 ------------------ guide/routing.md | 66 ------------------ guide/runtime-lifecycle.md | 78 --------------------- guide/websockets.md | 70 ------------------- tests/test_documentation.py | 78 --------------------- 15 files changed, 56 insertions(+), 913 deletions(-) delete mode 100644 guide/adapters.md delete mode 100644 guide/api-reference.md delete mode 100644 guide/configuration.md delete mode 100644 guide/development.md delete mode 100644 guide/errors-observability.md delete mode 100644 guide/getting-started.md delete mode 100644 guide/index.md delete mode 100644 guide/platforms-kernels.md delete mode 100644 guide/protocol-roadmap.md delete mode 100644 guide/requests-and-responses.md delete mode 100644 guide/routing.md delete mode 100644 guide/runtime-lifecycle.md delete mode 100644 guide/websockets.md delete mode 100644 tests/test_documentation.py diff --git a/README.md b/README.md index 73b5744..3f9b5c3 100644 --- a/README.md +++ b/README.md @@ -1,64 +1,68 @@ # SmallServer -SmallServer is a SmallOS-native web framework for Python 3.10+. It serves -bounded HTTP/1.1 requests, exact and timeout-bounded regex routes, optional -RFC 6455 WebSockets, explicit runtime lifecycle control, and third-party -execution adapters. - -```python -from smallserver import Response, SmallServer - -app = 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. +## Current scope -@app.get("/health") -async def health(request): - return Response.json({"status": "ok"}) +The current package provides an HTTP/1.1 baseline over a SmallOS runtime. It can: +- 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. -if __name__ == "__main__": - app.listen(host="127.0.0.1", port=8000) -``` +Keep-alive/pipelining, TLS, path parameters, and HTTP/2 are not implemented yet. -Install the canonical SmallOS master dependency and this package, then run the -demo: +## Install for development -```console +```bash python3 -m pip install -r requirements.txt python3 -m pip install -e . -python3 demo.py +python3 -m unittest discover -s tests -v ``` -Application code can use blocking `app.listen()` without importing SmallOS. -Advanced applications can supply their own runtime, schedule without starting -it, and own adapters for blocking or asyncio-native libraries. +SmallOS is installed from the canonical `master` branch in `requirements.txt`. +It owns scheduling, socket readiness, and foreign execution adapters. -## Optional features +## Run the demo -Static routing and HTTP-only imports need neither optional protocol package. -Install only the feature an application serves: +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. -```console -python3 -m pip install -e '.[regex-routes]' -python3 -m pip install -e '.[websocket]' +```bash +python3 -m pip install -r requirements.txt +python3 demo.py ``` -Regex routes use bounded full-path matching after exact static lookup. -WebSocket routes use a separate static route table, so an ordinary `GET` and a -WebSocket Upgrade may coexist at one path. +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. + +## Bind a server + +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. ```python -from smallserver import WebSocket +from smallserver import Response, SmallServer + +app = SmallServer() +@app.get("/health") +async def health(request): + return Response.json({"status": "ok"}) -@app.websocket("/echo", origins={"https://app.example.com"}) -async def echo(socket: WebSocket) -> None: - await socket.accept() - async for message in socket: - if message.is_text: - await socket.send_text(message.text) - else: - await socket.send_bytes(message.bytes) +app.listen(host="127.0.0.1", port=8000) ``` ### Configure the managed SmallOS runtime @@ -91,8 +95,6 @@ configure it directly with `SmallOS(config=...)`; SmallServer rejects `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. Managed `listen()` blocks and catches Ctrl-C after closing its listener, wakeup channel, connections, and server tasks. It returns the closed `ServerHandle`, @@ -100,28 +102,18 @@ whose cached `address` and `port` remain available for diagnostics. Each current connection accepts one request and sends a `Connection: close` response. -## 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) -- [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 HTTP methods and a WebSocket route, -[`examples/websocket_echo.py`](examples/websocket_echo.py) for a bounded echo -server, [`examples/manual_runtime.py`](examples/manual_runtime.py) for -caller-owned SmallOS startup, and -[`examples/adapters_demo.py`](examples/adapters_demo.py) for blocking and -asyncio escape hatches. +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 diff --git a/guide/adapters.md b/guide/adapters.md deleted file mode 100644 index 2bc91b1..0000000 --- a/guide/adapters.md +++ /dev/null @@ -1,49 +0,0 @@ -# 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 deleted file mode 100644 index 3dcabd2..0000000 --- a/guide/api-reference.md +++ /dev/null @@ -1,97 +0,0 @@ -# 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` — optional timeout-bounded full-path route decorators. -- `websocket(path, *, origins=None, subprotocols=())` — static WebSocket route. -- `async dispatch(request)` — dispatch an existing `Request`. -- `listen(host="127.0.0.1", port=8000, 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)` — 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, routed path, headers, byte body, -raw target, query string, immutable path parameters, 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). - -### `ServerHandle` - -Read-only properties: `address`, `port`, `closed`, `failure`, `finished`, -`cleanup_errors`, and `owned_connection_count`. - -Operations: `close()`, `async close_from_task(task)`, and `finalize()`. - -## Regex routing - -- `RegexRouteConfig` — finite route, pattern, capture, path, and timeout limits. -- `RegexRoutesUnavailable` — the optional matching engine is missing. -- `RouteMatchTimeout` and `RoutePathTooLarge` — bounded matching failures. -- `RouteErrorEvent` — sanitized event sent to the optional observer. - -## WebSockets - -- `WebSocketConfig` — finite frame, message, mailbox, connection, 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 deleted file mode 100644 index 7200540..0000000 --- a/guide/configuration.md +++ /dev/null @@ -1,61 +0,0 @@ -# Configuration - -Pass a `ServerConfig` to `listen()` or `serve()` to tune finite listener, -parser, and scheduling limits. - -```python -from smallserver import 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, -) -``` - -| 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 origin-form request-target bytes. | -| `max_route_error_events` | 16 | Bounded sanitized observer-event queue. | - -Every field must be a positive integer; booleans are rejected. 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. - -## Regex routing limits - -Pass `RegexRouteConfig` to `SmallServer(regex_config=...)`. It bounds path -bytes, pattern length, route count, named captures, individual match time, and -total matching time. Regex configuration is validated without importing the -optional engine; registration imports it lazily. - -## WebSocket limits - -Pass `WebSocketConfig` to `SmallServer(websocket_config=...)`. Its positive, -finite settings bound frame and reassembled-message bytes, inbox/outbox counts -and bytes, read/write chunks, WebSocket connection count, and handshake, idle, -Pong, write, and close deadlines. `max_frame_payload_bytes` cannot exceed -`max_message_bytes`. See [WebSockets](websockets.md) for operational behavior. diff --git a/guide/development.md b/guide/development.md deleted file mode 100644 index b51b5c0..0000000 --- a/guide/development.md +++ /dev/null @@ -1,59 +0,0 @@ -# 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 . -``` - -Install both optional test surfaces with -`python3 -m pip install -e '.[regex-routes,websocket]'` when validating the -complete feature set. Also run the suite without extras to keep HTTP-only -imports lazy. - -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, WebSockets, 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. - -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 three 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 deleted file mode 100644 index 9b1bb5d..0000000 --- a/guide/errors-observability.md +++ /dev/null @@ -1,57 +0,0 @@ -# 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. - -## 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. - -Regex matching timeouts may be reported through `route_error_observer`. Its -dedicated SmallOS task receives bounded, traceback-free `RouteErrorEvent` -values containing only an opaque route ID and category. Observer failures and -capacity drops are isolated and counted on `ServerHandle`; the observer must -return quickly and use an execution adapter for blocking work. diff --git a/guide/getting-started.md b/guide/getting-started.md deleted file mode 100644 index 1101ab5..0000000 --- a/guide/getting-started.md +++ /dev/null @@ -1,66 +0,0 @@ -# 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 . -``` - -Install `.[regex-routes]` for regex routes or `.[websocket]` for WebSocket -routes. Static HTTP usage imports without either optional package. - -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 plus a WebSocket echo route at `/ws`: - -```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 ordinary HTTP/1.1 connection serves one request and closes after the -response. See [Routing](routing.md), [WebSockets](websockets.md), and -[Configuration](configuration.md) before building a larger application. diff --git a/guide/index.md b/guide/index.md deleted file mode 100644 index 54bac06..0000000 --- a/guide/index.md +++ /dev/null @@ -1,29 +0,0 @@ -# SmallServer guide - -This guide documents the current SmallServer API: bounded HTTP/1.1, static and -regex routing, WebSockets, managed or caller-owned SmallOS lifecycle, and -execution adapters. - -## Learn SmallServer - -1. [Getting started](getting-started.md) — install, create an app, and run it. -2. [Routing](routing.md) — exact and bounded regex routes. -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 protocol 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) - -SmallServer currently supports HTTP/1.1 and RFC 6455 Upgrade only. See the -roadmap for deferred HTTP/2, TLS, compression, and keep-alive work. diff --git a/guide/platforms-kernels.md b/guide/platforms-kernels.md deleted file mode 100644 index e29f0df..0000000 --- a/guide/platforms-kernels.md +++ /dev/null @@ -1,40 +0,0 @@ -# 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 deleted file mode 100644 index 9be0286..0000000 --- a/guide/protocol-roadmap.md +++ /dev/null @@ -1,31 +0,0 @@ -# Protocol and feature roadmap - -The current branch provides bounded HTTP/1.1, static and regex routes, shared -HTTP values, RFC 6455 Upgrade, explicit SmallOS lifecycle control, and -application-owned execution adapters. - -## Routing extensions - -Timeout-bounded regex routes and immutable named captures are implemented as -the optional `regex-routes` extra. Exact static routes retain precedence. - -## WebSocket server - -Optional RFC 6455 server support over HTTP/1.1 Upgrade is implemented through -the `websocket` extra with SmallOS-native transport ownership and bounded -protocol state. TLS, compression, custom extensions, and RFC 8441 WebSockets -over HTTP/2 remain separate concerns. - -## HTTP/2 server - -The HTTP/2 feature is planned as an optional cleartext prior-knowledge server -using the hyper-h2 4.x sans-I/O stack. Its branch is responsible for documenting -dependency installation, stream concurrency, flow control, protocol limits, -GOAWAY, and graceful shutdown. SmallServer does not yet accept HTTP/2 -connections or export an HTTP/2 configuration type. - -## 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 this page as direction, not a compatibility promise. diff --git a/guide/requests-and-responses.md b/guide/requests-and-responses.md deleted file mode 100644 index b27c79f..0000000 --- a/guide/requests-and-responses.md +++ /dev/null @@ -1,68 +0,0 @@ -# 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` for the current network server; -- `raw_target`: the exact origin-form target; -- `query_string`: undecoded text after `?`; -- `path_params` and `route_pattern`: immutable regex-route context when used. - -The base parser accepts one origin-form HTTP/1.1 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, percent escapes, 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 deleted file mode 100644 index 5fcd6b5..0000000 --- a/guide/routing.md +++ /dev/null @@ -1,66 +0,0 @@ -# Routing - -SmallServer checks exact static routes first, then optional timeout-bounded -regular-expression routes. Register static routes with `get`, `post`, `put`, -`patch`, `delete`, or the multi-method `route` decorator. - -```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 - -The parser preserves the exact ASCII origin-form target as -`request.raw_target`. Routing uses `request.path`, excluding the raw query -string stored in `request.query_string`. Neither field nor a regex capture is -percent-decoded, so `/files/a%2Fb` remains distinct from `/files/a/b`. - -## Regex routes - -Install the bounded matching engine only when needed: - -```console -python3 -m pip install -e '.[regex-routes]' -``` - -```python -@app.get_regex(r"/users/(?P[0-9]+)") -async def user(request): - return Response.json({"user_id": request.path_params["user_id"]}) -``` - -`route_regex(pattern, methods)` and the five method-specific regex decorators -use full-path matching in registration order after static lookup. Only named -captures are exposed through immutable `request.path_params`; an unmatched -optional group is omitted. `request.route_pattern` identifies the selected -pattern. - -Patterns must begin with a literal `/`. Registration and dispatch bound route -count, pattern length, capture count, path bytes, each match, and total matching -time. A timeout becomes a sanitized 500 on the network path and may be observed -through the bounded `route_error_observer` channel without disclosing the -hostile path. Oversized paths return 414 before matching. diff --git a/guide/runtime-lifecycle.md b/guide/runtime-lifecycle.md deleted file mode 100644 index d67611e..0000000 --- a/guide/runtime-lifecycle.md +++ /dev/null @@ -1,78 +0,0 @@ -# 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 `SmallOS().setKernel(Unix())`, -starts it, blocks until shutdown, and finalizes server-owned resources. 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 deleted file mode 100644 index 5fe7b48..0000000 --- a/guide/websockets.md +++ /dev/null @@ -1,70 +0,0 @@ -# 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/tests/test_documentation.py b/tests/test_documentation.py deleted file mode 100644 index 044afd5..0000000 --- a/tests/test_documentation.py +++ /dev/null @@ -1,78 +0,0 @@ -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", - "websockets.md", - "configuration.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() From 19df00b13d6090c01d96fe8a90124002205499c5 Mon Sep 17 00:00:00 2001 From: MikiEEE <35975462+MikiEEE@users.noreply.github.com> Date: Sun, 23 Aug 2026 16:14:21 -0500 Subject: [PATCH 48/53] Revert "chore: move public documentation out of WebSocket PR" This reverts commit e292b38e713496360619789bf5896c5eaf203929. --- README.md | 120 +++++++++++++++++--------------- guide/adapters.md | 49 +++++++++++++ guide/api-reference.md | 97 ++++++++++++++++++++++++++ guide/configuration.md | 61 ++++++++++++++++ guide/development.md | 59 ++++++++++++++++ guide/errors-observability.md | 57 +++++++++++++++ guide/getting-started.md | 66 ++++++++++++++++++ guide/index.md | 29 ++++++++ guide/platforms-kernels.md | 40 +++++++++++ guide/protocol-roadmap.md | 31 +++++++++ guide/requests-and-responses.md | 68 ++++++++++++++++++ guide/routing.md | 66 ++++++++++++++++++ guide/runtime-lifecycle.md | 78 +++++++++++++++++++++ guide/websockets.md | 70 +++++++++++++++++++ tests/test_documentation.py | 78 +++++++++++++++++++++ 15 files changed, 913 insertions(+), 56 deletions(-) create mode 100644 guide/adapters.md create mode 100644 guide/api-reference.md create mode 100644 guide/configuration.md create mode 100644 guide/development.md create mode 100644 guide/errors-observability.md create mode 100644 guide/getting-started.md create mode 100644 guide/index.md create mode 100644 guide/platforms-kernels.md create mode 100644 guide/protocol-roadmap.md create mode 100644 guide/requests-and-responses.md create mode 100644 guide/routing.md create mode 100644 guide/runtime-lifecycle.md create mode 100644 guide/websockets.md create mode 100644 tests/test_documentation.py diff --git a/README.md b/README.md index 3f9b5c3..73b5744 100644 --- a/README.md +++ b/README.md @@ -1,68 +1,64 @@ # 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 SmallOS-native web framework for Python 3.10+. It serves +bounded HTTP/1.1 requests, exact and timeout-bounded regex routes, optional +RFC 6455 WebSockets, explicit runtime lifecycle control, and third-party +execution adapters. -## Current scope +```python +from smallserver import Response, SmallServer -The current package provides an HTTP/1.1 baseline over a SmallOS runtime. It can: +app = SmallServer() -- 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. +@app.get("/health") +async def health(request): + return Response.json({"status": "ok"}) -## Install for development -```bash -python3 -m pip install -r requirements.txt -python3 -m pip install -e . -python3 -m unittest discover -s tests -v +if __name__ == "__main__": + app.listen(host="127.0.0.1", port=8000) ``` -SmallOS is installed from the canonical `master` branch in `requirements.txt`. -It owns scheduling, socket readiness, and foreign execution adapters. +Install the canonical SmallOS master dependency and this package, then run the +demo: -## Run the demo - -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. - -```bash +```console python3 -m pip install -r requirements.txt +python3 -m pip install -e . python3 demo.py ``` -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. +Application code can use blocking `app.listen()` without importing SmallOS. +Advanced applications can supply their own runtime, schedule without starting +it, and own adapters for blocking or asyncio-native libraries. -## Bind a server +## Optional features -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. +Static routing and HTTP-only imports need neither optional protocol package. +Install only the feature an application serves: -```python -from smallserver import Response, SmallServer +```console +python3 -m pip install -e '.[regex-routes]' +python3 -m pip install -e '.[websocket]' +``` -app = SmallServer() +Regex routes use bounded full-path matching after exact static lookup. +WebSocket routes use a separate static route table, so an ordinary `GET` and a +WebSocket Upgrade may coexist at one path. -@app.get("/health") -async def health(request): - return Response.json({"status": "ok"}) +```python +from smallserver import WebSocket -app.listen(host="127.0.0.1", port=8000) + +@app.websocket("/echo", origins={"https://app.example.com"}) +async def echo(socket: WebSocket) -> None: + await socket.accept() + async for message in socket: + if message.is_text: + await socket.send_text(message.text) + else: + await socket.send_bytes(message.bytes) ``` ### Configure the managed SmallOS runtime @@ -95,6 +91,8 @@ configure it directly with `SmallOS(config=...)`; SmallServer rejects `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. Managed `listen()` blocks and catches Ctrl-C after closing its listener, wakeup channel, connections, and server tasks. It returns the closed `ServerHandle`, @@ -102,18 +100,28 @@ 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: +## 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) +- [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 HTTP methods and a WebSocket route, +[`examples/websocket_echo.py`](examples/websocket_echo.py) for a bounded echo +server, [`examples/manual_runtime.py`](examples/manual_runtime.py) for +caller-owned SmallOS startup, and +[`examples/adapters_demo.py`](examples/adapters_demo.py) for blocking and +asyncio escape hatches. ```python from SmallPackage import SmallOS, Unix 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..3dcabd2 --- /dev/null +++ b/guide/api-reference.md @@ -0,0 +1,97 @@ +# 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` — optional timeout-bounded full-path route decorators. +- `websocket(path, *, origins=None, subprotocols=())` — static WebSocket route. +- `async dispatch(request)` — dispatch an existing `Request`. +- `listen(host="127.0.0.1", port=8000, 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)` — 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, routed path, headers, byte body, +raw target, query string, immutable path parameters, 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). + +### `ServerHandle` + +Read-only properties: `address`, `port`, `closed`, `failure`, `finished`, +`cleanup_errors`, and `owned_connection_count`. + +Operations: `close()`, `async close_from_task(task)`, and `finalize()`. + +## Regex routing + +- `RegexRouteConfig` — finite route, pattern, capture, path, and timeout limits. +- `RegexRoutesUnavailable` — the optional matching engine is missing. +- `RouteMatchTimeout` and `RoutePathTooLarge` — bounded matching failures. +- `RouteErrorEvent` — sanitized event sent to the optional observer. + +## WebSockets + +- `WebSocketConfig` — finite frame, message, mailbox, connection, 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..7200540 --- /dev/null +++ b/guide/configuration.md @@ -0,0 +1,61 @@ +# Configuration + +Pass a `ServerConfig` to `listen()` or `serve()` to tune finite listener, +parser, and scheduling limits. + +```python +from smallserver import 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, +) +``` + +| 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 origin-form request-target bytes. | +| `max_route_error_events` | 16 | Bounded sanitized observer-event queue. | + +Every field must be a positive integer; booleans are rejected. 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. + +## Regex routing limits + +Pass `RegexRouteConfig` to `SmallServer(regex_config=...)`. It bounds path +bytes, pattern length, route count, named captures, individual match time, and +total matching time. Regex configuration is validated without importing the +optional engine; registration imports it lazily. + +## WebSocket limits + +Pass `WebSocketConfig` to `SmallServer(websocket_config=...)`. Its positive, +finite settings bound frame and reassembled-message bytes, inbox/outbox counts +and bytes, read/write chunks, WebSocket connection count, and handshake, idle, +Pong, write, and close deadlines. `max_frame_payload_bytes` cannot exceed +`max_message_bytes`. See [WebSockets](websockets.md) for operational behavior. diff --git a/guide/development.md b/guide/development.md new file mode 100644 index 0000000..b51b5c0 --- /dev/null +++ b/guide/development.md @@ -0,0 +1,59 @@ +# 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 . +``` + +Install both optional test surfaces with +`python3 -m pip install -e '.[regex-routes,websocket]'` when validating the +complete feature set. Also run the suite without extras to keep HTTP-only +imports lazy. + +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, WebSockets, 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. + +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 three 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..9b1bb5d --- /dev/null +++ b/guide/errors-observability.md @@ -0,0 +1,57 @@ +# 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. + +## 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. + +Regex matching timeouts may be reported through `route_error_observer`. Its +dedicated SmallOS task receives bounded, traceback-free `RouteErrorEvent` +values containing only an opaque route ID and category. Observer failures and +capacity drops are isolated and counted on `ServerHandle`; the observer must +return quickly and use an execution adapter for blocking work. diff --git a/guide/getting-started.md b/guide/getting-started.md new file mode 100644 index 0000000..1101ab5 --- /dev/null +++ b/guide/getting-started.md @@ -0,0 +1,66 @@ +# 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 . +``` + +Install `.[regex-routes]` for regex routes or `.[websocket]` for WebSocket +routes. Static HTTP usage imports without either optional package. + +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 plus a WebSocket echo route at `/ws`: + +```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 ordinary HTTP/1.1 connection serves one request and closes after the +response. See [Routing](routing.md), [WebSockets](websockets.md), and +[Configuration](configuration.md) before building a larger application. diff --git a/guide/index.md b/guide/index.md new file mode 100644 index 0000000..54bac06 --- /dev/null +++ b/guide/index.md @@ -0,0 +1,29 @@ +# SmallServer guide + +This guide documents the current SmallServer API: bounded HTTP/1.1, static and +regex routing, WebSockets, managed or caller-owned SmallOS lifecycle, and +execution adapters. + +## Learn SmallServer + +1. [Getting started](getting-started.md) — install, create an app, and run it. +2. [Routing](routing.md) — exact and bounded regex routes. +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 protocol 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) + +SmallServer currently supports HTTP/1.1 and RFC 6455 Upgrade only. See the +roadmap for deferred HTTP/2, TLS, compression, and keep-alive work. 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..9be0286 --- /dev/null +++ b/guide/protocol-roadmap.md @@ -0,0 +1,31 @@ +# Protocol and feature roadmap + +The current branch provides bounded HTTP/1.1, static and regex routes, shared +HTTP values, RFC 6455 Upgrade, explicit SmallOS lifecycle control, and +application-owned execution adapters. + +## Routing extensions + +Timeout-bounded regex routes and immutable named captures are implemented as +the optional `regex-routes` extra. Exact static routes retain precedence. + +## WebSocket server + +Optional RFC 6455 server support over HTTP/1.1 Upgrade is implemented through +the `websocket` extra with SmallOS-native transport ownership and bounded +protocol state. TLS, compression, custom extensions, and RFC 8441 WebSockets +over HTTP/2 remain separate concerns. + +## HTTP/2 server + +The HTTP/2 feature is planned as an optional cleartext prior-knowledge server +using the hyper-h2 4.x sans-I/O stack. Its branch is responsible for documenting +dependency installation, stream concurrency, flow control, protocol limits, +GOAWAY, and graceful shutdown. SmallServer does not yet accept HTTP/2 +connections or export an HTTP/2 configuration type. + +## 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 this page 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..b27c79f --- /dev/null +++ b/guide/requests-and-responses.md @@ -0,0 +1,68 @@ +# 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` for the current network server; +- `raw_target`: the exact origin-form target; +- `query_string`: undecoded text after `?`; +- `path_params` and `route_pattern`: immutable regex-route context when used. + +The base parser accepts one origin-form HTTP/1.1 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, percent escapes, 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..5fcd6b5 --- /dev/null +++ b/guide/routing.md @@ -0,0 +1,66 @@ +# Routing + +SmallServer checks exact static routes first, then optional timeout-bounded +regular-expression routes. Register static routes with `get`, `post`, `put`, +`patch`, `delete`, or the multi-method `route` decorator. + +```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 + +The parser preserves the exact ASCII origin-form target as +`request.raw_target`. Routing uses `request.path`, excluding the raw query +string stored in `request.query_string`. Neither field nor a regex capture is +percent-decoded, so `/files/a%2Fb` remains distinct from `/files/a/b`. + +## Regex routes + +Install the bounded matching engine only when needed: + +```console +python3 -m pip install -e '.[regex-routes]' +``` + +```python +@app.get_regex(r"/users/(?P[0-9]+)") +async def user(request): + return Response.json({"user_id": request.path_params["user_id"]}) +``` + +`route_regex(pattern, methods)` and the five method-specific regex decorators +use full-path matching in registration order after static lookup. Only named +captures are exposed through immutable `request.path_params`; an unmatched +optional group is omitted. `request.route_pattern` identifies the selected +pattern. + +Patterns must begin with a literal `/`. Registration and dispatch bound route +count, pattern length, capture count, path bytes, each match, and total matching +time. A timeout becomes a sanitized 500 on the network path and may be observed +through the bounded `route_error_observer` channel without disclosing the +hostile path. Oversized paths return 414 before matching. diff --git a/guide/runtime-lifecycle.md b/guide/runtime-lifecycle.md new file mode 100644 index 0000000..d67611e --- /dev/null +++ b/guide/runtime-lifecycle.md @@ -0,0 +1,78 @@ +# 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 `SmallOS().setKernel(Unix())`, +starts it, blocks until shutdown, and finalizes server-owned resources. 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/tests/test_documentation.py b/tests/test_documentation.py new file mode 100644 index 0000000..044afd5 --- /dev/null +++ b/tests/test_documentation.py @@ -0,0 +1,78 @@ +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", + "websockets.md", + "configuration.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() From 397f9899978aa9484fb499853e9fd33b2b2f4a4a Mon Sep 17 00:00:00 2001 From: MikiEEE <35975462+MikiEEE@users.noreply.github.com> Date: Sun, 23 Aug 2026 16:15:37 -0500 Subject: [PATCH 49/53] docs: align examples with reconciled WebSocket API --- README.md | 31 ++++++++++++++++--------------- 1 file changed, 16 insertions(+), 15 deletions(-) diff --git a/README.md b/README.md index 73b5744..2411238 100644 --- a/README.md +++ b/README.md @@ -229,8 +229,8 @@ 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. +Exact static routes are dependency-free. Optional regex routes expose bounded +named captures; automatic path-template syntax is not implemented. ## Dispatch a request @@ -238,16 +238,17 @@ 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" +async def test_health() -> None: + 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, @@ -284,9 +285,9 @@ 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. +`dispatch()` turns this into a text response with status 413. Direct dispatch +leaves unexpected exceptions visible; network listeners return a sanitized +500 response. ## Third-party blocking and asyncio libraries From 906b64d2c7507faf0a14795712ce6bd2affdf925 Mon Sep 17 00:00:00 2001 From: MikiEEE <35975462+MikiEEE@users.noreply.github.com> Date: Sun, 23 Aug 2026 16:25:39 -0500 Subject: [PATCH 50/53] Fix coalesced WebSocket close test race --- tests/test_websocket.py | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) 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) From 0ef794fbccffc6715d81b319a5ce5297cae25a4c Mon Sep 17 00:00:00 2001 From: MikiEEE <35975462+MikiEEE@users.noreply.github.com> Date: Sun, 23 Aug 2026 16:30:21 -0500 Subject: [PATCH 51/53] chore: pin SmallOS v1.2.0 release --- README.md | 2 +- RELEASING.md | 12 +++++++----- guide/development.md | 2 +- guide/getting-started.md | 11 ++++++----- guide/platforms-kernels.md | 7 ++++--- requirements.txt | 6 +++--- 6 files changed, 22 insertions(+), 18 deletions(-) diff --git a/README.md b/README.md index 817fa07..5316943 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,7 @@ if __name__ == "__main__": app.listen(host="127.0.0.1", port=8000) ``` -Install the canonical SmallOS master dependency, the package, and test tools: +Install the pinned SmallOS v1.2.0 release, the package, and test tools: ```console python3 -m pip install -r requirements.txt diff --git a/RELEASING.md b/RELEASING.md index ed91051..1122b2e 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -45,11 +45,13 @@ python3 -m twine check dist/* ## Publishing boundary The automated process creates a GitHub release; it does not publish to PyPI. -SmallServer currently installs SmallOS from its canonical Git `master` branch +SmallServer currently installs the canonical SmallOS `v1.2.0` GitHub release through `requirements.txt`, while `pyproject.toml` intentionally has no runtime dependency declaration. Publishing the wheel to PyPI before SmallOS has an -installable release dependency would give users an incomplete installation. +installable package-index dependency would give users an incomplete +installation. -Add PyPI trusted publishing only after SmallOS has a stable package release, -SmallServer declares that dependency in `pyproject.toml`, and an installed-wheel -test proves a clean environment receives every runtime dependency. +Add PyPI trusted publishing only after SmallOS has a stable package-index +release, SmallServer declares that dependency in `pyproject.toml`, and an +installed-wheel test proves a clean environment receives every runtime +dependency. diff --git a/guide/development.md b/guide/development.md index bb0f035..590651c 100644 --- a/guide/development.md +++ b/guide/development.md @@ -2,7 +2,7 @@ ## Set up -Use Python 3.10 or newer and install the canonical SmallOS master checkout plus +Use Python 3.10 or newer and install the pinned SmallOS v1.2.0 release plus SmallServer in editable mode: ```console diff --git a/guide/getting-started.md b/guide/getting-started.md index 1871378..9ff45e0 100644 --- a/guide/getting-started.md +++ b/guide/getting-started.md @@ -2,17 +2,18 @@ ## 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. +SmallServer requires Python 3.10 or newer. `requirements.txt` installs the +canonical SmallOS GitHub release tagged `v1.2.0`; 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. +The first command needs Git and network access. The release tag makes the +SmallOS source revision reproducible; deployments should still lock all of +their transitive build dependencies. ## Create an application diff --git a/guide/platforms-kernels.md b/guide/platforms-kernels.md index e29f0df..fd0ce11 100644 --- a/guide/platforms-kernels.md +++ b/guide/platforms-kernels.md @@ -12,9 +12,10 @@ 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. +The canonical SmallOS dependency is installed from the GitHub `v1.2.0` release +tag by `requirements.txt`. Python package metadata intentionally has no runtime +dependency until SmallOS has an unambiguous package-index distribution +contract. ## Custom and constrained kernels diff --git a/requirements.txt b/requirements.txt index 9d050d1..cea0232 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,3 +1,3 @@ -# Install SmallOS directly from its canonical master branch. The PyPI package -# name is not yet available to this project. --e git+https://github.com/MikiEEE/SmallOS.git@master#egg=SmallPackage +# Install the canonical SmallOS v1.2.0 GitHub release. The distribution is not +# yet consumed from a package index by this project. +SmallPackage @ git+https://github.com/MikiEEE/SmallOS.git@v1.2.0 From 24bd4078a43110027bd73e27668ab3d6b0accc6d Mon Sep 17 00:00:00 2001 From: MikiEEE <35975462+MikiEEE@users.noreply.github.com> Date: Sun, 23 Aug 2026 16:39:56 -0500 Subject: [PATCH 52/53] feat: expose execution adapters through SmallServer --- examples/adapters_demo.py | 6 +++--- guide/adapters.md | 21 +++++++++++++----- guide/api-reference.md | 13 +++++++++++ smallserver/__init__.py | 42 ++++++++++++++++++++++++++++++++++-- smallserver/adapters.py | 22 ++++++++++++++++++- tests/test_adapters.py | 36 ++++++++++++++++++++++++++++--- tests/test_server_runtime.py | 2 +- tests/test_websocket.py | 2 +- tests/typing/adapters.py | 25 +++++++++++++++++++++ 9 files changed, 153 insertions(+), 16 deletions(-) create mode 100644 tests/typing/adapters.py diff --git a/examples/adapters_demo.py b/examples/adapters_demo.py index effe6e3..56ada44 100644 --- a/examples/adapters_demo.py +++ b/examples/adapters_demo.py @@ -8,16 +8,16 @@ import threading from SmallPackage import SmallOS, SmallTask, Unix -from SmallPackage.adapters.asyncio_loop import AsyncioAdapter -from SmallPackage.adapters.errors import AdapterError -from SmallPackage.adapters.threads import ThreadAdapter from smallserver import ( + AdapterError, AdapterRegistry, + AsyncioAdapter, Headers, Request, Response, SmallServer, + ThreadAdapter, http_error_from_adapter, ) diff --git a/guide/adapters.md b/guide/adapters.md index 2bc91b1..0871eb9 100644 --- a/guide/adapters.md +++ b/guide/adapters.md @@ -1,8 +1,9 @@ # 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: +functions or drive a second event loop directly. SmallServer exposes the +SmallOS-backed escape hatches through its own public API, so application code +does not need to import adapter modules from `SmallPackage`: - `ThreadAdapter` for blocking or thread-affine callables; - `AsyncioAdapter` for coroutine-based libraries on a persistent asyncio loop. @@ -11,9 +12,13 @@ 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 +from smallserver import ( + AdapterError, + AdapterRegistry, + Response, + ThreadAdapter, + http_error_from_adapter, +) services = AdapterRegistry(blocking=ThreadAdapter(max_workers=2, max_pending=8)) @@ -39,6 +44,12 @@ 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. +Import `ThreadAdapter` and `AsyncioAdapter` from `smallserver`. Their work is +still scheduled and completed through SmallOS, but the backend module layout is +not part of application code. SmallServer also exports `AdapterError` and its +capacity, unavailable, closed, cancelled, protocol, and execution subclasses +for explicit handling. + `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 diff --git a/guide/api-reference.md b/guide/api-reference.md index eae0fd4..036fc6b 100644 --- a/guide/api-reference.md +++ b/guide/api-reference.md @@ -84,6 +84,14 @@ See [WebSockets](websockets.md) for handshake and completion semantics. ## Adapters +### `ThreadAdapter(max_workers=None, max_pending=64, ...)` + +Bounded worker-thread execution for synchronous or thread-affine callables. + +### `AsyncioAdapter(max_pending=64, ...)` + +Bounded coroutine execution on one persistent asyncio loop thread. + ### `AdapterRegistry(**adapters)` Methods: `register`, `get`, `call`, `names`, `items`, and `shutdown`. It also @@ -93,6 +101,11 @@ implements a context manager and exposes `closed`. Convert a SmallOS `AdapterError` to a sanitized `HTTPError`. +Applications can import `AdapterError`, `AdapterCapacityError`, +`AdapterUnavailableError`, `AdapterClosedError`, `AdapterCancelledError`, +`AdapterProtocolError`, and `AdapterExecutionError` directly from +`smallserver`. + ### `AdapterShutdownError` Raised after registry shutdown attempts every adapter but one or more fail. diff --git a/smallserver/__init__.py b/smallserver/__init__.py index 4fb8ae0..52f467b 100644 --- a/smallserver/__init__.py +++ b/smallserver/__init__.py @@ -31,12 +31,41 @@ ) if TYPE_CHECKING: - from .adapters import AdapterRegistry, AdapterShutdownError, http_error_from_adapter + from .adapters import ( + AdapterCancelledError, + AdapterCapacityError, + AdapterClosedError, + AdapterError, + AdapterExecutionError, + AdapterProtocolError, + AdapterRegistry, + AdapterShutdownError, + AdapterUnavailableError, + AsyncioAdapter, + ThreadAdapter, + http_error_from_adapter, + ) + + +_ADAPTER_EXPORTS = { + "AdapterCancelledError", + "AdapterCapacityError", + "AdapterClosedError", + "AdapterError", + "AdapterExecutionError", + "AdapterProtocolError", + "AdapterRegistry", + "AdapterShutdownError", + "AdapterUnavailableError", + "AsyncioAdapter", + "ThreadAdapter", + "http_error_from_adapter", +} def __getattr__(name: str) -> Any: """Load optional SmallOS adapter integration only when it is requested.""" - if name in {"AdapterRegistry", "AdapterShutdownError", "http_error_from_adapter"}: + if name in _ADAPTER_EXPORTS: from . import adapters value = getattr(adapters, name) @@ -45,8 +74,16 @@ def __getattr__(name: str) -> Any: raise AttributeError("module {!r} has no attribute {!r}".format(__name__, name)) __all__ = [ + "AdapterCancelledError", + "AdapterCapacityError", + "AdapterClosedError", + "AdapterError", + "AdapterExecutionError", + "AdapterProtocolError", "AdapterRegistry", "AdapterShutdownError", + "AdapterUnavailableError", + "AsyncioAdapter", "Headers", "HTTPError", "HTTP2Config", @@ -64,6 +101,7 @@ def __getattr__(name: str) -> Any: "ServerHandle", "ServerStartupError", "SmallServer", + "ThreadAdapter", "WebSocket", "WebSocketCapacityError", "WebSocketConfig", diff --git a/smallserver/adapters.py b/smallserver/adapters.py index c88f8a7..5107711 100644 --- a/smallserver/adapters.py +++ b/smallserver/adapters.py @@ -1,4 +1,4 @@ -"""Explicit lifecycle and HTTP translation helpers for SmallOS adapters.""" +"""SmallServer's public facade for SmallOS execution adapters.""" from __future__ import annotations @@ -6,17 +6,37 @@ from types import TracebackType from typing import Any +from SmallPackage.adapters.asyncio_loop import AsyncioAdapter from SmallPackage.adapters.errors import ( AdapterCancelledError, AdapterCapacityError, AdapterClosedError, AdapterError, + AdapterExecutionError, + AdapterProtocolError, AdapterUnavailableError, ) +from SmallPackage.adapters.threads import ThreadAdapter from .errors import HTTPError +__all__ = [ + "AdapterCancelledError", + "AdapterCapacityError", + "AdapterClosedError", + "AdapterError", + "AdapterExecutionError", + "AdapterProtocolError", + "AdapterRegistry", + "AdapterShutdownError", + "AdapterUnavailableError", + "AsyncioAdapter", + "ThreadAdapter", + "http_error_from_adapter", +] + + class AdapterShutdownError(RuntimeError): """One or more adapters failed while the registry was shutting down.""" diff --git a/tests/test_adapters.py b/tests/test_adapters.py index 8ffb455..b9107d9 100644 --- a/tests/test_adapters.py +++ b/tests/test_adapters.py @@ -4,17 +4,20 @@ import unittest from SmallPackage import SmallOS, SmallTask, Unix -from SmallPackage.adapters.asyncio_loop import AsyncioAdapter -from SmallPackage.adapters.errors import AdapterCapacityError, AdapterProtocolError -from SmallPackage.adapters.threads import ThreadAdapter +import smallserver from smallserver import ( + AdapterCapacityError, + AdapterError, + AdapterProtocolError, AdapterRegistry, AdapterShutdownError, + AsyncioAdapter, Headers, Request, Response, SmallServer, + ThreadAdapter, http_error_from_adapter, ) @@ -36,6 +39,33 @@ def shutdown(self, wait=True, cancel_pending=False) -> None: class AdapterRegistryTests(unittest.TestCase): + def test_public_facade_exposes_adapter_types_and_errors(self) -> None: + expected_exports = { + "AdapterCancelledError", + "AdapterCapacityError", + "AdapterClosedError", + "AdapterError", + "AdapterExecutionError", + "AdapterProtocolError", + "AdapterUnavailableError", + "AsyncioAdapter", + "ThreadAdapter", + } + self.assertLessEqual(expected_exports, set(smallserver.__all__)) + for name in expected_exports: + self.assertIsNotNone(getattr(smallserver, name)) + self.assertIs(smallserver.ThreadAdapter, ThreadAdapter) + self.assertIs(smallserver.AsyncioAdapter, AsyncioAdapter) + blocking = ThreadAdapter(max_workers=1, max_pending=1) + foreign_async = AsyncioAdapter(max_pending=1) + try: + self.assertIsInstance(AdapterCapacityError("full"), AdapterError) + self.assertFalse(blocking.closed) + self.assertFalse(foreign_async.closed) + finally: + foreign_async.shutdown() + blocking.shutdown() + def test_registry_delegates_and_shuts_down_in_reverse_order(self) -> None: events: list[str] = [] first = FakeAdapter(events, "first") diff --git a/tests/test_server_runtime.py b/tests/test_server_runtime.py index 18441cf..3f78a17 100644 --- a/tests/test_server_runtime.py +++ b/tests/test_server_runtime.py @@ -7,10 +7,10 @@ import unittest from SmallPackage import SmallOS, Unix -from SmallPackage.adapters.threads import ThreadAdapter from smallserver import ( AdapterRegistry, + ThreadAdapter, RegexRouteConfig, Request, Response, diff --git a/tests/test_websocket.py b/tests/test_websocket.py index 3edd8c0..d52e5b7 100644 --- a/tests/test_websocket.py +++ b/tests/test_websocket.py @@ -9,10 +9,10 @@ from unittest.mock import patch from SmallPackage import SmallOS, SmallTask, SmallWebSocketClient, Unix -from SmallPackage.adapters.threads import ThreadAdapter from smallserver import ( AdapterRegistry, + ThreadAdapter, Headers, Request, Response, diff --git a/tests/typing/adapters.py b/tests/typing/adapters.py new file mode 100644 index 0000000..2772f17 --- /dev/null +++ b/tests/typing/adapters.py @@ -0,0 +1,25 @@ +"""Public adapter-facade typing fixture.""" + +from smallserver import AdapterError, AdapterRegistry, AsyncioAdapter, ThreadAdapter + + +def blocking(value: str) -> int: + return len(value) + + +async def foreign_async(value: str) -> int: + return len(value) + + +def facade() -> AdapterRegistry: + thread_adapter = ThreadAdapter(max_workers=1, max_pending=4) + asyncio_adapter = AsyncioAdapter(max_pending=4) + services = AdapterRegistry(blocking=thread_adapter, async_sdk=asyncio_adapter) + + thread_adapter.call(blocking, "smallserver") + asyncio_adapter.call(foreign_async, "smallserver") + return services + + +def handle(error: AdapterError) -> str: + return str(error) From 810ab7adbcb1e2cf82bb8f2d6d1fc18be24a6e4a Mon Sep 17 00:00:00 2001 From: MikiEEE <35975462+MikiEEE@users.noreply.github.com> Date: Sun, 23 Aug 2026 16:50:54 -0500 Subject: [PATCH 53/53] test: preserve coalesced WebSocket deadline frames --- tests/test_websocket.py | 42 +++++++++++++++++++++++++++++------------ 1 file changed, 30 insertions(+), 12 deletions(-) diff --git a/tests/test_websocket.py b/tests/test_websocket.py index d52e5b7..6bb40cf 100644 --- a/tests/test_websocket.py +++ b/tests/test_websocket.py @@ -871,19 +871,23 @@ def connect(path: str): response = b"" while b"\r\n\r\n" not in response: response += stream.recv(4096) - return stream, response + headers, separator, websocket_data = response.partition(b"\r\n\r\n") + return stream, headers + separator, websocket_data def client_work() -> None: try: - stream, response = connect("/handshake-timeout") + stream, response, _ = connect("/handshake-timeout") outcomes["handshake"] = response stream.close() - stream, response = connect("/idle-timeout") + stream, response, websocket_data = connect("/idle-timeout") outcomes["idle_handshake"] = response idle_client = api.Connection(api.ConnectionType.CLIENT) idle_events = _receive_events( - stream, idle_client, api.CloseConnection + stream, + idle_client, + api.CloseConnection, + initial_data=websocket_data, ) idle_close = next( event @@ -894,23 +898,37 @@ def client_work() -> None: stream.sendall(idle_client.send(idle_close.response())) stream.close() - stream, response = connect("/pong-timeout") + stream, response, websocket_data = connect("/pong-timeout") outcomes["pong_handshake"] = response pong_client = api.Connection(api.ConnectionType.CLIENT) - ping_events = _receive_events(stream, pong_client, api.Ping) + ping_events = _receive_events( + stream, + pong_client, + api.Ping, + initial_data=websocket_data, + ) outcomes["ping_payload"] = next( event.payload for event in ping_events if isinstance(event, api.Ping) ) - close_events = _receive_events( - stream, pong_client, api.CloseConnection - ) pong_close = next( - event - for event in close_events - if isinstance(event, api.CloseConnection) + ( + event + for event in ping_events + if isinstance(event, api.CloseConnection) + ), + None, ) + if pong_close is None: + close_events = _receive_events( + stream, pong_client, api.CloseConnection + ) + pong_close = next( + event + for event in close_events + if isinstance(event, api.CloseConnection) + ) outcomes["pong_code"] = pong_close.code stream.sendall(pong_client.send(pong_close.response())) stream.close()