Squashed from work: - asset-ai: FastH3 4-step fast video backend; clip keyframes on the wire - asset-ui: loop video chains — text→image→video that ends where it began - h3: safetensors -> pruned-Q4_K GGUF quantizer for the 24GB DiT tiers - h3_quant_gguf verify: row-error gates calibrated to the measured Q4_K floor - asset-ai realtime: the feedback loop — the source anchors, the drifted frame inits - asset-ai realtime: a feedback loop survives a resize and travels by default - asset-ai realtime: the feedback loop frees itself from the feed handshake and pauses for its listener - asset-ai realtime: the outbound encode leaves the loop's critical path - asset-ai ocr: the ocr domain — Chandra 2 at page resolution, and the tower goes planner-owned - llm slots: a lane can hold an image span — embedding prefill and a rope cursor of its own - vision tower on CUDA: the encode leg gets its two missing kernels - llm/ocr: one M-RoPE grid encoder for both image paths, and a livelock made an error - vision tower on CUDA: the f16 GEMM keeps the precision it was throwing away - live: a feed that moves box takes its trip with it — one seed image - vision tower on CUDA: the tiled attention becomes bit-exact, and tensor cores go - llm prefill on CUDA: the MMA attention kernel gets the tile a 4-to-1 model needs - asset-ai ocr: the CUDA encode lane joins the integration — vision-parity sits beside run's three arms, and the kernels - Merge branch 'ocr-perf-integration' into work - asset-ai: the live anchor can follow the trip, and text leaves the 5090 - asset-ai: the camera moves the world, and the world starts still - asset-import: the EA strategy classics, in the one 2D contract - rtsmap: one seeded generator for tiled strategy maps - asset-ui: one card for the strategy classics, with a pack dropdown - asset-ai: music3 reference-audio path, ocr/h3 backends, registry - asset: mp4 sample index for range-streaming, chat tools, import profiles - cnc: tiberium is twelve growth frames, not twelve empty variants - platform: native file and save dialogs, in-house on all three desktops - chat: the scan holds out for a lane home - chat: a full home queues you — take the free lane - chat: the preload has a percentage, and the boundless cap stops showing - llm cuda: the 32x2 attention tile — even GQA ratios stay on MMA - sa3 gets a bake path: the sfx model's tables precomputed by a diffusion-side bin - sqlite_query: anti-join regression test - td import: HARV's second frame block is its harvesting cycle, not a turret - asset-ui: sprite enhancement runs on the 32B dev DiT — distillation, not the prompt, was the ceiling - ai-hub: makepad-asset-ai becomes makepad-ai-hub at libs/ai/hub, the chat pane becomes makepad-chat-ui, the service bin - asset-ui: test health fixtures grow the realtime field they were born without - ai-hub: one home at ~/.makepad — weights/ run/ cache/ logs/, the service cache migrates from ai_content by a single re - ai-hub: subprocess workers die with the node — process groups everywhere, PDEATHSIG on linux, one KILL_ON_JOB_CLOSE Jo - ai-hub: the hub object — AiHub::in_process, pipes vocabulary, and the local LLM engine generalized out of mpfiles (aic - strict-json: the dependency-free JSON module gets its own crate; asset-client re-exports it so nothing downstream move - ai-hub: the machine layer — node entries, the 0600 machine token, and the residency election that IS the lock (aicore - ai-hub: MPHUB1 — the fabric beacon only dedicated nodes can send (aicore §4) - ai-hub: job leases — work lives only while it is renewed (aicore §8) - asset-creator: the pipeline library is born — specs, the deps gate, and the derived-state law (aicore §9) - ai-hub: RAM residency facts — the CPU-side twin of residency.rs (aicore §3) - ai-hub: ETA placement primitives — relative GPU throughput, the four-term estimate, and an observable breakdown (aicor - ai-hub: leases go live on the wire — origin fields on submit, /job/<id>/keepalive, /bye, and the reaper that cancels w - ai-hub: the chat providers move in — fleet qwen, openai, grok, claude/codex/grok CLIs, the responses driver, and the w - asset-creator: the engine — one pipeline run against the hub, deps-gated, spliced, cancellable, resumable-by-construct - ai-hub: the machine node mode — --machine binds loopback, registers in ~/.makepad/run, and exits on its own once idle - asset-creator: makepad-creator-run — the detached client for runs that must outlive a window (aicore §9) - ai-hub: a native Claude Messages-API provider — API-key or Claude Code OAuth, bounded SSE streaming, injected tools (a - route + converse: off makepad_ai — the Agent seam moves to converse, route's cloud dispatcher rides the hub's Claude p - asset-creator: the preset tables move in — fifteen chain-policy constants shared by every creator app (aicore §9 / P6) - makepad_ai is deleted — every backend is a hub pipe, the agent seam lives with its consumers (aicore §14, decided 2026 - ai-hub: loads hold the machine residency election — set_model_state claims on Loaded and publishes the service port (a - ai-hub: chats run the machine election — route to a serving holder, wait on a loading one, claim and publish when open - ai-hub: pick_for_domain_eta — ETA-ranked placement over the shared hard-filter core (aicore §6 / P4) - asset-creator: the engine picks a provider per stage at dispatch time — a chain's later stages see fresh fleet state ( - ai-hub: the fabric secret gates the service HTTP surface — bearer on everything but /health and the ticketed peer path - vj: DREAM runs execute in the app — pipelines.rs becomes the run it used to watch (aicore §9 / F1) - asset-creator: the runner — generate one thing and put it in the catalog, one implementation for every surface (aicore - chat-ui: the session runs in the app — no broker anywhere on the chat path (aicore P8 / F5) - asset-store: assets.query is a first-class query endpoint — the bounded SQL surface outlives the broker (aicore P8 / F - asset-creator: CreatorTools — the chat tool pack for a store that only stores (aicore §9 / P8) - asset-store: the shrink — the store stores (aicore P7) - importer + asset-server host: the coordination era ends (aicore P7) - store config purge + asset-ui goes fleet-direct; the derive protocol gets its route proof (aicore P7) - client + chat dispatcher: the dead wire comes out (aicore P7/P8) - ai-hub: 0.3.0 — the health version says which era a node runs - ai-hub: the default fleet is 'gen' — apps hear the LAN without env plumbing - ai-hub: the preload note percents the prefill, not the job bar - ai-hub: conversations keep their KV — the wire mirror, the lane identity, the in-turn dynamic context (aicore §7) - ai-hub: an open-think model is thinking from its first token - libs: the zero-warning sweep — stitch casts say what they mean, xatlas keeps upstream's surface quietly - zero-warning sweep, round two — the first full-workspace pass - zero-warning sweep, round three — the model lanes and the deep examples - zero-warning sweep, round four — the last stragglers - zero-warning sweep, round five — vj and chat-ui - zero-warning sweep, round six — three cascades Co-authored-by: Claude <info@makepad.nl>
920 lines
36 KiB
Rust
920 lines
36 KiB
Rust
//! End-to-end test: starts the real service on a localhost socket and drives
|
|
//! it through the `LocalService` provider (which exercises the same
|
|
//! http_client the sandbox will use): health, models, generate with the
|
|
//! testpattern backend, job polling, artifact fetch, queue/reject policy.
|
|
|
|
use makepad_ai_hub::client::{ContentProvider, LocalService};
|
|
use makepad_ai_hub::download::Downloader;
|
|
use makepad_ai_hub::error::AssetAiError;
|
|
use makepad_ai_hub::http_client::{http_fetch, HttpClientRequest};
|
|
use makepad_ai_hub::protocol::{GenerateRequestJson, RealtimeRequestJson, RealtimeResponseJson};
|
|
use makepad_ai_hub::realtime_wire::{self, FrameHeader, FrameKind};
|
|
use makepad_ai_hub::registry::{Domain, Registry};
|
|
use makepad_ai_hub::server::{start_service, ServiceConfig};
|
|
use makepad_live_id::LiveId;
|
|
use makepad_micro_serde::{DeJson, SerJson};
|
|
use makepad_network::plain_web_socket::PlainWebSocket;
|
|
use makepad_network::{HttpMethod, HttpRequest, WebSocketMessage};
|
|
use std::path::PathBuf;
|
|
use std::sync::mpsc::{Receiver, RecvTimeoutError};
|
|
use std::time::{Duration, Instant};
|
|
|
|
fn test_dir(name: &str) -> PathBuf {
|
|
let dir = std::env::temp_dir().join(format!(
|
|
"makepad-asset-ai-e2e-{name}-{}",
|
|
std::process::id()
|
|
));
|
|
let _ = std::fs::remove_dir_all(&dir);
|
|
std::fs::create_dir_all(&dir).unwrap();
|
|
dir
|
|
}
|
|
|
|
fn peer_off() -> makepad_ai_hub::peer_serve::PeerOptions {
|
|
// Tests must not race on process-global env: pin the peer lane off with
|
|
// explicit options unless a test opts in.
|
|
makepad_ai_hub::peer_serve::PeerOptions {
|
|
serve: Some(false),
|
|
sources: Some(Vec::new()),
|
|
..Default::default()
|
|
}
|
|
}
|
|
|
|
fn start_test_service(name: &str) -> LocalService {
|
|
let handle = start_service(ServiceConfig {
|
|
host: "127.0.0.1".to_string(),
|
|
port: 0,
|
|
cache_dir: test_dir(name),
|
|
registry: Registry::embedded().unwrap(),
|
|
downloader: Downloader::new("http://127.0.0.1:1", None).unwrap(),
|
|
peer: peer_off(),
|
|
fleet: makepad_ai_hub::discovery::DEFAULT_FLEET.to_string(),
|
|
})
|
|
.unwrap();
|
|
let provider = LocalService::new(&format!("http://{}", handle.addr));
|
|
// The service outlives the test; keep the singleton lock with it.
|
|
std::mem::forget(handle);
|
|
provider
|
|
}
|
|
|
|
fn poll_until_done(provider: &LocalService, job_id: &str) -> Vec<(String, String)> {
|
|
let deadline = Instant::now() + Duration::from_secs(20);
|
|
loop {
|
|
let status = provider.poll(job_id).unwrap();
|
|
match status.state.as_str() {
|
|
"done" => {
|
|
return status
|
|
.artifacts
|
|
.iter()
|
|
.map(|a| (a.id.clone(), a.content_type.clone()))
|
|
.collect()
|
|
}
|
|
"error" => panic!("job failed: {:?}", status.error),
|
|
_ => {
|
|
assert!(Instant::now() < deadline, "job did not finish in time");
|
|
std::thread::sleep(Duration::from_millis(25));
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
fn poll_until_terminal(provider: &LocalService, job_id: &str) -> String {
|
|
let deadline = Instant::now() + Duration::from_secs(20);
|
|
loop {
|
|
let status = provider.poll(job_id).unwrap();
|
|
if matches!(status.state.as_str(), "done" | "error" | "cancelled") {
|
|
return status.state;
|
|
}
|
|
assert!(Instant::now() < deadline, "job did not finish in time");
|
|
std::thread::sleep(Duration::from_millis(10));
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn health_and_models() {
|
|
let provider = start_test_service("health");
|
|
|
|
let health = provider.health().unwrap();
|
|
assert_eq!(health.service, "makepad-asset-ai");
|
|
assert_eq!(health.version, env!("CARGO_PKG_VERSION"));
|
|
assert!(health.models_loaded.is_empty());
|
|
|
|
let models = provider.list_models().unwrap();
|
|
let testpattern = models.iter().find(|m| m.id == "testpattern").unwrap();
|
|
assert!(testpattern.available);
|
|
assert_eq!(testpattern.state, "ready");
|
|
assert_eq!(testpattern.domain, "image");
|
|
|
|
// flux1-schnell: registered, no cached files -> absent. Availability
|
|
// requires BOTH the compiled `flux` feature AND real FP8 execution
|
|
// capability (a CUDA device): the canonical combined-FP8 tier has no
|
|
// CPU/Metal fallback, so a mac/CI test run with `--features flux` must
|
|
// still list flux unavailable, fail-closed.
|
|
let expect_flux =
|
|
cfg!(feature = "flux") && makepad_ai_hub::backend::backend_provisioned("flux");
|
|
let schnell = models.iter().find(|m| m.id == "flux1-schnell").unwrap();
|
|
assert_eq!(schnell.state, "absent");
|
|
assert_eq!(schnell.available, expect_flux);
|
|
|
|
let dev = models.iter().find(|m| m.id == "flux1-dev").unwrap();
|
|
assert_eq!(dev.available, expect_flux);
|
|
assert!(dev.gated);
|
|
|
|
let trellis = models.iter().find(|m| m.id == "trellis-2").unwrap();
|
|
assert_eq!(trellis.domain, "mesh");
|
|
// Availability tracks the compiled backend: the default (stub) build has
|
|
// no mesh feature, a `--features mesh` test run does.
|
|
assert_eq!(trellis.available, cfg!(feature = "mesh"));
|
|
}
|
|
|
|
#[test]
|
|
fn generate_testpattern_end_to_end() {
|
|
let provider = start_test_service("generate");
|
|
|
|
let job_id = provider
|
|
.request(
|
|
Domain::Image,
|
|
&GenerateRequestJson {
|
|
model: "testpattern".to_string(),
|
|
prompt: Some("a red fox jumping a fence".to_string()),
|
|
width: Some(64),
|
|
height: Some(48),
|
|
seed: Some(42),
|
|
..Default::default()
|
|
},
|
|
)
|
|
.unwrap();
|
|
assert!(job_id.starts_with("job-"));
|
|
|
|
let artifacts = poll_until_done(&provider, &job_id);
|
|
assert_eq!(artifacts.len(), 1);
|
|
let (artifact_id, content_type) = &artifacts[0];
|
|
assert_eq!(content_type, "image/png");
|
|
|
|
let artifact = provider.fetch_artifact(artifact_id).unwrap();
|
|
assert_eq!(artifact.content_type, "image/png");
|
|
assert_eq!(
|
|
&artifact.bytes[..8],
|
|
&[0x89, b'P', b'N', b'G', b'\r', b'\n', 0x1a, b'\n']
|
|
);
|
|
let width = u32::from_be_bytes(artifact.bytes[16..20].try_into().unwrap());
|
|
let height = u32::from_be_bytes(artifact.bytes[20..24].try_into().unwrap());
|
|
assert_eq!((width, height), (64, 48));
|
|
|
|
// Determinism: the same request renders identical bytes.
|
|
let job2 = provider
|
|
.request(
|
|
Domain::Image,
|
|
&GenerateRequestJson {
|
|
model: "testpattern".to_string(),
|
|
prompt: Some("a red fox jumping a fence".to_string()),
|
|
width: Some(64),
|
|
height: Some(48),
|
|
seed: Some(42),
|
|
..Default::default()
|
|
},
|
|
)
|
|
.unwrap();
|
|
let artifacts2 = poll_until_done(&provider, &job2);
|
|
let artifact2 = provider.fetch_artifact(&artifacts2[0].0).unwrap();
|
|
assert_eq!(artifact.bytes, artifact2.bytes);
|
|
|
|
// Testpattern has no resident runtime: generation must not falsely turn
|
|
// an on-disk/callable model into `loaded` fleet affinity.
|
|
let health = provider.health().unwrap();
|
|
assert!(!health.models_loaded.contains(&"testpattern".to_string()));
|
|
let models = provider.list_models().unwrap();
|
|
assert_eq!(
|
|
models.iter().find(|model| model.id == "testpattern").unwrap().state,
|
|
"ready"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn model_picked_by_domain_when_unnamed() {
|
|
let provider = start_test_service("bydomain");
|
|
// Empty model + image domain -> the provider picks testpattern (the only
|
|
// available image model in a no-flux build).
|
|
let job_id = provider
|
|
.request(
|
|
Domain::Image,
|
|
&GenerateRequestJson {
|
|
prompt: Some("anything".to_string()),
|
|
width: Some(16),
|
|
height: Some(16),
|
|
..Default::default()
|
|
},
|
|
)
|
|
.unwrap();
|
|
let artifacts = poll_until_done(&provider, &job_id);
|
|
assert_eq!(artifacts.len(), 1);
|
|
|
|
// Mesh domain has no available model in the default (stub) build ->
|
|
// clean error. (A `--features mesh` build compiles the real trellis
|
|
// backend, so the domain IS routable there and this check doesn't apply.)
|
|
#[cfg(not(feature = "mesh"))]
|
|
{
|
|
let err = provider
|
|
.request(Domain::Mesh, &GenerateRequestJson::default())
|
|
.unwrap_err();
|
|
assert!(matches!(err, AssetAiError::Unavailable(_)));
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn queue_and_reject_policies() {
|
|
let provider = start_test_service("policies");
|
|
|
|
// A slow job (the delay_ms test hook) occupies the single GPU slot.
|
|
let slow = provider
|
|
.request(
|
|
Domain::Image,
|
|
&GenerateRequestJson {
|
|
model: "testpattern".to_string(),
|
|
prompt: Some("slow".to_string()),
|
|
width: Some(16),
|
|
height: Some(16),
|
|
delay_ms: Some(1500),
|
|
..Default::default()
|
|
},
|
|
)
|
|
.unwrap();
|
|
|
|
// Wait until it is actually running.
|
|
let deadline = Instant::now() + Duration::from_secs(5);
|
|
loop {
|
|
let status = provider.poll(&slow).unwrap();
|
|
if status.state == "running" {
|
|
break;
|
|
}
|
|
assert!(Instant::now() < deadline, "slow job never started");
|
|
std::thread::sleep(Duration::from_millis(10));
|
|
}
|
|
|
|
// Reject policy while busy -> Busy error (http 409).
|
|
let err = provider
|
|
.request(
|
|
Domain::Image,
|
|
&GenerateRequestJson {
|
|
model: "testpattern".to_string(),
|
|
prompt: Some("rejected".to_string()),
|
|
width: Some(16),
|
|
height: Some(16),
|
|
queue_policy: Some("reject".to_string()),
|
|
..Default::default()
|
|
},
|
|
)
|
|
.unwrap_err();
|
|
assert_eq!(err, AssetAiError::Busy);
|
|
|
|
// Queue policy while busy -> accepted, runs after the slow one.
|
|
let queued = provider
|
|
.request(
|
|
Domain::Image,
|
|
&GenerateRequestJson {
|
|
model: "testpattern".to_string(),
|
|
prompt: Some("queued".to_string()),
|
|
width: Some(16),
|
|
height: Some(16),
|
|
..Default::default()
|
|
},
|
|
)
|
|
.unwrap();
|
|
assert_eq!(provider.poll(&queued).unwrap().state, "queued");
|
|
|
|
poll_until_done(&provider, &slow);
|
|
poll_until_done(&provider, &queued);
|
|
}
|
|
|
|
#[test]
|
|
fn error_paths() {
|
|
let provider = start_test_service("errors");
|
|
|
|
// Unknown model -> http 404 with a message.
|
|
let err = provider
|
|
.request(
|
|
Domain::Image,
|
|
&GenerateRequestJson {
|
|
model: "does-not-exist".to_string(),
|
|
..Default::default()
|
|
},
|
|
)
|
|
.unwrap_err();
|
|
match err {
|
|
AssetAiError::Http(message) => assert!(message.contains("unknown model")),
|
|
other => panic!("expected Http error, got {other:?}"),
|
|
}
|
|
|
|
// Unavailable model -> http 503. Pick one dynamically so the assertion
|
|
// holds under every feature combination: any model /models reports as
|
|
// unavailable must refuse generation with an explicit 503.
|
|
let models = provider.list_models().unwrap();
|
|
if let Some(unavailable) = models.iter().find(|m| !m.available) {
|
|
let err = provider
|
|
.request(
|
|
Domain::Image,
|
|
&GenerateRequestJson {
|
|
model: unavailable.id.clone(),
|
|
..Default::default()
|
|
},
|
|
)
|
|
.unwrap_err();
|
|
match err {
|
|
AssetAiError::Http(message) => assert!(message.contains("503"), "got: {message}"),
|
|
other => panic!("expected Http error, got {other:?}"),
|
|
}
|
|
// The same model must say WHY on /models.
|
|
assert!(unavailable.unavailable_reason.is_some());
|
|
}
|
|
|
|
// Unknown job and artifact ids -> clean 404s.
|
|
assert!(provider.poll("job-999").is_err());
|
|
assert!(provider.fetch_artifact("nope-0").is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn pull_only_job_downloads_and_stops() {
|
|
let provider = start_test_service("pull");
|
|
|
|
// A pull job prepares artifacts (a no-op for testpattern) and completes
|
|
// before resident loading/generation: done, zero artifacts, still Ready.
|
|
let job_id = provider
|
|
.request(
|
|
Domain::Image,
|
|
&GenerateRequestJson {
|
|
model: "testpattern".to_string(),
|
|
pull_only: Some(true),
|
|
..Default::default()
|
|
},
|
|
)
|
|
.unwrap();
|
|
let artifacts = poll_until_done(&provider, &job_id);
|
|
assert!(artifacts.is_empty());
|
|
let models = provider.list_models().unwrap();
|
|
assert_eq!(
|
|
models.iter().find(|model| model.id == "testpattern").unwrap().state,
|
|
"ready"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn cancelled_generation_keeps_truthful_state_and_next_job_succeeds() {
|
|
let provider = start_test_service("cancel-recover");
|
|
let slow = provider
|
|
.request(
|
|
Domain::Image,
|
|
&GenerateRequestJson {
|
|
model: "testpattern".to_string(),
|
|
prompt: Some("slow".into()),
|
|
width: Some(16),
|
|
height: Some(16),
|
|
delay_ms: Some(2_000),
|
|
..Default::default()
|
|
},
|
|
)
|
|
.unwrap();
|
|
let deadline = Instant::now() + Duration::from_secs(5);
|
|
loop {
|
|
if provider.poll(&slow).unwrap().state == "running" {
|
|
break;
|
|
}
|
|
assert!(Instant::now() < deadline);
|
|
std::thread::sleep(Duration::from_millis(10));
|
|
}
|
|
provider.cancel(&slow).unwrap();
|
|
assert_eq!(poll_until_terminal(&provider, &slow), "cancelled");
|
|
let models = provider.list_models().unwrap();
|
|
assert_eq!(
|
|
models.iter().find(|model| model.id == "testpattern").unwrap().state,
|
|
"ready"
|
|
);
|
|
|
|
let next = provider
|
|
.request(
|
|
Domain::Image,
|
|
&GenerateRequestJson {
|
|
model: "testpattern".to_string(),
|
|
prompt: Some("recovered".into()),
|
|
width: Some(16),
|
|
height: Some(16),
|
|
..Default::default()
|
|
},
|
|
)
|
|
.unwrap();
|
|
assert_eq!(poll_until_done(&provider, &next).len(), 1);
|
|
}
|
|
|
|
#[test]
|
|
fn artifact_handoff_is_hash_verified() {
|
|
let provider = start_test_service("hash");
|
|
let job = provider
|
|
.request(
|
|
Domain::Image,
|
|
&GenerateRequestJson {
|
|
model: "testpattern".to_string(),
|
|
prompt: Some("hash me".into()),
|
|
width: Some(32),
|
|
height: Some(32),
|
|
seed: Some(7),
|
|
..Default::default()
|
|
},
|
|
)
|
|
.unwrap();
|
|
// Wait for done, then inspect the raw status: every artifact ref must
|
|
// carry its digest and exact length.
|
|
let deadline = Instant::now() + Duration::from_secs(20);
|
|
let status = loop {
|
|
let status = provider.poll(&job).unwrap();
|
|
match status.state.as_str() {
|
|
"done" => break status,
|
|
"error" => panic!("job failed: {:?}", status.error),
|
|
_ => {
|
|
assert!(Instant::now() < deadline);
|
|
std::thread::sleep(Duration::from_millis(25));
|
|
}
|
|
}
|
|
};
|
|
assert!(!status.artifacts.is_empty());
|
|
for artifact in &status.artifacts {
|
|
let sha = artifact.sha256.as_deref().expect("artifact sha256 present");
|
|
assert_eq!(sha.len(), 64);
|
|
assert!(sha.bytes().all(|b| matches!(b, b'0'..=b'9' | b'a'..=b'f')));
|
|
let len = artifact.byte_len.expect("artifact byte_len present");
|
|
assert!(len > 0);
|
|
|
|
// fetch_artifact verifies the X-Artifact-Sha256 header internally;
|
|
// then the JSON-level verifier must agree on the same bytes.
|
|
let fetched = provider.fetch_artifact(&artifact.id).unwrap();
|
|
assert_eq!(fetched.bytes.len() as u64, len);
|
|
makepad_ai_hub::client::verify_artifact_bytes(&fetched.bytes, artifact).unwrap();
|
|
|
|
// A corrupted relay is refused explicitly.
|
|
let mut corrupted = fetched.bytes.clone();
|
|
corrupted[0] ^= 0xff;
|
|
let refused = makepad_ai_hub::client::verify_artifact_bytes(&corrupted, artifact);
|
|
assert!(refused.is_err(), "corrupted bytes must not verify");
|
|
}
|
|
|
|
// Job metadata: model + lifecycle timestamps + bounded stage log.
|
|
assert_eq!(status.model.as_deref(), Some("testpattern"));
|
|
let queued = status.queued_ms.expect("queued_ms");
|
|
let started = status.started_ms.expect("started_ms");
|
|
let finished = status.finished_ms.expect("finished_ms");
|
|
assert!(queued <= started && started <= finished);
|
|
let log = status.log.expect("stage log present");
|
|
assert!(!log.is_empty());
|
|
assert!(log.iter().any(|line| line.contains("render") || line.contains("done")));
|
|
}
|
|
|
|
#[test]
|
|
fn node_identity_is_durable_and_capabilities_are_honest() {
|
|
// Two service starts over the SAME cache dir: the durable node_key must
|
|
// survive the restart while the per-start node_id rotates.
|
|
let dir = test_dir("identity");
|
|
let start = |dir: &PathBuf| {
|
|
let handle = start_service(ServiceConfig {
|
|
host: "127.0.0.1".to_string(),
|
|
port: 0,
|
|
cache_dir: dir.clone(),
|
|
registry: Registry::embedded().unwrap(),
|
|
downloader: Downloader::new("http://127.0.0.1:1", None).unwrap(),
|
|
peer: peer_off(),
|
|
fleet: makepad_ai_hub::discovery::DEFAULT_FLEET.to_string(),
|
|
})
|
|
.unwrap();
|
|
// Dropping the handle releases the per-cache-dir singleton lock —
|
|
// exactly the "old process gone" restart this test simulates.
|
|
LocalService::new(&format!("http://{}", handle.addr))
|
|
};
|
|
let first = start(&dir).health().unwrap();
|
|
let second = start(&dir).health().unwrap();
|
|
let key_a = first.node_key.expect("node_key present");
|
|
let key_b = second.node_key.expect("node_key present");
|
|
assert_eq!(key_a, key_b, "node_key is durable across restarts");
|
|
assert_eq!(key_a.len(), 32);
|
|
assert_ne!(first.node_id, second.node_id, "node_id rotates per start");
|
|
assert!(first.started_ms.unwrap() > 0);
|
|
assert!(first.queue_limit.unwrap() >= 1);
|
|
assert!(first.vram_reserve_mb.is_some());
|
|
|
|
// Capabilities list only what THIS build+machine can serve: the
|
|
// default-feature test build serves image (testpattern) but must not
|
|
// claim e.g. video (backend not compiled).
|
|
let caps = first.capabilities.expect("capabilities present");
|
|
assert!(caps.contains(&"image".to_string()));
|
|
// Domains whose backend is not compiled must not be claimed. (With the
|
|
// video feature on, h3 is compiled and the claim is legitimate.)
|
|
if !cfg!(feature = "video") {
|
|
assert!(!caps.contains(&"video".to_string()));
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn models_report_revision_and_explicit_unavailable_reason() {
|
|
let provider = start_test_service("reasons");
|
|
let models = provider.list_models().unwrap();
|
|
|
|
let testpattern = models.iter().find(|m| m.id == "testpattern").unwrap();
|
|
assert!(testpattern.available);
|
|
assert!(testpattern.unavailable_reason.is_none());
|
|
|
|
// The model must say WHY it is unavailable instead of a bare false. The
|
|
// canonical FP8 tier needs the compiled feature AND a CUDA device; the
|
|
// reason names whichever gate failed (feature -> "not compiled",
|
|
// capability -> "not provisioned"). Only a CUDA box with the feature
|
|
// shows it available with no reason.
|
|
let flux = models.iter().find(|m| m.id == "flux1-schnell").unwrap();
|
|
if cfg!(feature = "flux") && makepad_ai_hub::backend::backend_provisioned("flux") {
|
|
assert!(flux.available);
|
|
assert!(flux.unavailable_reason.is_none());
|
|
} else {
|
|
assert!(!flux.available);
|
|
let reason = flux.unavailable_reason.as_deref().expect("explicit reason");
|
|
assert!(
|
|
reason.contains("not compiled") || reason.contains("not provisioned"),
|
|
"got: {reason}"
|
|
);
|
|
}
|
|
|
|
// Pinned registry revisions surface for provenance-tracking consumers.
|
|
let trellis = models.iter().find(|m| m.id == "trellis-2").unwrap();
|
|
let revision = trellis.revision.as_deref().expect("trellis revisions pinned");
|
|
assert!(revision.len() >= 40);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Realtime / live session (POST /realtime + GET /realtime/<id> websocket)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
fn post_realtime(base_url: &str, request: &RealtimeRequestJson) -> (u16, RealtimeResponseJson) {
|
|
let url = format!("{base_url}/realtime");
|
|
let response = http_fetch(&HttpClientRequest::post(
|
|
&url,
|
|
"application/json",
|
|
request.serialize_json().as_bytes(),
|
|
))
|
|
.unwrap();
|
|
let status = response.status;
|
|
let bytes = response.read_body_to_vec(4 * 1024 * 1024).unwrap();
|
|
let text = std::str::from_utf8(&bytes).unwrap();
|
|
let parsed = RealtimeResponseJson::deserialize_json_lenient(text)
|
|
.unwrap_or_else(|e| panic!("bad realtime response json {text:?}: {e:?}"));
|
|
(status, parsed)
|
|
}
|
|
|
|
/// Opens a plain-TCP websocket to `<base_url><ws_path>` (base_url is
|
|
/// "http://host:port" — rewritten to "ws://host:port" here, matching the
|
|
/// scheme `PlainWebSocket::open` accepts).
|
|
fn open_realtime_socket(base_url: &str, ws_path: &str) -> (PlainWebSocket, Receiver<WebSocketMessage>) {
|
|
let ws_url = format!("{}{}", base_url.replacen("http://", "ws://", 1), ws_path);
|
|
let (tx, rx) = std::sync::mpsc::channel::<WebSocketMessage>();
|
|
let socket = PlainWebSocket::open(LiveId::empty(), HttpRequest::new(ws_url, HttpMethod::GET), tx);
|
|
(socket, rx)
|
|
}
|
|
|
|
/// Drains `rx` until the socket reports `Closed` (or its channel disconnects
|
|
/// — the io thread dropping its sender after `Closed` is also a valid
|
|
/// "closed" signal), or `timeout` elapses.
|
|
fn wait_for_socket_close(rx: &Receiver<WebSocketMessage>, timeout: Duration) -> bool {
|
|
let deadline = Instant::now() + timeout;
|
|
loop {
|
|
match rx.recv_timeout(Duration::from_millis(200)) {
|
|
Ok(WebSocketMessage::Closed) => return true,
|
|
Ok(_) => {} // drain frames/stats/stopped/etc while waiting
|
|
Err(RecvTimeoutError::Disconnected) => return true,
|
|
Err(RecvTimeoutError::Timeout) => {
|
|
if Instant::now() >= deadline {
|
|
return false;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
fn poll_realtime_terminal(provider: &LocalService, job_id: &str) -> String {
|
|
let deadline = Instant::now() + Duration::from_secs(15);
|
|
loop {
|
|
let status = provider.poll(job_id).unwrap();
|
|
if matches!(status.state.as_str(), "done" | "cancelled" | "error") {
|
|
return status.state;
|
|
}
|
|
assert!(
|
|
Instant::now() < deadline,
|
|
"live session did not reach a terminal state in time (state={})",
|
|
status.state
|
|
);
|
|
std::thread::sleep(Duration::from_millis(20));
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn realtime_feed_mode_streams_frames_and_stops_cleanly() {
|
|
let provider = start_test_service("realtime-feed");
|
|
let base_url = provider.base_url().to_string();
|
|
|
|
let (status, response) = post_realtime(
|
|
&base_url,
|
|
&RealtimeRequestJson {
|
|
model: "testpattern".to_string(),
|
|
width: Some(16),
|
|
height: Some(16),
|
|
prompt: Some("a red fox".to_string()),
|
|
strength: Some(0.7),
|
|
steps: Some(2),
|
|
loop_mode: Some("feed".to_string()),
|
|
output_encoding: Some("raw".to_string()),
|
|
idle_timeout_s: Some(5),
|
|
..Default::default()
|
|
},
|
|
);
|
|
assert_eq!(status, 200, "realtime response: {response:?}");
|
|
let job_id = response.job_id.clone().expect("job_id");
|
|
let ws_path = response.ws_path.clone().expect("ws_path");
|
|
assert_eq!(ws_path, format!("/realtime/{job_id}"));
|
|
assert!(provider.poll(&job_id).unwrap().state == "queued" || provider.poll(&job_id).unwrap().state == "live");
|
|
|
|
let (mut socket, rx) = open_realtime_socket(&base_url, &ws_path);
|
|
|
|
// A control update (must not crash/derail the session) ...
|
|
socket
|
|
.send_message(WebSocketMessage::String(
|
|
r#"{"type":"control","prompt":"a blue whale","strength":0.9}"#.to_string(),
|
|
))
|
|
.unwrap();
|
|
|
|
// Then raw RGB8 input frames — sent one at a time, interleaved with
|
|
// receiving, not as an upfront burst: the session mailbox keeps only the
|
|
// LATEST unconsumed frame (see protocol.rs's backpressure law), so a
|
|
// burst sent faster than the worker's loop cadence would mostly
|
|
// coalesce away and starve loop_mode="feed" (which blocks waiting for a
|
|
// genuinely NEW frame each iteration). A real camera-feed client would
|
|
// push continuously the same way. Keep sending fresh frames until at
|
|
// least 5 outputs and one stats message have arrived.
|
|
let mut frames_received = 0usize;
|
|
let mut stats_received = false;
|
|
let mut next_input_index = 0u32;
|
|
let deadline = Instant::now() + Duration::from_secs(15);
|
|
while (frames_received < 5 || !stats_received) && Instant::now() < deadline {
|
|
if next_input_index < 200 {
|
|
let payload = vec![((next_input_index * 37) % 255) as u8; 16 * 16 * 3];
|
|
let header = FrameHeader {
|
|
kind: FrameKind::Raw,
|
|
width: 16,
|
|
height: 16,
|
|
frame_index: next_input_index,
|
|
};
|
|
let bytes = realtime_wire::encode_frame(header, &payload);
|
|
socket.send_message(WebSocketMessage::Binary(bytes)).unwrap();
|
|
next_input_index += 1;
|
|
}
|
|
match rx.recv_timeout(Duration::from_millis(30)) {
|
|
Ok(WebSocketMessage::Binary(data)) => {
|
|
if realtime_wire::is_frame_message(&data) {
|
|
let (header, payload) = realtime_wire::decode_frame(&data).unwrap();
|
|
assert_eq!(header.kind, FrameKind::Raw);
|
|
assert_eq!((header.width, header.height), (16, 16));
|
|
assert_eq!(payload.len(), 16 * 16 * 3);
|
|
frames_received += 1;
|
|
} else {
|
|
let text = std::str::from_utf8(&data).expect("non-frame push must be utf-8 json");
|
|
if text.contains("\"type\":\"stats\"") {
|
|
stats_received = true;
|
|
assert!(text.contains("\"frames_out\""));
|
|
}
|
|
}
|
|
}
|
|
Ok(WebSocketMessage::Closed) => panic!("socket closed before {frames_received} frames arrived"),
|
|
Ok(WebSocketMessage::Error(e)) => panic!("websocket error: {e}"),
|
|
Ok(_) => {}
|
|
Err(RecvTimeoutError::Timeout) => {}
|
|
Err(RecvTimeoutError::Disconnected) => panic!("socket channel disconnected early"),
|
|
}
|
|
}
|
|
assert!(frames_received >= 5, "only received {frames_received} frames");
|
|
assert!(stats_received, "never received a stats message");
|
|
|
|
// GET /job/<id> carries live counters whenever it reports state=live
|
|
// (the counters themselves are only refreshed at ~10 Hz — see
|
|
// `run_live`'s doc — so they may still read frames_out=0 a few ms into
|
|
// the session; only the field's presence is a stable contract here).
|
|
let live_status = provider.poll(&job_id).unwrap();
|
|
if live_status.state == "live" {
|
|
live_status.live.expect("live counters present while state=live");
|
|
}
|
|
|
|
socket
|
|
.send_message(WebSocketMessage::String(r#"{"type":"stop"}"#.to_string()))
|
|
.unwrap();
|
|
|
|
let state = poll_realtime_terminal(&provider, &job_id);
|
|
assert_eq!(state, "done", "stop must end the session as done, not error/cancelled");
|
|
|
|
assert!(wait_for_socket_close(&rx, Duration::from_secs(10)), "socket never closed after stop");
|
|
socket.close();
|
|
}
|
|
|
|
#[test]
|
|
fn realtime_feedback_mode_produces_frames_without_any_input_then_cancels() {
|
|
let provider = start_test_service("realtime-feedback");
|
|
let base_url = provider.base_url().to_string();
|
|
|
|
let (status, response) = post_realtime(
|
|
&base_url,
|
|
&RealtimeRequestJson {
|
|
model: "testpattern".to_string(),
|
|
// 16 is LiveParams::from_request's clamp floor for width/height.
|
|
width: Some(16),
|
|
height: Some(16),
|
|
loop_mode: Some("feedback".to_string()),
|
|
// Explicit "raw": this test asserts on raw RGB8 payload size;
|
|
// the service-wide default is "h264" when the video codec
|
|
// feature is compiled in (see the dedicated h264 round-trip
|
|
// test below for that path).
|
|
output_encoding: Some("raw".to_string()),
|
|
idle_timeout_s: Some(5),
|
|
..Default::default()
|
|
},
|
|
);
|
|
assert_eq!(status, 200, "realtime response: {response:?}");
|
|
let job_id = response.job_id.clone().expect("job_id");
|
|
let ws_path = response.ws_path.clone().expect("ws_path");
|
|
|
|
let (mut socket, rx) = open_realtime_socket(&base_url, &ws_path);
|
|
|
|
// No input frames are ever sent — feedback mode must still produce
|
|
// output (its own previous output, camera-warped, seeds the next step;
|
|
// the very first frame has no previous output either, and testpattern's
|
|
// live_step handles `init: None` by rendering the pure pattern).
|
|
let mut frames_received = 0usize;
|
|
let deadline = Instant::now() + Duration::from_secs(15);
|
|
while frames_received < 3 && Instant::now() < deadline {
|
|
match rx.recv_timeout(Duration::from_millis(500)) {
|
|
Ok(WebSocketMessage::Binary(data)) if realtime_wire::is_frame_message(&data) => {
|
|
let (header, payload) = realtime_wire::decode_frame(&data).unwrap();
|
|
assert_eq!((header.width, header.height), (16, 16));
|
|
assert_eq!(payload.len(), 16 * 16 * 3);
|
|
frames_received += 1;
|
|
}
|
|
Ok(WebSocketMessage::Error(e)) => panic!("websocket error: {e}"),
|
|
_ => {}
|
|
}
|
|
}
|
|
assert!(frames_received >= 3, "only received {frames_received} feedback-mode frames");
|
|
|
|
// Cancel via the ordinary job-cancel endpoint (not the ws "stop"
|
|
// message) — the live session must honor POST /job/<id>/cancel exactly
|
|
// like a running generate job.
|
|
provider.cancel(&job_id).unwrap();
|
|
let state = poll_realtime_terminal(&provider, &job_id);
|
|
assert_eq!(state, "cancelled");
|
|
|
|
assert!(wait_for_socket_close(&rx, Duration::from_secs(10)), "socket never closed after cancel");
|
|
socket.close();
|
|
}
|
|
|
|
#[test]
|
|
fn realtime_post_admission_errors_match_generate_semantics() {
|
|
let provider = start_test_service("realtime-admission-errors");
|
|
let base_url = provider.base_url().to_string();
|
|
|
|
let (status, response) = post_realtime(
|
|
&base_url,
|
|
&RealtimeRequestJson {
|
|
model: "does-not-exist".to_string(),
|
|
..Default::default()
|
|
},
|
|
);
|
|
assert_eq!(status, 404, "unknown model: {response:?}");
|
|
|
|
// A registered-but-unavailable model 503s before the live-support check
|
|
// even runs — same admission order as POST /generate.
|
|
let expect_flux = cfg!(feature = "flux") && makepad_ai_hub::backend::backend_provisioned("flux");
|
|
let (status, response) = post_realtime(
|
|
&base_url,
|
|
&RealtimeRequestJson {
|
|
model: "flux1-schnell".to_string(),
|
|
..Default::default()
|
|
},
|
|
);
|
|
if !expect_flux {
|
|
assert_eq!(status, 503, "unavailable model: {response:?}");
|
|
}
|
|
}
|
|
|
|
/// Phase 2: hardware H.264 codec round trip. The test acts as its own
|
|
/// client, using the SAME `makepad-video` stream encoder/decoder the
|
|
/// service uses internally (VideoToolbox on macOS — this is the only
|
|
/// platform this was actually run on; see platform/video's module docs for
|
|
/// the Windows MFT path, which is cross-compile-checked only).
|
|
#[cfg(feature = "video")]
|
|
#[test]
|
|
fn realtime_h264_round_trip_input_and_output() {
|
|
use makepad_video::{StreamVideoCodec, VideoStreamDecoder, VideoStreamEncoder, VideoStreamEncoderOptions};
|
|
|
|
const WIDTH: u32 = 64;
|
|
const HEIGHT: u32 = 64;
|
|
|
|
let provider = start_test_service("realtime-h264");
|
|
let base_url = provider.base_url().to_string();
|
|
|
|
let (status, response) = post_realtime(
|
|
&base_url,
|
|
&RealtimeRequestJson {
|
|
model: "testpattern".to_string(),
|
|
width: Some(WIDTH),
|
|
height: Some(HEIGHT),
|
|
loop_mode: Some("feed".to_string()),
|
|
input_encoding: Some("h264".to_string()),
|
|
output_encoding: Some("h264".to_string()),
|
|
idle_timeout_s: Some(5),
|
|
..Default::default()
|
|
},
|
|
);
|
|
assert_eq!(status, 200, "realtime response: {response:?}");
|
|
let job_id = response.job_id.clone().expect("job_id");
|
|
let ws_path = response.ws_path.clone().expect("ws_path");
|
|
|
|
let (mut socket, rx) = open_realtime_socket(&base_url, &ws_path);
|
|
|
|
// The client's own encoder/decoder — independent instances of the same
|
|
// hardware codec seam (platform/video), proving the wire round trip
|
|
// rather than any shared in-process state.
|
|
let mut client_encoder = VideoStreamEncoder::new(VideoStreamEncoderOptions {
|
|
codec: StreamVideoCodec::H264,
|
|
width: WIDTH,
|
|
height: HEIGHT,
|
|
fps: 30,
|
|
bitrate_kbps: 2_000,
|
|
keyint: 30,
|
|
low_latency: true,
|
|
})
|
|
.expect("client encoder creation");
|
|
let mut client_decoder = VideoStreamDecoder::new(StreamVideoCodec::H264).expect("client decoder creation");
|
|
|
|
let mut decoded_output_frames = 0usize;
|
|
let mut stats_reported_h264 = false;
|
|
let mut next_input_index = 0u32;
|
|
let deadline = Instant::now() + Duration::from_secs(20);
|
|
while decoded_output_frames < 5 && Instant::now() < deadline {
|
|
if next_input_index < 300 {
|
|
// A moving synthetic gradient — the same "must actually move"
|
|
// shape as platform/video's own codec test.
|
|
let shift = (next_input_index * 4) as usize;
|
|
let mut rgb = vec![0u8; (WIDTH * HEIGHT * 3) as usize];
|
|
for (i, px) in rgb.chunks_exact_mut(3).enumerate() {
|
|
px[0] = ((i + shift) % 256) as u8;
|
|
px[1] = (i % 256) as u8;
|
|
px[2] = ((i * 2 + shift) % 256) as u8;
|
|
}
|
|
let pts_100ns = next_input_index as i64 * (10_000_000 / 30);
|
|
let packets = client_encoder.push_frame_rgb8(&rgb, pts_100ns).expect("client encode");
|
|
for packet in packets {
|
|
let header = FrameHeader {
|
|
kind: FrameKind::H264,
|
|
width: WIDTH as u16,
|
|
height: HEIGHT as u16,
|
|
frame_index: next_input_index,
|
|
};
|
|
let bytes = realtime_wire::encode_frame(header, &packet.data);
|
|
socket.send_message(WebSocketMessage::Binary(bytes)).unwrap();
|
|
}
|
|
next_input_index += 1;
|
|
}
|
|
match rx.recv_timeout(Duration::from_millis(30)) {
|
|
Ok(WebSocketMessage::Binary(data)) => {
|
|
if realtime_wire::is_frame_message(&data) {
|
|
let (header, payload) = realtime_wire::decode_frame(&data).unwrap();
|
|
assert_eq!(header.kind, FrameKind::H264, "server output frame must be h264");
|
|
let frames = client_decoder
|
|
.push_packet(payload, header.frame_index as i64)
|
|
.expect("client decode of server output");
|
|
for frame in frames {
|
|
assert_eq!((frame.width, frame.height), (WIDTH, HEIGHT));
|
|
let rgb = frame.to_rgb8();
|
|
assert_eq!(rgb.len(), (WIDTH * HEIGHT * 3) as usize);
|
|
decoded_output_frames += 1;
|
|
}
|
|
} else {
|
|
let text = std::str::from_utf8(&data).expect("non-frame push must be utf-8 json");
|
|
if text.contains("\"type\":\"stats\"") && text.contains("\"output\":\"h264\"") {
|
|
stats_reported_h264 = true;
|
|
}
|
|
}
|
|
}
|
|
Ok(WebSocketMessage::Closed) => panic!("socket closed before {decoded_output_frames} h264 frames decoded"),
|
|
Ok(WebSocketMessage::Error(e)) => panic!("websocket error: {e}"),
|
|
Ok(_) => {}
|
|
Err(RecvTimeoutError::Timeout) => {}
|
|
Err(RecvTimeoutError::Disconnected) => panic!("socket channel disconnected early"),
|
|
}
|
|
}
|
|
assert!(decoded_output_frames >= 5, "only decoded {decoded_output_frames} h264 output frames");
|
|
assert!(stats_reported_h264, "stats never reported codec.output = h264");
|
|
|
|
socket
|
|
.send_message(WebSocketMessage::String(r#"{"type":"stop"}"#.to_string()))
|
|
.unwrap();
|
|
let state = poll_realtime_terminal(&provider, &job_id);
|
|
assert_eq!(state, "done");
|
|
assert!(wait_for_socket_close(&rx, Duration::from_secs(10)), "socket never closed after stop");
|
|
socket.close();
|
|
}
|