wandelbots-novaflow 0.1.0.dev1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. novaflow/cli/__init__.py +20 -0
  2. novaflow/cli/actions/__init__.py +1 -0
  3. novaflow/cli/actions/conformance.py +47 -0
  4. novaflow/cli/actions/lint.py +73 -0
  5. novaflow/cli/main.py +83 -0
  6. novaflow/conformance/__init__.py +58 -0
  7. novaflow/conformance/adapters/__init__.py +16 -0
  8. novaflow/conformance/adapters/httpx.py +284 -0
  9. novaflow/conformance/adapters/synthetic.py +124 -0
  10. novaflow/conformance/harness.py +334 -0
  11. novaflow/conformance/tracker.py +124 -0
  12. novaflow/core/__init__.py +21 -0
  13. novaflow/core/action.py +2405 -0
  14. novaflow/core/action_context.py +128 -0
  15. novaflow/core/contract.py +15 -0
  16. novaflow/core/correlation.py +96 -0
  17. novaflow/core/crash_visibility.py +55 -0
  18. novaflow/core/demand_map.py +194 -0
  19. novaflow/core/exceptions.py +53 -0
  20. novaflow/core/halt.py +378 -0
  21. novaflow/core/input_validation.py +29 -0
  22. novaflow/core/materializers/__init__.py +16 -0
  23. novaflow/core/materializers/image.py +569 -0
  24. novaflow/core/materializers/json_data.py +114 -0
  25. novaflow/core/materializers/pointcloud.py +368 -0
  26. novaflow/core/nats_backoff.py +66 -0
  27. novaflow/core/nats_circuit_breaker.py +36 -0
  28. novaflow/core/object_store.py +301 -0
  29. novaflow/core/worker_pool.py +1090 -0
  30. novaflow/lint/__init__.py +6 -0
  31. novaflow/lint/halt.py +293 -0
  32. novaflow/settings/__init__.py +3 -0
  33. novaflow/settings/nova_settings.py +19 -0
  34. novaflow/settings/settings.py +119 -0
  35. wandelbots_novaflow-0.1.0.dev1.dist-info/METADATA +305 -0
  36. wandelbots_novaflow-0.1.0.dev1.dist-info/RECORD +38 -0
  37. wandelbots_novaflow-0.1.0.dev1.dist-info/WHEEL +4 -0
  38. wandelbots_novaflow-0.1.0.dev1.dist-info/entry_points.txt +2 -0
