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.
- novaflow/cli/__init__.py +20 -0
- novaflow/cli/actions/__init__.py +1 -0
- novaflow/cli/actions/conformance.py +47 -0
- novaflow/cli/actions/lint.py +73 -0
- novaflow/cli/main.py +83 -0
- novaflow/conformance/__init__.py +58 -0
- novaflow/conformance/adapters/__init__.py +16 -0
- novaflow/conformance/adapters/httpx.py +284 -0
- novaflow/conformance/adapters/synthetic.py +124 -0
- novaflow/conformance/harness.py +334 -0
- novaflow/conformance/tracker.py +124 -0
- novaflow/core/__init__.py +21 -0
- novaflow/core/action.py +2405 -0
- novaflow/core/action_context.py +128 -0
- novaflow/core/contract.py +15 -0
- novaflow/core/correlation.py +96 -0
- novaflow/core/crash_visibility.py +55 -0
- novaflow/core/demand_map.py +194 -0
- novaflow/core/exceptions.py +53 -0
- novaflow/core/halt.py +378 -0
- novaflow/core/input_validation.py +29 -0
- novaflow/core/materializers/__init__.py +16 -0
- novaflow/core/materializers/image.py +569 -0
- novaflow/core/materializers/json_data.py +114 -0
- novaflow/core/materializers/pointcloud.py +368 -0
- novaflow/core/nats_backoff.py +66 -0
- novaflow/core/nats_circuit_breaker.py +36 -0
- novaflow/core/object_store.py +301 -0
- novaflow/core/worker_pool.py +1090 -0
- novaflow/lint/__init__.py +6 -0
- novaflow/lint/halt.py +293 -0
- novaflow/settings/__init__.py +3 -0
- novaflow/settings/nova_settings.py +19 -0
- novaflow/settings/settings.py +119 -0
- wandelbots_novaflow-0.1.0.dev1.dist-info/METADATA +305 -0
- wandelbots_novaflow-0.1.0.dev1.dist-info/RECORD +38 -0
- wandelbots_novaflow-0.1.0.dev1.dist-info/WHEEL +4 -0
- wandelbots_novaflow-0.1.0.dev1.dist-info/entry_points.txt +2 -0
novaflow/cli/__init__.py
ADDED
|
@@ -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
|