A channel's message slots are named from the client, on every backend

The IR called them react-message and django-message, so a FastAPI channel had
to declare a DjangoMessage. They are client-message and server-message now,
and the direction words hold wherever a channel is declared: Params /
ClientMessage / ServerMessage, with mizan-core deriving <Pascal>Params and
friends so no backend names a type itself. Django's ReactChannel and
FastAPI's ReactChannel are both Channel.

mizan-fastapi never registered a channels extension, so build_ir() emitted no
channel at all and every payload type was invisible to codegen. It registers
one now. RegistryExtension is an ABC requiring all(), which is what the IR
reads — an extension that cannot enumerate its registrations no longer exists.

The gate that should have caught the rename could not: tests/afi registered no
channel because mizan-rust had no channel registry to register one in, so a
five-package rename of the wire contract passed byte-parity without a channel
byte crossing it. mizan-rust grows ChannelSlotKind, a CHANNELS slice, a
#[mizan::channel] macro, and KDL emission whose wire_to_pascal matches Python's
split; the AFI fixture now carries a channel with every slot and one with a
single slot, so all three backends prove the contract byte for byte.

MizanChannel held three Option<String> beside three has_*() predicates and
unwrapped them with defaults; it holds an ordered slot vector, so an absent
slot is absent rather than defaulted. The channels target emitted a React
hooks file that a stage1-only consumer could not compile — react emits that
now. The codegen's parity tests byte-compared emitted source against baselines
without ever compiling it: they compile the generated crate and run its tests,
import the generated Python package and call every method, and typecheck each
TypeScript target against a consumer.

Also fixed at source: app_visitor printed its import diagnostic to stdout, the
stream export_mizan_ir writes KDL to, so a failed import silently corrupted the
IR; the apps root was hardcoded to "apps"; _default_literal crashed build_ir on
any non-JSON-serializable field default; Django and mizan-core derived Pascal
names two different ways, disagreeing on every dotted channel name.

ir.py builds a document and renders templates/ir/document.kdl.j2 rather than
appending KDL strings with hand-tracked indentation, and named types resolve to
a fixed point — a model reachable only through a union branch was referenced by
a ref that no type block ever defined.

The rest is the write-gate's own classifiers run over the standing tree:
relative imports, silent swallows, Protocol contracts that should be ABCs,
emitters hand-rendering target source, catch-all arms over closed enums, and
comments narrating the project rather than the code.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-27 14:03:19 -04:00
parent 398c90fc8b
commit 3aafec6dd4
345 changed files with 11054 additions and 17359 deletions

View File

