From 9e6e74a6e4fbcf088cc53d9065f99d4345a5cbf5 Mon Sep 17 00:00:00 2001 From: Universe Date: Wed, 15 Jul 2026 05:12:54 +0900 Subject: [PATCH 1/8] refactor: extract reusable uhura host --- Cargo.lock | 17 +- Cargo.toml | 1 + crates/uhura-cli/Cargo.toml | 2 +- crates/uhura-cli/src/cmd/dev.rs | 1720 ++------------- crates/uhura-cli/src/cmd/mod.rs | 1 - crates/uhura-cli/src/cmd/trace.rs | 50 +- crates/uhura-host/Cargo.toml | 19 + crates/uhura-host/src/lib.rs | 1855 +++++++++++++++++ .../src/source.rs} | 117 +- 9 files changed, 2151 insertions(+), 1631 deletions(-) create mode 100644 crates/uhura-host/Cargo.toml create mode 100644 crates/uhura-host/src/lib.rs rename crates/{uhura-cli/src/cmd/editor_model.rs => uhura-host/src/source.rs} (93%) diff --git a/Cargo.lock b/Cargo.lock index ff04104..5d83084 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -458,8 +458,8 @@ dependencies = [ "uhura-base", "uhura-check", "uhura-core", - "uhura-editor-model", "uhura-fixture", + "uhura-host", "uhura-port", "uhura-syntax", ] @@ -495,6 +495,21 @@ dependencies = [ "uhura-port", ] +[[package]] +name = "uhura-host" +version = "0.0.0" +dependencies = [ + "serde_json", + "toml", + "uhura-base", + "uhura-check", + "uhura-core", + "uhura-editor-model", + "uhura-fixture", + "uhura-port", + "uhura-syntax", +] + [[package]] name = "uhura-port" version = "0.0.0" diff --git a/Cargo.toml b/Cargo.toml index 97aafc3..661a837 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -31,6 +31,7 @@ uhura-check = { path = "crates/uhura-check" } uhura-core = { path = "crates/uhura-core" } uhura-fixture = { path = "crates/uhura-fixture" } uhura-editor-model = { path = "crates/uhura-editor-model" } +uhura-host = { path = "crates/uhura-host" } uhura-cli = { path = "crates/uhura-cli" } [workspace.lints.rust] diff --git a/crates/uhura-cli/Cargo.toml b/crates/uhura-cli/Cargo.toml index 4e9b1c0..70b3336 100644 --- a/crates/uhura-cli/Cargo.toml +++ b/crates/uhura-cli/Cargo.toml @@ -26,7 +26,7 @@ uhura-check = { workspace = true } uhura-port = { workspace = true, features = ["toml"] } uhura-core = { workspace = true } uhura-fixture = { workspace = true } -uhura-editor-model = { workspace = true } +uhura-host = { workspace = true } serde_json = { workspace = true } tiny_http = { workspace = true } toml = { workspace = true } diff --git a/crates/uhura-cli/src/cmd/dev.rs b/crates/uhura-cli/src/cmd/dev.rs index 5aed98c..45ab3b7 100644 --- a/crates/uhura-cli/src/cmd/dev.rs +++ b/crates/uhura-cli/src/cmd/dev.rs @@ -1,27 +1,18 @@ -//! One native host for the model-driven Editor and interactive Play routes. -//! -//! Rust owns coherent project capture, checking/evaluation, immutable -//! `EditorState`, last-good Play artifacts, and HTTP/SSE transport. The -//! compiled web application owns every browser document and all presentation. +//! Standalone `uhura play`/`uhura dev` transport and observer adapter. -use std::collections::{BTreeMap, BTreeSet}; -use std::io::Read; -use std::path::{Component, Path, PathBuf}; +use std::path::{Path, PathBuf}; use std::process::ExitCode; -use std::sync::mpsc::{Receiver, Sender, channel}; -use std::sync::{Arc, Mutex, RwLock}; +use std::sync::Arc; use std::time::Duration; -use uhura_base::{Severity, sha256_hex, to_canonical_json, to_envelope}; -use uhura_check::check; -use uhura_check::fixture::load_fixture; -use uhura_core::ir::ProgramIr; -use uhura_editor_model::{EditorRender, EditorState}; +use uhura_host::{ + Host, ProjectSourceFingerprint, ProjectSourceSnapshot, RequestMethod, RouteRequest, WebAssets, + build_candidate, capture_project_snapshot, +}; use crate::CommonArgs; -use crate::cmd::trace::{boot_updates, fixture_slices_json}; -const EDITOR_EVENT_PROTOCOL: &str = "uhura-editor-event/0"; +pub use uhura_host::boot_envelope; pub fn run(common: &CommonArgs, port: u16) -> ExitCode { run_host(common, port, PrimarySurface::Play) @@ -40,23 +31,23 @@ enum PrimarySurface { impl PrimarySurface { fn command(self) -> &'static str { match self { - PrimarySurface::Editor => "uhura editor", - PrimarySurface::Play => "uhura play", + Self::Editor => "uhura editor", + Self::Play => "uhura play", } } fn route(self) -> &'static str { match self { - PrimarySurface::Editor => "/", - PrimarySurface::Play => "/play", + Self::Editor => "/", + Self::Play => "/play", } } } fn run_host(common: &CommonArgs, port: u16, primary: PrimarySurface) -> ExitCode { let command = primary.command(); - let web = match WebApp::locate() { - Ok(web) => Arc::new(web), + let web = match locate_web_assets() { + Ok(web) => web, Err(error) => { eprintln!("{command}: {error}"); return ExitCode::from(2); @@ -65,45 +56,17 @@ fn run_host(common: &CommonArgs, port: u16, primary: PrimarySurface) -> ExitCode let root = common.root.clone(); let first_observation = project_fingerprint(&root); - let (editor_outcome, baseline_snapshot) = build_stable_editor(&root, first_observation, 1); - let baseline = baseline_snapshot.fingerprint.clone(); - match &editor_outcome { - Ok(artifact) => println!( - "{command}: Editor revision 1 — {} previews ({} replay-derived)", - artifact.preview_count, artifact.replay_derived_count - ), - Err(_) => { - println!("{command}: Editor revision 1 rejected — application starts with diagnostics") - } - } - let editor = match EditorHostState::initial(editor_outcome) { - Ok(editor) => editor, + let (candidate, baseline_snapshot) = build_stable_candidate(&root, first_observation, 1); + let baseline = baseline_snapshot.fingerprint().clone(); + let (host, report) = match Host::new(web, candidate) { + Ok(host) => host, Err(error) => { - eprintln!("{command}: could not publish initial Editor state: {error}"); + eprintln!("{command}: could not publish initial host state: {error}"); return ExitCode::from(2); } }; - let state = Arc::new(RwLock::new(DevState { - play: PlayState::default(), - editor, - })); - let play_clients: Clients = Arc::new(Mutex::new(Vec::new())); - let editor_clients: Clients = Arc::new(Mutex::new(Vec::new())); + print_initial_report(command, report); - recheck_play_into(&baseline_snapshot.files, &state, &play_clients); - { - let state = state.read().expect("state lock"); - match (&state.play.good, state.play.ok) { - (Some(_), true) => println!("{command}: Play checked clean"), - (Some(_), false) => { - println!("{command}: Play check failing — serving the last good build") - } - (None, _) => println!("{command}: Play check failing — no good build yet"), - } - } - - // Browser assets are resolved before binding so a source-built CLI fails - // clearly instead of opening an unusable port. let server = match tiny_http::Server::http(("127.0.0.1", port)) { Ok(server) => server, Err(error) => { @@ -126,557 +89,48 @@ fn run_host(common: &CommonArgs, port: u16, primary: PrimarySurface) -> ExitCode } ); - // One observer drives both independent products. Editor publication is a - // complete-state replacement; Play keeps its own last-good generation. + let host = Arc::new(host); { let root = root.clone(); - let state = Arc::clone(&state); - let play_clients = Arc::clone(&play_clients); - let editor_clients = Arc::clone(&editor_clients); - std::thread::spawn(move || { - let mut seen = baseline; - loop { - std::thread::sleep(Duration::from_millis(150)); - let observed = project_fingerprint(&root); - if observed == seen { - continue; - } - let stable = wait_for_stable_fingerprint(&root, observed); - if stable == seen { - continue; - } - - let revision = state.read().expect("state lock").editor.source_revision + 1; - let (outcome, settled) = build_stable_editor(&root, stable, revision); - seen = settled.fingerprint.clone(); - let report = outcome - .as_ref() - .ok() - .map(|artifact| (artifact.preview_count, artifact.replay_derived_count)); - if let Err(error) = - publish_editor_candidate(revision, outcome, &state, &editor_clients) - { - // This is an internal ordering/contract break, not an - // author diagnostic. Leave the prior atomic state intact. - eprintln!("uhura host: could not publish Editor revision {revision}: {error}"); - } else if let Some((previews, derived)) = report { - println!( - "uhura host: Editor revision {revision} current — {previews} previews \ - ({derived} replay-derived)" - ); - } else { - println!( - "uhura host: Editor revision {revision} rejected — last render is stale" - ); - } - - recheck_play_into(&settled.files, &state, &play_clients); - let play = &state.read().expect("state lock").play; - println!( - "uhura host: Play generation {} — {}", - play.generation, - if play.ok { - "ok, clients reload" - } else { - "check failing, last-good runtime retained" - } - ); - } - }); + let host = Arc::clone(&host); + std::thread::spawn(move || observe(root, host, baseline)); } let server = Arc::new(server); for request in server.incoming_requests() { - let state = Arc::clone(&state); - let play_clients = Arc::clone(&play_clients); - let editor_clients = Arc::clone(&editor_clients); - let root = root.clone(); - let web = Arc::clone(&web); - std::thread::spawn(move || { - handle(request, &root, &web, &state, &play_clients, &editor_clients) - }); + let host = Arc::clone(&host); + std::thread::spawn(move || respond(request, &host)); } ExitCode::SUCCESS } -// ── state and coherent Editor publication ────────────────────────────────── - -struct DevState { - play: PlayState, - editor: EditorHostState, -} - -#[derive(Default)] -struct PlayState { - generation: u64, - ok: bool, - diagnostics: Option, - /// Last-good artifacts; a rejected generation never replaces these. - good: Option, -} - -struct GoodBuild { - ir: String, - inspect_json: String, - stylesheet: String, - fixture_json: String, - script_json: String, - boot_json: String, - icons_json: String, - config_json: String, - provider_js: Option, -} - -type EditorBuildOutcome = Result; - -struct EditorHostState { - source_revision: u64, - state_json: String, - /// Always kept with its original render revision and `current` marker; - /// stale publication mutates a clone only. - last_renderable: Option, -} - -#[derive(Clone, Debug, Eq, PartialEq)] -struct RevisionOrderError { - expected: u64, - received: u64, -} - -impl std::fmt::Display for RevisionOrderError { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - write!( - f, - "Editor candidate revision {} arrived; expected {}", - self.received, self.expected - ) - } -} - -impl std::error::Error for RevisionOrderError {} - -impl EditorHostState { - fn initial(outcome: EditorBuildOutcome) -> Result { - let (state_json, last_renderable) = materialize_editor_state(1, outcome, None)?; - Ok(Self { - source_revision: 1, - state_json, - last_renderable, - }) - } - - fn apply(&mut self, revision: u64, outcome: EditorBuildOutcome) -> Result<(), String> { - let expected = self.source_revision + 1; - if revision != expected { - return Err(RevisionOrderError { - expected, - received: revision, - } - .to_string()); - } - // Build the whole replacement before mutating the published slot. - let (state_json, last_renderable) = - materialize_editor_state(revision, outcome, self.last_renderable.as_ref())?; - self.source_revision = revision; - self.state_json = state_json; - self.last_renderable = last_renderable; - Ok(()) +fn locate_web_assets() -> Result { + let mut candidates = Vec::new(); + if let Some(explicit) = std::env::var_os("UHURA_WEB_DIST") { + candidates.push(PathBuf::from(explicit)); } -} - -fn materialize_editor_state( - revision: u64, - outcome: EditorBuildOutcome, - last_renderable: Option<&EditorRender>, -) -> Result<(String, Option), String> { - let (state, next_renderable) = match outcome { - Ok(artifact) => { - let next_renderable = artifact.render.clone(); - let state = EditorState::current(revision, artifact.diagnostics, artifact.render) - .map_err(|error| error.to_string())?; - (state, Some(next_renderable)) - } - Err(diagnostics) => match last_renderable { - Some(render) => ( - EditorState::stale(revision, diagnostics, render.clone()) - .map_err(|error| error.to_string())?, - Some(render.clone()), - ), - None => ( - EditorState::cold_invalid(revision, diagnostics) - .map_err(|error| error.to_string())?, - None, - ), - }, - }; - let state_json = state - .to_canonical_string() - .map_err(|error| error.to_string())?; - Ok((state_json, next_renderable)) -} - -fn publish_editor_candidate( - revision: u64, - outcome: EditorBuildOutcome, - state: &RwLock, - clients: &Clients, -) -> Result<(), String> { + if let Ok(executable) = std::env::current_exe() + && let Some(bin) = executable.parent() { - let mut state = state.write().expect("state lock"); - state.editor.apply(revision, outcome)?; + candidates.push(bin.join("../share/uhura/web")); } - broadcast(clients, &editor_sse_payload(revision)); - Ok(()) -} + candidates.push(tool_root().join("web/dist")); -/// Build until the exact captured bytes equal the observation both before and -/// after evaluation. An edit landing during work can never publish a mixed -/// model or an older candidate over a newer one. -fn build_stable_editor( - root: &Path, - mut before: super::editor_model::ProjectSourceFingerprint, - revision: u64, -) -> ( - EditorBuildOutcome, - super::editor_model::ProjectSourceSnapshot, -) { - loop { - let snapshot = super::editor_model::capture_project_snapshot(root); - if before != snapshot.fingerprint { - before = wait_for_stable_fingerprint(root, snapshot.fingerprint); - continue; - } - - let outcome = super::editor_model::build_captured_snapshot_at(&snapshot, revision) - .map_err(|failure| failure.envelope); - let after = project_fingerprint(root); - if snapshot.fingerprint == after { - return (outcome, snapshot); - } - before = wait_for_stable_fingerprint(root, after); - } -} - -fn project_fingerprint(root: &Path) -> super::editor_model::ProjectSourceFingerprint { - super::editor_model::capture_project_snapshot(root).fingerprint -} - -fn wait_for_stable_fingerprint( - root: &Path, - mut observed: super::editor_model::ProjectSourceFingerprint, -) -> super::editor_model::ProjectSourceFingerprint { - loop { - std::thread::sleep(Duration::from_millis(100)); - let again = project_fingerprint(root); - if again == observed { - return observed; - } - observed = again; - } -} - -// ── Play's independent last-good artifacts ───────────────────────────────── - -fn recheck_play_into( - files: &super::editor_model::ProjectSourceFiles, - state: &RwLock, - clients: &Clients, -) { - let outcome = recheck_play(files); - let payload; - { - let mut state = state.write().expect("state lock"); - state.play.generation += 1; - match outcome { - Ok(good) => { - state.play.ok = true; - state.play.diagnostics = None; - state.play.good = Some(good); - } - Err(envelope) => { - state.play.ok = false; - state.play.diagnostics = Some(envelope); - } - } - payload = play_sse_payload(&state.play); - } - broadcast(clients, &payload); -} - -fn recheck_play( - files: &super::editor_model::ProjectSourceFiles, -) -> Result { - let fail = |message: String| { - serde_json::json!({ - "format": "uhura-diagnostics", - "version": 0, - "summary": { "errors": 1, "warnings": 0 }, - "diagnostics": [{ - "code": "UH9000", - "rule": "play/recheck", - "severity": "error", - "message": message, - }], - }) - }; - let input = - super::editor_model::assemble_snapshot_input(files).map_err(|failure| failure.envelope)?; - let output = check(&input); - if output - .diagnostics - .iter() - .any(|diagnostic| diagnostic.severity == Severity::Error) - { - return Err(to_envelope(&output.diagnostics, &output.source_map)); - } - let Some(lowered) = &output.lowered else { - return Err(fail("the check produced no program".to_string())); - }; - let program = &lowered.program; - - let manifest = &input.manifest; - let profile = manifest - .play - .values() - .next() - .ok_or_else(|| fail("uhura.toml declares no [play.*] profile (§3)".to_string()))?; - let fixture_rel = manifest.fixtures.get(&profile.fixture).ok_or_else(|| { - fail(format!( - "play fixture `{}` is not declared", - profile.fixture - )) - })?; - let read = |relative: &str| -> Result { - let bytes = files - .resolve(Path::new(relative)) - .map_err(|error| fail(format!("{relative}: {error}")))? - .ok_or_else(|| fail(format!("{relative}: missing from the captured project")))?; - std::str::from_utf8(bytes) - .map(str::to_owned) - .map_err(|error| fail(format!("{relative}: source is not UTF-8: {error}"))) - }; - let fixture_text = read(fixture_rel)?; - let script_text = read(&format!("fixtures/scripts/{}.toml", profile.script))?; - let fixture = load_fixture(&fixture_text) - .map_err(|issues| fail(format!("fixture: {}", issues[0].message)))?; - let script_json = uhura_fixture::toml_to_json(&script_text).map_err(fail)?; - let fixture_json = fixture_slices_json(&fixture); - let script_canonical = to_canonical_json(&script_json); - uhura_fixture::FixtureDriver::new(&fixture_json, &script_canonical) - .map_err(|error| fail(format!("script `{}`: {error}", profile.script)))?; - - let (config_json, provider_js) = match &profile.provider { - Some(provider) => { - let provider_js = read(&provider.module)?; - let provider_hash = sha256_hex(provider_js.as_bytes()); - let config_json = to_canonical_json(&serde_json::json!({ - "allow_fixture": profile.allow_fixture, - "provider": { - "kind": "module", - "module": format!( - "/api/play/provider.js?sha256={provider_hash}" - ), - "config": &provider.config, - }, - })); - (config_json, Some(provider_js)) - } - None => ( - to_canonical_json(&serde_json::json!({ - "allow_fixture": true, - "provider": { "kind": "fixture" }, - })), - None, - ), - }; - - let mut inspection = uhura_core::inspect::program_graph(program); - inspection["spans"] = - serde_json::to_value(&lowered.spans).expect("IR spans are always serializable"); - - Ok(GoodBuild { - ir: program.to_canonical_string(), - inspect_json: to_canonical_json(&inspection), - stylesheet: output.stylesheet.clone(), - fixture_json, - script_json: script_canonical, - boot_json: boot_envelope(program, &fixture).map_err(fail)?, - icons_json: structured_icons_json(), - config_json, - provider_js, - }) -} - -/// The `/api/play/boot.json` wire form. Acceptance tests reuse this exact -/// producer, so fixture and browser boot cannot drift. -pub fn boot_envelope( - program: &ProgramIr, - fixture: &uhura_check::fixture::FixtureData, -) -> Result { - let updates = boot_updates(program, fixture)?; - Ok(to_canonical_json(&serde_json::json!({ - "updates": updates - .iter() - .map(uhura_port::envelope::ProjectionUpdate::to_json) - .collect::>(), - }))) -} - -fn structured_icons_json() -> String { - let icons = uhura_editor_model::icons::table() - .into_iter() - .map(|(name, icon)| (name, icon.to_json())) - .collect::>(); - to_canonical_json(&serde_json::Value::Object(icons)) -} - -// ── SSE ──────────────────────────────────────────────────────────────────── - -type Clients = Arc>>>; - -fn play_sse_payload(play: &PlayState) -> String { - let mut event = serde_json::json!({ - "generation": play.generation, - "ok": play.ok, - }); - if let Some(diagnostics) = &play.diagnostics { - event["diagnostics"] = diagnostics.clone(); - } - sse_frame(&event) -} - -fn editor_sse_payload(source_revision: u64) -> String { - sse_frame(&serde_json::json!({ - "protocol": EDITOR_EVENT_PROTOCOL, - "sourceRevision": source_revision, - })) -} - -fn sse_frame(value: &serde_json::Value) -> String { - // Padding crosses tiny_http's write buffer; EventSource ignores comments. - format!( - "data: {}\n\n: {}\n\n", - to_canonical_json(value), - "·".repeat(4096) - ) -} - -fn broadcast(clients: &Clients, payload: &str) { - let mut clients = clients.lock().expect("clients lock"); - clients.retain(|sender| sender.send(payload.to_string()).is_ok()); -} - -struct SseStream { - receiver: Receiver, - buffer: Vec, - offset: usize, -} - -impl Read for SseStream { - fn read(&mut self, output: &mut [u8]) -> std::io::Result { - if self.offset >= self.buffer.len() { - match self.receiver.recv() { - Ok(frame) => { - self.buffer = frame.into_bytes(); - self.offset = 0; - } - Err(_) => return Ok(0), - } - } - let count = (self.buffer.len() - self.offset).min(output.len()); - output[..count].copy_from_slice(&self.buffer[self.offset..self.offset + count]); - self.offset += count; - Ok(count) - } -} - -fn respond_sse(request: tiny_http::Request, clients: &Clients, hello: impl FnOnce() -> String) { - let (sender, receiver) = channel::(); - { - // Registration and snapshot share the broadcast lock: an update can - // be duplicated at the boundary but can never be lost. - let mut clients = clients.lock().expect("clients lock"); - let _ = sender.send(hello()); - clients.push(sender); - } - let response = tiny_http::Response::new( - tiny_http::StatusCode(200), - vec![ - header("Content-Type", "text/event-stream; charset=utf-8"), - header("Cache-Control", "no-store"), - ], - SseStream { - receiver, - buffer: Vec::new(), - offset: 0, - }, - None, - None, - ); - let _ = request.respond(response); -} - -// ── one web application and namespaced transport ─────────────────────────── - -#[derive(Clone, Debug)] -struct WebApp { - files: Arc>, - index: Arc>, - wasm_root: PathBuf, -} - -#[derive(Clone, Debug)] -struct WebFile { - bytes: Arc>, - content_type: String, -} - -impl WebApp { - fn locate() -> Result { - let mut candidates = Vec::new(); - if let Some(explicit) = std::env::var_os("UHURA_WEB_DIST") { - candidates.push(PathBuf::from(explicit)); - } - if let Ok(executable) = std::env::current_exe() - && let Some(bin) = executable.parent() - { - candidates.push(bin.join("../share/uhura/web")); - } - candidates.push(tool_root().join("web/dist")); - load_web_app_from(&candidates) - } -} - -fn load_web_app_from(candidates: &[PathBuf]) -> Result { let mut attempted = Vec::new(); for root in candidates { - if attempted.iter().any(|seen: &PathBuf| seen == root) { + if attempted.contains(&root) { continue; } attempted.push(root.clone()); let index_path = root.join("index.html"); match std::fs::symlink_metadata(&index_path) { Ok(_) => { - let files = snapshot_web_bundle(root)?; - let index = files - .get("index.html") - .ok_or_else(|| format!("{} is not a regular file", index_path.display()))?; - if index.bytes.is_empty() { - return Err(format!("{} is empty", index_path.display())); - } - if files.len() == 1 { - return Err(format!( - "browser application bundle at {} contains only index.html", - root.display() - )); - } - validate_index_assets(root, index.bytes.as_slice(), &files)?; - let index = Arc::clone(&index.bytes); - return Ok(WebApp { - files: Arc::new(files), - index, - wasm_root: locate_wasm_for(root), - }); + let wasm_root = locate_wasm_for(&root); + return if wasm_root.is_dir() { + WebAssets::from_directories(&root, &wasm_root) + } else { + WebAssets::from_frontend_directory(&root) + }; } Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} Err(error) => { @@ -695,209 +149,6 @@ fn load_web_app_from(candidates: &[PathBuf]) -> Result { )) } -fn snapshot_web_bundle(root: &Path) -> Result, String> { - let mut files = BTreeMap::new(); - let mut directories = vec![root.to_path_buf()]; - - while let Some(directory) = directories.pop() { - let entries = std::fs::read_dir(&directory) - .map_err(|error| format!("could not read {}: {error}", directory.display()))?; - let mut entries = entries - .collect::, _>>() - .map_err(|error| format!("could not read {}: {error}", directory.display()))?; - entries.sort_by_key(std::fs::DirEntry::file_name); - - for entry in entries { - let path = entry.path(); - let relative = normalized_web_bundle_path(root, &path)?; - let file_type = entry - .file_type() - .map_err(|error| format!("could not inspect {}: {error}", path.display()))?; - if file_type.is_dir() { - directories.push(path); - continue; - } - if !file_type.is_file() { - return Err(format!( - "browser application bundle contains an unsafe non-regular entry: {}", - path.display() - )); - } - - let extension = path - .extension() - .and_then(|value| value.to_str()) - .unwrap_or(""); - let bytes = std::fs::read(&path) - .map_err(|error| format!("could not read {}: {error}", path.display()))?; - files.insert( - relative, - WebFile { - bytes: Arc::new(bytes), - content_type: content_type(extension), - }, - ); - } - } - - Ok(files) -} - -fn validate_index_assets( - root: &Path, - index: &[u8], - files: &BTreeMap, -) -> Result<(), String> { - let index = std::str::from_utf8(index).map_err(|error| { - format!( - "{} is not UTF-8: {error}", - root.join("index.html").display() - ) - })?; - let references = index_asset_references(index)?; - if references.is_empty() { - return Err(format!( - "{} references no local JavaScript or CSS assets", - root.join("index.html").display() - )); - } - for reference in references { - if !files.contains_key(&reference) { - return Err(format!( - "{} references a missing application asset: /{reference}", - root.join("index.html").display() - )); - } - } - Ok(()) -} - -fn index_asset_references(index: &str) -> Result, String> { - let bytes = index.as_bytes(); - let mut references = BTreeSet::new(); - let mut cursor = 0; - while cursor < bytes.len() { - if !bytes[cursor].is_ascii_alphabetic() { - cursor += 1; - continue; - } - let name_start = cursor; - while bytes - .get(cursor) - .is_some_and(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b':')) - { - cursor += 1; - } - let name = &index[name_start..cursor]; - if !name.eq_ignore_ascii_case("src") && !name.eq_ignore_ascii_case("href") { - continue; - } - while bytes.get(cursor).is_some_and(u8::is_ascii_whitespace) { - cursor += 1; - } - if bytes.get(cursor) != Some(&b'=') { - continue; - } - cursor += 1; - while bytes.get(cursor).is_some_and(u8::is_ascii_whitespace) { - cursor += 1; - } - let Some(first) = bytes.get(cursor).copied() else { - break; - }; - let (value_start, value_end) = if matches!(first, b'\'' | b'"') { - cursor += 1; - let start = cursor; - while bytes.get(cursor).is_some_and(|byte| *byte != first) { - cursor += 1; - } - let end = cursor; - if cursor < bytes.len() { - cursor += 1; - } - (start, end) - } else { - let start = cursor; - while bytes - .get(cursor) - .is_some_and(|byte| !byte.is_ascii_whitespace() && *byte != b'>') - { - cursor += 1; - } - (start, cursor) - }; - let value = &index[value_start..value_end]; - let path_end = value.find(['?', '#']).unwrap_or(value.len()); - let path = &value[..path_end]; - let lowercase = path.to_ascii_lowercase(); - if !lowercase.ends_with(".js") - && !lowercase.ends_with(".mjs") - && !lowercase.ends_with(".css") - { - continue; - } - if path.starts_with("//") - || path - .find(':') - .is_some_and(|colon| path.find('/').is_none_or(|slash| colon < slash)) - { - continue; - } - let relative = path.strip_prefix('/').unwrap_or(path); - if relative.contains('\\') - || relative - .split('/') - .any(|segment| segment.is_empty() || segment == "." || segment == "..") - || Path::new(relative).is_absolute() - { - return Err(format!( - "index.html contains an unsafe local application asset reference: {value}" - )); - } - references.insert(relative.to_string()); - } - Ok(references) -} - -fn normalized_web_bundle_path(root: &Path, path: &Path) -> Result { - let relative = path.strip_prefix(root).map_err(|_| { - format!( - "browser application bundle entry escapes {}: {}", - root.display(), - path.display() - ) - })?; - let mut segments = Vec::new(); - for component in relative.components() { - let Component::Normal(segment) = component else { - return Err(format!( - "browser application bundle contains an unsafe path: {}", - path.display() - )); - }; - let segment = segment.to_str().ok_or_else(|| { - format!( - "browser application bundle path is not UTF-8: {}", - path.display() - ) - })?; - if segment.contains('\\') { - return Err(format!( - "browser application bundle contains an unsafe path: {}", - path.display() - )); - } - segments.push(segment); - } - if segments.is_empty() { - return Err(format!( - "browser application bundle contains an unsafe path: {}", - path.display() - )); - } - Ok(segments.join("/")) -} - fn locate_wasm_for(web_root: &Path) -> PathBuf { if let Some(explicit) = std::env::var_os("UHURA_WASM_DIST") { return PathBuf::from(explicit); @@ -919,814 +170,135 @@ fn tool_root() -> PathBuf { Path::new(env!("CARGO_MANIFEST_DIR")).join("../..") } -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -enum PlayArtifact { - Ir, - Inspect, - Stylesheet, - Fixture, - Script, - Boot, - Icons, - Config, -} - -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -enum ApiRoute<'a> { - EditorState, - EditorEvents, - PlayEvents, - PlayArtifact(PlayArtifact), - PlayProvider, - PlayAsset(&'a str), - PlayWasm(&'a str), - Unknown, -} - -fn api_route(path: &str) -> Option> { - let route = match path { - "/api/editor/state" => ApiRoute::EditorState, - "/api/editor/events" => ApiRoute::EditorEvents, - "/api/play/events" => ApiRoute::PlayEvents, - "/api/play/ir.json" => ApiRoute::PlayArtifact(PlayArtifact::Ir), - "/api/play/inspect.json" => ApiRoute::PlayArtifact(PlayArtifact::Inspect), - "/api/play/stylesheet.css" => ApiRoute::PlayArtifact(PlayArtifact::Stylesheet), - "/api/play/fixture.json" => ApiRoute::PlayArtifact(PlayArtifact::Fixture), - "/api/play/script.json" => ApiRoute::PlayArtifact(PlayArtifact::Script), - "/api/play/boot.json" => ApiRoute::PlayArtifact(PlayArtifact::Boot), - "/api/play/icons.json" => ApiRoute::PlayArtifact(PlayArtifact::Icons), - "/api/play/config.json" => ApiRoute::PlayArtifact(PlayArtifact::Config), - "/api/play/provider.js" => ApiRoute::PlayProvider, - _ => { - if let Some(relative) = path.strip_prefix("/api/play/assets/") - && !relative.is_empty() - { - ApiRoute::PlayAsset(relative) - } else if let Some(relative) = path.strip_prefix("/api/play/wasm/") - && !relative.is_empty() - { - ApiRoute::PlayWasm(relative) - } else if path.starts_with("/api/") { - ApiRoute::Unknown - } else { - return None; - } - } - }; - Some(route) -} - -/// (content type, body, optional Play generation) or (status, message). -type Served = Result<(String, Vec, Option), (u16, String)>; - -fn handle( - request: tiny_http::Request, - root: &Path, - web: &WebApp, - state: &RwLock, - play_clients: &Clients, - editor_clients: &Clients, -) { - let url = request.url().to_string(); - let (path, query) = split_request_url(&url); - - if !matches!( - request.method(), - tiny_http::Method::Get | tiny_http::Method::Head - ) { - let response = tiny_http::Response::from_string("the Uhura host accepts GET and HEAD only") - .with_status_code(405) - .with_header(header("Allow", "GET, HEAD")) - .with_header(header("Content-Type", "text/plain; charset=utf-8")) - .with_header(header("Cache-Control", "no-store")); - let _ = request.respond(response); - return; +fn print_initial_report(command: &str, report: uhura_host::PublicationReport) { + if report.editor_current { + println!( + "{command}: Editor revision 1 — {} previews ({} replay-derived)", + report.preview_count.unwrap_or(0), + report.replay_derived_count.unwrap_or(0) + ); + } else { + println!("{command}: Editor revision 1 rejected — application starts with diagnostics"); } - - match api_route(path) { - Some(ApiRoute::PlayEvents) => { - if request.method() != &tiny_http::Method::Get { - respond_sse_method_error(request, path); - return; - } - respond_sse(request, play_clients, || { - play_sse_payload(&state.read().expect("state lock").play) - }); - return; - } - Some(ApiRoute::EditorEvents) => { - if request.method() != &tiny_http::Method::Get { - respond_sse_method_error(request, path); - return; - } - respond_sse(request, editor_clients, || { - let revision = state.read().expect("state lock").editor.source_revision; - editor_sse_payload(revision) - }); - return; - } - _ => {} + if report.play_ok { + println!("{command}: Play checked clean"); + } else if report.has_good_play { + println!("{command}: Play check failing — serving the last good build"); + } else { + println!("{command}: Play check failing — no good build yet"); } +} - let outcome = match api_route(path) { - Some(ApiRoute::EditorState) => editor_state_artifact(state), - Some(ApiRoute::PlayArtifact(artifact_kind)) => play_artifact(state, artifact_kind), - Some(ApiRoute::PlayProvider) => provider_artifact(state, query), - Some(ApiRoute::PlayAsset(relative)) => { - serve_play_asset(&root.join("fixtures/assets"), relative) +fn observe(root: std::path::PathBuf, host: Arc, mut seen: ProjectSourceFingerprint) { + loop { + std::thread::sleep(Duration::from_millis(150)); + let observed = project_fingerprint(&root); + if observed == seen { + continue; } - Some(ApiRoute::PlayWasm(relative)) => { - serve_tree(&web.wasm_root, relative).map_err(|(status, message)| { - ( - status, - format!("{message}\n(build the Wasm bundle first: scripts/build-wasm.sh)"), - ) - }) + let stable = wait_for_stable_fingerprint(&root, observed); + if stable == seen { + continue; } - Some(ApiRoute::EditorEvents | ApiRoute::PlayEvents) => unreachable!("returned above"), - Some(ApiRoute::Unknown) => Err((404, format!("no such API endpoint: {path}"))), - None => application_path(web, path), - }; - let _ = match outcome { - Ok((content_type, mut bytes, generation)) => { - if request.method() == &tiny_http::Method::Head { - bytes.clear(); + let revision = host.source_revision() + 1; + let (candidate, settled) = build_stable_candidate(&root, stable, revision); + seen = settled.fingerprint().clone(); + match host.publish(candidate) { + Err(error) => { + eprintln!("uhura host: could not publish revision {revision}: {error}"); } - let mut response = tiny_http::Response::from_data(bytes) - .with_header(header("Content-Type", &content_type)) - .with_header(header("Cache-Control", "no-store")); - if let Some(generation) = generation { - response = - response.with_header(header("X-Uhura-Generation", &generation.to_string())); + Ok(report) => { + if report.editor_current { + println!( + "uhura host: Editor revision {revision} current — {} previews \ + ({} replay-derived)", + report.preview_count.unwrap_or(0), + report.replay_derived_count.unwrap_or(0) + ); + } else { + println!( + "uhura host: Editor revision {revision} rejected — last render is stale" + ); + } + println!( + "uhura host: Play generation {} — {}", + report.play_generation, + if report.play_ok { + "ok, clients reload" + } else { + "check failing, last-good runtime retained" + } + ); } - request.respond(response) } - Err((status, message)) => request.respond( - tiny_http::Response::from_string(message) - .with_status_code(tiny_http::StatusCode(status)) - .with_header(header("Content-Type", "text/plain; charset=utf-8")) - .with_header(header("Cache-Control", "no-store")), - ), - }; -} - -fn respond_sse_method_error(request: tiny_http::Request, path: &str) { - let response = tiny_http::Response::from_string(format!("{path} requires GET")) - .with_status_code(405) - .with_header(header("Allow", "GET")) - .with_header(header("Content-Type", "text/plain; charset=utf-8")) - .with_header(header("Cache-Control", "no-store")); - let _ = request.respond(response); -} - -fn split_request_url(url: &str) -> (&str, Option<&str>) { - url.split_once('?') - .map_or((url, None), |(path, query)| (path, Some(query))) -} - -fn editor_state_artifact(state: &RwLock) -> Served { - let state = state.read().expect("state lock"); - Ok(( - content_type("json"), - state.editor.state_json.clone().into_bytes(), - None, - )) -} - -fn play_artifact(state: &RwLock, artifact_kind: PlayArtifact) -> Served { - let state = state.read().expect("state lock"); - let Some(good) = &state.play.good else { - return Err(( - 503, - "no good Play build yet — fix the project diagnostics".to_string(), - )); - }; - let (extension, bytes) = match artifact_kind { - PlayArtifact::Ir => ("json", good.ir.as_bytes()), - PlayArtifact::Inspect => ("json", good.inspect_json.as_bytes()), - PlayArtifact::Stylesheet => ("css", good.stylesheet.as_bytes()), - PlayArtifact::Fixture => ("json", good.fixture_json.as_bytes()), - PlayArtifact::Script => ("json", good.script_json.as_bytes()), - PlayArtifact::Boot => ("json", good.boot_json.as_bytes()), - PlayArtifact::Icons => ("json", good.icons_json.as_bytes()), - PlayArtifact::Config => ("json", good.config_json.as_bytes()), - }; - Ok(( - content_type(extension), - bytes.to_vec(), - Some(state.play.generation), - )) -} - -fn provider_artifact(state: &RwLock, query: Option<&str>) -> Served { - let state = state.read().expect("state lock"); - let Some(good) = &state.play.good else { - return Err(( - 503, - "no good Play build yet — fix the project diagnostics".to_string(), - )); - }; - let Some(module) = &good.provider_js else { - return Err(( - 404, - "the Play profile uses the fixture provider".to_string(), - )); - }; - let requested_hash = query.and_then(|query| { - query - .split('&') - .find_map(|part| part.strip_prefix("sha256=")) - }); - let actual_hash = sha256_hex(module.as_bytes()); - if requested_hash.is_some_and(|expected| expected != actual_hash) { - return Err(( - 409, - "the provider changed after config.json was fetched — reload the page".to_string(), - )); } - Ok(( - content_type("js"), - module.clone().into_bytes(), - Some(state.play.generation), - )) } -fn app_document(web: &WebApp) -> Served { - Ok((content_type("html"), web.index.as_ref().clone(), None)) +fn project_fingerprint(root: &Path) -> ProjectSourceFingerprint { + capture_project_snapshot(root).fingerprint().clone() } -fn application_path(web: &WebApp, path: &str) -> Served { - let Some(relative) = path.strip_prefix('/') else { - return app_document(web); - }; - if relative == "assets" { - return Err((404, "no such application asset".to_string())); - } - if web.files.contains_key(relative) { - return serve_web_file(web, relative); - } - if relative.starts_with("assets/") { - return serve_web_file(web, relative); - } - if relative == "favicon.ico" { - return favicon(web); - } - app_document(web) -} - -fn serve_web_file(web: &WebApp, relative: &str) -> Served { - if relative.contains('\\') - || relative - .split('/') - .any(|segment| segment.is_empty() || segment == "." || segment == "..") - || Path::new(relative).is_absolute() - { - return Err((400, "bad application asset path".to_string())); - } - let file = web - .files - .get(relative) - .ok_or_else(|| (404, format!("no such application asset: /{relative}")))?; - Ok((file.content_type.clone(), file.bytes.as_ref().clone(), None)) -} - -fn favicon(web: &WebApp) -> Served { - match serve_web_file(web, "favicon.ico") { - Ok(file) => Ok(file), - Err((404, _)) => Ok((content_type("ico"), Vec::new(), None)), - Err(error) => Err(error), +fn wait_for_stable_fingerprint( + root: &Path, + mut observed: ProjectSourceFingerprint, +) -> ProjectSourceFingerprint { + loop { + std::thread::sleep(Duration::from_millis(100)); + let again = project_fingerprint(root); + if again == observed { + return observed; + } + observed = again; } } -fn serve_play_asset(base: &Path, encoded_relative: &str) -> Served { - let relative = decode_play_asset_path(encoded_relative)?; - serve_tree(base, &relative) -} - -/// Decode one URL-path suffix without giving an encoded percent sign a second -/// interpretation. Asset identities may contain spaces and safe nested `/` -/// separators, but the decoded result must remain a lexical relative path. -fn decode_play_asset_path(encoded: &str) -> Result { - let bytes = encoded.as_bytes(); - let mut decoded = Vec::with_capacity(bytes.len()); - let mut index = 0; - while index < bytes.len() { - if bytes[index] != b'%' { - decoded.push(bytes[index]); - index += 1; +fn build_stable_candidate( + root: &Path, + mut before: ProjectSourceFingerprint, + revision: u64, +) -> (uhura_host::ClientCandidate, ProjectSourceSnapshot) { + loop { + let snapshot = capture_project_snapshot(root); + if before != *snapshot.fingerprint() { + before = wait_for_stable_fingerprint(root, snapshot.fingerprint().clone()); continue; } - let Some(high) = bytes.get(index + 1).and_then(|byte| hex_value(*byte)) else { - return Err((400, "bad asset path: malformed percent escape".to_string())); - }; - let Some(low) = bytes.get(index + 2).and_then(|byte| hex_value(*byte)) else { - return Err((400, "bad asset path: malformed percent escape".to_string())); - }; - decoded.push((high << 4) | low); - index += 3; - } - - let decoded = String::from_utf8(decoded) - .map_err(|_| (400, "bad asset path: decoded path is not UTF-8".to_string()))?; - let path = Path::new(&decoded); - if decoded.contains(['\\', '\0']) - || decoded - .split('/') - .any(|segment| segment.is_empty() || segment == "." || segment == "..") - || path.is_absolute() - || path - .components() - .any(|component| !matches!(component, std::path::Component::Normal(_))) - { - return Err((400, "bad asset path".to_string())); - } - Ok(decoded) -} - -fn hex_value(byte: u8) -> Option { - match byte { - b'0'..=b'9' => Some(byte - b'0'), - b'a'..=b'f' => Some(byte - b'a' + 10), - b'A'..=b'F' => Some(byte - b'A' + 10), - _ => None, - } -} - -fn serve_tree(base: &Path, relative: &str) -> Served { - if relative - .split('/') - .any(|segment| segment == ".." || segment.is_empty()) - || relative.contains('\\') - { - return Err((400, "bad path".to_string())); - } - let path = base.join(relative); - let extension = path - .extension() - .and_then(|value| value.to_str()) - .unwrap_or(""); - std::fs::read(&path) - .map(|bytes| (content_type(extension), bytes, None)) - .map_err(|error| (404, format!("{}: {error}", path.display()))) -} - -fn content_type(extension: &str) -> String { - match extension { - "html" => "text/html; charset=utf-8", - "js" | "mjs" => "text/javascript; charset=utf-8", - "css" => "text/css; charset=utf-8", - "json" => "application/json; charset=utf-8", - "wasm" => "application/wasm", - "jpg" | "jpeg" => "image/jpeg", - "mp4" => "video/mp4", - "svg" => "image/svg+xml", - "ico" => "image/x-icon", - "png" => "image/png", - "webp" => "image/webp", - "woff" => "font/woff", - "woff2" => "font/woff2", - _ => "application/octet-stream", + let candidate = build_candidate(&snapshot, revision); + let after = project_fingerprint(root); + if snapshot.fingerprint() == &after { + return (candidate, snapshot); + } + before = wait_for_stable_fingerprint(root, after); } - .to_string() -} - -fn header(name: &str, value: &str) -> tiny_http::Header { - tiny_http::Header::from_bytes(name.as_bytes(), value.as_bytes()).expect("valid header") } -#[cfg(test)] -mod tests { - use std::collections::BTreeMap; - use std::fs; - use std::path::PathBuf; - use std::sync::Arc; - use std::time::{SystemTime, UNIX_EPOCH}; - - use uhura_base::to_canonical_json; - use uhura_editor_model::{Application, AuthoringMetadata, EditorRender, RenderFreshness}; - - use crate::cmd::editor_model::EditorModelArtifact; - - use super::{ - ApiRoute, EditorHostState, PlayArtifact, WebApp, api_route, app_document, application_path, - content_type, decode_play_asset_path, editor_sse_payload, load_web_app_from, recheck_play, - serve_play_asset, split_request_url, tool_root, +fn respond(request: tiny_http::Request, host: &Host) { + let method = match request.method() { + tiny_http::Method::Get => RequestMethod::Get, + tiny_http::Method::Head => RequestMethod::Head, + _ => RequestMethod::Other, }; - - fn render(revision: u64, name: &str) -> EditorRender { - EditorRender { - revision, - freshness: RenderFreshness::Current, - application: Application { - name: name.to_string(), - }, - authoring: AuthoringMetadata::default(), - groups: Vec::new(), - previews: Vec::new(), - stylesheet: String::new(), - icons: BTreeMap::new(), - assets: BTreeMap::new(), - } - } - - fn artifact(revision: u64, name: &str) -> EditorModelArtifact { - EditorModelArtifact { - render: render(revision, name), - preview_count: 0, - replay_derived_count: 0, - diagnostics: serde_json::Value::Null, - } - } - - fn diagnostics(message: &str) -> serde_json::Value { - serde_json::json!({ - "format": "uhura-diagnostics", - "version": 0, - "summary": { "errors": 1, "warnings": 0 }, - "diagnostics": [{ - "code": "UH9000", - "rule": "editor/test", - "severity": "error", - "message": message, - }], + let response = host.route(RouteRequest { + method, + url: request.url(), + }); + let headers = response + .headers + .iter() + .map(|(name, value)| { + tiny_http::Header::from_bytes(name.as_bytes(), value.as_bytes()) + .expect("host response headers are valid") }) - } - - fn state_json(state: &EditorHostState) -> serde_json::Value { - serde_json::from_str(&state.state_json).expect("state JSON") - } - - #[test] - fn editor_transitions_current_to_stale_and_recovers() { - let mut state = EditorHostState::initial(Ok(artifact(1, "first"))).unwrap(); - let first = state_json(&state); - assert_eq!(first["sourceRevision"], 1); - assert_eq!(first["render"]["freshness"], "current"); - assert_eq!(first["render"]["revision"], 1); - - state.apply(2, Err(diagnostics("broken"))).unwrap(); - let stale = state_json(&state); - assert_eq!(stale["sourceRevision"], 2); - assert_eq!(stale["render"]["freshness"], "stale"); - assert_eq!(stale["render"]["revision"], 1); - assert_eq!(stale["diagnostics"]["diagnostics"][0]["message"], "broken"); - - state.apply(3, Ok(artifact(3, "recovered"))).unwrap(); - let recovered = state_json(&state); - assert_eq!(recovered["sourceRevision"], 3); - assert_eq!(recovered["render"]["freshness"], "current"); - assert_eq!(recovered["render"]["revision"], 3); - assert_eq!(recovered["render"]["application"]["name"], "recovered"); - assert_eq!(recovered["diagnostics"], serde_json::Value::Null); - } - - #[test] - fn editor_cold_invalid_recovers_without_a_process_restart() { - let mut state = EditorHostState::initial(Err(diagnostics("cold"))).unwrap(); - let cold = state_json(&state); - assert_eq!(cold["sourceRevision"], 1); - assert_eq!(cold["render"], serde_json::Value::Null); - - state.apply(2, Ok(artifact(2, "ready"))).unwrap(); - let ready = state_json(&state); - assert_eq!(ready["render"]["freshness"], "current"); - assert_eq!(ready["render"]["revision"], 2); - } - - #[test] - fn editor_revisions_are_strictly_monotonic_and_atomic() { - let mut state = EditorHostState::initial(Ok(artifact(1, "one"))).unwrap(); - let before = state.state_json.clone(); - assert!(state.apply(1, Ok(artifact(1, "old"))).is_err()); - assert!(state.apply(3, Ok(artifact(3, "future"))).is_err()); - assert_eq!(state.source_revision, 1); - assert_eq!(state.state_json, before); - - state.apply(2, Ok(artifact(2, "two"))).unwrap(); - assert_eq!(state.source_revision, 2); - } - - #[test] - fn editor_sse_event_has_only_protocol_and_source_revision() { - let frame = editor_sse_payload(7); - let json = frame - .lines() - .next() - .and_then(|line| line.strip_prefix("data: ")) - .expect("data line"); - let event: serde_json::Value = serde_json::from_str(json).unwrap(); - assert_eq!( - event, - serde_json::json!({ - "protocol": "uhura-editor-event/0", - "sourceRevision": 7, - }) - ); - } - - #[test] - fn editor_and_play_routes_serve_byte_identical_application_entry() { - let web = WebApp { - files: Arc::new(BTreeMap::new()), - index: Arc::new(b"
Uhura
".to_vec()), - wasm_root: PathBuf::from("unused-wasm"), - }; - let (_, editor, _) = app_document(&web).unwrap(); - let (_, play, _) = app_document(&web).unwrap(); - assert_eq!(editor, play); - assert_eq!(editor.as_slice(), b"
Uhura
"); - } - - #[test] - fn api_routes_are_explicit_and_play_is_fully_namespaced() { - assert_eq!(api_route("/api/editor/state"), Some(ApiRoute::EditorState)); - assert_eq!( - api_route("/api/editor/events"), - Some(ApiRoute::EditorEvents) - ); - assert_eq!(api_route("/api/play/events"), Some(ApiRoute::PlayEvents)); - assert_eq!( - api_route("/api/play/ir.json"), - Some(ApiRoute::PlayArtifact(PlayArtifact::Ir)) - ); - assert_eq!( - api_route("/api/play/inspect.json"), - Some(ApiRoute::PlayArtifact(PlayArtifact::Inspect)) - ); - assert_eq!( - api_route("/api/play/assets/avatar.jpg"), - Some(ApiRoute::PlayAsset("avatar.jpg")) - ); - assert_eq!( - api_route("/api/play/wasm/uhura_wasm.js"), - Some(ApiRoute::PlayWasm("uhura_wasm.js")) - ); - assert_eq!(api_route("/ir.json"), None); - assert_eq!(api_route("/events"), None); - assert_eq!(api_route("/api/nope"), Some(ApiRoute::Unknown)); - } - - #[test] - fn play_inspection_artifact_is_coherent_with_checked_ir_and_spans() { - let root = tool_root().join("examples/instagram-uhura"); - let snapshot = super::super::editor_model::capture_project_snapshot(&root); - let good = recheck_play(&snapshot.files).expect("canonical example checks"); - let inspection: serde_json::Value = - serde_json::from_str(&good.inspect_json).expect("inspection JSON"); - let program = uhura_core::ir::load_program(&good.ir).expect("served IR loads"); - - assert_eq!(good.inspect_json, to_canonical_json(&inspection)); - assert_eq!(inspection["protocol"], "uhura-inspect/0"); - assert_eq!(inspection["kind"], "program"); - assert_eq!(inspection["span-offset-encoding"], "utf-8-bytes"); - assert_eq!(inspection["ir"]["hash"], program.hash()); - assert!( - inspection["nodes"] - .as_array() - .expect("graph nodes") - .iter() - .any(|node| node["id"] == "pages.feed/handler/0"), - "handler ids align with trace selection ids", - ); - assert!(inspection["spans"]["pages.feed/handler/0"].is_object()); - } - - #[test] - fn play_asset_paths_decode_spaces_and_safe_nested_slashes_once() { - assert_eq!( - decode_play_asset_path("gallery%2Fsummer%20day.jpg").unwrap(), - "gallery/summer day.jpg" - ); - assert_eq!( - decode_play_asset_path("%252e%252e%2Fsecret.jpg").unwrap(), - "%2e%2e/secret.jpg" - ); - } - - #[test] - fn play_asset_paths_reject_unsafe_or_malformed_input() { - for encoded in [ - "", - "/absolute.jpg", - "%2Fabsolute.jpg", - "album//photo.jpg", - "album%2F%2Fphoto.jpg", - ".", - "%2e", - "..", - "%2e%2e%2Fsecret.jpg", - "album/./photo.jpg", - "album%2F%2e%2e%2Fsecret.jpg", - "album\\photo.jpg", - "album%5Cphoto.jpg", - "%", - "%2", - "%GG", - "%FF.jpg", - "%00.jpg", - ] { - let error = decode_play_asset_path(encoded).unwrap_err(); - assert_eq!(error.0, 400, "{encoded}"); - } - } - - #[test] - fn play_asset_serving_preserves_decoded_extension_and_never_decodes_twice() { - let unique = SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap() - .as_nanos(); - let root = - std::env::temp_dir().join(format!("uhura-play-assets-{}-{unique}", std::process::id())); - let assets = root.join("assets"); - fs::create_dir_all(assets.join("summer album")).unwrap(); - fs::write(assets.join("summer album/clip one.mp4"), b"fixture-video").unwrap(); - fs::write(root.join("secret.jpg"), b"outside").unwrap(); - - let (kind, bytes, generation) = - serve_play_asset(&assets, "summer%20album%2Fclip%20one.mp4").unwrap(); - assert_eq!(kind, "video/mp4"); - assert_eq!(bytes, b"fixture-video"); - assert_eq!(generation, None); - - let error = serve_play_asset(&assets, "%252e%252e%2Fsecret.jpg").unwrap_err(); - assert_eq!(error.0, 404); - - fs::remove_dir_all(root).unwrap(); - } - - #[test] - fn frontend_locator_uses_a_complete_later_candidate() { - let unique = SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap() - .as_nanos(); - let root = - std::env::temp_dir().join(format!("uhura-web-dist-{}-{unique}", std::process::id())); - let missing = root.join("missing"); - let ready = root.join("ready"); - fs::create_dir_all(ready.join("assets")).unwrap(); - fs::write( - ready.join("index.html"), - r#""#, - ) - .unwrap(); - fs::write(ready.join("assets/app.js"), "application code").unwrap(); - - let web = load_web_app_from(&[missing, ready]).unwrap(); - assert_eq!( - web.index.as_slice(), - br#""# - ); - assert!(web.files.contains_key("assets/app.js")); - - fs::remove_dir_all(root).unwrap(); - } - - #[test] - fn frontend_bundle_snapshot_survives_a_dist_rebuild() { - let unique = SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap() - .as_nanos(); - let root = std::env::temp_dir().join(format!( - "uhura-web-snapshot-{}-{unique}", - std::process::id() - )); - let assets = root.join("assets"); - fs::create_dir_all(&assets).unwrap(); - fs::write( - root.join("index.html"), - r#""#, - ) - .unwrap(); - fs::write(assets.join("app-old.js"), "old application").unwrap(); - fs::write(assets.join("app-old.css"), "old styles").unwrap(); - - let web = load_web_app_from(std::slice::from_ref(&root)).unwrap(); - - fs::remove_dir_all(&root).unwrap(); - fs::create_dir_all(root.join("assets")).unwrap(); - fs::write( - root.join("index.html"), - r#""#, - ) - .unwrap(); - fs::write(root.join("assets/app-new.js"), "new application").unwrap(); - - let (index_type, index, _) = app_document(&web).unwrap(); - assert_eq!(index_type, "text/html; charset=utf-8"); - assert_eq!(index, br#""#); - let (script_type, script, _) = application_path(&web, "/assets/app-old.js").unwrap(); - assert_eq!(script_type, "text/javascript; charset=utf-8"); - assert_eq!(script, b"old application"); - let (style_type, style, _) = application_path(&web, "/assets/app-old.css").unwrap(); - assert_eq!(style_type, "text/css; charset=utf-8"); - assert_eq!(style, b"old styles"); - assert_eq!( - application_path(&web, "/assets/app-new.js").unwrap_err().0, - 404 - ); - - fs::remove_dir_all(root).unwrap(); - } - - #[test] - fn frontend_locator_rejects_an_index_only_bundle() { - let unique = SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap() - .as_nanos(); - let root = std::env::temp_dir().join(format!( - "uhura-web-index-only-{}-{unique}", - std::process::id() - )); - fs::create_dir_all(&root).unwrap(); - fs::write(root.join("index.html"), "incomplete application").unwrap(); - - let error = load_web_app_from(std::slice::from_ref(&root)).unwrap_err(); - assert!(error.contains("contains only index.html"), "{error}"); - - fs::remove_dir_all(root).unwrap(); - } - - #[test] - fn frontend_locator_rejects_missing_index_assets() { - let unique = SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap() - .as_nanos(); - let root = std::env::temp_dir().join(format!( - "uhura-web-missing-asset-{}-{unique}", - std::process::id() - )); - fs::create_dir_all(root.join("assets")).unwrap(); - fs::write( - root.join("index.html"), - r#""#, - ) - .unwrap(); - fs::write(root.join("assets/app.js"), "application").unwrap(); - - let error = load_web_app_from(std::slice::from_ref(&root)).unwrap_err(); - assert!( - error.contains("missing application asset: /assets/missing.css"), - "{error}" - ); - - fs::remove_dir_all(root).unwrap(); - } - - #[cfg(unix)] - #[test] - fn frontend_locator_rejects_unsafe_bundle_entries_and_paths() { - use std::os::unix::fs::symlink; - - let unique = SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap() - .as_nanos(); - let root = - std::env::temp_dir().join(format!("uhura-web-unsafe-{}-{unique}", std::process::id())); - let linked = root.join("linked"); - fs::create_dir_all(linked.join("assets")).unwrap(); - fs::write(linked.join("index.html"), "application").unwrap(); - fs::write(linked.join("outside.js"), "outside").unwrap(); - symlink("../outside.js", linked.join("assets/app.js")).unwrap(); - - let error = load_web_app_from(std::slice::from_ref(&linked)).unwrap_err(); - assert!(error.contains("unsafe non-regular entry"), "{error}"); - - let backslash = root.join("backslash"); - fs::create_dir_all(&backslash).unwrap(); - fs::write(backslash.join("index.html"), "application").unwrap(); - fs::write(backslash.join("assets\\app.js"), "application").unwrap(); - - let error = load_web_app_from(std::slice::from_ref(&backslash)).unwrap_err(); - assert!(error.contains("unsafe path"), "{error}"); - - fs::remove_dir_all(root).unwrap(); - } - - #[test] - fn query_is_transport_metadata_not_a_client_route() { - assert_eq!( - split_request_url("/api/play/provider.js?sha256=abc"), - ("/api/play/provider.js", Some("sha256=abc")) - ); - assert_eq!( - split_request_url("/play?post=42"), - ("/play", Some("post=42")) - ); - } - - #[test] - fn media_and_browser_asset_content_types_are_preserved() { - assert_eq!(content_type("mp4"), "video/mp4"); - assert_eq!(content_type("webp"), "image/webp"); - assert_eq!(content_type("wasm"), "application/wasm"); - assert_eq!(content_type("woff2"), "font/woff2"); - } + .collect(); + let length = response.body.content_length(); + let response = tiny_http::Response::new( + tiny_http::StatusCode(response.status), + headers, + response.body, + length, + None, + ); + let _ = request.respond(response); } diff --git a/crates/uhura-cli/src/cmd/mod.rs b/crates/uhura-cli/src/cmd/mod.rs index 8723bca..76e3839 100644 --- a/crates/uhura-cli/src/cmd/mod.rs +++ b/crates/uhura-cli/src/cmd/mod.rs @@ -1,7 +1,6 @@ pub mod check; pub mod dev; pub mod editor; -pub mod editor_model; pub mod fmt; pub mod trace; diff --git a/crates/uhura-cli/src/cmd/trace.rs b/crates/uhura-cli/src/cmd/trace.rs index 6bd39e3..379e3aa 100644 --- a/crates/uhura-cli/src/cmd/trace.rs +++ b/crates/uhura-cli/src/cmd/trace.rs @@ -15,14 +15,16 @@ use std::process::ExitCode; use uhura_base::{Ident, Severity, render_text, to_canonical_json}; use uhura_check::check; -use uhura_check::fixture::{FixtureData, load_fixture}; +use uhura_check::fixture::load_fixture; use uhura_core::event::{ApplyNote, Event, apply_failure, apply_updates, decode_carried_data}; use uhura_core::ir::ProgramIr; use uhura_core::state::{Projections, UiState}; use uhura_core::step::step_u; use uhura_core::view::{Descriptor, Node, Snapshot}; use uhura_fixture::FixtureDriver; -use uhura_port::envelope::{ProjectionUpdate, ProviderMsg}; +use uhura_port::envelope::ProviderMsg; + +pub use uhura_host::{boot_updates, fixture_slices_json}; use crate::CommonArgs; @@ -252,50 +254,6 @@ fn deliver_commands( Ok(()) } -/// The boot deliveries (§9.2): every `boot = true` projection from its -/// `boot.` fixture slice, revision 1 (driver mints start at 2 — -/// micro-decision #43). `uhura play`'s `/api/play/boot.json` and the wasm ABI -/// contract test build the same envelope. -pub fn boot_updates( - program: &ProgramIr, - fixture: &FixtureData, -) -> Result, String> { - let mut updates = Vec::new(); - for (name, decl) in &program.projections { - if !decl.boot { - continue; - } - let Some(value) = fixture.get("boot", name.as_str()) else { - return Err(format!( - "boot projection `{name}` needs a `boot.{name}` fixture slice (§6.1)" - )); - }; - updates.push(ProjectionUpdate { - port: decl.port.clone(), - projection: name.clone(), - key: None, - revision: 1, - value: value.clone(), - }); - } - Ok(updates) -} - -/// The resolved fixture slices as one canonical JSON object — the -/// `FixtureDriver::new` input (`uhura play` serves it as -/// `/api/play/fixture.json`). -pub fn fixture_slices_json(fixture: &FixtureData) -> String { - let mut root = serde_json::Map::new(); - for (ns, slices) in &fixture.slices { - let mut ns_map = serde_json::Map::new(); - for (name, value) in slices { - ns_map.insert(name.clone(), value.clone()); - } - root.insert(ns.clone(), serde_json::Value::Object(ns_map)); - } - to_canonical_json(&serde_json::Value::Object(root)) -} - // ── the harness-only [[ui]] stimulus section ──────────────────────────────── struct Stimulus { diff --git a/crates/uhura-host/Cargo.toml b/crates/uhura-host/Cargo.toml new file mode 100644 index 0000000..7bb016a --- /dev/null +++ b/crates/uhura-host/Cargo.toml @@ -0,0 +1,19 @@ +[package] +name = "uhura-host" +version.workspace = true +edition.workspace = true +publish.workspace = true + +[lints] +workspace = true + +[dependencies] +uhura-base = { workspace = true } +uhura-syntax = { workspace = true } +uhura-check = { workspace = true } +uhura-port = { workspace = true, features = ["toml"] } +uhura-core = { workspace = true } +uhura-fixture = { workspace = true } +uhura-editor-model = { workspace = true } +serde_json = { workspace = true } +toml = { workspace = true } diff --git a/crates/uhura-host/src/lib.rs b/crates/uhura-host/src/lib.rs new file mode 100644 index 0000000..d4752e2 --- /dev/null +++ b/crates/uhura-host/src/lib.rs @@ -0,0 +1,1855 @@ +//! Reusable host state and artifacts for the model-driven Editor and Play. +//! +//! Rust owns coherent project capture, checking/evaluation, immutable +//! `EditorState`, last-good Play artifacts, and HTTP/SSE transport. The +//! compiled web application owns every browser document and all presentation. + +use std::collections::{BTreeMap, BTreeSet}; +use std::io::{Cursor, Read}; +#[cfg(test)] +use std::path::PathBuf; +use std::path::{Component, Path}; +use std::sync::mpsc::{Receiver, RecvTimeoutError, Sender, channel}; +use std::sync::{Arc, Mutex, RwLock}; +use std::time::Duration; + +use uhura_base::{Severity, sha256_hex, to_canonical_json, to_envelope}; +use uhura_check::check; +use uhura_check::fixture::load_fixture; +use uhura_core::ir::ProgramIr; +use uhura_editor_model::{EditorRender, EditorState}; + +pub mod source; + +pub use source::{ProjectSourceFingerprint, ProjectSourceSnapshot, capture_project_snapshot}; + +const EDITOR_EVENT_PROTOCOL: &str = "uhura-editor-event/0"; + +// ── state and coherent Editor publication ────────────────────────────────── + +struct DevState { + play: PlayState, + editor: EditorHostState, +} + +#[derive(Default)] +struct PlayState { + generation: u64, + ok: bool, + diagnostics: Option, + /// Last-good artifacts; a rejected generation never replaces these. + good: Option, +} + +struct GoodBuild { + ir: String, + inspect_json: String, + stylesheet: String, + fixture_json: String, + script_json: String, + boot_json: String, + icons_json: String, + config_json: String, + provider_js: Option, + play_assets: BTreeMap>, +} + +type EditorBuildOutcome = Result; + +/// A complete off-path result for one coherently captured source revision. +/// Hosts may inspect its summary, then atomically publish it into [`Host`]. +pub struct ClientCandidate { + revision: u64, + editor: EditorBuildOutcome, + play: Result, +} + +/// Build-time facts used by terminal and aggregate-host presentation. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct CandidateSummary { + pub revision: u64, + pub editor_current: bool, + pub preview_count: Option, + pub replay_derived_count: Option, + pub play_ok: bool, +} + +impl ClientCandidate { + pub fn summary(&self) -> CandidateSummary { + let editor = self.editor.as_ref().ok(); + CandidateSummary { + revision: self.revision, + editor_current: editor.is_some(), + preview_count: editor.map(|artifact| artifact.preview_count), + replay_derived_count: editor.map(|artifact| artifact.replay_derived_count), + play_ok: self.play.is_ok(), + } + } +} + +/// State that became visible after one atomic publication. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct PublicationReport { + pub source_revision: u64, + pub editor_current: bool, + pub preview_count: Option, + pub replay_derived_count: Option, + pub play_generation: u64, + pub play_ok: bool, + pub has_good_play: bool, +} + +struct EditorHostState { + source_revision: u64, + state_json: String, + /// Always kept with its original render revision and `current` marker; + /// stale publication mutates a clone only. + last_renderable: Option, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +struct RevisionOrderError { + expected: u64, + received: u64, +} + +impl std::fmt::Display for RevisionOrderError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!( + f, + "Editor candidate revision {} arrived; expected {}", + self.received, self.expected + ) + } +} + +impl std::error::Error for RevisionOrderError {} + +impl EditorHostState { + fn initial(outcome: EditorBuildOutcome) -> Result { + let (state_json, last_renderable) = materialize_editor_state(1, outcome, None)?; + Ok(Self { + source_revision: 1, + state_json, + last_renderable, + }) + } + + fn apply(&mut self, revision: u64, outcome: EditorBuildOutcome) -> Result<(), String> { + let expected = self.source_revision + 1; + if revision != expected { + return Err(RevisionOrderError { + expected, + received: revision, + } + .to_string()); + } + // Build the whole replacement before mutating the published slot. + let (state_json, last_renderable) = + materialize_editor_state(revision, outcome, self.last_renderable.as_ref())?; + self.source_revision = revision; + self.state_json = state_json; + self.last_renderable = last_renderable; + Ok(()) + } +} + +fn materialize_editor_state( + revision: u64, + outcome: EditorBuildOutcome, + last_renderable: Option<&EditorRender>, +) -> Result<(String, Option), String> { + let (state, next_renderable) = match outcome { + Ok(artifact) => { + let next_renderable = artifact.render.clone(); + let state = EditorState::current(revision, artifact.diagnostics, artifact.render) + .map_err(|error| error.to_string())?; + (state, Some(next_renderable)) + } + Err(diagnostics) => match last_renderable { + Some(render) => ( + EditorState::stale(revision, diagnostics, render.clone()) + .map_err(|error| error.to_string())?, + Some(render.clone()), + ), + None => ( + EditorState::cold_invalid(revision, diagnostics) + .map_err(|error| error.to_string())?, + None, + ), + }, + }; + let state_json = state + .to_canonical_string() + .map_err(|error| error.to_string())?; + Ok((state_json, next_renderable)) +} + +/// Build Editor and Play from exactly the bytes in `snapshot`. +pub fn build_candidate(snapshot: &ProjectSourceSnapshot, revision: u64) -> ClientCandidate { + let editor = + source::build_captured_snapshot_at(snapshot, revision).map_err(|failure| failure.envelope); + let play = recheck_play(&snapshot.files); + ClientCandidate { + revision, + editor, + play, + } +} + +fn apply_play(play: &mut PlayState, outcome: Result) { + play.generation += 1; + match outcome { + Ok(good) => { + play.ok = true; + play.diagnostics = None; + play.good = Some(good); + } + Err(envelope) => { + play.ok = false; + play.diagnostics = Some(envelope); + } + } +} + +// ── Play's independent last-good artifacts ───────────────────────────────── + +fn recheck_play(files: &source::ProjectSourceFiles) -> Result { + let fail = |message: String| { + serde_json::json!({ + "format": "uhura-diagnostics", + "version": 0, + "summary": { "errors": 1, "warnings": 0 }, + "diagnostics": [{ + "code": "UH9000", + "rule": "play/recheck", + "severity": "error", + "message": message, + }], + }) + }; + let input = source::assemble_snapshot_input(files).map_err(|failure| failure.envelope)?; + let output = check(&input); + if output + .diagnostics + .iter() + .any(|diagnostic| diagnostic.severity == Severity::Error) + { + return Err(to_envelope(&output.diagnostics, &output.source_map)); + } + let Some(lowered) = &output.lowered else { + return Err(fail("the check produced no program".to_string())); + }; + let program = &lowered.program; + + let manifest = &input.manifest; + let profile = manifest + .play + .values() + .next() + .ok_or_else(|| fail("uhura.toml declares no [play.*] profile (§3)".to_string()))?; + let fixture_rel = manifest.fixtures.get(&profile.fixture).ok_or_else(|| { + fail(format!( + "play fixture `{}` is not declared", + profile.fixture + )) + })?; + let read = |relative: &str| -> Result { + let bytes = files + .resolve(Path::new(relative)) + .map_err(|error| fail(format!("{relative}: {error}")))? + .ok_or_else(|| fail(format!("{relative}: missing from the captured project")))?; + std::str::from_utf8(bytes) + .map(str::to_owned) + .map_err(|error| fail(format!("{relative}: source is not UTF-8: {error}"))) + }; + let fixture_text = read(fixture_rel)?; + let script_text = read(&format!("fixtures/scripts/{}.toml", profile.script))?; + let fixture = load_fixture(&fixture_text) + .map_err(|issues| fail(format!("fixture: {}", issues[0].message)))?; + let script_json = uhura_fixture::toml_to_json(&script_text).map_err(fail)?; + let fixture_json = fixture_slices_json(&fixture); + let script_canonical = to_canonical_json(&script_json); + uhura_fixture::FixtureDriver::new(&fixture_json, &script_canonical) + .map_err(|error| fail(format!("script `{}`: {error}", profile.script)))?; + + let (config_json, provider_js) = match &profile.provider { + Some(provider) => { + let provider_js = read(&provider.module)?; + let provider_hash = sha256_hex(provider_js.as_bytes()); + let config_json = to_canonical_json(&serde_json::json!({ + "allow_fixture": profile.allow_fixture, + "provider": { + "kind": "module", + "module": format!( + "/api/play/provider.js?sha256={provider_hash}" + ), + "config": &provider.config, + }, + })); + (config_json, Some(provider_js)) + } + None => ( + to_canonical_json(&serde_json::json!({ + "allow_fixture": true, + "provider": { "kind": "fixture" }, + })), + None, + ), + }; + + let mut inspection = uhura_core::inspect::program_graph(program); + inspection["spans"] = + serde_json::to_value(&lowered.spans).expect("IR spans are always serializable"); + + Ok(GoodBuild { + ir: program.to_canonical_string(), + inspect_json: to_canonical_json(&inspection), + stylesheet: output.stylesheet.clone(), + fixture_json, + script_json: script_canonical, + boot_json: boot_envelope(program, &fixture).map_err(fail)?, + icons_json: structured_icons_json(), + config_json, + provider_js, + play_assets: files.subtree(Path::new("fixtures/assets")), + }) +} + +/// The boot deliveries shared by the standalone trace harness and Play host. +pub fn boot_updates( + program: &ProgramIr, + fixture: &uhura_check::fixture::FixtureData, +) -> Result, String> { + let mut updates = Vec::new(); + for (name, decl) in &program.projections { + if !decl.boot { + continue; + } + let Some(value) = fixture.get("boot", name.as_str()) else { + return Err(format!( + "boot projection `{name}` needs a `boot.{name}` fixture slice (§6.1)" + )); + }; + updates.push(uhura_port::envelope::ProjectionUpdate { + port: decl.port.clone(), + projection: name.clone(), + key: None, + revision: 1, + value: value.clone(), + }); + } + Ok(updates) +} + +/// Resolve fixture slices into the canonical `FixtureDriver` input. +pub fn fixture_slices_json(fixture: &uhura_check::fixture::FixtureData) -> String { + let mut root = serde_json::Map::new(); + for (namespace, slices) in &fixture.slices { + let mut namespace_map = serde_json::Map::new(); + for (name, value) in slices { + namespace_map.insert(name.clone(), value.clone()); + } + root.insert(namespace.clone(), serde_json::Value::Object(namespace_map)); + } + to_canonical_json(&serde_json::Value::Object(root)) +} + +/// The `/api/play/boot.json` wire form. Acceptance tests reuse this exact +/// producer, so fixture and browser boot cannot drift. +pub fn boot_envelope( + program: &ProgramIr, + fixture: &uhura_check::fixture::FixtureData, +) -> Result { + let updates = boot_updates(program, fixture)?; + Ok(to_canonical_json(&serde_json::json!({ + "updates": updates + .iter() + .map(uhura_port::envelope::ProjectionUpdate::to_json) + .collect::>(), + }))) +} + +fn structured_icons_json() -> String { + let icons = uhura_editor_model::icons::table() + .into_iter() + .map(|(name, icon)| (name, icon.to_json())) + .collect::>(); + to_canonical_json(&serde_json::Value::Object(icons)) +} + +/// Listenerless Editor/Play host. The state and event hubs have host-session +/// lifetime; individual valid Play generations are immutable values inside it. +pub struct Host { + state: RwLock, + play_clients: Clients, + editor_clients: Clients, + web: Arc, +} + +impl Host { + /// Publish revision 1 and create stable route/event state. + pub fn new( + web: WebAssets, + candidate: ClientCandidate, + ) -> Result<(Self, PublicationReport), String> { + if candidate.revision != 1 { + return Err(format!( + "initial Uhura candidate must be revision 1, got {}", + candidate.revision + )); + } + let summary = candidate.summary(); + let editor = EditorHostState::initial(candidate.editor)?; + let mut play = PlayState::default(); + apply_play(&mut play, candidate.play); + let report = publication_report(&play, summary); + Ok(( + Self { + state: RwLock::new(DevState { play, editor }), + play_clients: Arc::new(Mutex::new(Vec::new())), + editor_clients: Arc::new(Mutex::new(Vec::new())), + web: Arc::new(web), + }, + report, + )) + } + + /// Atomically replace Editor publication state and advance Play's + /// last-good state, then notify the stable event hubs. + pub fn publish(&self, candidate: ClientCandidate) -> Result { + let summary = candidate.summary(); + let revision = candidate.revision; + let (report, editor_payload, play_payload) = { + let mut state = self.state.write().expect("state lock"); + state.editor.apply(revision, candidate.editor)?; + apply_play(&mut state.play, candidate.play); + ( + publication_report(&state.play, summary), + editor_sse_payload(revision), + play_sse_payload(&state.play), + ) + }; + broadcast(&self.editor_clients, &editor_payload); + broadcast(&self.play_clients, &play_payload); + Ok(report) + } + + pub fn source_revision(&self) -> u64 { + self.state + .read() + .expect("state lock") + .editor + .source_revision + } +} + +fn publication_report(play: &PlayState, summary: CandidateSummary) -> PublicationReport { + PublicationReport { + source_revision: summary.revision, + editor_current: summary.editor_current, + preview_count: summary.preview_count, + replay_derived_count: summary.replay_derived_count, + play_generation: play.generation, + play_ok: play.ok, + has_good_play: play.good.is_some(), + } +} + +// ── SSE ──────────────────────────────────────────────────────────────────── + +type Clients = Arc>>>; + +fn play_sse_payload(play: &PlayState) -> String { + let mut event = serde_json::json!({ + "generation": play.generation, + "ok": play.ok, + }); + if let Some(diagnostics) = &play.diagnostics { + event["diagnostics"] = diagnostics.clone(); + } + sse_frame(&event) +} + +fn editor_sse_payload(source_revision: u64) -> String { + sse_frame(&serde_json::json!({ + "protocol": EDITOR_EVENT_PROTOCOL, + "sourceRevision": source_revision, + })) +} + +fn sse_frame(value: &serde_json::Value) -> String { + // Padding crosses common streaming response buffers; EventSource ignores + // comments. + format!( + "data: {}\n\n: {}\n\n", + to_canonical_json(value), + "·".repeat(4096) + ) +} + +fn broadcast(clients: &Clients, payload: &str) { + let mut clients = clients.lock().expect("clients lock"); + clients.retain(|sender| sender.send(payload.to_string()).is_ok()); +} + +pub struct EventStream { + receiver: Receiver, + buffer: Vec, + offset: usize, +} + +/// One bounded wait on a host-session event stream. +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum EventStreamPoll { + Frame(String), + Timeout, + Closed, +} + +impl EventStream { + /// Wait at most `timeout` for one complete SSE frame. + /// + /// Async adapters can repeat this bounded wait and drop the stream when + /// their client disconnects, avoiding an indefinitely blocked bridge + /// thread. Use either this framed API or [`Read`], but do not mix them on + /// one stream. + pub fn next_frame_timeout(&self, timeout: Duration) -> EventStreamPoll { + match self.receiver.recv_timeout(timeout) { + Ok(frame) => EventStreamPoll::Frame(frame), + Err(RecvTimeoutError::Timeout) => EventStreamPoll::Timeout, + Err(RecvTimeoutError::Disconnected) => EventStreamPoll::Closed, + } + } +} + +impl Read for EventStream { + fn read(&mut self, output: &mut [u8]) -> std::io::Result { + if self.offset >= self.buffer.len() { + match self.receiver.recv() { + Ok(frame) => { + self.buffer = frame.into_bytes(); + self.offset = 0; + } + Err(_) => return Ok(0), + } + } + let count = (self.buffer.len() - self.offset).min(output.len()); + output[..count].copy_from_slice(&self.buffer[self.offset..self.offset + count]); + self.offset += count; + Ok(count) + } +} + +fn subscribe(clients: &Clients, hello: impl FnOnce() -> String) -> EventStream { + let (sender, receiver) = channel::(); + { + // Registration and snapshot share the broadcast lock: an update can + // be duplicated at the boundary but can never be lost. + let mut clients = clients.lock().expect("clients lock"); + let _ = sender.send(hello()); + clients.push(sender); + } + EventStream { + receiver, + buffer: Vec::new(), + offset: 0, + } +} + +// ── one web application and namespaced transport ─────────────────────────── + +#[derive(Clone, Debug)] +pub struct WebAssets { + files: Arc>, + index: Arc>, + wasm_files: Arc>, +} + +#[derive(Clone, Debug)] +struct WebFile { + bytes: Arc>, + content_type: String, +} + +impl WebAssets { + /// Snapshot explicit frontend and Wasm directories into an immutable host + /// value. Aggregate hosts should use this constructor with package-owned + /// paths instead of relying on standalone CLI discovery. + pub fn from_directories(web_root: &Path, wasm_root: &Path) -> Result { + load_web_assets(web_root, Some(wasm_root)) + } + + /// Snapshot a frontend directory without a Wasm bundle. This preserves + /// the standalone CLI's useful pre-Wasm diagnostics; packaged aggregate + /// hosts should use [`Self::from_directories`] instead. + pub fn from_frontend_directory(web_root: &Path) -> Result { + load_web_assets(web_root, None) + } +} + +#[cfg(test)] +fn load_web_app_from(candidates: &[PathBuf]) -> Result { + let mut attempted = Vec::new(); + for root in candidates { + if attempted.iter().any(|seen: &PathBuf| seen == root) { + continue; + } + attempted.push(root.clone()); + let index_path = root.join("index.html"); + match std::fs::symlink_metadata(&index_path) { + Ok(_) => { + return WebAssets::from_frontend_directory(root); + } + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(error) => { + return Err(format!("could not read {}: {error}", index_path.display())); + } + } + } + let locations = attempted + .iter() + .map(|root| root.join("index.html").display().to_string()) + .collect::>() + .join(", "); + Err(format!( + "browser application is not built (looked for {locations}); set \ + UHURA_WEB_DIST or build web/ before starting a browser surface" + )) +} + +fn load_web_assets( + web_root: &Path, + explicit_wasm_root: Option<&Path>, +) -> Result { + let index_path = web_root.join("index.html"); + let files = snapshot_web_bundle(web_root)?; + let index = files + .get("index.html") + .ok_or_else(|| format!("{} is not a regular file", index_path.display()))?; + if index.bytes.is_empty() { + return Err(format!("{} is empty", index_path.display())); + } + if files.len() == 1 { + return Err(format!( + "browser application bundle at {} contains only index.html", + web_root.display() + )); + } + validate_index_assets(web_root, index.bytes.as_slice(), &files)?; + let index = Arc::clone(&index.bytes); + + let wasm_files = match explicit_wasm_root { + Some(wasm_root) => snapshot_web_bundle(wasm_root)?, + None => BTreeMap::new(), + }; + Ok(WebAssets { + files: Arc::new(files), + index, + wasm_files: Arc::new(wasm_files), + }) +} + +fn snapshot_web_bundle(root: &Path) -> Result, String> { + let mut files = BTreeMap::new(); + let mut directories = vec![root.to_path_buf()]; + + while let Some(directory) = directories.pop() { + let entries = std::fs::read_dir(&directory) + .map_err(|error| format!("could not read {}: {error}", directory.display()))?; + let mut entries = entries + .collect::, _>>() + .map_err(|error| format!("could not read {}: {error}", directory.display()))?; + entries.sort_by_key(std::fs::DirEntry::file_name); + + for entry in entries { + let path = entry.path(); + let relative = normalized_web_bundle_path(root, &path)?; + let file_type = entry + .file_type() + .map_err(|error| format!("could not inspect {}: {error}", path.display()))?; + if file_type.is_dir() { + directories.push(path); + continue; + } + if !file_type.is_file() { + return Err(format!( + "browser application bundle contains an unsafe non-regular entry: {}", + path.display() + )); + } + + let extension = path + .extension() + .and_then(|value| value.to_str()) + .unwrap_or(""); + let bytes = std::fs::read(&path) + .map_err(|error| format!("could not read {}: {error}", path.display()))?; + files.insert( + relative, + WebFile { + bytes: Arc::new(bytes), + content_type: content_type(extension), + }, + ); + } + } + + Ok(files) +} + +fn validate_index_assets( + root: &Path, + index: &[u8], + files: &BTreeMap, +) -> Result<(), String> { + let index = std::str::from_utf8(index).map_err(|error| { + format!( + "{} is not UTF-8: {error}", + root.join("index.html").display() + ) + })?; + let references = index_asset_references(index)?; + if references.is_empty() { + return Err(format!( + "{} references no local JavaScript or CSS assets", + root.join("index.html").display() + )); + } + for reference in references { + if !files.contains_key(&reference) { + return Err(format!( + "{} references a missing application asset: /{reference}", + root.join("index.html").display() + )); + } + } + Ok(()) +} + +fn index_asset_references(index: &str) -> Result, String> { + let bytes = index.as_bytes(); + let mut references = BTreeSet::new(); + let mut cursor = 0; + while cursor < bytes.len() { + if !bytes[cursor].is_ascii_alphabetic() { + cursor += 1; + continue; + } + let name_start = cursor; + while bytes + .get(cursor) + .is_some_and(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b':')) + { + cursor += 1; + } + let name = &index[name_start..cursor]; + if !name.eq_ignore_ascii_case("src") && !name.eq_ignore_ascii_case("href") { + continue; + } + while bytes.get(cursor).is_some_and(u8::is_ascii_whitespace) { + cursor += 1; + } + if bytes.get(cursor) != Some(&b'=') { + continue; + } + cursor += 1; + while bytes.get(cursor).is_some_and(u8::is_ascii_whitespace) { + cursor += 1; + } + let Some(first) = bytes.get(cursor).copied() else { + break; + }; + let (value_start, value_end) = if matches!(first, b'\'' | b'"') { + cursor += 1; + let start = cursor; + while bytes.get(cursor).is_some_and(|byte| *byte != first) { + cursor += 1; + } + let end = cursor; + if cursor < bytes.len() { + cursor += 1; + } + (start, end) + } else { + let start = cursor; + while bytes + .get(cursor) + .is_some_and(|byte| !byte.is_ascii_whitespace() && *byte != b'>') + { + cursor += 1; + } + (start, cursor) + }; + let value = &index[value_start..value_end]; + let path_end = value.find(['?', '#']).unwrap_or(value.len()); + let path = &value[..path_end]; + let lowercase = path.to_ascii_lowercase(); + if !lowercase.ends_with(".js") + && !lowercase.ends_with(".mjs") + && !lowercase.ends_with(".css") + { + continue; + } + if path.starts_with("//") + || path + .find(':') + .is_some_and(|colon| path.find('/').is_none_or(|slash| colon < slash)) + { + continue; + } + let relative = path.strip_prefix('/').unwrap_or(path); + if relative.contains('\\') + || relative + .split('/') + .any(|segment| segment.is_empty() || segment == "." || segment == "..") + || Path::new(relative).is_absolute() + { + return Err(format!( + "index.html contains an unsafe local application asset reference: {value}" + )); + } + references.insert(relative.to_string()); + } + Ok(references) +} + +fn normalized_web_bundle_path(root: &Path, path: &Path) -> Result { + let relative = path.strip_prefix(root).map_err(|_| { + format!( + "browser application bundle entry escapes {}: {}", + root.display(), + path.display() + ) + })?; + let mut segments = Vec::new(); + for component in relative.components() { + let Component::Normal(segment) = component else { + return Err(format!( + "browser application bundle contains an unsafe path: {}", + path.display() + )); + }; + let segment = segment.to_str().ok_or_else(|| { + format!( + "browser application bundle path is not UTF-8: {}", + path.display() + ) + })?; + if segment.contains('\\') { + return Err(format!( + "browser application bundle contains an unsafe path: {}", + path.display() + )); + } + segments.push(segment); + } + if segments.is_empty() { + return Err(format!( + "browser application bundle contains an unsafe path: {}", + path.display() + )); + } + Ok(segments.join("/")) +} + +#[cfg(test)] +fn tool_root() -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")).join("../..") +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum PlayArtifact { + Ir, + Inspect, + Stylesheet, + Fixture, + Script, + Boot, + Icons, + Config, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum ApiRoute<'a> { + EditorState, + EditorEvents, + PlayEvents, + PlayArtifact(PlayArtifact), + PlayProvider, + PlayAsset(&'a str), + PlayWasm(&'a str), + Unknown, +} + +fn api_route(path: &str) -> Option> { + let route = match path { + "/api/editor/state" => ApiRoute::EditorState, + "/api/editor/events" => ApiRoute::EditorEvents, + "/api/play/events" => ApiRoute::PlayEvents, + "/api/play/ir.json" => ApiRoute::PlayArtifact(PlayArtifact::Ir), + "/api/play/inspect.json" => ApiRoute::PlayArtifact(PlayArtifact::Inspect), + "/api/play/stylesheet.css" => ApiRoute::PlayArtifact(PlayArtifact::Stylesheet), + "/api/play/fixture.json" => ApiRoute::PlayArtifact(PlayArtifact::Fixture), + "/api/play/script.json" => ApiRoute::PlayArtifact(PlayArtifact::Script), + "/api/play/boot.json" => ApiRoute::PlayArtifact(PlayArtifact::Boot), + "/api/play/icons.json" => ApiRoute::PlayArtifact(PlayArtifact::Icons), + "/api/play/config.json" => ApiRoute::PlayArtifact(PlayArtifact::Config), + "/api/play/provider.js" => ApiRoute::PlayProvider, + _ => { + if let Some(relative) = path.strip_prefix("/api/play/assets/") + && !relative.is_empty() + { + ApiRoute::PlayAsset(relative) + } else if let Some(relative) = path.strip_prefix("/api/play/wasm/") + && !relative.is_empty() + { + ApiRoute::PlayWasm(relative) + } else if path.starts_with("/api/") { + ApiRoute::Unknown + } else { + return None; + } + } + }; + Some(route) +} + +/// (content type, body, optional Play generation) or (status, message). +type Served = Result<(String, Vec, Option), (u16, String)>; + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum RequestMethod { + Get, + Head, + Other, +} + +pub struct RouteRequest<'a> { + pub method: RequestMethod, + pub url: &'a str, +} + +pub struct RouteResponse { + pub status: u16, + pub headers: Vec<(String, String)>, + pub body: RouteBody, +} + +pub enum RouteBody { + Bytes(Cursor>), + Events(EventStream), +} + +impl RouteBody { + pub fn content_length(&self) -> Option { + match self { + Self::Bytes(bytes) => Some(bytes.get_ref().len()), + Self::Events(_) => None, + } + } +} + +impl Read for RouteBody { + fn read(&mut self, output: &mut [u8]) -> std::io::Result { + match self { + Self::Bytes(bytes) => bytes.read(output), + Self::Events(events) => events.read(output), + } + } +} + +impl Host { + /// Resolve one HTTP-like request without owning a listener or server loop. + pub fn route(&self, request: RouteRequest<'_>) -> RouteResponse { + let (path, query) = split_request_url(request.url); + if request.method == RequestMethod::Other { + return byte_response( + 405, + "text/plain; charset=utf-8", + "the Uhura host accepts GET and HEAD only" + .as_bytes() + .to_vec(), + vec![("Allow".to_string(), "GET, HEAD".to_string())], + ); + } + + match api_route(path) { + Some(ApiRoute::PlayEvents) => { + if request.method != RequestMethod::Get { + return event_method_error(path); + } + let stream = subscribe(&self.play_clients, || { + play_sse_payload(&self.state.read().expect("state lock").play) + }); + return event_response(stream); + } + Some(ApiRoute::EditorEvents) => { + if request.method != RequestMethod::Get { + return event_method_error(path); + } + let stream = subscribe(&self.editor_clients, || { + let revision = self + .state + .read() + .expect("state lock") + .editor + .source_revision; + editor_sse_payload(revision) + }); + return event_response(stream); + } + _ => {} + } + + let outcome = match api_route(path) { + Some(ApiRoute::EditorState) => editor_state_artifact(&self.state), + Some(ApiRoute::PlayArtifact(artifact_kind)) => { + play_artifact(&self.state, artifact_kind) + } + Some(ApiRoute::PlayProvider) => provider_artifact(&self.state, query), + Some(ApiRoute::PlayAsset(relative)) => play_asset(&self.state, relative), + Some(ApiRoute::PlayWasm(relative)) => serve_file_map(&self.web.wasm_files, relative) + .map_err(|(status, message)| { + ( + status, + format!("{message}\n(build the Wasm bundle first: scripts/build-wasm.sh)"), + ) + }), + Some(ApiRoute::EditorEvents | ApiRoute::PlayEvents) => { + unreachable!("returned above") + } + Some(ApiRoute::Unknown) => Err((404, format!("no such API endpoint: {path}"))), + None => application_path(&self.web, path), + }; + served_response(request.method, outcome) + } +} + +fn served_response(method: RequestMethod, outcome: Served) -> RouteResponse { + match outcome { + Ok((content_type, mut bytes, generation)) => { + if method == RequestMethod::Head { + bytes.clear(); + } + let mut headers = Vec::new(); + if let Some(generation) = generation { + headers.push(("X-Uhura-Generation".to_string(), generation.to_string())); + } + byte_response(200, &content_type, bytes, headers) + } + Err((status, message)) => byte_response( + status, + "text/plain; charset=utf-8", + message.into_bytes(), + Vec::new(), + ), + } +} + +fn byte_response( + status: u16, + content_type: &str, + bytes: Vec, + mut headers: Vec<(String, String)>, +) -> RouteResponse { + headers.push(("Content-Type".to_string(), content_type.to_string())); + headers.push(("Cache-Control".to_string(), "no-store".to_string())); + RouteResponse { + status, + headers, + body: RouteBody::Bytes(Cursor::new(bytes)), + } +} + +fn event_response(stream: EventStream) -> RouteResponse { + RouteResponse { + status: 200, + headers: vec![ + ( + "Content-Type".to_string(), + "text/event-stream; charset=utf-8".to_string(), + ), + ("Cache-Control".to_string(), "no-store".to_string()), + ], + body: RouteBody::Events(stream), + } +} + +fn event_method_error(path: &str) -> RouteResponse { + byte_response( + 405, + "text/plain; charset=utf-8", + format!("{path} requires GET").into_bytes(), + vec![("Allow".to_string(), "GET".to_string())], + ) +} + +fn split_request_url(url: &str) -> (&str, Option<&str>) { + url.split_once('?') + .map_or((url, None), |(path, query)| (path, Some(query))) +} + +fn editor_state_artifact(state: &RwLock) -> Served { + let state = state.read().expect("state lock"); + Ok(( + content_type("json"), + state.editor.state_json.clone().into_bytes(), + None, + )) +} + +fn play_artifact(state: &RwLock, artifact_kind: PlayArtifact) -> Served { + let state = state.read().expect("state lock"); + let Some(good) = &state.play.good else { + return Err(( + 503, + "no good Play build yet — fix the project diagnostics".to_string(), + )); + }; + let (extension, bytes) = match artifact_kind { + PlayArtifact::Ir => ("json", good.ir.as_bytes()), + PlayArtifact::Inspect => ("json", good.inspect_json.as_bytes()), + PlayArtifact::Stylesheet => ("css", good.stylesheet.as_bytes()), + PlayArtifact::Fixture => ("json", good.fixture_json.as_bytes()), + PlayArtifact::Script => ("json", good.script_json.as_bytes()), + PlayArtifact::Boot => ("json", good.boot_json.as_bytes()), + PlayArtifact::Icons => ("json", good.icons_json.as_bytes()), + PlayArtifact::Config => ("json", good.config_json.as_bytes()), + }; + Ok(( + content_type(extension), + bytes.to_vec(), + Some(state.play.generation), + )) +} + +fn provider_artifact(state: &RwLock, query: Option<&str>) -> Served { + let state = state.read().expect("state lock"); + let Some(good) = &state.play.good else { + return Err(( + 503, + "no good Play build yet — fix the project diagnostics".to_string(), + )); + }; + let Some(module) = &good.provider_js else { + return Err(( + 404, + "the Play profile uses the fixture provider".to_string(), + )); + }; + let requested_hash = query.and_then(|query| { + query + .split('&') + .find_map(|part| part.strip_prefix("sha256=")) + }); + let actual_hash = sha256_hex(module.as_bytes()); + if requested_hash.is_some_and(|expected| expected != actual_hash) { + return Err(( + 409, + "the provider changed after config.json was fetched — reload the page".to_string(), + )); + } + Ok(( + content_type("js"), + module.clone().into_bytes(), + Some(state.play.generation), + )) +} + +fn app_document(web: &WebAssets) -> Served { + Ok((content_type("html"), web.index.as_ref().clone(), None)) +} + +fn application_path(web: &WebAssets, path: &str) -> Served { + let Some(relative) = path.strip_prefix('/') else { + return app_document(web); + }; + if relative == "assets" { + return Err((404, "no such application asset".to_string())); + } + if web.files.contains_key(relative) { + return serve_web_file(web, relative); + } + if relative.starts_with("assets/") { + return serve_web_file(web, relative); + } + if relative == "favicon.ico" { + return favicon(web); + } + app_document(web) +} + +fn serve_web_file(web: &WebAssets, relative: &str) -> Served { + if relative.contains('\\') + || relative + .split('/') + .any(|segment| segment.is_empty() || segment == "." || segment == "..") + || Path::new(relative).is_absolute() + { + return Err((400, "bad application asset path".to_string())); + } + let file = web + .files + .get(relative) + .ok_or_else(|| (404, format!("no such application asset: /{relative}")))?; + Ok((file.content_type.clone(), file.bytes.as_ref().clone(), None)) +} + +fn favicon(web: &WebAssets) -> Served { + match serve_web_file(web, "favicon.ico") { + Ok(file) => Ok(file), + Err((404, _)) => Ok((content_type("ico"), Vec::new(), None)), + Err(error) => Err(error), + } +} + +fn play_asset(state: &RwLock, encoded_relative: &str) -> Served { + let state = state.read().expect("state lock"); + let Some(good) = &state.play.good else { + return Err(( + 503, + "no good Play build yet — fix the project diagnostics".to_string(), + )); + }; + captured_play_asset(&good.play_assets, encoded_relative) +} + +fn captured_play_asset(assets: &BTreeMap>, encoded_relative: &str) -> Served { + let relative = decode_play_asset_path(encoded_relative)?; + let bytes = assets + .get(&relative) + .ok_or_else(|| (404, format!("no such Play asset: {relative}")))?; + let extension = Path::new(&relative) + .extension() + .and_then(|value| value.to_str()) + .unwrap_or(""); + Ok((content_type(extension), bytes.to_vec(), None)) +} + +/// Decode one URL-path suffix without giving an encoded percent sign a second +/// interpretation. Asset identities may contain spaces and safe nested `/` +/// separators, but the decoded result must remain a lexical relative path. +fn decode_play_asset_path(encoded: &str) -> Result { + let bytes = encoded.as_bytes(); + let mut decoded = Vec::with_capacity(bytes.len()); + let mut index = 0; + while index < bytes.len() { + if bytes[index] != b'%' { + decoded.push(bytes[index]); + index += 1; + continue; + } + + let Some(high) = bytes.get(index + 1).and_then(|byte| hex_value(*byte)) else { + return Err((400, "bad asset path: malformed percent escape".to_string())); + }; + let Some(low) = bytes.get(index + 2).and_then(|byte| hex_value(*byte)) else { + return Err((400, "bad asset path: malformed percent escape".to_string())); + }; + decoded.push((high << 4) | low); + index += 3; + } + + let decoded = String::from_utf8(decoded) + .map_err(|_| (400, "bad asset path: decoded path is not UTF-8".to_string()))?; + let path = Path::new(&decoded); + if decoded.contains(['\\', '\0']) + || decoded + .split('/') + .any(|segment| segment.is_empty() || segment == "." || segment == "..") + || path.is_absolute() + || path + .components() + .any(|component| !matches!(component, std::path::Component::Normal(_))) + { + return Err((400, "bad asset path".to_string())); + } + Ok(decoded) +} + +fn hex_value(byte: u8) -> Option { + match byte { + b'0'..=b'9' => Some(byte - b'0'), + b'a'..=b'f' => Some(byte - b'a' + 10), + b'A'..=b'F' => Some(byte - b'A' + 10), + _ => None, + } +} + +fn serve_file_map(files: &BTreeMap, relative: &str) -> Served { + if relative + .split('/') + .any(|segment| segment == "." || segment == ".." || segment.is_empty()) + || relative.contains('\\') + || Path::new(relative).is_absolute() + { + return Err((400, "bad path".to_string())); + } + let file = files + .get(relative) + .ok_or_else(|| (404, format!("no such bundled file: {relative}")))?; + Ok((file.content_type.clone(), file.bytes.as_ref().clone(), None)) +} + +fn content_type(extension: &str) -> String { + match extension { + "html" => "text/html; charset=utf-8", + "js" | "mjs" => "text/javascript; charset=utf-8", + "css" => "text/css; charset=utf-8", + "json" => "application/json; charset=utf-8", + "wasm" => "application/wasm", + "jpg" | "jpeg" => "image/jpeg", + "mp4" => "video/mp4", + "svg" => "image/svg+xml", + "ico" => "image/x-icon", + "png" => "image/png", + "webp" => "image/webp", + "woff" => "font/woff", + "woff2" => "font/woff2", + _ => "application/octet-stream", + } + .to_string() +} + +#[cfg(test)] +mod tests { + use std::collections::BTreeMap; + use std::fs; + use std::io::Read; + use std::sync::Arc; + use std::time::{Duration, SystemTime, UNIX_EPOCH}; + + use uhura_base::to_canonical_json; + use uhura_editor_model::{Application, AuthoringMetadata, EditorRender, RenderFreshness}; + + use crate::source::EditorModelArtifact; + + use super::{ + ApiRoute, EditorHostState, EventStream, EventStreamPoll, PlayArtifact, RequestMethod, + RouteBody, RouteRequest, WebAssets, api_route, app_document, application_path, + captured_play_asset, content_type, decode_play_asset_path, editor_sse_payload, + load_web_app_from, recheck_play, serve_file_map, split_request_url, tool_root, + }; + + fn render(revision: u64, name: &str) -> EditorRender { + EditorRender { + revision, + freshness: RenderFreshness::Current, + application: Application { + name: name.to_string(), + }, + authoring: AuthoringMetadata::default(), + groups: Vec::new(), + previews: Vec::new(), + stylesheet: String::new(), + icons: BTreeMap::new(), + assets: BTreeMap::new(), + } + } + + fn artifact(revision: u64, name: &str) -> EditorModelArtifact { + EditorModelArtifact { + render: render(revision, name), + preview_count: 0, + replay_derived_count: 0, + diagnostics: serde_json::Value::Null, + } + } + + fn diagnostics(message: &str) -> serde_json::Value { + serde_json::json!({ + "format": "uhura-diagnostics", + "version": 0, + "summary": { "errors": 1, "warnings": 0 }, + "diagnostics": [{ + "code": "UH9000", + "rule": "editor/test", + "severity": "error", + "message": message, + }], + }) + } + + fn state_json(state: &EditorHostState) -> serde_json::Value { + serde_json::from_str(&state.state_json).expect("state JSON") + } + + #[test] + fn editor_transitions_current_to_stale_and_recovers() { + let mut state = EditorHostState::initial(Ok(artifact(1, "first"))).unwrap(); + let first = state_json(&state); + assert_eq!(first["sourceRevision"], 1); + assert_eq!(first["render"]["freshness"], "current"); + assert_eq!(first["render"]["revision"], 1); + + state.apply(2, Err(diagnostics("broken"))).unwrap(); + let stale = state_json(&state); + assert_eq!(stale["sourceRevision"], 2); + assert_eq!(stale["render"]["freshness"], "stale"); + assert_eq!(stale["render"]["revision"], 1); + assert_eq!(stale["diagnostics"]["diagnostics"][0]["message"], "broken"); + + state.apply(3, Ok(artifact(3, "recovered"))).unwrap(); + let recovered = state_json(&state); + assert_eq!(recovered["sourceRevision"], 3); + assert_eq!(recovered["render"]["freshness"], "current"); + assert_eq!(recovered["render"]["revision"], 3); + assert_eq!(recovered["render"]["application"]["name"], "recovered"); + assert_eq!(recovered["diagnostics"], serde_json::Value::Null); + } + + #[test] + fn editor_cold_invalid_recovers_without_a_process_restart() { + let mut state = EditorHostState::initial(Err(diagnostics("cold"))).unwrap(); + let cold = state_json(&state); + assert_eq!(cold["sourceRevision"], 1); + assert_eq!(cold["render"], serde_json::Value::Null); + + state.apply(2, Ok(artifact(2, "ready"))).unwrap(); + let ready = state_json(&state); + assert_eq!(ready["render"]["freshness"], "current"); + assert_eq!(ready["render"]["revision"], 2); + } + + #[test] + fn editor_revisions_are_strictly_monotonic_and_atomic() { + let mut state = EditorHostState::initial(Ok(artifact(1, "one"))).unwrap(); + let before = state.state_json.clone(); + assert!(state.apply(1, Ok(artifact(1, "old"))).is_err()); + assert!(state.apply(3, Ok(artifact(3, "future"))).is_err()); + assert_eq!(state.source_revision, 1); + assert_eq!(state.state_json, before); + + state.apply(2, Ok(artifact(2, "two"))).unwrap(); + assert_eq!(state.source_revision, 2); + } + + #[test] + fn editor_sse_event_has_only_protocol_and_source_revision() { + let frame = editor_sse_payload(7); + let json = frame + .lines() + .next() + .and_then(|line| line.strip_prefix("data: ")) + .expect("data line"); + let event: serde_json::Value = serde_json::from_str(json).unwrap(); + assert_eq!( + event, + serde_json::json!({ + "protocol": "uhura-editor-event/0", + "sourceRevision": 7, + }) + ); + } + + #[test] + fn editor_and_play_routes_serve_byte_identical_application_entry() { + let web = WebAssets { + files: Arc::new(BTreeMap::new()), + index: Arc::new(b"
Uhura
".to_vec()), + wasm_files: Arc::new(BTreeMap::new()), + }; + let (_, editor, _) = app_document(&web).unwrap(); + let (_, play, _) = app_document(&web).unwrap(); + assert_eq!(editor, play); + assert_eq!(editor.as_slice(), b"
Uhura
"); + } + + #[test] + fn api_routes_are_explicit_and_play_is_fully_namespaced() { + assert_eq!(api_route("/api/editor/state"), Some(ApiRoute::EditorState)); + assert_eq!( + api_route("/api/editor/events"), + Some(ApiRoute::EditorEvents) + ); + assert_eq!(api_route("/api/play/events"), Some(ApiRoute::PlayEvents)); + assert_eq!( + api_route("/api/play/ir.json"), + Some(ApiRoute::PlayArtifact(PlayArtifact::Ir)) + ); + assert_eq!( + api_route("/api/play/inspect.json"), + Some(ApiRoute::PlayArtifact(PlayArtifact::Inspect)) + ); + assert_eq!( + api_route("/api/play/assets/avatar.jpg"), + Some(ApiRoute::PlayAsset("avatar.jpg")) + ); + assert_eq!( + api_route("/api/play/wasm/uhura_wasm.js"), + Some(ApiRoute::PlayWasm("uhura_wasm.js")) + ); + assert_eq!(api_route("/ir.json"), None); + assert_eq!(api_route("/events"), None); + assert_eq!(api_route("/api/nope"), Some(ApiRoute::Unknown)); + } + + #[test] + fn play_inspection_artifact_is_coherent_with_checked_ir_and_spans() { + let root = tool_root().join("examples/instagram-uhura"); + let snapshot = crate::source::capture_project_snapshot(&root); + let good = recheck_play(&snapshot.files).expect("canonical example checks"); + let inspection: serde_json::Value = + serde_json::from_str(&good.inspect_json).expect("inspection JSON"); + let program = uhura_core::ir::load_program(&good.ir).expect("served IR loads"); + + assert_eq!(good.inspect_json, to_canonical_json(&inspection)); + assert_eq!(inspection["protocol"], "uhura-inspect/0"); + assert_eq!(inspection["kind"], "program"); + assert_eq!(inspection["span-offset-encoding"], "utf-8-bytes"); + assert_eq!(inspection["ir"]["hash"], program.hash()); + assert!( + inspection["nodes"] + .as_array() + .expect("graph nodes") + .iter() + .any(|node| node["id"] == "pages.feed/handler/0"), + "handler ids align with trace selection ids", + ); + assert!(inspection["spans"]["pages.feed/handler/0"].is_object()); + } + + #[test] + fn play_asset_paths_decode_spaces_and_safe_nested_slashes_once() { + assert_eq!( + decode_play_asset_path("gallery%2Fsummer%20day.jpg").unwrap(), + "gallery/summer day.jpg" + ); + assert_eq!( + decode_play_asset_path("%252e%252e%2Fsecret.jpg").unwrap(), + "%2e%2e/secret.jpg" + ); + } + + #[test] + fn play_asset_paths_reject_unsafe_or_malformed_input() { + for encoded in [ + "", + "/absolute.jpg", + "%2Fabsolute.jpg", + "album//photo.jpg", + "album%2F%2Fphoto.jpg", + ".", + "%2e", + "..", + "%2e%2e%2Fsecret.jpg", + "album/./photo.jpg", + "album%2F%2e%2e%2Fsecret.jpg", + "album\\photo.jpg", + "album%5Cphoto.jpg", + "%", + "%2", + "%GG", + "%FF.jpg", + "%00.jpg", + ] { + let error = decode_play_asset_path(encoded).unwrap_err(); + assert_eq!(error.0, 400, "{encoded}"); + } + } + + #[test] + fn play_asset_serving_preserves_decoded_extension_and_never_decodes_twice() { + let assets = BTreeMap::from([( + "summer album/clip one.mp4".to_string(), + Arc::<[u8]>::from(&b"fixture-video"[..]), + )]); + + let (kind, bytes, generation) = + captured_play_asset(&assets, "summer%20album%2Fclip%20one.mp4").unwrap(); + assert_eq!(kind, "video/mp4"); + assert_eq!(bytes, b"fixture-video"); + assert_eq!(generation, None); + + let error = captured_play_asset(&assets, "%252e%252e%2Fsecret.jpg").unwrap_err(); + assert_eq!(error.0, 404); + } + + fn test_web_assets() -> WebAssets { + WebAssets { + files: Arc::new(BTreeMap::new()), + index: Arc::new(b"
Uhura
".to_vec()), + wasm_files: Arc::new(BTreeMap::new()), + } + } + + fn next_event(stream: &EventStream) -> serde_json::Value { + let EventStreamPoll::Frame(frame) = stream.next_frame_timeout(Duration::from_secs(1)) + else { + panic!("expected event frame"); + }; + let json = frame + .lines() + .next() + .and_then(|line| line.strip_prefix("data: ")) + .expect("event data line"); + serde_json::from_str(json).expect("event JSON") + } + + #[test] + fn host_publication_is_coherent_and_keeps_event_streams_stable() { + let root = tool_root().join("examples/instagram-uhura"); + let snapshot = crate::source::capture_project_snapshot(&root); + let candidate = super::build_candidate(&snapshot, 1); + let (host, first) = super::Host::new(test_web_assets(), candidate).unwrap(); + assert_eq!(first.source_revision, 1); + assert_eq!(first.play_generation, 1); + assert!(first.editor_current); + assert!(first.play_ok); + + let editor_events = match host + .route(RouteRequest { + method: RequestMethod::Get, + url: "/api/editor/events", + }) + .body + { + RouteBody::Events(stream) => stream, + RouteBody::Bytes(_) => panic!("expected Editor event stream"), + }; + let play_events = match host + .route(RouteRequest { + method: RequestMethod::Get, + url: "/api/play/events", + }) + .body + { + RouteBody::Events(stream) => stream, + RouteBody::Bytes(_) => panic!("expected Play event stream"), + }; + assert_eq!(next_event(&editor_events)["sourceRevision"], 1); + assert_eq!(next_event(&play_events)["generation"], 1); + assert_eq!( + editor_events.next_frame_timeout(Duration::from_millis(1)), + EventStreamPoll::Timeout + ); + + let second = host.publish(super::build_candidate(&snapshot, 2)).unwrap(); + assert_eq!(second.source_revision, 2); + assert_eq!(second.play_generation, 2); + assert!(second.editor_current); + assert!(second.play_ok); + assert_eq!(host.source_revision(), 2); + assert_eq!(next_event(&editor_events)["sourceRevision"], 2); + assert_eq!(next_event(&play_events)["generation"], 2); + + let mut response = host.route(RouteRequest { + method: RequestMethod::Get, + url: "/api/play/ir.json", + }); + assert_eq!(response.status, 200); + assert!( + response + .headers + .iter() + .any(|(name, value)| { name == "X-Uhura-Generation" && value == "2" }) + ); + let mut body = String::new(); + response.body.read_to_string(&mut body).unwrap(); + assert!(!body.is_empty()); + } + + #[test] + fn frontend_locator_uses_a_complete_later_candidate() { + let unique = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos(); + let root = + std::env::temp_dir().join(format!("uhura-web-dist-{}-{unique}", std::process::id())); + let missing = root.join("missing"); + let ready = root.join("ready"); + fs::create_dir_all(ready.join("assets")).unwrap(); + fs::write( + ready.join("index.html"), + r#""#, + ) + .unwrap(); + fs::write(ready.join("assets/app.js"), "application code").unwrap(); + + let web = load_web_app_from(&[missing, ready]).unwrap(); + assert_eq!( + web.index.as_slice(), + br#""# + ); + assert!(web.files.contains_key("assets/app.js")); + + fs::remove_dir_all(root).unwrap(); + } + + #[test] + fn frontend_bundle_snapshot_survives_a_dist_rebuild() { + let unique = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos(); + let root = std::env::temp_dir().join(format!( + "uhura-web-snapshot-{}-{unique}", + std::process::id() + )); + let assets = root.join("assets"); + fs::create_dir_all(&assets).unwrap(); + fs::write( + root.join("index.html"), + r#""#, + ) + .unwrap(); + fs::write(assets.join("app-old.js"), "old application").unwrap(); + fs::write(assets.join("app-old.css"), "old styles").unwrap(); + + let web = load_web_app_from(std::slice::from_ref(&root)).unwrap(); + + fs::remove_dir_all(&root).unwrap(); + fs::create_dir_all(root.join("assets")).unwrap(); + fs::write( + root.join("index.html"), + r#""#, + ) + .unwrap(); + fs::write(root.join("assets/app-new.js"), "new application").unwrap(); + + let (index_type, index, _) = app_document(&web).unwrap(); + assert_eq!(index_type, "text/html; charset=utf-8"); + assert_eq!(index, br#""#); + let (script_type, script, _) = application_path(&web, "/assets/app-old.js").unwrap(); + assert_eq!(script_type, "text/javascript; charset=utf-8"); + assert_eq!(script, b"old application"); + let (style_type, style, _) = application_path(&web, "/assets/app-old.css").unwrap(); + assert_eq!(style_type, "text/css; charset=utf-8"); + assert_eq!(style, b"old styles"); + assert_eq!( + application_path(&web, "/assets/app-new.js").unwrap_err().0, + 404 + ); + + fs::remove_dir_all(root).unwrap(); + } + + #[test] + fn wasm_bundle_snapshot_survives_a_dist_rebuild() { + let unique = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos(); + let root = std::env::temp_dir().join(format!( + "uhura-wasm-snapshot-{}-{unique}", + std::process::id() + )); + fs::create_dir_all(root.join("assets")).unwrap(); + fs::create_dir_all(root.join("wasm")).unwrap(); + fs::write( + root.join("index.html"), + r#""#, + ) + .unwrap(); + fs::write(root.join("assets/app.js"), "application").unwrap(); + fs::write(root.join("wasm/uhura_wasm.js"), "old wasm glue").unwrap(); + + let web = WebAssets::from_directories(&root, &root.join("wasm")).unwrap(); + fs::remove_dir_all(root.join("wasm")).unwrap(); + + let (kind, bytes, _) = serve_file_map(&web.wasm_files, "uhura_wasm.js").unwrap(); + assert_eq!(kind, "text/javascript; charset=utf-8"); + assert_eq!(bytes, b"old wasm glue"); + + fs::remove_dir_all(root).unwrap(); + } + + #[test] + fn frontend_locator_rejects_an_index_only_bundle() { + let unique = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos(); + let root = std::env::temp_dir().join(format!( + "uhura-web-index-only-{}-{unique}", + std::process::id() + )); + fs::create_dir_all(&root).unwrap(); + fs::write(root.join("index.html"), "incomplete application").unwrap(); + + let error = load_web_app_from(std::slice::from_ref(&root)).unwrap_err(); + assert!(error.contains("contains only index.html"), "{error}"); + + fs::remove_dir_all(root).unwrap(); + } + + #[test] + fn frontend_locator_rejects_missing_index_assets() { + let unique = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos(); + let root = std::env::temp_dir().join(format!( + "uhura-web-missing-asset-{}-{unique}", + std::process::id() + )); + fs::create_dir_all(root.join("assets")).unwrap(); + fs::write( + root.join("index.html"), + r#""#, + ) + .unwrap(); + fs::write(root.join("assets/app.js"), "application").unwrap(); + + let error = load_web_app_from(std::slice::from_ref(&root)).unwrap_err(); + assert!( + error.contains("missing application asset: /assets/missing.css"), + "{error}" + ); + + fs::remove_dir_all(root).unwrap(); + } + + #[cfg(unix)] + #[test] + fn frontend_locator_rejects_unsafe_bundle_entries_and_paths() { + use std::os::unix::fs::symlink; + + let unique = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos(); + let root = + std::env::temp_dir().join(format!("uhura-web-unsafe-{}-{unique}", std::process::id())); + let linked = root.join("linked"); + fs::create_dir_all(linked.join("assets")).unwrap(); + fs::write(linked.join("index.html"), "application").unwrap(); + fs::write(linked.join("outside.js"), "outside").unwrap(); + symlink("../outside.js", linked.join("assets/app.js")).unwrap(); + + let error = load_web_app_from(std::slice::from_ref(&linked)).unwrap_err(); + assert!(error.contains("unsafe non-regular entry"), "{error}"); + + let backslash = root.join("backslash"); + fs::create_dir_all(&backslash).unwrap(); + fs::write(backslash.join("index.html"), "application").unwrap(); + fs::write(backslash.join("assets\\app.js"), "application").unwrap(); + + let error = load_web_app_from(std::slice::from_ref(&backslash)).unwrap_err(); + assert!(error.contains("unsafe path"), "{error}"); + + fs::remove_dir_all(root).unwrap(); + } + + #[test] + fn query_is_transport_metadata_not_a_client_route() { + assert_eq!( + split_request_url("/api/play/provider.js?sha256=abc"), + ("/api/play/provider.js", Some("sha256=abc")) + ); + assert_eq!( + split_request_url("/play?post=42"), + ("/play", Some("post=42")) + ); + } + + #[test] + fn media_and_browser_asset_content_types_are_preserved() { + assert_eq!(content_type("mp4"), "video/mp4"); + assert_eq!(content_type("webp"), "image/webp"); + assert_eq!(content_type("wasm"), "application/wasm"); + assert_eq!(content_type("woff2"), "font/woff2"); + } +} diff --git a/crates/uhura-cli/src/cmd/editor_model.rs b/crates/uhura-host/src/source.rs similarity index 93% rename from crates/uhura-cli/src/cmd/editor_model.rs rename to crates/uhura-host/src/source.rs index 86f9325..0ba28eb 100644 --- a/crates/uhura-cli/src/cmd/editor_model.rs +++ b/crates/uhura-host/src/source.rs @@ -1,4 +1,4 @@ -//! Coherent source capture and Editor read-model construction. Filesystem +//! Coherent Uhura source capture and Editor read-model construction. Filesystem //! bytes are captured once, then checking, example replay, and evaluation use //! that immutable revision. Browser presentation lives entirely in `web/`. @@ -63,6 +63,23 @@ impl ProjectSourceFiles { } Ok(found) } + + pub(crate) fn subtree(&self, prefix: &Path) -> BTreeMap> { + self.entries + .iter() + .filter_map(|(path, bytes)| { + let relative = path.strip_prefix(prefix).ok()?; + if relative.as_os_str().is_empty() { + return None; + } + let parts = relative + .components() + .map(|component| component.as_os_str().to_str()) + .collect::>>()?; + Some((parts.join("/"), Arc::clone(bytes))) + }) + .collect() + } } fn normalize_corpus_path(path: &Path) -> Result { @@ -102,7 +119,7 @@ fn case_key(path: &Path) -> Option> { } #[derive(Clone, Debug, Default, Eq, PartialEq)] -pub(crate) struct ProjectSourceFingerprint { +pub struct ProjectSourceFingerprint { entries: BTreeMap, case_insensitive: bool, } @@ -121,6 +138,57 @@ impl DerefMut for ProjectSourceFingerprint { } } +impl ProjectSourceFingerprint { + /// Deterministic content identity for this complete observation. + /// + /// The digest includes filesystem case behavior plus length-prefixed raw + /// path identities and values for every entry. It is suitable for host + /// generation comparisons without relying on `Debug` formatting. + pub fn stable_id(&self) -> String { + let mut bytes = b"uhura-project-source-fingerprint/1\0".to_vec(); + bytes.push(u8::from(self.case_insensitive)); + bytes.extend_from_slice(&(self.entries.len() as u64).to_be_bytes()); + for (path, value) in &self.entries { + let path = fingerprint_path_bytes(path); + append_fingerprint_field(&mut bytes, &path); + append_fingerprint_field(&mut bytes, value.as_bytes()); + } + uhura_base::sha256_hex(&bytes) + } +} + +fn append_fingerprint_field(bytes: &mut Vec, field: &[u8]) { + bytes.extend_from_slice(&(field.len() as u64).to_be_bytes()); + bytes.extend_from_slice(field); +} + +#[cfg(unix)] +fn fingerprint_path_bytes(path: &Path) -> Vec { + use std::os::unix::ffi::OsStrExt; + + let mut bytes = b"unix\0".to_vec(); + bytes.extend_from_slice(path.as_os_str().as_bytes()); + bytes +} + +#[cfg(windows)] +fn fingerprint_path_bytes(path: &Path) -> Vec { + use std::os::windows::ffi::OsStrExt; + + let mut bytes = b"windows-utf16le\0".to_vec(); + for unit in path.as_os_str().encode_wide() { + bytes.extend_from_slice(&unit.to_le_bytes()); + } + bytes +} + +#[cfg(not(any(unix, windows)))] +fn fingerprint_path_bytes(path: &Path) -> Vec { + let mut bytes = b"unicode-lossy\0".to_vec(); + bytes.extend_from_slice(path.to_string_lossy().as_bytes()); + bytes +} + #[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd)] pub(crate) struct ProjectCaptureFailure { path: PathBuf, @@ -130,12 +198,19 @@ pub(crate) struct ProjectCaptureFailure { } #[derive(Clone, Debug, Default)] -pub(crate) struct ProjectSourceSnapshot { +pub struct ProjectSourceSnapshot { pub(crate) files: ProjectSourceFiles, pub(crate) fingerprint: ProjectSourceFingerprint, failures: Vec, } +impl ProjectSourceSnapshot { + /// Content identity of every captured project input. + pub fn fingerprint(&self) -> &ProjectSourceFingerprint { + &self.fingerprint + } +} + /// One completely checked, browser-neutral Editor model held in memory. pub(crate) struct EditorModelArtifact { pub(crate) render: EditorRender, @@ -232,7 +307,7 @@ pub(crate) fn build_captured_snapshot_at( /// fingerprinted and consumed by the builder, so an attempt cannot mix file /// revisions. Safe in-project symlinks retain logical identity; broad output /// exclusions are overridden only for exact declared dependencies. -pub(crate) fn capture_project_snapshot(root: &Path) -> ProjectSourceSnapshot { +pub fn capture_project_snapshot(root: &Path) -> ProjectSourceSnapshot { capture_project_snapshot_with(root, &mut |path: &Path| std::fs::read(path)) } @@ -1379,10 +1454,11 @@ mod tests { use uhura_base::{Diagnostic, SourceMap, Span}; use super::{ - EditorModelBuildFailure, ProjectSourceFiles, build_captured_snapshot, build_snapshot, - capture_project_snapshot, capture_project_snapshot_with, failure_envelope, - load_snapshot_assets, project_indeterminate_path_blocks, project_path_blocks, - project_scan_root, snapshot_rel_path, snapshot_source_name, + EditorModelBuildFailure, ProjectSourceFiles, ProjectSourceFingerprint, + build_captured_snapshot, build_snapshot, capture_project_snapshot, + capture_project_snapshot_with, failure_envelope, load_snapshot_assets, + project_indeterminate_path_blocks, project_path_blocks, project_scan_root, + snapshot_rel_path, snapshot_source_name, }; fn corpus_root() -> PathBuf { @@ -1412,6 +1488,31 @@ mod tests { } } + #[test] + fn fingerprint_stable_id_covers_case_behavior_paths_and_values() { + let mut first = ProjectSourceFingerprint::default(); + first.insert(PathBuf::from("app/a.uhura"), "one".to_string()); + first.insert(PathBuf::from("app/b.uhura"), "two".to_string()); + + let mut reordered = ProjectSourceFingerprint::default(); + reordered.insert(PathBuf::from("app/b.uhura"), "two".to_string()); + reordered.insert(PathBuf::from("app/a.uhura"), "one".to_string()); + assert_eq!(first.stable_id(), reordered.stable_id()); + + let mut changed_value = first.clone(); + changed_value.insert(PathBuf::from("app/a.uhura"), "changed".to_string()); + assert_ne!(first.stable_id(), changed_value.stable_id()); + + let mut changed_path = first.clone(); + let value = changed_path.remove(Path::new("app/a.uhura")).unwrap(); + changed_path.insert(PathBuf::from("app/c.uhura"), value); + assert_ne!(first.stable_id(), changed_path.stable_id()); + + let mut changed_case_behavior = first.clone(); + changed_case_behavior.case_insensitive = true; + assert_ne!(first.stable_id(), changed_case_behavior.stable_id()); + } + #[test] fn operational_editor_failures_use_the_standard_diagnostics_envelope() { let envelope = failure_envelope("editor/assets", "assets: manifest is invalid"); From 7dba27bc06b4981d94130f8d52d252fe569ccc5a Mon Sep 17 00:00:00 2001 From: Universe Date: Wed, 15 Jul 2026 05:30:45 +0900 Subject: [PATCH 2/8] feat: expose candidate diagnostics --- crates/uhura-host/src/lib.rs | 114 +++++++++++++++++++++++++++++++++++ 1 file changed, 114 insertions(+) diff --git a/crates/uhura-host/src/lib.rs b/crates/uhura-host/src/lib.rs index d4752e2..f9a9143 100644 --- a/crates/uhura-host/src/lib.rs +++ b/crates/uhura-host/src/lib.rs @@ -42,6 +42,10 @@ struct PlayState { } struct GoodBuild { + /// Diagnostics belonging to this otherwise usable Play build. This is + /// `null` when the checker reported nothing and a complete + /// `uhura-diagnostics/0` envelope when it reported warnings. + diagnostics: serde_json::Value, ir: String, inspect_json: String, stylesheet: String, @@ -60,6 +64,7 @@ type EditorBuildOutcome = Result /// Hosts may inspect its summary, then atomically publish it into [`Host`]. pub struct ClientCandidate { revision: u64, + source_fingerprint: ProjectSourceFingerprint, editor: EditorBuildOutcome, play: Result, } @@ -74,6 +79,20 @@ pub struct CandidateSummary { pub play_ok: bool, } +/// Structured diagnostics produced while building one client candidate. +/// +/// Each component is either JSON `null` or a complete +/// `uhura-diagnostics/0` envelope. Accepted Editor and Play outcomes may still +/// carry warnings; rejected outcomes carry their error envelope. Use +/// [`ClientCandidate::summary`] to distinguish acceptance from rejection. +/// This view borrows the off-path candidate and does not construct a [`Host`] +/// or load [`WebAssets`]. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct CandidateDiagnostics<'a> { + pub editor: &'a serde_json::Value, + pub play: &'a serde_json::Value, +} + impl ClientCandidate { pub fn summary(&self) -> CandidateSummary { let editor = self.editor.as_ref().ok(); @@ -85,6 +104,43 @@ impl ClientCandidate { play_ok: self.play.is_ok(), } } + + /// Inspect the exact Editor and Play diagnostics produced for this + /// candidate without publishing it. + #[must_use] + pub fn diagnostics(&self) -> CandidateDiagnostics<'_> { + let editor = match &self.editor { + Ok(artifact) => &artifact.diagnostics, + Err(diagnostics) => diagnostics, + }; + let play = match &self.play { + Ok(artifact) => &artifact.diagnostics, + Err(diagnostics) => diagnostics, + }; + CandidateDiagnostics { editor, play } + } + + /// Content identity of the exact coherent source snapshot consumed by + /// this candidate build. + /// + /// This source identity, rather than an artifact-output hash, is the v1 + /// candidate identity. Editor artifacts embed the publication revision, + /// while all Editor and Play artifact encodings remain private, + /// toolchain-derived implementation details. Hashing those outputs would + /// either make an unchanged source acquire a new identity at every + /// publication or promise cross-toolchain stability the host does not + /// provide. Within one Uhura binary the artifacts are deterministic + /// functions of this captured source and the requested revision. + #[must_use] + pub fn source_fingerprint(&self) -> &ProjectSourceFingerprint { + &self.source_fingerprint + } + + /// Deterministic digest form of [`Self::source_fingerprint`]. + #[must_use] + pub fn source_id(&self) -> String { + self.source_fingerprint.stable_id() + } } /// State that became visible after one atomic publication. @@ -192,6 +248,7 @@ pub fn build_candidate(snapshot: &ProjectSourceSnapshot, revision: u64) -> Clien let play = recheck_play(&snapshot.files); ClientCandidate { revision, + source_fingerprint: snapshot.fingerprint.clone(), editor, play, } @@ -240,6 +297,7 @@ fn recheck_play(files: &source::ProjectSourceFiles) -> Result Result 0); + assert!(!envelope["diagnostics"].as_array().unwrap().is_empty()); + } + + fs::remove_dir_all(root).unwrap(); + } + #[test] fn frontend_locator_uses_a_complete_later_candidate() { let unique = SystemTime::now() From 2d7459e682b3ae5f353abb227ad88e9635faef99 Mon Sep 17 00:00:00 2001 From: Universe Date: Wed, 15 Jul 2026 05:36:18 +0900 Subject: [PATCH 3/8] fix: bound host event queues --- crates/uhura-host/src/lib.rs | 63 ++++++++++++++++++++++++++++++------ 1 file changed, 54 insertions(+), 9 deletions(-) diff --git a/crates/uhura-host/src/lib.rs b/crates/uhura-host/src/lib.rs index f9a9143..3ed2356 100644 --- a/crates/uhura-host/src/lib.rs +++ b/crates/uhura-host/src/lib.rs @@ -9,7 +9,7 @@ use std::io::{Cursor, Read}; #[cfg(test)] use std::path::PathBuf; use std::path::{Component, Path}; -use std::sync::mpsc::{Receiver, RecvTimeoutError, Sender, channel}; +use std::sync::mpsc::{Receiver, RecvTimeoutError, SyncSender, TrySendError, sync_channel}; use std::sync::{Arc, Mutex, RwLock}; use std::time::Duration; @@ -517,7 +517,12 @@ fn publication_report(play: &PlayState, summary: CandidateSummary) -> Publicatio // ── SSE ──────────────────────────────────────────────────────────────────── -type Clients = Arc>>>; +// Event frames are invalidations, not an artifact log: Editor and Play clients +// refetch the host's current immutable state after receiving one. Each +// subscriber therefore has a one-frame queue. If it is stalled, the already +// queued frame remains a sufficient invalidation and later publications are +// intentionally coalesced instead of growing memory without bound. +type Clients = Arc>>>; fn play_sse_payload(play: &PlayState) -> String { let mut event = serde_json::json!({ @@ -549,7 +554,10 @@ fn sse_frame(value: &serde_json::Value) -> String { fn broadcast(clients: &Clients, payload: &str) { let mut clients = clients.lock().expect("clients lock"); - clients.retain(|sender| sender.send(payload.to_string()).is_ok()); + clients.retain(|sender| match sender.try_send(payload.to_string()) { + Ok(()) | Err(TrySendError::Full(_)) => true, + Err(TrySendError::Disconnected(_)) => false, + }); } pub struct EventStream { @@ -601,12 +609,15 @@ impl Read for EventStream { } fn subscribe(clients: &Clients, hello: impl FnOnce() -> String) -> EventStream { - let (sender, receiver) = channel::(); + let (sender, receiver) = sync_channel::(1); { // Registration and snapshot share the broadcast lock: an update can - // be duplicated at the boundary but can never be lost. + // be coalesced with the initial invalidation but can never leave the + // subscriber without an invalidation to refetch current state. let mut clients = clients.lock().expect("clients lock"); - let _ = sender.send(hello()); + sender + .try_send(hello()) + .expect("new event queue has one available slot"); clients.push(sender); } EventStream { @@ -1376,7 +1387,7 @@ mod tests { use std::collections::BTreeMap; use std::fs; use std::io::Read; - use std::sync::Arc; + use std::sync::{Arc, Mutex}; use std::time::{Duration, SystemTime, UNIX_EPOCH}; use uhura_base::to_canonical_json; @@ -1386,9 +1397,9 @@ mod tests { use super::{ ApiRoute, EditorHostState, EventStream, EventStreamPoll, PlayArtifact, RequestMethod, - RouteBody, RouteRequest, WebAssets, api_route, app_document, application_path, + RouteBody, RouteRequest, WebAssets, api_route, app_document, application_path, broadcast, captured_play_asset, content_type, decode_play_asset_path, editor_sse_payload, - load_web_app_from, recheck_play, serve_file_map, split_request_url, tool_root, + load_web_app_from, recheck_play, serve_file_map, split_request_url, subscribe, tool_root, }; fn render(revision: u64, name: &str) -> EditorRender { @@ -1646,6 +1657,40 @@ mod tests { serde_json::from_str(json).expect("event JSON") } + #[test] + fn stalled_event_subscriber_keeps_only_one_invalidation() { + let clients = Arc::new(Mutex::new(Vec::new())); + let stream = subscribe(&clients, || editor_sse_payload(1)); + + for revision in 2..=128 { + broadcast(&clients, &editor_sse_payload(revision)); + } + + // The initial frame still invalidates the client's view, so it can + // refetch the current artifact. Redundant frames did not accumulate. + assert_eq!(next_event(&stream)["sourceRevision"], 1); + assert_eq!( + stream.next_frame_timeout(Duration::from_millis(1)), + EventStreamPoll::Timeout + ); + + // Draining the slot lets the next publication wake the same stream. + broadcast(&clients, &editor_sse_payload(129)); + assert_eq!(next_event(&stream)["sourceRevision"], 129); + } + + #[test] + fn publication_prunes_disconnected_event_subscribers() { + let clients = Arc::new(Mutex::new(Vec::new())); + let stream = subscribe(&clients, || editor_sse_payload(1)); + assert_eq!(clients.lock().expect("clients lock").len(), 1); + + drop(stream); + broadcast(&clients, &editor_sse_payload(2)); + + assert!(clients.lock().expect("clients lock").is_empty()); + } + #[test] fn host_publication_is_coherent_and_keeps_event_streams_stable() { let root = tool_root().join("examples/instagram-uhura"); From ada1d1244b8a58af378214fb05a3224bc71d9770 Mon Sep 17 00:00:00 2001 From: Universe Date: Wed, 15 Jul 2026 05:53:09 +0900 Subject: [PATCH 4/8] feat: consume framework host environment --- .../instagram-uhura/providers/spock.test.ts | 177 ++++++++++++++++++ examples/instagram-uhura/providers/spock.ts | 140 +++++++++++++- 2 files changed, 313 insertions(+), 4 deletions(-) diff --git a/examples/instagram-uhura/providers/spock.test.ts b/examples/instagram-uhura/providers/spock.test.ts index 785ce96..de63348 100644 --- a/examples/instagram-uhura/providers/spock.test.ts +++ b/examples/instagram-uhura/providers/spock.test.ts @@ -235,6 +235,22 @@ function graphql(data: unknown): Response { return new Response(JSON.stringify({ data })); } +function frameworkEnvironment( + authority: Record = { + graphql_path: "/framework/graphql", + rpc_path: "/framework/rpc", + storage_path: "/framework/storage", + }, +): Record { + return { + protocol: "spock-host-environment/1", + mode: "dev", + project_generation_id: 7, + backend_generation_id: 3, + authority, + }; +} + function whoami(init: RequestInit): Response { const headers = new Headers(init?.headers); const actor = headers.get("x-spock-actor"); @@ -334,6 +350,167 @@ function onlyOutcome(messages: Decoded[]): Decoded { return outcome; } +test("prefers one strictly typed framework environment before authority work", async () => { + const data = snapshot(); + const calls: string[] = []; + await withFetch(async (input, init) => { + const url = String(input); + calls.push(url); + if (url === "/~project/environment") { + assert.equal(init.method, "GET"); + assert.equal(new Headers(init.headers).get("accept"), "application/json"); + return new Response(JSON.stringify(frameworkEnvironment())); + } + if (url === "/framework/graphql") return graphql(data); + if (url === "/~whoami") return whoami(init); + if (url === "/framework/storage/object/sign/media-theo") { + return new Response( + JSON.stringify({ + url: "/framework/storage/object/media-theo?exp=9999999999&sig=test", + }), + ); + } + if (url === "/framework/rpc/unlike_post") { + return new Response(JSON.stringify({ user: MIRA, post: "post-lena-video" })); + } + throw new Error(`unexpected fetch ${url}`); + }, async () => { + const remote = driver(); + await remote.assembleBoot(); + bootMessages(remote); + + assert.equal( + await remote.resolveAsset("media-theo"), + "/framework/storage/object/media-theo?exp=9999999999&sig=test", + ); + remote.deliver( + command("feed", "unlike-post", { post: "post-lena-video" }), + ); + onlyOutcome(await settle(remote)); + remote.dispose(); + }); + + assert.equal(calls[0], "/~project/environment"); + assert.equal( + calls.filter((url) => url === "/~project/environment").length, + 1, + ); + assert.ok(calls.includes("/framework/graphql")); + assert.ok(calls.includes("/framework/rpc/unlike_post")); + assert.ok(calls.includes("/framework/storage/object/sign/media-theo")); + assert.equal(calls.some((url) => url.startsWith("http://spock.test")), false); +}); + +test("falls back for unavailable or invalid framework metadata", async () => { + const cases: Array<{ name: string; response: () => Response }> = [ + { + name: "unavailable", + response: () => new Response(null, { status: 404 }), + }, + { + name: "wrong protocol", + response: () => + new Response( + JSON.stringify({ + ...frameworkEnvironment(), + protocol: "spock-host-environment/0", + }), + ), + }, + { + name: "extra top-level provider data", + response: () => + new Response( + JSON.stringify({ ...frameworkEnvironment(), provider: { actor: THEO } }), + ), + }, + { + name: "absolute authority URL", + response: () => + new Response( + JSON.stringify( + frameworkEnvironment({ + graphql_path: "https://other.test/graphql", + rpc_path: "/framework/rpc", + storage_path: "/framework/storage", + }), + ), + ), + }, + { + name: "invalid generation", + response: () => + new Response( + JSON.stringify({ + ...frameworkEnvironment(), + backend_generation_id: 0, + }), + ), + }, + ]; + + for (const candidate of cases) { + const calls: string[] = []; + const data = snapshot(); + await withFetch(async (input, init) => { + const url = String(input); + calls.push(url); + if (url === "/~project/environment") return candidate.response(); + if (url === "http://spock.test/graphql/v1") return graphql(data); + if (url === "http://spock.test/~whoami") return whoami(init); + throw new Error(`unexpected fetch ${url}`); + }, async () => { + const remote = driver(); + await remote.assembleBoot(); + remote.dispose(); + }); + assert.deepEqual( + calls.slice(0, 2), + ["/~project/environment", "http://spock.test/graphql/v1"], + candidate.name, + ); + } +}); + +test("disposing during environment discovery aborts without authority fallback", async () => { + const controller = new AbortController(); + let markStarted!: () => void; + const started = new Promise((resolve) => { + markStarted = resolve; + }); + let authorityCalls = 0; + + await withFetch(async (input, init) => { + if (String(input) !== "/~project/environment") { + authorityCalls += 1; + throw new Error(`unexpected authority fetch ${String(input)}`); + } + markStarted(); + return await new Promise((_resolve, reject) => { + init.signal?.addEventListener( + "abort", + () => reject(new DOMException("disposed", "AbortError")), + { once: true }, + ); + }); + }, async () => { + const remote = driver("mira.santos", { + signal: controller.signal, + pickFile: async () => null, + }); + const boot = remote.assembleBoot(); + await started; + controller.abort(); + await assert.rejects( + boot, + (error: unknown) => + error instanceof DOMException && error.name === "AbortError", + ); + }); + + assert.equal(authorityCalls, 0); +}); + test("normalizes a configured username and exposes authority-owned actors", async () => { const data = snapshot(); await withFetch(async (input, init) => { diff --git a/examples/instagram-uhura/providers/spock.ts b/examples/instagram-uhura/providers/spock.ts index a0aa989..c137f49 100644 --- a/examples/instagram-uhura/providers/spock.ts +++ b/examples/instagram-uhura/providers/spock.ts @@ -164,6 +164,103 @@ const AUTHORITY_COMMANDS = new Set([ ]); const AUTHORITY_REQUEST_TIMEOUT_MS = 15_000; +const HOST_ENVIRONMENT_PATH = "/~project/environment"; +const HOST_ENVIRONMENT_PROTOCOL = "spock-host-environment/1"; + +interface AuthorityEndpoints { + graphqlUrl: string; + rpcUrl: string; + storageUrl: string; + whoamiUrl: string; +} + +function exactObject( + value: unknown, + keys: readonly string[], +): value is Record { + if (typeof value !== "object" || value === null || Array.isArray(value)) { + return false; + } + const actual = Object.keys(value); + return ( + actual.length === keys.length && keys.every((key) => Object.hasOwn(value, key)) + ); +} + +function authorityPath(value: unknown): string | null { + if ( + typeof value !== "string" || + !value.startsWith("/") || + value.startsWith("//") || + value === "/" || + value.endsWith("/") + ) { + return null; + } + try { + const parsed = new URL(value, "https://spock.invalid/"); + if ( + parsed.origin !== "https://spock.invalid" || + parsed.pathname !== value || + parsed.search.length > 0 || + parsed.hash.length > 0 + ) { + return null; + } + } catch { + return null; + } + return value; +} + +function integratedAuthority(value: unknown): AuthorityEndpoints | null { + if ( + !exactObject(value, [ + "protocol", + "mode", + "project_generation_id", + "backend_generation_id", + "authority", + ]) || + value.protocol !== HOST_ENVIRONMENT_PROTOCOL || + (value.mode !== "start" && value.mode !== "dev") || + !Number.isSafeInteger(value.project_generation_id) || + (value.project_generation_id as number) < 1 || + !Number.isSafeInteger(value.backend_generation_id) || + (value.backend_generation_id as number) < 1 || + !exactObject(value.authority, [ + "graphql_path", + "rpc_path", + "storage_path", + ]) + ) { + return null; + } + + const graphqlUrl = authorityPath(value.authority.graphql_path); + const rpcUrl = authorityPath(value.authority.rpc_path); + const storageUrl = authorityPath(value.authority.storage_path); + if (graphqlUrl === null || rpcUrl === null || storageUrl === null) return null; + + return { + graphqlUrl, + rpcUrl, + storageUrl, + whoamiUrl: "/~whoami", + }; +} + +function resolveFromEndpoint(reference: string, endpoint: string): string { + try { + return new URL(reference, endpoint).toString(); + } catch { + const sameOrigin = "https://spock.invalid"; + const resolved = new URL(reference, `${sameOrigin}${endpoint}`); + return resolved.origin === sameOrigin + ? `${resolved.pathname}${resolved.search}${resolved.hash}` + : resolved.toString(); + } +} function enqueueAuthorityWork(work: () => Promise): Promise { const queued = authorityTail.then(work, work); @@ -486,7 +583,12 @@ export function createDriver( if (storageUrl.length === 0) { throw new Error("Spock provider needs `storage_url`"); } - const whoamiUrl = new URL("/~whoami", graphqlUrl).toString(); + const configuredAuthority: AuthorityEndpoints = { + graphqlUrl, + rpcUrl, + storageUrl, + whoamiUrl: new URL("/~whoami", graphqlUrl).toString(), + }; const outbox: string[] = []; let inflight = 0; @@ -497,6 +599,7 @@ export function createDriver( const uploadedFileNames = new Map(); const cancellable = new AbortController(); let disposed = host.signal.aborted; + let authorityResolution: Promise | undefined; function dispose(): void { if (disposed) return; @@ -517,6 +620,28 @@ export function createDriver( throw new DOMException("Uhura Play provider was disposed", "AbortError"); } + function authorityEndpoints(): Promise { + authorityResolution ??= (async () => { + try { + const response = await fetch(HOST_ENVIRONMENT_PATH, { + method: "GET", + headers: { accept: "application/json" }, + signal: cancellable.signal, + }); + assertLive(); + if (!response.ok) return configuredAuthority; + const body = await response.text(); + assertLive(); + const environment = integratedAuthority(JSON.parse(body)); + return environment ?? configuredAuthority; + } catch (error) { + if (disposed || cancellable.signal.aborted) throw error; + return configuredAuthority; + } + })(); + return authorityResolution; + } + const revisions = new Map(); /** @@ -585,6 +710,7 @@ export function createDriver( * @returns {Promise} */ async function fetchSnapshot(): Promise { + const { graphqlUrl } = await authorityEndpoints(); const response = await fetch(graphqlUrl, { method: "POST", headers: { "content-type": "application/json" }, @@ -625,6 +751,7 @@ export function createDriver( */ async function verifyViewer(): Promise { const expected = viewerId(); + const { whoamiUrl } = await authorityEndpoints(); const response = await fetch(whoamiUrl, { headers: { "x-spock-actor": expected }, signal: cancellable.signal, @@ -650,6 +777,7 @@ export function createDriver( fn: string, payload: Record, ): Promise { + const { rpcUrl } = await authorityEndpoints(); const timeout = new AbortController(); const timeoutId = setTimeout( () => timeout.abort(), @@ -703,6 +831,7 @@ export function createDriver( if (current) return current; const signing = (async () => { + const { storageUrl } = await authorityEndpoints(); const response = await fetch( `${storageUrl}/object/sign/${encodeURIComponent(asset)}`, { @@ -721,8 +850,10 @@ export function createDriver( if (typeof envelope.url !== "string") { throw new Error("Spock storage signing returned no URL"); } - const absolute = new URL(envelope.url, storageUrl).toString(); - const expiry = Number(new URL(absolute).searchParams.get("exp")); + const absolute = resolveFromEndpoint(envelope.url, storageUrl); + const expiry = Number( + new URL(absolute, "https://spock.invalid/").searchParams.get("exp"), + ); const refreshAt = Number.isFinite(expiry) ? Math.max(Date.now(), expiry * 1000 - 30_000) : Date.now(); @@ -750,6 +881,7 @@ export function createDriver( throw new Error("Choose an image file (JPEG, PNG, or WebP)"); } + const { storageUrl } = await authorityEndpoints(); const mintResponse = await fetch(`${storageUrl}/object/upload/sign`, { method: "POST", headers: { "x-spock-actor": viewerId() }, @@ -766,7 +898,7 @@ export function createDriver( throw new Error("Spock storage upload signing returned no object id or URL"); } - const putUrl = new URL(mint.url, storageUrl).toString(); + const putUrl = resolveFromEndpoint(mint.url, storageUrl); const putResponse = await fetch(putUrl, { method: "PUT", headers: { "content-type": contentType }, From 60c00e0c7ebd3ad8ff03e70e1aa34ae588c1a67c Mon Sep 17 00:00:00 2001 From: Universe Date: Wed, 15 Jul 2026 06:56:25 +0900 Subject: [PATCH 5/8] fix: harden reusable host lifecycle --- crates/uhura-host/src/lib.rs | 112 ++++++++++++++++++++++++++++------- 1 file changed, 90 insertions(+), 22 deletions(-) diff --git a/crates/uhura-host/src/lib.rs b/crates/uhura-host/src/lib.rs index 3ed2356..084391e 100644 --- a/crates/uhura-host/src/lib.rs +++ b/crates/uhura-host/src/lib.rs @@ -9,8 +9,10 @@ use std::io::{Cursor, Read}; #[cfg(test)] use std::path::PathBuf; use std::path::{Component, Path}; -use std::sync::mpsc::{Receiver, RecvTimeoutError, SyncSender, TrySendError, sync_channel}; -use std::sync::{Arc, Mutex, RwLock}; +use std::sync::mpsc::{ + Receiver, RecvTimeoutError, SyncSender, TryRecvError, TrySendError, sync_channel, +}; +use std::sync::{Arc, Mutex, RwLock, Weak}; use std::time::Duration; use uhura_base::{Severity, sha256_hex, to_canonical_json, to_envelope}; @@ -466,8 +468,8 @@ impl Host { Ok(( Self { state: RwLock::new(DevState { play, editor }), - play_clients: Arc::new(Mutex::new(Vec::new())), - editor_clients: Arc::new(Mutex::new(Vec::new())), + play_clients: Arc::new(Mutex::new(ClientRegistry::default())), + editor_clients: Arc::new(Mutex::new(ClientRegistry::default())), web: Arc::new(web), }, report, @@ -522,7 +524,13 @@ fn publication_report(play: &PlayState, summary: CandidateSummary) -> Publicatio // subscriber therefore has a one-frame queue. If it is stalled, the already // queued frame remains a sufficient invalidation and later publications are // intentionally coalesced instead of growing memory without bound. -type Clients = Arc>>>; +#[derive(Default)] +struct ClientRegistry { + next_id: u64, + clients: BTreeMap>, +} + +type Clients = Arc>; fn play_sse_payload(play: &PlayState) -> String { let mut event = serde_json::json!({ @@ -554,16 +562,20 @@ fn sse_frame(value: &serde_json::Value) -> String { fn broadcast(clients: &Clients, payload: &str) { let mut clients = clients.lock().expect("clients lock"); - clients.retain(|sender| match sender.try_send(payload.to_string()) { - Ok(()) | Err(TrySendError::Full(_)) => true, - Err(TrySendError::Disconnected(_)) => false, - }); + clients + .clients + .retain(|_, sender| match sender.try_send(payload.to_string()) { + Ok(()) | Err(TrySendError::Full(_)) => true, + Err(TrySendError::Disconnected(_)) => false, + }); } pub struct EventStream { receiver: Receiver, buffer: Vec, offset: usize, + subscription_id: u64, + clients: Weak>, } /// One bounded wait on a host-session event stream. @@ -575,6 +587,15 @@ pub enum EventStreamPoll { } impl EventStream { + /// Poll once without occupying an executor thread. + pub fn try_next_frame(&self) -> EventStreamPoll { + match self.receiver.try_recv() { + Ok(frame) => EventStreamPoll::Frame(frame), + Err(TryRecvError::Empty) => EventStreamPoll::Timeout, + Err(TryRecvError::Disconnected) => EventStreamPoll::Closed, + } + } + /// Wait at most `timeout` for one complete SSE frame. /// /// Async adapters can repeat this bounded wait and drop the stream when @@ -590,6 +611,18 @@ impl EventStream { } } +impl Drop for EventStream { + fn drop(&mut self) { + if let Some(clients) = self.clients.upgrade() { + clients + .lock() + .expect("clients lock") + .clients + .remove(&self.subscription_id); + } + } +} + impl Read for EventStream { fn read(&mut self, output: &mut [u8]) -> std::io::Result { if self.offset >= self.buffer.len() { @@ -610,7 +643,7 @@ impl Read for EventStream { fn subscribe(clients: &Clients, hello: impl FnOnce() -> String) -> EventStream { let (sender, receiver) = sync_channel::(1); - { + let subscription_id = { // Registration and snapshot share the broadcast lock: an update can // be coalesced with the initial invalidation but can never leave the // subscriber without an invalidation to refetch current state. @@ -618,12 +651,17 @@ fn subscribe(clients: &Clients, hello: impl FnOnce() -> String) -> EventStream { sender .try_send(hello()) .expect("new event queue has one available slot"); - clients.push(sender); - } + let subscription_id = clients.next_id; + clients.next_id += 1; + clients.clients.insert(subscription_id, sender); + subscription_id + }; EventStream { receiver, buffer: Vec::new(), offset: 0, + subscription_id, + clients: Arc::downgrade(clients), } } @@ -636,6 +674,18 @@ pub struct WebAssets { wasm_files: Arc>, } +/// One immutable file captured in a [`WebAssets`] snapshot. +/// +/// Paths are manifest-relative and begin with `web/` or `wasm/`. Aggregate +/// hosts can compare this inventory with a package manifest without rereading +/// mutable filesystem state after the served bytes have been captured. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct WebAssetDigest { + pub path: String, + pub sha256: String, + pub size: u64, +} + #[derive(Clone, Debug)] struct WebFile { bytes: Arc>, @@ -656,6 +706,24 @@ impl WebAssets { pub fn from_frontend_directory(web_root: &Path) -> Result { load_web_assets(web_root, None) } + + /// Describe the exact immutable bytes held by this snapshot. + #[must_use] + pub fn inventory(&self) -> Vec { + let mut inventory = Vec::with_capacity(self.files.len() + self.wasm_files.len()); + inventory.extend(self.files.iter().map(|(path, file)| WebAssetDigest { + path: format!("web/{path}"), + sha256: sha256_hex(file.bytes.as_slice()), + size: file.bytes.len() as u64, + })); + inventory.extend(self.wasm_files.iter().map(|(path, file)| WebAssetDigest { + path: format!("wasm/{path}"), + sha256: sha256_hex(file.bytes.as_slice()), + size: file.bytes.len() as u64, + })); + inventory.sort_by(|left, right| left.path.cmp(&right.path)); + inventory + } } #[cfg(test)] @@ -1396,10 +1464,11 @@ mod tests { use crate::source::EditorModelArtifact; use super::{ - ApiRoute, EditorHostState, EventStream, EventStreamPoll, PlayArtifact, RequestMethod, - RouteBody, RouteRequest, WebAssets, api_route, app_document, application_path, broadcast, - captured_play_asset, content_type, decode_play_asset_path, editor_sse_payload, - load_web_app_from, recheck_play, serve_file_map, split_request_url, subscribe, tool_root, + ApiRoute, ClientRegistry, EditorHostState, EventStream, EventStreamPoll, PlayArtifact, + RequestMethod, RouteBody, RouteRequest, WebAssets, api_route, app_document, + application_path, broadcast, captured_play_asset, content_type, decode_play_asset_path, + editor_sse_payload, load_web_app_from, recheck_play, serve_file_map, split_request_url, + subscribe, tool_root, }; fn render(revision: u64, name: &str) -> EditorRender { @@ -1659,7 +1728,7 @@ mod tests { #[test] fn stalled_event_subscriber_keeps_only_one_invalidation() { - let clients = Arc::new(Mutex::new(Vec::new())); + let clients = Arc::new(Mutex::new(ClientRegistry::default())); let stream = subscribe(&clients, || editor_sse_payload(1)); for revision in 2..=128 { @@ -1680,15 +1749,14 @@ mod tests { } #[test] - fn publication_prunes_disconnected_event_subscribers() { - let clients = Arc::new(Mutex::new(Vec::new())); + fn dropping_event_stream_unregisters_immediately() { + let clients = Arc::new(Mutex::new(ClientRegistry::default())); let stream = subscribe(&clients, || editor_sse_payload(1)); - assert_eq!(clients.lock().expect("clients lock").len(), 1); + assert_eq!(clients.lock().expect("clients lock").clients.len(), 1); drop(stream); - broadcast(&clients, &editor_sse_payload(2)); - assert!(clients.lock().expect("clients lock").is_empty()); + assert!(clients.lock().expect("clients lock").clients.is_empty()); } #[test] From 8f20987d1f19b927b3d067872885c9adaed83b6e Mon Sep 17 00:00:00 2001 From: Universe Date: Wed, 15 Jul 2026 06:56:30 +0900 Subject: [PATCH 6/8] fix: harden framework environment discovery --- examples/instagram-uhura/README.md | 23 +++++- .../instagram-uhura/providers/spock.test.ts | 71 ++++++++++++++++++- examples/instagram-uhura/providers/spock.ts | 43 +++++++++-- 3 files changed, 126 insertions(+), 11 deletions(-) diff --git a/examples/instagram-uhura/README.md b/examples/instagram-uhura/README.md index 51f86f7..9b2adfc 100644 --- a/examples/instagram-uhura/README.md +++ b/examples/instagram-uhura/README.md @@ -26,7 +26,23 @@ Core. Browser `File` values stay between the play shell and the provider; Uhura Core sees only the resulting storage-object id plus serializable display metadata such as the filename—never the file object or its bytes. -## Run the Editor with live Play +When Play is served by the combined framework host, the provider first reads +the strictly versioned `spock-host-environment/1` document from +`/~project/environment`. Valid authority paths are same-origin capabilities +and win over `uhura.toml`; the absolute URLs in `uhura.toml` are a standalone +fallback only when discovery is unavailable, invalid, or does not answer +within two seconds. A valid environment may advertise `graphql_path: null`. +That means GraphQL is deliberately absent: storage and RPC paths remain the +integrated capabilities, and the first snapshot query reports the missing +GraphQL capability instead of silently contacting a configured fallback host. + +## Run the current split checkout (transition path) + +Canonical framework projects use `spock dev` or `spock start` to own one +project, origin, port, and lifecycle. This repository's Instagram dogfood still +keeps its Spock authority and Uhura client in separate example roots, so the +two-process composition runner remains the in-place transition and comparison +oracle. From the Spock repository root, use the general Spock–Uhura composition runner with this example's two inputs. It starts the authority, waits for it, launches @@ -43,7 +59,8 @@ panel to enter the live prototype at `/play`. The runner builds the frontend, provider, and Wasm artifacts, then serves them without a Node process at runtime. -For low-level development, the equivalent two-terminal commands are: +For low-level development of this split example, the equivalent standalone +two-terminal commands are: From the Spock repository root, start the authority and the Uhura Editor server in separate terminals: @@ -63,7 +80,7 @@ Open and click **Play**. The Play toolbar can restart the UI session, switch between the 390 × 844 Mobile and 1280 × 800 Desktop frames, and select any seeded Spock actor. The app runs exclusively against the configured Spock provider; live play defaults to Mira and to the endpoints in -`uhura.toml`. +`uhura.toml` only when no valid integrated host environment was discovered. The Mobile/Desktop frame is Play-chrome preference state persisted in browser local storage. Actor selection is tab-local session-storage state. Play never reads or rewrites the application's query parameters for any diff --git a/examples/instagram-uhura/providers/spock.test.ts b/examples/instagram-uhura/providers/spock.test.ts index de63348..c5a4f6b 100644 --- a/examples/instagram-uhura/providers/spock.test.ts +++ b/examples/instagram-uhura/providers/spock.test.ts @@ -1,6 +1,6 @@ import assert from "node:assert/strict"; -import { test } from "vitest"; +import { test, vi } from "vitest"; import { createDriver, @@ -472,6 +472,75 @@ test("falls back for unavailable or invalid framework metadata", async () => { } }); +test("bounds framework discovery before using standalone fallback endpoints", async () => { + const calls: string[] = []; + const data = snapshot(); + vi.useFakeTimers(); + try { + await withFetch(async (input, init) => { + const url = String(input); + calls.push(url); + if (url === "/~project/environment") { + return await new Promise((_resolve, reject) => { + const signal = init.signal; + const abort = (): void => + reject( + new DOMException("environment discovery timed out", "AbortError"), + ); + if (signal?.aborted) abort(); + else signal?.addEventListener("abort", abort, { once: true }); + }); + } + if (url === "http://spock.test/graphql/v1") return graphql(data); + if (url === "http://spock.test/~whoami") return whoami(init); + throw new Error(`unexpected fetch ${url}`); + }, async () => { + const remote = driver(); + const boot = remote.assembleBoot(); + await vi.advanceTimersByTimeAsync(2_001); + await boot; + remote.dispose(); + }); + } finally { + vi.useRealTimers(); + } + + assert.deepEqual(calls.slice(0, 2), [ + "/~project/environment", + "http://spock.test/graphql/v1", + ]); +}); + +test("treats nullable integrated GraphQL as capability absence, not fallback", async () => { + const calls: string[] = []; + await withFetch(async (input) => { + const url = String(input); + calls.push(url); + if (url === "/~project/environment") { + return new Response( + JSON.stringify( + frameworkEnvironment({ + graphql_path: null, + rpc_path: "/framework/rpc", + storage_path: "/framework/storage", + }), + ), + ); + } + throw new Error(`unexpected fetch ${url}`); + }, async () => { + const remote = driver(); + await assert.rejects( + remote.assembleBoot(), + /integrated Spock host does not advertise a GraphQL capability/, + ); + remote.dispose(); + }); + + assert.deepEqual(calls, ["/~project/environment"]); + assert.equal(calls.some((url) => url.startsWith("http://spock.test")), false); +}); + test("disposing during environment discovery aborts without authority fallback", async () => { const controller = new AbortController(); let markStarted!: () => void; diff --git a/examples/instagram-uhura/providers/spock.ts b/examples/instagram-uhura/providers/spock.ts index c137f49..09bf02f 100644 --- a/examples/instagram-uhura/providers/spock.ts +++ b/examples/instagram-uhura/providers/spock.ts @@ -105,11 +105,11 @@ const COMMAND_REFUSALS: Readonly> = { }; export interface SpockDriverConfig { - /** Full Spock `/graphql/v1` endpoint. */ + /** Standalone fallback for the full Spock `/graphql/v1` endpoint. */ graphql_url: string; - /** Spock `/rest/v1/rpc` prefix. */ + /** Standalone fallback for the Spock `/rest/v1/rpc` prefix. */ rpc_url: string; - /** Spock `/storage/v1` prefix. */ + /** Standalone fallback for the Spock `/storage/v1` prefix. */ storage_url: string; /** Seeded user UUID or unique username. */ actor: string; @@ -164,11 +164,12 @@ const AUTHORITY_COMMANDS = new Set([ ]); const AUTHORITY_REQUEST_TIMEOUT_MS = 15_000; +const HOST_ENVIRONMENT_TIMEOUT_MS = 2_000; const HOST_ENVIRONMENT_PATH = "/~project/environment"; const HOST_ENVIRONMENT_PROTOCOL = "spock-host-environment/1"; interface AuthorityEndpoints { - graphqlUrl: string; + graphqlUrl: string | null; rpcUrl: string; storageUrl: string; whoamiUrl: string; @@ -237,10 +238,19 @@ function integratedAuthority(value: unknown): AuthorityEndpoints | null { return null; } - const graphqlUrl = authorityPath(value.authority.graphql_path); + const graphqlPath = value.authority.graphql_path; + // `null` is an explicit capability absence in the integrated environment, + // not invalid metadata and never a reason to contact the standalone host. + const graphqlUrl = graphqlPath === null ? null : authorityPath(graphqlPath); const rpcUrl = authorityPath(value.authority.rpc_path); const storageUrl = authorityPath(value.authority.storage_path); - if (graphqlUrl === null || rpcUrl === null || storageUrl === null) return null; + if ( + (graphqlPath !== null && graphqlUrl === null) || + rpcUrl === null || + storageUrl === null + ) { + return null; + } return { graphqlUrl, @@ -622,11 +632,22 @@ export function createDriver( function authorityEndpoints(): Promise { authorityResolution ??= (async () => { + // Discovery is opportunistic for standalone Uhura sessions. Bound it so + // a same-origin route that accepts but never answers cannot stall boot. + const discovery = new AbortController(); + const abortDiscovery = (): void => discovery.abort(); + if (cancellable.signal.aborted) abortDiscovery(); + else { + cancellable.signal.addEventListener("abort", abortDiscovery, { + once: true, + }); + } + const timeout = setTimeout(abortDiscovery, HOST_ENVIRONMENT_TIMEOUT_MS); try { const response = await fetch(HOST_ENVIRONMENT_PATH, { method: "GET", headers: { accept: "application/json" }, - signal: cancellable.signal, + signal: discovery.signal, }); assertLive(); if (!response.ok) return configuredAuthority; @@ -637,6 +658,9 @@ export function createDriver( } catch (error) { if (disposed || cancellable.signal.aborted) throw error; return configuredAuthority; + } finally { + clearTimeout(timeout); + cancellable.signal.removeEventListener("abort", abortDiscovery); } })(); return authorityResolution; @@ -711,6 +735,11 @@ export function createDriver( */ async function fetchSnapshot(): Promise { const { graphqlUrl } = await authorityEndpoints(); + if (graphqlUrl === null) { + throw new Error( + "integrated Spock host does not advertise a GraphQL capability", + ); + } const response = await fetch(graphqlUrl, { method: "POST", headers: { "content-type": "application/json" }, From ade081c634531adc69dd59de43c1429f95874852 Mon Sep 17 00:00:00 2001 From: Universe Date: Wed, 15 Jul 2026 07:53:39 +0900 Subject: [PATCH 7/8] fix: use stable file identity across platforms --- Cargo.lock | 34 ++++++++++++++++++++++ Cargo.toml | 1 + crates/uhura-host/Cargo.toml | 1 + crates/uhura-host/src/source.rs | 50 ++++++++------------------------- 4 files changed, 48 insertions(+), 38 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 5d83084..f2c22ba 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -262,6 +262,15 @@ version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + [[package]] name = "semver" version = "1.0.28" @@ -499,6 +508,7 @@ dependencies = [ name = "uhura-host" version = "0.0.0" dependencies = [ + "same-file", "serde_json", "toml", "uhura-base", @@ -614,6 +624,30 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + [[package]] name = "winnow" version = "0.7.15" diff --git a/Cargo.toml b/Cargo.toml index 661a837..2ab8fb1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -22,6 +22,7 @@ toml = "0.8" wasm-bindgen = "0.2" cargo_metadata = "0.19" image = { version = "0.25", default-features = false, features = ["jpeg", "webp"] } +same-file = "1" # Internal uhura-base = { path = "crates/uhura-base" } diff --git a/crates/uhura-host/Cargo.toml b/crates/uhura-host/Cargo.toml index 7bb016a..65e548e 100644 --- a/crates/uhura-host/Cargo.toml +++ b/crates/uhura-host/Cargo.toml @@ -17,3 +17,4 @@ uhura-fixture = { workspace = true } uhura-editor-model = { workspace = true } serde_json = { workspace = true } toml = { workspace = true } +same-file = { workspace = true } diff --git a/crates/uhura-host/src/source.rs b/crates/uhura-host/src/source.rs index 0ba28eb..44e10a3 100644 --- a/crates/uhura-host/src/source.rs +++ b/crates/uhura-host/src/source.rs @@ -1096,49 +1096,23 @@ fn detect_case_insensitive_filesystem(root: &Path) -> bool { if alias_is_distinct_entry { return false; } - let actual = match std::fs::metadata(&actual) { - Ok(metadata) => metadata, + match std::fs::metadata(&actual) { + Ok(_) => {} Err(_) => continue, - }; - let alias = match std::fs::metadata(&alias) { - Ok(metadata) => metadata, + } + match std::fs::metadata(&alias) { + Ok(_) => {} Err(error) if error.kind() == io::ErrorKind::NotFound => return false, Err(_) => continue, - }; - return same_file_identity(&actual, &alias); - } - false -} - -#[cfg(unix)] -fn same_file_identity(left: &std::fs::Metadata, right: &std::fs::Metadata) -> bool { - use std::os::unix::fs::MetadataExt; - - left.dev() == right.dev() && left.ino() == right.ino() -} - -#[cfg(windows)] -fn same_file_identity(left: &std::fs::Metadata, right: &std::fs::Metadata) -> bool { - use std::os::windows::fs::MetadataExt; - - match ( - left.volume_serial_number(), - left.file_index(), - right.volume_serial_number(), - right.file_index(), - ) { - (Some(left_volume), Some(left_index), Some(right_volume), Some(right_index)) => { - left_volume == right_volume && left_index == right_index } - _ => false, + // Keep both identities live while comparing them. This is portable + // to stable Windows, where std's by-handle metadata IDs are unstable. + match same_file::is_same_file(&actual, &alias) { + Ok(same_file) => return same_file, + Err(_) => continue, + } } -} - -#[cfg(not(any(unix, windows)))] -fn same_file_identity(left: &std::fs::Metadata, right: &std::fs::Metadata) -> bool { - left.len() == right.len() - && left.modified().ok() == right.modified().ok() - && left.created().ok() == right.created().ok() + false } fn toggle_ascii_case(name: &std::ffi::OsStr) -> Option { From 77ab5d93eb22963d3afdd42eb511ee3dccb53cca Mon Sep 17 00:00:00 2001 From: Universe Date: Wed, 15 Jul 2026 09:19:02 +0900 Subject: [PATCH 8/8] fix: bound host transport resources --- crates/uhura-cli/src/cmd/dev.rs | 299 ++++++++++++- crates/uhura-host/src/lib.rs | 745 +++++++++++++++++++++++++++----- 2 files changed, 937 insertions(+), 107 deletions(-) diff --git a/crates/uhura-cli/src/cmd/dev.rs b/crates/uhura-cli/src/cmd/dev.rs index 45ab3b7..5b9d301 100644 --- a/crates/uhura-cli/src/cmd/dev.rs +++ b/crates/uhura-cli/src/cmd/dev.rs @@ -2,7 +2,8 @@ use std::path::{Path, PathBuf}; use std::process::ExitCode; -use std::sync::Arc; +use std::sync::atomic::{AtomicUsize, Ordering}; +use std::sync::{Arc, Mutex, mpsc}; use std::time::Duration; use uhura_host::{ @@ -14,6 +15,201 @@ use crate::CommonArgs; pub use uhura_host::boot_envelope; +const REQUEST_WORKERS: usize = 8; +const REQUEST_QUEUE_CAPACITY: usize = 64; +const SSE_SESSION_LIMIT: usize = 4; + +struct BoundedExecutor { + sender: mpsc::SyncSender, +} + +impl BoundedExecutor +where + T: Send + 'static, +{ + fn new( + thread_name: &str, + worker_count: usize, + queue_capacity: usize, + handler: F, + ) -> std::io::Result + where + F: Fn(T) + Send + Sync + 'static, + { + assert!(worker_count > 0, "a bounded executor needs a worker"); + let (sender, receiver) = mpsc::sync_channel::(queue_capacity); + let receiver = Arc::new(Mutex::new(receiver)); + let handler = Arc::new(handler); + + for worker in 0..worker_count { + let receiver = Arc::clone(&receiver); + let handler = Arc::clone(&handler); + let worker_name = format!("{thread_name}-{worker}"); + drop( + std::thread::Builder::new() + .name(worker_name) + .spawn(move || { + loop { + let task = receiver + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .recv(); + match task { + Ok(task) => handler(task), + Err(_) => break, + } + } + })?, + ); + } + + Ok(Self { sender }) + } + + fn try_submit(&self, task: T) -> Result<(), mpsc::TrySendError> { + self.sender.try_send(task) + } +} + +struct AdmissionLimit { + limit: usize, + admitted: AtomicUsize, +} + +impl AdmissionLimit { + fn new(limit: usize) -> Arc { + assert!(limit > 0, "an admission limit must allow one session"); + Arc::new(Self { + limit, + admitted: AtomicUsize::new(0), + }) + } + + fn try_acquire(self: &Arc) -> Option { + self.admitted + .fetch_update(Ordering::AcqRel, Ordering::Acquire, |admitted| { + (admitted < self.limit).then_some(admitted + 1) + }) + .ok()?; + Some(AdmissionPermit { + limit: Arc::clone(self), + }) + } +} + +struct AdmissionPermit { + limit: Arc, +} + +impl Drop for AdmissionPermit { + fn drop(&mut self) { + let previous = self.limit.admitted.fetch_sub(1, Ordering::Release); + debug_assert!(previous > 0, "admission permits cannot underflow"); + } +} + +struct RequestTask { + request: tiny_http::Request, + host: Arc, + _sse_permit: Option, +} + +struct RequestDispatcher { + requests: BoundedExecutor, + sse: BoundedExecutor, + sse_limit: Arc, +} + +impl RequestDispatcher { + fn new() -> std::io::Result { + // Event streams can live for the browser session, so they receive a + // disjoint worker lane and admission limit. They can never consume the + // workers that keep ordinary Editor and Play requests responsive. + Ok(Self { + requests: BoundedExecutor::new( + "uhura-request", + REQUEST_WORKERS, + REQUEST_QUEUE_CAPACITY, + execute_request, + )?, + sse: BoundedExecutor::new( + "uhura-sse", + SSE_SESSION_LIMIT, + SSE_SESSION_LIMIT, + execute_request, + )?, + sse_limit: AdmissionLimit::new(SSE_SESSION_LIMIT), + }) + } + + fn dispatch(&self, request: tiny_http::Request, host: Arc) { + if is_sse_request(&request) { + let Some(permit) = self.sse_limit.try_acquire() else { + reject_overloaded(request, "too many live Uhura event streams"); + return; + }; + self.submit( + &self.sse, + RequestTask { + request, + host, + _sse_permit: Some(permit), + }, + "Uhura event-stream workers are unavailable", + ); + } else { + self.submit( + &self.requests, + RequestTask { + request, + host, + _sse_permit: None, + }, + "Uhura request workers are saturated", + ); + } + } + + fn submit( + &self, + executor: &BoundedExecutor, + task: RequestTask, + message: &'static str, + ) { + if let Err(error) = executor.try_submit(task) { + let task = match error { + mpsc::TrySendError::Full(task) | mpsc::TrySendError::Disconnected(task) => task, + }; + reject_overloaded(task.request, message); + } + } +} + +fn execute_request(task: RequestTask) { + respond(task.request, &task.host); +} + +fn is_sse_request(request: &tiny_http::Request) -> bool { + is_sse_route(request.method(), request.url()) +} + +fn is_sse_route(method: &tiny_http::Method, url: &str) -> bool { + method == &tiny_http::Method::Get + && matches!( + url.split_once('?').map_or(url, |(path, _)| path), + "/api/editor/events" | "/api/play/events" + ) +} + +fn reject_overloaded(request: tiny_http::Request, message: &str) { + let retry_after = tiny_http::Header::from_bytes("Retry-After", "1") + .expect("static overload response header is valid"); + let response = tiny_http::Response::from_string(format!("{message}; retry shortly\n")) + .with_status_code(tiny_http::StatusCode(503)) + .with_header(retry_after); + let _ = request.respond(response); +} + pub fn run(common: &CommonArgs, port: u16) -> ExitCode { run_host(common, port, PrimarySurface::Play) } @@ -74,6 +270,13 @@ fn run_host(common: &CommonArgs, port: u16, primary: PrimarySurface) -> ExitCode return ExitCode::from(2); } }; + let dispatcher = match RequestDispatcher::new() { + Ok(dispatcher) => dispatcher, + Err(error) => { + eprintln!("{command}: could not start bounded request workers: {error}"); + return ExitCode::from(2); + } + }; println!("{command}: http://127.0.0.1:{port}{}", primary.route()); println!( "{command}: {} http://127.0.0.1:{port}{}", @@ -98,8 +301,7 @@ fn run_host(common: &CommonArgs, port: u16, primary: PrimarySurface) -> ExitCode let server = Arc::new(server); for request in server.incoming_requests() { - let host = Arc::clone(&host); - std::thread::spawn(move || respond(request, &host)); + dispatcher.dispatch(request, Arc::clone(&host)); } ExitCode::SUCCESS } @@ -299,6 +501,95 @@ fn respond(request: tiny_http::Request, host: &Host) { response.body, length, None, - ); + ) + // Byte responses already carry their exact representation length from + // `uhura-host`. Keep that metadata on the wire even for larger Editor + // snapshots; unknown-length SSE bodies remain chunked automatically. + .with_chunked_threshold(usize::MAX); let _ = request.respond(response); } + +#[cfg(test)] +mod tests { + use std::sync::mpsc; + + use super::*; + + struct TestTask { + id: usize, + release: mpsc::Receiver<()>, + } + + #[test] + fn bounded_executor_rejects_saturation_and_runs_queued_work_after_release() { + let (started_tx, started_rx) = mpsc::channel(); + let (finished_tx, finished_rx) = mpsc::channel(); + let executor = BoundedExecutor::new("uhura-test-worker", 1, 1, move |task: TestTask| { + started_tx.send(task.id).unwrap(); + task.release.recv().unwrap(); + finished_tx.send(task.id).unwrap(); + }) + .unwrap(); + + let (release_first, first) = mpsc::channel(); + executor + .try_submit(TestTask { + id: 1, + release: first, + }) + .unwrap(); + assert_eq!(started_rx.recv_timeout(Duration::from_secs(1)), Ok(1)); + + let (release_second, second) = mpsc::channel(); + executor + .try_submit(TestTask { + id: 2, + release: second, + }) + .unwrap(); + let (_release_rejected, rejected) = mpsc::channel(); + assert!(matches!( + executor.try_submit(TestTask { + id: 3, + release: rejected, + }), + Err(mpsc::TrySendError::Full(TestTask { id: 3, .. })) + )); + + release_first.send(()).unwrap(); + assert_eq!(finished_rx.recv_timeout(Duration::from_secs(1)), Ok(1)); + assert_eq!(started_rx.recv_timeout(Duration::from_secs(1)), Ok(2)); + release_second.send(()).unwrap(); + assert_eq!(finished_rx.recv_timeout(Duration::from_secs(1)), Ok(2)); + } + + #[test] + fn admission_limit_caps_sessions_and_releases_permits() { + let limit = AdmissionLimit::new(2); + let first = limit.try_acquire().expect("first session"); + let second = limit.try_acquire().expect("second session"); + assert!(limit.try_acquire().is_none(), "third session exceeds cap"); + + drop(first); + let replacement = limit.try_acquire().expect("released slot is reusable"); + assert!(limit.try_acquire().is_none(), "cap remains exact"); + + drop(second); + drop(replacement); + assert_eq!(limit.admitted.load(Ordering::Acquire), 0); + } + + #[test] + fn only_get_event_endpoints_enter_the_sse_lane() { + assert!(is_sse_route(&tiny_http::Method::Get, "/api/editor/events")); + assert!(is_sse_route( + &tiny_http::Method::Get, + "/api/play/events?generation=2" + )); + assert!(!is_sse_route( + &tiny_http::Method::Head, + "/api/editor/events" + )); + assert!(!is_sse_route(&tiny_http::Method::Get, "/api/editor/state")); + } +} diff --git a/crates/uhura-host/src/lib.rs b/crates/uhura-host/src/lib.rs index 084391e..d682f90 100644 --- a/crates/uhura-host/src/lib.rs +++ b/crates/uhura-host/src/lib.rs @@ -465,11 +465,16 @@ impl Host { let mut play = PlayState::default(); apply_play(&mut play, candidate.play); let report = publication_report(&play, summary); + let event_admission = Arc::new(EventAdmission::new(MAX_EVENT_STREAMS_PER_HOST)); Ok(( Self { state: RwLock::new(DevState { play, editor }), - play_clients: Arc::new(Mutex::new(ClientRegistry::default())), - editor_clients: Arc::new(Mutex::new(ClientRegistry::default())), + play_clients: Arc::new(Mutex::new(ClientRegistry::with_admission(Arc::clone( + &event_admission, + )))), + editor_clients: Arc::new(Mutex::new(ClientRegistry::with_admission( + event_admission, + ))), web: Arc::new(web), }, report, @@ -524,10 +529,75 @@ fn publication_report(play: &PlayState, summary: CandidateSummary) -> Publicatio // subscriber therefore has a one-frame queue. If it is stalled, the already // queued frame remains a sufficient invalidation and later publications are // intentionally coalesced instead of growing memory without bound. -#[derive(Default)] +const BLOCKING_EVENT_STREAM_KEEPALIVE_INTERVAL: Duration = Duration::from_secs(1); +// `tiny_http` writes unknown-length HTTP/1.1 bodies through an 8 KiB chunk +// encoder that does not flush partial chunks. A keepalive must cross that +// boundary so every timeout reaches the socket instead of accumulating in the +// encoder while a disconnected client continues occupying its worker. +const BLOCKING_EVENT_STREAM_WRITE_BOUNDARY: usize = 8 * 1024; +// Editor and Play share this host-session budget. Keeping admission below the +// HTTP adapters means every listener implementation gets the same bound and a +// disconnected response releases capacity when its `EventStream` is dropped. +const MAX_EVENT_STREAMS_PER_HOST: usize = 4; + +struct EventAdmission { + active: Mutex, + limit: usize, +} + +impl EventAdmission { + fn new(limit: usize) -> Self { + Self { + active: Mutex::new(0), + limit, + } + } + + fn try_acquire(self: &Arc) -> Option { + let mut active = self.active.lock().expect("event admission lock"); + if *active >= self.limit { + return None; + } + *active += 1; + Some(EventStreamPermit { + admission: Arc::clone(self), + }) + } +} + +struct EventStreamPermit { + admission: Arc, +} + +impl Drop for EventStreamPermit { + fn drop(&mut self) { + let mut active = self.admission.active.lock().expect("event admission lock"); + *active = (*active) + .checked_sub(1) + .expect("event admission count underflow"); + } +} + struct ClientRegistry { next_id: u64, clients: BTreeMap>, + admission: Arc, +} + +impl ClientRegistry { + fn with_admission(admission: Arc) -> Self { + Self { + next_id: 0, + clients: BTreeMap::new(), + admission, + } + } +} + +impl Default for ClientRegistry { + fn default() -> Self { + Self::with_admission(Arc::new(EventAdmission::new(MAX_EVENT_STREAMS_PER_HOST))) + } } type Clients = Arc>; @@ -576,6 +646,8 @@ pub struct EventStream { offset: usize, subscription_id: u64, clients: Weak>, + blocking_keepalive_interval: Duration, + _admission_permit: Option, } /// One bounded wait on a host-session event stream. @@ -625,14 +697,29 @@ impl Drop for EventStream { impl Read for EventStream { fn read(&mut self, output: &mut [u8]) -> std::io::Result { + if output.is_empty() { + return Ok(0); + } if self.offset >= self.buffer.len() { - match self.receiver.recv() { + match self.receiver.recv_timeout(self.blocking_keepalive_interval) { Ok(frame) => { self.buffer = frame.into_bytes(); - self.offset = 0; } - Err(_) => return Ok(0), + Err(RecvTimeoutError::Timeout) => { + // The blocking `Read` adapter cannot otherwise observe a + // quiet client disconnect: its next operation would remain + // parked on the event channel. An SSE comment is invisible + // to EventSource while giving the HTTP writer a bounded + // opportunity to discover a closed socket. + self.buffer.clear(); + self.buffer.push(b':'); + self.buffer + .resize(BLOCKING_EVENT_STREAM_WRITE_BOUNDARY + 1, b' '); + self.buffer.extend_from_slice(b"\n\n"); + } + Err(RecvTimeoutError::Disconnected) => return Ok(0), } + self.offset = 0; } let count = (self.buffer.len() - self.offset).min(output.len()); output[..count].copy_from_slice(&self.buffer[self.offset..self.offset + count]); @@ -641,7 +728,17 @@ impl Read for EventStream { } } -fn subscribe(clients: &Clients, hello: impl FnOnce() -> String) -> EventStream { +fn subscribe(clients: &Clients, hello: impl FnOnce() -> String) -> Option { + subscribe_with_blocking_keepalive(clients, hello, BLOCKING_EVENT_STREAM_KEEPALIVE_INTERVAL) +} + +fn subscribe_with_blocking_keepalive( + clients: &Clients, + hello: impl FnOnce() -> String, + blocking_keepalive_interval: Duration, +) -> Option { + let admission = Arc::clone(&clients.lock().expect("clients lock").admission); + let admission_permit = admission.try_acquire()?; let (sender, receiver) = sync_channel::(1); let subscription_id = { // Registration and snapshot share the broadcast lock: an update can @@ -656,13 +753,15 @@ fn subscribe(clients: &Clients, hello: impl FnOnce() -> String) -> EventStream { clients.clients.insert(subscription_id, sender); subscription_id }; - EventStream { + Some(EventStream { receiver, buffer: Vec::new(), offset: 0, subscription_id, clients: Arc::downgrade(clients), - } + blocking_keepalive_interval, + _admission_permit: Some(admission_permit), + }) } // ── one web application and namespaced transport ─────────────────────────── @@ -870,86 +969,213 @@ fn index_asset_references(index: &str) -> Result, String> { let mut references = BTreeSet::new(); let mut cursor = 0; while cursor < bytes.len() { - if !bytes[cursor].is_ascii_alphabetic() { - cursor += 1; + let Some(offset) = bytes[cursor..].iter().position(|byte| *byte == b'<') else { + break; + }; + let tag_start = cursor + offset; + if bytes[tag_start..].starts_with(b"") + .map_or(bytes.len(), |offset| tag_start + 4 + offset + 3); continue; } - let name_start = cursor; + + let name_start = tag_start + 1; + let Some(first) = bytes.get(name_start) else { + break; + }; + if matches!(first, b'/' | b'!' | b'?') { + cursor = html_tag_end(bytes, name_start).map_or(bytes.len(), |tag_end| tag_end + 1); + continue; + } + if !first.is_ascii_alphabetic() { + cursor = name_start; + continue; + } + + let mut name_end = name_start; while bytes - .get(cursor) + .get(name_end) .is_some_and(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b':')) + { + name_end += 1; + } + let Some(tag_end) = html_tag_end(bytes, name_end) else { + break; + }; + collect_index_asset_attributes(index, name_end, tag_end, &mut references)?; + cursor = tag_end + 1; + + let name = &index[name_start..name_end]; + if is_html_raw_text_element(name) { + cursor = skip_html_raw_text(index, cursor, name); + } + } + Ok(references) +} + +fn html_tag_end(bytes: &[u8], start: usize) -> Option { + let mut quote = None; + for (offset, byte) in bytes[start..].iter().copied().enumerate() { + if let Some(expected) = quote { + if byte == expected { + quote = None; + } + continue; + } + match byte { + b'\'' | b'"' => quote = Some(byte), + b'>' => return Some(start + offset), + _ => {} + } + } + None +} + +fn collect_index_asset_attributes( + index: &str, + start: usize, + end: usize, + references: &mut BTreeSet, +) -> Result<(), String> { + let bytes = index.as_bytes(); + let mut cursor = start; + while cursor < end { + while bytes + .get(cursor) + .is_some_and(|byte| byte.is_ascii_whitespace() || *byte == b'/') { cursor += 1; } - let name = &index[name_start..cursor]; - if !name.eq_ignore_ascii_case("src") && !name.eq_ignore_ascii_case("href") { + if cursor >= end { + break; + } + + let name_start = cursor; + while bytes.get(cursor).is_some_and(|byte| { + !byte.is_ascii_whitespace() + && !matches!(byte, b'\0' | b'\'' | b'"' | b'/' | b'=' | b'>') + }) { + cursor += 1; + } + if cursor == name_start { + cursor += 1; continue; } + let name = &index[name_start..cursor]; + while bytes.get(cursor).is_some_and(u8::is_ascii_whitespace) { cursor += 1; } - if bytes.get(cursor) != Some(&b'=') { + if cursor >= end || bytes[cursor] != b'=' { continue; } cursor += 1; while bytes.get(cursor).is_some_and(u8::is_ascii_whitespace) { cursor += 1; } - let Some(first) = bytes.get(cursor).copied() else { + if cursor >= end { break; - }; - let (value_start, value_end) = if matches!(first, b'\'' | b'"') { + } + + let (value_start, value_end) = if matches!(bytes[cursor], b'\'' | b'"') { + let quote = bytes[cursor]; cursor += 1; - let start = cursor; - while bytes.get(cursor).is_some_and(|byte| *byte != first) { + let value_start = cursor; + while cursor < end && bytes[cursor] != quote { cursor += 1; } - let end = cursor; - if cursor < bytes.len() { + let value_end = cursor; + if cursor < end { cursor += 1; } - (start, end) + (value_start, value_end) } else { - let start = cursor; - while bytes - .get(cursor) - .is_some_and(|byte| !byte.is_ascii_whitespace() && *byte != b'>') - { + let value_start = cursor; + while cursor < end && !bytes[cursor].is_ascii_whitespace() { cursor += 1; } - (start, cursor) + (value_start, cursor) }; - let value = &index[value_start..value_end]; - let path_end = value.find(['?', '#']).unwrap_or(value.len()); - let path = &value[..path_end]; - let lowercase = path.to_ascii_lowercase(); - if !lowercase.ends_with(".js") - && !lowercase.ends_with(".mjs") - && !lowercase.ends_with(".css") - { - continue; - } - if path.starts_with("//") - || path - .find(':') - .is_some_and(|colon| path.find('/').is_none_or(|slash| colon < slash)) - { - continue; + + if name.eq_ignore_ascii_case("src") || name.eq_ignore_ascii_case("href") { + record_index_asset_reference(&index[value_start..value_end], references)?; } - let relative = path.strip_prefix('/').unwrap_or(path); - if relative.contains('\\') - || relative - .split('/') - .any(|segment| segment.is_empty() || segment == "." || segment == "..") - || Path::new(relative).is_absolute() - { - return Err(format!( - "index.html contains an unsafe local application asset reference: {value}" - )); + } + Ok(()) +} + +fn record_index_asset_reference( + value: &str, + references: &mut BTreeSet, +) -> Result<(), String> { + let path_end = value.find(['?', '#']).unwrap_or(value.len()); + let path = &value[..path_end]; + let lowercase = path.to_ascii_lowercase(); + if !lowercase.ends_with(".js") && !lowercase.ends_with(".mjs") && !lowercase.ends_with(".css") { + return Ok(()); + } + if path.starts_with("//") + || path + .find(':') + .is_some_and(|colon| path.find('/').is_none_or(|slash| colon < slash)) + { + return Ok(()); + } + let relative = path.strip_prefix('/').unwrap_or(path); + if relative.contains('\\') + || relative + .split('/') + .any(|segment| segment.is_empty() || segment == "." || segment == "..") + || Path::new(relative).is_absolute() + { + return Err(format!( + "index.html contains an unsafe local application asset reference: {value}" + )); + } + references.insert(relative.to_string()); + Ok(()) +} + +fn is_html_raw_text_element(name: &str) -> bool { + [ + "script", + "style", + "textarea", + "title", + "xmp", + "iframe", + "noembed", + "noframes", + "plaintext", + ] + .into_iter() + .any(|element| name.eq_ignore_ascii_case(element)) +} + +fn skip_html_raw_text(index: &str, mut cursor: usize, name: &str) -> usize { + let bytes = index.as_bytes(); + while cursor < bytes.len() { + let Some(offset) = bytes[cursor..].iter().position(|byte| *byte == b'<') else { + return bytes.len(); + }; + let tag_start = cursor + offset; + let close_name_start = tag_start + 2; + let close_name_end = close_name_start + name.len(); + let closes_element = bytes.get(tag_start + 1) == Some(&b'/') + && bytes + .get(close_name_start..close_name_end) + .is_some_and(|candidate| candidate.eq_ignore_ascii_case(name.as_bytes())) + && bytes + .get(close_name_end) + .is_some_and(|byte| byte.is_ascii_whitespace() || matches!(byte, b'/' | b'>')); + if closes_element { + return html_tag_end(bytes, close_name_end).map_or(bytes.len(), |tag_end| tag_end + 1); } - references.insert(relative.to_string()); + cursor = tag_start + 1; } - Ok(references) + bytes.len() } fn normalized_web_bundle_path(root: &Path, path: &Path) -> Result { @@ -1100,6 +1326,11 @@ impl Read for RouteBody { impl Host { /// Resolve one HTTP-like request without owning a listener or server loop. pub fn route(&self, request: RouteRequest<'_>) -> RouteResponse { + let method = request.method; + finalize_route_response(method, self.route_unfinalized(request)) + } + + fn route_unfinalized(&self, request: RouteRequest<'_>) -> RouteResponse { let (path, query) = split_request_url(request.url); if request.method == RequestMethod::Other { return byte_response( @@ -1117,16 +1348,18 @@ impl Host { if request.method != RequestMethod::Get { return event_method_error(path); } - let stream = subscribe(&self.play_clients, || { + let Some(stream) = subscribe(&self.play_clients, || { play_sse_payload(&self.state.read().expect("state lock").play) - }); + }) else { + return event_capacity_error(); + }; return event_response(stream); } Some(ApiRoute::EditorEvents) => { if request.method != RequestMethod::Get { return event_method_error(path); } - let stream = subscribe(&self.editor_clients, || { + let Some(stream) = subscribe(&self.editor_clients, || { let revision = self .state .read() @@ -1134,7 +1367,9 @@ impl Host { .editor .source_revision; editor_sse_payload(revision) - }); + }) else { + return event_capacity_error(); + }; return event_response(stream); } _ => {} @@ -1160,16 +1395,23 @@ impl Host { Some(ApiRoute::Unknown) => Err((404, format!("no such API endpoint: {path}"))), None => application_path(&self.web, path), }; - served_response(request.method, outcome) + served_response(outcome) } } -fn served_response(method: RequestMethod, outcome: Served) -> RouteResponse { +fn finalize_route_response(method: RequestMethod, mut response: RouteResponse) -> RouteResponse { + if method == RequestMethod::Head + && let RouteBody::Bytes(bytes) = &mut response.body + { + bytes.get_mut().clear(); + bytes.set_position(0); + } + response +} + +fn served_response(outcome: Served) -> RouteResponse { match outcome { - Ok((content_type, mut bytes, generation)) => { - if method == RequestMethod::Head { - bytes.clear(); - } + Ok((content_type, bytes, generation)) => { let mut headers = Vec::new(); if let Some(generation) = generation { headers.push(("X-Uhura-Generation".to_string(), generation.to_string())); @@ -1191,6 +1433,7 @@ fn byte_response( bytes: Vec, mut headers: Vec<(String, String)>, ) -> RouteResponse { + headers.push(("Content-Length".to_string(), bytes.len().to_string())); headers.push(("Content-Type".to_string(), content_type.to_string())); headers.push(("Cache-Control".to_string(), "no-store".to_string())); RouteResponse { @@ -1223,6 +1466,15 @@ fn event_method_error(path: &str) -> RouteResponse { ) } +fn event_capacity_error() -> RouteResponse { + byte_response( + 503, + "text/plain; charset=utf-8", + b"too many active Uhura event streams; retry shortly".to_vec(), + vec![("Retry-After".to_string(), "1".to_string())], + ) +} + fn split_request_url(url: &str) -> (&str, Option<&str>) { url.split_once('?') .map_or((url, None), |(path, query)| (path, Some(query))) @@ -1300,17 +1552,21 @@ fn app_document(web: &WebAssets) -> Served { } fn application_path(web: &WebAssets, path: &str) -> Served { - let Some(relative) = path.strip_prefix('/') else { + let Some(encoded_relative) = path.strip_prefix('/') else { return app_document(web); }; + if encoded_relative.is_empty() { + return app_document(web); + } + let relative = decode_asset_path(encoded_relative, "bad application asset path")?; if relative == "assets" { return Err((404, "no such application asset".to_string())); } - if web.files.contains_key(relative) { - return serve_web_file(web, relative); + if web.files.contains_key(&relative) { + return serve_web_file(web, &relative); } if relative.starts_with("assets/") { - return serve_web_file(web, relative); + return serve_web_file(web, &relative); } if relative == "favicon.ico" { return favicon(web); @@ -1319,14 +1575,6 @@ fn application_path(web: &WebAssets, path: &str) -> Served { } fn serve_web_file(web: &WebAssets, relative: &str) -> Served { - if relative.contains('\\') - || relative - .split('/') - .any(|segment| segment.is_empty() || segment == "." || segment == "..") - || Path::new(relative).is_absolute() - { - return Err((400, "bad application asset path".to_string())); - } let file = web .files .get(relative) @@ -1369,6 +1617,10 @@ fn captured_play_asset(assets: &BTreeMap>, encoded_relative: & /// interpretation. Asset identities may contain spaces and safe nested `/` /// separators, but the decoded result must remain a lexical relative path. fn decode_play_asset_path(encoded: &str) -> Result { + decode_asset_path(encoded, "bad asset path") +} + +fn decode_asset_path(encoded: &str, error_prefix: &str) -> Result { let bytes = encoded.as_bytes(); let mut decoded = Vec::with_capacity(bytes.len()); let mut index = 0; @@ -1380,17 +1632,17 @@ fn decode_play_asset_path(encoded: &str) -> Result { } let Some(high) = bytes.get(index + 1).and_then(|byte| hex_value(*byte)) else { - return Err((400, "bad asset path: malformed percent escape".to_string())); + return Err((400, format!("{error_prefix}: malformed percent escape"))); }; let Some(low) = bytes.get(index + 2).and_then(|byte| hex_value(*byte)) else { - return Err((400, "bad asset path: malformed percent escape".to_string())); + return Err((400, format!("{error_prefix}: malformed percent escape"))); }; decoded.push((high << 4) | low); index += 3; } let decoded = String::from_utf8(decoded) - .map_err(|_| (400, "bad asset path: decoded path is not UTF-8".to_string()))?; + .map_err(|_| (400, format!("{error_prefix}: decoded path is not UTF-8")))?; let path = Path::new(&decoded); if decoded.contains(['\\', '\0']) || decoded @@ -1401,7 +1653,7 @@ fn decode_play_asset_path(encoded: &str) -> Result { .components() .any(|component| !matches!(component, std::path::Component::Normal(_))) { - return Err((400, "bad asset path".to_string())); + return Err((400, error_prefix.to_string())); } Ok(decoded) } @@ -1415,17 +1667,10 @@ fn hex_value(byte: u8) -> Option { } } -fn serve_file_map(files: &BTreeMap, relative: &str) -> Served { - if relative - .split('/') - .any(|segment| segment == "." || segment == ".." || segment.is_empty()) - || relative.contains('\\') - || Path::new(relative).is_absolute() - { - return Err((400, "bad path".to_string())); - } +fn serve_file_map(files: &BTreeMap, encoded_relative: &str) -> Served { + let relative = decode_asset_path(encoded_relative, "bad path")?; let file = files - .get(relative) + .get(&relative) .ok_or_else(|| (404, format!("no such bundled file: {relative}")))?; Ok((file.content_type.clone(), file.bytes.as_ref().clone(), None)) } @@ -1452,10 +1697,11 @@ fn content_type(extension: &str) -> String { #[cfg(test)] mod tests { - use std::collections::BTreeMap; + use std::collections::{BTreeMap, BTreeSet}; use std::fs; use std::io::Read; - use std::sync::{Arc, Mutex}; + use std::sync::mpsc::sync_channel; + use std::sync::{Arc, Mutex, Weak}; use std::time::{Duration, SystemTime, UNIX_EPOCH}; use uhura_base::to_canonical_json; @@ -1464,11 +1710,13 @@ mod tests { use crate::source::EditorModelArtifact; use super::{ - ApiRoute, ClientRegistry, EditorHostState, EventStream, EventStreamPoll, PlayArtifact, - RequestMethod, RouteBody, RouteRequest, WebAssets, api_route, app_document, - application_path, broadcast, captured_play_asset, content_type, decode_play_asset_path, - editor_sse_payload, load_web_app_from, recheck_play, serve_file_map, split_request_url, - subscribe, tool_root, + ApiRoute, BLOCKING_EVENT_STREAM_WRITE_BOUNDARY, ClientCandidate, ClientRegistry, + EditorHostState, EventStream, EventStreamPoll, Host, MAX_EVENT_STREAMS_PER_HOST, + PlayArtifact, ProjectSourceFingerprint, RequestMethod, RouteBody, RouteRequest, + RouteResponse, WebAssets, WebFile, api_route, app_document, application_path, broadcast, + captured_play_asset, content_type, decode_play_asset_path, editor_sse_payload, + index_asset_references, load_web_app_from, recheck_play, serve_file_map, split_request_url, + subscribe, subscribe_with_blocking_keepalive, tool_root, }; fn render(revision: u64, name: &str) -> EditorRender { @@ -1514,6 +1762,22 @@ mod tests { serde_json::from_str(&state.state_json).expect("state JSON") } + fn test_host(web: WebAssets) -> Host { + let candidate = ClientCandidate { + revision: 1, + source_fingerprint: ProjectSourceFingerprint::default(), + editor: Ok(artifact(1, "test")), + play: Err(diagnostics("no Play build")), + }; + Host::new(web, candidate).unwrap().0 + } + + fn response_bytes(mut response: RouteResponse) -> Vec { + let mut bytes = Vec::new(); + response.body.read_to_end(&mut bytes).unwrap(); + bytes + } + #[test] fn editor_transitions_current_to_stale_and_recovers() { let mut state = EditorHostState::initial(Ok(artifact(1, "first"))).unwrap(); @@ -1624,6 +1888,55 @@ mod tests { assert_eq!(api_route("/api/nope"), Some(ApiRoute::Unknown)); } + #[test] + fn head_strips_every_byte_response_without_changing_get_metadata() { + let host = test_host(test_web_assets()); + for (url, expected_status) in [ + ("/api/editor/state", 200), + ("/api/nope", 404), + ("/api/play/ir.json", 503), + ] { + let get = host.route(RouteRequest { + method: RequestMethod::Get, + url, + }); + let head = host.route(RouteRequest { + method: RequestMethod::Head, + url, + }); + + assert_eq!(get.status, expected_status, "GET {url}"); + assert_eq!(head.status, get.status, "HEAD {url}"); + assert_eq!(head.headers, get.headers, "HEAD {url}"); + let content_length = get + .headers + .iter() + .find_map(|(name, value)| { + (name == "Content-Length").then(|| value.parse::().unwrap()) + }) + .expect("byte responses report their GET representation length"); + assert_eq!(response_bytes(get).len(), content_length, "GET {url}"); + assert!(content_length > 0, "GET {url}"); + assert!(response_bytes(head).is_empty(), "HEAD {url}"); + } + + for url in ["/api/editor/events", "/api/play/events"] { + let response = host.route(RouteRequest { + method: RequestMethod::Head, + url, + }); + assert_eq!(response.status, 405, "HEAD {url}"); + assert!( + response + .headers + .iter() + .any(|(name, value)| name == "Allow" && value == "GET"), + "HEAD {url}", + ); + assert!(response_bytes(response).is_empty(), "HEAD {url}"); + } + } + #[test] fn play_inspection_artifact_is_coherent_with_checked_ir_and_spans() { let root = tool_root().join("examples/instagram-uhura"); @@ -1705,6 +2018,84 @@ mod tests { assert_eq!(error.0, 404); } + #[test] + fn application_and_wasm_assets_decode_once_and_reject_unsafe_paths() { + let files = BTreeMap::from([ + ( + "assets/summer day.js".to_string(), + WebFile { + bytes: Arc::new(b"application".to_vec()), + content_type: content_type("js"), + }, + ), + ( + "assets/%2e%2e/literal.js".to_string(), + WebFile { + bytes: Arc::new(b"literal application percent escapes".to_vec()), + content_type: content_type("js"), + }, + ), + ]); + let wasm_files = BTreeMap::from([ + ( + "runtime glue.js".to_string(), + WebFile { + bytes: Arc::new(b"wasm glue".to_vec()), + content_type: content_type("js"), + }, + ), + ( + "%2e%2e/literal.wasm".to_string(), + WebFile { + bytes: Arc::new(b"literal wasm percent escapes".to_vec()), + content_type: content_type("wasm"), + }, + ), + ]); + let host = test_host(WebAssets { + files: Arc::new(files), + index: Arc::new(b"
Uhura
".to_vec()), + wasm_files: Arc::new(wasm_files), + }); + + for (url, expected) in [ + ("/assets/summer%20day.js", b"application".as_slice()), + ("/assets%2Fsummer%20day.js", b"application".as_slice()), + ("/api/play/wasm/runtime%20glue.js", b"wasm glue".as_slice()), + ( + "/assets/%252e%252e/literal.js", + b"literal application percent escapes".as_slice(), + ), + ( + "/api/play/wasm/%252e%252e%2Fliteral.wasm", + b"literal wasm percent escapes".as_slice(), + ), + ] { + let response = host.route(RouteRequest { + method: RequestMethod::Get, + url, + }); + assert_eq!(response.status, 200, "GET {url}"); + assert_eq!(response_bytes(response), expected, "GET {url}"); + } + + for url in [ + "/assets/%2e%2e/secret.js", + "/assets%2F%2e%2e%2Fsecret.js", + "/assets/%5Csecret.js", + "/assets/%GG.js", + "/api/play/wasm/%2e%2e/secret.wasm", + "/api/play/wasm/%5Csecret.wasm", + "/api/play/wasm/%GG.wasm", + ] { + let response = host.route(RouteRequest { + method: RequestMethod::Get, + url, + }); + assert_eq!(response.status, 400, "GET {url}"); + } + } + fn test_web_assets() -> WebAssets { WebAssets { files: Arc::new(BTreeMap::new()), @@ -1729,7 +2120,7 @@ mod tests { #[test] fn stalled_event_subscriber_keeps_only_one_invalidation() { let clients = Arc::new(Mutex::new(ClientRegistry::default())); - let stream = subscribe(&clients, || editor_sse_payload(1)); + let stream = subscribe(&clients, || editor_sse_payload(1)).expect("event stream admission"); for revision in 2..=128 { broadcast(&clients, &editor_sse_payload(revision)); @@ -1748,10 +2139,49 @@ mod tests { assert_eq!(next_event(&stream)["sourceRevision"], 129); } + #[test] + fn blocking_event_stream_keepalive_crosses_the_http_chunk_boundary() { + let (_sender, receiver) = sync_channel(1); + let mut stream = EventStream { + receiver, + buffer: Vec::new(), + offset: 0, + subscription_id: 0, + clients: Weak::new(), + blocking_keepalive_interval: Duration::ZERO, + _admission_permit: None, + }; + + let mut output = vec![0; BLOCKING_EVENT_STREAM_WRITE_BOUNDARY + 16]; + let count = stream.read(&mut output).expect("keepalive read"); + assert_eq!(count, BLOCKING_EVENT_STREAM_WRITE_BOUNDARY + 3); + assert_eq!(output[0], b':'); + assert!( + output[1..BLOCKING_EVENT_STREAM_WRITE_BOUNDARY + 1] + .iter() + .all(|byte| *byte == b' ') + ); + assert_eq!( + &output[BLOCKING_EVENT_STREAM_WRITE_BOUNDARY + 1..count], + b"\n\n" + ); + } + + #[test] + fn framed_event_stream_poll_does_not_synthesize_keepalives() { + let clients = Arc::new(Mutex::new(ClientRegistry::default())); + let stream = + subscribe_with_blocking_keepalive(&clients, || editor_sse_payload(1), Duration::ZERO) + .expect("event stream admission"); + + assert_eq!(next_event(&stream)["sourceRevision"], 1); + assert_eq!(stream.try_next_frame(), EventStreamPoll::Timeout); + } + #[test] fn dropping_event_stream_unregisters_immediately() { let clients = Arc::new(Mutex::new(ClientRegistry::default())); - let stream = subscribe(&clients, || editor_sse_payload(1)); + let stream = subscribe(&clients, || editor_sse_payload(1)).expect("event stream admission"); assert_eq!(clients.lock().expect("clients lock").clients.len(), 1); drop(stream); @@ -1759,6 +2189,87 @@ mod tests { assert!(clients.lock().expect("clients lock").clients.is_empty()); } + #[test] + fn host_event_admission_is_shared_bounded_and_reusable() { + let host = test_host(test_web_assets()); + let admission = Arc::clone(&host.editor_clients.lock().expect("clients lock").admission); + let active_subscribers = || { + host.editor_clients + .lock() + .expect("clients lock") + .clients + .len() + + host + .play_clients + .lock() + .expect("clients lock") + .clients + .len() + }; + let open_stream = |url| { + let response = host.route(RouteRequest { + method: RequestMethod::Get, + url, + }); + assert_eq!(response.status, 200, "GET {url}"); + match response.body { + RouteBody::Events(stream) => stream, + RouteBody::Bytes(_) => panic!("GET {url} should return an event stream"), + } + }; + + let mut streams = (0..MAX_EVENT_STREAMS_PER_HOST) + .map(|index| { + open_stream(if index % 2 == 0 { + "/api/editor/events" + } else { + "/api/play/events" + }) + }) + .collect::>(); + assert_eq!(active_subscribers(), MAX_EVENT_STREAMS_PER_HOST); + assert_eq!( + *admission.active.lock().expect("event admission lock"), + MAX_EVENT_STREAMS_PER_HOST + ); + + let saturated = host.route(RouteRequest { + method: RequestMethod::Get, + url: "/api/editor/events?retry=1", + }); + assert_eq!(saturated.status, 503); + assert!( + saturated + .headers + .iter() + .any(|(name, value)| { name == "Retry-After" && value == "1" }) + ); + assert!( + String::from_utf8(response_bytes(saturated)) + .unwrap() + .contains("too many active Uhura event streams") + ); + assert_eq!(active_subscribers(), MAX_EVENT_STREAMS_PER_HOST); + + drop(streams.pop().expect("one admitted stream")); + assert_eq!(active_subscribers(), MAX_EVENT_STREAMS_PER_HOST - 1); + streams.push(open_stream("/api/play/events")); + assert_eq!(active_subscribers(), MAX_EVENT_STREAMS_PER_HOST); + + drop(streams); + assert_eq!(active_subscribers(), 0, "registries remove dropped streams"); + assert_eq!( + *admission.active.lock().expect("event admission lock"), + 0, + "dropped streams return all permits" + ); + + let fresh = open_stream("/api/editor/events"); + assert_eq!(active_subscribers(), 1); + drop(fresh); + assert_eq!(active_subscribers(), 0); + } + #[test] fn host_publication_is_coherent_and_keeps_event_streams_stable() { let root = tool_root().join("examples/instagram-uhura"); @@ -1905,6 +2416,34 @@ mod tests { fs::remove_dir_all(root).unwrap(); } + #[test] + fn frontend_asset_discovery_reads_only_start_tag_attributes() { + let references = index_asset_references( + r#" + + + + +
+ + + "#, + ) + .unwrap(); + + assert_eq!( + references, + BTreeSet::from(["assets/app.css".to_string(), "assets/app.js".to_string(),]) + ); + } + #[test] fn frontend_bundle_snapshot_survives_a_dist_rebuild() { let unique = SystemTime::now()