329 lines
11 KiB
Rust
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,
|
|
}
|
|
}
|