@@ -0,0 +1,20 @@
1
+ """The `nf` command-line tool for novaflow action authors.
2
+
3
+ `nf` is the single user-facing entry point for novaflow developer
4
+ tooling. Today it hosts:
5
+
6
+ * `nf actions lint` — heuristic halt-cleanup check
7
+ * `nf actions conformance` — halt-conformance harness driver (stub)
8
+
9
+ Future commands (planned, not yet wired):
10
+
11
+ * `nf actions list` — show handlers discovered in the package
12
+ * `nf actions validate` — schema/signature check at dev-time
13
+ * `nf actions new` — scaffold a new action service
14
+ * `nf trees validate` — schema-check a behaviour-tree JSON
15
+ * `nf services list` — query a control-plane for registered services
16
+
17
+ The pattern: every subcommand is a thin wrapper over functionality
18
+ that already exists in the SDK or schemas. The CLI exists so devs
19
+ only have to learn one command, not five module paths.
20
+ """
@@ -0,0 +1 @@
1
+ """`nf actions ...` — developer tooling for novaflow action services."""
@@ -0,0 +1,47 @@
1
+ """`nf actions conformance` — halt-conformance harness driver.
2
+
3
+ Today this is a stub: the harness API
4
+ (`novaflow.conformance.HaltConformanceHarness`) is the supported way
5
+ to write conformance tests, and devs run them under pytest. See
6
+ `docs/halt-conformance.md` for a worked example.
7
+
8
+ The CLI form here will, in a later step, auto-discover handlers in
9
+ `src/actions/` and write a halt-conformance attestation into the
10
+ action manifest — but per-handler harness wiring (which adapter,
11
+ which fixture, which halt timing) is action-specific, so the pytest
12
+ form remains the source of truth for now.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import argparse
18
+
19
+
20
+ def add_parser(subparsers: argparse._SubParsersAction) -> argparse.ArgumentParser:
21
+ p = subparsers.add_parser(
22
+ "conformance",
23
+ help="halt-conformance harness driver (stub)",
24
+ description=(
25
+ "Halt-conformance is run via the Python harness API "
26
+ "under pytest today. This CLI will gain auto-discovery "
27
+ "and manifest-attestation in a later step. For now, "
28
+ "see docs/halt-conformance.md."
29
+ ),
30
+ )
31
+ p.set_defaults(_run=run)
32
+ return p
33
+
34
+
35
+ def run(args: argparse.Namespace) -> int:
36
+ print(
37
+ "nf actions conformance: not yet implemented as a CLI driver.\n"
38
+ "\n"
39
+ "Write conformance cases against your handlers using the harness "
40
+ "API and run them under pytest:\n"
41
+ "\n"
42
+ " from novaflow.conformance import HaltConformanceHarness\n"
43
+ " from novaflow.conformance.adapters.httpx import HttpxAdapter\n"
44
+ "\n"
45
+ "See docs/halt-conformance.md for a worked example.\n"
46
+ )
47
+ return 0
@@ -0,0 +1,73 @@
1
+ """`nf actions lint` — wrap the halt-cleanup heuristic lint rule.
2
+
3
+ This subcommand is a thin shell around `novaflow.lint.halt.main`. It
4
+ exists so devs run one command (`nf actions lint`) instead of
5
+ remembering the module path (`uv run -m novaflow.lint.halt`).
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import argparse
11
+ from pathlib import Path
12
+
13
+ from novaflow.lint.halt import analyze_paths, _format_warning
14
+
15
+
16
+ def add_parser(subparsers: argparse._SubParsersAction) -> argparse.ArgumentParser:
17
+ p = subparsers.add_parser(
18
+ "lint",
19
+ help="halt-cleanup heuristic check (warning-only)",
20
+ description=(
21
+ "Scan Python files for action handlers that acquire a "
22
+ "resource without a paired cleanup (no `async with`, no "
23
+ "`.close()`, no `halt.on_halt`, not returned). The rule "
24
+ "is heuristic (~70%% TP / ~30%% FP per Spike I); use it "
25
+ "as a developer aid, not as a CI gate. The conformance "
26
+ "harness (`nf actions conformance`) is the authoritative "
27
+ "safety check."
28
+ ),
29
+ )
30
+ p.add_argument(
31
+ "paths",
32
+ nargs="*",
33
+ default=["src/actions"],
34
+ help=(
35
+ "Paths to scan. Default: `src/actions` (the conventional "
36
+ "novaflow action-handler location). Pass a different "
37
+ "path or paths to override."
38
+ ),
39
+ )
40
+ p.set_defaults(_run=run)
41
+ return p
42
+
43
+
44
+ def run(args: argparse.Namespace) -> int:
45
+ paths = [Path(p).resolve() for p in args.paths]
46
+ missing = [p for p in paths if not p.exists()]
47
+ if missing:
48
+ print(
49
+ f"nf actions lint: paths not found: "
50
+ f"{', '.join(str(p) for p in missing)}",
51
+ file=__import__("sys").stderr,
52
+ )
53
+ return 2
54
+
55
+ common = paths[0] if len(paths) == 1 else None
56
+ handler_count, warnings = analyze_paths(paths)
57
+
58
+ print(f"Inspected {handler_count} action handlers; {len(warnings)} warnings.")
59
+ if warnings:
60
+ print()
61
+ print("Warnings (file:line — variable acquired without obvious cleanup):")
62
+ for w in warnings:
63
+ print(_format_warning(w, root=common))
64
+ print()
65
+ print("=" * 60)
66
+ print("Note: this rule is heuristic (~70% TP / ~30% FP).")
67
+ print("The conformance harness (real-server observation) is the")
68
+ print("authoritative safety check. Common false-positive shapes:")
69
+ print(" * Legacy handlers written before the SDK existed")
70
+ print(" * Module-scope clients initialised in on_startup")
71
+ print(" * Returned values where the caller is responsible for cleanup")
72
+ # Always exit 0 — advisory rule, not a CI gate.
73
+ return 0
novaflow/cli/main.py ADDED
@@ -0,0 +1,83 @@
1
+ """Entry point and subcommand dispatcher for the `nf` CLI.
2
+
3
+ Argparse-based for minimum dependencies. Subcommand modules expose
4
+ two things:
5
+
6
+ * `add_parser(subparsers) -> argparse.ArgumentParser` — registers
7
+ its flags and options
8
+ * `run(args) -> int` — executes
9
+ the subcommand, returns the process exit code
10
+
11
+ This contract keeps each subcommand self-contained; adding a new
12
+ one is `register_subcommand(subparsers, mod)` in `_build_parser()`.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import argparse
18
+ import sys
19
+ from typing import Any
20
+
21
+
22
+ def _build_parser() -> argparse.ArgumentParser:
23
+ parser = argparse.ArgumentParser(
24
+ prog="nf",
25
+ description=(
26
+ "Developer tooling for novaflow action services. "
27
+ "Run `nf <command> --help` for details on a subcommand."
28
+ ),
29
+ )
30
+ parser.set_defaults(_run=None)
31
+
32
+ subparsers = parser.add_subparsers(
33
+ title="commands",
34
+ dest="command",
35
+ metavar="COMMAND",
36
+ )
37
+
38
+ # `nf actions ...`
39
+ actions_parser = subparsers.add_parser(
40
+ "actions",
41
+ help="action-author tooling (lint, conformance, …)",
42
+ description=(
43
+ "Tooling for novaflow action services. Run from the "
44
+ "package root (the directory containing `pyproject.toml` "
45
+ "and `src/actions/`)."
46
+ ),
47
+ )
48
+ actions_sub = actions_parser.add_subparsers(
49
+ title="action commands",
50
+ dest="actions_command",
51
+ metavar="SUBCOMMAND",
52
+ )
53
+ # When invoked as `nf actions` with no subcommand, print this
54
+ # parser's help (not the top-level one).
55
+ actions_parser.set_defaults(_run=lambda _args: (actions_parser.print_help() or 0))
56
+
57
+ # Subcommand modules are imported lazily inside their `add_parser`
58
+ # so import errors in one don't break the whole CLI.
59
+ from novaflow.cli.actions import lint as lint_cmd
60
+ lint_cmd.add_parser(actions_sub)
61
+
62
+ from novaflow.cli.actions import conformance as conformance_cmd
63
+ conformance_cmd.add_parser(actions_sub)
64
+
65
+ return parser
66
+
67
+
68
+ def main(argv: list[str] | None = None) -> int:
69
+ parser = _build_parser()
70
+ args = parser.parse_args(argv)
71
+
72
+ run: Any = getattr(args, "_run", None)
73
+ if run is None:
74
+ # No subcommand chosen — print help and exit 0 (consistent
75
+ # with `git`, `docker`, etc.).
76
+ parser.print_help()
77
+ return 0
78
+
79
+ return run(args)
80
+
81
+
82
+ if __name__ == "__main__":
83
+ sys.exit(main())
@@ -0,0 +1,58 @@
1
+ """Halt-conformance harness for novaflow action services.
2
+
3
+ The harness verifies that an action handler's halt path actually
4
+ releases the resources it acquired — a property the lint rule
5
+ (novaflow.lint.halt) can only approximate by AST shape. The
6
+ authoritative check is to observe real-server state after a halt
7
+ fires: did the connection drop? did the transaction roll back? did
8
+ the websocket close?
9
+
10
+ Architecture:
11
+
12
+ * `HaltConformanceHarness` orchestrates the test flow: spin up the
13
+ real dependency, install the adapter's observation hook, dispatch
14
+ the handler, fire halt mid-execution, query the server.
15
+ * `Adapter` (protocol) — per-library plugin that knows how to spin
16
+ up a test container, observe its own resource state, and tear down.
17
+ Built-in adapters: synthetic (in-process fake; used by the
18
+ harness's own self-tests) and httpx (in-process asyncio HTTP
19
+ server with TCP-level peer-close detection — the canonical
20
+ real-server demo). Custom backends (postgres, gRPC, MQTT, …)
21
+ are easy to add via the small `Adapter` protocol; see
22
+ `docs/halt-conformance.md`.
23
+ * `ResourceTracker` — adapter-side bookkeeping: each acquire is
24
+ recorded, each release matched off, leaks reported as unmatched
25
+ acquires.
26
+
27
+ This package ships with the SDK; third-party action authors run it
28
+ locally or in their own CI. Today the harness is driven via the
29
+ Python API from a `tests/test_halt_conformance.py` in the action
30
+ package — see `docs/halt-conformance.md`. The typical invocation
31
+ is::
32
+
33
+ uv run pytest tests/test_halt_conformance.py
34
+
35
+ A planned `nf actions conformance` CLI will auto-discover handlers
36
+ and write manifest attestations; that's a follow-up.
37
+
38
+ The result is an attestation in the action manifest. It is *not* a
39
+ runtime gate — the supervisor SIGKILL floor enforces termination
40
+ regardless. Conformance is the quality bar; SIGKILL is the safety
41
+ floor.
42
+ """
43
+
44
+ from novaflow.conformance.harness import (
45
+ ConformanceFailure,
46
+ ConformanceResult,
47
+ HaltConformanceHarness,
48
+ run_conformance,
49
+ )
50
+ from novaflow.conformance.tracker import ResourceTracker
51
+
52
+ __all__ = [
53
+ "ConformanceFailure",
54
+ "ConformanceResult",
55
+ "HaltConformanceHarness",
56
+ "ResourceTracker",
57
+ "run_conformance",
58
+ ]
@@ -0,0 +1,16 @@
1
+ """Per-library adapters for the halt-conformance harness.
2
+
3
+ Each adapter knows how to spin up its real dependency, observe its
4
+ own resource state, and tear down. The harness drives them all
5
+ through the `Adapter` protocol.
6
+
7
+ Built-in adapters:
8
+ * synthetic — in-memory fake; used by the harness's own self-tests.
9
+ * httpx — in-process asyncio HTTP/1.1 server with TCP-level
10
+ peer-close detection. The canonical real-server demo;
11
+ no Docker required.
12
+
13
+ Custom backends (postgres, gRPC, MQTT, vendor-specific clients) live
14
+ in user code; the small `Adapter` protocol makes them straightforward
15
+ to add. See `docs/halt-conformance.md`.
16
+ """
@@ -0,0 +1,284 @@
1
+ """HTTPX adapter — observes real server-side connection state.
2
+
3
+ Spike E's pattern applied to HTTP clients: spin up a small in-process
4
+ asyncio HTTP server, run the handler against it, count actual
5
+ connections at the server. This catches the leak shape that matters
6
+ for HTTP-API actions: "handler made a request, got a response, but
7
+ never called `client.close()` on the httpx.AsyncClient" — keeping
8
+ the keepalive socket open at the server.
9
+
10
+ The server does enough HTTP/1.1 to satisfy `httpx.AsyncClient` for
11
+ GET / POST with keepalive. It's intentionally minimal — not a full
12
+ aiohttp app, just enough to make connection state observable.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import asyncio
18
+ import contextlib
19
+ import time
20
+ import uuid
21
+ from typing import Any
22
+
23
+ from novaflow.conformance.tracker import LeakReport, ResourceTracker
24
+
25
+ # HTTP/1.1 keepalive idle timeout. Long enough to stay open across a
26
+ # few sequential requests in one handler; short enough that a "leaky"
27
+ # handler's connection still shows up in the count when leak_report
28
+ # runs.
29
+ _KEEPALIVE_IDLE_TIMEOUT_S = 30.0
30
+ _DEFAULT_DRAIN_TIMEOUT_S = 2.0
31
+
32
+
33
+ class HttpxAdapter:
34
+ """Adapter for halt-conformance against a small in-process HTTP server.
35
+
36
+ Usage::
37
+
38
+ async with HttpxAdapter() as adapter:
39
+ harness = HaltConformanceHarness("MyAction", adapter)
40
+ harness.add_case(ConformanceCase("get_health", my_handler))
41
+ result = await harness.run()
42
+
43
+ Handlers receive a fixture dict with `base_url`. They build their
44
+ own httpx.AsyncClient against it. Leak detection runs at the
45
+ server side — the server tracks `connection_made` /
46
+ `connection_lost` and reports the live count.
47
+
48
+ Endpoints exposed by the test server:
49
+ * GET /health → 200 ``{"connected": true}``
50
+ * POST /echo → 200 ``{"received": "<body>"}``
51
+
52
+ These match the shapes a typical HTTP-backed action handler
53
+ exercises (the canonical test target for this adapter).
54
+ """
55
+
56
+ name = "httpx"
57
+
58
+ def __init__(self, drain_timeout_s: float = _DEFAULT_DRAIN_TIMEOUT_S) -> None:
59
+ self._drain_timeout_s = drain_timeout_s
60
+ self._tracker = ResourceTracker()
61
+ self._server: asyncio.Server | None = None
62
+ self._port: int | None = None
63
+ self._app_id = uuid.uuid4().hex[:8]
64
+ # Live transports keyed by id() so we can count and force-close.
65
+ self._live_transports: dict[int, asyncio.Transport] = {}
66
+
67
+ # -- async context manager -----------------------------------------------
68
+
69
+ async def __aenter__(self) -> "HttpxAdapter":
70
+ self._server = await asyncio.start_server(
71
+ self._handle_client, host="127.0.0.1", port=0
72
+ )
73
+ sock = self._server.sockets[0]
74
+ self._port = sock.getsockname()[1]
75
+ return self
76
+
77
+ async def __aexit__(self, exc_type, exc, tb) -> None:
78
+ # Force-close any leaked connections before tearing down.
79
+ for t in list(self._live_transports.values()):
80
+ with contextlib.suppress(Exception):
81
+ t.close()
82
+ self._live_transports.clear()
83
+ if self._server is not None:
84
+ self._server.close()
85
+ await self._server.wait_closed()
86
+ self._server = None
87
+
88
+ async def reset_for_case(self) -> None:
89
+ """Drain the live-transport ledger before the next case starts.
90
+
91
+ Without this, a keepalive connection that the previous case's
92
+ BalanceClient (or equivalent) opened and didn't immediately
93
+ drop at the server still shows in `_live_transports` when
94
+ the next case's leak check runs — masquerading as a leak the
95
+ new case caused.
96
+
97
+ Force-aborting the transport (rather than `close()`) makes
98
+ the per-connection handler's `reader.readuntil` see EOF
99
+ immediately. Plain `transport.close()` schedules a graceful
100
+ shutdown that may not propagate before the next case begins
101
+ under pytest-asyncio's per-test loop scheduling.
102
+ """
103
+ for t in list(self._live_transports.values()):
104
+ with contextlib.suppress(Exception):
105
+ # `abort()` skips the graceful close; the handler's
106
+ # readuntil sees EOF or ConnectionError on the next
107
+ # tick and exits.
108
+ t.abort()
109
+ # Poll until handlers actually drain themselves (each handler
110
+ # pops its own entry in the `finally` block).
111
+ deadline = time.monotonic() + 1.0
112
+ while self._live_transports and time.monotonic() < deadline:
113
+ await asyncio.sleep(0.02)
114
+ self._live_transports.clear()
115
+
116
+ # -- harness protocol ----------------------------------------------------
117
+
118
+ @property
119
+ def tracker(self) -> ResourceTracker:
120
+ return self._tracker
121
+
122
+ def fixture(self) -> dict[str, Any]:
123
+ if self._port is None:
124
+ raise RuntimeError(
125
+ "HttpxAdapter.fixture() called outside async with"
126
+ )
127
+ return {
128
+ "base_url": f"http://127.0.0.1:{self._port}",
129
+ "app_id": self._app_id,
130
+ }
131
+
132
+ # -- real-server observation --------------------------------------------
133
+
134
+ def count_server_conns(self) -> int:
135
+ """How many live client connections does the server hold?"""
136
+ return len(self._live_transports)
137
+
138
+ async def wait_for_zero_conns(
139
+ self, timeout_s: float | None = None
140
+ ) -> tuple[bool, float]:
141
+ """Poll until the server reports zero live connections."""
142
+ budget = timeout_s if timeout_s is not None else self._drain_timeout_s
143
+ deadline = time.monotonic() + budget
144
+ t0 = time.monotonic()
145
+ while time.monotonic() < deadline:
146
+ if self.count_server_conns() == 0:
147
+ return True, time.monotonic() - t0
148
+ await asyncio.sleep(0.05)
149
+ return False, time.monotonic() - t0
150
+
151
+ async def collect_leak_report(self) -> LeakReport:
152
+ """Async leak hook called by the harness — polls until the
153
+ server reports zero live conns or the drain budget expires."""
154
+ ok, elapsed = await self.wait_for_zero_conns()
155
+ if ok:
156
+ return LeakReport(leaked=[], total_acquires=0, total_releases=0)
157
+
158
+ from novaflow.conformance.tracker import _Acquired
159
+
160
+ n = self.count_server_conns()
161
+ acquired_at_ms = (time.monotonic() - elapsed) * 1000
162
+ leaked = [
163
+ _Acquired(
164
+ handle_id=tid,
165
+ resource_kind="httpx.server_connection",
166
+ acquired_at_ms=acquired_at_ms,
167
+ metadata={
168
+ "drain_timeout_s": self._drain_timeout_s,
169
+ "drain_elapsed_s": round(elapsed, 3),
170
+ },
171
+ )
172
+ for tid in list(self._live_transports.keys())
173
+ ]
174
+ return LeakReport(leaked=leaked, total_acquires=n, total_releases=0)
175
+
176
+ # -- HTTP server (minimal HTTP/1.1) -------------------------------------
177
+
178
+ async def _handle_client(
179
+ self, reader: asyncio.StreamReader, writer: asyncio.StreamWriter
180
+ ) -> None:
181
+ """One client connection. Loops over requests on the same
182
+ socket (keep-alive) until the client closes or idles out."""
183
+ transport = writer.transport
184
+ tid = id(transport)
185
+ self._live_transports[tid] = transport
186
+ try:
187
+ while True:
188
+ try:
189
+ headers_blob = await asyncio.wait_for(
190
+ reader.readuntil(b"\r\n\r\n"),
191
+ timeout=_KEEPALIVE_IDLE_TIMEOUT_S,
192
+ )
193
+ except (asyncio.TimeoutError, asyncio.IncompleteReadError, ConnectionError):
194
+ break
195
+ if not headers_blob:
196
+ break
197
+
198
+ # Parse just enough to dispatch on path.
199
+ request_line, _, rest = headers_blob.partition(b"\r\n")
200
+ try:
201
+ method, path, _ = request_line.decode("ascii", errors="replace").split(" ", 2)
202
+ except ValueError:
203
+ break
204
+
205
+ # Drain Content-Length body if present so the next
206
+ # request on this keepalive connection finds clean
207
+ # bytes.
208
+ content_length = 0
209
+ for line in rest.split(b"\r\n"):
210
+ lower = line.lower()
211
+ if lower.startswith(b"content-length:"):
212
+ try:
213
+ content_length = int(line.split(b":", 1)[1].strip())
214
+ except ValueError:
215
+ content_length = 0
216
+ break
217
+ body = b""
218
+ if content_length > 0:
219
+ body = await reader.readexactly(content_length)
220
+
221
+ # `/very_slow` blocks the response so a halt fired
222
+ # mid-request actually catches the request in flight.
223
+ # Race the sleep against a peer-close detector: if the
224
+ # client closes its end (mid-flight cancel), `reader.read`
225
+ # returns immediately and we bail before responding —
226
+ # mirroring how a real production HTTP server (uvicorn,
227
+ # aiohttp) handles peer-close during a slow handler.
228
+ # Without this, the test fixture would falsely flag
229
+ # leaks because it sat blocked on a fixed-time sleep
230
+ # while the TCP socket had already been hung up by the
231
+ # client.
232
+ if path.split("?", 1)[0] == "/very_slow":
233
+ sleep_task = asyncio.create_task(asyncio.sleep(2.0))
234
+ peer_close = asyncio.create_task(reader.read(1))
235
+ done, pending = await asyncio.wait(
236
+ [sleep_task, peer_close],
237
+ return_when=asyncio.FIRST_COMPLETED,
238
+ )
239
+ for t in pending:
240
+ t.cancel()
241
+ with contextlib.suppress(asyncio.CancelledError, Exception):
242
+ # CodeQL FP (py/ineffectual-statement):
243
+ # awaited to drain the cancelled task; the
244
+ # value is irrelevant.
245
+ await t
246
+ if peer_close in done:
247
+ # Client closed the socket — exit the keepalive
248
+ # loop, server's `finally` will clean up.
249
+ return
250
+
251
+ response = self._dispatch(method, path, body)
252
+ writer.write(response)
253
+ await writer.drain()
254
+ finally:
255
+ self._live_transports.pop(tid, None)
256
+ with contextlib.suppress(Exception):
257
+ writer.close()
258
+ await writer.wait_closed()
259
+
260
+ def _dispatch(self, method: str, path: str, body: bytes) -> bytes:
261
+ """Tiny router. Returns a complete HTTP/1.1 response."""
262
+ # Strip query string if present.
263
+ path_only = path.split("?", 1)[0]
264
+ if method == "GET" and path_only == "/health":
265
+ return _build_response(200, b'{"connected": true}')
266
+ if method == "POST" and path_only == "/echo":
267
+ payload = b'{"received": ' + (body or b'""') + b"}"
268
+ return _build_response(200, payload)
269
+ if method == "GET" and path_only in ("/slow", "/very_slow"):
270
+ # Note: only `/very_slow` actually delays — the connection
271
+ # handler sleeps before calling _dispatch for that path.
272
+ return _build_response(200, b'{"slow": "ok"}')
273
+ return _build_response(404, b'{"error": "not found"}')
274
+
275
+
276
+ def _build_response(status: int, body: bytes) -> bytes:
277
+ reason = {200: "OK", 404: "Not Found"}.get(status, "OK")
278
+ return (
279
+ f"HTTP/1.1 {status} {reason}\r\n"
280
+ f"Content-Type: application/json\r\n"
281
+ f"Content-Length: {len(body)}\r\n"
282
+ f"Connection: keep-alive\r\n"
283
+ f"\r\n"
284
+ ).encode("ascii") + body