Files
core/test/dever-tests/tests/external_component.rs
T

329 lines
11 KiB
Rust

use std::fs;
use std::path::PathBuf;
use dever_runtime::component::{self, Definition, Reply};
use dever_runtime::wire::{Encoded, Encoder};
use sha2::{Digest, Sha256};
#[path = "support/process_runtime.rs"]
mod process_runtime;
#[path = "support/temp.rs"]
mod temp;
const OPERATIONS: &[&str] = &[
"echo",
"fail",
"wait",
"timeout",
"crash",
"duplicate",
"out_of_order",
"unknown",
"oversize",
"truncated",
];
#[test]
fn component_protocol_and_lifecycle_are_bounded() {
let fixture = install_fixture();
let result = dever_runtime::task::run_entry(async {
fixture.prepare()?;
fs::write(
fixture.directory.path().join("config/setting.json"),
"invalid",
)
.map_err(|error| error.to_string())?;
normal_lifecycle(fixture.entry)
.await
.map_err(|error| format!("normal lifecycle: {error}"))?;
shutdown_with_queued_calls_is_bounded(fixture.entry)
.await
.map_err(|error| format!("queued shutdown: {error}"))?;
rejected_handshakes(fixture.entry)
.await
.map_err(|error| format!("handshake rejection: {error}"))?;
malformed_responses(fixture.entry)
.await
.map_err(|error| format!("malformed response: {error}"))?;
cancelled_lifecycle(&fixture)
.await
.map_err(|error| format!("cancelled lifecycle: {error}"))
});
result.expect("external component protocol fixture");
assert!(fixture.path.with_extension("shutdown").is_file());
assert!(dever_runtime::task::run_entry(async { fixture.prepare() }).is_err());
}
async fn cancelled_lifecycle(fixture: &InstalledFixture) -> Result<(), String> {
let entry = fixture.entry;
let start = dever_runtime::task::run(async move {
component::start(definition("cancel_start", entry, "delayed_start", 2_000)).await
})
.await?;
wait_for_marker(&fixture.path.with_extension("starting")).await?;
dever_runtime::task::stop(start).await?;
component::shutdown().await?;
component::start(definition(
"cancel_shutdown",
entry,
"delayed_shutdown",
2_000,
))
.await?;
let stopping = dever_runtime::task::run(component::shutdown()).await?;
wait_for_marker(&fixture.path.with_extension("stopping")).await?;
dever_runtime::task::stop(stopping).await?;
// The cancelled caller must leave ownership available for a complete second drain.
component::shutdown().await?;
Ok(())
}
async fn wait_for_marker(path: &std::path::Path) -> Result<(), String> {
tokio::time::timeout(std::time::Duration::from_secs(2), async {
while !path.is_file() {
tokio::time::sleep(std::time::Duration::from_millis(2)).await;
}
})
.await
.map_err(|_| "fixture did not reach its lifecycle checkpoint".to_owned())
}
async fn shutdown_with_queued_calls_is_bounded(entry: &'static str) -> Result<(), String> {
component::start(definition("queued_shutdown", entry, "normal", 5_000)).await?;
let mut calls = Vec::new();
for _ in 0..80 {
calls.push(
dever_runtime::task::run(async {
component::call("queued_shutdown", "wait", &payload(0))
.await
.map(|_| ())
})
.await?,
);
}
tokio::time::sleep(std::time::Duration::from_millis(20)).await;
tokio::time::timeout(std::time::Duration::from_secs(1), component::shutdown())
.await
.map_err(|_| "external Adapter shutdown did not stop queued calls".to_owned())??;
for call in calls {
let error = dever_runtime::task::wait(call)
.await
.expect_err("shutdown must stop every pending call");
assert!(
error.contains("stopped"),
"unexpected shutdown error: {error}"
);
}
Ok(())
}
async fn normal_lifecycle(entry: &'static str) -> Result<(), String> {
// A short-lived caller starts the shared Worker; ending its Scope must not stop it.
let started = dever_runtime::task::run(async move {
component::start(definition("normal", entry, "normal", 50)).await
})
.await?;
dever_runtime::task::wait(started)
.await
.map_err(|error| format!("start: {error}"))?;
for value in [1_i64, 2] {
match component::call("normal", "echo", &payload(value))
.await
.map_err(|error| format!("echo {value}: {error}"))?
{
Reply::Result(body) => assert_eq!(body, format!(r#"{{"value":{value}}}"#)),
Reply::Error { .. } => return Err("echo returned a business error".into()),
}
}
match component::call("normal", "fail", &payload(0))
.await
.map_err(|error| format!("business error: {error}"))?
{
Reply::Error { identity, payload } => {
assert_eq!(identity, "notification.delivery.Rejected");
assert_eq!(payload, r#"{"reason":"fixture"}"#);
}
Reply::Result(_) => return Err("fail returned a result".into()),
}
let wait = dever_runtime::task::run(async {
component::call("normal", "wait", &payload(0))
.await
.map(|_| ())
})
.await?;
tokio::time::sleep(std::time::Duration::from_millis(20)).await;
dever_runtime::task::stop(wait)
.await
.map_err(|error| format!("cancel wait: {error}"))?;
let timeout = component::call("normal", "timeout", &payload(0))
.await
.expect_err("timeout operation must reach the deadline");
assert_eq!(timeout, "external Adapter call timed out");
assert!(matches!(
component::call("normal", "echo", &payload(3))
.await
.map_err(|error| format!("echo after timeout: {error}"))?,
Reply::Result(_)
));
let crash = component::call("normal", "crash", &payload(0))
.await
.expect_err("crashed worker must fail its pending call");
assert!(
crash.contains("early eof"),
"unexpected crash fault: {crash}"
);
assert!(matches!(
component::call("normal", "echo", &payload(4))
.await
.map_err(|error| format!("echo after crash: {error}"))?,
Reply::Result(_)
));
component::shutdown()
.await
.map_err(|error| format!("shutdown: {error}"))?;
Ok(())
}
async fn rejected_handshakes(entry: &'static str) -> Result<(), String> {
for (key, mode, expected) in [
("duplicate_ready", "duplicate_ready", "duplicate JSON field"),
(
"capability_mismatch",
"capability_mismatch",
"handshake does not match",
),
] {
let error = component::start(definition(key, entry, mode, 100))
.await
.expect_err("invalid handshake must be rejected");
assert!(
error.contains(expected),
"unexpected handshake error: {error}"
);
}
Ok(())
}
async fn malformed_responses(entry: &'static str) -> Result<(), String> {
for operation in [
"duplicate",
"out_of_order",
"unknown",
"oversize",
"truncated",
] {
component::start(definition(operation, entry, "normal", 100)).await?;
let error = component::call(operation, operation, &payload(0))
.await
.expect_err("invalid response must fail its pending call");
let expected = match operation {
"duplicate" => "duplicate JSON field",
"out_of_order" => "out-of-order request id",
"unknown" => "unknown message kind",
"oversize" => "frame exceeds byte limit",
"truncated" => "cannot read complete component protocol frame",
_ => unreachable!(),
};
assert!(
error.contains(expected),
"unexpected {operation} call fault: {error}"
);
let cleanup = component::shutdown()
.await
.expect_err("protocol fault must survive cleanup");
assert!(
cleanup.contains(expected),
"unexpected {operation} fault: {cleanup}"
);
}
Ok(())
}
fn definition(
key: &'static str,
entry: &'static str,
mode: &'static str,
timeout_ms: u64,
) -> Definition {
let (_, setting) = component::adapter_settings()
.unwrap()
.select_adapter("notification.delivery", &["fixture"])
.unwrap();
let mut setting: serde_json::Value = serde_json::from_str(&setting.unwrap()).unwrap();
setting["mode"] = serde_json::json!(mode);
Definition {
ecosystem: "exec".into(),
key: key.into(),
entry: entry.into(),
port: "notification.delivery".into(),
adapter: "notification.delivery.fixture".into(),
schema: "a19ca3e615ce5e71".into(),
capabilities: vec!["network".into(), "file".into()],
operations: OPERATIONS.iter().map(|name| (*name).into()).collect(),
setting: Some(setting.to_string()),
timeout_ms,
}
}
fn payload(value: i64) -> Encoded {
let mut writer = Encoder::default();
writer.begin_object().expect("payload object");
writer.key("value").expect("payload field");
writer.int(value).expect("payload value");
writer.end().expect("payload end");
writer.finish().expect("payload JSON")
}
struct InstalledFixture {
directory: temp::TemporaryDirectory,
path: PathBuf,
entry: &'static str,
resources: Vec<dever_runtime::external::Resource<'static>>,
}
impl InstalledFixture {
fn prepare(&self) -> Result<(), String> {
let digest = dever_runtime::external::bundle_digest(&self.resources);
dever_runtime::external::prepare(self.directory.path(), &digest, &self.resources)
}
}
fn install_fixture() -> InstalledFixture {
let directory = temp::TemporaryDirectory::new();
fs::create_dir(directory.path().join("lifecycle")).unwrap();
fs::create_dir(directory.path().join("config")).unwrap();
fs::write(directory.path().join("config/setting.json"), serde_json::json!({"adapter":{"notification.delivery":{"setting":{"marker":directory.path().join("lifecycle/worker")}}}}).to_string()).unwrap();
let workspace = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../..");
let mut assets = process_runtime::assets(&workspace);
assets.push(process_runtime::Asset {
path: "workers/fixture/worker".into(),
bytes: fs::read(env!("CARGO_BIN_EXE_component-fixture")).unwrap(),
executable: true,
});
assets.push(process_runtime::Asset {
path: "worker.dever-worker.json".into(),
bytes: br#"{"format":"dever-worker-launch-v1","ecosystem":"exec","entry":"worker","executable":"workers/fixture/worker","arguments":[],"working_directory":"workers/fixture"}"#.to_vec(), executable: false,
});
let resources = assets
.into_iter()
.map(|asset| dever_runtime::external::Resource {
path: Box::leak(asset.path.into_boxed_str()),
sha256: Box::leak(format!("{:x}", Sha256::digest(&asset.bytes)).into_boxed_str()),
bytes: Box::leak(asset.bytes.into_boxed_slice()),
executable: asset.executable,
})
.collect();
let path = directory.path().join("lifecycle/worker");
InstalledFixture {
directory,
path,
entry: "worker",
resources,
}
}