#![cfg(not(target_arch = "wasm32"))] //! T3 destructive socket battery for the instance, run, value and event //! routes. Every operation under test goes through one of FlowServer's two //! HTTP planes; only fixture setup and assertions use library types. mod support; use makepad_ai_hub::discovery::DEFAULT_FLEET; use makepad_ai_hub::download::Downloader; use makepad_ai_hub::peer_serve::PeerOptions; use makepad_ai_hub::registry::{Domain, ModelSpec, Registry}; use makepad_ai_hub::server::{start_service, ServiceConfig}; use makepad_flow::engine::{FixedGen, HubHttp, NetPolicy}; use makepad_flow::host::{Endpoints, FlowServer, FlowServerConfig}; use makepad_flow::{ CreateInstanceRequest, CreateInstanceResponse, CreateRunRequest, CreateRunResponse, EventsResponse, FlowResponse, InputValueDto, InstanceRow, Literal, PortType, PutSourceRequest, PutValueResponse, RunRowDto, RunState, Seams, }; use makepad_micro_serde::{DeJson, JsonValue, SerJson}; use std::collections::{HashMap, HashSet}; use std::fs; use std::io::{self, Read, Write}; use std::net::{SocketAddr, TcpListener, TcpStream}; use std::path::{Path, PathBuf}; use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::{Arc, Mutex}; use std::thread::JoinHandle; use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; use support::{seams, FakeChat, FakeGen, FakeHttp}; // This battery opens two FlowServer listeners per case (and two more for // real HTTP/gen cases). Some CI sandboxes reject concurrent binds instead // of merely assigning separate ephemeral ports, so keep this test binary's // socket ownership deterministic. static SOCKET_TEST: Mutex<()> = Mutex::new(()); fn socket_test() -> std::sync::MutexGuard<'static, ()> { SOCKET_TEST.lock().unwrap_or_else(|poison| poison.into_inner()) } struct TempRoot(PathBuf); impl TempRoot { fn new(label: &str) -> Self { let nonce = SystemTime::now() .duration_since(UNIX_EPOCH) .unwrap() .as_nanos(); let path = std::env::temp_dir().join(format!( "makepad-flow-battery-runs-{}-{label}-{nonce}", std::process::id() )); fs::create_dir_all(&path).unwrap(); Self(path) } } impl Drop for TempRoot { fn drop(&mut self) { let _ = fs::remove_dir_all(&self.0); } } #[derive(Debug)] struct HttpResponse { status: u16, headers: HashMap, bytes: Vec, } impl HttpResponse { fn body(&self) -> String { String::from_utf8_lossy(&self.bytes).into_owned() } } fn request( address: SocketAddr, method: &str, target: &str, token: Option<&str>, headers: &[(&str, &str)], body: &[u8], ) -> HttpResponse { let mut stream = TcpStream::connect(address).unwrap(); stream .set_read_timeout(Some(Duration::from_secs(35))) .unwrap(); stream .set_write_timeout(Some(Duration::from_secs(35))) .unwrap(); write!(stream, "{method} {target} HTTP/1.1\r\nHost: localhost\r\n").unwrap(); if let Some(token) = token { write!(stream, "Authorization: Bearer {token}\r\n").unwrap(); } for (name, value) in headers { write!(stream, "{name}: {value}\r\n").unwrap(); } write!( stream, "Content-Length: {}\r\nConnection: close\r\n\r\n", body.len() ) .unwrap(); stream.write_all(body).unwrap(); stream.flush().unwrap(); let mut raw = Vec::new(); stream.read_to_end(&mut raw).unwrap(); let boundary = raw .windows(4) .position(|window| window == b"\r\n\r\n") .expect("HTTP response head terminator"); let head = std::str::from_utf8(&raw[..boundary]).expect("HTTP response head utf-8"); let status = head .lines() .next() .and_then(|line| line.split_whitespace().nth(1)) .and_then(|status| status.parse().ok()) .expect("HTTP response status"); let headers = head .lines() .skip(1) .filter_map(|line| line.split_once(':')) .map(|(name, value)| (name.to_ascii_lowercase(), value.trim().to_string())) .collect(); HttpResponse { status, headers, bytes: raw[boundary + 4..].to_vec(), } } fn json_request( address: SocketAddr, method: &str, target: &str, token: Option<&str>, body: &str, ) -> HttpResponse { request( address, method, target, token, &[("Content-Type", "application/json")], body.as_bytes(), ) } fn base_config(root: &Path, seams: Seams) -> FlowServerConfig { let mut config = FlowServerConfig::new(root.to_path_buf()).with_seams(seams); config.watch_interval_ms = 25; config.janitor_sweep_secs = 1; config.log = Box::new(|_| {}); config } fn start(root: &Path, seams: Seams) -> FlowServer { FlowServer::start(base_config(root, seams)).unwrap() } fn put_flow(endpoints: &Endpoints, name: &str, source: &str) { let response = json_request( endpoints.control, "PUT", &format!("/v1/flows/{name}"), Some(&endpoints.token), &PutSourceRequest { source: source.to_string(), } .serialize_json(), ); assert_eq!(response.status, 200, "PUT flow {name}: {}", response.body()); } fn create_instance(endpoints: &Endpoints, flow: &str, pin: bool) -> String { let response = json_request( endpoints.control, "POST", &format!("/v1/flows/{flow}/instances"), Some(&endpoints.token), &CreateInstanceRequest { pin: pin.then_some(true), ..CreateInstanceRequest::default() } .serialize_json(), ); assert_eq!(response.status, 201, "create {flow}: {}", response.body()); CreateInstanceResponse::deserialize_json(&response.body()) .unwrap() .instance } fn start_run(endpoints: &Endpoints, instance: &str, outputs: Option>) -> CreateRunResponse { let response = json_request( endpoints.control, "POST", &format!("/v1/instances/{instance}/runs"), Some(&endpoints.token), &CreateRunRequest { outputs }.serialize_json(), ); assert_eq!(response.status, 202, "start run: {}", response.body()); CreateRunResponse::deserialize_json(&response.body()).unwrap() } fn event_cursor(endpoints: &Endpoints, topic: &str) -> String { let response = json_request( endpoints.control, "GET", &format!("/v1/events?topic={topic}"), Some(&endpoints.token), "", ); assert_eq!(response.status, 200, "events cursor: {}", response.body()); EventsResponse::deserialize_json(&response.body()) .unwrap() .cursor } fn event_kind(event: &JsonValue) -> &str { event .key("kind") .and_then(JsonValue::string) .map(String::as_str) .unwrap_or("") } fn event_str<'a>(event: &'a JsonValue, key: &str) -> Option<&'a str> { event .key(key) .and_then(JsonValue::string) .map(String::as_str) } fn event_seq(event: &JsonValue) -> u64 { match event.key("seq") { Some(JsonValue::U64(value)) => *value, Some(JsonValue::I64(value)) => (*value).try_into().unwrap(), other => panic!("event has no numeric seq: {other:?}"), } } fn event_u64(event: &JsonValue, key: &str) -> Option { match event.key(key) { Some(JsonValue::U64(value)) => Some(*value), Some(JsonValue::I64(value)) => (*value).try_into().ok(), _ => None, } } fn variant_name(value: Option<&JsonValue>) -> Option<&str> { match value { Some(JsonValue::Object(fields)) if fields.len() == 1 => { fields.keys().next().map(String::as_str) } _ => None, } } fn poll_events_until( endpoints: &Endpoints, topic: &str, mut cursor: String, timeout: Duration, mut stop: impl FnMut(&JsonValue) -> bool, ) -> Vec { let deadline = Instant::now() + timeout; let mut events = Vec::new(); loop { let response = json_request( endpoints.control, "GET", &format!("/v1/events?cursor={cursor}&wait=500&topic={topic}"), Some(&endpoints.token), "", ); assert_eq!(response.status, 200, "poll events: {}", response.body()); let page = EventsResponse::deserialize_json(&response.body()).unwrap(); assert!(!page.gap, "unexpected event gap while polling {topic}"); cursor = page.cursor; for event in page.events { let done = stop(&event); events.push(event); if done { return events; } } assert!(Instant::now() < deadline, "event deadline elapsed for {topic}: {events:#?}"); } } fn get_run(endpoints: &Endpoints, run_id: &str) -> RunRowDto { let response = json_request( endpoints.control, "GET", &format!("/v1/runs/{run_id}"), Some(&endpoints.token), "", ); assert_eq!(response.status, 200, "get run: {}", response.body()); RunRowDto::deserialize_json(&response.body()).unwrap() } fn wait_run_terminal(endpoints: &Endpoints, run_id: &str, timeout: Duration) -> RunRowDto { let deadline = Instant::now() + timeout; loop { let row = get_run(endpoints, run_id); if matches!(row.state, RunState::Done | RunState::Failed | RunState::Cancelled) { return row; } assert!(Instant::now() < deadline, "run {run_id} did not finish: {row:#?}"); std::thread::sleep(Duration::from_millis(25)); } } fn text_input(text: &str) -> InputValueDto { InputValueDto { ty: PortType::Text, text: Some(text.to_string()), json: None, digest: None, } } fn json_input(value: JsonValue) -> InputValueDto { InputValueDto { ty: PortType::Json, text: None, json: Some(value), digest: None, } } fn image_input(digest: &str) -> InputValueDto { InputValueDto { ty: PortType::Image, text: None, json: None, digest: Some(digest.to_string()), } } fn one_input( node: &str, port: &str, value: InputValueDto, ) -> HashMap> { HashMap::from([(node.to_string(), HashMap::from([(port.to_string(), value)]))]) } fn put_inputs( endpoints: &Endpoints, instance: &str, actor: Option<&str>, inputs: &HashMap>, ) -> HttpResponse { let suffix = actor.map(|actor| format!("?actor={actor}")).unwrap_or_default(); json_request( endpoints.control, "PUT", &format!("/v1/instances/{instance}/inputs{suffix}"), Some(&endpoints.token), &inputs.serialize_json(), ) } fn put_value(endpoints: &Endpoints, ty: &str, content_type: &str, bytes: &[u8]) -> (HttpResponse, String) { let response = request( endpoints.data, "PUT", &format!("/v1/values?type={ty}&content_type={content_type}"), Some(&endpoints.token), &[("Content-Type", content_type)], bytes, ); let digest = if response.status == 201 { PutValueResponse::deserialize_json(&response.body()) .unwrap() .digest } else { String::new() }; (response, digest) } fn start_testpattern(label: &str) -> Option { let cache = std::env::temp_dir().join(format!( "makepad-flow-battery-runs-hub-{}-{label}-{}", std::process::id(), SystemTime::now() .duration_since(UNIX_EPOCH) .unwrap() .as_nanos() )); fs::create_dir_all(&cache).unwrap(); let service = start_service(ServiceConfig { host: "127.0.0.1".to_string(), port: 0, cache_dir: cache, registry: Registry { models: vec![ModelSpec { id: "testpattern".to_string(), domain: Domain::Image, backend: "testpattern".to_string(), available: true, gated: false, vram_gb: Some(0.0), min_vram_gb: None, min_compute_cap: None, note: None, license: None, files: Vec::new(), }], }, downloader: Downloader::new("http://127.0.0.1:1", None).unwrap(), peer: PeerOptions { serve: Some(false), sources: Some(Vec::new()), ..Default::default() }, fleet: DEFAULT_FLEET.to_string(), }); let handle = match service { Ok(handle) => handle, Err(error) if error.to_string().contains("Operation not permitted") => { eprintln!("skipping real testpattern service: loopback bind is forbidden"); return None; } Err(error) => panic!("start testpattern service: {error}"), }; let url = format!("http://{}", handle.addr); // The hub service has no stop message. This integration-test process // owns the service threads and tears them down when the binary exits. std::mem::forget(handle); Some(url) } #[derive(Clone, Debug)] struct FixtureRequest { method: String, path: String, content_type: Option, body: Vec, } struct FixtureServer { address: SocketAddr, requests: Arc>>, stop: Arc, join: Option>, } impl FixtureServer { fn start() -> Self { let listener = TcpListener::bind("127.0.0.1:0").unwrap(); listener.set_nonblocking(true).unwrap(); let address = listener.local_addr().unwrap(); let requests = Arc::new(Mutex::new(Vec::new())); let thread_requests = requests.clone(); let stop = Arc::new(AtomicBool::new(false)); let thread_stop = stop.clone(); let join = std::thread::spawn(move || { while !thread_stop.load(Ordering::SeqCst) { match listener.accept() { Ok((mut stream, _)) => { stream .set_read_timeout(Some(Duration::from_secs(5))) .unwrap(); let request = read_fixture_request(&mut stream).unwrap(); let (status, content_type, body) = match (request.method.as_str(), request.path.as_str()) { ("GET", "/today.json") => { (200, "application/json", br#"["a red fox", "a blue heron"]"#.to_vec()) } ("POST", "/pictures") => { (200, "application/json", br#"{"stored":true}"#.to_vec()) } _ => (404, "application/json", br#"{"error":"not found"}"#.to_vec()), }; thread_requests.lock().unwrap().push(request); write!( stream, "HTTP/1.1 {status} OK\r\nContent-Type: {content_type}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n", body.len() ) .unwrap(); stream.write_all(&body).unwrap(); stream.flush().unwrap(); } Err(error) if error.kind() == io::ErrorKind::WouldBlock => { std::thread::sleep(Duration::from_millis(5)); } Err(error) => panic!("fixture accept: {error}"), } } }); Self { address, requests, stop, join: Some(join), } } fn base_url(&self) -> String { format!("http://{}", self.address) } fn posted(&self) -> Option { self.requests .lock() .unwrap() .iter() .find(|request| request.method == "POST" && request.path == "/pictures") .cloned() } } impl Drop for FixtureServer { fn drop(&mut self) { self.stop.store(true, Ordering::SeqCst); let _ = TcpStream::connect(self.address); if let Some(join) = self.join.take() { let _ = join.join(); } } } fn read_fixture_request(stream: &mut TcpStream) -> io::Result { let mut raw = Vec::new(); let mut buffer = [0u8; 4096]; let boundary = loop { let read = stream.read(&mut buffer)?; if read == 0 { return Err(io::Error::new(io::ErrorKind::UnexpectedEof, "fixture request head")); } raw.extend_from_slice(&buffer[..read]); if let Some(boundary) = raw.windows(4).position(|window| window == b"\r\n\r\n") { break boundary; } if raw.len() > 64 * 1024 { return Err(io::Error::new(io::ErrorKind::InvalidData, "fixture request head too large")); } }; let head = std::str::from_utf8(&raw[..boundary]) .map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error))?; let mut lines = head.lines(); let mut request_line = lines.next().unwrap_or("").split_whitespace(); let method = request_line.next().unwrap_or("").to_string(); let path = request_line.next().unwrap_or("").to_string(); let mut content_length = 0usize; let mut content_type = None; for line in lines { let Some((name, value)) = line.split_once(':') else { continue; }; if name.eq_ignore_ascii_case("content-length") { content_length = value.trim().parse().unwrap_or(0); } if name.eq_ignore_ascii_case("content-type") { content_type = Some(value.trim().to_string()); } } let mut body = raw[boundary + 4..].to_vec(); while body.len() < content_length { let read = stream.read(&mut buffer)?; if read == 0 { break; } body.extend_from_slice(&buffer[..read]); } body.truncate(content_length); Ok(FixtureRequest { method, path, content_type, body, }) } const ASK_FLOW: &str = r#"use mod.flow.* let first = Ask{question: "Which?" type: @text options: ["first", "second"]} let other = Ask{question: "Other?" type: @text} let result = Output{type: @text value: first.text()} let other_result = Output{type: @text value: other.text()} Flow{first, other, result, other_result} "#; const IMAGE_INPUT_FLOW: &str = r#"use mod.flow.* let picture = Input{type: @image default: nil} let result = Output{type: @image value: picture.image()} Flow{picture, result} "#; const CHAT_FLOW_ONE: &str = r#"use mod.flow.* let prompt = Input{default: "one"} let expand = Llm{prompt: prompt.text()} let result = Output{value: expand.text()} Flow{concurrency: 1 prompt, expand, result} "#; const CHAT_FLOW_TWO: &str = r#"use mod.flow.* let prompt = Input{default: "two"} let expand = Llm{prompt: prompt.text()} let result = Output{value: expand.text()} Flow{concurrency: 2 prompt, expand, result} "#; // ------------------------------------------------------------------------- // One test per T3 bullet. // ------------------------------------------------------------------------- #[test] fn contact_sheet_end_to_end_through_http_only() { let _socket_test = socket_test(); let Some(gen_url) = start_testpattern("contact-sheet") else { return; }; let fixture = FixtureServer::start(); let root = TempRoot::new("contact-sheet"); let mut config = base_config( &root.0, Seams { chat: Arc::new(FakeChat::done("unused")), gen: Arc::new(FixedGen(gen_url)), http: Arc::new(HubHttp), }, ); config.net = NetPolicy { allow: vec!["127.0.0.1".to_string()], deny_private: false, }; let server = FlowServer::start(config).unwrap(); let endpoints = server.endpoints(); let source = include_str!("fixtures/battery/contact_sheet.splash") .replace("{{BASE_URL}}", &fixture.base_url()); put_flow(&endpoints, "contact-sheet", &source); let instance = create_instance(&endpoints, "contact-sheet", false); let run_cursor = event_cursor(&endpoints, "run"); let run = start_run(&endpoints, &instance, None); assert_eq!(run.queued, 0); let mut saw_first = false; let mut saw_second = false; let mut saw_waiting = false; let before_answer = poll_events_until( &endpoints, "run", run_cursor, Duration::from_secs(15), |event| { if event_str(event, "run_id") == Some(run.run_id.as_str()) { saw_first |= event_kind(event) == "node.started" && event_str(event, "node") == Some("first"); saw_second |= event_kind(event) == "node.started" && event_str(event, "node") == Some("second"); saw_waiting |= event_kind(event) == "node.waiting" && event_str(event, "node") == Some("choice"); } saw_first && saw_second && saw_waiting }, ); let first_start = before_answer .iter() .position(|event| event_kind(event) == "node.started" && event_str(event, "node") == Some("first")) .expect("first candidate started"); let second_start = before_answer .iter() .position(|event| event_kind(event) == "node.started" && event_str(event, "node") == Some("second")) .expect("second candidate started"); let first_candidate_result = before_answer.iter().position(|event| { matches!(event_kind(event), "node.progress" | "node.done") && matches!(event_str(event, "node"), Some("first" | "second")) }); assert!( first_candidate_result.is_none_or(|index| first_start < index && second_start < index), "candidate images were not dispatched together: {before_answer:#?}" ); let waiting = before_answer .iter() .find(|event| { event_kind(event) == "node.waiting" && event_str(event, "node") == Some("choice") }) .expect("choice node.waiting"); let options = match waiting.key("options") { Some(JsonValue::Array(options)) => options, other => panic!("waiting options are not an array: {other:?}"), }; assert_eq!(options.len(), 2, "{waiting:#?}"); let waiting_rows = json_request( endpoints.control, "GET", "/v1/instances?waiting=1", Some(&endpoints.token), "", ); assert_eq!(waiting_rows.status, 200, "{}", waiting_rows.body()); let waiting_rows = Vec::::deserialize_json(&waiting_rows.body()).unwrap(); let waiting_row = waiting_rows .iter() .find(|row| row.instance == instance) .expect("parked contact-sheet instance"); assert_eq!( waiting_row.waiting.as_ref().unwrap().options, vec![Literal::Str("first".to_string()), Literal::Str("second".to_string())] ); let resume_cursor = event_cursor(&endpoints, "run"); let answer = put_inputs( &endpoints, &instance, Some("chat"), &one_input("choice", "text", text_input("first")), ); assert_eq!(answer.status, 200, "{}", answer.body()); let after_answer = poll_events_until( &endpoints, "run", resume_cursor, Duration::from_secs(15), |event| { event_kind(event) == "run.finished" && event_str(event, "run_id") == Some(run.run_id.as_str()) }, ); let answered = after_answer .iter() .find(|event| event_kind(event) == "node.answered") .expect("node.answered"); assert_eq!(event_str(answered, "node"), Some("choice")); assert_eq!(event_str(answered, "by"), Some("chat")); let image_done = after_answer .iter() .find(|event| event_kind(event) == "node.done" && event_str(event, "node") == Some("image")) .expect("final image node.done"); let digest = image_done .key("outputs") .and_then(|value| match value { JsonValue::Array(values) => Some(values.as_slice()), _ => None, }) .and_then(|outputs| outputs.first()) .and_then(|output| output.key("value")) .and_then(|value| value.key("digest")) .and_then(JsonValue::string) .cloned() .expect("final image digest"); assert_eq!( variant_name(after_answer.last().and_then(|event| event.key("state"))), Some("Done") ); let stored = fixture.posted().expect("fixture received POST /pictures"); assert_eq!(stored.content_type.as_deref(), Some("image/png")); assert_eq!(&stored.body[..8], b"\x89PNG\r\n\x1a\n"); let fetched = request( endpoints.data, "GET", &format!("/v1/values/{digest}"), Some(&endpoints.token), &[], &[], ); assert_eq!(fetched.status, 200, "{}", fetched.body()); assert_eq!(stored.body, fetched.bytes); let row = get_run(&endpoints, &run.run_id); assert_eq!(row.state, RunState::Done); assert_eq!(row.http_log.len(), 2, "{row:#?}"); assert_eq!(row.http_log[0].method, "GET"); assert_eq!(row.http_log[1].method, "POST"); server.shutdown(); } #[test] fn ask_edge_cases_reject_bad_answers_cancel_and_timeout() { let _socket_test = socket_test(); let root = TempRoot::new("ask-edges"); let server = start( &root.0, seams(FakeChat::done("unused"), FakeGen::done(), FakeHttp::json(200, "{}")), ); let endpoints = server.endpoints(); put_flow(&endpoints, "ask", ASK_FLOW); let instance = create_instance(&endpoints, "ask", false); let cursor = event_cursor(&endpoints, "run"); let run = start_run(&endpoints, &instance, Some(vec!["result".to_string()])); poll_events_until( &endpoints, "run", cursor, Duration::from_secs(5), |event| event_kind(event) == "node.waiting" && event_str(event, "node") == Some("first"), ); let wrong_node = put_inputs( &endpoints, &instance, None, &one_input("other", "text", text_input("first")), ); assert_eq!(wrong_node.status, 409, "{}", wrong_node.body()); let wrong_type = put_inputs( &endpoints, &instance, None, &one_input("first", "text", json_input(JsonValue::String("first".to_string()))), ); assert_eq!(wrong_type.status, 422, "{}", wrong_type.body()); let answer_cursor = event_cursor(&endpoints, "run"); let answer = put_inputs( &endpoints, &instance, Some("chat"), &one_input("first", "text", text_input("first")), ); assert_eq!(answer.status, 200, "{}", answer.body()); poll_events_until( &endpoints, "run", answer_cursor, Duration::from_secs(5), |event| event_kind(event) == "run.finished" && event_str(event, "run_id") == Some(&run.run_id), ); let twice = put_inputs( &endpoints, &instance, None, &one_input("first", "text", text_input("second")), ); assert_eq!(twice.status, 409, "{}", twice.body()); let delete_instance = create_instance(&endpoints, "ask", false); let delete_cursor = event_cursor(&endpoints, "run"); let delete_run = start_run( &endpoints, &delete_instance, Some(vec!["result".to_string()]), ); poll_events_until( &endpoints, "run", delete_cursor.clone(), Duration::from_secs(5), |event| { event_kind(event) == "node.waiting" && event_str(event, "run_id") == Some(&delete_run.run_id) }, ); let after_wait_cursor = event_cursor(&endpoints, "run"); let deleted = json_request( endpoints.control, "DELETE", &format!("/v1/instances/{delete_instance}"), Some(&endpoints.token), "", ); assert_eq!(deleted.status, 204, "{}", deleted.body()); let cancelled = poll_events_until( &endpoints, "run", after_wait_cursor, Duration::from_secs(5), |event| { event_kind(event) == "run.finished" && event_str(event, "run_id") == Some(&delete_run.run_id) }, ); assert_eq!( variant_name(cancelled.last().and_then(|event| event.key("state"))), Some("Cancelled") ); let gone = json_request( endpoints.control, "GET", &format!("/v1/instances/{delete_instance}"), Some(&endpoints.token), "", ); assert_eq!(gone.status, 404); let timeout_source = r#"use mod.flow.* let ask = Ask{question: "Wait?" timeout: 1} let result = Output{value: ask.text()} Flow{ask, result} "#; put_flow(&endpoints, "timeout", timeout_source); let timeout_instance = create_instance(&endpoints, "timeout", false); let timeout_cursor = event_cursor(&endpoints, "run"); let started = Instant::now(); let timeout_run = start_run(&endpoints, &timeout_instance, None); let timeout_events = poll_events_until( &endpoints, "run", timeout_cursor, Duration::from_secs(3), |event| { event_kind(event) == "run.finished" && event_str(event, "run_id") == Some(&timeout_run.run_id) }, ); assert!(started.elapsed() < Duration::from_secs(2), "Ask timeout took {:?}", started.elapsed()); assert!(timeout_events.iter().any(|event| { event_kind(event) == "node.failed" && event_str(event, "node") == Some("ask") && event_str(event, "error").is_some_and(|error| error.contains("timeout")) })); server.shutdown(); } #[test] fn inputs_validate_nodes_digests_uploads_dedup_and_body_limit() { let _socket_test = socket_test(); let root = TempRoot::new("inputs"); let server = start( &root.0, seams(FakeChat::done("unused"), FakeGen::done(), FakeHttp::json(200, "{}")), ); let endpoints = server.endpoints(); put_flow(&endpoints, "image-input", IMAGE_INPUT_FLOW); let instance = create_instance(&endpoints, "image-input", false); let absent = put_inputs( &endpoints, &instance, None, &one_input("not_in_graph", "image", image_input(&"0".repeat(64))), ); assert_eq!(absent.status, 422, "{}", absent.body()); assert!(absent.body().contains("not_in_graph"), "{}", absent.body()); let missing = put_inputs( &endpoints, &instance, None, &one_input("picture", "image", image_input(&"0".repeat(64))), ); assert_eq!(missing.status, 422, "{}", missing.body()); const PNG: &[u8] = &[ 0x89, b'P', b'N', b'G', 0x0d, 0x0a, 0x1a, 0x0a, 0, 0, 0, 0x0d, b'I', b'H', b'D', b'R', 0, 0, 0, 1, 0, 0, 0, 1, 8, 6, 0, 0, 0, 0x1f, 0x15, 0xc4, 0x89, 0, 0, 0, 0x0d, b'I', b'D', b'A', b'T', 8, 0xd7, 0x63, 0xf8, 0xcf, 0xc0, 0xf0, 0x1f, 0, 5, 0, 1, 0xff, 0x89, 0x99, 0x3d, 0x1d, 0, 0, 0, 0, b'I', b'E', b'N', b'D', 0xae, 0x42, 0x60, 0x82, ]; let (first_upload, first_digest) = put_value(&endpoints, "image", "image/png", PNG); assert_eq!(first_upload.status, 201, "{}", first_upload.body()); let (second_upload, second_digest) = put_value(&endpoints, "image", "image/png", PNG); assert_eq!(second_upload.status, 201, "{}", second_upload.body()); assert_eq!(first_digest, second_digest); let set = put_inputs( &endpoints, &instance, None, &one_input("picture", "image", image_input(&first_digest)), ); assert_eq!(set.status, 200, "{}", set.body()); let run = start_run(&endpoints, &instance, None); let row = wait_run_terminal(&endpoints, &run.run_id, Duration::from_secs(5)); assert_eq!(row.state, RunState::Done); assert_eq!(row.outputs["result"].digest, first_digest); let oversized = vec![0x5a; 64 * 1024 * 1024 + 1]; let started = Instant::now(); let too_large = request( endpoints.data, "PUT", "/v1/values?type=image&content_type=image/png", Some(&endpoints.token), &[("Content-Type", "image/png")], &oversized, ); assert!(matches!(too_large.status, 400 | 413), "{too_large:?}"); assert!(started.elapsed() < Duration::from_secs(10), "oversized upload hung: {:?}", started.elapsed()); server.shutdown(); } #[test] fn concurrency_queues_parallelizes_and_finishes_twenty_seeded_instances() { let _socket_test = socket_test(); let root = TempRoot::new("concurrency"); let server = start( &root.0, seams(FakeChat::done("done"), FakeGen::done(), FakeHttp::json(200, "{}")), ); let endpoints = server.endpoints(); put_flow(&endpoints, "serial", CHAT_FLOW_ONE); let serial_instance = create_instance(&endpoints, "serial", false); let serial_cursor = event_cursor(&endpoints, "run"); let serial_first = start_run(&endpoints, &serial_instance, None); let serial_second = start_run(&endpoints, &serial_instance, None); assert_eq!(serial_first.queued, 0); assert_eq!(serial_second.queued, 1); let serial_events = poll_events_until( &endpoints, "run", serial_cursor, Duration::from_secs(10), |event| { event_kind(event) == "run.finished" && event_str(event, "run_id") == Some(&serial_second.run_id) }, ); let position = |kind: &str, run_id: &str| { serial_events .iter() .position(|event| event_kind(event) == kind && event_str(event, "run_id") == Some(run_id)) .unwrap_or_else(|| panic!("missing {kind} for {run_id}: {serial_events:#?}")) }; assert!( position("run.started", &serial_first.run_id) < position("run.finished", &serial_first.run_id) ); assert!( position("run.finished", &serial_first.run_id) < position("run.started", &serial_second.run_id), "queued run started before the first finished: {serial_events:#?}" ); put_flow(&endpoints, "parallel", CHAT_FLOW_TWO); let parallel_instance = create_instance(&endpoints, "parallel", false); let parallel_cursor = event_cursor(&endpoints, "run"); let parallel_first = start_run(&endpoints, ¶llel_instance, None); let parallel_second = start_run(&endpoints, ¶llel_instance, None); assert_eq!(parallel_first.queued, 0); assert_eq!(parallel_second.queued, 0); let parallel_events = poll_events_until( &endpoints, "run", parallel_cursor, Duration::from_secs(10), |event| { event_kind(event) == "run.finished" && event_str(event, "run_id") == Some(¶llel_second.run_id) }, ); let first_finished = parallel_events .iter() .position(|event| event_kind(event) == "run.finished") .expect("a parallel run finished"); for run_id in [¶llel_first.run_id, ¶llel_second.run_id] { let started = parallel_events .iter() .position(|event| event_kind(event) == "run.started" && event_str(event, "run_id") == Some(run_id)) .unwrap_or_else(|| panic!("parallel run {run_id} never started: {parallel_events:#?}")); assert!(started < first_finished, "runs did not overlap: {parallel_events:#?}"); } server.shutdown(); let Some(gen_url) = start_testpattern("twenty-instances") else { return; }; let root = TempRoot::new("twenty-instances"); let server = start( &root.0, Seams { chat: Arc::new(FakeChat::done("unused")), gen: Arc::new(FixedGen(gen_url)), http: Arc::new(FakeHttp::json(200, "{}")), }, ); let endpoints = server.endpoints(); let mut source = String::from("use mod.flow.*\n"); for seed in 0..20 { source.push_str(&format!( "let image{seed} = Image{{prompt: \"seeded\" model: \"testpattern\" width: 32 height: 32 steps: 1 seed: {seed}}}\n" )); source.push_str(&format!( "let result{seed} = Output{{type: @image value: image{seed}.image()}}\n" )); } source.push_str("Flow{concurrency: 2 "); for seed in 0..20 { source.push_str(&format!("image{seed}, result{seed}, ")); } source.push_str("}\n"); put_flow(&endpoints, "seeded", &source); let cursor = event_cursor(&endpoints, "run"); let mut runs = Vec::new(); for seed in 0..20 { let instance = create_instance(&endpoints, "seeded", false); runs.push(start_run( &endpoints, &instance, Some(vec![format!("result{seed}")]), )); } let expected: HashSet = runs.iter().map(|run| run.run_id.clone()).collect(); let mut finished = HashSet::new(); poll_events_until( &endpoints, "run", cursor, Duration::from_secs(30), |event| { if event_kind(event) == "run.finished" { if let Some(run_id) = event_str(event, "run_id") { if expected.contains(run_id) { finished.insert(run_id.to_string()); } } } finished.len() == expected.len() }, ); let digests: HashSet = runs .iter() .map(|run| { let row = get_run(&endpoints, &run.run_id); assert_eq!(row.state, RunState::Done, "{row:#?}"); row.outputs .values() .next() .expect("seeded output") .digest .clone() }) .collect(); assert_eq!(digests.len(), 20, "different seeds did not produce 20 digests"); server.shutdown(); } #[test] fn live_definition_changes_preserve_revision_pins_inputs_and_cancel_on_delete() { let _socket_test = socket_test(); let root = TempRoot::new("live-definitions"); let server = start( &root.0, seams( FakeChat { pending: true, ..FakeChat::done("unused") }, FakeGen::done(), FakeHttp::json(200, "{}"), ), ); let endpoints = server.endpoints(); let idle_v1 = r#"use mod.flow.* let prompt = Input{default: "old"} let result = Output{value: prompt.text()} Flow{prompt, result} "#; let idle_v2 = r#"use mod.flow.* let prompt = Input{default: "new default"} let extra = Input{default: "extra"} let result = Output{value: prompt.text()} Flow{prompt, extra, result} "#; put_flow(&endpoints, "idle", idle_v1); let live = create_instance(&endpoints, "idle", false); let pinned = create_instance(&endpoints, "idle", true); for instance in [&live, &pinned] { let response = put_inputs( &endpoints, instance, None, &one_input("prompt", "text", text_input("kept")), ); assert_eq!(response.status, 200, "{}", response.body()); } put_flow(&endpoints, "idle", idle_v2); let live_row = json_request( endpoints.control, "GET", &format!("/v1/instances/{live}"), Some(&endpoints.token), "", ); let live_row = InstanceRow::deserialize_json(&live_row.body()).unwrap(); assert_eq!(live_row.revision, 2); assert_eq!(live_row.input_text("prompt", "text"), Some("kept".to_string())); assert!(live_row.inputs.contains_key("extra")); let pinned_row = json_request( endpoints.control, "GET", &format!("/v1/instances/{pinned}"), Some(&endpoints.token), "", ); let pinned_row = InstanceRow::deserialize_json(&pinned_row.body()).unwrap(); assert_eq!(pinned_row.revision, 1); assert_eq!(pinned_row.input_text("prompt", "text"), Some("kept".to_string())); assert!(!pinned_row.inputs.contains_key("extra")); let running_v1 = r#"use mod.flow.* let choice = Ask{question: "Old question"} let result = Output{value: choice.text()} Flow{choice, result} "#; let running_v2 = r#"use mod.flow.* let choice = Ask{question: "New question"} let extra = Input{default: "new"} let result = Output{value: choice.text()} Flow{choice, extra, result} "#; put_flow(&endpoints, "running", running_v1); let running = create_instance(&endpoints, "running", false); let old_cursor = event_cursor(&endpoints, "run"); let old_run = start_run(&endpoints, &running, None); let old_events = poll_events_until( &endpoints, "run", old_cursor, Duration::from_secs(5), |event| { event_kind(event) == "node.waiting" && event_str(event, "run_id") == Some(&old_run.run_id) }, ); let started = old_events .iter() .find(|event| event_kind(event) == "run.started") .expect("old run.started"); assert_eq!(event_u64(started, "revision"), Some(1)); put_flow(&endpoints, "running", running_v2); let finish_cursor = event_cursor(&endpoints, "run"); let answer = put_inputs( &endpoints, &running, Some("chat"), &one_input("choice", "text", text_input("first")), ); assert_eq!(answer.status, 200, "{}", answer.body()); poll_events_until( &endpoints, "run", finish_cursor, Duration::from_secs(5), |event| { event_kind(event) == "run.finished" && event_str(event, "run_id") == Some(&old_run.run_id) }, ); let old_row = get_run(&endpoints, &old_run.run_id); assert_eq!(old_row.revision, 1); assert_eq!(old_row.state, RunState::Done); let new_cursor = event_cursor(&endpoints, "run"); let new_run = start_run(&endpoints, &running, None); let new_events = poll_events_until( &endpoints, "run", new_cursor, Duration::from_secs(5), |event| { event_kind(event) == "node.waiting" && event_str(event, "run_id") == Some(&new_run.run_id) }, ); let new_started = new_events .iter() .find(|event| event_kind(event) == "run.started") .expect("new run.started"); assert_eq!(event_u64(new_started, "revision"), Some(2)); let delete_cursor = event_cursor(&endpoints, "run"); let deleted = json_request( endpoints.control, "DELETE", "/v1/flows/running", Some(&endpoints.token), "", ); assert_eq!(deleted.status, 204, "{}", deleted.body()); let cancelled = poll_events_until( &endpoints, "run", delete_cursor, Duration::from_secs(5), |event| { event_kind(event) == "run.finished" && event_str(event, "run_id") == Some(&new_run.run_id) }, ); assert_eq!( variant_name(cancelled.last().and_then(|event| event.key("state"))), Some("Cancelled") ); let gone = json_request( endpoints.control, "GET", &format!("/v1/instances/{running}"), Some(&endpoints.token), "", ); assert_eq!(gone.status, 404); server.shutdown(); } #[test] fn values_support_range_etag_missing_and_ttl_reference_liveness() { let _socket_test = socket_test(); let root = TempRoot::new("values"); let mut config = base_config( &root.0, seams(FakeChat::done("unused"), FakeGen::done(), FakeHttp::json(200, "{}")), ); config.values_ram_budget = 1; config.value_ttl_secs = 1; let server = FlowServer::start(config).unwrap(); let endpoints = server.endpoints(); let bytes = b"0123456789abcdefghijklmnopqrstuvwxyz"; let (uploaded, digest) = put_value(&endpoints, "bytes", "application/octet-stream", bytes); assert_eq!(uploaded.status, 201, "{}", uploaded.body()); let full = request( endpoints.data, "GET", &format!("/v1/values/{digest}"), Some(&endpoints.token), &[], &[], ); assert_eq!(full.status, 200); assert_eq!(full.bytes, bytes); let etag = full.headers.get("etag").expect("ETag").clone(); let range = request( endpoints.data, "GET", &format!("/v1/values/{digest}"), Some(&endpoints.token), &[("Range", "bytes=0-9")], &[], ); assert_eq!(range.status, 206, "{range:?}"); assert_eq!(range.bytes, &bytes[..10]); let not_modified = request( endpoints.data, "GET", &format!("/v1/values/{digest}"), Some(&endpoints.token), &[("If-None-Match", &etag)], &[], ); assert_eq!(not_modified.status, 304, "{not_modified:?}"); assert!(not_modified.bytes.is_empty()); let unknown = request( endpoints.data, "GET", &format!("/v1/values/{}", "f".repeat(64)), Some(&endpoints.token), &[], &[], ); assert_eq!(unknown.status, 404, "{unknown:?}"); let (unused_upload, unused_digest) = put_value( &endpoints, "image", "image/png", b"unreferenced-image-bytes", ); assert_eq!(unused_upload.status, 201); let (live_upload, live_digest) = put_value( &endpoints, "image", "image/png", b"parked-run-image-bytes", ); assert_eq!(live_upload.status, 201); let parked_source = r#"use mod.flow.* let picture = Input{type: @image default: nil} let ask = Ask{question: "Keep waiting"} let result = Output{type: @image value: picture.image()} let answer = Output{value: ask.text()} Flow{picture, ask, result, answer} "#; put_flow(&endpoints, "parked-value", parked_source); let instance = create_instance(&endpoints, "parked-value", false); let set = put_inputs( &endpoints, &instance, None, &one_input("picture", "image", image_input(&live_digest)), ); assert_eq!(set.status, 200, "{}", set.body()); let cursor = event_cursor(&endpoints, "run"); let run = start_run(&endpoints, &instance, None); poll_events_until( &endpoints, "run", cursor, Duration::from_secs(5), |event| { event_kind(event) == "node.waiting" && event_str(event, "run_id") == Some(&run.run_id) }, ); std::thread::sleep(Duration::from_millis(2_500)); let expired = request( endpoints.data, "GET", &format!("/v1/values/{unused_digest}"), Some(&endpoints.token), &[], &[], ); assert_eq!(expired.status, 404, "{expired:?}"); let retained = request( endpoints.data, "GET", &format!("/v1/values/{live_digest}"), Some(&endpoints.token), &[], &[], ); assert_eq!(retained.status, 200, "{retained:?}"); server.shutdown(); } #[test] fn events_filter_delta_page_in_order_and_signal_a_4096_event_gap() { let _socket_test = socket_test(); let root = TempRoot::new("events"); let server = start( &root.0, seams( FakeChat::done("streamed text"), FakeGen::done(), FakeHttp::json(200, "{}"), ), ); let endpoints = server.endpoints(); put_flow(&endpoints, "stream", CHAT_FLOW_ONE); let instance = create_instance(&endpoints, "stream", false); let run_cursor = event_cursor(&endpoints, "run"); let instance_cursor = event_cursor(&endpoints, "instance"); let run = start_run(&endpoints, &instance, None); let run_events = poll_events_until( &endpoints, "run", run_cursor, Duration::from_secs(5), |event| { event_kind(event) == "run.finished" && event_str(event, "run_id") == Some(&run.run_id) }, ); assert!(run_events.iter().any(|event| event_kind(event) == "node.delta")); let instance_events = poll_events_until( &endpoints, "instance", instance_cursor, Duration::from_secs(5), |event| { event_kind(event) == "run.finished" && event_str(event, "run_id") == Some(&run.run_id) }, ); assert!(instance_events.iter().any(|event| event_kind(event) == "node.done")); assert!(!instance_events.iter().any(|event| event_kind(event) == "node.delta")); let paging_cursor = event_cursor(&endpoints, "flows"); put_flow(&endpoints, "page-a", "use mod.flow.*\nFlow{label: \"a\"}\n"); put_flow(&endpoints, "page-b", "use mod.flow.*\nFlow{label: \"b\"}\n"); let first = json_request( endpoints.control, "GET", &format!("/v1/events?cursor={paging_cursor}&topic=flows&limit=1"), Some(&endpoints.token), "", ); assert_eq!(first.status, 200, "{}", first.body()); let first = EventsResponse::deserialize_json(&first.body()).unwrap(); assert_eq!(first.events.len(), 1); let second = json_request( endpoints.control, "GET", &format!("/v1/events?cursor={}&topic=flows&limit=1", first.cursor), Some(&endpoints.token), "", ); assert_eq!(second.status, 200, "{}", second.body()); let second = EventsResponse::deserialize_json(&second.body()).unwrap(); assert_eq!(second.events.len(), 1); assert!(event_seq(&first.events[0]) < event_seq(&second.events[0])); let mut burst = String::from("use mod.flow.*\n"); for index in 0..256 { burst.push_str(&format!("let n{index} = Input{{}}\n")); burst.push_str(&format!( "let o{index} = Output{{value: n{index}.text()}}\n" )); } burst.push_str("Flow{concurrency: 32 "); for index in 0..256 { burst.push_str(&format!("n{index}, o{index}, ")); } burst.push_str("}\n"); put_flow(&endpoints, "burst", &burst); let burst_definition = json_request( endpoints.control, "GET", "/v1/flows/burst", Some(&endpoints.token), "", ); let burst_definition = FlowResponse::deserialize_json(&burst_definition.body()).unwrap(); assert_eq!(burst_definition.graph.unwrap().nodes.len(), 512); let burst_instance = create_instance(&endpoints, "burst", false); let stale_cursor = event_cursor(&endpoints, "run"); // Each run contributes 516 global journal entries: run start/finish and // 256 node.done events are each projected to both `run` and `instance`. // Nine runs therefore put this subscriber 4,644 entries behind. for _ in 0..9 { let started = start_run(&endpoints, &burst_instance, None); assert_eq!(started.queued, 0); } let deadline = Instant::now() + Duration::from_secs(15); loop { let response = json_request( endpoints.control, "GET", &format!("/v1/events?cursor={stale_cursor}&topic=run"), Some(&endpoints.token), "", ); assert_eq!(response.status, 200, "{}", response.body()); let page = EventsResponse::deserialize_json(&response.body()).unwrap(); if page.gap { break; } assert!(Instant::now() < deadline, "subscriber never received gap=true"); std::thread::sleep(Duration::from_millis(25)); } server.shutdown(); } #[test] fn autostart_creates_one_auto_instance_and_janitor_keeps_it() { let _socket_test = socket_test(); let root = TempRoot::new("autostart"); fs::create_dir_all(root.0.join("flows")).unwrap(); fs::write( root.0.join("flows/auto.splash"), "use mod.flow.*\nlet prompt = Input{default: \"auto\"}\nFlow{autostart: true prompt}\n", ) .unwrap(); let mut config = base_config( &root.0, seams(FakeChat::done("unused"), FakeGen::done(), FakeHttp::json(200, "{}")), ); config.instance_ttl_secs = 1; let server = FlowServer::start(config).unwrap(); let endpoints = server.endpoints(); let list = || { let response = json_request( endpoints.control, "GET", "/v1/instances?flow=auto", Some(&endpoints.token), "", ); assert_eq!(response.status, 200, "{}", response.body()); Vec::::deserialize_json(&response.body()).unwrap() }; let rows = list(); assert_eq!(rows.len(), 1, "{rows:#?}"); assert_eq!(rows[0].owner, "auto"); let id = rows[0].instance.clone(); std::thread::sleep(Duration::from_millis(2_500)); let rows = list(); assert_eq!(rows.len(), 1, "autostart instance expired: {rows:#?}"); assert_eq!(rows[0].instance, id); assert_eq!(rows[0].owner, "auto"); server.shutdown(); } #[test] fn shutdown_cancels_inflight_within_five_seconds_and_runs_do_not_persist() { let _socket_test = socket_test(); let root = TempRoot::new("shutdown"); let server = start( &root.0, seams( FakeChat { pending: true, ..FakeChat::done("unused") }, FakeGen::done(), FakeHttp::json(200, "{}"), ), ); let endpoints = server.endpoints(); put_flow(&endpoints, "stalled", CHAT_FLOW_ONE); let instance = create_instance(&endpoints, "stalled", false); let cursor = event_cursor(&endpoints, "run"); let run = start_run(&endpoints, &instance, None); poll_events_until( &endpoints, "run", cursor, Duration::from_secs(5), |event| { event_kind(event) == "node.started" && event_str(event, "run_id") == Some(&run.run_id) }, ); let started = Instant::now(); server.shutdown(); assert!(started.elapsed() < Duration::from_secs(5), "shutdown took {:?}", started.elapsed()); let restarted = start( &root.0, seams(FakeChat::done("unused"), FakeGen::done(), FakeHttp::json(200, "{}")), ); let endpoints = restarted.endpoints(); let rows = json_request( endpoints.control, "GET", "/v1/runs", Some(&endpoints.token), "", ); assert_eq!(rows.status, 200, "{}", rows.body()); let rows = Vec::::deserialize_json(&rows.body()).unwrap(); assert!(rows.is_empty(), "run rows survived restart: {rows:#?}"); restarted.shutdown(); }