hyf

Context-aware query service for Radroots
git clone https://radroots.dev/git/hyf.git
Log | Files | Refs | README | LICENSE

commit 985663ff1f52ac5daf36ea128371ffa753702b7d
parent d822883e01144db69b0c73f9c3dc013f65991dca
Author: triesap <tyson@radroots.org>
Date:   Tue, 22 Sep 2026 16:54:30 +0000

tests: prove strict scripted fixtures and parent-owned lifecycle

- Add explicit ordered exchange scripts with strict method/path/header/body
  and scripted status/header/body/delay matching for both providers.
- Enforce bounded lexical framing, exact route/auth validation and bounded
  extra-exchange/missing-exchange accounting with case-specific reasons.
- Add parent-enforced deadlines, bounded output and exception-safe reaping in
  a shared test-only POSIX lifecycle module for stubs and run_stdio_entrypoint.
- Report bounded phase/case/reason plus owned identity for cause-specific
  negative controls; existing 24 D16 stdio signatures remain unchanged.

Diffstat:
Mtests/jev_provider_helper.mojo | 616+++++++++++++++++++++++++++++++++++++++++++++++++++++++++----------------------
Mtests/max_local_process_helper.mojo | 712++++++++++++++++++++++++++++++++++++++++++++++++++++++-------------------------
Atests/parent_lifecycle.mojo | 314+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mtests/stdio_process_helper.mojo | 198++++++++++++++++++++++++++++++++++++++++++++++++++++++-------------------------
Atests/stdio_stall_entrypoint.mojo | 8++++++++
Mtests/strict_fixture.mojo | 658+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++------
Mtests/test_provider_helpers.mojo | 749+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--------------
Mtests/test_repo_local_process_contract.mojo | 27+++++++++++++++++++++++++++
8 files changed, 2661 insertions(+), 621 deletions(-)