@@ -1,22 +1,7 @@
//! `MizanClient` — the kernel entry point.
//!
//! Mirrors the `configure(opts)` + module-level state in
//! `frontends/mizan-base/src/index.ts`, but as an owned struct because
//! Rust lacks module-level mutable state. Consumers hold an
//! `Arc<MizanClient>` and pass it everywhere the TS code would have
//! used the module-level `config`.
//!
//! Public surface:
//! - `MizanClient::new(config)` — build with reqwest cookie jar.
//! - `client.fetch_context(name, params)` — async, returns parsed JSON bundle.
//! - `client.call(fn_name, args)` — async, applies merge + invalidation
//! from the response then returns `result`.
//! - `client.register_context(name, params, fetch_fn)` — register an
//! instance; returns a `ContextHandle`.
//! - `client.invalidate(name)` / `client.invalidate_scoped(name, params)`
//! — schedule invalidation via the kernel queue.
//! - `client.merge(context, params, slot, value)` — splice a value into
//! a context bundle slot.
//! `MizanClient` — the kernel entry point. It owns the reqwest client and
//! its cookie jar, the context registry, and the invalidation queue.
//! Consumers hold an `Arc<MizanClient>` and reach every request path
//! through it.
use std::sync::Arc;
use std::time::Duration;
@@ -34,6 +19,9 @@ use crate::transport;
pub struct MizanConfig {
/// Absolute URL of the mounted Mizan router. reqwest has no document
/// origin to resolve against, so a browser-style relative path will
/// not build a client.
pub base_url: String,
pub session: bool,
pub csrf_cookie_name: String,
@@ -45,7 +33,7 @@ pub struct MizanConfig {
impl Default for MizanConfig {
fn default() -> Self {
Self {
base_url: "/api/mizan".to_string(),
base_url: "http://localhost:8000/api/mizan".to_string(),
session: true,
csrf_cookie_name: "csrftoken".to_string(),
csrf_header_name: "X-CSRFToken".to_string(),
@@ -55,8 +43,18 @@ impl Default for MizanConfig {
}
/// The CSRF state the cookie jar holds right now. `Unset` is a jar the
/// server has not put a CSRF cookie in, and requests go out without the
/// header.
pub(crate) enum Csrf {
Unset,
Token(String),
}
pub struct MizanClient {
config: Arc<MizanConfig>,
base: Url,
http: reqwest::Client,
cookie_jar: Arc<reqwest::cookie::Jar>,
registry: Arc<ContextRegistry>,
@@ -66,16 +64,30 @@ pub struct MizanClient {
impl MizanClient {
/// Build a client. Both the TLS stack and the configured `base_url`
/// are resolved here, once, so every request path below reads an
/// absolute `Url` and a live HTTP client that exist by construction.
pub fn new(config: MizanConfig) -> Arc<Self> {
let cookie_jar = Arc::new(reqwest::cookie::Jar::default());
let http = reqwest::Client::builder()
let http = match reqwest::Client::builder()
.cookie_provider(Arc::clone(&cookie_jar))
.build()
.expect("reqwest client construction");
{
Ok(client) => client,
Err(e) => panic!("the rustls TLS backend failed to initialize: {e}"),
};
let base = match Url::parse(&config.base_url) {
Ok(url) => url,
Err(e) => panic!(
"MizanConfig.base_url must be an absolute URL; got {:?} ({e})",
config.base_url
),
};
let registry = Arc::new(ContextRegistry::new());
let queue = InvalidationQueue::new(Arc::clone(&registry));
Arc::new(Self {
config: Arc::new(config),
base,
http,
cookie_jar,
registry,
@@ -88,6 +100,14 @@ impl MizanClient {
&self.config
}
/// The absolute URL of `<base_url>/<suffix>`. `Url::set_path` cannot
/// fail on a base that already parsed as hierarchical.
pub(crate) fn endpoint(&self, suffix: &str) -> Url {
let mut url = self.base.clone();
url.set_path(&format!("{}/{}", self.base.path().trim_end_matches('/'), suffix));
url
}
pub fn http(&self) -> &reqwest::Client {
&self.http
}
@@ -101,65 +121,99 @@ impl MizanClient {
}
/// Hit `/session/` once on first call to bootstrap the CSRF cookie.
/// No-op when `config.session == false`. Three attempts with 100ms
/// × attempt backoff.
pub async fn ensure_session_ready(&self) -> Result<(), MizanError> {
/// No-op when `config.session == false`. Three attempts with 100ms ×
/// attempt backoff. A bootstrap that never lands a cookie is reported
/// on stderr and left at that: subsequent calls proceed without CSRF
/// and still succeed against a server that does not require it.
pub async fn ensure_session_ready(&self) {
if !self.config.session {
return Ok(());
return;
}
self.session_ready
.get_or_try_init(|| async {
if self.read_csrf_cookie().is_some() {
return Ok(());
.get_or_init(|| async {
if let Csrf::Token(_) = self.csrf() {
return;
}
let url = Url::parse(&format!("{}/session/", self.config.base_url.trim_end_matches('/')))
.map_err(|e| MizanError::transport(format!("invalid base_url: {e}")))?;
for attempt in 0..3 {
let res = self.http.get(url.clone()).send().await;
if res.is_ok() && self.read_csrf_cookie().is_some() {
return Ok(());
let url = self.endpoint("session/");
for attempt in 0..3u32 {
match self.http.get(url.clone()).send().await {
Ok(_) => {
if let Csrf::Token(_) = self.csrf() {
return;
}
}
Err(e) => eprintln!("[mizan] session bootstrap attempt {attempt}: {e}"),
}
if attempt < 2 {
tokio::time::sleep(Duration::from_millis(100 * (attempt as u64 + 1))).await;
}
}
// Mirror TS: failing to bootstrap is non-fatal — subsequent
// calls proceed without CSRF and may still succeed (e.g.,
// FastAPI configs that don't require it).
Ok(())
eprintln!(
"[mizan] session bootstrap did not yield a {:?} cookie; \
requests will carry no CSRF header",
self.config.csrf_cookie_name
);
})
.await
.copied()
.await;
}
pub(crate) async fn resolve_headers(&self) -> HeaderMap {
let mut headers = HeaderMap::new();
for (name, value) in &self.config.extra_headers {
if let (Ok(n), Ok(v)) = (HeaderName::try_from(name.as_str()), HeaderValue::try_from(value.as_str())) {
headers.insert(n, v);
}
self.insert_header(&mut headers, name, value);
}
if let Some(token) = self.read_csrf_cookie() {
if let (Ok(n), Ok(v)) = (
HeaderName::try_from(self.config.csrf_header_name.as_str()),
HeaderValue::try_from(token.as_str()),
) {
headers.insert(n, v);
match self.csrf() {
Csrf::Unset => {}
Csrf::Token(token) => {
let header_name = self.config.csrf_header_name.clone();
self.insert_header(&mut headers, &header_name, &token);
}
}
headers.insert(ACCEPT, HeaderValue::from_static("application/json"));
headers
}
fn read_csrf_cookie(&self) -> Option<String> {
let url = Url::parse(&self.config.base_url).ok()?;
let header = self.cookie_jar.cookies(&url)?;
let raw = header.to_str().ok()?;
/// Add one header, reporting a name or value reqwest refuses rather
/// than dropping it into a request that then behaves inexplicably.
fn insert_header(&self, headers: &mut HeaderMap, name: &str, value: &str) {
let parsed_name = match HeaderName::try_from(name) {
Ok(n) => n,
Err(e) => {
eprintln!("[mizan] header name {name:?} rejected: {e}");
return;
}
};
match HeaderValue::try_from(value) {
Ok(v) => {
headers.insert(parsed_name, v);
}
Err(e) => eprintln!("[mizan] value for header {name:?} rejected: {e}"),
}
}
/// Which CSRF state the jar spells for `base`: the `Cookie` header the
/// jar holds for that URL, decoded as ASCII, scanned for the
/// configured cookie name. Any reading short of that is `Unset`, and a
/// header carrying bytes outside ASCII is reported on stderr.
pub(crate) fn csrf(&self) -> Csrf {
let header = match self.cookie_jar.cookies(&self.base) {
None => return Csrf::Unset,
Some(header) => header,
};
let pairs = match header.to_str() {
Ok(text) => text,
Err(e) => {
eprintln!("[mizan] cookie header from the server is not valid ASCII: {e}");
return Csrf::Unset;
}
};
let needle = format!("{}=", self.config.csrf_cookie_name);
raw.split(';')
.map(|p| p.trim())
.find_map(|p| p.strip_prefix(&needle))
.map(|v| v.trim_matches('"').to_string())
for part in pairs.split(';') {
if let Some(token) = part.trim().strip_prefix(&needle) {
return Csrf::Token(token.trim_matches('"').to_string());
}
}
Csrf::Unset
}
// ── High-level API ─────────────────────────────────────────────────
@@ -193,3 +247,153 @@ impl MizanClient {
self.registry.merge(context, params, slot, value).await;
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn endpoint_appends_to_the_mounted_prefix() {
let client = MizanClient::new(MizanConfig {
base_url: "http://127.0.0.1:8765/api/mizan".to_string(),
session: false,
..Default::default()
});
assert_eq!(
client.endpoint("call/").as_str(),
"http://127.0.0.1:8765/api/mizan/call/"
);
assert_eq!(
client.endpoint("ctx/user/").as_str(),
"http://127.0.0.1:8765/api/mizan/ctx/user/"
);
}
#[test]
fn endpoint_tolerates_a_trailing_slash_on_the_base() {
let client = MizanClient::new(MizanConfig {
base_url: "http://127.0.0.1:8765/api/mizan/".to_string(),
session: false,
..Default::default()
});
assert_eq!(
client.endpoint("session/").as_str(),
"http://127.0.0.1:8765/api/mizan/session/"
);
}
#[test]
#[should_panic(expected = "must be an absolute URL")]
fn relative_base_url_is_rejected_at_construction() {
MizanClient::new(MizanConfig {
base_url: "/api/mizan".to_string(),
session: false,
..Default::default()
});
}
#[test]
fn an_empty_jar_reads_as_unset() {
let client = MizanClient::new(MizanConfig {
base_url: "http://127.0.0.1:8765/api/mizan".to_string(),
session: false,
..Default::default()
});
assert!(matches!(client.csrf(), Csrf::Unset));
}
#[test]
fn a_seeded_jar_reads_back_the_token() {
let client = MizanClient::new(MizanConfig {
base_url: "http://127.0.0.1:8765/api/mizan".to_string(),
session: false,
..Default::default()
});
client
.cookie_jar
.add_cookie_str("csrftoken=\"abc123\"; Path=/", &client.base);
match client.csrf() {
Csrf::Unset => panic!("a jar carrying csrftoken must not read as Unset"),
Csrf::Token(token) => assert_eq!(token, "abc123"),
}
}
#[test]
fn a_jar_without_the_configured_name_reads_as_unset() {
let client = MizanClient::new(MizanConfig {
base_url: "http://127.0.0.1:8765/api/mizan".to_string(),
session: false,
csrf_cookie_name: "othertoken".to_string(),
..Default::default()
});
client
.cookie_jar
.add_cookie_str("csrftoken=abc123; Path=/", &client.base);
assert!(matches!(client.csrf(), Csrf::Unset));
}
#[test]
fn a_name_that_merely_ends_with_the_configured_name_is_not_the_token() {
let client = MizanClient::new(MizanConfig {
base_url: "http://127.0.0.1:8765/api/mizan".to_string(),
session: false,
..Default::default()
});
client
.cookie_jar
.add_cookie_str("xsrfcsrftoken=decoy; Path=/", &client.base);
assert!(matches!(client.csrf(), Csrf::Unset));
}
#[test]
fn the_token_is_found_among_several_cookies() {
let client = MizanClient::new(MizanConfig {
base_url: "http://127.0.0.1:8765/api/mizan".to_string(),
session: false,
..Default::default()
});
client
.cookie_jar
.add_cookie_str("sessionid=zzz; Path=/", &client.base);
client
.cookie_jar
.add_cookie_str("csrftoken=abc123; Path=/", &client.base);
match client.csrf() {
Csrf::Unset => panic!("csrftoken alongside other cookies must still be found"),
Csrf::Token(token) => assert_eq!(token, "abc123"),
}
}
#[tokio::test]
async fn resolve_headers_omits_csrf_when_the_jar_is_empty() {
let client = MizanClient::new(MizanConfig {
base_url: "http://127.0.0.1:8765/api/mizan".to_string(),
session: false,
..Default::default()
});
let headers = client.resolve_headers().await;
assert!(!headers.contains_key("X-CSRFToken"));
match headers.get(ACCEPT) {
None => panic!("every request must declare it accepts JSON"),
Some(accept) => assert_eq!(accept, "application/json"),
}
}
#[tokio::test]
async fn resolve_headers_carries_the_token_once_the_jar_holds_it() {
let client = MizanClient::new(MizanConfig {
base_url: "http://127.0.0.1:8765/api/mizan".to_string(),
session: false,
..Default::default()
});
client
.cookie_jar
.add_cookie_str("csrftoken=abc123; Path=/", &client.base);
let headers = client.resolve_headers().await;
match headers.get("X-CSRFToken") {
None => panic!("a jar carrying csrftoken must produce the CSRF header"),
Some(token) => assert_eq!(token, "abc123"),
}
}
}

View File

@@ -1,28 +1,27 @@
//! Context registry.
//!
//! Mirrors the `contexts: Map<string, Map<ParamKey, ContextEntry>>`
//! shape in `frontends/mizan-base/src/index.ts`. Each entry holds the
//! latest `ContextState`, a `tokio::sync::watch::Sender` for notifying
//! subscribers, and a fetch function the registry invokes on demand.
//! Keyed `context name → stable_key(params) → entry`. Each entry holds a
//! cell carrying the latest `ContextState` and the count of publishes that
//! produced it, plus a fetch function the registry invokes on demand.
//!
//! Subscribers receive a `ContextHandle` whose `rx: watch::Receiver`
//! they read from in their own loop. Watch channels overwrite the
//! previous value if the receiver hasn't consumed it yet — the render
//! loop sees only the latest state on each tick, never an intermediate
//! one. The TS kernel achieves the same effect via React's external
//! store re-render coalescing.
//! Subscribers receive a `ContextHandle` that holds the same cell and
//! remembers the count it last read. A burst of publishes between two
//! reads therefore collapses into a single advance to the newest state —
//! the render loop never sees an intermediate one.
use std::collections::hash_map::Entry as MapEntry;
use std::collections::BTreeMap;
use std::collections::HashMap;
use std::future::Future;
use std::pin::Pin;
use std::sync::Arc;
use serde_json::Value;
use tokio::sync::{Mutex, RwLock, mpsc, watch};
use serde_json::{Map, Value};
use tokio::sync::{Mutex, Notify, RwLock};
use tokio_util::sync::CancellationToken;
use crate::error::MizanError;
use crate::merge::merge_into_bundle;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
@@ -59,17 +58,106 @@ pub type FetchFn = Arc<
>;
/// The entry's current state and the number of publishes that produced it.
struct Published {
version: u64,
state: ContextStateRaw,
}
/// One entry's shared state. Publishers and readers reach the same
/// allocation through an `Arc`, so a reader's next state always arrives:
/// the cell lives exactly as long as the last side still holding it.
struct ContextCell {
published: RwLock<Published>,
advanced: Notify,
}
impl ContextCell {
fn new(initial: ContextStateRaw) -> Arc<Self> {
Arc::new(Self {
published: RwLock::new(Published { version: 0, state: initial }),
advanced: Notify::new(),
})
}
async fn state(&self) -> ContextStateRaw {
self.published.read().await.state.clone()
}
async fn version(&self) -> u64 {
self.published.read().await.version
}
async fn publish(&self, state: ContextStateRaw) {
{
let mut published = self.published.write().await;
published.version += 1;
published.state = state;
}
self.advanced.notify_waiters();
}
}
struct ContextEntry {
params: Value,
tx: watch::Sender<ContextStateRaw>,
cell: Arc<ContextCell>,
fetch_fn: FetchFn,
refetch_tx: mpsc::UnboundedSender<()>,
/// Cancel signal for the entry's spawned refetch loop. Set when the
/// last handle on the entry unregisters.
/// Raised to ask the entry's fetch loop for another pass. Several
/// raises before the loop wakes drive one fetch.
refetch: Arc<Notify>,
/// Cancel signal for the entry's spawned fetch loop. Set when the
/// entry is unregistered.
cancel: CancellationToken,
}
/// Run one entry's fetches. Each raise of `refetch` publishes a Loading
/// state, runs the entry's fetch closure, and publishes what it answered.
/// The closure is re-read from the entry every pass, so a re-registration
/// between passes takes effect.
fn spawn_fetch_loop(
entry: Arc<Mutex<ContextEntry>>,
refetch: Arc<Notify>,
cancel: CancellationToken,
) {
tokio::spawn(async move {
loop {
tokio::select! {
_ = cancel.cancelled() => break,
_ = refetch.notified() => {
let (fetch_fn, cell) = {
let entry = entry.lock().await;
(entry.fetch_fn.clone(), Arc::clone(&entry.cell))
};
let carried = cell.state().await.data;
cell.publish(ContextState {
data: carried,
status: ContextStatus::Loading,
error: None,
})
.await;
let next = match fetch_fn().await {
Ok(data) => ContextState {
data: Some(data),
status: ContextStatus::Success,
error: None,
},
Err(err) => ContextState {
data: cell.state().await.data,
status: ContextStatus::Error,
error: Some(Arc::new(err)),
},
};
cell.publish(next).await;
}
}
}
});
}
pub struct ContextRegistry {
/// Outer key: context name. Inner key: `stable_key(params)`.
entries: RwLock<HashMap<String, HashMap<String, Arc<Mutex<ContextEntry>>>>>,
@@ -88,6 +176,33 @@ impl ContextRegistry {
Self { entries: RwLock::new(HashMap::new()) }
}
/// The entries `(name, key)` selects — one when the pair names a live
/// entry, none otherwise. Merge and invalidate directives arrive from
/// the server, which names contexts and param scopes this client may
/// never have subscribed to, and those select nothing to act on. The
/// read lock is taken and released here, so callers hold no lock
/// while awaiting an entry's own mutex.
async fn entry_at(&self, name: &str, key: &str) -> Vec<Arc<Mutex<ContextEntry>>> {
let outer = self.entries.read().await;
match outer.get(name) {
None => Vec::new(),
Some(inner) => match inner.get(key) {
None => Vec::new(),
Some(entry) => vec![Arc::clone(entry)],
},
}
}
/// Every entry registered under `name`, across all param scopes. A
/// name nobody subscribed to selects none.
async fn entries_of(&self, name: &str) -> Vec<Arc<Mutex<ContextEntry>>> {
let outer = self.entries.read().await;
match outer.get(name) {
None => Vec::new(),
Some(inner) => inner.values().map(Arc::clone).collect(),
}
}
/// Register an instance of `(context_name, params)`. Idempotent —
/// re-registering the same key returns a handle on the existing
/// entry (the fetch_fn closure is replaced so the latest binding
@@ -105,83 +220,49 @@ impl ContextRegistry {
let mut outer = self.entries.write().await;
let inner = outer.entry(name.clone()).or_default();
if let Some(existing) = inner.get(&key).cloned() {
// Update the fetch closure so the latest registration's
// closure wins (matches the TS Strict-Mode behavior).
{
// `Entry` names both reachable states of the slot, so a repeat
// registration and a first registration are branches rather than
// an absence to test for.
let (cell, refetch, cancel) = match inner.entry(key.clone()) {
MapEntry::Occupied(occupied) => {
let existing = Arc::clone(occupied.get());
let mut entry = existing.lock().await;
entry.fetch_fn = fetch_fn;
(
Arc::clone(&entry.cell),
Arc::clone(&entry.refetch),
entry.cancel.clone(),
)
}
MapEntry::Vacant(slot) => {
let initial = match initial_data {
Some(data) => ContextState {
data: Some(data),
status: ContextStatus::Success,
error: None,
},
None => ContextStateRaw::idle(),
};
let cell = ContextCell::new(initial);
let refetch = Arc::new(Notify::new());
let cancel = CancellationToken::new();
let entry = Arc::new(Mutex::new(ContextEntry {
cell: Arc::clone(&cell),
fetch_fn,
refetch: Arc::clone(&refetch),
cancel: cancel.clone(),
}));
slot.insert(Arc::clone(&entry));
spawn_fetch_loop(entry, Arc::clone(&refetch), cancel.clone());
(cell, refetch, cancel)
}
let entry = existing.lock().await;
return ContextHandle {
rx: entry.tx.subscribe(),
refetch_tx: entry.refetch_tx.clone(),
cancel: entry.cancel.clone(),
registry: Arc::clone(self),
name,
key,
};
}
let initial = match initial_data {
Some(data) => ContextState { data: Some(data), status: ContextStatus::Success, error: None },
None => ContextStateRaw::idle(),
};
let (tx, _rx) = watch::channel(initial);
let (refetch_tx, mut refetch_rx) = mpsc::unbounded_channel::<()>();
let cancel = CancellationToken::new();
let entry = Arc::new(Mutex::new(ContextEntry {
params: params.clone(),
tx: tx.clone(),
fetch_fn: fetch_fn.clone(),
refetch_tx: refetch_tx.clone(),
cancel: cancel.clone(),
}));
inner.insert(key.clone(), Arc::clone(&entry));
drop(outer);
// Spawn the entry's refetch loop. The loop owns its own fetch
// closure handle resolution via the entry mutex — each tick
// reads the latest closure, so updates via re-register apply.
let entry_for_task = Arc::clone(&entry);
let cancel_for_task = cancel.clone();
tokio::spawn(async move {
loop {
tokio::select! {
_ = cancel_for_task.cancelled() => break,
msg = refetch_rx.recv() => {
if msg.is_none() { break; }
let (fetch_fn, tx) = {
let entry = entry_for_task.lock().await;
(entry.fetch_fn.clone(), entry.tx.clone())
};
// Loading state
let cur = tx.borrow().clone();
let loading = ContextState { data: cur.data, status: ContextStatus::Loading, error: None };
let _ = tx.send(loading);
// Drive the fetch
match fetch_fn().await {
Ok(data) => {
let _ = tx.send(ContextState { data: Some(data), status: ContextStatus::Success, error: None });
}
Err(err) => {
let cur = tx.borrow().clone();
let _ = tx.send(ContextState {
data: cur.data,
status: ContextStatus::Error,
error: Some(Arc::new(err)),
});
}
}
}
}
}
});
ContextHandle {
rx: tx.subscribe(),
refetch_tx,
seen: cell.version().await,
cell,
refetch,
cancel,
registry: Arc::clone(self),
name,
@@ -189,8 +270,9 @@ impl ContextRegistry {
}
}
/// Merge a value into a context entry's bundle slot. Mirrors the
/// TS kernel `merge(context, params, slot, value)` call.
/// Splice `value` into the `slot` of the selected entry's bundle and
/// publish the result. An entry that has no data yet, or whose bundle
/// already matches the merge, produces no notification.
pub async fn merge(
&self,
name: &str,
@@ -200,53 +282,39 @@ impl ContextRegistry {
) {
let key = match params {
Some(p) => stable_key(p),
None => stable_key(&Value::Object(Default::default())),
None => stable_key(&Value::Object(Map::new())),
};
let entry_handle = {
let outer = self.entries.read().await;
outer.get(name).and_then(|inner| inner.get(&key)).cloned()
};
let Some(entry_arc) = entry_handle else { return };
let entry = entry_arc.lock().await;
let cur = entry.tx.borrow().clone();
let Some(bundle) = cur.data.as_ref() else { return };
let Some(merged) = crate::merge::merge_into_bundle(bundle, slot, value) else { return };
let _ = entry.tx.send(ContextState {
data: Some(merged),
status: ContextStatus::Success,
error: None,
});
for entry_arc in self.entry_at(name, &key).await {
let cell = {
let entry = entry_arc.lock().await;
Arc::clone(&entry.cell)
};
let bundle = match cell.state().await.data {
None => continue,
Some(bundle) => bundle,
};
let merged = merge_into_bundle(&bundle, slot, value);
if merged == bundle {
continue;
}
cell.publish(ContextState {
data: Some(merged),
status: ContextStatus::Success,
error: None,
})
.await;
}
}
/// Trigger refetch on every entry of `name`.
pub async fn invalidate_broad(&self, name: &str) {
let entries = {
let outer = self.entries.read().await;
outer.get(name).map(|inner| inner.values().cloned().collect::<Vec<_>>())
};
let Some(entries) = entries else { return };
for entry in entries {
let tx = {
let e = entry.lock().await;
e.refetch_tx.clone()
};
let _ = tx.send(());
}
raise_refetch(self.entries_of(name).await).await;
}
/// Trigger refetch on the single entry matching `(name, params)`.
/// Trigger refetch on the entry matching `(name, params)`.
pub async fn invalidate_scoped(&self, name: &str, params: &Value) {
let key = stable_key(params);
let entry_arc = {
let outer = self.entries.read().await;
outer.get(name).and_then(|inner| inner.get(&key)).cloned()
};
let Some(entry_arc) = entry_arc else { return };
let tx = {
let entry = entry_arc.lock().await;
entry.refetch_tx.clone()
};
let _ = tx.send(());
raise_refetch(self.entry_at(name, &key).await).await;
}
async fn unregister(&self, name: &str, key: &str) {
@@ -264,9 +332,24 @@ impl ContextRegistry {
}
/// Ask each selected entry's fetch loop for another pass.
async fn raise_refetch(selected: Vec<Arc<Mutex<ContextEntry>>>) {
for entry_arc in selected {
let refetch = {
let entry = entry_arc.lock().await;
Arc::clone(&entry.refetch)
};
refetch.notify_one();
}
}
pub struct ContextHandle {
pub rx: watch::Receiver<ContextStateRaw>,
refetch_tx: mpsc::UnboundedSender<()>,
cell: Arc<ContextCell>,
/// The publish count this handle has already read. `changed()`
/// returns as soon as the cell moves past it.
seen: u64,
refetch: Arc<Notify>,
cancel: CancellationToken,
registry: Arc<ContextRegistry>,
name: String,
@@ -275,14 +358,34 @@ pub struct ContextHandle {
impl ContextHandle {
/// Drive a refetch. Returns immediately; the new state lands on
/// `rx` once the kernel's refetch task finishes the fetch.
/// Drive a refetch. Returns immediately; the new state lands on the
/// cell once the entry's fetch loop finishes the fetch.
pub fn refetch(&self) {
let _ = self.refetch_tx.send(());
self.refetch.notify_one();
}
pub fn state(&self) -> ContextStateRaw {
self.rx.borrow().clone()
pub async fn state(&self) -> ContextStateRaw {
self.cell.state().await
}
/// The next state published after the one this handle last read.
pub async fn changed(&mut self) -> ContextStateRaw {
loop {
// Enrol for the next advance before reading the count, so a
// publish landing between the read and the await still wakes
// this handle.
let advanced = self.cell.advanced.notified();
tokio::pin!(advanced);
advanced.as_mut().enable();
{
let published = self.cell.published.read().await;
if published.version > self.seen {
self.seen = published.version;
return published.state.clone();
}
}
advanced.await;
}
}
pub fn cancel_token(&self) -> CancellationToken {
@@ -295,19 +398,20 @@ impl ContextHandle {
}
/// Byte-identical to TS `JSON.stringify(params, Object.keys(params).sort())`.
///
/// Uses `BTreeMap` for deterministic key ordering and serializes via
/// `serde_json::to_string` (compact, no whitespace) — matches the TS
/// default. Non-object / non-string params (numbers, booleans) pass
/// through serde_json's standard JSON representation.
/// Compact JSON of `params` with object keys in sorted order, so two
/// callers that spell the same params in a different order land on the
/// same registry entry.
pub fn stable_key(params: &Value) -> String {
match params {
Value::Object(map) => {
let sorted: BTreeMap<&String, &Value> = map.iter().collect();
serde_json::to_string(&sorted).unwrap_or_default()
let sorted: BTreeMap<&str, &Value> = map.iter().map(|(k, v)| (k.as_str(), v)).collect();
let ordered: Map<String, Value> = sorted
.into_iter()
.map(|(k, v)| (k.to_string(), v.clone()))
.collect();
Value::Object(ordered).to_string()
}
other => serde_json::to_string(other).unwrap_or_default(),
other => other.to_string(),
}
}
@@ -316,6 +420,45 @@ pub fn stable_key(params: &Value) -> String {
mod tests {
use super::*;
use serde_json::json;
use std::sync::atomic::{AtomicU32, Ordering};
use std::time::Duration;
const SETTLE: Duration = Duration::from_secs(2);
/// Read states off `handle` until one reports Success.
async fn success_within(handle: &mut ContextHandle) -> ContextStateRaw {
let settled = tokio::time::timeout(SETTLE, async {
loop {
let state = handle.changed().await;
if state.status == ContextStatus::Success {
return state;
}
}
})
.await;
match settled {
Ok(state) => state,
Err(elapsed) => panic!("no Success state within {SETTLE:?}: {elapsed}"),
}
}
/// The bundle a state carries.
fn bundle(state: ContextStateRaw) -> Value {
match state.data {
Some(data) => data,
None => panic!("state carries no bundle"),
}
}
fn counted_fetch(counter: Arc<AtomicU32>) -> FetchFn {
Arc::new(move || {
let counter = Arc::clone(&counter);
Box::pin(async move {
let n = counter.fetch_add(1, Ordering::SeqCst) + 1;
Ok(json!({ "count": n }))
})
})
}
#[test]
fn stable_key_sorts_object_keys() {
@@ -333,32 +476,144 @@ mod tests {
#[tokio::test]
async fn register_and_refetch() {
let registry = Arc::new(ContextRegistry::new());
let counter = Arc::new(std::sync::atomic::AtomicU32::new(0));
let counter_clone = Arc::clone(&counter);
let fetch_fn: FetchFn = Arc::new(move || {
let counter = Arc::clone(&counter_clone);
Box::pin(async move {
let n = counter.fetch_add(1, std::sync::atomic::Ordering::SeqCst) + 1;
Ok(json!({ "count": n }))
})
});
let mut handle = registry.register("test", json!({}), fetch_fn, None).await;
let counter = Arc::new(AtomicU32::new(0));
let mut handle = registry
.register("test", json!({}), counted_fetch(Arc::clone(&counter)), None)
.await;
handle.refetch();
// Poll until success — watch::Receiver::changed() returns once
// per "newest value seen" advance, so back-to-back sends from the
// refetch task can coalesce into a single notification. The loop
// ignores intermediate Loading states and waits for Success.
loop {
tokio::time::timeout(std::time::Duration::from_secs(2), handle.rx.changed())
.await
.expect("changed timed out")
.unwrap();
if handle.state().status == ContextStatus::Success {
break;
}
}
let state = handle.state();
assert_eq!(state.data.unwrap()["count"], 1);
let state = success_within(&mut handle).await;
assert_eq!(bundle(state)["count"], 1);
}
#[tokio::test]
async fn a_state_published_between_reads_is_not_missed() {
let registry = Arc::new(ContextRegistry::new());
let counter = Arc::new(AtomicU32::new(0));
let mut handle = registry
.register("test", json!({}), counted_fetch(Arc::clone(&counter)), None)
.await;
handle.refetch();
success_within(&mut handle).await;
// Let the second fetch land in full before the handle asks for it,
// so `changed()` has to answer from the recorded advance rather
// than from a wakeup it was present for.
registry.invalidate_broad("test").await;
tokio::time::sleep(Duration::from_millis(300)).await;
let state = success_within(&mut handle).await;
assert_eq!(bundle(state)["count"], 2);
}
#[tokio::test]
async fn merge_splices_into_registered_bundle() {
let registry = Arc::new(ContextRegistry::new());
let fetch_fn: FetchFn = Arc::new(|| {
Box::pin(async { Ok(json!({ "user": { "id": 1, "name": "old" } })) })
});
let mut handle = registry
.register("session", json!({}), fetch_fn, None)
.await;
handle.refetch();
success_within(&mut handle).await;
registry
.merge("session", None, "user", &json!({ "id": 1, "name": "new" }))
.await;
let merged = success_within(&mut handle).await;
assert_eq!(bundle(merged)["user"]["name"], "new");
}
#[tokio::test]
async fn merge_into_absent_slot_publishes_nothing() {
let registry = Arc::new(ContextRegistry::new());
let fetch_fn: FetchFn = Arc::new(|| Box::pin(async { Ok(json!({ "user": 1 })) }));
let mut handle = registry
.register("session", json!({}), fetch_fn, None)
.await;
handle.refetch();
success_within(&mut handle).await;
registry.merge("session", None, "absent", &json!(42)).await;
let quiet =
tokio::time::timeout(Duration::from_millis(200), handle.changed()).await;
assert!(quiet.is_err(), "merge into an absent slot must not notify");
assert_eq!(bundle(handle.state().await), json!({ "user": 1 }));
}
#[tokio::test]
async fn directives_naming_an_unregistered_context_are_inert() {
let registry = Arc::new(ContextRegistry::new());
registry.merge("never_registered", None, "slot", &json!(1)).await;
registry.invalidate_broad("never_registered").await;
registry.invalidate_scoped("never_registered", &json!({})).await;
}
#[tokio::test]
async fn a_directive_naming_an_unregistered_scope_leaves_its_siblings_alone() {
let registry = Arc::new(ContextRegistry::new());
let counter = Arc::new(AtomicU32::new(0));
let mut handle = registry
.register(
"user",
json!({ "id": 1 }),
counted_fetch(Arc::clone(&counter)),
None,
)
.await;
handle.refetch();
success_within(&mut handle).await;
registry.invalidate_scoped("user", &json!({ "id": 99 })).await;
registry.merge("user", Some(&json!({ "id": 99 })), "count", &json!(42)).await;
let quiet =
tokio::time::timeout(Duration::from_millis(200), handle.changed()).await;
assert!(quiet.is_err(), "a scope nobody registered selects no entry");
assert_eq!(counter.load(Ordering::SeqCst), 1);
}
#[tokio::test]
async fn invalidate_broad_reaches_every_param_scope() {
let registry = Arc::new(ContextRegistry::new());
let counter = Arc::new(AtomicU32::new(0));
let mut one = registry
.register(
"user",
json!({ "id": 1 }),
counted_fetch(Arc::clone(&counter)),
None,
)
.await;
let mut two = registry
.register(
"user",
json!({ "id": 2 }),
counted_fetch(Arc::clone(&counter)),
None,
)
.await;
registry.invalidate_broad("user").await;
success_within(&mut one).await;
success_within(&mut two).await;
assert_eq!(counter.load(Ordering::SeqCst), 2);
}
#[tokio::test]
async fn an_unregistered_entry_still_answers_the_handle_it_left_behind() {
let registry = Arc::new(ContextRegistry::new());
let fetch_fn: FetchFn = Arc::new(|| Box::pin(async { Ok(json!({ "user": 1 })) }));
let mut handle = registry
.register("session", json!({}), fetch_fn, None)
.await;
handle.refetch();
success_within(&mut handle).await;
registry.unregister("session", &stable_key(&json!({}))).await;
// The cell outlives the registry entry, so the handle keeps
// reading the last state rather than losing its publisher.
assert_eq!(bundle(handle.state().await), json!({ "user": 1 }));
let quiet =
tokio::time::timeout(Duration::from_millis(200), handle.changed()).await;
assert!(quiet.is_err(), "an unregistered entry publishes nothing more");
}
}

View File

@@ -1,4 +1,4 @@
//! Wire error envelope. Mirrors `MizanError` in `frontends/mizan-base/src/index.ts`.
//! Wire error envelope.
//!
//! Two envelope shapes are tolerated:
//!
@@ -24,23 +24,43 @@ pub struct MizanError {
impl MizanError {
pub fn from_response(status: u16, body: String) -> Self {
let parsed = serde_json::from_str::<Envelope>(&body).ok();
let (code, message, details) = match parsed {
Some(Envelope::Fastapi { error }) => (
let (code, message, details) = match serde_json::from_str::<Envelope>(&body) {
Ok(Envelope::Fastapi { error }) => (
error.code.unwrap_or_else(|| format!("HTTP_{status}")),
error.message.unwrap_or_else(|| format!("Mizan call failed ({status})")),
error.details,
),
Some(Envelope::Django { code, message, details, .. }) => (
Ok(Envelope::Django { error: true, code, message, details }) => (
code.unwrap_or_else(|| format!("HTTP_{status}")),
message.unwrap_or_else(|| format!("Mizan call failed ({status})")),
details,
),
None => (
format!("HTTP_{status}"),
format!("Mizan call failed ({status})"),
None,
),
Ok(Envelope::Django { error: false, .. }) => {
eprintln!(
"[mizan] {status} body carries \"error\": false, so it declares no \
error to report; falling back to HTTP_{status}"
);
(
format!("HTTP_{status}"),
format!("Mizan call failed ({status})"),
None,
)
}
Err(e) => {
// A body matching neither envelope usually means something
// other than the Mizan router answered — a proxy page, a
// framework debug page. Naming it here is the only place
// that fact is visible; `raw_body` carries the body on.
eprintln!(
"[mizan] {status} body is neither Mizan envelope shape ({e}); \
falling back to HTTP_{status}"
);
(
format!("HTTP_{status}"),
format!("Mizan call failed ({status})"),
None,
)
}
};
Self { status, code, message, details, raw_body: body }
}
@@ -72,8 +92,9 @@ impl std::error::Error for MizanError {}
enum Envelope {
Fastapi { error: NestedError },
Django {
// Django form is `{"error": true, "code": ..., "message": ..., "details": ...}`.
// `error` is a bool sentinel; the actual fields are siblings.
// Django form is `{"error": true, "code": ..., "message": ...}`.
// The untagged match needs this key present to pick this arm, and
// its value decides whether the body declares an error at all.
error: bool,
code: Option<String>,
message: Option<String>,
@@ -111,10 +132,19 @@ mod tests {
assert_eq!(e.message, "missing");
}
#[test]
fn a_body_declaring_no_error_falls_back_to_the_status() {
let body = r#"{"error":false,"code":"IGNORED","message":"ignored"}"#;
let e = MizanError::from_response(500, body.to_string());
assert_eq!(e.code, "HTTP_500");
assert_eq!(e.raw_body, body);
}
#[test]
fn falls_back_on_unparseable_body() {
let e = MizanError::from_response(500, "Internal Server Error".to_string());
assert_eq!(e.code, "HTTP_500");
assert!(e.message.contains("500"));
assert_eq!(e.raw_body, "Internal Server Error");
}
}

View File

@@ -1,15 +1,14 @@
//! Invalidation queue.
//!
//! Mirrors the TS kernel's `pending` / `pendingScoped` / `flush()` pair
//! at `frontends/mizan-base/src/index.ts`. Mutations accumulate
//! invalidation targets; the queue batches them and triggers refetches
//! on the matching context entries.
//! Mutations accumulate invalidation targets — broad (every entry of a
//! context name) and scoped (the one entry matching `(name, params)`).
//! The queue batches them and drives the matching registry entries to
//! refetch.
//!
//! The TS kernel uses `queueMicrotask(flush)` to batch within a single
//! event-loop tick. The Rust equivalent is a `tokio::task::yield_now()`
//! debounce: when `invalidate()` is called, push to the queue, and if
//! no flush is scheduled spawn a task that yields once then flushes.
//! That gives the same "batch within a single async tick" semantics.
//! Batching is a `tokio::task::yield_now()` debounce: `invalidate()`
//! records the target and, when no flush is already scheduled, spawns a
//! task that yields once and then flushes. Everything recorded inside a
//! single async tick therefore lands in one flush.
use std::collections::HashSet;
use std::sync::Arc;
@@ -76,8 +75,8 @@ impl InvalidationQueue {
}
let this = Arc::clone(self);
tokio::spawn(async move {
// Yield once to batch invalidations queued in the same
// async tick — equivalent to TS `queueMicrotask`.
// Yield once so every target recorded in this async tick is
// already in `pending` when the flush reads it.
tokio::task::yield_now().await;
this.flush().await;
this.scheduled.store(false, Ordering::SeqCst);
@@ -85,13 +84,13 @@ impl InvalidationQueue {
}
async fn flush(&self) {
let snapshot = {
let (broad, scoped) = {
let mut pending = self.pending.lock().await;
let broad = std::mem::take(&mut pending.broad);
let scoped = std::mem::take(&mut pending.scoped);
(broad, scoped)
(
std::mem::take(&mut pending.broad),
std::mem::take(&mut pending.scoped),
)
};
let (broad, scoped) = snapshot;
// Broad first — they cover all scoped variants of the same name.
for name in &broad {
@@ -112,8 +111,12 @@ mod tests {
use super::*;
use crate::context::{ContextHandle, ContextRegistry, ContextStatus, FetchFn};
use serde_json::json;
use std::sync::atomic::AtomicU32;
use std::time::Duration;
fn counted_fetch(counter: Arc<std::sync::atomic::AtomicU32>) -> FetchFn {
const SETTLE: Duration = Duration::from_secs(2);
fn counted_fetch(counter: Arc<AtomicU32>) -> FetchFn {
Arc::new(move || {
let counter = Arc::clone(&counter);
Box::pin(async move {
@@ -123,12 +126,19 @@ mod tests {
})
}
/// Read states off `handle` until one reports Success.
async fn wait_for_success(handle: &mut ContextHandle) {
loop {
handle.rx.changed().await.unwrap();
if handle.state().status == ContextStatus::Success {
return;
let settled = tokio::time::timeout(SETTLE, async {
loop {
if handle.changed().await.status == ContextStatus::Success {
return;
}
}
})
.await;
match settled {
Ok(()) => {}
Err(elapsed) => panic!("no Success state within {SETTLE:?}: {elapsed}"),
}
}
@@ -136,8 +146,10 @@ mod tests {
async fn broad_invalidate_triggers_refetch() {
let registry = Arc::new(ContextRegistry::new());
let queue = InvalidationQueue::new(Arc::clone(&registry));
let counter = Arc::new(std::sync::atomic::AtomicU32::new(0));
let mut handle = registry.register("user", json!({}), counted_fetch(Arc::clone(&counter)), None).await;
let counter = Arc::new(AtomicU32::new(0));
let mut handle = registry
.register("user", json!({}), counted_fetch(Arc::clone(&counter)), None)
.await;
handle.refetch();
wait_for_success(&mut handle).await;
assert_eq!(counter.load(Ordering::SeqCst), 1);
@@ -145,4 +157,26 @@ mod tests {
wait_for_success(&mut handle).await;
assert_eq!(counter.load(Ordering::SeqCst), 2);
}
#[tokio::test]
async fn a_broad_target_absorbs_a_scoped_one_in_the_same_tick() {
let registry = Arc::new(ContextRegistry::new());
let queue = InvalidationQueue::new(Arc::clone(&registry));
let counter = Arc::new(AtomicU32::new(0));
let mut handle = registry
.register("user", json!({ "id": 1 }), counted_fetch(Arc::clone(&counter)), None)
.await;
handle.refetch();
wait_for_success(&mut handle).await;
assert_eq!(counter.load(Ordering::SeqCst), 1);
queue.invalidate("user").await;
queue.invalidate_scoped("user", json!({ "id": 1 })).await;
wait_for_success(&mut handle).await;
// Both targets name the same entry, so the flush must refetch it
// once, not twice.
tokio::time::sleep(Duration::from_millis(200)).await;
assert_eq!(counter.load(Ordering::SeqCst), 2);
}
}

View File

@@ -1,16 +1,11 @@
//! Mizan client kernel.
//!
//! Rust port of `@mizan/base` (frontends/mizan-base/src/index.ts). Same
//! public surface, same protocol, same wire shape. Consumers — generated
//! per-app crates, the GPU worker, the Python `PyMizanClient` — depend
//! on this kernel and never construct HTTP requests directly.
//!
//! Modules:
//! - [`client`] — `MizanClient`, `MizanConfig`, session init
//! - [`context`] — registry, `ContextState`, `ContextHandle`, `stable_key`
//! - [`error`] — `MizanError`, envelope parsing
//! - [`transport`] — `mizan_fetch`, `mizan_call`, retry, header resolution
//! - [`merge`] — `splice_slot`
//! - [`merge`] — `splice_slot`, `merge_into_bundle`
//! - [`invalidation`] — `InvalidationQueue`, debounced flush
pub mod client;

View File

@@ -1,53 +1,81 @@
//! Mutation-driven merge of a value into a context's bundle slot.
//!
//! Mirrors `spliceSlot` in `frontends/mizan-base/src/index.ts`. The server
//! has already resolved which slot the value lands in (by matching the
//! mutation's return type against each context function's return type),
//! so the kernel does no inference — it writes directly to `bundle[slot]`.
//! The server has already resolved which slot the value lands in (by
//! matching the mutation's return type against each context function's
//! return type), so nothing here infers a slot — it writes directly to
//! `bundle[slot]`.
//!
//! Rules:
//! - If the existing slot is an array and the new value is also an array,
//! the array replaces the slot wholesale.
//! - If the existing slot is an array and the new value is an object with
//! an `id` field, upsert by `id` replace the matching entry in place
//! or append.
//! Splice rules:
//! - Existing slot is an array and the new value is also an array — the
//! new array replaces the slot wholesale.
//! - Existing slot is an array and the new value is an object with an
//! `id` field upsert by `id`, replacing the matching entry in place
//! or appending.
//! - Otherwise the slot is replaced with the new value.
use serde_json::map::Entry;
use serde_json::Value;
pub fn splice_slot(slot: &Value, value: &Value) -> Value {
if let Value::Array(slot_arr) = slot {
if let Value::Array(_) = value {
return value.clone();
}
if let Some(id) = value.get("id") {
let mut next = slot_arr.clone();
let idx = next.iter().position(|item| item.get("id") == Some(id));
match idx {
Some(i) => next[i] = value.clone(),
None => next.push(value.clone()),
}
return Value::Array(next);
}
/// Whether `item` is an object whose `id` equals `id`. Anything else —
/// a scalar, an array, an object with no `id` — is not a match.
fn carries_id(item: &Value, id: &Value) -> bool {
match item {
Value::Object(fields) => match fields.get("id") {
Some(existing) => existing == id,
None => false,
},
Value::Null | Value::Bool(_) | Value::Number(_) | Value::String(_) | Value::Array(_) => false,
}
value.clone()
}
/// Apply a merge entry to the bundle of a context entry. Returns the new
/// bundle, or `None` if the slot wasn't present in the bundle (caller
/// should treat that as a no-op so server-driven merges into stale
/// caches don't fabricate slots).
pub fn merge_into_bundle(bundle: &Value, slot_name: &str, value: &Value) -> Option<Value> {
let obj = bundle.as_object()?;
if !obj.contains_key(slot_name) {
return None;
/// Replace the element of `existing` carrying `id`, or append `value`
/// when no element carries it.
fn upsert_by_id(existing: &[Value], id: &Value, value: &Value) -> Value {
let mut next = existing.to_vec();
match next.iter().position(|item| carries_id(item, id)) {
Some(i) => next[i] = value.clone(),
None => next.push(value.clone()),
}
Value::Array(next)
}
pub fn splice_slot(slot: &Value, value: &Value) -> Value {
let Value::Array(existing) = slot else {
return value.clone();
};
match value {
Value::Array(_) => value.clone(),
Value::Object(fields) => match fields.get("id") {
Some(id) => upsert_by_id(existing, id, value),
None => value.clone(),
},
Value::Null | Value::Bool(_) | Value::Number(_) | Value::String(_) => value.clone(),
}
}
/// Apply a merge entry to a context entry's bundle, returning the bundle
/// the entry should now hold. A bundle that is not an object, or that
/// carries no slot named `slot_name`, comes back unchanged; no slot is
/// added that the bundle did not already have.
pub fn merge_into_bundle(bundle: &Value, slot_name: &str, value: &Value) -> Value {
let Value::Object(obj) = bundle else {
return bundle.clone();
};
let mut next = obj.clone();
let spliced = splice_slot(obj.get(slot_name)?, value);
next.insert(slot_name.to_string(), spliced);
Some(Value::Object(next))
// `Entry` names both reachable states of the lookup, so the vacant
// case is a branch rather than an absence to test for.
match next.entry(slot_name.to_string()) {
Entry::Vacant(_) => bundle.clone(),
Entry::Occupied(mut slot) => {
let spliced = splice_slot(slot.get(), value);
slot.insert(spliced);
Value::Object(next)
}
}
}
@@ -91,17 +119,48 @@ mod tests {
}
#[test]
fn merge_into_bundle_skips_missing_slot() {
fn an_object_without_an_id_replaces_the_array() {
let slot = json!([{"id": 1}]);
let value = json!({"name": "no-id"});
assert_eq!(splice_slot(&slot, &value), json!({"name": "no-id"}));
}
#[test]
fn scalar_elements_never_match_an_id() {
let slot = json!([1, 2, {"id": 3, "name": "c"}]);
let value = json!({"id": 3, "name": "C"});
assert_eq!(
splice_slot(&slot, &value),
json!([1, 2, {"id": 3, "name": "C"}]),
);
}
#[test]
fn merge_into_bundle_leaves_missing_slot_untouched() {
let bundle = json!({"existing": 1});
let value = json!(42);
assert!(merge_into_bundle(&bundle, "missing", &value).is_none());
assert_eq!(merge_into_bundle(&bundle, "missing", &value), bundle);
}
#[test]
fn merge_into_bundle_leaves_non_object_bundle_untouched() {
let bundle = json!([1, 2, 3]);
let value = json!(42);
assert_eq!(merge_into_bundle(&bundle, "slot", &value), bundle);
}
#[test]
fn merge_into_bundle_updates_present_slot() {
let bundle = json!({"user_profile": {"id": 1, "name": "old"}});
let value = json!({"id": 1, "name": "new"});
let merged = merge_into_bundle(&bundle, "user_profile", &value).unwrap();
let merged = merge_into_bundle(&bundle, "user_profile", &value);
assert_eq!(merged["user_profile"]["name"], "new");
}
#[test]
fn merge_into_bundle_keeps_sibling_slots() {
let bundle = json!({"a": 1, "b": 2});
let merged = merge_into_bundle(&bundle, "a", &json!(9));
assert_eq!(merged, json!({"a": 9, "b": 2}));
}
}

View File

@@ -1,32 +1,39 @@
//! PyO3 façade — exposes `MizanClient` to Python as `PyMizanClient`.
//!
//! Same kernel, same wire. The Python wrapper that the codegen emits
//! adds typed methods on top of this client (Pydantic in / Pydantic
//! out); this module's job is the GIL boundary plus the async-to-sync
//! bridge.
//! One tokio multi-thread runtime is owned by the `PyMizanClient`. `call`
//! and `fetch_context` drive it under `py.allow_threads`, so the GIL is
//! released across the network round-trip. `subscribe_context` spawns a
//! tokio task holding the `ContextHandle`; each state it reads re-acquires
//! the GIL via `Python::with_gil` to fire the Python callback, and the
//! returned `PyContextSubscription` cancels that task.
//!
//! Architecture:
//! - One tokio multi-thread runtime owned by the `PyMizanClient`.
//! - `call` / `fetch_context` use `py.allow_threads(|| rt.block_on(...))`
//! so the GIL is released across the network round-trip.
//! - `subscribe_context` spawns a tokio task that owns a watch
//! receiver; on each change the task acquires the GIL via
//! `Python::with_gil` and fires the Python callback. The returned
//! `CancellationToken` (wrapped as `PyContextSubscription`) lets
//! Python cancel the watcher.
//! Two perimeters are crossed in this module. `depythonize`, `pythonize`
//! and `call1` cross the CPython FFI — they fail inside the interpreter,
//! on allocation, on a type CPython will not represent, or on a raise.
//! `MizanError` is the HTTP round-trip's own answer, carried in from the
//! wire. Nothing else here is fallible.
use std::sync::Arc;
use pyo3::prelude::*;
use pyo3::types::{PyDict};
use pyo3::types::PyDict;
use pythonize::{depythonize, pythonize};
use serde_json::Value;
use serde_json::{Map, Value};
use tokio::runtime::Runtime;
use tokio::sync::watch;
use tokio_util::sync::CancellationToken;
use crate::client::{MizanClient, MizanConfig};
use crate::context::{ContextStateRaw, ContextStatus};
use crate::error::MizanError;
/// A wire failure reaches Python as a `RuntimeError` whose text is
/// `MizanError`'s Display — status, code and message.
impl From<MizanError> for PyErr {
fn from(err: MizanError) -> Self {
PyErr::new::<pyo3::exceptions::PyRuntimeError, _>(err.to_string())
}
}
#[pyclass]
@@ -52,6 +59,9 @@ impl PyContextSubscription {
#[pymethods]
impl PyMizanClient {
/// Build the client and the runtime it owns. Both the tokio reactor
/// and the kernel's HTTP stack are established here, so every method
/// below runs against resources that exist by construction.
#[new]
#[pyo3(signature = (base_url, *, session = false, csrf_cookie_name = String::from("csrftoken"), csrf_header_name = String::from("X-CSRFToken")))]
fn new(
@@ -59,9 +69,11 @@ impl PyMizanClient {
session: bool,
csrf_cookie_name: String,
csrf_header_name: String,
) -> PyResult<Self> {
let rt = Runtime::new()
.map_err(|e| PyErr::new::<pyo3::exceptions::PyRuntimeError, _>(format!("tokio runtime: {e}")))?;
) -> Self {
let rt = match Runtime::new() {
Ok(rt) => rt,
Err(e) => panic!("the tokio reactor could not be started: {e}"),
};
let config = MizanConfig {
base_url,
session,
@@ -69,10 +81,10 @@ impl PyMizanClient {
csrf_header_name,
extra_headers: Vec::new(),
};
Ok(Self {
Self {
inner: MizanClient::new(config),
rt: Arc::new(rt),
})
}
}
/// Invoke a mutation or plain function. `args` is a Python dict (or
@@ -84,8 +96,7 @@ impl PyMizanClient {
let inner = Arc::clone(&self.inner);
let result: Value = py.allow_threads(|| {
self.rt.block_on(async move { inner.call(&fn_name, args_value).await })
})
.map_err(mizan_err_to_py)?;
})?;
pythonize(py, &result)
.map_err(|e| PyErr::new::<pyo3::exceptions::PyValueError, _>(format!("encode result: {e}")))
.map(|bound| bound.unbind())
@@ -98,8 +109,7 @@ impl PyMizanClient {
let inner = Arc::clone(&self.inner);
let result: Value = py.allow_threads(|| {
self.rt.block_on(async move { inner.fetch_context(&name, &params_value).await })
})
.map_err(mizan_err_to_py)?;
})?;
pythonize(py, &result)
.map_err(|e| PyErr::new::<pyo3::exceptions::PyValueError, _>(format!("encode result: {e}")))
.map(|bound| bound.unbind())
@@ -124,9 +134,9 @@ impl PyMizanClient {
let params_value: Value = depythonize(params.as_any())
.map_err(|e| PyErr::new::<pyo3::exceptions::PyValueError, _>(format!("params: {e}")))?;
// Build a serde-friendly fetch closure that delegates to the
// kernel's `fetch_context` (which itself runs the typed HTTP
// pipeline). The subscription's refetches go through this.
// The subscription's refetches delegate to the kernel's
// `fetch_context`, so they run the same typed HTTP pipeline as a
// one-shot fetch.
let inner_for_fetch = Arc::clone(&self.inner);
let name_for_fetch = name.clone();
let params_for_fetch = params_value.clone();
@@ -138,36 +148,36 @@ impl PyMizanClient {
as std::pin::Pin<Box<dyn std::future::Future<Output = _> + Send + 'static>>
});
let watched_name = name.clone();
let inner = Arc::clone(&self.inner);
let handle = py.allow_threads(|| {
let mut handle = py.allow_threads(|| {
self.rt.block_on(async move {
inner.register_context(name.clone(), params_value, fetch_fn).await
inner.register_context(name, params_value, fetch_fn).await
})
});
let cancel = handle.cancel_token();
let cancel_for_task = cancel.clone();
let callback = Arc::new(callback);
let callback_for_task = Arc::clone(&callback);
// Drive an initial refetch before destructuring so the first
// state lands without requiring the caller to invalidate.
// Drive an initial refetch so the first state lands without
// requiring the caller to invalidate.
handle.refetch();
let rx: watch::Receiver<ContextStateRaw> = handle.rx;
self.rt.spawn(async move {
let mut rx = rx;
loop {
tokio::select! {
_ = cancel_for_task.cancelled() => break,
res = rx.changed() => {
if res.is_err() { break; }
let snapshot = rx.borrow_and_update().clone();
state = handle.changed() => {
let payload = state_to_json(&state);
Python::with_gil(|py| {
let dict = match state_to_pydict(py, &snapshot) {
Ok(d) => d,
Err(e) => { eprintln!("[pyo3_bridge] encode state: {e}"); return; }
};
if let Err(e) = callback_for_task.call1(py, (dict,)) {
eprintln!("[pyo3_bridge] callback raised: {e}");
match pythonize(py, &payload) {
Ok(obj) => {
if let Err(e) = callback.call1(py, (obj,)) {
e.print(py);
}
}
Err(e) => eprintln!(
"[pyo3_bridge] context {watched_name:?} state \
could not be allocated as a Python object: {e}"
),
}
});
}
@@ -178,72 +188,69 @@ impl PyMizanClient {
Ok(PyContextSubscription { cancel })
}
/// Schedule a broad invalidation.
/// Schedule a broad invalidation. `InvalidationQueue::invalidate` has
/// return type `()`: it records the target, and the refetch it causes
/// runs later on the flush task. That unit is this method's own
/// return value, so Python sees `None` when the target is recorded.
fn invalidate(&self, py: Python<'_>, name: String) {
let inner = Arc::clone(&self.inner);
py.allow_threads(|| {
self.rt.block_on(async move { inner.invalidate(name).await })
});
})
}
/// Schedule a scoped invalidation.
/// Schedule a scoped invalidation. `InvalidationQueue::invalidate_scoped`
/// likewise has return type `()`; that unit is what `Ok` wraps here.
/// Reading `params` out of CPython is the one thing that can fail.
fn invalidate_scoped(&self, py: Python<'_>, name: String, params: &Bound<'_, PyDict>) -> PyResult<()> {
let params_value: Value = depythonize(params.as_any())
.map_err(|e| PyErr::new::<pyo3::exceptions::PyValueError, _>(format!("params: {e}")))?;
let inner = Arc::clone(&self.inner);
py.allow_threads(|| {
Ok(py.allow_threads(|| {
self.rt.block_on(async move { inner.invalidate_scoped(name, params_value).await })
});
Ok(())
}))
}
}
fn state_to_pydict<'py>(py: Python<'py>, state: &ContextStateRaw) -> PyResult<Bound<'py, PyDict>> {
let dict = PyDict::new_bound(py);
/// The subscription payload as plain JSON. Every branch yields a value,
/// so the watcher reaches the FFI crossing with nothing left to check.
fn state_to_json(state: &ContextStateRaw) -> Value {
let status = match state.status {
ContextStatus::Idle => "idle",
ContextStatus::Loading => "loading",
ContextStatus::Success => "success",
ContextStatus::Error => "error",
};
dict.set_item("status", status)?;
match &state.data {
Some(v) => {
let obj = pythonize(py, v)
.map_err(|e| PyErr::new::<pyo3::exceptions::PyValueError, _>(format!("encode state.data: {e}")))?;
dict.set_item("data", obj)?;
}
None => dict.set_item("data", py.None())?,
}
match &state.error {
let data = match &state.data {
Some(v) => v.clone(),
None => Value::Null,
};
let error = match &state.error {
None => Value::Null,
Some(err) => {
let err_dict = PyDict::new_bound(py);
err_dict.set_item("status", err.status)?;
err_dict.set_item("code", &err.code)?;
err_dict.set_item("message", &err.message)?;
if let Some(details) = &err.details {
let obj = pythonize(py, details)
.map_err(|e| PyErr::new::<pyo3::exceptions::PyValueError, _>(format!("encode error.details: {e}")))?;
err_dict.set_item("details", obj)?;
} else {
err_dict.set_item("details", py.None())?;
}
dict.set_item("error", err_dict)?;
let details = match &err.details {
Some(d) => d.clone(),
None => Value::Null,
};
let mut map = Map::new();
map.insert("status".into(), Value::from(err.status));
map.insert("code".into(), Value::from(err.code.clone()));
map.insert("message".into(), Value::from(err.message.clone()));
map.insert("details".into(), details);
Value::Object(map)
}
None => dict.set_item("error", py.None())?,
}
Ok(dict)
};
let mut out = Map::new();
out.insert("status".into(), Value::from(status));
out.insert("data".into(), data);
out.insert("error".into(), error);
Value::Object(out)
}
fn mizan_err_to_py(err: crate::MizanError) -> PyErr {
PyErr::new::<pyo3::exceptions::PyRuntimeError, _>(format!("{err}"))
}
/// Python extension module entry point. Wheels built via `maturin
/// develop --features pyo3` import the module as `mizan_rust`.
/// Python extension module entry point. The function name is the imported
/// module name — renaming it renames the module Python sees.
#[pymodule]
fn mizan_rust(m: &Bound<'_, PyModule>) -> PyResult<()> {
m.add_class::<PyMizanClient>()?;

View File

@@ -1,22 +1,21 @@
//! HTTP transport. Mirrors `mizanFetch` and `mizanCall` in
//! `frontends/mizan-base/src/index.ts`.
//! HTTP transport.
//!
//! - `mizan_fetch(client, context, params)` → `GET /api/mizan/ctx/<name>/?params`
//! - `mizan_call(client, fn_name, args)` → `POST /api/mizan/call/` with
//! `{fn, args}` body. On response, applies any `merge` entries first,
//! then `invalidate` entries, then returns the `result` field.
//!
//! Retries: 3 attempts total, 200ms × attempt linear backoff. Retries
//! on network errors and 5xx; surfaces 4xx immediately (matches TS).
//! Retries: 3 attempts total, 200ms × attempt linear backoff, on network
//! errors and 5xx. A 4xx surfaces immediately — it is the server's
//! considered answer, not a transient fault.
//!
//! CSRF: the reqwest cookie jar stores the CSRF cookie from the
//! `/session/` bootstrap; on every call we read it via
//! `reqwest::cookie::Jar::cookies(&url)` and add it as the configured
//! header. Both names come from `MizanConfig`.
//! `/session/` bootstrap; every request reads it back out of the jar and
//! adds it as the configured header. Both names come from `MizanConfig`.
use std::time::Duration;
use reqwest::{Method, Url};
use reqwest::{Method, RequestBuilder, Url};
use serde::Deserialize;
use serde_json::Value;
@@ -30,8 +29,7 @@ const BACKOFF_BASE: Duration = Duration::from_millis(200);
/// `GET /api/mizan/ctx/<context>/?params`.
pub async fn mizan_fetch(client: &MizanClient, context: &str, params: &Value) -> Result<Value, MizanError> {
let mut url = Url::parse(&format!("{}/ctx/{}/", client.config().base_url.trim_end_matches('/'), context))
.map_err(|e| MizanError::transport(format!("invalid base_url: {e}")))?;
let mut url = client.endpoint(&format!("ctx/{context}/"));
if let Value::Object(map) = params {
let mut qp = url.query_pairs_mut();
for (k, v) in map {
@@ -51,37 +49,55 @@ pub async fn mizan_fetch(client: &MizanClient, context: &str, params: &Value) ->
/// `POST /api/mizan/call/` with `{fn, args}` body. Applies merge +
/// invalidation entries from the response before returning `result`.
pub async fn mizan_call(client: &MizanClient, fn_name: &str, args: Value) -> Result<Value, MizanError> {
let url = Url::parse(&format!("{}/call/", client.config().base_url.trim_end_matches('/')))
.map_err(|e| MizanError::transport(format!("invalid base_url: {e}")))?;
let url = client.endpoint("call/");
let payload = serde_json::json!({ "fn": fn_name, "args": args });
let body_bytes = serde_json::to_vec(&payload)
.map_err(|e| MizanError::transport(format!("encode: {e}")))?;
// `Value`'s Display is the JSON encoder, so a Value we built ourselves
// encodes with no failure case to thread.
let body_bytes = payload.to_string().into_bytes();
let body = request_with_retry(client, Method::POST, url, Some(body_bytes)).await?;
// The response body is the server's, so decoding it is the wire
// perimeter and its failure is the caller's answer.
let response: CallResponse = serde_json::from_str(&body)
.map_err(|e| MizanError::transport(format!("decode: {e}")))?;
if let Some(merges) = response.merge {
for entry in &merges {
client.context_registry()
.merge(&entry.context, entry.params.as_ref(), &entry.slot, &entry.value)
.await;
}
// `ContextRegistry::merge` and both `InvalidationQueue` entry points
// have return type `()`: an entry naming a context this client never
// registered is inert inside them, so these calls report nothing back.
for entry in &response.merge {
client.context_registry()
.merge(&entry.context, entry.params.as_ref(), &entry.slot, &entry.value)
.await;
}
if let Some(invalidations) = response.invalidate {
for entry in invalidations {
match entry {
InvalidateEntry::Broad(name) => {
client.invalidation_queue().invalidate(name).await;
}
InvalidateEntry::Scoped { context, params } => {
client.invalidation_queue().invalidate_scoped(context, params).await;
}
for entry in response.invalidate {
match entry {
InvalidateEntry::Broad(name) => {
client.invalidation_queue().invalidate(name).await
}
InvalidateEntry::Scoped { context, params } => {
client.invalidation_queue().invalidate_scoped(context, params).await
}
}
}
Ok(response.result.unwrap_or(Value::Null))
Ok(response.result)
}
/// Send `req` and read its body. Both halves are the network perimeter:
/// the send can fail to reach the server and the body can fail to arrive
/// or decode, and either is retryable.
async fn send_and_read(req: RequestBuilder) -> Result<(u16, String), MizanError> {
let res = req
.send()
.await
.map_err(|e| MizanError::transport(e.to_string()))?;
let status = res.status().as_u16();
let text = res
.text()
.await
.map_err(|e| MizanError::transport(format!("response body: {e}")))?;
Ok((status, text))
}
@@ -91,53 +107,61 @@ async fn request_with_retry(
url: Url,
body: Option<Vec<u8>>,
) -> Result<String, MizanError> {
client.ensure_session_ready().await?;
client.ensure_session_ready().await;
let mut last_err: Option<MizanError> = None;
for attempt in 0..MAX_ATTEMPTS {
let mut attempt: u32 = 0;
loop {
let headers = client.resolve_headers().await;
let mut req = client.http().request(method.clone(), url.clone()).headers(headers);
if let Some(bytes) = &body {
req = req.header(reqwest::header::CONTENT_TYPE, "application/json")
.body(bytes.clone());
}
match req.send().await {
Ok(res) => {
let status = res.status().as_u16();
let text = res.text().await.unwrap_or_default();
// Every path out of this match either returns or names the error
// the next attempt would retry, so the loop never carries a
// "maybe we have an error by now" slot.
let retryable = match send_and_read(req).await {
Ok((status, text)) => {
if status < 400 {
return Ok(text);
}
if (400..500).contains(&status) {
return Err(MizanError::from_response(status, text));
}
last_err = Some(MizanError::from_response(status, text));
}
Err(e) => {
last_err = Some(MizanError::transport(e.to_string()));
MizanError::from_response(status, text)
}
Err(e) => e,
};
attempt += 1;
if attempt == MAX_ATTEMPTS {
return Err(retryable);
}
if attempt + 1 < MAX_ATTEMPTS {
tokio::time::sleep(BACKOFF_BASE.saturating_mul(attempt + 1)).await;
}
tokio::time::sleep(BACKOFF_BASE.saturating_mul(attempt)).await;
}
Err(last_err.unwrap_or_else(|| MizanError::transport("retry budget exhausted")))
}
/// A `call/` response. Every field defaults, so a server that omits
/// `result`, `merge` or `invalidate` decodes to the same shape as one
/// that sends them empty: a null result and nothing to apply.
#[derive(Deserialize)]
struct CallResponse {
result: Option<Value>,
#[serde(default)]
merge: Option<Vec<MergeEntry>>,
result: Value,
#[serde(default)]
invalidate: Option<Vec<InvalidateEntry>>,
merge: Vec<MergeEntry>,
#[serde(default)]
invalidate: Vec<InvalidateEntry>,
}
#[derive(Deserialize)]
struct MergeEntry {
context: String,
/// Absent for a merge into the unscoped instance of the context;
/// present when the server targets one param scope.
#[serde(default)]
params: Option<Value>,
slot: String,
@@ -151,3 +175,57 @@ enum InvalidateEntry {
Broad(String),
Scoped { context: String, params: Value },
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_response_with_only_a_result_decodes_with_nothing_to_apply() {
let r: CallResponse = serde_json::from_str(r#"{"result":{"id":1}}"#).unwrap();
assert_eq!(r.result, serde_json::json!({"id": 1}));
assert!(r.merge.is_empty());
assert!(r.invalidate.is_empty());
}
#[test]
fn an_empty_response_decodes_to_a_null_result() {
let r: CallResponse = serde_json::from_str("{}").unwrap();
assert_eq!(r.result, Value::Null);
assert!(r.merge.is_empty());
assert!(r.invalidate.is_empty());
}
#[test]
fn invalidate_entries_decode_in_both_wire_forms() {
let r: CallResponse = serde_json::from_str(
r#"{"invalidate":["user",{"context":"cart","params":{"id":2}}]}"#,
)
.unwrap();
assert_eq!(r.invalidate.len(), 2);
match &r.invalidate[0] {
InvalidateEntry::Broad(name) => assert_eq!(name, "user"),
InvalidateEntry::Scoped { .. } => panic!("a bare string is a broad target"),
}
match &r.invalidate[1] {
InvalidateEntry::Broad(_) => panic!("an object is a scoped target"),
InvalidateEntry::Scoped { context, params } => {
assert_eq!(context, "cart");
assert_eq!(params, &serde_json::json!({"id": 2}));
}
}
}
#[test]
fn a_merge_entry_without_params_targets_the_unscoped_instance() {
let r: CallResponse = serde_json::from_str(
r#"{"merge":[{"context":"session","slot":"user","value":{"id":1}}]}"#,
)
.unwrap();
assert_eq!(r.merge.len(), 1);
assert_eq!(r.merge[0].context, "session");
assert_eq!(r.merge[0].slot, "user");
assert!(r.merge[0].params.is_none());
}
}