Mizan-Rust backend adapter: server-side substrate + three-way parity
Adds first-class Rust-backed Mizan to sit alongside mizan-django and
mizan-fastapi. A Rust dev writes:
#[derive(Mizan, Serialize, Deserialize)]
pub struct ProfileOutput { pub user_id: i64, pub name: String }
#[mizan::context("user")]
pub struct UserCtx;
#[mizan::client(context = UserCtx)]
pub async fn user_profile(_req: &RequestHandle<'_>, user_id: i64) -> ProfileOutput { ... }
…and gets byte-identical KDL to the Python emitters, served over the
same wire protocol the React / Rust / Vue / Svelte kernels speak.
New crates:
- cores/mizan-rust/ (Cargo: mizan-core) — IR types, KDL emitter, traits, registry,
runtime (compute_invalidation / compute_merges
ported from mizan-fastapi), graph_check with
structural type-matching
- cores/mizan-rust-macros/ (Cargo: mizan-macros) — #[derive(Mizan)], #[mizan::context],
#[mizan::client] proc macros
- backends/mizan-rust-axum/ (Cargo: mizan-axum) — axum HTTP adapter: /session/, /call/, /ctx/:name/
- tests/afi/rust_app/ — AFI fixture port + server / export-ir binaries
Substrate-shape moves required by cross-language equivalence:
- IR canonicalization: functions / contexts / context-members / shared-by
now sort alphabetically in both Python and Rust emitters. The IR is a
contract; linkme doesn't preserve declaration order, so canonical sort
is the only stable mapping. afi_ir.kdl + per-target baselines regenerated.
- MizanType::TYPE_NAME is a const (with a default type_name() reader) so
it's usable in linkme TypeEntry static initializers.
- Tree-shaken type registry: #[derive(Mizan)] only emits the trait impl;
the #[mizan::client] macro registers canonical-named entries from
fn signatures, including Vec<T> element types for ref resolution.
- Merge resolution is structural (NamedType shape comparison) rather than
by name — matches the Python types_match_for_merge semantics.
Three-way forcing functions:
- tests/afi/test_codegen_parity.py — Django ≡ FastAPI ≡ Rust on KDL bytes (3 pass)
- tests/rust/run_wire_parity.py — 12/12 probes against FastAPI + Rust (EXIT=0)
Incidental fixes surfaced by the new tests:
- Stale `from .registry import validate_registry` import removed from
mizan-django/setup/discovery.py (referenced a function that no longer
exists; was masking codegen-parity).
- BASE_DIR added to tests/afi/django_app/project/settings.py.
- /session/ endpoint added to mizan-fastapi for protocol-shaped readiness
probe parity (wire-parity harness now polls /api/mizan/session/ on both
backends rather than FastAPI's /openapi.json).
- Root .gitignore picks up Rust target/ across the tree so new crates
don't need per-crate gitignore.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,13 +1,20 @@
|
||||
"""Drive the wire-parity check end-to-end.
|
||||
"""Three-way wire-parity check.
|
||||
|
||||
1. Boot the FastAPI fixture app via uvicorn on a free port.
|
||||
2. Poll /openapi.json until the server is up.
|
||||
3. Run the Rust `drive_kernel` binary (raw kernel calls) against it.
|
||||
4. Run the Rust `drive_emitted` binary (typed codegen functions) against
|
||||
the same server.
|
||||
5. Tear the server down.
|
||||
For each backend (FastAPI, Rust axum):
|
||||
1. Boot the fixture server on a free port.
|
||||
2. Poll /api/mizan/session/ until the server responds.
|
||||
3. Run the Rust `drive_kernel` binary (raw kernel calls) against it.
|
||||
4. Run the Rust `drive_emitted` binary (typed codegen functions) against it.
|
||||
5. Tear the server down.
|
||||
|
||||
Either non-zero driver exit propagates as the script's exit code.
|
||||
Any non-zero driver exit propagates as the script's exit code. Adding the
|
||||
Rust backend here proves that mizan-axum honors the same wire contract as
|
||||
mizan-fastapi — same JSON shapes, same invalidate/merge semantics — beyond
|
||||
the static IR equivalence the codegen-parity test gates.
|
||||
|
||||
Readiness probe is `/api/mizan/session/` (Mizan-protocol-shaped) rather
|
||||
than `/openapi.json` (FastAPI-feature-shaped) so the harness reads the
|
||||
same surface across backends.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -25,6 +32,7 @@ from pathlib import Path
|
||||
REPO_ROOT = Path(__file__).resolve().parents[2]
|
||||
AFI_DIR = REPO_ROOT / "tests" / "afi"
|
||||
RUST_DIR = REPO_ROOT / "tests" / "rust"
|
||||
RUST_APP_DIR = AFI_DIR / "rust_app"
|
||||
BOOT_TIMEOUT_S = 15.0
|
||||
POLL_INTERVAL_S = 0.25
|
||||
|
||||
@@ -35,24 +43,22 @@ def pick_free_port() -> int:
|
||||
return s.getsockname()[1]
|
||||
|
||||
|
||||
def wait_for_server(port: int, timeout_s: float) -> bool:
|
||||
def wait_for_server(port: int, timeout_s: float, label: str) -> bool:
|
||||
deadline = time.monotonic() + timeout_s
|
||||
url = f"http://127.0.0.1:{port}/openapi.json"
|
||||
url = f"http://127.0.0.1:{port}/api/mizan/session/"
|
||||
while time.monotonic() < deadline:
|
||||
try:
|
||||
with urllib.request.urlopen(url, timeout=1.0) as resp:
|
||||
if resp.status == 200:
|
||||
return True
|
||||
except (urllib.error.URLError, ConnectionError, OSError) as e:
|
||||
# Surface the kind of failure so a stuck boot doesn't read
|
||||
# as "silently waiting"; the loop continues until timeout.
|
||||
sys.stderr.write(f"[wire_parity] waiting for server: {type(e).__name__}\n")
|
||||
sys.stderr.write(f"[wire_parity:{label}] waiting: {type(e).__name__}\n")
|
||||
time.sleep(POLL_INTERVAL_S)
|
||||
return False
|
||||
|
||||
|
||||
def run_driver(name: str, base_url: str) -> int:
|
||||
sys.stdout.write(f"\n=== {name} ===\n")
|
||||
def run_driver(name: str, base_url: str, label: str) -> int:
|
||||
sys.stdout.write(f"\n=== {label} :: {name} ===\n")
|
||||
sys.stdout.flush()
|
||||
return subprocess.run(
|
||||
["cargo", "run", "--quiet", "--bin", name, "--", base_url],
|
||||
@@ -60,36 +66,47 @@ def run_driver(name: str, base_url: str) -> int:
|
||||
).returncode
|
||||
|
||||
|
||||
def main() -> int:
|
||||
port = pick_free_port()
|
||||
base_url = f"http://127.0.0.1:{port}/api/mizan"
|
||||
|
||||
server = subprocess.Popen(
|
||||
["uv", "run", "uvicorn", "fastapi_app:make_app",
|
||||
"--factory", "--port", str(port), "--log-level", "warning"],
|
||||
def boot_fastapi(port: int) -> subprocess.Popen:
|
||||
return subprocess.Popen(
|
||||
[
|
||||
"uv", "run", "uvicorn", "fastapi_app:make_app",
|
||||
"--factory", "--port", str(port), "--log-level", "warning",
|
||||
],
|
||||
cwd=AFI_DIR,
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.PIPE,
|
||||
)
|
||||
|
||||
|
||||
def boot_rust(port: int) -> subprocess.Popen:
|
||||
return subprocess.Popen(
|
||||
[
|
||||
"cargo", "run", "--quiet", "--release",
|
||||
"--manifest-path", str(RUST_APP_DIR / "Cargo.toml"),
|
||||
"--bin", "server",
|
||||
],
|
||||
env={**os.environ, "PORT": str(port)},
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.PIPE,
|
||||
)
|
||||
|
||||
|
||||
def run_against(label: str, server: subprocess.Popen, port: int) -> int:
|
||||
failures = 0
|
||||
try:
|
||||
if not wait_for_server(port, BOOT_TIMEOUT_S):
|
||||
sys.stderr.write(
|
||||
f"[wire_parity] server failed to start within {BOOT_TIMEOUT_S}s\n",
|
||||
)
|
||||
stderr_tail = server.stderr.read(4096) if server.stderr else b""
|
||||
if stderr_tail:
|
||||
sys.stderr.write(stderr_tail.decode("utf-8", errors="replace"))
|
||||
if not wait_for_server(port, BOOT_TIMEOUT_S, label):
|
||||
sys.stderr.write(f"[wire_parity:{label}] server boot timed out after {BOOT_TIMEOUT_S}s\n")
|
||||
if server.stderr:
|
||||
tail = server.stderr.read(4096)
|
||||
if tail:
|
||||
sys.stderr.write(tail.decode("utf-8", errors="replace"))
|
||||
return 1
|
||||
|
||||
failures = 0
|
||||
base_url = f"http://127.0.0.1:{port}/api/mizan"
|
||||
for driver in ("drive_kernel", "drive_emitted"):
|
||||
rc = run_driver(driver, base_url)
|
||||
rc = run_driver(driver, base_url, label)
|
||||
if rc != 0:
|
||||
sys.stderr.write(f"[wire_parity] {driver} exited {rc}\n")
|
||||
sys.stderr.write(f"[wire_parity:{label}] {driver} exited {rc}\n")
|
||||
failures += 1
|
||||
|
||||
return 0 if failures == 0 else 1
|
||||
finally:
|
||||
server.terminate()
|
||||
try:
|
||||
@@ -97,6 +114,21 @@ def main() -> int:
|
||||
except subprocess.TimeoutExpired:
|
||||
server.kill()
|
||||
server.wait()
|
||||
return failures
|
||||
|
||||
|
||||
def main() -> int:
|
||||
total_failures = 0
|
||||
|
||||
fastapi_port = pick_free_port()
|
||||
sys.stdout.write(f"[wire_parity] booting fastapi on port {fastapi_port}\n")
|
||||
total_failures += run_against("fastapi", boot_fastapi(fastapi_port), fastapi_port)
|
||||
|
||||
rust_port = pick_free_port()
|
||||
sys.stdout.write(f"[wire_parity] booting rust on port {rust_port}\n")
|
||||
total_failures += run_against("rust", boot_rust(rust_port), rust_port)
|
||||
|
||||
return 0 if total_failures == 0 else 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
Reference in New Issue
Block a user