diff --git a/tests/jev_provider_helper.mojo b/tests/jev_provider_helper.mojo @@ -1,61 +1,70 @@ -import std.os -from std.ffi import c_int, c_size_t, c_ssize_t, c_uint, external_call -from std.os import Pipe, Process -from std.sys._libc import close - -from flare.net import SocketAddr -from flare.tcp import TcpListener, TcpStream -from flare.utils import usleep - -from strict_fixture import ConnectionReader, FramedRequest, bearer_token - - -def _dup2(oldfd: c_int, newfd: c_int) -> c_int: - return external_call["dup2", c_int](oldfd, newfd) - - -@always_inline -def _fork() -> c_int: - return external_call["fork", c_int]() - - -@always_inline -def _alarm(seconds: c_uint) -> c_uint: - return external_call["alarm", c_uint](seconds) +"""Strict scripted Jev provider HTTP fixture (ADR-0012 D29 / ADR-0014 D33). +Same parent-owned lifecycle and phase/case/reason reporting as the MaxLocal +fixture: parent-enforced startup/read/write/wait deadlines (FX06), bounded +reporting (FX07) and exception-safe reaping (FX08). +""" -@always_inline -def _exit_child(code: c_int): - _ = external_call["_exit", c_int](code) - - -def _write(fd: Int, text: String): - _ = external_call["write", c_ssize_t]( - fd, text.as_bytes().unsafe_ptr(), c_size_t(text.byte_length()) - ) - - -def _read_pipe_line(mut pipe: Pipe) raises -> String: - var buffer = InlineArray[Byte, 1](fill=0) - var output = String("") - while True: - var read = pipe.read_bytes(Span(buffer)) - if read == 0: - break - var chunk = String( - from_utf8=Span(ptr=buffer.unsafe_ptr(), length=Int(read)) - ) - if chunk == "\n": - break - output += chunk - return output^ - - -def _json_string(value: String) -> String: - return '"' + value.replace("\\", "\\\\").replace('"', '\\"') + '"' +from std.collections import List +from flare.net import SocketAddr +from flare.tcp import TcpListener +from flare.utils import usleep -def _analysis() -> String: +from parent_lifecycle import ( + FIXTURE_DEFAULT_DEADLINE_MS, + TERMINATION_GRACE_MS, + ProcessStatus, + child_exit, + close_fd, + dup2_fd, + fork_pid, + make_pipe, + read_line_bounded, + set_alarm, + terminate_owned, + wait_bounded, + write_raw, +) +from strict_fixture import ( + STRICT_MAX_REPORT_BYTES, + STRICT_COMPLETION_GRACE_MS, + ConnectionReader, + ExchangeScript, + FramedRequest, + ServeReport, + authorization_reason, + exchange_script, + parse_report, + render_response, + report_line, + serve_scripts, + validate_convenience, + verify_exchange, +) + + +comptime STUB_ALARM_SECONDS = 20 +comptime JEV_INTENDED_PATH = "/v1/systemone" + + +def allowed_jev_paths() -> List[String]: + var paths = List[String]() + paths.append(JEV_INTENDED_PATH) + return paths^ + + +def allowed_jev_methods() -> List[String]: + var methods = List[String]() + methods.append("POST") + return methods^ + + +def require_bearer_for(mode: String) -> Bool: + return False + + +def analysis() -> String: return ( '{"model":"jev-1.13.0","answers":{' '"supply_status":{"type":"choice","choice":"offered","probabilities":{"offered":1.0,"forecast":0.0,"unclear":0.0},"confidence":1.0},' @@ -65,124 +74,315 @@ def _analysis() -> String: ) -def _response(status: Int, body: String) -> String: - var reason = "OK" - if status == 401: - reason = "Unauthorized" - elif status == 429: - reason = "Too Many Requests" - elif status == 500: - reason = "Internal Server Error" - elif status == 529: - reason = "Overloaded" - return ( - "HTTP/1.1 " - + String(status) - + " " - + reason - + "\r\ncontent-type: application/json\r\ncontent-length: " - + String(body.byte_length()) - + "\r\nconnection: close\r\n\r\n" - + body - ) - - -def _send(mut reader: ConnectionReader, status: Int, body: String) raises: - reader.write_all(_response(status, body)) +def _status(mode: String) -> Int: + if mode == "rate_limit": + return 429 + if mode == "server_error": + return 500 + if mode == "overloaded": + return 529 + if mode == "auth": + return 401 + return 200 -def _send_raw(mut reader: ConnectionReader, response: String) raises: - reader.write_all(response) - - -def _handle( - mut reader: ConnectionReader, mode: String, framed: FramedRequest -) raises: - var path = framed.path - if mode == "echo_authorization": - var token = bearer_token(framed.headers_raw) - if token == "": - _send(reader, 401, '{"error":{"message":"missing authorization"}}') - else: - _send(reader, 200, '{"authorization":' + _json_string(token) + "}") - return - if path != "/v1/systemone": - _send(reader, 404, '{"error":{"message":"not found"}}') - return - - if mode == "ok": - _send(reader, 200, _analysis()) - elif mode == "rate_limit": - _send(reader, 429, '{"error":{"message":"slow down"}}') - elif mode == "server_error": - _send(reader, 500, '{"error":{"message":"boom"}}') - elif mode == "overloaded": - _send(reader, 529, '{"error":{"message":"overloaded"}}') - elif mode == "auth": - _send(reader, 401, '{"error":{"message":"bad key"}}') - elif mode == "malformed_json": - _send(reader, 200, "not json") - elif mode == "model_mismatch": - _send(reader, 200, _analysis().replace("jev-1.13.0", "jev-other")) - elif mode == "truncated": - var truncated = ( +def _raw_response(mode: String) -> String: + if mode == "truncated": + return ( "HTTP/1.1 200 OK\r\ncontent-type: application/json\r\n" "content-length: 999\r\nconnection: close\r\n\r\n" '{"model":"jev' ) - reader.write_all(truncated) - elif mode == "redirect": - reader.write_all( + if mode == "redirect": + return ( "HTTP/1.1 302 Found\r\nlocation:" " http://127.0.0.1:1/steal\r\ncontent-length:" " 0\r\nconnection: close\r\n\r\n" ) - elif mode == "slow": - usleep(2_000_000) - _send(reader, 200, _analysis()) + return "" + + +def _delay_ms(mode: String) -> Int: + if mode == "slow": + return 2000 + return 0 + + +def _body(mode: String) -> String: + if mode == "ok" or mode == "slow": + return analysis() + if mode == "rate_limit": + return '{"error":{"message":"slow down"}}' + if mode == "server_error": + return '{"error":{"message":"boom"}}' + if mode == "overloaded": + return '{"error":{"message":"overloaded"}}' + if mode == "auth": + return '{"error":{"message":"bad key"}}' + if mode == "malformed_json": + return "not json" + if mode == "model_mismatch": + return analysis().replace("jev-1.13.0", "jev-other") + return '{"error":{"message":"unsupported_mode"}}' + + +def _build_script(mode: String, framed: FramedRequest) -> ExchangeScript: + var script = exchange_script( + mode, framed.method, JEV_INTENDED_PATH, _status(mode), "" + ) + script.delay_ms = _delay_ms(mode) + script.close_connection = True + script.require_bearer = require_bearer_for(mode) + var raw = _raw_response(mode) + if raw != "": + script.raw_response = raw + elif mode == "echo_authorization": + var auth = authorization_reason(framed.headers_raw, True) + if auth != "": + script.status = 401 + script.response_body = '{"error":{"message":"' + auth + '"}}' + else: + script.echo_authorization = True else: - _send(reader, 500, '{"error":{"message":"unsupported_mode"}}') + script.response_body = _body(mode) + return script^ -def _serve(port: Int, mode: String, requests: Int) raises: +def serve_jev(port: Int, mode: String, requests: Int) raises -> ServeReport: + var allowed = allowed_jev_paths() + var methods = allowed_jev_methods() var listener = TcpListener.bind(SocketAddr.localhost(UInt16(port))) var actual_port = Int(listener.local_addr().port) - _write(1, "ready " + String(actual_port) + "\n") + write_raw(1, "ready " + String(actual_port) + "\n") var request_count = 0 - while request_count < requests: - var stream = listener.accept() - var reader = ConnectionReader(stream^) + var connection_count = 0 + try: while request_count < requests: - var framed = reader.read() - if not framed.ok: - if framed.error == "empty": + var stream = listener.accept() + connection_count += 1 + var reader = ConnectionReader(stream^) + while request_count < requests: + var framed = reader.read() + if not framed.ok: + if framed.error == "empty": + if request_count < requests: + return ServeReport( + False, + "accounting", + mode, + "missing_exchanges", + request_count, + connection_count, + ) + break + return ServeReport( + False, + "read", + mode, + framed.error, + request_count, + connection_count, + ) + var reason = validate_convenience( + framed, allowed, methods, False + ) + if reason != "": + return ServeReport( + False, + "exchange", + mode, + reason, + request_count, + connection_count, + ) + var next_index = request_count + 1 + var script = _build_script(mode, framed) + var verify = verify_exchange(script, framed) + if verify != "": + return ServeReport( + False, + "exchange", + mode, + verify, + request_count, + connection_count, + ) + if next_index == requests: + var extra = reader.probe_completion( + STRICT_COMPLETION_GRACE_MS + ) + if extra != "": + return ServeReport( + False, + "accounting", + mode, + extra, + request_count, + connection_count, + ) + request_count = next_index + if script.delay_ms > 0: + usleep(script.delay_ms * 1000) + reader.write_all( + render_response( + script, + framed.headers_raw, + request_count, + connection_count, + ) + ) + if script.close_connection: break - raise Error("strict fixture framing error: " + framed.error) - request_count += 1 - _handle(reader, mode, framed) - if not framed.keep_alive: - break - listener.close() + if request_count < requests: + return ServeReport( + False, + "accounting", + mode, + "missing_exchanges", + request_count, + connection_count, + ) + return ServeReport( + True, "complete", mode, "ok", request_count, connection_count + ) + except: + return ServeReport( + False, "read", mode, "io_error", request_count, connection_count + ) + + +# ── Parent side ───────────────────────────────────────────────────────────── struct SpawnedJevStub(Movable): var pid: Int - - def __init__(out self, pid: Int): + var _report_fd: Int + var _reaped: Bool + var _ok: Bool + var _phase: String + var _case: String + var _reason: String + var _requests: Int + var _connections: Int + + def __init__(out self, pid: Int, report_fd: Int): self.pid = pid + self._report_fd = report_fd + self._reaped = False + self._ok = False + self._phase = "pending" + self._case = "-" + self._reason = "not_reaped" + self._requests = 0 + self._connections = 0 + + def ok(self) -> Bool: + return self._ok + + def phase(self) -> String: + return String(self._phase) + + def failure_case(self) -> String: + return String(self._case) + + def reason(self) -> String: + return String(self._reason) + + def request_count(self) -> Int: + return self._requests + + def connection_count(self) -> Int: + return self._connections + + def describe(self) -> String: + return ( + "phase=" + + self._phase + + " case=" + + self._case + + " reason=" + + self._reason + + " requests=" + + String(self._requests) + + " connections=" + + String(self._connections) + ) - def wait(mut self) raises: - var process = Process(self.pid) - var status = process.wait() - if not status.exit_code or status.exit_code.value() != 0: - raise Error("jev stub exited unexpectedly") + def status(self) -> ProcessStatus: + return wait_bounded(self.pid, 0) + + def _store( + mut self, + ok: Bool, + phase: String, + case_label: String, + reason: String, + requests: Int, + connections: Int, + ): + self._ok = ok + self._phase = String(phase) + self._case = String(case_label) + self._reason = String(reason) + self._requests = requests + self._connections = connections + + def reap(mut self): + if self._reaped: + return + var st = wait_bounded(self.pid, FIXTURE_DEFAULT_DEADLINE_MS) + var report_text = "" + if st.state == "running": + var term = terminate_owned(self.pid, TERMINATION_GRACE_MS) + self._store(False, "watchdog", "-", "timeout", 0, 0) + if not term.reaped(): + self._reason = "unreaped:" + term.describe() + self._reaped = True + close_fd(self._report_fd) + return + if self._report_fd >= 0: + try: + report_text = read_line_bounded( + self._report_fd, STRICT_MAX_REPORT_BYTES, 1000 + ) + except: + report_text = "" + if report_text.startswith("result "): + var parsed = parse_report(report_text) + self._store( + parsed.ok, + parsed.phase, + parsed.case_label, + parsed.reason, + parsed.requests, + parsed.connections, + ) + elif st.exited and st.exit_code == 0: + self._store(True, "complete", "-", "ok", 0, 0) + elif st.exited: + self._store( + False, "startup", "-", "exit_" + String(st.exit_code), 0, 0 + ) + else: + self._store( + False, "watchdog", "-", "signal_" + String(st.signal), 0, 0 + ) + self._reaped = True + close_fd(self._report_fd) + def wait(mut self) raises: + self.reap() + if not self._ok: + raise Error("fixture-failure " + self.describe()) -def reserve_jev_port() raises -> Int: - var listener = TcpListener.bind(SocketAddr.localhost(0)) - var port = Int(listener.local_addr().port) - listener.close() - return port + def terminate(mut self) raises: + if self._reaped: + return + var st = terminate_owned(self.pid, TERMINATION_GRACE_MS) + if not st.reaped(): + raise Error("lifecycle: owned child not reaped: " + st.describe()) + self._reaped = True + close_fd(self._report_fd) @fieldwise_init @@ -191,6 +391,13 @@ struct SpawnedJevStubAuto(Movable): var stub: SpawnedJevStub +def reserve_jev_port() raises -> Int: + var listener = TcpListener.bind(SocketAddr.localhost(0)) + var port = Int(listener.local_addr().port) + listener.close() + return port + + def spawn_jev_stub_auto( mode: String, requests: Int ) raises -> SpawnedJevStubAuto: @@ -204,36 +411,109 @@ def spawn_jev_stub( return started.stub^ +def serve_jev_scripted( + port: Int, var scripts: List[ExchangeScript] +) raises -> ServeReport: + var listener = TcpListener.bind(SocketAddr.localhost(UInt16(port))) + var actual_port = Int(listener.local_addr().port) + write_raw(1, "ready " + String(actual_port) + "\n") + return serve_scripts(listener, scripts^, "scripted") + + +def _read_ready_line(fd: Int) -> String: + try: + return read_line_bounded(fd, 256, FIXTURE_DEFAULT_DEADLINE_MS) + except: + return "" + + +def spawn_jev_scripted_auto( + var scripts: List[ExchangeScript], +) raises -> SpawnedJevStubAuto: + return _spawn_jev_scripted(0, scripts^) + + +def _spawn_jev_scripted( + port: Int, var scripts: List[ExchangeScript] +) raises -> SpawnedJevStubAuto: + var total = len(scripts) + var pipe = make_pipe() + var pid = fork_pid() + if pid == 0: + if dup2_fd(pipe.write_fd, 1) < 0: + child_exit(126) + close_fd(pipe.read_fd) + close_fd(pipe.write_fd) + _ = set_alarm(STUB_ALARM_SECONDS) + try: + var report = serve_jev_scripted(port, scripts^) + write_raw(1, report_line(report) + "\n") + child_exit(0 if report.ok else 125) + except: + var failed = ServeReport( + False, "startup", "scripted", "serve_failed", 0, 0 + ) + write_raw(1, report_line(failed) + "\n") + child_exit(125) + close_fd(pipe.write_fd) + var ready_line = _read_ready_line(pipe.read_fd) + if not ready_line.startswith("ready"): + var st = terminate_owned(pid, TERMINATION_GRACE_MS) + close_fd(pipe.read_fd) + raise Error( + "jev stub failed to report ready (" + + ready_line + + " / " + + st.describe() + + ")" + ) + var reported_port = port + var space = ready_line.find(" ") + if space >= 0: + reported_port = Int(String(ready_line[byte = space + 1 :])) + _ = total + return SpawnedJevStubAuto( + port=reported_port, stub=SpawnedJevStub(pid, pipe.read_fd) + ) + + def _spawn_jev_stub( port: Int, mode: String, requests: Int ) raises -> SpawnedJevStubAuto: - var stdout_pipe = Pipe() - var stdout_read_fd = c_int(stdout_pipe.fd_in.value().value) - var stdout_write_fd = c_int(stdout_pipe.fd_out.value().value) - var pid = _fork() - if pid < 0: - raise Error("failed to spawn jev stub") + var pipe = make_pipe() + var pid = fork_pid() if pid == 0: - if _dup2(stdout_write_fd, 1) < 0: - _exit_child(c_int(126)) - _ = close(stdout_read_fd) - _ = close(stdout_write_fd) - _ = _alarm(c_uint(20)) + if dup2_fd(pipe.write_fd, 1) < 0: + child_exit(126) + close_fd(pipe.read_fd) + close_fd(pipe.write_fd) + _ = set_alarm(STUB_ALARM_SECONDS) try: - _serve(port, mode, requests) - _exit_child(c_int(0)) + var report = serve_jev(port, mode, requests) + write_raw(1, report_line(report) + "\n") + child_exit(0 if report.ok else 125) except: - _exit_child(c_int(125)) - stdout_pipe.set_input_only() - var ready_line = _read_pipe_line(stdout_pipe) + var failed = ServeReport( + False, "startup", mode, "serve_failed", 0, 0 + ) + write_raw(1, report_line(failed) + "\n") + child_exit(125) + close_fd(pipe.write_fd) + var ready_line = _read_ready_line(pipe.read_fd) if not ready_line.startswith("ready"): - stdout_pipe.set_output_only() - var process = Process(Int(pid)) - _ = process.wait() - raise Error("jev stub failed to report ready") + var st = terminate_owned(pid, TERMINATION_GRACE_MS) + close_fd(pipe.read_fd) + raise Error( + "jev stub failed to report ready (" + + ready_line + + " / " + + st.describe() + + ")" + ) var reported_port = port var space = ready_line.find(" ") if space >= 0: reported_port = Int(String(ready_line[byte = space + 1 :])) - stdout_pipe.set_output_only() - return SpawnedJevStubAuto(port=reported_port, stub=SpawnedJevStub(Int(pid))) + return SpawnedJevStubAuto( + port=reported_port, stub=SpawnedJevStub(pid, pipe.read_fd) + ) diff --git a/tests/max_local_process_helper.mojo b/tests/max_local_process_helper.mojo @@ -1,66 +1,84 @@ -from std.ffi import c_int, c_size_t, c_ssize_t, c_uint, external_call -from std.os import Pipe, Process -from std.sys._libc import close +"""Strict scripted MaxLocal HTTP fixture (ADR-0012 D29 / ADR-0014 D33). + +Owned by the parent test process: startup, read, write and wait all have +parent-enforced deadlines (FX06) and cleanup is exception-safe (FX08). Fixture +verification failures are reported with a bounded phase/case/reason so tests +can distinguish intended rejections from compiler/loader/startup/signal +failures (FX07). +""" + +from std.collections import List from flare.net import SocketAddr from flare.tcp import TcpListener -from flare.tcp import TcpStream from flare.utils import usleep -from strict_fixture import ConnectionReader, FramedRequest, bearer_token - - -def _dup2(oldfd: c_int, newfd: c_int) -> c_int: - return external_call["dup2", c_int](oldfd, newfd) - - -@always_inline -def _fork() -> c_int: - return external_call["fork", c_int]() - - -@always_inline -def _kill(pid: c_int, sig: c_int) -> c_int: - return external_call["kill", c_int](pid, sig) - - -@always_inline -def _alarm(seconds: c_uint) -> c_uint: - return external_call["alarm", c_uint](seconds) - - -@always_inline -def _exit_child(code: c_int): - _ = external_call["_exit", c_int](code) +from parent_lifecycle import ( + FIXTURE_DEFAULT_DEADLINE_MS, + TERMINATION_GRACE_MS, + ProcessStatus, + child_exit, + close_fd, + dup2_fd, + fork_pid, + make_pipe, + read_line_bounded, + set_alarm, + terminate_owned, + wait_bounded, + write_raw, +) +from strict_fixture import ( + STRICT_MAX_REPORT_BYTES, + json_escape, + STRICT_COMPLETION_GRACE_MS, + ConnectionReader, + ExchangeScript, + FramedRequest, + ServeReport, + authorization_reason, + exchange_script, + parse_report, + render_response, + report_line, + serve_scripts, + validate_convenience, + verify_exchange, +) + + +comptime STUB_ALARM_SECONDS = 20 + + +def allowed_max_local_paths() -> List[String]: + var paths = List[String]() + paths.append("/health") + paths.append("/v1/chat/completions") + return paths^ + + +def allowed_max_local_methods() -> List[String]: + var methods = List[String]() + methods.append("GET") + methods.append("POST") + return methods^ + + +def require_bearer_for(mode: String) -> Bool: + # Convenience modes never bypass route/method validation; the + # echo_authorization mode validates the exact Authorization header inside + # its own handler and answers 401 when it is missing/duplicated/spoofed. + return False + + +# ── Scripted response bodies ──────────────────────────────────────────────── -def _write(fd: Int, text: String): - _ = external_call["write", c_ssize_t]( - fd, text.as_bytes().unsafe_ptr(), c_size_t(text.byte_length()) - ) - - -def _read_pipe_line(mut pipe: Pipe) raises -> String: - var buffer = InlineArray[Byte, 1](fill=0) - var output = String("") - while True: - var read = pipe.read_bytes(Span(buffer)) - if read == 0: - break - var chunk = String( - from_utf8=Span(ptr=buffer.unsafe_ptr(), length=Int(read)) - ) - if chunk == "\n": - break - output += chunk - return output^ - - -def _json_string(value: String) -> String: - return '"' + value.replace("\\", "\\\\").replace('"', '\\"') + '"' +def _chat_completion(body: String) -> String: + return '{"choices":[{"message":{"content":' + json_escape(body) + "}}]}" -def _query_rewrite_analysis() -> String: +def query_rewrite_analysis() -> String: return ( '{"original_text":"local apples pickup weekend",' '"normalized_text":"local apples pickup weekend",' @@ -76,182 +94,389 @@ def _query_rewrite_analysis() -> String: ) -def _chat_completion(body: String) -> String: - return '{"choices":[{"message":{"content":' + _json_string(body) + "}}]}" - - -def _response(status: Int, body: String) -> String: - var reason = "OK" - if status == 401: - reason = "Unauthorized" - elif status == 404: - reason = "Not Found" - elif status == 500: - reason = "Internal Server Error" - elif status == 503: - reason = "Service Unavailable" +def _schema_invalid_analysis() -> String: return ( - "HTTP/1.1 " - + String(status) - + " " - + reason - + "\r\ncontent-type: application/json\r\ncontent-length: " - + String(body.byte_length()) - + "\r\nconnection: close\r\n\r\n" - + body + '{"original_text":"local apples pickup weekend",' + '"normalized_text":"local apples pickup weekend",' + '"query_terms":["apples","pickup","weekend"],' + '"normalization_signals":["lowercase","local_intent_detected"],' + '"ranking_hints":["prefer_local_results","prefer_pickup"],' + '"extracted_filters":{' + '"local_intent":true,' + '"fulfillment":"pickup",' + '"time_window":"weekend"' + "}}" ) -def _send(mut reader: ConnectionReader, status: Int, body: String) raises: - reader.write_all(_response(status, body)) - - -def _send_raw(mut reader: ConnectionReader, response: String) raises: - reader.write_all(response) - - -def _handle_health(mut reader: ConnectionReader, mode: String) raises: +def _health_status(mode: String) -> Int: if mode == "health_non_2xx": - _send(reader, 503, '{"status":"unavailable"}') - elif mode == "health_timeout": - usleep(1_000_000) - elif mode == "health_malformed_http": - _send_raw(reader, "not an http response\r\n\r\n") - elif mode == "query_rewrite_remaining_deadline_timeout": - usleep(200_000) - _send(reader, 200, '{"status":"ok"}') - else: - _send(reader, 200, '{"status":"ok"}') - - -def _handle_chat_completions(mut reader: ConnectionReader, mode: String) raises: - if mode == "query_rewrite_ok": - _send(reader, 200, _chat_completion(_query_rewrite_analysis())) - elif mode == "query_rewrite_non_2xx": - _send(reader, 503, '{"error":{"message":"provider unavailable"}}') - elif mode == "query_rewrite_invalid_json": - _send(reader, 200, '{"choices":[{"message":{"content":"not json"}}]}') - elif mode == "query_rewrite_schema_invalid": - var body = ( - '{"original_text":"local apples pickup weekend",' - '"normalized_text":"local apples pickup weekend",' - '"query_terms":["apples","pickup","weekend"],' - '"normalization_signals":["lowercase","local_intent_detected"],' - '"ranking_hints":["prefer_local_results","prefer_pickup"],' - '"extracted_filters":{' - '"local_intent":true,' - '"fulfillment":"pickup",' - '"time_window":"weekend"' - "}}" + return 503 + return 200 + + +def _chat_status(mode: String) -> Int: + if mode == "query_rewrite_non_2xx": + return 503 + return 200 + + +def _raw_response(mode: String, path: String) -> String: + if path == "/health" and mode == "health_malformed_http": + return "not an http response\r\n\r\n" + if ( + path == "/v1/chat/completions" + and mode == "query_rewrite_malformed_http" + ): + return "not an http response\r\n\r\n" + return "" + + +def _delay_ms(mode: String, path: String) -> Int: + if path == "/health" and mode == "health_timeout": + return 1000 + if path == "/health" and mode == "query_rewrite_remaining_deadline_timeout": + return 200 + if path == "/v1/chat/completions": + if mode == "query_rewrite_timeout": + return 2000 + if mode == "query_rewrite_remaining_deadline_timeout": + return 400 + if mode == "stall": + return 30000 + return 0 + + +def _chat_body( + mode: String, request_index: Int, connection_index: Int +) -> String: + if mode == "count_requests": + return ( + '{"request_index":' + + String(request_index) + + ',"connection_index":' + + String(connection_index) + + "}" ) - _send(reader, 200, _chat_completion(body)) - elif mode == "query_rewrite_top_level_string": - _send(reader, 200, '"not object"') - elif mode == "query_rewrite_top_level_array": - _send(reader, 200, "[]") - elif mode == "query_rewrite_top_level_null": - _send(reader, 200, "null") - elif mode == "query_rewrite_empty_choices": - _send(reader, 200, '{"choices":[]}') - elif mode == "query_rewrite_missing_content": - _send(reader, 200, '{"choices":[{"message":{}}]}') - elif mode == "query_rewrite_error_payload": - _send(reader, 200, '{"error":{"message":"provider refusal"}}') - elif mode == "query_rewrite_timeout": - usleep(2_000_000) - _send(reader, 200, _chat_completion(_query_rewrite_analysis())) - elif mode == "query_rewrite_remaining_deadline_timeout": - usleep(400_000) - _send(reader, 200, _chat_completion(_query_rewrite_analysis())) - elif mode == "query_rewrite_malformed_http": - _send_raw(reader, "not an http response\r\n\r\n") - else: - _send(reader, 500, '{"error":"unsupported_mode"}') + if mode == "query_rewrite_ok": + return _chat_completion(query_rewrite_analysis()) + if mode == "query_rewrite_non_2xx": + return '{"error":{"message":"provider unavailable"}}' + if mode == "query_rewrite_invalid_json": + return '{"choices":[{"message":{"content":"not json"}}]}' + if mode == "query_rewrite_schema_invalid": + return _chat_completion(_schema_invalid_analysis()) + if mode == "query_rewrite_top_level_string": + return '"not object"' + if mode == "query_rewrite_top_level_array": + return "[]" + if mode == "query_rewrite_top_level_null": + return "null" + if mode == "query_rewrite_empty_choices": + return '{"choices":[]}' + if mode == "query_rewrite_missing_content": + return '{"choices":[{"message":{}}]}' + if mode == "query_rewrite_error_payload": + return '{"error":{"message":"provider refusal"}}' + if mode in ( + "query_rewrite_timeout", + "query_rewrite_remaining_deadline_timeout", + ): + return _chat_completion(query_rewrite_analysis()) + return '{"error":"unsupported_mode"}' + + +def _health_body(mode: String) -> String: + if mode == "health_non_2xx": + return '{"status":"unavailable"}' + return '{"status":"ok"}' -def _handle_framed( - mut reader: ConnectionReader, +def _build_script( mode: String, + framed: FramedRequest, request_index: Int, connection_index: Int, - framed: FramedRequest, -) raises: +) -> ExchangeScript: var path = framed.path - if mode == "echo_body_bytes": - _send( - reader, - 200, - '{"received_bytes":' + String(framed.body.byte_length()) + "}", - ) - elif mode == "count_requests": - _send( - reader, - 200, - '{"request_index":' - + String(request_index) - + ',"connection_index":' - + String(connection_index) - + "}", - ) - elif mode == "echo_authorization": - var token = bearer_token(framed.headers_raw) - if token == "": - _send(reader, 401, '{"error":"missing_authorization"}') + var status = _chat_status(mode) + if path == "/health": + status = _health_status(mode) + var script = exchange_script(mode, framed.method, path, status, "") + script.delay_ms = _delay_ms(mode, path) + script.close_connection = True + script.require_bearer = require_bearer_for(mode) + var raw = _raw_response(mode, path) + if raw != "": + script.raw_response = raw + elif mode == "echo_authorization" and path == "/v1/chat/completions": + var auth = authorization_reason(framed.headers_raw, True) + if auth != "": + script.status = 401 + script.response_body = '{"error":"' + auth + '"}' else: - _send(reader, 200, '{"authorization":' + _json_string(token) + "}") - elif mode == "stall": - usleep(30_000_000) + script.echo_authorization = True + elif mode == "echo_body_bytes" and path == "/v1/chat/completions": + script.response_body = ( + '{"received_bytes":' + String(framed.body.byte_length()) + "}" + ) elif path == "/health": - _handle_health(reader, mode) - elif path == "/v1/chat/completions": - _handle_chat_completions(reader, mode) + script.response_body = _health_body(mode) else: - _send(reader, 404, '{"error":"not_found"}') + script.response_body = _chat_body(mode, request_index, connection_index) + return script^ + + +# ── Child serve loop ──────────────────────────────────────────────────────── -def _serve_max_local_stub(port: Int, mode: String, requests: Int) raises: +def serve_max_local( + port: Int, mode: String, requests: Int +) raises -> ServeReport: + var allowed = allowed_max_local_paths() + var methods = allowed_max_local_methods() var listener = TcpListener.bind(SocketAddr.localhost(UInt16(port))) var actual_port = Int(listener.local_addr().port) - _write(1, "ready " + String(actual_port) + "\n") + write_raw(1, "ready " + String(actual_port) + "\n") var request_count = 0 var connection_count = 0 - while request_count < requests: - var stream = listener.accept() - connection_count += 1 - var reader = ConnectionReader(stream^) + try: while request_count < requests: - var framed = reader.read() - if not framed.ok: - if framed.error == "empty": + var stream = listener.accept() + connection_count += 1 + var reader = ConnectionReader(stream^) + while request_count < requests: + var framed = reader.read() + if not framed.ok: + if framed.error == "empty": + if request_count < requests: + return ServeReport( + False, + "accounting", + mode, + "missing_exchanges", + request_count, + connection_count, + ) + break + return ServeReport( + False, + "read", + mode, + framed.error, + request_count, + connection_count, + ) + var reason = validate_convenience( + framed, allowed, methods, require_bearer_for(mode) + ) + if reason != "": + return ServeReport( + False, + "exchange", + mode, + reason, + request_count, + connection_count, + ) + var next_index = request_count + 1 + var script = _build_script( + mode, framed, next_index, connection_count + ) + var verify = verify_exchange(script, framed) + if verify != "": + return ServeReport( + False, + "exchange", + mode, + verify, + request_count, + connection_count, + ) + if next_index == requests: + var extra = reader.probe_completion( + STRICT_COMPLETION_GRACE_MS + ) + if extra != "": + return ServeReport( + False, + "accounting", + mode, + extra, + request_count, + connection_count, + ) + request_count = next_index + if script.delay_ms > 0: + usleep(script.delay_ms * 1000) + reader.write_all( + render_response( + script, + framed.headers_raw, + request_count, + connection_count, + ) + ) + if script.close_connection: break - raise Error("strict fixture framing error: " + framed.error) - request_count += 1 - _handle_framed( - reader, mode, request_count, connection_count, framed + if request_count < requests: + return ServeReport( + False, + "accounting", + mode, + "missing_exchanges", + request_count, + connection_count, ) - if not framed.keep_alive: - break - listener.close() + return ServeReport( + True, "complete", mode, "ok", request_count, connection_count + ) + except: + return ServeReport( + False, "read", mode, "io_error", request_count, connection_count + ) + + +# ── Parent side ───────────────────────────────────────────────────────────── struct SpawnedMaxLocalStub(Movable): var pid: Int var port: Int - - def __init__(out self, pid: Int, port: Int): + var _report_fd: Int + var _reaped: Bool + var _ok: Bool + var _phase: String + var _case: String + var _reason: String + var _requests: Int + var _connections: Int + + def __init__(out self, pid: Int, port: Int, report_fd: Int): self.pid = pid self.port = port + self._report_fd = report_fd + self._reaped = False + self._ok = False + self._phase = "pending" + self._case = "-" + self._reason = "not_reaped" + self._requests = 0 + self._connections = 0 + + def ok(self) -> Bool: + return self._ok + + def phase(self) -> String: + return String(self._phase) + + def failure_case(self) -> String: + return String(self._case) + + def reason(self) -> String: + return String(self._reason) + + def request_count(self) -> Int: + return self._requests + + def connection_count(self) -> Int: + return self._connections + + def describe(self) -> String: + return ( + "phase=" + + self._phase + + " case=" + + self._case + + " reason=" + + self._reason + + " requests=" + + String(self._requests) + + " connections=" + + String(self._connections) + ) + + def status(self) -> ProcessStatus: + return wait_bounded(self.pid, 0) + + def _store( + mut self, + ok: Bool, + phase: String, + case_label: String, + reason: String, + requests: Int, + connections: Int, + ): + self._ok = ok + self._phase = String(phase) + self._case = String(case_label) + self._reason = String(reason) + self._requests = requests + self._connections = connections + + def reap(mut self): + """Reap the owned child and decode its bounded report (never raises).""" + if self._reaped: + return + var st = wait_bounded(self.pid, FIXTURE_DEFAULT_DEADLINE_MS) + var report_text = "" + if st.state == "running": + var term = terminate_owned(self.pid, TERMINATION_GRACE_MS) + self._store(False, "watchdog", "-", "timeout", 0, 0) + if not term.reaped(): + self._reason = "unreaped:" + term.describe() + self._reaped = True + close_fd(self._report_fd) + return + if self._report_fd >= 0: + try: + report_text = read_line_bounded( + self._report_fd, STRICT_MAX_REPORT_BYTES, 1000 + ) + except: + report_text = "" + if report_text.startswith("result "): + var parsed = parse_report(report_text) + self._store( + parsed.ok, + parsed.phase, + parsed.case_label, + parsed.reason, + parsed.requests, + parsed.connections, + ) + elif st.exited and st.exit_code == 0: + self._store(True, "complete", "-", "ok", 0, 0) + elif st.exited: + self._store( + False, + "startup", + "-", + "exit_" + String(st.exit_code), + 0, + 0, + ) + else: + self._store( + False, "watchdog", "-", "signal_" + String(st.signal), 0, 0 + ) + self._reaped = True + close_fd(self._report_fd) def wait(mut self) raises: - var process = Process(self.pid) - var status = process.wait() - if not status.exit_code or status.exit_code.value() != 0: - raise Error("max_local stub exited unexpectedly") + self.reap() + if not self._ok: + raise Error("fixture-failure " + self.describe()) def terminate(mut self) raises: - _ = _kill(c_int(self.pid), c_int(15)) - var process = Process(self.pid) - _ = process.wait() + if self._reaped: + return + var st = terminate_owned(self.pid, TERMINATION_GRACE_MS) + if not st.reaped(): + raise Error("lifecycle: owned child not reaped: " + st.describe()) + self._reaped = True + close_fd(self._report_fd) def reserve_loopback_port() raises -> Int: @@ -261,41 +486,88 @@ def reserve_loopback_port() raises -> Int: return port +def _serve_max_local_for( + port: Int, + var scripts: List[ExchangeScript], + mode: String, + requests: Int, + scripted: Bool, +) raises -> ServeReport: + if scripted: + return serve_max_local_scripted(port, scripts^) + return serve_max_local(port, mode, requests) + + +def _read_ready_line(fd: Int) -> String: + try: + return read_line_bounded(fd, 256, FIXTURE_DEFAULT_DEADLINE_MS) + except: + return "" + + +def serve_max_local_scripted( + port: Int, var scripts: List[ExchangeScript] +) raises -> ServeReport: + var listener = TcpListener.bind(SocketAddr.localhost(UInt16(port))) + var actual_port = Int(listener.local_addr().port) + write_raw(1, "ready " + String(actual_port) + "\n") + return serve_scripts(listener, scripts^, "scripted") + + def spawn_max_local_stub( port: Int, mode: String, requests: Int ) raises -> SpawnedMaxLocalStub: - var stdout_pipe = Pipe() - var stdout_read_fd = c_int(stdout_pipe.fd_in.value().value) - var stdout_write_fd = c_int(stdout_pipe.fd_out.value().value) + var scripts = List[ExchangeScript]() + return _spawn_max_local(port, scripts^, mode, requests, False) - var pid = _fork() - if pid < 0: - raise Error("failed to spawn max_local stub") +def spawn_max_local_scripted( + port: Int, var scripts: List[ExchangeScript] +) raises -> SpawnedMaxLocalStub: + return _spawn_max_local(port, scripts^, "scripted", len(scripts), True) + + +def _spawn_max_local( + port: Int, + var scripts: List[ExchangeScript], + mode: String, + requests: Int, + scripted: Bool, +) raises -> SpawnedMaxLocalStub: + var pipe = make_pipe() + var pid = fork_pid() if pid == 0: - if _dup2(stdout_write_fd, 1) < 0: - _exit_child(c_int(126)) - _ = close(stdout_read_fd) - _ = close(stdout_write_fd) - _ = _alarm(c_uint(20)) + if dup2_fd(pipe.write_fd, 1) < 0: + child_exit(126) + close_fd(pipe.read_fd) + close_fd(pipe.write_fd) + _ = set_alarm(STUB_ALARM_SECONDS) try: - _serve_max_local_stub(port, mode, requests) - _exit_child(c_int(0)) + var report = _serve_max_local_for( + port, scripts^, mode, requests, scripted + ) + write_raw(1, report_line(report) + "\n") + child_exit(0 if report.ok else 125) except: - _exit_child(c_int(125)) - - stdout_pipe.set_input_only() - var ready_line = _read_pipe_line(stdout_pipe) + var failed = ServeReport( + False, "startup", mode, "serve_failed", 0, 0 + ) + write_raw(1, report_line(failed) + "\n") + child_exit(125) + close_fd(pipe.write_fd) + var ready_line = _read_ready_line(pipe.read_fd) if not ready_line.startswith("ready"): - stdout_pipe.set_output_only() - var process = Process(Int(pid)) - _ = process.wait() - raise Error("max_local stub failed to report ready") - + var st = terminate_owned(pid, TERMINATION_GRACE_MS) + close_fd(pipe.read_fd) + raise Error( + "max_local stub failed to report ready (" + + ready_line + + " / " + + st.describe() + + ")" + ) var reported_port = port var space = ready_line.find(" ") if space >= 0: reported_port = Int(String(ready_line[byte = space + 1 :])) - - stdout_pipe.set_output_only() - return SpawnedMaxLocalStub(Int(pid), reported_port) + return SpawnedMaxLocalStub(pid, reported_port, pipe.read_fd) diff --git a/tests/parent_lifecycle.mojo b/tests/parent_lifecycle.mojo @@ -0,0 +1,314 @@ +"""Governed test-only POSIX process/pipe lifecycle and deadline helpers. + +ADR-0014 D33 FX06/FX08 require *parent-enforced*, finite startup/read/write/ +wait deadlines and exception-safe cleanup/reaping for provider fixture +children and ``run_stdio_entrypoint``. A child ``alarm(2)`` watchdog is +defense in depth, never the parent's lifecycle proof. + +Mojo's ``std.os.Pipe`` wrapper does not expose stable raw descriptors for +``poll(2)``-based bounded I/O, so this module owns the required POSIX surface +directly. It is test-only tooling: it changes no HYF product policy. + +Ownership rules: + +* ``make_pipe``/``fork_pid`` create resources owned by the calling test. +* ``terminate_owned`` only signals a pid this process forked and has not yet + reaped, so a reused PID can never be targeted by a repeated teardown. +* No broad ``pkill``/name matching is performed anywhere. +""" + +from std.ffi import c_int, c_uint, c_ssize_t, c_size_t, external_call +from std.sys._libc import close +from std.time import perf_counter_ns + + +comptime WNOHANG: Int = 1 +comptime POLLIN: Int = 1 +comptime POLLOUT: Int = 4 +comptime POLLERR: Int = 8 +comptime POLLHUP: Int = 16 +comptime POLLNVAL: Int = 32 +comptime SIGALRM: Int = 14 +comptime SIGKILL: Int = 9 +comptime SIGTERM: Int = 15 + +comptime FIXTURE_DEFAULT_DEADLINE_MS: Int = 20000 +comptime TERMINATION_GRACE_MS: Int = 2000 +comptime LIFECYCLE_POLL_SLICE_MS: Int = 25 + + +def now_ms() -> Int: + return Int(perf_counter_ns() // 1_000_000) + + +# ── Raw descriptor helpers ────────────────────────────────────────────────── + + +@fieldwise_init +struct PipeFds(Movable): + var read_fd: Int + var write_fd: Int + + +def make_pipe() raises -> PipeFds: + var fds = InlineArray[c_int, 2](fill=0) + if Int(external_call["pipe", c_int](fds.unsafe_ptr())) != 0: + raise Error("lifecycle: pipe failed") + return PipeFds(Int(fds[0]), Int(fds[1])) + + +def close_fd(fd: Int): + if fd >= 0: + _ = close(c_int(fd)) + + +def fork_pid() raises -> Int: + var pid = Int(external_call["fork", c_int]()) + if pid < 0: + raise Error("lifecycle: fork failed") + return pid + + +def child_exit(code: Int): + _ = external_call["_exit", c_int](c_int(code)) + + +def dup2_fd(oldfd: Int, newfd: Int) -> Int: + return Int(external_call["dup2", c_int](c_int(oldfd), c_int(newfd))) + + +def kill_pid(pid: Int, sig: Int) -> Int: + return Int(external_call["kill", c_int](c_int(pid), c_int(sig))) + + +def owned_pid() -> Int: + return Int(external_call["getpid", c_int]()) + + +def set_alarm(seconds: Int) -> Int: + return Int(external_call["alarm", c_uint](c_uint(seconds))) + + +def sleep_ms(ms: Int): + if ms > 0: + _ = external_call["usleep", c_int](c_int(ms * 1000)) + + +def poll_fd(fd: Int, events: Int, timeout_ms: Int) -> Int: + var cell = InlineArray[Int32, 2](fill=0) + cell[0] = Int32(fd) + cell[1] = Int32(events) + var n = Int( + external_call["poll", c_int]( + cell.unsafe_ptr(), c_uint(1), c_int(timeout_ms) + ) + ) + if n <= 0: + return 0 + return (Int(cell[1]) >> 16) & 0xFFFF + + +def read_fd(fd: Int, buf: UnsafePointer[Byte, ...], max_bytes: Int) -> Int: + return Int(external_call["read", c_ssize_t](fd, buf, c_size_t(max_bytes))) + + +def _write_fd(fd: Int, ptr: UnsafePointer[UInt8, ...], n: Int) -> Int: + return Int(external_call["write", c_ssize_t](fd, ptr, c_size_t(n))) + + +def write_raw(fd: Int, text: String) -> Int: + """Best-effort blocking write of a small bounded string (no deadline).""" + var n = _write_fd(fd, text.as_bytes().unsafe_ptr(), text.byte_length()) + return n + + +def write_fd_bounded(fd: Int, data: String, deadline_ms: Int) -> String: + """Write ``data`` with a parent-enforced deadline. + + Returns ``""`` on success or a bounded reason such as + ``write_deadline_expired`` / ``write_pipe_closed``. + """ + var total = data.byte_length() + var sent = 0 + var start = now_ms() + while sent < total: + if now_ms() - start >= deadline_ms: + return "write_deadline_expired" + var ev = poll_fd(fd, POLLOUT, LIFECYCLE_POLL_SLICE_MS) + if ev == 0: + continue + if (ev & (POLLERR | POLLHUP | POLLNVAL)) != 0: + return "write_pipe_closed" + var chunk = min(4096, total - sent) + var slice = data[byte = sent : sent + chunk] + var n = _write_fd(fd, slice.as_bytes().unsafe_ptr(), chunk) + if n <= 0: + return "write_failed" + sent += n + return "" + + +def read_line_bounded( + fd: Int, max_bytes: Int, deadline_ms: Int +) raises -> String: + """Read one newline-terminated line with bounded size and deadline. + + Raises on overflow or deadline. An EOF before a newline returns whatever + bytes were read (possibly empty). + """ + var out = List[UInt8]() + var buf = InlineArray[Byte, 512](fill=0) + var start = now_ms() + while True: + if len(out) >= max_bytes: + raise Error("ready_output_overflow") + if now_ms() - start >= deadline_ms: + raise Error("read_deadline_expired") + var ev = poll_fd(fd, POLLIN, LIFECYCLE_POLL_SLICE_MS) + if ev == 0: + continue + var n = read_fd(fd, buf.unsafe_ptr(), 512) + if n <= 0: + break + for index in range(n): + if Int(buf[index]) == 10: + return _bytes_to_string(out) + out.append(UInt8(Int(buf[index]))) + return _bytes_to_string(out) + + +def drain_fd_bounded( + fd: Int, max_bytes: Int, deadline_ms: Int +) raises -> String: + """Read available bytes up to ``max_bytes`` under deadline.""" + var out = List[UInt8]() + var buf = InlineArray[Byte, 1024](fill=0) + var start = now_ms() + while len(out) < max_bytes: + if now_ms() - start >= deadline_ms: + break + var ev = poll_fd(fd, POLLIN, LIFECYCLE_POLL_SLICE_MS) + if ev == 0: + continue + var n = read_fd(fd, buf.unsafe_ptr(), 1024) + if n <= 0: + break + for index in range(n): + out.append(UInt8(Int(buf[index]))) + return _bytes_to_string(out) + + +def read_all_bounded( + fd: Int, max_bytes: Int, deadline_ms: Int +) raises -> String: + """Read until EOF, with a bounded size cap and parent deadline. + + Raises ``stdout_overflow`` when the cap is exceeded and + ``read_deadline_expired`` when the deadline elapses first. + """ + var out = List[UInt8]() + var buf = InlineArray[Byte, 4096](fill=0) + var start = now_ms() + while True: + if now_ms() - start >= deadline_ms: + raise Error("read_deadline_expired") + var ev = poll_fd(fd, POLLIN, LIFECYCLE_POLL_SLICE_MS) + if ev == 0: + continue + var n = read_fd(fd, buf.unsafe_ptr(), 4096) + if n <= 0: + break + if len(out) + n > max_bytes: + raise Error("stdout_overflow") + for index in range(n): + out.append(UInt8(Int(buf[index]))) + return _bytes_to_string(out) + + +def _bytes_to_string(bytes: List[UInt8]) raises -> String: + if len(bytes) == 0: + return "" + return String(from_utf8=Span(ptr=bytes.unsafe_ptr(), length=len(bytes))) + + +# ── Child lifecycle ───────────────────────────────────────────────────────── + + +@fieldwise_init +struct ProcessStatus(Movable): + var state: String + var exited: Bool + var exit_code: Int + var signal: Int + var raw: Int + + def reaped(self) -> Bool: + return self.state == "reaped" + + def describe(self) -> String: + if self.state != "reaped": + return self.state + if self.exited: + return "exited=" + String(self.exit_code) + return "signal=" + String(self.signal) + + +def _decode_status(raw: Int) -> ProcessStatus: + var low = raw & 0x7F + if low == 0: + return ProcessStatus("reaped", True, (raw >> 8) & 0xFF, 0, raw) + if low == 0x7F: + return ProcessStatus("stopped", False, -1, 0, raw) + return ProcessStatus("reaped", False, -1, low, raw) + + +def wait_nohang(pid: Int) -> ProcessStatus: + var status = InlineArray[c_int, 1](fill=0) + var r = Int( + external_call["waitpid", c_int]( + c_int(pid), status.unsafe_ptr(), c_int(WNOHANG) + ) + ) + if r == pid: + return _decode_status(Int(status[0])) + if r < 0: + return ProcessStatus("gone", False, -1, -1, -1) + return ProcessStatus("running", False, -1, 0, 0) + + +def wait_bounded(pid: Int, deadline_ms: Int) -> ProcessStatus: + var start = now_ms() + while True: + var st = wait_nohang(pid) + if st.state != "running": + return st^ + if now_ms() - start >= deadline_ms: + return st^ + sleep_ms(5) + + +def terminate_owned(pid: Int, grace_ms: Int) -> ProcessStatus: + """Reap a child this test owns, escalating SIGTERM -> SIGKILL. + + A pid already reaped or not waitable (``gone``) is never signaled, so a + reused PID from an unrelated process can never be targeted. + """ + var st = wait_nohang(pid) + if st.state != "running": + return st^ + _ = kill_pid(pid, SIGTERM) + st = wait_bounded(pid, grace_ms) + if st.state == "running": + _ = kill_pid(pid, SIGKILL) + st = wait_bounded(pid, grace_ms) + return st^ + + +def pid_not_waitable(pid: Int) -> Bool: + """True when ``pid`` is neither running nor an unreaped zombie of ours. + + Used only to evidence, after ``terminate_owned`` reaped a specific owned + child, that the same pid is no longer waitable. It never reaps a pid this + test did not fork and never scans by process name. + """ + return wait_nohang(pid).state == "gone" diff --git a/tests/stdio_process_helper.mojo b/tests/stdio_process_helper.mojo @@ -1,7 +1,30 @@ +"""Bounded, parent-owned stdio entrypoint runner for HYF process tests. + +ADR-0014 D33 FX06/FX08: the *parent* enforces finite startup/read/write/wait +deadlines, bounds stdout/ready output, and reaps/closes every owned descriptor +on success, assertion failure, timeout or early return. The child ``alarm`` is +defense in depth only. + +The request/response API is unchanged so existing call sites keep working. +""" + import std.os -from std.os import Pipe, Process from std.ffi import CStringSlice, c_int, external_call -from std.sys._libc import close + +from parent_lifecycle import ( + TERMINATION_GRACE_MS, + FIXTURE_DEFAULT_DEADLINE_MS, + child_exit, + close_fd, + dup2_fd, + fork_pid, + make_pipe, + read_all_bounded, + set_alarm, + terminate_owned, + wait_bounded, + write_fd_bounded, +) from safe_tempdir import SafeTempDir from json import Value, loads @@ -10,6 +33,13 @@ from json import Value, loads comptime HYF_PATHS_PROFILE_ENV = "HYF_PATHS_PROFILE" comptime HYF_PATHS_REPO_LOCAL_ROOT_ENV = "HYF_PATHS_REPO_LOCAL_ROOT" +# The entrypoint compiles and runs a real Mojo program; this is a finite +# build/run lane budget, not a fixture lifetime and not a product SLO. +comptime STDIO_ENTRYPOINT_DEADLINE_MS = 120000 +comptime STDIO_CHILD_ALARM_SECONDS = 180 +comptime STDIO_MAX_STDOUT_BYTES = 2097152 +comptime STDIO_MAX_STDERR_BYTES = 65536 + struct ScopedEnvVar: var name: String @@ -33,33 +63,6 @@ struct ScopedEnvVar: _ = std.os.unsetenv(self.name) -def _dup2(oldfd: c_int, newfd: c_int) -> c_int: - return external_call["dup2", c_int](oldfd, newfd) - - -@always_inline -def _fork() -> c_int: - return external_call["fork", c_int]() - - -@always_inline -def _exit_child(code: c_int): - _ = external_call["_exit", c_int](code) - - -def _read_pipe_to_string(mut pipe: Pipe) raises -> String: - var buffer = InlineArray[Byte, 4096](fill=0) - var output = String("") - while True: - var read = pipe.read_bytes(Span(buffer)) - if read == 0: - break - output += String( - from_utf8=Span(ptr=buffer.unsafe_ptr(), length=Int(read)) - ) - return output^ - - def run_stdio_entrypoint( entrypoint: String, request_json: String ) raises -> Value: @@ -74,12 +77,35 @@ def run_stdio_entrypoint( ) +def _terminate_and_raise( + pid: Int, stdin_fd: Int, stdout_fd: Int, stderr_fd: Int, reason: String +) raises: + var st = terminate_owned(pid, TERMINATION_GRACE_MS) + close_fd(stdin_fd) + close_fd(stdout_fd) + close_fd(stderr_fd) + raise Error("stdio-entrypoint " + reason + " (child " + st.describe() + ")") + + def run_stdio_entrypoint_with_2_args( entrypoint: String, request_json: String, arg0: String, arg1: String ) raises -> Value: - var stdin_pipe = Pipe() - var stdout_pipe = Pipe() - var output = String("") + return run_stdio_entrypoint_with_deadline( + entrypoint, request_json, arg0, arg1, STDIO_ENTRYPOINT_DEADLINE_MS + ) + + +def run_stdio_entrypoint_with_deadline( + entrypoint: String, + request_json: String, + arg0: String, + arg1: String, + deadline_ms: Int, +) raises -> Value: + var stdin_pipe = make_pipe() + var stdout_pipe = make_pipe() + var stderr_pipe = make_pipe() + var command = String("mojo") var include_flag = String("-I") var include_path = String("src") @@ -107,48 +133,98 @@ def run_stdio_entrypoint_with_2_args( process_arg1.as_c_string_slice() ) - var stdin_read_fd = c_int(stdin_pipe.fd_in.value().value) - var stdin_write_fd = c_int(stdin_pipe.fd_out.value().value) - var stdout_read_fd = c_int(stdout_pipe.fd_in.value().value) - var stdout_write_fd = c_int(stdout_pipe.fd_out.value().value) + var stdin_read_fd = stdin_pipe.read_fd + var stdin_write_fd = stdin_pipe.write_fd + var stdout_read_fd = stdout_pipe.read_fd + var stdout_write_fd = stdout_pipe.write_fd + var stderr_read_fd = stderr_pipe.read_fd + var stderr_write_fd = stderr_pipe.write_fd var command_ptr = command.as_c_string_slice().unsafe_ptr() var argv_ptr = argv.unsafe_ptr() - var pid = _fork() - if pid < 0: - raise Error("failed to spawn hyf process test child") - + var pid = fork_pid() if pid == 0: - if _dup2(stdin_read_fd, 0) < 0: - _exit_child(c_int(126)) - if _dup2(stdout_write_fd, 1) < 0: - _exit_child(c_int(126)) - _ = close(stdin_read_fd) - _ = close(stdin_write_fd) - _ = close(stdout_read_fd) - _ = close(stdout_write_fd) + if dup2_fd(stdin_read_fd, 0) < 0: + child_exit(126) + if dup2_fd(stdout_write_fd, 1) < 0: + child_exit(126) + if dup2_fd(stderr_write_fd, 2) < 0: + child_exit(126) + close_fd(stdin_read_fd) + close_fd(stdin_write_fd) + close_fd(stdout_read_fd) + close_fd(stdout_write_fd) + close_fd(stderr_read_fd) + close_fd(stderr_write_fd) + _ = set_alarm(STDIO_CHILD_ALARM_SECONDS) _ = external_call["execvp", c_int](command_ptr, argv_ptr) - _exit_child(c_int(127)) - - stdin_pipe.set_output_only() - stdout_pipe.set_input_only() + child_exit(127) - stdin_pipe.write_bytes((request_json + "\n").as_bytes()) - stdin_pipe.set_input_only() + close_fd(stdin_read_fd) + close_fd(stdout_write_fd) + close_fd(stderr_write_fd) - output = _read_pipe_to_string(stdout_pipe) - stdout_pipe.set_output_only() - - var process = Process(Int(pid)) - var status = process.wait() - if not status.exit_code or status.exit_code.value() != 0: - raise Error("hyf process exited unexpectedly") + var write_reason = write_fd_bounded( + stdin_write_fd, request_json + "\n", deadline_ms + ) + close_fd(stdin_write_fd) + if write_reason != "": + _terminate_and_raise( + pid, -1, stdout_read_fd, stderr_read_fd, "write_" + write_reason + ) + var output = "" + try: + output = read_all_bounded( + stdout_read_fd, STDIO_MAX_STDOUT_BYTES, deadline_ms + ) + except: + _terminate_and_raise( + pid, + -1, + stdout_read_fd, + stderr_read_fd, + "stdout_bounded_read_failed", + ) + close_fd(stdout_read_fd) + + var diagnostics = _diagnostics_or_empty(stderr_read_fd) + close_fd(stderr_read_fd) + + var st = wait_bounded(pid, deadline_ms) + if not st.reaped(): + var term = terminate_owned(pid, TERMINATION_GRACE_MS) + raise Error("stdio-entrypoint timeout (child " + term.describe() + ")") + if st.exited and st.exit_code == 127: + raise Error( + "stdio-entrypoint exec_failed (stdout=" + + output + + " stderr=" + + diagnostics + + ")" + ) + if not st.exited or st.exit_code != 0: + raise Error( + "stdio-entrypoint child_failed (" + + st.describe() + + " stderr=" + + diagnostics + + ")" + ) if output == "": raise Error("hyf process returned no stdout payload") return loads(output) +def _diagnostics_or_empty(fd: Int) -> String: + try: + return read_all_bounded( + fd, STDIO_MAX_STDERR_BYTES, TERMINATION_GRACE_MS + ) + except: + return "" + + def run_hyf_stdio(request_json: String) raises -> Value: var response = Value(None) with SafeTempDir() as temp_dir: diff --git a/tests/stdio_stall_entrypoint.mojo b/tests/stdio_stall_entrypoint.mojo @@ -0,0 +1,8 @@ +"""Test-only stdio entrypoint that stalls to exercise parent deadlines.""" + +from std.ffi import c_int, external_call + + +def main(): + for _ in range(200): + _ = external_call["usleep", c_int](c_int(1_000_000)) diff --git a/tests/strict_fixture.mojo b/tests/strict_fixture.mojo @@ -1,39 +1,229 @@ -"""Strict bounded HTTP request framing for local test fixtures (ADR-0012 D29). +"""Strict bounded HTTP request framing and scripted exchanges for local test +fixtures (ADR-0012 D29, ADR-0014 D33 FX01-FX05). -Bounded byte-oriented accumulation and header parsing *before* UTF-8 decoding, -validated nonnegative ``Content-Length``, exact header-name matching, -duplicate/conflicting length rejection, premature-EOF rejection, bounded -unsupported-transfer rejection and surplus retention for persistent -connections. +This module owns the *test fixture* wire contract only. It deliberately does +not change HYF's product HTTP/TLS policy or timeouts. + +Framing contract (FX03): + +* bounded byte-oriented accumulation before any UTF-8 decode +* header cap 65,536 bytes and body cap 1,048,576 bytes (ADR-0010 D21) +* lexical ``Content-Length``: nonempty ASCII digits only, with an explicit + overflow guard; no sign, whitespace or non-digit +* request line must be ``METHOD SP target SP HTTP/1.1|HTTP/1.0`` +* malformed header syntax (whitespace before ``:``, obs-fold, control bytes, + non-token name bytes) is rejected rather than silently stripped +* duplicate/conflicting framing and unsupported transfer encodings rejected +* premature EOF and oversize rejected before unbounded accumulation +* surplus bytes after a frame are retained for the next frame + +Exchange contract (FX01/FX02/FX04/FX05): explicit ordered scripts carrying the +expected method/path/selected headers/body plus the scripted status/headers/ +body/delay and connection semantics. Request and connection counters are +distinct and verified. """ from std.collections import List +from flare.tcp import TcpListener from flare.tcp import TcpStream +from flare.utils import usleep + comptime STRICT_MAX_HEADER_BYTES = 65536 comptime STRICT_MAX_BODY_BYTES = 1048576 +comptime STRICT_MAX_READY_BYTES = 256 +comptime STRICT_MAX_REPORT_BYTES = 2048 +comptime STRICT_COMPLETION_GRACE_MS = 100 + + +# ── JSON-safe escaping (FX04) ─────────────────────────────────────────────── + + +def _hex_digit(value: Int) -> UInt8: + if value < 10: + return UInt8(48 + value) + return UInt8(97 + (value - 10)) + + +def json_escape(value: String) -> String: + """Return ``value`` as a JSON string literal with full control escaping.""" + var out = List[UInt8]() + out.append(UInt8(34)) + for byte in value.as_bytes(): + var b = Int(byte) + if b == 34: + out.append(UInt8(92)) + out.append(UInt8(34)) + elif b == 92: + out.append(UInt8(92)) + out.append(UInt8(92)) + elif b == 8: + out.append(UInt8(92)) + out.append(UInt8(98)) + elif b == 12: + out.append(UInt8(92)) + out.append(UInt8(102)) + elif b == 10: + out.append(UInt8(92)) + out.append(UInt8(110)) + elif b == 13: + out.append(UInt8(92)) + out.append(UInt8(114)) + elif b == 9: + out.append(UInt8(92)) + out.append(UInt8(116)) + elif b < 32 or b == 127: + out.append(UInt8(92)) + out.append(UInt8(117)) + out.append(UInt8(48)) + out.append(UInt8(48)) + out.append(_hex_digit(b // 16)) + out.append(_hex_digit(b % 16)) + else: + out.append(UInt8(b)) + out.append(UInt8(34)) + return String(unsafe_from_utf8=Span(ptr=out.unsafe_ptr(), length=len(out))) + + +# ── Header access (FX03/FX04) ─────────────────────────────────────────────── + + +def header_values(headers_raw: String, name: String) -> List[String]: + """Return every value for an exactly named header (case-insensitive name). + + The header name is compared byte-exactly after lowercasing; malformed + spacing was already rejected by the framer, so no whitespace stripping of + the name token occurs here. + """ + var out = List[String]() + var lines = headers_raw.split("\r\n") + var wanted = name.lower() + for i in range(1, len(lines)): + var line = String(lines[i]) + var colon = line.find(":") + if colon <= 0: + continue + if String(line[byte=0:colon]).lower() == wanted: + out.append(String(line[byte = colon + 1 :].strip())) + return out^ + + +def header_value(headers_raw: String, name: String) -> String: + var values = header_values(headers_raw, name) + if len(values) != 1: + return "" + return values[0] + + +def bearer_token(headers_raw: String) -> String: + var values = header_values(headers_raw, "authorization") + if len(values) != 1: + return "" + if not values[0].startswith("Bearer "): + return "" + return String(values[0][byte=7:]) + + +def authorization_reason(headers_raw: String, required: Bool) -> String: + """Validate the exact ``Authorization`` header, rejecting spoofs (FX04).""" + if not required: + return "" + var values = header_values(headers_raw, "authorization") + if len(values) == 0: + return "auth_missing" + if len(values) > 1: + return "auth_duplicate" + if not values[0].startswith("Bearer ") or values[0].byte_length() <= 7: + return "auth_invalid" + return "" + +# ── Framing ───────────────────────────────────────────────────────────────── + +@fieldwise_init struct FramedRequest(Movable): var ok: Bool var error: String var method: String var path: String + var version: String var headers_raw: String var body: String + var content_length: Int var keep_alive: Bool var total_bytes: Int - def __init__(out self): - self.ok = False - self.error = "" - self.method = "" - self.path = "" - self.headers_raw = "" - self.body = "" - self.keep_alive = False - self.total_bytes = 0 + +def _token_byte(value: Int) -> Bool: + if value >= 48 and value <= 57: + return True + if value >= 65 and value <= 90: + return True + if value >= 97 and value <= 122: + return True + # RFC 7230 token punctuation. + return ( + value == 33 + or value == 35 + or value == 36 + or value == 37 + or value == 38 + or value == 39 + or value == 42 + or value == 43 + or value == 45 + or value == 46 + or value == 94 + or value == 95 + or value == 96 + or value == 124 + or value == 126 + ) + + +def _valid_method(method: String) -> Bool: + if method.byte_length() == 0: + return False + for byte in method.as_bytes(): + var b = Int(byte) + if b < 65 or b > 90: + return False + return True + + +def _valid_version(version: String) -> Bool: + return version == "HTTP/1.1" or version == "HTTP/1.0" + + +def _valid_header_name(name: String) -> Bool: + if name.byte_length() == 0: + return False + for byte in name.as_bytes(): + if not _token_byte(Int(byte)): + return False + return True + + +def _valid_header_value(value: String) -> Bool: + for byte in value.as_bytes(): + var b = Int(byte) + if b == 9: + continue + if b < 32 or b == 127: + return False + return True + + +def _ascii_digits(value: String) -> Bool: + if value.byte_length() == 0: + return False + for byte in value.as_bytes(): + var b = Int(byte) + if b < 48 or b > 57: + return False + return True def _bytes_string(bytes: List[UInt8], stop: Int) raises -> String: @@ -50,6 +240,9 @@ struct ConnectionReader(Movable): self._buffer = List[UInt8]() self._eof = False + def has_buffered(self) -> Bool: + return len(self._buffer) > 0 + def _read_more(mut self) raises -> Int: var chunk = InlineArray[Byte, 4096](fill=0) var n = self._stream.read(chunk.unsafe_ptr(), 4096) @@ -77,7 +270,18 @@ struct ConnectionReader(Movable): return -1 def read(mut self) raises -> FramedRequest: - var outcome = FramedRequest() + var outcome = FramedRequest( + ok=False, + error="", + method="", + path="", + version="", + headers_raw="", + body="", + content_length=0, + keep_alive=False, + total_bytes=0, + ) var header_end = self._header_end() while header_end < 0: if self._eof: @@ -90,6 +294,9 @@ struct ConnectionReader(Movable): outcome.error = "header_too_large" return outcome^ _ = self._read_more() + if len(self._buffer) > STRICT_MAX_HEADER_BYTES + 4: + outcome.error = "header_too_large" + return outcome^ header_end = self._header_end() if header_end > STRICT_MAX_HEADER_BYTES: outcome.error = "header_too_large" @@ -107,36 +314,63 @@ struct ConnectionReader(Movable): return outcome^ outcome.method = String(parts[0]) outcome.path = String(parts[1]) + outcome.version = String(parts[2]) + if not _valid_method(outcome.method): + outcome.error = "malformed_method" + return outcome^ + if not _valid_version(outcome.version): + outcome.error = "malformed_version" + return outcome^ + if outcome.path.byte_length() == 0: + outcome.error = "malformed_target" + return outcome^ - var content_length = -1 + var content_length = 0 var have_length = False var have_transfer = False for i in range(1, len(lines)): var line = String(lines[i]) if line.byte_length() == 0: continue + var first = Int(line.as_bytes()[0]) + if first == 32 or first == 9: + outcome.error = "malformed_header" + return outcome^ var colon = line.find(":") - if colon < 0: + if colon <= 0: + outcome.error = "malformed_header" + return outcome^ + var name = String(line[byte=0:colon]) + var value = String(line[byte = colon + 1 :].strip()) + if not _valid_header_name(name) or not _valid_header_value(value): outcome.error = "malformed_header" return outcome^ - var name = String(line[byte=0:colon]).lower().strip() - var value = String(line[byte = colon + 1 :]).strip() - if name == "content-length": + var lower = name.lower() + if lower == "content-length": if have_length: outcome.error = "duplicate_content_length" return outcome^ + if not _ascii_digits(value): + outcome.error = "malformed_content_length" + return outcome^ + if value.byte_length() > 10: + outcome.error = "content_length_overflow" + return outcome^ var parsed = Int(value) - if parsed < 0: - outcome.error = "negative_content_length" + if parsed > STRICT_MAX_BODY_BYTES: + outcome.error = "body_too_large" return outcome^ content_length = parsed have_length = True - elif name == "transfer-encoding": + elif lower == "transfer-encoding": + if have_transfer: + outcome.error = "duplicate_transfer_encoding" + return outcome^ have_transfer = True if value.lower() != "identity": outcome.error = "unsupported_transfer_encoding" return outcome^ - elif name == "connection" and value.lower() == "keep-alive": + elif lower == "connection" and value.lower() == "keep-alive": outcome.keep_alive = True if have_transfer and have_length: @@ -144,9 +378,7 @@ struct ConnectionReader(Movable): return outcome^ if not have_length: content_length = 0 - if content_length > STRICT_MAX_BODY_BYTES: - outcome.error = "body_too_large" - return outcome^ + outcome.content_length = content_length var expected_total = header_end + 4 + content_length while len(self._buffer) < expected_total: @@ -167,23 +399,363 @@ struct ConnectionReader(Movable): outcome.total_bytes = expected_total return outcome^ + def probe_completion(mut self, grace_ms: Int) -> String: + """Bounded completion handshake after the final expected exchange. -def header_value(headers_raw: String, name: String) -> String: - """Return the value of an exactly named header, or "" when absent.""" - var lines = headers_raw.split("\r\n") - var wanted = name.lower() - for i in range(1, len(lines)): - var line = String(lines[i]) - var colon = line.find(":") - if colon < 0: + Any already-buffered or subsequently received bytes mean the client + sent an extra exchange. A timeout with no bytes is success; this never + waits indefinitely for hypothetical future requests. + """ + if len(self._buffer) > 0: + return "extra_exchange_after_completion" + try: + self._stream.set_recv_timeout(grace_ms) + except: + return "" + try: + var n = self._read_more() + if n > 0: + return "extra_exchange_after_completion" + except: + return "" + return "" + + +# ── Scripted exchanges (FX01/FX02/FX04/FX05) ──────────────────────────────── + + +@fieldwise_init +struct ExchangeScript(Copyable, Movable): + var case_label: String + var method: String + var path: String + var headers: String + var body: String + var check_body: Bool + var require_bearer: Bool + var status: Int + var response_headers: String + var response_body: String + var raw_response: String + var delay_ms: Int + var close_connection: Bool + var echo_authorization: Bool + + def __copyinit__(out self, existing: Self): + self.case_label = existing.case_label + self.method = existing.method + self.path = existing.path + self.headers = existing.headers + self.body = existing.body + self.check_body = existing.check_body + self.require_bearer = existing.require_bearer + self.status = existing.status + self.response_headers = existing.response_headers + self.response_body = existing.response_body + self.raw_response = existing.raw_response + self.delay_ms = existing.delay_ms + self.close_connection = existing.close_connection + self.echo_authorization = existing.echo_authorization + + +def exchange_script( + case_label: String, + method: String, + path: String, + status: Int, + body: String, +) -> ExchangeScript: + return ExchangeScript( + case_label=case_label, + method=method, + path=path, + headers="", + body="", + check_body=False, + require_bearer=False, + status=status, + response_headers="", + response_body=body, + raw_response="", + delay_ms=0, + close_connection=True, + echo_authorization=False, + ) + + +def verify_exchange(script: ExchangeScript, framed: FramedRequest) -> String: + """Return ``""`` when the request matches the script, else a bounded reason. + """ + if framed.method != script.method: + return "method_mismatch" + if framed.path != script.path: + return "path_mismatch" + if script.check_body and framed.body != script.body: + return "body_mismatch" + var expected_headers = script.headers.split("\n") + for entry in expected_headers: + var expected = String(entry).strip() + if expected.byte_length() == 0: + continue + var split = expected.find(":") + if split <= 0: continue - if String(line[byte=0:colon]).lower().strip() == wanted: - return String(String(line[byte = colon + 1 :]).strip()) + var name = String(expected[byte=0:split]) + var value = String(expected[byte = split + 1 :].strip()) + var values = header_values(framed.headers_raw, name) + if len(values) == 0: + return "header_missing:" + name + if len(values) > 1: + return "header_duplicate:" + name + if values[0] != value: + return "header_mismatch:" + name + var auth = authorization_reason(framed.headers_raw, script.require_bearer) + if auth != "": + return auth return "" -def bearer_token(headers_raw: String) -> String: - var value = header_value(headers_raw, "authorization") - if not value.startswith("Bearer "): - return "" - return String(value[byte=7:]) +def _substitute( + text: String, request_index: Int, connection_index: Int +) -> String: + var out = text.replace("{request_index}", String(request_index)) + return out.replace("{connection_index}", String(connection_index)) + + +def render_response( + script: ExchangeScript, + request_headers_raw: String, + request_index: Int, + connection_index: Int, +) -> String: + if script.raw_response != "": + return String(script.raw_response) + var reason = "OK" + if script.status == 401: + reason = "Unauthorized" + elif script.status == 404: + reason = "Not Found" + elif script.status == 429: + reason = "Too Many Requests" + elif script.status == 500: + reason = "Internal Server Error" + elif script.status == 503: + reason = "Service Unavailable" + elif script.status == 529: + reason = "Overloaded" + var body = _substitute( + script.response_body, request_index, connection_index + ) + if script.echo_authorization: + body = ( + '{"authorization":' + + json_escape(bearer_token(request_headers_raw)) + + "}" + ) + var extra = "" + if script.response_headers != "": + extra = script.response_headers + "\r\n" + var connection = "keep-alive" if not script.close_connection else "close" + return ( + "HTTP/1.1 " + + String(script.status) + + " " + + reason + + "\r\ncontent-type: application/json\r\n" + + extra + + "content-length: " + + String(body.byte_length()) + + "\r\nconnection: " + + connection + + "\r\n\r\n" + + body + ) + + +# ── Convenience-mode validation + reports ──────────────────────────────────── + + +def serve_scripts( + listener: TcpListener, var scripts: List[ExchangeScript], label: String +) raises -> ServeReport: + """Serve an ordered list of explicit exchange scripts (FX01/FX02/FX05). + + Every script must be consumed in order on its expected method/path/ + selected-headers/body; unexpected, extra, missing and unconsumed exchanges + fail with a bounded case-specific reason. A 404-then-success outcome is + never a pass. + """ + var request_count = 0 + var connection_count = 0 + var total = len(scripts) + try: + while request_count < total: + var stream = listener.accept() + connection_count += 1 + var reader = ConnectionReader(stream^) + while request_count < total: + var framed = reader.read() + if not framed.ok: + if framed.error == "empty": + if request_count < total: + return ServeReport( + False, + "accounting", + label, + "missing_exchanges", + request_count, + connection_count, + ) + break + return ServeReport( + False, + "read", + label, + framed.error, + request_count, + connection_count, + ) + var script = scripts[request_count].copy() + var verify = verify_exchange(script, framed) + if verify != "": + return ServeReport( + False, + "exchange", + script.case_label, + verify, + request_count, + connection_count, + ) + var next_index = request_count + 1 + if next_index == total: + var extra = reader.probe_completion( + STRICT_COMPLETION_GRACE_MS + ) + if extra != "": + return ServeReport( + False, + "accounting", + script.case_label, + extra, + request_count, + connection_count, + ) + request_count = next_index + if script.delay_ms > 0: + usleep(script.delay_ms * 1000) + reader.write_all( + render_response( + script, + framed.headers_raw, + request_count, + connection_count, + ) + ) + if script.close_connection: + break + if request_count < total: + return ServeReport( + False, + "accounting", + scripts[request_count].case_label, + "missing_exchanges", + request_count, + connection_count, + ) + return ServeReport( + True, "complete", label, "ok", request_count, connection_count + ) + except: + return ServeReport( + False, "read", label, "io_error", request_count, connection_count + ) + + +def validate_convenience( + framed: FramedRequest, + allowed_paths: List[String], + allowed_methods: List[String], + require_bearer: Bool, +) -> String: + """Validate route then method (and auth) before any response. + + ``allowed_methods`` is parallel to ``allowed_paths``. Wrong routes, + methods and spoofed auth never count as a successful intended exchange + (FX02/FX04). + """ + var matched = -1 + for index in range(len(allowed_paths)): + if framed.path == allowed_paths[index]: + matched = index + if matched < 0: + return "unexpected_path" + if matched >= len(allowed_methods): + return "unexpected_method" + if framed.method != allowed_methods[matched]: + return "unexpected_method" + return authorization_reason(framed.headers_raw, require_bearer) + + +@fieldwise_init +struct ServeReport(Movable): + var ok: Bool + var phase: String + var case_label: String + var reason: String + var requests: Int + var connections: Int + + def describe(self) -> String: + return ( + "phase=" + + self.phase + + " case=" + + self.case_label + + " reason=" + + self.reason + + " requests=" + + String(self.requests) + + " connections=" + + String(self.connections) + ) + + +def report_line(report: ServeReport) -> String: + var state = "ok" if report.ok else "fail" + return "result " + state + " " + report.describe() + + +def _safe_int(value: String) -> Int: + try: + return Int(value) + except: + return 0 + + +def parse_report(line: String) -> ServeReport: + var report = ServeReport(False, "unknown", "-", "missing_report", 0, 0) + if not line.startswith("result "): + return report^ + var remainder = String(line[byte=7:]) + var parts = remainder.split(" ") + if len(parts) == 0: + return report^ + report.ok = String(parts[0]) == "ok" + for index in range(1, len(parts)): + var field = String(parts[index]) + var eq = field.find("=") + if eq <= 0: + continue + var key = String(field[byte=0:eq]) + var value = String(field[byte = eq + 1 :]) + if key == "phase": + report.phase = value + elif key == "case": + report.case_label = value + elif key == "reason": + report.reason = value + elif key == "requests": + report.requests = _safe_int(value) + elif key == "connections": + report.connections = _safe_int(value) + return report^ diff --git a/tests/test_provider_helpers.mojo b/tests/test_provider_helpers.mojo @@ -1,37 +1,40 @@ -from std.testing import TestSuite, assert_true +"""Strict fixture/script verification tests (ADR-0012 D29 / ADR-0014 D33). -from flare.net import SocketAddr -from flare.tcp import TcpStream +Covers FX01-FX08 for both providers plus the in-memory mutation controls that +prove a negative control fails for the intended cause rather than for any +exception, signal or unrelated startup failure. +""" + +from std.testing import TestSuite, assert_true, assert_equal +from flare.net import SocketAddr +from flare.tcp import TcpListener, TcpStream + +from parent_lifecycle import pid_not_waitable +from strict_fixture import ( + ExchangeScript, + FramedRequest, + authorization_reason, + exchange_script, + json_escape, + verify_exchange, +) from max_local_process_helper import ( SpawnedMaxLocalStub, + reserve_loopback_port, + spawn_max_local_scripted, spawn_max_local_stub, ) -from jev_provider_helper import spawn_jev_stub_auto +from jev_provider_helper import ( + spawn_jev_scripted_auto, + spawn_jev_stub_auto, +) -def _client_request( - port: Int, path: String, body: String, auth: String = "" -) raises -> String: - var client = TcpStream.connect(SocketAddr.localhost(UInt16(port))) - var auth_line = "" if auth == "" else "authorization: " + auth + "\r\n" - var headers = ( - "POST " - + path - + " HTTP/1.1\r\nhost: 127.0.0.1\r\n" - + auth_line - + "content-type: application/json\r\ncontent-length: " - + String(body.byte_length()) - + "\r\nconnection: close\r\n\r\n" - ) - client.write_all(Span[UInt8, _](headers.as_bytes())) - var sent = 0 - while sent < body.byte_length(): - var end = sent + 3000 - if end > body.byte_length(): - end = body.byte_length() - client.write_all(Span[UInt8, _](body[byte=sent:end].as_bytes())) - sent = end +# ── Client helpers ────────────────────────────────────────────────────────── + + +def _read_all(mut client: TcpStream) raises -> String: var buffer = InlineArray[Byte, 4096](fill=0) var response = String("") while True: @@ -41,16 +44,93 @@ def _client_request( response += String( unsafe_from_utf8=Span(ptr=buffer.unsafe_ptr(), length=Int(n)) ) + return response^ + + +def _write_all(mut client: TcpStream, text: String) raises: + client.write_all(Span[UInt8, _](text.as_bytes())) + + +def _raw_exchange(port: Int, raw: String) raises -> String: + var client = TcpStream.connect(SocketAddr.localhost(UInt16(port))) + _write_all(client, raw) + var response = _read_all(client) client.close() return response^ +def _raw_send_only(port: Int, raw: String) raises: + var client = TcpStream.connect(SocketAddr.localhost(UInt16(port))) + _write_all(client, raw) + client.close() + + +def _read_one_response(mut client: TcpStream) raises -> String: + var data = String("") + var buf = InlineArray[Byte, 1024](fill=0) + while data.find("\r\n\r\n") < 0: + var n = client.read(buf.unsafe_ptr(), 1024) + if n <= 0: + return data^ + data += String( + unsafe_from_utf8=Span(ptr=buf.unsafe_ptr(), length=Int(n)) + ) + var header_end = data.find("\r\n\r\n") + var length = 0 + for line in data[byte=0:header_end].split("\r\n"): + var entry = String(line) + if entry.lower().startswith("content-length:"): + length = Int(String(entry[byte=15:]).strip()) + var body_start = header_end + 4 + while data.byte_length() - body_start < length: + var n = client.read(buf.unsafe_ptr(), 1024) + if n <= 0: + break + data += String( + unsafe_from_utf8=Span(ptr=buf.unsafe_ptr(), length=Int(n)) + ) + return data^ + + +def _request( + port: Int, + method: String, + path: String, + body: String, + extra: String = "", + connection: String = "close", +) raises -> String: + var raw = ( + method + + " " + + path + + " HTTP/1.1\r\nhost: 127.0.0.1\r\n" + + extra + + "content-type: application/json\r\ncontent-length: " + + String(body.byte_length()) + + "\r\nconnection: " + + connection + + "\r\n\r\n" + + body + ) + return _raw_exchange(port, raw) + + +def _default_script() -> ExchangeScript: + return exchange_script( + "expect_ok", "POST", "/v1/chat/completions", 200, '{"ok":true}' + ) + + +# ── Existing convenience-mode coverage ────────────────────────────────────── + + def test_max_local_stub_reads_fragmented_large_body() raises: var stub = spawn_max_local_stub(0, "echo_body_bytes", 1) var body = String("") for _ in range(9000): body += "x" - var response = _client_request(stub.port, "/v1/chat/completions", body) + var response = _request(stub.port, "POST", "/v1/chat/completions", body) assert_true(response.find('"received_bytes":9000') >= 0) stub.wait() @@ -59,167 +139,577 @@ def test_max_local_stub_counts_every_wire_attempt() raises: var requests = 3 var stub = spawn_max_local_stub(0, "count_requests", requests) for index in range(requests): - var response = _client_request(stub.port, "/v1/chat/completions", "{}") + var response = _request(stub.port, "POST", "/v1/chat/completions", "{}") assert_true(response.find('"request_index":' + String(index + 1)) >= 0) stub.wait() def test_max_local_stub_rejects_unknown_path() raises: + # FX02/FX04: an unexpected route must fail fixture verification, not be + # answered 404 and then reported as a successful stub run. var stub = spawn_max_local_stub(0, "query_rewrite_ok", 1) - var response = _client_request(stub.port, "/not-a-route", "{}") - assert_true(response.find("404") >= 0) - assert_true(response.find('"not_found"') >= 0) - stub.wait() + var response = _request(stub.port, "POST", "/not-a-route", "{}") + assert_true(response.find("404") < 0) + stub.reap() + assert_true(not stub.ok()) + assert_equal(stub.phase(), "exchange") + assert_equal(stub.reason(), "unexpected_path") def test_max_local_stub_binds_and_reports_port() raises: var stub = spawn_max_local_stub(0, "count_requests", 1) assert_true(stub.port > 0) - var response = _client_request(stub.port, "/v1/chat/completions", "{}") + var response = _request(stub.port, "POST", "/v1/chat/completions", "{}") assert_true(response.find('"request_index":1') >= 0) stub.wait() def test_jev_stub_observes_bearer_sentinel_at_intended_origin() raises: var started = spawn_jev_stub_auto("echo_authorization", 1) - var response = _client_request( - started.port, "/v1/systemone", "{}", "Bearer hyf-sentinel-token" + var response = _request( + started.port, + "POST", + "/v1/systemone", + "{}", + "authorization: Bearer hyf-sentinel-token\r\n", ) assert_true(response.find("hyf-sentinel-token") >= 0) started.stub.wait() -def _client_send_raw(port: Int, data: String) raises -> String: - var client = TcpStream.connect(SocketAddr.localhost(UInt16(port))) - client.write_all(Span[UInt8, _](data.as_bytes())) - var buffer = InlineArray[Byte, 4096](fill=0) - var response = String("") - while True: - var n = client.read(buffer.unsafe_ptr(), 4096) - if n <= 0: - break - response += String( - unsafe_from_utf8=Span(ptr=buffer.unsafe_ptr(), length=Int(n)) - ) - client.close() - return response^ - - -def _client_two_on_one(port: Int, path: String) raises -> String: - var client = TcpStream.connect(SocketAddr.localhost(UInt16(port))) - var body = "{}" - var first = ( - "POST " - + path - + " HTTP/1.1\r\nhost: 127.0.0.1\r\ncontent-type:" - " application/json\r\ncontent-length: " - + String(body.byte_length()) - + "\r\nconnection: keep-alive\r\n\r\n" - + body - ) - var second = ( - "POST " - + path - + " HTTP/1.1\r\nhost: 127.0.0.1\r\ncontent-type:" - " application/json\r\ncontent-length: " - + String(body.byte_length()) - + "\r\nconnection: close\r\n\r\n" - + body +def test_max_local_stub_stalled_child_is_reaped() raises: + var stub = spawn_max_local_stub(0, "stall", 1) + _raw_send_only( + stub.port, + ( + "POST /v1/chat/completions HTTP/1.1\r\nhost: 127.0.0.1\r\n" + "content-length: 2\r\nconnection: close\r\n\r\n{}" + ), ) - client.write_all(Span[UInt8, _](first.as_bytes())) - client.write_all(Span[UInt8, _](second.as_bytes())) - var buffer = InlineArray[Byte, 4096](fill=0) - var response = String("") - while True: - var n = client.read(buffer.unsafe_ptr(), 4096) - if n <= 0: - break - response += String( - unsafe_from_utf8=Span(ptr=buffer.unsafe_ptr(), length=Int(n)) - ) - client.close() - return response^ + stub.terminate() + assert_true(pid_not_waitable(stub.pid)) -def _stub_failed(mut stub: SpawnedMaxLocalStub) raises -> Bool: - try: - stub.wait() - return False - except: - return True +# ── FX01: explicit ordered scripted exchanges ─────────────────────────────── -def _client_send_only(port: Int, data: String) raises: - var client = TcpStream.connect(SocketAddr.localhost(UInt16(port))) - client.write_all(Span[UInt8, _](data.as_bytes())) - client.close() +def test_max_local_scripted_matches_explicit_exchange() raises: + var scripts = List[ExchangeScript]() + var script = exchange_script( + "explicit_ok", "POST", "/v1/chat/completions", 201, '{"made":"yes"}' + ) + script.headers = "x-sentinel:explicit" + script.check_body = True + script.body = '{"q":"apples"}' + script.response_headers = "x-scripted: yes" + script.delay_ms = 20 + scripts.append(script^) + var stub = spawn_max_local_scripted(0, scripts^) + var response = _request( + stub.port, + "POST", + "/v1/chat/completions", + '{"q":"apples"}', + "x-sentinel: explicit\r\n", + ) + assert_true(response.find("201") >= 0) + assert_true(response.find("x-scripted: yes") >= 0) + assert_true(response.find('{"made":"yes"}') >= 0) + stub.wait() + assert_equal(stub.request_count(), 1) + assert_equal(stub.connection_count(), 1) -def test_max_local_strict_rejects_truncated_frame() raises: - var stub = spawn_max_local_stub(0, "echo_body_bytes", 1) - _client_send_only( +def test_max_local_scripted_rejects_wrong_method() raises: + var scripts = List[ExchangeScript]() + scripts.append(_default_script()) + var stub = spawn_max_local_scripted(0, scripts^) + _raw_send_only( stub.port, ( - "POST /v1/chat/completions HTTP/1.1\r\nhost: 127.0.0.1\r\n" - "content-length: 100\r\nconnection: close\r\n\r\nshort" + "GET /v1/chat/completions HTTP/1.1\r\nhost: 127.0.0.1\r\n" + "content-length: 2\r\nconnection: close\r\n\r\n{}" ), ) - assert_true(_stub_failed(stub)) + stub.reap() + assert_equal(stub.phase(), "exchange") + assert_equal(stub.reason(), "method_mismatch") -def test_max_local_strict_rejects_duplicate_content_length() raises: - var stub = spawn_max_local_stub(0, "echo_body_bytes", 1) - _client_send_only( +def test_max_local_scripted_rejects_wrong_path() raises: + var scripts = List[ExchangeScript]() + scripts.append(_default_script()) + var stub = spawn_max_local_scripted(0, scripts^) + _raw_send_only( + stub.port, + ( + "POST /v1/other HTTP/1.1\r\nhost: 127.0.0.1\r\n" + "content-length: 2\r\nconnection: close\r\n\r\n{}" + ), + ) + stub.reap() + assert_equal(stub.phase(), "exchange") + assert_equal(stub.reason(), "path_mismatch") + + +def test_max_local_scripted_rejects_wrong_selected_header() raises: + var scripts = List[ExchangeScript]() + var script = _default_script() + script.headers = "x-sentinel:expected" + scripts.append(script^) + var stub = spawn_max_local_scripted(0, scripts^) + _raw_send_only( stub.port, ( "POST /v1/chat/completions HTTP/1.1\r\nhost: 127.0.0.1\r\n" - "content-length: 2\r\ncontent-length: 2\r\n" - "connection: close\r\n\r\n{}" + "x-sentinel: wrong\r\ncontent-length: 2\r\nconnection: close\r\n" + "\r\n{}" + ), + ) + stub.reap() + assert_equal(stub.phase(), "exchange") + assert_equal(stub.reason(), "header_mismatch:x-sentinel") + + +def test_max_local_scripted_rejects_wrong_body() raises: + var scripts = List[ExchangeScript]() + var script = _default_script() + script.check_body = True + script.body = '{"expected":true}' + scripts.append(script^) + var stub = spawn_max_local_scripted(0, scripts^) + _raw_send_only( + stub.port, + ( + "POST /v1/chat/completions HTTP/1.1\r\nhost: 127.0.0.1\r\n" + 'content-length: 15\r\nconnection: close\r\n\r\n{"wrong":true} ' ), ) - assert_true(_stub_failed(stub)) + stub.reap() + assert_equal(stub.phase(), "exchange") + assert_equal(stub.reason(), "body_mismatch") -def test_max_local_strict_rejects_oversize_body() raises: - var stub = spawn_max_local_stub(0, "echo_body_bytes", 1) - _client_send_only( - stub.port, +# ── FX02: unexpected/extra/missing/unconsumed accounting ──────────────────── + + +def test_scripted_rejects_extra_pipelined_exchange() raises: + var scripts = List[ExchangeScript]() + var script = _default_script() + script.close_connection = False + scripts.append(script^) + var stub = spawn_max_local_scripted(0, scripts^) + var first_frame = ( + "POST /v1/chat/completions HTTP/1.1\r\nhost: 127.0.0.1\r\n" + "content-length: 2\r\nconnection: keep-alive\r\n\r\n{}" + ) + var second_frame = String(first_frame) + _raw_send_only(stub.port, first_frame + second_frame) + stub.reap() + assert_equal(stub.phase(), "accounting") + assert_equal(stub.reason(), "extra_exchange_after_completion") + + +def test_scripted_rejects_missing_exchange() raises: + var scripts = List[ExchangeScript]() + scripts.append(_default_script()) + var stub = spawn_max_local_scripted(0, scripts^) + var client = TcpStream.connect(SocketAddr.localhost(UInt16(stub.port))) + client.close() + stub.reap() + assert_equal(stub.phase(), "accounting") + assert_equal(stub.reason(), "missing_exchanges") + assert_equal(stub.request_count(), 0) + + +def test_scripted_reports_unconsumed_remaining_scripts() raises: + var scripts = List[ExchangeScript]() + var first = _default_script() + first.close_connection = False + scripts.append(first^) + scripts.append(_default_script()) + var stub = spawn_max_local_scripted(0, scripts^) + var client = TcpStream.connect(SocketAddr.localhost(UInt16(stub.port))) + _write_all( + client, ( "POST /v1/chat/completions HTTP/1.1\r\nhost: 127.0.0.1\r\n" - "content-length: 2097152\r\nconnection: close\r\n\r\n" + "content-length: 2\r\nconnection: keep-alive\r\n\r\n{}" ), ) - assert_true(_stub_failed(stub)) + var response = _read_one_response(client) + client.close() + assert_true(response.find('{"ok":true}') >= 0) + stub.reap() + assert_equal(stub.reason(), "missing_exchanges") + assert_equal(stub.request_count(), 1) -def test_max_local_strict_persistent_exchanges_distinct_counters() raises: - var stub = spawn_max_local_stub(0, "count_requests", 2) - var response = _client_two_on_one(stub.port, "/v1/chat/completions") - assert_true(response.find('"request_index":1') >= 0) - assert_true(response.find('"request_index":2') >= 0) - assert_true(response.find('"connection_index":1') >= 0) - assert_true(response.find('"connection_index":2') < 0) +# ── FX03: strict lexical framing ──────────────────────────────────────────── + + +def _framing_failure(raw: String) raises -> SpawnedMaxLocalStub: + var scripts = List[ExchangeScript]() + scripts.append(_default_script()) + var stub = spawn_max_local_scripted(0, scripts^) + _raw_send_only(stub.port, raw) + stub.reap() + return stub^ + + +def test_strict_framing_lexical_content_length() raises: + var plus = _framing_failure( + "POST /v1/chat/completions HTTP/1.1\r\nhost: 127.0.0.1\r\n" + "content-length: +2\r\nconnection: close\r\n\r\n{}" + ) + assert_equal(plus.phase(), "read") + assert_equal(plus.reason(), "malformed_content_length") + var spaced = _framing_failure( + "POST /v1/chat/completions HTTP/1.1\r\nhost: 127.0.0.1\r\n" + "content-length: 2 3\r\nconnection: close\r\n\r\n{}" + ) + assert_equal(spaced.reason(), "malformed_content_length") + var empty = _framing_failure( + "POST /v1/chat/completions HTTP/1.1\r\nhost: 127.0.0.1\r\n" + "content-length:\r\nconnection: close\r\n\r\n" + ) + assert_equal(empty.reason(), "malformed_content_length") + var non_digit = _framing_failure( + "POST /v1/chat/completions HTTP/1.1\r\nhost: 127.0.0.1\r\n" + "content-length: 2x\r\nconnection: close\r\n\r\n{}" + ) + assert_equal(non_digit.reason(), "malformed_content_length") + var overflow = _framing_failure( + "POST /v1/chat/completions HTTP/1.1\r\nhost: 127.0.0.1\r\n" + "content-length: 99999999999999999999\r\nconnection: close\r\n\r\n" + ) + assert_equal(overflow.reason(), "content_length_overflow") + + +def test_strict_framing_versions_and_header_syntax() raises: + var version = _framing_failure( + "POST /v1/chat/completions NONHTTP\r\nhost: 127.0.0.1\r\n" + "content-length: 2\r\nconnection: close\r\n\r\n{}" + ) + assert_equal(version.phase(), "read") + assert_equal(version.reason(), "malformed_version") + var space_name = _framing_failure( + "POST /v1/chat/completions HTTP/1.1\r\nhost : 127.0.0.1\r\n" + "content-length: 2\r\nconnection: close\r\n\r\n{}" + ) + assert_equal(space_name.reason(), "malformed_header") + var obs_fold = _framing_failure( + "POST /v1/chat/completions HTTP/1.1\r\nhost: 127.0.0.1\r\n" + " content-length: 2\r\nconnection: close\r\n\r\n{}" + ) + assert_equal(obs_fold.reason(), "malformed_header") + + +def test_strict_framing_conflicting_and_transfer_modes() raises: + var conflicting = _framing_failure( + "POST /v1/chat/completions HTTP/1.1\r\nhost: 127.0.0.1\r\n" + "content-length: 2\r\ntransfer-encoding: identity\r\n" + "connection: close\r\n\r\n{}" + ) + assert_equal(conflicting.reason(), "conflicting_framing") + var chunked = _framing_failure( + "POST /v1/chat/completions HTTP/1.1\r\nhost:" + " 127.0.0.1\r\ntransfer-encoding: chunked\r\nconnection:" + " close\r\n\r\n2\r\n{}\r\n0\r\n\r\n" + ) + assert_equal(chunked.reason(), "unsupported_transfer_encoding") + var duplicate = _framing_failure( + "POST /v1/chat/completions HTTP/1.1\r\nhost: 127.0.0.1\r\n" + "transfer-encoding: identity\r\ntransfer-encoding: identity\r\n" + "connection: close\r\n\r\n" + ) + assert_equal(duplicate.reason(), "duplicate_transfer_encoding") + + +def test_strict_framing_split_utf8_body() raises: + var scripts = List[ExchangeScript]() + var script = exchange_script( + "utf8", "POST", "/v1/chat/completions", 200, '{"ok":true}' + ) + script.check_body = True + var payload = String("") + for _ in range(800): + payload += "é" + script.body = payload + scripts.append(script^) + var stub = spawn_max_local_scripted(0, scripts^) + var client = TcpStream.connect(SocketAddr.localhost(UInt16(stub.port))) + var head = ( + "POST /v1/chat/completions HTTP/1.1\r\nhost: 127.0.0.1\r\n" + "content-length: " + + String(payload.byte_length()) + + "\r\nconnection: close\r\n\r\n" + ) + _write_all(client, head) + var payload_bytes = payload.as_bytes() + var sent = 0 + while sent < payload.byte_length(): + var end = sent + 3 + if end > payload.byte_length(): + end = payload.byte_length() + client.write_all(payload_bytes[sent:end]) + sent = end + var response = _read_all(client) + client.close() + assert_true(response.find("200") >= 0) stub.wait() -def test_jev_strict_rejects_x_authorization() raises: - var started = spawn_jev_stub_auto("echo_authorization", 1) - var response = _client_send_raw( +def test_strict_framing_surplus_retained_for_second_frame() raises: + var scripts = List[ExchangeScript]() + var first = exchange_script( + "first", "POST", "/v1/chat/completions", 200, '{"n":1}' + ) + first.close_connection = False + scripts.append(first^) + var second = exchange_script( + "second", "POST", "/v1/chat/completions", 200, '{"n":2}' + ) + scripts.append(second^) + var stub = spawn_max_local_scripted(0, scripts^) + var first_frame = ( + "POST /v1/chat/completions HTTP/1.1\r\nhost: 127.0.0.1\r\n" + "content-length: 2\r\nconnection: keep-alive\r\n\r\n{}" + ) + var second_frame = String(first_frame) + var response = _raw_exchange(stub.port, first_frame + second_frame) + assert_true(response.find('{"n":1}') >= 0) + assert_true(response.find('{"n":2}') >= 0) + stub.wait() + assert_equal(stub.request_count(), 2) + assert_equal(stub.connection_count(), 1) + + +# ── FX04: route/method before auth, exact headers, safe escaping ──────────── + + +def test_jev_scripted_wrong_route_auth_not_bypassed() raises: + var scripts = List[ExchangeScript]() + var script = exchange_script( + "jev_expect", "POST", "/v1/systemone", 200, '{"ok":true}' + ) + script.require_bearer = True + scripts.append(script^) + var started = spawn_jev_scripted_auto(scripts^) + _raw_send_only( started.port, ( - "POST /v1/systemone HTTP/1.1\r\nhost: 127.0.0.1\r\nx-authorization:" - " Bearer spoof\r\ncontent-type: application/json\r\ncontent-length:" - " 2\r\nconnection: close\r\n\r\n{}" + "POST /not-a-route HTTP/1.1\r\nhost: 127.0.0.1\r\n" + "x-authorization: Bearer spoof\r\ncontent-length: 2\r\n" + "connection: close\r\n\r\n{}" + ), + ) + started.stub.reap() + assert_equal(started.stub.phase(), "exchange") + assert_equal(started.stub.reason(), "path_mismatch") + + +def test_scripted_rejects_duplicate_authorization() raises: + var scripts = List[ExchangeScript]() + var script = exchange_script( + "dup_auth", "POST", "/v1/chat/completions", 200, '{"ok":true}' + ) + script.require_bearer = True + scripts.append(script^) + var stub = spawn_max_local_scripted(0, scripts^) + _raw_send_only( + stub.port, + ( + "POST /v1/chat/completions HTTP/1.1\r\nhost: 127.0.0.1\r\n" + "authorization: Bearer one\r\nauthorization: Bearer two\r\n" + "content-length: 2\r\nconnection: close\r\n\r\n{}" ), ) - assert_true(response.find("401") >= 0) - assert_true(response.find("spoof") < 0) + stub.reap() + assert_equal(stub.phase(), "exchange") + assert_equal(stub.reason(), "auth_duplicate") + + +def test_json_escape_control_characters() raises: + assert_equal(json_escape("a\nb\tc"), '"a\\nb\\tc"') + assert_equal(json_escape('q"uote'), '"q\\"uote"') + assert_equal(json_escape("back\\slash"), '"back\\\\slash"') + assert_equal(json_escape("ctl\x01"), '"ctl\\u0001"') + + +def test_verify_exchange_mutation_controls() raises: + # Deliberate in-memory mutations: each must fail for its own bounded + # reason rather than any generic exception (FX07). + var script = exchange_script( + "control", "POST", "/v1/chat/completions", 200, "{}" + ) + script.headers = "x-sel:1" + script.check_body = True + script.body = '{"b":1}' + var ok = FramedRequest( + ok=True, + error="", + method="POST", + path="/v1/chat/completions", + version="HTTP/1.1", + headers_raw="host: h\r\nx-sel:1", + body='{"b":1}', + content_length=7, + keep_alive=False, + total_bytes=0, + ) + assert_equal(verify_exchange(script, ok), "") + var wrong_method = FramedRequest( + ok=True, + error="", + method="PUT", + path="/v1/chat/completions", + version="HTTP/1.1", + headers_raw="host: h\r\nx-sel:1", + body='{"b":1}', + content_length=7, + keep_alive=False, + total_bytes=0, + ) + assert_equal(verify_exchange(script, wrong_method), "method_mismatch") + var wrong_path = FramedRequest( + ok=True, + error="", + method="POST", + path="/v1/other", + version="HTTP/1.1", + headers_raw="host: h\r\nx-sel:1", + body='{"b":1}', + content_length=7, + keep_alive=False, + total_bytes=0, + ) + assert_equal(verify_exchange(script, wrong_path), "path_mismatch") + var missing_header = FramedRequest( + ok=True, + error="", + method="POST", + path="/v1/chat/completions", + version="HTTP/1.1", + headers_raw="host: h", + body='{"b":1}', + content_length=7, + keep_alive=False, + total_bytes=0, + ) + assert_equal( + verify_exchange(script, missing_header), "header_missing:x-sel" + ) + var wrong_body = FramedRequest( + ok=True, + error="", + method="POST", + path="/v1/chat/completions", + version="HTTP/1.1", + headers_raw="host: h\r\nx-sel:1", + body='{"b":2}', + content_length=7, + keep_alive=False, + total_bytes=0, + ) + assert_equal(verify_exchange(script, wrong_body), "body_mismatch") + assert_equal( + authorization_reason( + ( + "POST /v1/chat/completions HTTP/1.1\r\nx-authorization: Bearer" + " spoof" + ), + True, + ), + "auth_missing", + ) + assert_equal( + authorization_reason( + "POST /v1/chat/completions HTTP/1.1\r\nauthorization: Bearer t", + True, + ), + "", + ) + + +# ── FX05: persistent reusable/close connection semantics ──────────────────── + + +def test_scripted_persistent_counters_and_close_semantics() raises: + var scripts = List[ExchangeScript]() + var first = exchange_script( + "reusable", "POST", "/v1/systemone", 200, '{"step":1}' + ) + first.close_connection = False + first.response_headers = "x-step: one" + scripts.append(first^) + var second = exchange_script( + "close", "POST", "/v1/systemone", 200, '{"step":2}' + ) + second.response_headers = "x-step: two" + scripts.append(second^) + var started = spawn_jev_scripted_auto(scripts^) + var first_frame = ( + "POST /v1/systemone HTTP/1.1\r\nhost: 127.0.0.1\r\n" + "authorization: Bearer t\r\ncontent-length: 2\r\n" + "connection: keep-alive\r\n\r\n{}" + ) + var second_frame = String(first_frame) + var response = _raw_exchange(started.port, first_frame + second_frame) + assert_true(response.find("x-step: one") >= 0) + assert_true(response.find("x-step: two") >= 0) + assert_true(response.find("connection: keep-alive") >= 0) + assert_true(response.find("connection: close") >= 0) started.stub.wait() + assert_equal(started.stub.request_count(), 2) + assert_equal(started.stub.connection_count(), 1) -def test_max_local_stub_stalled_child_is_reaped() raises: +# ── FX06/FX07/FX08: parent lifecycle and cause-specific failures ──────────── + + +def test_startup_failure_distinct_from_exchange_failure() raises: + # Occupy a port so the fixture child cannot bind; the parent must observe + # a startup failure (no ready line), not a script/parser rejection. + var blocker = TcpListener.bind(SocketAddr.localhost(0)) + var port = Int(blocker.local_addr().port) + var raised = False + try: + var stub = spawn_max_local_stub(port, "count_requests", 1) + stub.terminate() + except: + raised = True + blocker.close() + assert_true(raised) + + +def test_owned_child_reaped_after_early_terminate() raises: + var stub = spawn_max_local_stub(0, "count_requests", 1) + stub.terminate() + assert_true(pid_not_waitable(stub.pid)) + + +def test_repeated_failures_leave_no_owned_child() raises: + for _ in range(3): + var scripts = List[ExchangeScript]() + scripts.append(_default_script()) + var stub = spawn_max_local_scripted(0, scripts^) + _raw_send_only( + stub.port, + ( + "POST /v1/nope HTTP/1.1\r\nhost: 127.0.0.1\r\n" + "content-length: 2\r\nconnection: close\r\n\r\n{}" + ), + ) + stub.reap() + assert_true(not stub.ok()) + assert_equal(stub.reason(), "path_mismatch") + assert_true(pid_not_waitable(stub.pid)) + + +def test_timeout_terminates_and_reaps_stalled_child() raises: var stub = spawn_max_local_stub(0, "stall", 1) - _client_send_only( + _raw_send_only( stub.port, ( "POST /v1/chat/completions HTTP/1.1\r\nhost: 127.0.0.1\r\n" @@ -227,6 +717,7 @@ def test_max_local_stub_stalled_child_is_reaped() raises: ), ) stub.terminate() + assert_true(pid_not_waitable(stub.pid)) def main() raises: diff --git a/tests/test_repo_local_process_contract.mojo b/tests/test_repo_local_process_contract.mojo @@ -8,6 +8,7 @@ from stdio_process_helper import ( HYF_PATHS_REPO_LOCAL_ROOT_ENV, ScopedEnvVar, run_stdio_entrypoint, + run_stdio_entrypoint_with_deadline, ) @@ -89,5 +90,31 @@ def test_src_main_consumes_repo_local_env_without_outer_wrapper() raises: ) +def test_run_stdio_entrypoint_reaps_stalled_child_under_deadline() raises: + # FX06/FX08: the parent deadline (not the child alarm) must terminate and + # reap the owned child, and the raised error must carry the reap result. + var message = "" + try: + _ = run_stdio_entrypoint_with_deadline( + "tests/stdio_stall_entrypoint.mojo", "{}", "", "", 3000 + ) + except e: + message = String(e) + assert_true(message.find("stdio-entrypoint") >= 0) + assert_true(message.find("signal=") >= 0 or message.find("exited=") >= 0) + + +def test_run_stdio_entrypoint_classifies_loader_failure() raises: + var message = "" + try: + _ = run_stdio_entrypoint_with_deadline( + "tests/does_not_exist_entrypoint.mojo", "{}", "", "", 30000 + ) + except e: + message = String(e) + assert_true(message.find("child_failed") >= 0) + assert_true(message.find("timeout") < 0) + + def main() raises: TestSuite.discover_tests[__functions_in_module()]().run()