makepad/libs/flow/tests/battery_server.rs

1008 lines
33 KiB
Rust

#![cfg(not(target_arch = "wasm32"))]
use makepad_flow::host::{FlowServer, FlowServerConfig, ServerError};
use makepad_flow::{
EvalErrorResponse, EventsResponse, FlowResponse, FlowSummary, HealthResponse, NodesResponse,
PutSourceRequest, RevertRequest,
};
use makepad_micro_serde::{DeJson, JsonValue, SerJson};
use std::fs;
use std::io::{self, Read, Write};
use std::net::{Shutdown, SocketAddr, TcpStream};
use std::path::{Path, PathBuf};
use std::process::{Child, Command, ExitStatus, Stdio};
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
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-{}-{}-{nonce}",
std::process::id(),
label
));
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,
body: String,
}
fn parse_response(bytes: Vec<u8>) -> io::Result<HttpResponse> {
let text = String::from_utf8(bytes)
.map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error))?;
let (head, body) = text
.split_once("\r\n\r\n")
.ok_or_else(|| io::Error::new(io::ErrorKind::InvalidData, "response has no head"))?;
let status = head
.lines()
.next()
.and_then(|line| line.split_whitespace().nth(1))
.and_then(|status| status.parse().ok())
.ok_or_else(|| io::Error::new(io::ErrorKind::InvalidData, "response has no status"))?;
Ok(HttpResponse {
status,
body: body.to_string(),
})
}
fn request_with_authorization(
address: SocketAddr,
method: &str,
target: &str,
authorization: Option<&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(5)))
.unwrap();
let authorization = authorization
.map(|value| format!("Authorization: {value}\r\n"))
.unwrap_or_default();
write!(
stream,
"{method} {target} HTTP/1.1\r\nHost: localhost\r\n{authorization}Content-Length: {}\r\nConnection: close\r\n\r\n",
body.len()
)
.unwrap();
stream.write_all(body).unwrap();
stream.flush().unwrap();
let mut bytes = Vec::new();
stream.read_to_end(&mut bytes).unwrap();
parse_response(bytes).unwrap()
}
fn request(
address: SocketAddr,
method: &str,
target: &str,
token: Option<&str>,
body: &str,
) -> HttpResponse {
let authorization = token.map(|token| format!("Bearer {token}"));
request_with_authorization(
address,
method,
target,
authorization.as_deref(),
body.as_bytes(),
)
}
fn start(root: &Path) -> FlowServer {
let mut config = FlowServerConfig::new(root.to_path_buf());
config.watch_interval_ms = 25;
config.log = Box::new(|_| {});
FlowServer::start(config).unwrap()
}
fn source_request(source: &str) -> String {
PutSourceRequest {
source: source.to_string(),
}
.serialize_json()
}
fn cursor(address: SocketAddr, token: &str) -> String {
let response = request(address, "GET", "/v1/events", Some(token), "");
assert_eq!(response.status, 200, "{}", response.body);
EventsResponse::deserialize_json(&response.body)
.unwrap()
.cursor
}
fn poll(address: SocketAddr, token: &str, cursor: &str, suffix: &str) -> EventsResponse {
let response = request(
address,
"GET",
&format!("/v1/events?cursor={cursor}{suffix}"),
Some(token),
"",
);
assert_eq!(response.status, 200, "{}", response.body);
EventsResponse::deserialize_json(&response.body).unwrap()
}
fn event_field<'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 assert_eval_error(response: &HttpResponse) {
assert_eq!(response.status, 422, "{}", response.body);
let error = EvalErrorResponse::deserialize_json(&response.body)
.unwrap()
.error;
assert!(error.line > 0, "{error:?}");
assert!(error.col > 0, "{error:?}");
assert!(!error.message.is_empty(), "{error:?}");
}
const VALID_SOURCE_ONE: &str = "use mod.flow.*\nlet prompt = Input{type: @text default: \"one\"}\nlet output = Output{type: @text value: prompt.text()}\nFlow{label: \"one\" prompt, output}\n";
const VALID_SOURCE_TWO: &str = "use mod.flow.*\nlet prompt = Input{type: @text default: \"two\"}\nlet output = Output{type: @text value: prompt.text()}\nFlow{label: \"two\" prompt, output}\n";
#[test]
#[ignore = "BLOCKED: makepad-bounded-http removes trailing header whitespace before host routing; fixing exact bearer bytes is outside this lane"]
fn health_is_open_and_all_other_routes_require_an_exact_bearer() {
let root = TempRoot::new("auth");
let server = start(&root.0);
let endpoints = server.endpoints();
let health = request(endpoints.control, "GET", "/v1/health", None, "");
assert_eq!(health.status, 200, "{}", health.body);
let health = HealthResponse::deserialize_json(&health.body).unwrap();
assert_eq!(health.service, "makepad-flow");
let routes = [
(endpoints.control, "POST", "/v1/health"),
(endpoints.control, "GET", "/v1/nodes"),
(endpoints.control, "GET", "/v1/flows"),
(endpoints.control, "GET", "/v1/flows/auth-flow"),
(endpoints.control, "PUT", "/v1/flows/auth-flow"),
(endpoints.control, "PUT", "/v1/flows/auth-flow/graph"),
(endpoints.control, "POST", "/v1/flows/auth-flow/revert"),
(endpoints.control, "POST", "/v1/flows/auth-flow/instances"),
(endpoints.control, "DELETE", "/v1/flows/auth-flow"),
(endpoints.control, "GET", "/v1/events"),
(endpoints.control, "GET", "/v1/instances"),
(endpoints.control, "GET", "/v1/instances?flow=auth-flow"),
(endpoints.control, "GET", "/v1/instances?waiting=1"),
(endpoints.control, "GET", "/v1/instances/instance-1"),
(endpoints.control, "PUT", "/v1/instances/instance-1/inputs"),
(endpoints.control, "POST", "/v1/instances/instance-1/runs"),
(endpoints.control, "DELETE", "/v1/instances/instance-1"),
(endpoints.control, "GET", "/v1/runs"),
(endpoints.control, "GET", "/v1/runs?instance=instance-1"),
(endpoints.control, "GET", "/v1/runs/run-1"),
(endpoints.control, "POST", "/v1/runs/run-1/cancel"),
(endpoints.control, "GET", "/v1/services"),
(endpoints.control, "POST", "/v1/services/service-1/call"),
(endpoints.control, "POST", "/v1/services/service-1/subscribe"),
(
endpoints.control,
"DELETE",
"/v1/services/service-1/subscribe/sub-1",
),
(endpoints.data, "GET", "/v1/health"),
(endpoints.data, "GET", "/v1/values/deadbeef"),
];
let trailing = format!("Bearer {} ", endpoints.token);
let mut padded_statuses = Vec::new();
for (address, method, target) in routes {
let missing = request_with_authorization(address, method, target, None, b"");
assert_eq!(missing.status, 401, "{method} {target}: {missing:?}");
let wrong = request_with_authorization(
address,
method,
target,
Some("Bearer mpft_wrong"),
b"",
);
assert_eq!(wrong.status, 401, "{method} {target}: {wrong:?}");
let padded = request_with_authorization(address, method, target, Some(&trailing), b"");
padded_statuses.push((method, target, padded));
}
for (method, target, padded) in padded_statuses {
assert_eq!(padded.status, 401, "{method} {target}: {padded:?}");
}
server.shutdown();
}
#[test]
fn nodes_lists_image_range_and_a_documented_recipe_type() {
let root = TempRoot::new("nodes");
let server = start(&root.0);
let endpoints = server.endpoints();
let response = request(
endpoints.control,
"GET",
"/v1/nodes",
Some(&endpoints.token),
"",
);
assert_eq!(response.status, 200, "{}", response.body);
let catalog = NodesResponse::deserialize_json(&response.body).unwrap();
let image = catalog
.types
.iter()
.find(|node| node.type_name == "Image")
.expect("Image missing from node catalog");
let width = image
.params
.iter()
.find(|param| param.name == "width")
.expect("Image.width missing from node catalog");
let range = width.range.as_ref().expect("Image.width range missing");
assert_eq!((range.min, range.max, range.step), (256.0, 2048.0, Some(16.0)));
let recipe = catalog
.types
.iter()
.find(|node| matches!(node.type_name.as_str(), "Mesh" | "Video"))
.expect("Mesh and Video are both missing from node catalog");
assert!(
recipe
.params
.iter()
.any(|param| !param.name.is_empty() && !param.doc.is_empty()),
"{} has no documented parameter",
recipe.type_name
);
server.shutdown();
}
#[test]
fn every_recipe_template_puts_and_gets_with_graph_and_run_tool() {
let root = TempRoot::new("templates");
let server = start(&root.0);
let endpoints = server.endpoints();
let template_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("recipes/templates");
let mut templates: Vec<_> = fs::read_dir(&template_dir)
.unwrap()
.map(|entry| entry.unwrap().path())
.filter(|path| path.extension().is_some_and(|extension| extension == "splash"))
.collect();
templates.sort();
assert!(!templates.is_empty());
for path in templates {
let name = path.file_stem().unwrap().to_str().unwrap();
let source = fs::read_to_string(&path).unwrap();
let put = request(
endpoints.control,
"PUT",
&format!("/v1/flows/{name}"),
Some(&endpoints.token),
&source_request(&source),
);
assert_eq!(put.status, 200, "{}: {}", path.display(), put.body);
let rows = request(
endpoints.control,
"GET",
"/v1/flows",
Some(&endpoints.token),
"",
);
let rows = Vec::<FlowSummary>::deserialize_json(&rows.body).unwrap();
let row = rows.iter().find(|row| row.name == name).unwrap();
assert_eq!(row.state, "ok", "{}", path.display());
let get = request(
endpoints.control,
"GET",
&format!("/v1/flows/{name}"),
Some(&endpoints.token),
"",
);
assert_eq!(get.status, 200, "{}: {}", path.display(), get.body);
let definition = FlowResponse::deserialize_json(&get.body).unwrap();
assert_eq!(definition.source, source, "{}", path.display());
assert!(
definition.graph.as_ref().unwrap().nodes.len() >= 2,
"{}",
path.display()
);
assert!(
definition.tools.tools.iter().any(|tool| tool.name == "run"),
"{} has no run tool",
path.display()
);
}
server.shutdown();
}
#[test]
fn broken_sources_return_located_422_and_keep_the_last_good_graph() {
let root = TempRoot::new("broken");
let server = start(&root.0);
let endpoints = server.endpoints();
let put = request(
endpoints.control,
"PUT",
"/v1/flows/broken",
Some(&endpoints.token),
&source_request(VALID_SOURCE_ONE),
);
assert_eq!(put.status, 200, "{}", put.body);
let broken_sources = [
("unbalanced brace", "use mod.flow.*\nFlow{\n"),
(
"missing node",
"use mod.flow.*\nlet missing = Text{}\nlet output = Output{value: missing.text()}\nFlow{output}\n",
),
(
"type mismatch",
"use mod.flow.*\nlet pixels = Input{type: @image}\nlet image = Image{prompt: pixels.image()}\nFlow{pixels, image}\n",
),
];
for (case, source) in broken_sources {
let broken = request(
endpoints.control,
"PUT",
"/v1/flows/broken",
Some(&endpoints.token),
&source_request(source),
);
assert_eval_error(&broken);
let get = request(
endpoints.control,
"GET",
"/v1/flows/broken",
Some(&endpoints.token),
"",
);
let definition = FlowResponse::deserialize_json(&get.body).unwrap();
assert_eq!(definition.source, source, "{case}");
assert_eq!(definition.revision, 1, "{case}");
assert_eq!(definition.graph.as_ref().unwrap().nodes.len(), 2, "{case}");
assert!(definition.error.is_some(), "{case}");
let rows = request(
endpoints.control,
"GET",
"/v1/flows",
Some(&endpoints.token),
"",
);
let rows = Vec::<FlowSummary>::deserialize_json(&rows.body).unwrap();
assert_eq!(rows.iter().find(|row| row.name == "broken").unwrap().state, "error");
}
server.shutdown();
}
#[test]
fn revert_restores_revision_one_and_missing_revision_is_client_error() {
let root = TempRoot::new("revert");
let server = start(&root.0);
let endpoints = server.endpoints();
for source in [VALID_SOURCE_ONE, VALID_SOURCE_TWO] {
let put = request(
endpoints.control,
"PUT",
"/v1/flows/revert",
Some(&endpoints.token),
&source_request(source),
);
assert_eq!(put.status, 200, "{}", put.body);
}
let reverted = request(
endpoints.control,
"POST",
"/v1/flows/revert/revert",
Some(&endpoints.token),
&RevertRequest { revision: 1 }.serialize_json(),
);
assert_eq!(reverted.status, 200, "{}", reverted.body);
let get = request(
endpoints.control,
"GET",
"/v1/flows/revert",
Some(&endpoints.token),
"",
);
let definition = FlowResponse::deserialize_json(&get.body).unwrap();
assert_eq!(definition.source, VALID_SOURCE_ONE);
assert_eq!(definition.revision, 3);
let missing = request(
endpoints.control,
"POST",
"/v1/flows/revert/revert",
Some(&endpoints.token),
&RevertRequest { revision: 999 }.serialize_json(),
);
assert!((400..500).contains(&missing.status), "{missing:?}");
server.shutdown();
}
#[test]
fn delete_returns_204_then_get_and_second_delete_return_404() {
let root = TempRoot::new("delete");
let server = start(&root.0);
let endpoints = server.endpoints();
let put = request(
endpoints.control,
"PUT",
"/v1/flows/delete-me",
Some(&endpoints.token),
&source_request(VALID_SOURCE_ONE),
);
assert_eq!(put.status, 200, "{}", put.body);
let delete = request(
endpoints.control,
"DELETE",
"/v1/flows/delete-me",
Some(&endpoints.token),
"",
);
let get = request(
endpoints.control,
"GET",
"/v1/flows/delete-me",
Some(&endpoints.token),
"",
);
assert_eq!(get.status, 404, "{get:?}");
let again = request(
endpoints.control,
"DELETE",
"/v1/flows/delete-me",
Some(&endpoints.token),
"",
);
assert_eq!(again.status, 404, "{again:?}");
assert_eq!(delete.status, 204, "{delete:?}");
server.shutdown();
}
#[test]
fn invalid_flow_names_return_400_except_extra_path_segments_return_404() {
let root = TempRoot::new("names");
let server = start(&root.0);
let endpoints = server.endpoints();
let names = ["A".to_string(), "a".repeat(65), "..".to_string(), "a/b".to_string()];
for name in names {
let response = request(
endpoints.control,
"PUT",
&format!("/v1/flows/{name}"),
Some(&endpoints.token),
&source_request(VALID_SOURCE_ONE),
);
let expected = if name == "a/b" { 404 } else { 400 };
assert_eq!(response.status, expected, "{name}: {response:?}");
}
server.shutdown();
}
#[test]
fn oversized_and_incomplete_put_bodies_are_bounded_without_starving_health() {
let root = TempRoot::new("body-bounds");
let server = start(&root.0);
let endpoints = server.endpoints();
let oversized_body = vec![b'x'; 1024 * 1024 + 1];
let authorization = format!("Bearer {}", endpoints.token);
let oversized = request_with_authorization(
endpoints.control,
"PUT",
"/v1/flows/large",
Some(&authorization),
&oversized_body,
);
assert!(matches!(oversized.status, 400 | 413), "{oversized:?}");
let oversized_source = format!(
"use mod.flow.*\nlet llm = Llm{{system: \"{}\"}}\nFlow{{llm}}\n",
"x".repeat(193 * 1024)
);
let oversized_source = request(
endpoints.control,
"PUT",
"/v1/flows/large-source",
Some(&endpoints.token),
&source_request(&oversized_source),
);
assert_eval_error(&oversized_source);
let error = EvalErrorResponse::deserialize_json(&oversized_source.body)
.unwrap()
.error;
assert_eq!((error.line, error.col), (1, 1));
assert!(error.message.contains("192 KiB"), "{error:?}");
let mut incomplete = TcpStream::connect(endpoints.control).unwrap();
incomplete
.set_read_timeout(Some(Duration::from_secs(35)))
.unwrap();
write!(
incomplete,
"PUT /v1/flows/incomplete HTTP/1.1\r\nHost: localhost\r\nAuthorization: Bearer {}\r\nContent-Length: 100\r\nConnection: close\r\n\r\n{{",
endpoints.token
)
.unwrap();
incomplete.flush().unwrap();
let started = Instant::now();
let health_started = Instant::now();
let health = request(endpoints.control, "GET", "/v1/health", None, "");
assert_eq!(health.status, 200, "{health:?}");
assert!(health_started.elapsed() < Duration::from_secs(2));
let mut bytes = Vec::new();
incomplete.read_to_end(&mut bytes).unwrap();
let elapsed = started.elapsed();
let timeout = parse_response(bytes).unwrap();
assert_eq!(timeout.status, 408, "{timeout:?}");
assert!(elapsed >= Duration::from_secs(25), "returned too early: {elapsed:?}");
assert!(elapsed < Duration::from_secs(32), "body timeout hung: {elapsed:?}");
server.shutdown();
}
#[test]
fn events_poll_cursor_limit_topic_wait_clamp_and_restart_gap() {
let root = TempRoot::new("events");
let server = start(&root.0);
let endpoints = server.endpoints();
let empty = request(
endpoints.control,
"GET",
"/v1/events?wait=100",
Some(&endpoints.token),
"",
);
assert_eq!(empty.status, 200, "{}", empty.body);
let empty = EventsResponse::deserialize_json(&empty.body).unwrap();
assert!(empty.events.is_empty());
assert!(!empty.cursor.is_empty());
assert!(!empty.gap);
let mut next = empty.cursor;
let mut last_seq = 0;
for index in 0..3 {
let put = request(
endpoints.control,
"PUT",
&format!("/v1/flows/event-{index}"),
Some(&endpoints.token),
&source_request(if index % 2 == 0 {
VALID_SOURCE_ONE
} else {
VALID_SOURCE_TWO
}),
);
assert_eq!(put.status, 200, "{}", put.body);
let page = poll(endpoints.control, &endpoints.token, &next, "&topic=flows");
assert_eq!(page.events.len(), 1, "{:?}", page.events);
assert_eq!(event_field(&page.events[0], "kind"), Some("flow.changed"));
assert_eq!(event_field(&page.events[0], "topic"), Some("flows"));
let seq = event_seq(&page.events[0]);
assert!(seq > last_seq);
last_seq = seq;
next = page.cursor;
}
let page_start = next.clone();
for name in ["page-a", "page-b"] {
let put = request(
endpoints.control,
"PUT",
&format!("/v1/flows/{name}"),
Some(&endpoints.token),
&source_request(VALID_SOURCE_ONE),
);
assert_eq!(put.status, 200, "{}", put.body);
}
let first = poll(
endpoints.control,
&endpoints.token,
&page_start,
"&limit=1&topic=flows",
);
assert_eq!(first.events.len(), 1);
let second = poll(
endpoints.control,
&endpoints.token,
&first.cursor,
"&limit=1&topic=flows",
);
assert_eq!(second.events.len(), 1);
assert!(event_seq(&second.events[0]) > event_seq(&first.events[0]));
assert!(
first
.events
.iter()
.chain(&second.events)
.all(|event| event_field(event, "topic") == Some("flows"))
);
next = second.cursor;
let filter_start = next.clone();
let put = request(
endpoints.control,
"PUT",
"/v1/flows/filter",
Some(&endpoints.token),
&source_request(VALID_SOURCE_ONE),
);
assert_eq!(put.status, 200, "{}", put.body);
let excluded = poll(
endpoints.control,
&endpoints.token,
&filter_start,
"&topic=run",
);
assert!(excluded.events.is_empty());
let included = poll(
endpoints.control,
&endpoints.token,
&filter_start,
"&topic=flows",
);
assert_eq!(included.events.len(), 1);
assert_eq!(event_field(&included.events[0], "topic"), Some("flows"));
next = included.cursor;
let malformed = request(
endpoints.control,
"GET",
"/v1/events?cursor=not-a-cursor",
Some(&endpoints.token),
"",
);
assert_eq!(malformed.status, 400, "{malformed:?}");
let wait_started = Instant::now();
let clamped = poll(
endpoints.control,
&endpoints.token,
&next,
"&wait=99999",
);
assert!(!clamped.gap);
assert!(
wait_started.elapsed() < Duration::from_secs(32),
"wait clamp exceeded 31 seconds: {:?}",
wait_started.elapsed()
);
let old_cursor = clamped.cursor;
server.shutdown();
let restarted = start(&root.0);
let endpoints = restarted.endpoints();
let restarted_page = poll(endpoints.control, &endpoints.token, &old_cursor, "");
assert!(restarted_page.gap);
restarted.shutdown();
}
#[test]
fn watcher_emits_changed_changed_removed_and_ignores_bad_names() {
let root = TempRoot::new("watcher");
let server = start(&root.0);
let endpoints = server.endpoints();
let mut next = cursor(endpoints.control, &endpoints.token);
let path = root.0.join("flows/w.splash");
let changed_started = Instant::now();
fs::write(&path, "use mod.flow.*\nFlow{label: \"one\"}\n").unwrap();
let first = poll(
endpoints.control,
&endpoints.token,
&next,
"&wait=1000&topic=flows",
);
assert_eq!(first.events.len(), 1, "{:?}", first.events);
assert_eq!(event_field(&first.events[0], "kind"), Some("flow.changed"));
assert_eq!(event_field(&first.events[0], "name"), Some("w"));
assert!(changed_started.elapsed() < Duration::from_secs(1));
next = first.cursor;
let edited_started = Instant::now();
fs::write(
&path,
"use mod.flow.*\nFlow{label: \"second and longer\"}\n",
)
.unwrap();
let second = poll(
endpoints.control,
&endpoints.token,
&next,
"&wait=1000&topic=flows",
);
assert_eq!(second.events.len(), 1, "{:?}", second.events);
assert_eq!(event_field(&second.events[0], "kind"), Some("flow.changed"));
assert_eq!(event_field(&second.events[0], "name"), Some("w"));
assert!(edited_started.elapsed() < Duration::from_secs(1));
next = second.cursor;
let removed_started = Instant::now();
fs::remove_file(&path).unwrap();
let removed = poll(
endpoints.control,
&endpoints.token,
&next,
"&wait=1000&topic=flows",
);
assert_eq!(removed.events.len(), 1, "{:?}", removed.events);
assert_eq!(event_field(&removed.events[0], "kind"), Some("flow.removed"));
assert_eq!(event_field(&removed.events[0], "name"), Some("w"));
assert!(removed_started.elapsed() < Duration::from_secs(1));
next = removed.cursor;
fs::write(
root.0.join("flows/W.splash"),
"use mod.flow.*\nFlow{label: \"ignored\"}\n",
)
.unwrap();
let ignored = poll(
endpoints.control,
&endpoints.token,
&next,
"&wait=300&topic=flows",
);
assert!(ignored.events.is_empty(), "{:?}", ignored.events);
let rows = request(
endpoints.control,
"GET",
"/v1/flows",
Some(&endpoints.token),
"",
);
let rows = Vec::<FlowSummary>::deserialize_json(&rows.body).unwrap();
assert!(rows.iter().all(|row| row.name != "W"));
server.shutdown();
}
fn read_keep_alive_response(stream: &mut TcpStream) -> io::Result<HttpResponse> {
let mut bytes = Vec::new();
let mut byte = [0u8; 1];
while !bytes.ends_with(b"\r\n\r\n") {
stream.read_exact(&mut byte)?;
bytes.push(byte[0]);
if bytes.len() > 32 * 1024 {
return Err(io::Error::new(
io::ErrorKind::InvalidData,
"response head too large",
));
}
}
let head = String::from_utf8(bytes.clone())
.map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error))?;
let content_length = head
.lines()
.find_map(|line| {
let (name, value) = line.split_once(':')?;
name.eq_ignore_ascii_case("content-length")
.then(|| value.trim().parse::<usize>().ok())
.flatten()
})
.unwrap_or(0);
let mut body = vec![0; content_length];
stream.read_exact(&mut body)?;
bytes.extend_from_slice(&body);
parse_response(bytes)
}
fn open_held_health(address: SocketAddr) -> io::Result<(TcpStream, HttpResponse)> {
let mut stream = TcpStream::connect_timeout(&address, Duration::from_secs(1))?;
stream.set_read_timeout(Some(Duration::from_secs(1)))?;
stream.set_write_timeout(Some(Duration::from_secs(1)))?;
stream.write_all(
b"GET /v1/health HTTP/1.1\r\nHost: localhost\r\nContent-Length: 0\r\nConnection: keep-alive\r\n\r\n",
)?;
stream.flush()?;
let response = read_keep_alive_response(&mut stream)?;
Ok((stream, response))
}
#[test]
fn connection_capacity_refuses_65th_and_recovers_after_close() {
let root = TempRoot::new("capacity");
let server = start(&root.0);
let endpoints = server.endpoints();
let mut held = Vec::new();
for index in 0..70 {
let started = Instant::now();
match open_held_health(endpoints.control) {
Ok((stream, response)) if index < 64 => {
assert_eq!(response.status, 200, "connection {index}: {response:?}");
held.push(stream);
}
Ok((_stream, response)) => {
assert_eq!(response.status, 503, "connection {index}: {response:?}");
}
Err(error) if index >= 64 => {
assert!(
matches!(
error.kind(),
io::ErrorKind::ConnectionRefused
| io::ErrorKind::ConnectionReset
| io::ErrorKind::ConnectionAborted
| io::ErrorKind::UnexpectedEof
),
"connection {index}: {error}"
);
}
Err(error) => panic!("connection {index}: {error}"),
}
assert!(started.elapsed() < Duration::from_secs(2));
}
for stream in held {
let _ = stream.shutdown(Shutdown::Both);
}
let deadline = Instant::now() + Duration::from_secs(2);
loop {
match open_held_health(endpoints.control) {
Ok((_stream, response)) if response.status == 200 => break,
Ok((_stream, response)) if response.status == 503 => {}
Err(_) => {}
other => panic!("unexpected response after freeing capacity: {other:?}"),
}
assert!(Instant::now() < deadline, "capacity did not recover");
std::thread::sleep(Duration::from_millis(20));
}
server.shutdown();
}
fn lowercase_hex(text: &str, digits: usize) -> bool {
text.len() == digits
&& text
.bytes()
.all(|byte| byte.is_ascii_hexdigit() && !byte.is_ascii_uppercase())
}
#[test]
fn metadata_files_lock_stability_permissions_and_listen_rewrite() {
let root = TempRoot::new("files");
let server = start(&root.0);
let first = server.endpoints();
let server_id = fs::read_to_string(root.0.join("server-id")).unwrap();
assert!(lowercase_hex(server_id.trim(), 32), "{server_id:?}");
let token = fs::read_to_string(root.0.join("token")).unwrap();
let token = token.trim();
assert!(token.starts_with("mpft_"));
assert!(lowercase_hex(&token[5..], 64), "{token:?}");
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
assert_eq!(
fs::metadata(root.0.join("token"))
.unwrap()
.permissions()
.mode()
& 0o777,
0o600
);
}
let mut locked_config = FlowServerConfig::new(root.0.clone());
locked_config.log = Box::new(|_| {});
assert!(matches!(
FlowServer::start(locked_config),
Err(ServerError::Locked)
));
server.shutdown();
fs::write(root.0.join("listen"), "stale\n").unwrap();
let restarted = start(&root.0);
let second = restarted.endpoints();
assert_eq!(second.server_id, first.server_id);
assert_eq!(fs::read_to_string(root.0.join("server-id")).unwrap(), server_id);
let listen = fs::read_to_string(root.0.join("listen")).unwrap();
assert_ne!(listen, "stale\n");
assert_eq!(
listen.trim(),
format!(
"{}:{}:{}",
second.control.ip(),
second.control.port(),
second.data.port()
)
);
restarted.shutdown();
}
struct ChildGuard(Child);
impl Drop for ChildGuard {
fn drop(&mut self) {
if self.0.try_wait().ok().flatten().is_none() {
let _ = self.0.kill();
let _ = self.0.wait();
}
}
}
fn wait_for_binary_start(child: &mut Child, listen: &Path) {
let deadline = Instant::now() + Duration::from_secs(2);
loop {
if fs::read_to_string(listen)
.is_ok_and(|text| text.trim().split(':').count() == 3)
{
return;
}
if let Some(status) = child.try_wait().unwrap() {
panic!("flow-server exited before writing listen: {status}");
}
assert!(Instant::now() < deadline, "flow-server did not start in 2 seconds");
std::thread::sleep(Duration::from_millis(20));
}
}
#[cfg(unix)]
fn send_sigterm(child: &Child) {
unsafe extern "C" {
fn kill(pid: i32, signal: i32) -> i32;
}
const SIGTERM: i32 = 15;
assert_eq!(unsafe { kill(child.id() as i32, SIGTERM) }, 0);
}
fn wait_for_exit(child: &mut Child, timeout: Duration) -> ExitStatus {
let deadline = Instant::now() + timeout;
loop {
if let Some(status) = child.try_wait().unwrap() {
return status;
}
assert!(Instant::now() < deadline, "process did not exit in {timeout:?}");
std::thread::sleep(Duration::from_millis(20));
}
}
#[test]
#[cfg(unix)]
#[ignore = "requires `cargo build --release -p makepad-flow-server`; run with `cargo test -p makepad-flow --release --test battery_server -- --ignored binary_sigterm_exits_zero_releases_lock_and_allows_restart`"]
fn binary_sigterm_exits_zero_releases_lock_and_allows_restart() {
let root = TempRoot::new("binary-sigterm");
let binary = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("../..")
.join("target/release/makepad-flow-server");
assert!(binary.is_file(), "first run cargo build --release -p makepad-flow-server");
for _ in 0..2 {
let listen = root.0.join("listen");
let _ = fs::remove_file(&listen);
let child = Command::new(&binary)
.arg("--root")
.arg(&root.0)
.arg("--bind")
.arg("127.0.0.1")
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn()
.unwrap();
let mut child = ChildGuard(child);
wait_for_binary_start(&mut child.0, &listen);
send_sigterm(&child.0);
let status = wait_for_exit(&mut child.0, Duration::from_secs(2));
assert!(status.success(), "flow-server SIGTERM status: {status}");
}
}