Initial Dever 0.0.1

This commit is contained in:
Codex committed 2026-11-03 03:32:07 +00:00
commit 81233d098c
1479 files changed
+163782

No files matched your search

+90
View File
@@ -0,0 +1,90 @@
use dever_runtime::{
adapter::{Document, Schema},
value::secret::Secret,
};
use serde::Deserialize;
const SCHEMAS: &[Schema] = &[Schema {
name: "demo.value",
implementations: &["local", "remote"],
}];
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct Input {
offset: i64,
token: Secret,
}
#[test]
fn selection_is_explicit_and_only_the_selected_setting_is_decoded() {
let text = r#"{"adapter":{"demo.value":{"use":"remote","setting":{"offset":41,"token":"fixture-only-token"}}}}"#;
for _ in 0..100 {
let document = Document::parse(text.into()).unwrap();
let selections = document.select(SCHEMAS).unwrap();
assert_eq!(selections[0].index, 1);
let input: Input = selections[0].decode().unwrap();
assert_eq!(input.offset, 41);
assert_eq!(format!("{:?}", input.token), "Secret([REDACTED])");
drop(selections);
drop(document);
assert_eq!(format!("{:?}", input.token), "Secret([REDACTED])");
}
let document =
Document::parse(r#"{"adapter":{"demo.\u0076alue":{"use":"\u006cocal"}}}"#.into()).unwrap();
assert_eq!(document.select(SCHEMAS).unwrap()[0].index, 0);
let document = Document::parse("{}".into()).unwrap();
assert!(document.select(SCHEMAS).is_err());
let single = [Schema {
name: "demo.value",
implementations: &["default"],
}];
let selections = document.select(&single).unwrap();
assert_eq!(selections[0].index, 0);
assert!(selections[0].without_setting().is_ok());
assert!(selections[0].decode::<Input>().is_err());
}
#[test]
fn invalid_configuration_is_bounded_strict_and_redacted() {
for text in [
"[]",
"[{}]",
"null",
"{} {}",
"{",
r#"{"adapter":null}"#,
r#"{"adapter":{},"adapter":{}}"#,
r#"{"adapter":{"demo.value":["local",{}]}}"#,
r#"{"adapter":{"demo.value":{"use":null}}}"#,
r#"{"adapter":{"demo.value":{"use":"fixture-only-token"}}}"#,
r#"{"adapter":{"fixture-only-token":{"use":"local"}}}"#,
r#"{"adapter":{"demo.value":{"use":"local","fixture-only-token":1}}}"#,
r#"{"adapter":{"demo.value":{"use":"local","use":"remote"}}}"#,
r#"{"adapter":{"demo.value":{"use":"local","setting":{"token":"a","token":"b"}}}}"#,
] {
let error = match Document::parse(text.into()) {
Err(error) => error,
Ok(document) => document.select(SCHEMAS).err().expect(text),
};
assert!(!error.contains("fixture-only-token"), "{error}");
}
let too_large = format!("{{\"text\":\"{}\"}}", "x".repeat(16 * 1024 * 1024));
assert!(Document::parse(too_large).is_err());
for setting in [
"null",
"{}",
r#"{"offset":"fixture-only-token","token":"x"}"#,
r#"{"offset":1,"token":5}"#,
r#"{"offset":1,"token":"x","extra":"fixture-only-token"}"#,
] {
let document = Document::parse(format!(
r#"{{"adapter":{{"demo.value":{{"use":"local","setting":{setting}}}}}}}"#
))
.unwrap();
let selections = document.select(SCHEMAS).unwrap();
let error = selections[0].decode::<Input>().err().expect(setting);
assert!(!error.contains("fixture-only-token"));
assert!(selections[0].without_setting().is_err());
}
}
+161
View File
@@ -0,0 +1,161 @@
use dever_runtime::{
async_stream::{AsyncStream, Error},
channel::Channel,
task::{Limits, Runtime},
};
use futures_util::stream;
use std::{
future::{Future, poll_fn},
sync::{
Arc,
atomic::{AtomicUsize, Ordering},
},
task::Poll,
};
fn runtime() -> Runtime {
Runtime::new(Limits {
threads: 2,
workers: 1,
tasks: 8,
})
.unwrap()
}
async fn pending(future: std::pin::Pin<&mut impl Future>) {
let mut future = future;
poll_fn(|context| {
assert!(future.as_mut().poll(context).is_pending());
Poll::Ready(())
})
.await;
}
struct Notice(Arc<AtomicUsize>);
impl Drop for Notice {
fn drop(&mut self) {
self.0.fetch_add(1, Ordering::SeqCst);
}
}
#[test]
fn values_are_lazy_and_aliases_share_cursor_and_close() {
let calls = Arc::new(AtomicUsize::new(0));
let observed = calls.clone();
let values = AsyncStream::values((1..=3).inspect(move |_| {
calls.fetch_add(1, Ordering::SeqCst);
}));
assert_eq!(observed.load(Ordering::SeqCst), 0);
let alias = values.clone();
runtime()
.run(async |_| {
assert_eq!(values.next().await, Ok(Some(1)));
assert_eq!(alias.next().await, Ok(Some(2)));
assert_eq!(observed.load(Ordering::SeqCst), 2);
alias.close();
assert_eq!(values.next().await, Ok(None));
assert_eq!(observed.load(Ordering::SeqCst), 2);
})
.unwrap();
}
#[test]
fn channel_stream_preserves_null_items_and_external_channel_alias() {
runtime()
.run(async |_| {
let channel = Channel::new(2).unwrap();
channel.send(None).await.unwrap();
channel.send(Some(2)).await.unwrap();
let values = AsyncStream::channel(channel.clone());
assert_eq!(values.next().await, Ok(Some(None)));
values.close();
assert_eq!(values.next().await, Ok(None));
assert_eq!(channel.receive().await, Ok(Some(Some(2))));
channel.send(Some(3)).await.unwrap();
channel.close().await.unwrap();
let remaining = AsyncStream::channel(channel);
assert_eq!(remaining.next().await, Ok(Some(Some(3))));
assert_eq!(remaining.next().await, Ok(None));
})
.unwrap();
}
#[test]
fn close_wakes_current_and_queued_pulls() {
runtime()
.run(async |_| {
let values = AsyncStream::channel(Channel::<i64>::new(1).unwrap());
let mut first = std::pin::pin!(values.next());
let mut second = std::pin::pin!(values.next());
pending(first.as_mut()).await;
pending(second.as_mut()).await;
values.close();
assert_eq!(first.await, Ok(None));
assert_eq!(second.await, Ok(None));
})
.unwrap();
}
#[test]
fn cancellation_closes_aliases_and_releases_the_producer() {
let drops = Arc::new(AtomicUsize::new(0));
let observed = drops.clone();
let values = AsyncStream::<i64>::new(stream::unfold(Notice(drops), |owner| async move {
std::future::pending::<()>().await;
Some((Ok(1), owner))
}));
let alias = values.clone();
runtime()
.run(async |scope| {
let (started, ready) = tokio::sync::oneshot::channel();
let pending = scope
.spawn_leaf(move || async move {
let _owner = values.close_on_drop();
let mut next = std::pin::pin!(values.next());
pending(next.as_mut()).await;
started.send(()).unwrap();
next.await
})
.unwrap();
ready.await.unwrap();
pending.stop().await.unwrap();
assert_eq!(observed.load(Ordering::SeqCst), 1);
assert_eq!(alias.next().await, Ok(None));
})
.unwrap();
}
#[test]
fn terminal_fault_releases_source_before_delivery_and_is_not_repeated() {
let drops = Arc::new(AtomicUsize::new(0));
let observed = drops.clone();
let values = AsyncStream::<i64>::new(stream::unfold(Notice(drops), |owner| async move {
Some((Err(Error::Overflow), owner))
}));
runtime()
.run(async |_| {
assert_eq!(values.next().await, Err(Error::Overflow));
assert_eq!(observed.load(Ordering::SeqCst), 1);
assert_eq!(values.next().await, Ok(None));
})
.unwrap();
}
#[test]
fn ticks_are_created_without_runtime_and_closed_without_waiting_for_deadline() {
assert!(matches!(AsyncStream::ticks(0), Err(Error::Period)));
assert!(matches!(AsyncStream::ticks(-1), Err(Error::Period)));
let values = AsyncStream::ticks(60000).unwrap();
runtime()
.run(async |_| {
let mut first = std::pin::pin!(values.next());
pending(first.as_mut()).await;
values.close();
assert_eq!(first.await, Ok(None));
let values = AsyncStream::ticks(1).unwrap();
assert_eq!(values.next().await, Ok(Some(1)));
assert_eq!(values.next().await, Ok(Some(2)));
values.close();
})
.unwrap();
}
+69
View File
@@ -0,0 +1,69 @@
use super::request::{configured, request};
use dever_runtime::{
auth::{Error, Identity},
authorization::{self, Failure},
database::{Context as DatabaseContext, Registry, Sql},
task::Runtime,
};
struct Directory(std::path::PathBuf);
impl Drop for Directory {
fn drop(&mut self) {
std::fs::remove_dir_all(&self.0).unwrap();
}
}
#[test]
fn entry_namespace_requires_role_authorization_and_ignores_request_coordinates() {
let service = configured(Some("https://unit.test"), "user.account.verify").unwrap();
let cookie = super::request::cookie(&service);
let request = request(
"POST",
&[
("Host", "unit.test"),
("Origin", "https://unit.test"),
("Cookie", &cookie),
("X-Component", "forged"),
("X-Domain", "forged"),
],
);
let (mut context, _) = service.prepare("admin", &request, false, 100).unwrap();
assert_eq!(context.entry_namespace().unwrap_err(), Error::Forbidden);
context
.establish(Identity {
id: "user".into(),
user_id: Some(7),
tenant_id: None,
})
.unwrap();
assert_eq!(context.entry_namespace().unwrap_err(), Error::Forbidden);
let unique = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos();
let name = format!("dever-entry-{}-{unique}", std::process::id());
let executable = std::env::current_exe().unwrap();
let directory = Directory(executable.parent().unwrap().join(&name));
std::fs::create_dir(&directory.0).unwrap();
let settings = directory.0.join("setting.json");
std::fs::write(&settings, serde_json::json!({"database":{"default":{"type":"sqlite","path":format!("{name}/entry.db"),"max_connections":1}}}).to_string()).unwrap();
Runtime::new(Default::default()).unwrap().run(async |scope| {
let registry = Registry::load(&settings, &authorization::schema::BINDINGS, scope).await.unwrap();
let mut database = DatabaseContext::new(registry);
let executor = database.on(1, scope).await.unwrap();
executor.execute(scope, Sql::same("INSERT INTO _dever_permission(key,component,domain,site,action,method,kind,active) VALUES ('news.article.admin.create','news','article','admin','create','POST','http',1)"), vec![]).await.unwrap();
assert!(matches!(authorization::authorize_entry(&mut database, 1, scope, &mut context, "news", "article", "create").await, Err(Failure::Access(403))));
assert_eq!(context.entry_namespace().unwrap_err(), Error::Forbidden);
authorization::owner(database.on(1, scope).await.unwrap(), scope, "admin", 7).await.unwrap();
let transaction = database.enter();
authorization::authorize_entry(&mut database, 1, scope, &mut context, "news", "article", "create").await.unwrap();
assert_eq!(authorization::permissions(&mut database, 1, scope, &context, true).await.unwrap().len(), 1);
database.finish(scope, transaction, true).await.unwrap();
assert_eq!(context.entry_namespace().unwrap(), ("news", "article"));
assert_eq!(context.site(), "admin");
database.on(1, scope).await.unwrap().execute(scope, "UPDATE _dever_permission SET active=0", vec![]).await.unwrap();
assert!(authorization::permissions(&mut database, 1, scope, &context, true).await.unwrap().is_empty());
assert!(matches!(authorization::authorize_key(&mut database, 1, scope, &context, "news.article.admin.create").await, Err(Failure::Access(403))));
}).unwrap();
}
+119
View File
@@ -0,0 +1,119 @@
use super::request::{configured, request};
use dever_runtime::{
auth::{CookieOptions, Error, SameSite},
value::secret::Secret,
};
fn options() -> CookieOptions {
CookieOptions {
path: "/".into(),
domain: None,
max_age: None,
same_site: SameSite::Lax,
secure: true,
http_only: true,
}
}
#[test]
fn metadata_guards_secret_values_cookie_origin_and_response_fields() {
let service = configured(Some("https://unit.test"), "user.account.verify").unwrap();
let request = request(
"POST",
&[
("Host", "unit.test"),
("Origin", "https://unit.test"),
("Cookie", "theme=light; private=v1.c2VjcmV0"),
("X-Test", "value"),
("X-Forwarded-For", "203.0.113.9"),
],
);
let (mut context, _) = service
.prepare_at(
"admin",
&request,
true,
100,
"127.0.0.1:1234".parse().unwrap(),
)
.unwrap();
assert_eq!(context.client_address().unwrap(), "127.0.0.1");
assert_eq!(context.request_id().unwrap(), context.request_id().unwrap());
assert_eq!(context.header("X-Test").unwrap().as_deref(), Some("value"));
assert_eq!(context.cookie("theme").unwrap().as_deref(), Some("light"));
assert!(matches!(context.cookie("private"), Err(Error::Forbidden)));
assert!(context.secret_cookie("private").unwrap().is_some());
assert!(context.secret_cookie("theme").unwrap().is_none());
for name in [
"Authorization",
"Cookie",
"Set-Cookie",
"Proxy-Authorization",
"X-Api-Key",
] {
assert!(context.header(name).is_err());
}
for name in [
"Set-Cookie",
"Content-Length",
"Content-Type",
"Connection",
"Upgrade",
] {
assert!(context.set_header(name, "value").is_err());
}
assert!(
context
.set_header("X-Test", "value\r\nInjected: yes")
.is_err()
);
assert!(context.set_header("X-Test", &"x".repeat(9000)).is_err());
assert!(
context
.set_cookie("session", "cannot overwrite provider", options())
.is_err()
);
assert!(
context
.set_cookie("theme", "bad; value", options())
.is_err()
);
assert!(
context
.set_secret_cookie(
"private",
&Secret::from_input(b"bytes".to_vec()),
CookieOptions {
secure: false,
..options()
}
)
.is_err()
);
context
.set_secret_cookie("private", &Secret::from_input(b"bytes".to_vec()), options())
.unwrap();
assert!(
context
.set_cookie(
"cross",
"value",
CookieOptions {
same_site: SameSite::None,
secure: false,
..options()
}
)
.is_err()
);
context.set_header("X-Test", "response").unwrap();
let mut response = dever_runtime::api::response("{}");
context.publish(&mut response);
assert!(
response
.headers()
.get_all("set-cookie")
.iter()
.any(|header| header.as_bytes().starts_with(b"private=v1.Ynl0ZXM;"))
);
}
+131
View File
@@ -0,0 +1,131 @@
use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD};
use dever_runtime::{
adapter::Document,
auth::{Claims, Settings},
value::secret::Secret,
};
use hmac::{Hmac, Mac};
use sha2::Sha256;
#[cfg(feature = "authorization")]
mod entry;
mod metadata;
mod request;
mod tenant;
const KEY: &str = "unit-test-only-key-not-for-deployment";
fn settings() -> Settings {
let document = Document::parse(serde_json::json!({"auth":{"providers":{
"session":{"verify":"user.account.verify","jwtSecret":KEY,"ttlSeconds":60,"cookie":"session"},
"other":{"verify":"user.account.verify","jwtSecret":KEY,"ttlSeconds":60}
}}}).to_string()).unwrap();
Settings::load(&document).unwrap()
}
fn signed(header: &str, payload: &str, key: &str) -> Secret {
let signed = format!(
"{}.{}",
URL_SAFE_NO_PAD.encode(header),
URL_SAFE_NO_PAD.encode(payload)
);
let mut mac = Hmac::<Sha256>::new_from_slice(key.as_bytes()).unwrap();
mac.update(signed.as_bytes());
Secret::from_input(
format!(
"{signed}.{}",
URL_SAFE_NO_PAD.encode(mac.finalize().into_bytes())
)
.into_bytes(),
)
}
#[test]
fn signed_claims_remain_bound_to_provider_site_session_and_time() {
let settings = settings();
let provider = &settings.providers["session"];
let claims = Claims {
subject: "账号".into(),
session: "opaque-session".into(),
tenant: None,
site: "admin".into(),
};
let token = provider.issue(&claims, 100).unwrap();
assert_eq!(provider.verify_token(&token, "admin", 100).unwrap(), claims);
assert_eq!(provider.verify_token(&token, "admin", 159).unwrap(), claims);
assert!(provider.verify_token(&token, "admin", 160).is_err());
assert!(provider.verify_token(&token, "admin", 99).is_err());
assert!(provider.verify_token(&token, "front", 100).is_err());
assert!(
settings.providers["other"]
.verify_token(&token, "admin", 100)
.is_err()
);
assert!(!format!("{token:?}").contains("opaque-session"));
}
#[test]
fn token_validation_rejects_algorithm_confusion_tampering_and_ambiguous_claims() {
let settings = settings();
let provider = &settings.providers["session"];
let payload =
r#"{"iss":"session","aud":"admin","sub":"u","sid":"s","tenant":null,"iat":100,"exp":160}"#;
let header = r#"{"alg":"HS256","typ":"JWT"}"#;
assert!(
provider
.verify_token(&signed(header, payload, KEY), "admin", 100)
.is_ok()
);
for token in [
signed(r#"{"alg":"none","typ":"JWT"}"#, payload, KEY),
signed(r#"{"alg":"RS256","typ":"JWT"}"#, payload, KEY),
signed(header, payload, "different-unit-test-signing-key"),
signed(
header,
&payload.replace("\"sub\":\"u\"", "\"sub\":\"u\",\"sub\":\"v\""),
KEY,
),
signed(
header,
&payload.replace("\"sub\":\"u\"", "\"sub\":\"\""),
KEY,
),
signed(header, &payload.replace("\"exp\":160", "\"exp\":161"), KEY),
signed(header, &payload.replace("\"iat\":100", "\"iat\":101"), KEY),
signed(header, &payload.replace("\"iat\":100", "\"iat\":-1"), KEY),
signed(header, &payload.replace("\"exp\":160", "\"exp\":1e9"), KEY),
signed(
header,
&payload.replace("\"tenant\":null,", "\"permissions\":[\"*\"],"),
KEY,
),
Secret::from_input(vec![b'a'; 8193]),
] {
assert_eq!(
provider.verify_token(&token, "admin", 100).unwrap_err(),
"invalid authentication"
);
}
}
#[test]
fn authentication_settings_require_deployment_key_and_unambiguous_fields() {
for value in [
serde_json::json!({"verify":"user.account.verify","jwtSecret":"short","ttlSeconds":60}),
serde_json::json!({"verify":"user.account.verify","jwtSecret":KEY,"ttlSeconds":0}),
serde_json::json!({"verify":"user.account.verify","jwtSecret":KEY,"ttlSeconds":60,"cookie":"session; bad"}),
serde_json::json!({"verify":"user.account","jwtSecret":KEY,"ttlSeconds":60}),
serde_json::json!({"verify":"user.account.verify","jwtSecret":KEY,"ttlSeconds":60,"algorithm":"none"}),
] {
let document = Document::parse(
serde_json::json!({"auth":{"providers":{"session":value}}}).to_string(),
)
.unwrap();
assert!(Settings::load(&document).is_err());
}
assert!(
Settings::load(&Document::parse("{}".into()).unwrap())
.unwrap()
.providers
.is_empty()
);
}
+198
View File
@@ -0,0 +1,198 @@
use super::*;
use dever_runtime::{
api,
api::Request,
auth::{Error, Identity, Route, Service, Verification},
};
use std::sync::{
Arc,
atomic::{AtomicU64, Ordering},
};
static NEXT: AtomicU64 = AtomicU64::new(0);
pub(super) fn configured(origin: Option<&str>, verify: &str) -> Result<Service, String> {
let path = std::env::temp_dir().join(format!(
"dever-auth-config-{}-{}.json",
std::process::id(),
NEXT.fetch_add(1, Ordering::Relaxed)
));
let settings = serde_json::json!({
"http":{"host":"127.0.0.1","port":8181},
"sites":{"admin":{"path":"admin","auth":"session","hosts":["unit.test"],"origin":origin}},
"auth":{"providers":{"session":{"verify":verify,"jwtSecret":KEY,"ttlSeconds":60,"cookie":"session"}}}
});
std::fs::write(&path, settings.to_string()).unwrap();
let config = api::Config::load(&path, &["admin"]);
std::fs::remove_file(path).unwrap();
Service::new(
Arc::new(config?),
&[Verification {
name: "user.account.verify",
user_model: 13,
tenant_model: 17,
}],
&[Route {
topic: "admin",
required: true,
user_models: &[],
tenant_models: &[],
}],
)
}
pub(super) fn request(method: &str, fields: &[(&str, &str)]) -> Request {
let mut request = hyper::Request::builder()
.method(method)
.uri("/user/account/admin/me");
for (name, value) in fields {
request = request.header(*name, *value);
}
request.body(Default::default()).unwrap()
}
pub(super) fn cookie(service: &Service) -> String {
let request = request(
"POST",
&[("Host", "unit.test"), ("Origin", "https://unit.test")],
);
let (mut context, claims) = service.prepare("admin", &request, true, 100).unwrap();
assert!(claims.is_none());
context
.issue_cookie("user", "session-id", None, 100)
.unwrap();
let mut response = api::response("{}");
assert!(!response.headers().contains_key("set-cookie"));
context.publish(&mut response);
let cookie = response.headers().get("set-cookie").unwrap();
let value = std::str::from_utf8(cookie.as_bytes()).unwrap();
assert!(value.contains("Secure; HttpOnly; SameSite=Lax; Max-Age=60"));
value.split(';').next().unwrap().into()
}
#[test]
fn authentication_needs_compiled_verifier_before_it_establishes_identity() {
assert!(configured(None, "user.account.verify").is_err());
assert!(configured(Some("https://unit.test"), "user.account.arbitrary").is_err());
for origin in [
"https://unit.test/path",
"https://unit.test?query",
"https://user@unit.test",
"https://unit.test:bad",
"https://unit.test:99999",
"https://unit.test:0",
] {
assert!(
configured(Some(origin), "user.account.verify").is_err(),
"{origin}"
);
}
let service = configured(Some("https://unit.test"), "user.account.verify").unwrap();
let cookie = cookie(&service);
let request = request("GET", &[("Host", "unit.test"), ("Cookie", &cookie)]);
let (mut context, claims) = service.prepare("admin", &request, false, 100).unwrap();
assert_eq!(context.id().unwrap_err(), Error::Unauthorized);
assert_eq!(claims.unwrap().subject, "user");
assert_eq!(context.verifier(), Some("user.account.verify"));
context
.establish(Identity {
id: "verified-user".into(),
user_id: Some(7),
tenant_id: None,
})
.unwrap();
assert_eq!(context.id().unwrap(), "verified-user");
assert_eq!(context.session().unwrap(), "session-id");
assert_eq!(context.user_id(Some(13)).unwrap(), Some(7));
assert_eq!(context.user_id(Some(17)).unwrap_err(), Error::Internal);
}
#[test]
fn cookie_writes_require_exact_external_origin_and_host() {
let service = configured(Some("https://unit.test"), "user.account.verify").unwrap();
let cookie = cookie(&service);
for fields in [
vec![("Host", "unit.test")],
vec![("Host", "unit.test"), ("Origin", "null")],
vec![("Host", "unit.test"), ("Origin", "http://unit.test")],
vec![("Host", "unit.test"), ("Origin", "https://unit.test:444")],
vec![
("Host", "internal.test"),
("Origin", "https://unit.test"),
("X-Forwarded-Host", "unit.test"),
],
vec![("Host", "unit.test:bad"), ("Origin", "https://unit.test")],
vec![
("Host", "unit.test"),
("Origin", "https://unit.test"),
("Origin", "https://unit.test"),
],
] {
let mut fields = fields;
fields.push(("Cookie", &cookie));
let request = request("POST", &fields);
assert!(matches!(
service.prepare("admin", &request, false, 100),
Err(Error::Forbidden)
));
let (mut public, _) = service.prepare("admin", &request, true, 100).unwrap();
assert_eq!(public.clear_cookie().unwrap_err(), Error::Forbidden);
let mut response = api::response("{}");
public.publish(&mut response);
assert!(!response.headers().contains_key("set-cookie"));
}
let request = request(
"POST",
&[
("Host", "UNIT.TEST:443"),
("Origin", "https://unit.test"),
("Cookie", &cookie),
],
);
assert!(service.prepare("admin", &request, false, 100).is_ok());
}
#[test]
fn public_ignores_old_cookie_but_rejects_explicit_bad_authorization() {
let service = configured(Some("https://unit.test"), "user.account.verify").unwrap();
let cookie = cookie(&service);
let old = request(
"GET",
&[("Host", "unit.test"), ("Cookie", "session=old-expired")],
);
assert!(
service
.prepare("admin", &old, true, 100)
.unwrap()
.1
.is_none()
);
assert!(matches!(
service.prepare("admin", &old, false, 100),
Err(Error::Unauthorized)
));
for fields in [
vec![("Authorization", "Bearer invalid")],
vec![
("Authorization", "Bearer invalid"),
("Authorization", "Bearer invalid"),
],
vec![
("Cookie", cookie.as_str()),
("Authorization", "Bearer invalid"),
],
vec![("Cookie", cookie.as_str()), ("Cookie", cookie.as_str())],
] {
let request = request("GET", &fields);
assert!(matches!(
service.prepare("admin", &request, false, 100),
Err(Error::Unauthorized)
));
if fields.iter().any(|(name, _)| *name == "Authorization") {
assert!(matches!(
service.prepare("admin", &request, true, 100),
Err(Error::Unauthorized)
));
}
}
}
+98
View File
@@ -0,0 +1,98 @@
use super::{
request::{configured, request},
*,
};
use dever_runtime::auth::{Error, Identity};
#[test]
fn signed_tenant_reference_needs_provider_identity_before_becoming_storage_id() {
let service = configured(Some("https://unit.test"), "user.account.verify").unwrap();
let login = request(
"POST",
&[("Host", "unit.test"), ("Origin", "https://unit.test")],
);
let (mut context, _) = service.prepare("admin", &login, true, 100).unwrap();
let token = context
.issue("user", "session-id", Some("external-tenant"), 100)
.unwrap();
assert_eq!(
settings().providers["session"]
.verify_token(&token, "admin", 100)
.unwrap()
.tenant
.as_deref(),
Some("external-tenant")
);
context
.issue_cookie("user", "session-id", Some("external-tenant"), 100)
.unwrap();
let mut response = dever_runtime::api::response("{}");
context.publish(&mut response);
let value = response.headers().get("set-cookie").unwrap().as_bytes();
let cookie = std::str::from_utf8(value)
.unwrap()
.split(';')
.next()
.unwrap();
let incoming = request("GET", &[("Host", "unit.test"), ("Cookie", cookie)]);
let (mut verified, claims) = service.prepare("admin", &incoming, false, 100).unwrap();
assert_eq!(claims.unwrap().tenant.as_deref(), Some("external-tenant"));
assert_eq!(verified.verified_tenant(), None);
assert_eq!(verified.tenant_id(None).unwrap_err(), Error::Unauthorized);
for id in [0, -1] {
assert_eq!(
verified
.establish(Identity {
id: "verified-user".into(),
user_id: Some(7),
tenant_id: Some(id)
})
.unwrap_err(),
Error::Unauthorized
);
assert_eq!(verified.verified_tenant(), None);
}
verified
.establish(Identity {
id: "verified-user".into(),
user_id: Some(7),
tenant_id: Some(43),
})
.unwrap();
assert_eq!(verified.verified_tenant(), Some(43));
assert_eq!(verified.tenant_id(Some(17)).unwrap(), Some(43));
assert_eq!(verified.tenant_id(Some(13)).unwrap_err(), Error::Internal);
}
#[test]
#[cfg(feature = "job")]
fn restored_job_reverifies_tenant_and_persists_the_internal_id_separately() {
let service = configured(Some("https://unit.test"), "user.account.verify").unwrap();
let claims = Claims {
subject: "user".into(),
session: "session-id".into(),
tenant: Some("external-tenant".into()),
site: "admin".into(),
};
assert!(service.trusted("other", &claims).is_err());
let mut context = service.trusted("session", &claims).unwrap();
assert_eq!(context.verified_tenant(), None);
assert!(
context
.job_execution("news.article.admin.create".into())
.is_err()
);
context
.establish(Identity {
id: "verified-user".into(),
user_id: Some(7),
tenant_id: Some(43),
})
.unwrap();
let execution = context
.job_execution("news.article.admin.create".into())
.unwrap();
assert!(
matches!(execution, dever_runtime::job::Execution::User { tenant: Some(reference), tenant_id: Some(43), .. } if reference == "external-tenant")
);
}
+335
View File
@@ -0,0 +1,335 @@
use crate::{
api,
api::Request,
auth::{Context, Identity, Service},
authorization::{
self as authz, Declaration, Failure,
capability::{self, Tenant},
},
database::{Binding, Context as DatabaseContext, Registry, Sql, Storage},
task::Runtime,
};
use std::{
path::PathBuf,
sync::Arc,
time::{SystemTime, UNIX_EPOCH},
};
pub(super) struct Fixture {
directory: PathBuf,
pub(super) setting: PathBuf,
config: Arc<api::Config>,
service: Service,
request: Request,
}
impl Fixture {
pub(super) fn new() -> Self {
let unique = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_nanos();
let name = format!("dever-tenant-auth-{}-{unique}", std::process::id());
let executable = std::env::current_exe().unwrap();
let directory = executable.parent().unwrap().join(&name);
std::fs::create_dir(&directory).unwrap();
let setting = directory.join("setting.json");
std::fs::write(&setting, serde_json::json!({
"http":{"host":"127.0.0.1","port":8181},
"sites":{"admin":{"path":"admin","hosts":["unit.test"]}},
"database":{"default":{"type":"sqlite","path":format!("{name}/control.db"),"tenant_directory":format!("{name}/tenants"),"max_connections":1}},
"tenant":{"database":"default","max_pools":4,"idle_timeout_ms":1000}
}).to_string()).unwrap();
let config = Arc::new(api::Config::load(&setting, &["admin"]).unwrap());
let service = Service::new(config.clone(), &[], &[]).unwrap();
Self {
directory,
setting,
config,
service,
request: hyper::Request::builder()
.method("POST")
.uri("/news/article/admin/create")
.body(Default::default())
.unwrap(),
}
}
fn identity(&self, tenant: i64, user: i64) -> Context<'_> {
let (mut context, _) = self
.service
.prepare("admin", &self.request, true, 100)
.unwrap();
context.identity_for_authorization_test(Identity {
id: "verified-user".into(),
user_id: Some(user),
tenant_id: Some(tenant),
});
context
}
async fn registry(&self, scope: &crate::task::Scope<'_>) -> Arc<Registry> {
let mut bindings = authz::schema::BINDINGS.to_vec();
bindings.push(Binding {
storage: Storage::Tenant,
component: "news",
database: None,
table: "article",
schema: Sql::same("CREATE TABLE article(id BIGINT PRIMARY KEY)"),
plan: None,
references: &[],
});
let registry = Registry::load(&self.setting, &bindings, scope)
.await
.unwrap();
registry.migrate_tenant(scope, 17).await.unwrap();
registry.migrate_tenant(scope, 18).await.unwrap();
let database = DatabaseContext::new(registry.clone());
let mut control = database.control(scope).await.unwrap();
authz::synchronize(
&mut control,
scope,
&self.config,
&[Declaration {
topic: "admin",
component: "news",
domain: "article",
action: "create",
method: "POST",
}],
)
.await
.unwrap();
registry
}
}
impl Drop for Fixture {
fn drop(&mut self) {
std::fs::remove_dir_all(&self.directory).unwrap();
}
}
#[test]
fn authorization_routes_control_catalog_and_tenant_roles_without_cross_database_joins() {
let fixture = Fixture::new();
Runtime::new(Default::default())
.unwrap()
.run(async |scope| routing(&fixture, scope).await)
.unwrap();
}
pub(super) async fn routing(fixture: &Fixture, scope: &crate::task::Scope<'_>) {
let registry = fixture.registry(scope).await;
let mut first = DatabaseContext::new(registry.clone());
first.select_tenant(scope, 17).await.unwrap();
let mut second = DatabaseContext::new(registry.clone());
second.select_tenant(scope, 18).await.unwrap();
authz::owner(first.on(1, scope).await.unwrap(), scope, "admin", 7)
.await
.unwrap();
let mut actor = fixture.identity(17, 7);
authz::authorize_entry(
&mut first, 1, scope, &mut actor, "news", "article", "create",
)
.await
.unwrap();
assert_eq!(actor.entry_namespace().unwrap(), ("news", "article"));
let tenant_tables = first
.on(1, scope)
.await
.unwrap()
.query(
scope,
Sql {
sqlite: "SELECT COUNT(*) FROM sqlite_master WHERE name='_dever_permission'",
postgres: "SELECT COUNT(*) FROM information_schema.tables WHERE table_schema='public' AND table_name='_dever_permission'",
},
vec![],
1,
|row| row.get::<_, i64>(0),
)
.await
.unwrap();
assert_eq!(tenant_tables, [0]);
let key = capability::register_capability(
&mut Tenant::open(&mut first, 1, scope, &actor).await.unwrap(),
scope,
&actor,
[1; 16],
)
.await
.unwrap();
authz::save(
&mut first,
1,
scope,
&actor,
"editor",
"Editor",
false,
std::slice::from_ref(&key),
)
.await
.unwrap();
authz::grant(
first.on(1, scope).await.unwrap(),
scope,
&actor,
8,
"editor",
)
.await
.unwrap();
let reader = fixture.identity(17, 8);
let permissions = authz::permissions(&mut first, 1, scope, &reader, true)
.await
.unwrap();
assert_eq!(permissions.len(), 1);
assert_eq!(permissions[0].key, key);
let other = fixture.identity(18, 8);
assert!(
authz::permissions(&mut second, 1, scope, &other, true)
.await
.unwrap()
.is_empty()
);
assert!(matches!(
Tenant::open(&mut second, 1, scope, &actor).await,
Err(Failure::Access(403))
));
assert_eq!(
authz::permissions(&mut first, 1, scope, &actor, true)
.await
.unwrap()
.len(),
2
);
registry
.set_component(scope, 17, "news", false)
.await
.unwrap();
assert!(matches!(
authz::authorize_key(&mut first, 1, scope, &actor, "news.article.admin.create").await,
Err(Failure::Access(403))
));
assert!(
authz::permissions(&mut first, 1, scope, &actor, true)
.await
.unwrap()
.is_empty()
);
registry
.set_component(scope, 17, "news", true)
.await
.unwrap();
authz::authorize_key(&mut first, 1, scope, &actor, "news.article.admin.create")
.await
.unwrap();
drop(first);
drop(second);
registry.close().await;
}
#[test]
fn capability_role_and_model_changes_share_rollback_and_reject_platform_transactions() {
let fixture = Fixture::new();
Runtime::new(Default::default())
.unwrap()
.run(async |scope| rollback(&fixture, scope).await)
.unwrap();
}
pub(super) async fn rollback(fixture: &Fixture, scope: &crate::task::Scope<'_>) {
let registry = fixture.registry(scope).await;
let mut database = DatabaseContext::new(registry.clone());
database.select_tenant(scope, 17).await.unwrap();
authz::owner(database.on(1, scope).await.unwrap(), scope, "admin", 7)
.await
.unwrap();
let mut actor = fixture.identity(17, 7);
authz::authorize_entry(
&mut database,
1,
scope,
&mut actor,
"news",
"article",
"create",
)
.await
.unwrap();
let transaction = database.enter();
database
.on(5, scope)
.await
.unwrap()
.execute(scope, "INSERT INTO article VALUES (1)", vec![])
.await
.unwrap();
let key = capability::register_capability(
&mut Tenant::open(&mut database, 1, scope, &actor).await.unwrap(),
scope,
&actor,
[2; 16],
)
.await
.unwrap();
authz::save(
&mut database,
1,
scope,
&actor,
"rolled-back",
"Rollback",
false,
&[key],
)
.await
.unwrap();
assert_eq!(
authz::permissions(&mut database, 1, scope, &actor, false)
.await
.unwrap()
.len(),
2
);
assert!(database.on(0, scope).await.is_err());
database.finish(scope, transaction, false).await.unwrap();
assert_eq!(
authz::permissions(&mut database, 1, scope, &actor, false)
.await
.unwrap()
.len(),
1
);
assert!(
authz::roles(
database.on(1, scope).await.unwrap(),
scope,
&actor,
authz::Selector::Id("rolled-back".into())
)
.await
.unwrap()
.is_empty()
);
assert_eq!(
database
.on(5, scope)
.await
.unwrap()
.query(scope, "SELECT COUNT(*) FROM article", vec![], 1, |row| row
.get::<_, i64>(
0
))
.await
.unwrap(),
[0]
);
let mut missing = DatabaseContext::new(registry.clone());
assert!(missing.select_tenant(scope, 19).await.is_err());
drop(missing);
drop(database);
registry.close().await;
}
@@ -0,0 +1,7 @@
use super::{Context, Identity};
impl Context<'_> {
pub(crate) fn identity_for_authorization_test(&mut self, identity: Identity) {
self.identity = Some(identity);
}
}
+272
View File
@@ -0,0 +1,272 @@
use super::*;
use crate::{
api,
api::Request,
auth::{Identity, Service},
database::{Database, Options},
task::Runtime,
};
use std::{
path::PathBuf,
sync::{
Arc,
atomic::{AtomicU64, Ordering},
},
};
mod mutations;
static NEXT: AtomicU64 = AtomicU64::new(0);
const ID: [u8; 16] = [1, 144, 0, 0, 0, 0, 112, 0, 128, 0, 0, 0, 0, 0, 0, 1];
const KEY: &str = "capability:news.article.admin.01900000-0000-7000-8000-000000000001";
#[cfg(feature = "postgres")]
impl<'database> Tenant<'database> {
pub(crate) fn for_test(executor: &'database mut Executor, id: i64) -> Self {
Self { executor, id }
}
}
struct Fixture {
directory: PathBuf,
service: Service,
}
impl Fixture {
fn new() -> Self {
let directory = std::env::temp_dir().join(format!(
"dever-capability-{}-{}",
std::process::id(),
NEXT.fetch_add(1, Ordering::Relaxed)
));
std::fs::create_dir(&directory).unwrap();
let setting = directory.join("setting.json");
std::fs::write(&setting, r#"{"http":{"host":"127.0.0.1","port":8181},"sites":{"admin":{"path":"admin","hosts":["unit.test"]},"front":{"path":"front","hosts":["unit.test"]}}}"#).unwrap();
let config = api::Config::load(&setting, &["admin", "front"]).unwrap();
let service = Service::new(Arc::new(config), &[], &[]).unwrap();
Self { directory, service }
}
fn context<'request>(
&'request self,
request: &'request Request,
site: &str,
tenant: Option<i64>,
entry: Option<(&'static str, &'static str)>,
) -> Context<'request> {
let (mut context, _) = self.service.prepare(site, request, true, 100).unwrap();
context.identity_for_authorization_test(Identity {
id: "test-only".into(),
user_id: Some(7),
tenant_id: tenant,
});
if let Some((component, domain)) = entry {
context.bind_entry(component, domain);
}
context
}
async fn database(&self, scope: &Scope<'_>, name: &str, initialize: bool) -> Database {
let database = Database::open(
&self.directory,
Options {
driver: "sqlite".into(),
path: format!("{name}.db"),
url: None,
tls: None,
tenant_directory: None,
tenant_database_prefix: None,
min_connections: 1,
max_connections: 1,
max_page_size: 100,
wait_timeout_ms: 1000,
io_timeout_ms: 1000,
},
scope,
)
.await
.unwrap();
if initialize {
let mut executor = database.executor().await.unwrap();
for binding in &crate::authorization::schema::BINDINGS[1..] {
executor.initialize(scope, binding.schema).await.unwrap();
}
}
database
}
}
impl Drop for Fixture {
fn drop(&mut self) {
std::fs::remove_dir_all(&self.directory).unwrap();
}
}
fn request(method: &str) -> Request {
hyper::Request::builder()
.method(method)
.uri("/test")
.body(Default::default())
.unwrap()
}
fn denied<Value>(result: Result<Value, Failure>) {
assert!(matches!(result, Err(Failure::Access(403))));
}
#[test]
fn tenant_and_entry_guards_run_before_database_access() {
let fixture = Fixture::new();
let post = request("POST");
let get = request("GET");
Runtime::new(Default::default())
.unwrap()
.run(async |scope| {
let database = fixture.database(scope, "empty", false).await;
let mut executor = database.executor().await.unwrap();
let mut tenant = Tenant {
executor: &mut executor,
id: 1,
};
for tenant_id in [None, Some(0), Some(-1), Some(2)] {
let context = fixture.context(&post, "admin", tenant_id, Some(("news", "article")));
denied(register_capability(&mut tenant, scope, &context, ID).await);
denied(remove_capability(&mut tenant, scope, &context, ID).await);
denied(has_protected_roles(&mut tenant, scope, &context, 7).await);
denied(capabilities(&mut tenant, scope, &context, false).await);
}
for (request, entry) in [(&post, None), (&get, Some(("news", "article")))] {
let context = fixture.context(request, "admin", Some(1), entry);
denied(register_capability(&mut tenant, scope, &context, ID).await);
denied(remove_capability(&mut tenant, scope, &context, ID).await);
}
let (mut anonymous, _) = fixture.service.prepare("admin", &post, true, 100).unwrap();
assert!(
anonymous
.establish(Identity {
id: "u".into(),
user_id: Some(7),
tenant_id: Some(1)
})
.is_err()
);
assert!(matches!(
has_protected_roles(&mut tenant, scope, &anonymous, 7).await,
Err(Failure::Access(401))
));
})
.unwrap();
}
#[test]
fn capabilities_use_entry_namespace_and_distinct_physical_tenant_stores() {
let fixture = Fixture::new();
let post = request("POST");
Runtime::new(Default::default())
.unwrap()
.run(async |scope| {
let database = fixture.database(scope, "first", true).await;
let mut executor = database.executor().await.unwrap();
let mut tenant = Tenant {
executor: &mut executor,
id: 1,
};
let context = fixture.context(&post, "admin", Some(1), Some(("news", "article")));
for _ in 0..2 {
assert_eq!(
register_capability(&mut tenant, scope, &context, ID)
.await
.unwrap(),
KEY
);
}
let other = fixture.context(&post, "admin", Some(1), Some(("other", "article")));
let other_key = register_capability(&mut tenant, scope, &other, ID)
.await
.unwrap();
assert_ne!(other_key, KEY);
let front = fixture.context(&post, "front", Some(1), Some(("news", "article")));
let front_key = register_capability(&mut tenant, scope, &front, ID)
.await
.unwrap();
assert_ne!(front_key, KEY);
let listed = capabilities(&mut tenant, scope, &context, false)
.await
.unwrap();
assert_eq!(listed.len(), 2);
assert!(listed.iter().all(|value| value.site == "admin"
&& value.kind == "capability"
&& value.method.is_empty()));
assert_eq!(listed[0].action, "01900000-0000-7000-8000-000000000001");
assert_eq!(
capabilities(&mut tenant, scope, &front, false)
.await
.unwrap()
.len(),
1
);
let second = fixture.database(scope, "second", true).await;
let mut second_executor = second.executor().await.unwrap();
let mut second_tenant = Tenant {
executor: &mut second_executor,
id: 2,
};
let second_context =
fixture.context(&post, "admin", Some(2), Some(("news", "article")));
assert!(
capabilities(&mut second_tenant, scope, &second_context, false)
.await
.unwrap()
.is_empty()
);
assert_eq!(
register_capability(&mut second_tenant, scope, &second_context, ID)
.await
.unwrap(),
KEY
);
remove_capability(&mut tenant, scope, &context, ID)
.await
.unwrap();
assert_eq!(
capabilities(&mut second_tenant, scope, &second_context, false)
.await
.unwrap()
.len(),
1
);
assert_eq!(
capabilities(&mut tenant, scope, &other, false)
.await
.unwrap()[0]
.key,
other_key
);
})
.unwrap();
}
#[test]
fn protected_roles_span_sites_and_all_permission_roles_follow_the_active_catalog() {
let fixture = Fixture::new();
let post = request("POST");
let get = request("GET");
Runtime::new(Default::default()).unwrap().run(async |scope| {
let database = fixture.database(scope, "roles", true).await;
let mut executor = database.executor().await.unwrap();
executor.initialize(scope, "INSERT INTO _dever_role VALUES ('front','owner','Owner',1,0,1),('admin','all','All',1,1,0); INSERT INTO _dever_user_role VALUES ('front',7,'owner'),('admin',7,'all');").await.unwrap();
let mut tenant = Tenant { executor: &mut executor, id: 1 };
let context = fixture.context(&get, "admin", Some(1), None);
assert!(has_protected_roles(&mut tenant, scope, &context, 7).await.unwrap());
assert!(!has_protected_roles(&mut tenant, scope, &context, 8).await.unwrap());
assert!(matches!(has_protected_roles(&mut tenant, scope, &context, 0).await, Err(Failure::Access(400))));
let writer = fixture.context(&post, "admin", Some(1), Some(("news", "article")));
register_capability(&mut tenant, scope, &writer, ID).await.unwrap();
assert_eq!(capabilities(&mut tenant, scope, &context, true).await.unwrap().len(), 1);
remove_capability(&mut tenant, scope, &writer, ID).await.unwrap();
assert!(capabilities(&mut tenant, scope, &context, true).await.unwrap().is_empty());
register_capability(&mut tenant, scope, &writer, ID).await.unwrap();
tenant.executor.execute(scope, "UPDATE _dever_role SET enabled=0 WHERE site='admin'", vec![]).await.unwrap();
assert!(capabilities(&mut tenant, scope, &context, true).await.unwrap().is_empty());
}).unwrap();
}
@@ -0,0 +1,89 @@
use super::*;
#[test]
fn capability_directory_includes_permissions_beyond_the_model_page_limit() {
let fixture = Fixture::new();
let post = request("POST");
Runtime::new(Default::default())
.unwrap()
.run(async |scope| {
let database = fixture.database(scope, "pages", true).await;
let mut executor = database.executor().await.unwrap();
crate::authorization::owner(&mut executor, scope, "admin", 7)
.await
.unwrap();
let mut tenant = Tenant {
executor: &mut executor,
id: 1,
};
let context = fixture.context(&post, "admin", Some(1), Some(("news", "article")));
for value in 1u128..=101 {
register_capability(&mut tenant, scope, &context, value.to_be_bytes())
.await
.unwrap();
}
for current in [false, true] {
let entries = capabilities(&mut tenant, scope, &context, current)
.await
.unwrap();
assert_eq!(entries.len(), 101);
assert!(entries.windows(2).all(|pair| pair[0].key < pair[1].key));
}
})
.unwrap();
}
#[test]
fn removal_is_atomic_clears_all_grants_and_reregistration_does_not_restore_them() {
let fixture = Fixture::new();
let post = request("POST");
Runtime::new(Default::default()).unwrap().run(async |scope| {
let database = fixture.database(scope, "removal", true).await;
let mut executor = database.executor().await.unwrap();
let mut tenant = Tenant { executor: &mut executor, id: 1 };
let context = fixture.context(&post, "admin", Some(1), Some(("news", "article")));
register_capability(&mut tenant, scope, &context, ID).await.unwrap();
tenant.executor.initialize(scope, "INSERT INTO _dever_role VALUES ('admin','reader','Reader',0,1,0),('admin','second','Second',0,1,0); INSERT INTO _dever_user_role VALUES ('admin',7,'reader');").await.unwrap();
for role in ["reader", "second"] {
tenant.executor.execute(scope, "INSERT INTO _dever_role_permission VALUES ('admin',$1,$2)", vec![Parameter::Text(role.into()), Parameter::Text(KEY.into())]).await.unwrap();
}
assert_eq!(capabilities(&mut tenant, scope, &context, true).await.unwrap().len(), 1);
let owner = tenant.executor.begin(scope).await.unwrap();
remove_capability(&mut tenant, scope, &context, ID).await.unwrap();
assert!(capabilities(&mut tenant, scope, &context, true).await.unwrap().is_empty());
tenant.executor.finish(scope, owner, false).await.unwrap();
assert_eq!(capabilities(&mut tenant, scope, &context, true).await.unwrap().len(), 1);
remove_capability(&mut tenant, scope, &context, ID).await.unwrap();
remove_capability(&mut tenant, scope, &context, ID).await.unwrap();
let counts = tenant.executor.query(scope, "SELECT COUNT(*) FROM _dever_role_permission", vec![], 1, |row| row.get::<_, i64>(0)).await.unwrap();
assert_eq!(counts, [0]);
register_capability(&mut tenant, scope, &context, ID).await.unwrap();
assert!(capabilities(&mut tenant, scope, &context, true).await.unwrap().is_empty());
assert_eq!(capabilities(&mut tenant, scope, &context, false).await.unwrap().len(), 1);
}).unwrap();
}
#[test]
fn registration_and_models_share_rollback_and_failed_removal_keeps_the_catalog_active() {
let fixture = Fixture::new();
let post = request("POST");
Runtime::new(Default::default()).unwrap().run(async |scope| {
let database = fixture.database(scope, "rollback", true).await;
let mut executor = database.executor().await.unwrap();
executor.initialize(scope, "CREATE TABLE article(id BIGINT PRIMARY KEY);").await.unwrap();
let mut tenant = Tenant { executor: &mut executor, id: 1 };
let context = fixture.context(&post, "admin", Some(1), Some(("news", "article")));
let owner = tenant.executor.begin(scope).await.unwrap();
tenant.executor.execute(scope, "INSERT INTO article VALUES (1)", vec![]).await.unwrap();
register_capability(&mut tenant, scope, &context, ID).await.unwrap();
tenant.executor.finish(scope, owner, false).await.unwrap();
assert!(capabilities(&mut tenant, scope, &context, false).await.unwrap().is_empty());
assert_eq!(tenant.executor.query(scope, "SELECT COUNT(*) FROM article", vec![], 1, |row| row.get::<_, i64>(0)).await.unwrap(), [0]);
register_capability(&mut tenant, scope, &context, ID).await.unwrap();
tenant.executor.initialize(scope, "CREATE TRIGGER reject_removal BEFORE DELETE ON _dever_role_permission BEGIN SELECT RAISE(ABORT, 'test-only failure'); END; INSERT INTO _dever_role VALUES ('admin','reader','Reader',0,1,0);").await.unwrap();
tenant.executor.execute(scope, "INSERT INTO _dever_role_permission VALUES ('admin','reader',$1)", vec![Parameter::Text(KEY.into())]).await.unwrap();
assert!(matches!(remove_capability(&mut tenant, scope, &context, ID).await, Err(Failure::Database(_))));
assert_eq!(capabilities(&mut tenant, scope, &context, false).await.unwrap().len(), 1);
assert_eq!(tenant.executor.query(scope, "SELECT COUNT(*) FROM _dever_role_permission", vec![], 1, |row| row.get::<_, i64>(0)).await.unwrap(), [1]);
}).unwrap();
}
@@ -0,0 +1,87 @@
//! 行锁测试固定使用已借用连接;目录路由的真实 Context 验证由租户集成用例覆盖。
pub(super) use crate::authorization::{
disable, grant, owner, remove, revoke, roles, schema, synchronize,
};
use crate::{
auth::Context,
authorization::{Failure, Permission, access, capability, catalog, roles as storage},
database::Executor,
task::Scope,
};
pub(super) async fn permissions(
executor: &mut Executor,
scope: &Scope<'_>,
context: &Context<'_>,
current: bool,
) -> Result<Vec<Permission>, Failure> {
context.id()?;
let mut entries = catalog::read(executor, scope, context.site(), None).await?;
if let Some(id) = context.verified_tenant() {
let mut tenant = capability::Tenant::for_test(executor, id);
entries.extend(capability::capabilities(&mut tenant, scope, context, false).await?);
}
if current {
let Some(user) = context.user_id(None)? else {
return Ok(Vec::new());
};
access::filter(executor, scope, context, user, &mut entries).await?;
}
entries.sort_unstable_by(|left, right| left.key.cmp(&right.key));
Ok(entries)
}
pub(super) async fn authorize_key(
executor: &mut Executor,
scope: &Scope<'_>,
context: &Context<'_>,
key: &str,
) -> Result<(), Failure> {
context.id()?;
if !catalog::read(executor, scope, context.site(), None)
.await?
.iter()
.any(|entry| entry.key == key)
{
return Err(Failure::Access(403));
}
storage::granted(executor, scope, context, key).await
}
pub(super) async fn authorize_entry(
executor: &mut Executor,
scope: &Scope<'_>,
context: &mut Context<'_>,
component: &'static str,
domain: &'static str,
action: &str,
) -> Result<(), Failure> {
authorize_key(
executor,
scope,
context,
&format!("{component}.{domain}.{}.{action}", context.site()),
)
.await?;
context.bind_entry(component, domain);
Ok(())
}
pub(super) async fn save(
executor: &mut Executor,
scope: &Scope<'_>,
context: &Context<'_>,
id: &str,
name: &str,
all: bool,
keys: &[String],
) -> Result<(), Failure> {
context.id()?;
let catalog = catalog::read(executor, scope, context.site(), None)
.await?
.into_iter()
.map(|entry| entry.key)
.collect();
storage::save(executor, scope, context, catalog, id, name, all, keys).await
}
@@ -0,0 +1,222 @@
use super::*;
use crate::authorization::capability::{self, Tenant};
const ID: [u8; 16] = [1, 144, 0, 0, 0, 0, 112, 0, 128, 0, 0, 0, 0, 0, 0, 1];
fn context(case: &Case) -> Context<'_> {
let mut context = case.context("admin", Some(USER));
context.identity_for_authorization_test(Identity {
id: "verified-user".into(),
user_id: Some(USER),
tenant_id: Some(17),
});
context.bind_entry("news", "article");
context
}
#[test]
#[ignore = "requires explicit config/setting.json database.postgres_test"]
fn postgres_capabilities_share_role_transactions_and_reregistration_drops_old_grants() {
fixture(async |case, scope| {
let context = context(case);
let mut executor = case.executor(scope).await;
let key = capability::register_capability(
&mut Tenant::for_test(&mut executor, 17),
scope,
&context,
ID,
)
.await
.unwrap();
assert_eq!(
key,
"capability:news.article.admin.01900000-0000-7000-8000-000000000001"
);
authz::save(
&mut executor,
scope,
&context,
"editor",
"Editor",
false,
std::slice::from_ref(&key),
)
.await
.unwrap();
authz::grant(&mut executor, scope, &context, USER, "editor")
.await
.unwrap();
assert_eq!(
authz::permissions(&mut executor, scope, &context, true)
.await
.unwrap()
.len(),
1
);
let transaction = executor.begin(scope).await.unwrap();
capability::remove_capability(
&mut Tenant::for_test(&mut executor, 17),
scope,
&context,
ID,
)
.await
.unwrap();
assert!(
authz::permissions(&mut executor, scope, &context, true)
.await
.unwrap()
.is_empty()
);
executor.finish(scope, transaction, false).await.unwrap();
assert_eq!(
authz::permissions(&mut executor, scope, &context, true)
.await
.unwrap()
.len(),
1
);
capability::remove_capability(
&mut Tenant::for_test(&mut executor, 17),
scope,
&context,
ID,
)
.await
.unwrap();
capability::register_capability(
&mut Tenant::for_test(&mut executor, 17),
scope,
&context,
ID,
)
.await
.unwrap();
assert!(
authz::permissions(&mut executor, scope, &context, true)
.await
.unwrap()
.is_empty()
);
assert_eq!(
case.count(scope, "SELECT COUNT(*) FROM _dever_role_permission")
.await,
0
);
authz::owner(&mut executor, scope, "front", USER)
.await
.unwrap();
assert!(
capability::has_protected_roles(
&mut Tenant::for_test(&mut executor, 17),
scope,
&context,
USER
)
.await
.unwrap()
);
assert!(
!capability::has_protected_roles(
&mut Tenant::for_test(&mut executor, 17),
scope,
&context,
USER + 1
)
.await
.unwrap()
);
assert_eq!(
authz::permissions(&mut executor, scope, &context, false)
.await
.unwrap()[0]
.kind,
"capability"
);
});
}
#[test]
#[ignore = "requires explicit config/setting.json database.postgres_test"]
fn postgres_capability_removal_serializes_with_grants_in_both_lock_orders() {
fixture(async |case, scope| {
let context = context(case);
let mut first = case.executor(scope).await;
let mut second = case.executor(scope).await;
let blocker = case.pid(scope, &mut first).await;
let waiter = case.pid(scope, &mut second).await;
let key = capability::register_capability(
&mut Tenant::for_test(&mut first, 17),
scope,
&context,
ID,
)
.await
.unwrap();
let transaction = first.begin(scope).await.unwrap();
authz::save(
&mut first,
scope,
&context,
"editor",
"Editor",
false,
std::slice::from_ref(&key),
)
.await
.unwrap();
let remove = async {
capability::remove_capability(
&mut Tenant::for_test(&mut second, 17),
scope,
&context,
ID,
)
.await
};
let commit = async {
case.blocked(scope, waiter, blocker).await;
first.finish(scope, transaction, true).await.unwrap();
};
let (removed, ()) = tokio::join!(remove, commit);
removed.unwrap();
assert_eq!(
case.count(scope, "SELECT COUNT(*) FROM _dever_role_permission")
.await,
0
);
capability::register_capability(&mut Tenant::for_test(&mut first, 17), scope, &context, ID)
.await
.unwrap();
let transaction = first.begin(scope).await.unwrap();
capability::remove_capability(&mut Tenant::for_test(&mut first, 17), scope, &context, ID)
.await
.unwrap();
let grant = authz::save(
&mut second,
scope,
&context,
"late",
"Late",
false,
std::slice::from_ref(&key),
);
let commit = async {
case.blocked(scope, waiter, blocker).await;
first.finish(scope, transaction, true).await.unwrap();
};
let (granted, ()) = tokio::join!(grant, commit);
denied(granted, 400);
assert!(
authz::roles(&mut first, scope, &context, Selector::Id("late".into()))
.await
.unwrap()
.is_empty()
);
assert_eq!(
case.count(scope, "SELECT COUNT(*) FROM _dever_role_permission")
.await,
0
);
});
}
@@ -0,0 +1,140 @@
use super::*;
#[test]
#[ignore = "requires explicit config/setting.json database.postgres_test"]
fn postgres_directory_sync_is_atomic_and_never_broadens_owner_permissions() {
fixture(async |case, scope| {
let mut first = case.executor(scope).await;
let mut second = case.executor(scope).await;
let context = case.context("admin", Some(USER));
authz::synchronize(&mut first, scope, &case.config, &DECLARATIONS)
.await
.unwrap();
authz::owner(&mut first, scope, "admin", USER)
.await
.unwrap();
assert_eq!(
authz::permissions(&mut first, scope, &context, false)
.await
.unwrap()
.len(),
2
);
assert_eq!(
authz::permissions(&mut first, scope, &context, true)
.await
.unwrap()
.len(),
2
);
let owner = first.begin(scope).await.unwrap();
authz::synchronize(&mut first, scope, &case.config, &[])
.await
.unwrap();
denied(
authz::authorize_key(&mut first, scope, &context, CREATE).await,
403,
);
authz::authorize_key(&mut second, scope, &context, CREATE)
.await
.unwrap();
first.finish(scope, owner, false).await.unwrap();
authz::authorize_key(&mut first, scope, &context, CREATE)
.await
.unwrap();
let duplicate = [
Declaration {
topic: "admin",
component: "news",
domain: "article",
action: "create",
method: "POST",
},
Declaration {
topic: "admin",
component: "news",
domain: "article",
action: "create",
method: "DELETE",
},
];
denied(
authz::synchronize(&mut first, scope, &case.config, &duplicate).await,
500,
);
authz::authorize_key(&mut first, scope, &context, REMOVE)
.await
.unwrap();
authz::synchronize(&mut first, scope, &case.config, &DECLARATIONS[1..])
.await
.unwrap();
denied(
authz::authorize_key(&mut first, scope, &context, CREATE).await,
403,
);
authz::authorize_key(&mut first, scope, &context, REMOVE)
.await
.unwrap();
assert_eq!(
authz::permissions(&mut first, scope, &context, true)
.await
.unwrap()
.len(),
1
);
});
}
#[test]
#[ignore = "requires explicit config/setting.json database.postgres_test"]
fn postgres_startup_sync_serializes_new_permissions_before_replacing_the_catalog() {
fixture(async |case, scope| {
let mut first = case.executor(scope).await;
let mut second = case.executor(scope).await;
let context = case.context("admin", Some(USER));
let blocker = case.pid(scope, &mut first).await;
let pid = case.pid(scope, &mut second).await;
let owner = first.begin(scope).await.unwrap();
authz::synchronize(&mut first, scope, &case.config, &DECLARATIONS[..1])
.await
.unwrap();
let replace = authz::synchronize(&mut second, scope, &case.config, &DECLARATIONS[1..]);
let commit = async {
case.blocked(scope, pid, blocker).await;
first.finish(scope, owner, true).await.unwrap();
};
let (result, ()) = tokio::join!(replace, commit);
result.unwrap();
authz::owner(&mut first, scope, "admin", USER)
.await
.unwrap();
let active = authz::permissions(&mut first, scope, &context, true)
.await
.unwrap();
assert_eq!(active.len(), 1);
assert_eq!(active[0].key, REMOVE);
denied(
authz::authorize_key(&mut first, scope, &context, CREATE).await,
403,
);
denied(
authz::save(
&mut first,
scope,
&context,
"stale",
"Stale",
false,
&[CREATE.into()],
)
.await,
400,
);
assert!(
authz::roles(&mut first, scope, &context, Selector::Id("stale".into()))
.await
.unwrap()
.is_empty()
);
});
}
@@ -0,0 +1,149 @@
use super::*;
#[test]
#[ignore = "requires explicit config/setting.json database.postgres_test"]
fn postgres_role_mutations_check_protection_after_acquiring_the_row_lock() {
fixture(async |case, scope| {
let context = case.context("admin", Some(USER));
for operation in 0..5 {
let id = format!("role{operation}");
let mut locker = case.executor(scope).await;
authz::save(&mut locker, scope, &context, &id, "Original", false, &[])
.await
.unwrap();
authz::grant(&mut locker, scope, &context, USER, &id)
.await
.unwrap();
let blocker = case.pid(scope, &mut locker).await;
let owner = locker.begin(scope).await.unwrap();
locker
.query(
scope,
"SELECT id FROM _dever_role WHERE site='admin' AND id=$1 FOR UPDATE",
vec![Parameter::Text(id.clone())],
1,
|row| row.get::<_, String>(0),
)
.await
.unwrap();
let mut worker = case.executor(scope).await;
let pid = case.pid(scope, &mut worker).await;
let mutation = async {
match operation {
0 => authz::save(&mut worker, scope, &context, &id, "Changed", true, &[]).await,
1 => authz::grant(&mut worker, scope, &context, USER + 1, &id).await,
2 => authz::revoke(&mut worker, scope, &context, USER, &id).await,
3 => authz::disable(&mut worker, scope, &context, &id).await,
_ => authz::remove(&mut worker, scope, &context, &id).await,
}
};
let protect = async {
case.blocked(scope, pid, blocker).await;
locker
.execute(
scope,
"UPDATE _dever_role SET protected=true WHERE site='admin' AND id=$1",
vec![Parameter::Text(id.clone())],
)
.await
.unwrap();
locker.finish(scope, owner, true).await.unwrap();
};
let (result, ()) = tokio::join!(mutation, protect);
denied(result, 403);
let role = authz::roles(&mut worker, scope, &context, Selector::Id(id))
.await
.unwrap();
assert_eq!(role[0].name, "Original");
assert!(role[0].protected && role[0].enabled && !role[0].all_permissions);
}
assert_eq!(
case.count(scope, "SELECT COUNT(*) FROM _dever_user_role")
.await,
5
);
});
}
#[test]
#[ignore = "requires explicit config/setting.json database.postgres_test"]
fn postgres_concurrent_save_keeps_one_complete_role_and_owner_creation_cannot_be_overwritten() {
fixture(async |case, scope| {
let mut first = case.executor(scope).await;
let mut second = case.executor(scope).await;
authz::synchronize(&mut first, scope, &case.config, &DECLARATIONS)
.await
.unwrap();
let context = case.context("admin", Some(USER));
for index in 0..8 {
let id = format!("race{index}");
let first_keys = [CREATE.into()];
let second_keys = [REMOVE.into()];
let (left, right) = tokio::join!(
authz::save(
&mut first,
scope,
&context,
&id,
"Create",
false,
&first_keys
),
authz::save(
&mut second,
scope,
&context,
&id,
"Remove",
true,
&second_keys
),
);
left.unwrap();
right.unwrap();
let role = authz::roles(&mut first, scope, &context, Selector::Id(id))
.await
.unwrap();
assert!(
(role[0].name == "Create"
&& !role[0].all_permissions
&& role[0].permission_keys == [CREATE])
|| (role[0].name == "Remove"
&& role[0].all_permissions
&& role[0].permission_keys == [REMOVE])
);
}
let (owner, saved) = tokio::join!(
authz::owner(&mut first, scope, "admin", USER),
authz::save(
&mut second,
scope,
&context,
"owner",
"Ordinary",
false,
&[]
),
);
let role = authz::roles(&mut first, scope, &context, Selector::Id("owner".into()))
.await
.unwrap();
if owner.is_ok() {
denied(saved, 403);
assert!(role[0].protected && role[0].all_permissions && role[0].enabled);
assert_eq!(role[0].name, "Owner");
} else {
denied(owner, 409);
saved.unwrap();
assert!(!role[0].protected && !role[0].all_permissions);
assert_eq!(
case.count(
scope,
"SELECT COUNT(*) FROM _dever_user_role WHERE role_id='owner'"
)
.await,
0
);
}
});
}
@@ -0,0 +1,212 @@
use crate::{
adapter::Document,
api,
api::Request,
auth::{Context, Identity, Service},
authorization::{Declaration, Failure, Selector},
database::{Database, Error, Executor, Options, Parameter},
task::{Runtime, Scope},
};
use futures_util::FutureExt;
use std::{
panic::AssertUnwindSafe,
path::Path,
sync::Arc,
time::{Duration, SystemTime, UNIX_EPOCH},
};
#[path = "access.rs"]
mod authz;
mod capability;
mod catalog;
mod concurrent;
mod roles;
const CREATE: &str = "news.article.admin.create";
const REMOVE: &str = "news.article.admin.remove";
const USER: i64 = 4_294_967_300;
const DECLARATIONS: [Declaration; 3] = [
Declaration {
topic: "admin",
component: "news",
domain: "article",
action: "create",
method: "POST",
},
Declaration {
topic: "admin",
component: "news",
domain: "article",
action: "remove",
method: "DELETE",
},
Declaration {
topic: "front",
component: "news",
domain: "article",
action: "read",
method: "GET",
},
];
struct Case {
database: Database,
schema: String,
config: Arc<api::Config>,
service: Service,
request: Request,
}
impl Case {
async fn executor(&self, scope: &Scope<'_>) -> Executor {
let mut executor = self.database.executor().await.unwrap();
executor
.query(
scope,
"SELECT set_config('search_path',$1,false)",
vec![Parameter::Text(self.schema.clone())],
1,
|row| row.get::<_, String>(0),
)
.await
.unwrap();
executor
}
fn context(&self, site: &str, user: Option<i64>) -> Context<'_> {
let (mut context, _) = self
.service
.prepare(site, &self.request, true, 100)
.unwrap();
if let Some(user) = user {
context.identity_for_authorization_test(Identity {
id: "verified-fixture-user".into(),
user_id: Some(user),
tenant_id: None,
});
}
context
}
async fn count(&self, scope: &Scope<'_>, sql: &'static str) -> i64 {
self.executor(scope)
.await
.query(scope, sql, vec![], 1, |row| row.get::<_, i64>(0))
.await
.unwrap()[0]
}
async fn pid(&self, scope: &Scope<'_>, executor: &mut Executor) -> i64 {
executor
.query(
scope,
"SELECT CAST(pg_backend_pid() AS BIGINT)",
vec![],
1,
|row| row.get::<_, i64>(0),
)
.await
.unwrap()[0]
}
async fn blocked(&self, scope: &Scope<'_>, pid: i64, blocker: i64) {
let mut observer = self.executor(scope).await;
tokio::time::timeout(Duration::from_secs(2), async {
loop {
let waiting = observer.query(scope, "SELECT CAST(CAST($2 AS BIGINT) AS INTEGER) = ANY(pg_blocking_pids(CAST(CAST($1 AS BIGINT) AS INTEGER)))", vec![Parameter::Integer(pid), Parameter::Integer(blocker)], 1, |row| row.get::<_, bool>(0)).await.unwrap()[0];
if waiting { break; }
tokio::task::yield_now().await;
}
}).await.expect("authorization operation did not wait for its row lock");
}
}
fn denied<Value>(result: Result<Value, Failure>, expected: u16) {
assert!(matches!(result, Err(Failure::Access(status)) if status == expected));
}
fn fixture(check: impl AsyncFnOnce(&Case, &Scope<'_>)) {
let path = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../../config/setting.json");
let document = Document::load(&path).expect("cannot read explicit PostgreSQL test settings");
let root: serde_json::Value = document.decode().expect("invalid PostgreSQL test settings");
let value = root
.get("database")
.and_then(|database| database.get("postgres_test"))
.expect("database.postgres_test must explicitly select the test database");
let mut options: Options = serde_json::from_value(value.clone())
.unwrap_or_else(|_| panic!("invalid PostgreSQL test options"));
assert_eq!(options.driver, "postgres");
options.max_connections = 8;
options.wait_timeout_ms = 1000;
options.io_timeout_ms = 5000;
let unique = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_nanos();
let schema = format!("dever_auth_test_{}_{unique}", std::process::id());
let setting = std::env::temp_dir().join(format!("{schema}.json"));
std::fs::write(&setting, r#"{"http":{"host":"127.0.0.1","port":8181},"sites":{"admin":{"path":"admin","hosts":["unit.test"]},"front":{"path":"front","hosts":["unit.test"]}}}"#).unwrap();
let config = api::Config::load(&setting, &["admin", "front"]);
std::fs::remove_file(setting).unwrap();
let config = Arc::new(config.unwrap());
let service = Service::new(config.clone(), &[], &[]).unwrap();
Runtime::new(Default::default())
.unwrap()
.run(async |scope| {
let database = Database::open(Path::new("."), options, scope)
.await
.unwrap();
let case = Case {
database,
schema,
config,
service,
request: hyper::Request::builder()
.method("POST")
.uri("/entry")
.body(Default::default())
.unwrap(),
};
let mut owner = case.database.executor().await.unwrap();
owner
.postgres_operation(async |client| {
client
.batch_execute(&format!("CREATE SCHEMA {}", case.schema))
.await
.map_err(Error::from)
})
.await
.unwrap();
let outcome = AssertUnwindSafe(async {
let mut executor = case.executor(scope).await;
for _ in 0..2 {
for binding in authz::schema::BINDINGS {
executor
.schema(scope, binding.table, binding.schema)
.await
.unwrap();
}
}
drop(executor);
tokio::time::timeout(Duration::from_secs(30), check(&case, scope))
.await
.expect("PostgreSQL authorization test deadline exceeded");
})
.catch_unwind()
.await;
let cleanup = owner
.postgres_operation(async |client| {
client
.batch_execute(&format!("DROP SCHEMA {} CASCADE", case.schema))
.await
.map_err(Error::from)
})
.await;
case.database.close();
cleanup.expect("PostgreSQL authorization schema cleanup failed");
if let Err(panic) = outcome {
std::panic::resume_unwind(panic);
}
})
.unwrap();
}
@@ -0,0 +1,347 @@
use super::*;
#[test]
#[ignore = "requires explicit config/setting.json database.postgres_test"]
fn postgres_role_grant_revoke_disable_remove_and_entry_authorization() {
fixture(async |case, scope| {
let mut executor = case.executor(scope).await;
authz::synchronize(&mut executor, scope, &case.config, &DECLARATIONS)
.await
.unwrap();
let mut context = case.context("admin", Some(USER));
let front = case.context("front", Some(USER));
denied(
authz::authorize_entry(
&mut executor,
scope,
&mut context,
"news",
"article",
"create",
)
.await,
403,
);
assert!(context.entry_namespace().is_err());
authz::save(
&mut executor,
scope,
&context,
"editor",
"Editor",
false,
&[CREATE.into(), CREATE.into()],
)
.await
.unwrap();
for _ in 0..2 {
authz::grant(&mut executor, scope, &context, USER, "editor")
.await
.unwrap();
}
authz::authorize_entry(
&mut executor,
scope,
&mut context,
"news",
"article",
"create",
)
.await
.unwrap();
assert_eq!(context.entry_namespace().unwrap(), ("news", "article"));
denied(
authz::authorize_key(&mut executor, scope, &context, REMOVE).await,
403,
);
denied(
authz::authorize_key(&mut executor, scope, &front, CREATE).await,
403,
);
let roles = authz::roles(&mut executor, scope, &context, Selector::User(USER))
.await
.unwrap();
assert_eq!(roles.len(), 1);
assert_eq!(roles[0].permission_keys, [CREATE]);
assert!(!roles[0].all_permissions && roles[0].enabled && !roles[0].protected);
assert_eq!(
authz::permissions(&mut executor, scope, &context, true)
.await
.unwrap()[0]
.key,
CREATE
);
authz::revoke(&mut executor, scope, &context, USER, "editor")
.await
.unwrap();
denied(
authz::authorize_key(&mut executor, scope, &context, CREATE).await,
403,
);
authz::grant(&mut executor, scope, &context, USER, "editor")
.await
.unwrap();
authz::disable(&mut executor, scope, &context, "editor")
.await
.unwrap();
denied(
authz::authorize_key(&mut executor, scope, &context, CREATE).await,
403,
);
authz::save(&mut executor, scope, &context, "editor", "All", true, &[])
.await
.unwrap();
authz::authorize_key(&mut executor, scope, &context, REMOVE)
.await
.unwrap();
authz::remove(&mut executor, scope, &context, "editor")
.await
.unwrap();
assert_eq!(
case.count(scope, "SELECT COUNT(*) FROM _dever_user_role")
.await,
0
);
assert_eq!(
case.count(scope, "SELECT COUNT(*) FROM _dever_role_permission")
.await,
0
);
denied(
authz::grant(&mut executor, scope, &context, USER, "missing").await,
404,
);
denied(
authz::grant(&mut executor, scope, &context, 0, "missing").await,
400,
);
});
}
#[test]
#[ignore = "requires explicit config/setting.json database.postgres_test"]
fn postgres_owner_is_explicit_protected_and_never_authorizes_unknown_keys() {
fixture(async |case, scope| {
let mut executor = case.executor(scope).await;
authz::synchronize(&mut executor, scope, &case.config, &DECLARATIONS)
.await
.unwrap();
let context = case.context("admin", Some(USER));
denied(
authz::authorize_key(&mut executor, scope, &context, CREATE).await,
403,
);
for _ in 0..2 {
authz::owner(&mut executor, scope, "admin", USER)
.await
.unwrap();
}
authz::authorize_key(&mut executor, scope, &context, CREATE)
.await
.unwrap();
denied(
authz::authorize_key(&mut executor, scope, &context, "news.article.admin.unknown")
.await,
403,
);
denied(
authz::save(
&mut executor,
scope,
&context,
"owner",
"Changed",
false,
&[],
)
.await,
403,
);
denied(
authz::disable(&mut executor, scope, &context, "owner").await,
403,
);
denied(
authz::remove(&mut executor, scope, &context, "owner").await,
403,
);
denied(
authz::grant(&mut executor, scope, &context, 8, "owner").await,
403,
);
denied(
authz::revoke(&mut executor, scope, &context, USER, "owner").await,
403,
);
let owner = authz::roles(&mut executor, scope, &context, Selector::Id("owner".into()))
.await
.unwrap();
assert_eq!(owner.len(), 1);
assert!(owner[0].protected && owner[0].all_permissions && owner[0].enabled);
let front = case.context("front", Some(USER));
authz::save(
&mut executor,
scope,
&front,
"owner",
"Ordinary",
false,
&[],
)
.await
.unwrap();
denied(authz::owner(&mut executor, scope, "front", USER).await, 409);
assert!(
authz::roles(&mut executor, scope, &front, Selector::User(USER))
.await
.unwrap()
.is_empty()
);
});
}
#[test]
#[ignore = "requires explicit config/setting.json database.postgres_test"]
fn postgres_role_writes_reuse_ambient_transaction_and_roll_back_atomically() {
fixture(async |case, scope| {
let mut executor = case.executor(scope).await;
authz::synchronize(&mut executor, scope, &case.config, &DECLARATIONS)
.await
.unwrap();
let context = case.context("admin", Some(USER));
let owner = executor.begin(scope).await.unwrap();
authz::save(
&mut executor,
scope,
&context,
"editor",
"Editor",
false,
&[CREATE.into()],
)
.await
.unwrap();
authz::grant(&mut executor, scope, &context, USER, "editor")
.await
.unwrap();
authz::authorize_key(&mut executor, scope, &context, CREATE)
.await
.unwrap();
assert_eq!(
case.count(scope, "SELECT COUNT(*) FROM _dever_role").await,
0
);
denied(
authz::save(
&mut executor,
scope,
&context,
"editor",
"Invalid",
true,
&["unknown".into()],
)
.await,
400,
);
assert!(executor.finish(scope, owner, true).await.is_err());
assert_eq!(
case.count(scope, "SELECT COUNT(*) FROM _dever_role").await,
0
);
assert_eq!(
case.count(scope, "SELECT COUNT(*) FROM _dever_user_role")
.await,
0
);
authz::save(
&mut executor,
scope,
&context,
"editor",
"Editor",
false,
&[CREATE.into()],
)
.await
.unwrap();
denied(
authz::save(
&mut executor,
scope,
&context,
"editor",
"Invalid",
true,
&["news.article.front.read".into()],
)
.await,
400,
);
let role = authz::roles(&mut executor, scope, &context, Selector::All)
.await
.unwrap();
assert_eq!(role[0].name, "Editor");
assert_eq!(role[0].permission_keys, [CREATE]);
assert!(!role[0].all_permissions);
});
}
#[test]
#[ignore = "requires explicit config/setting.json database.postgres_test"]
fn postgres_missing_identity_is_rejected_before_any_database_query() {
fixture(async |case, scope| {
let mut executor = case.executor(scope).await;
executor
.postgres_operation(async |client| {
client
.batch_execute("SET search_path TO pg_catalog")
.await
.map_err(Error::from)
})
.await
.unwrap();
let mut anonymous = case.context("admin", None);
denied(
authz::roles(&mut executor, scope, &anonymous, Selector::All).await,
401,
);
denied(
authz::permissions(&mut executor, scope, &anonymous, false).await,
401,
);
denied(
authz::save(&mut executor, scope, &anonymous, "x", "X", true, &[]).await,
401,
);
denied(
authz::grant(&mut executor, scope, &anonymous, USER, "x").await,
401,
);
denied(
authz::revoke(&mut executor, scope, &anonymous, USER, "x").await,
401,
);
denied(
authz::disable(&mut executor, scope, &anonymous, "x").await,
401,
);
denied(
authz::remove(&mut executor, scope, &anonymous, "x").await,
401,
);
denied(
authz::authorize_entry(
&mut executor,
scope,
&mut anonymous,
"news",
"article",
"create",
)
.await,
401,
);
assert!(anonymous.entry_namespace().is_err());
});
}
@@ -0,0 +1,127 @@
use super::access_tests::{self, Fixture};
use crate::{
adapter::Document,
database::{Database, Options, Parameter},
task::{Runtime, Scope},
};
use futures_util::FutureExt;
use std::{panic::AssertUnwindSafe, path::Path};
fn fixture(check: impl AsyncFnOnce(&Fixture, &Scope<'_>)) {
let fixture = Fixture::new();
Runtime::new(Default::default())
.unwrap()
.run(async |scope| {
let document = Document::load(
&Path::new(env!("CARGO_MANIFEST_DIR")).join("../../../config/setting.json"),
)
.unwrap();
let settings: serde_json::Value = document.decode().unwrap();
let admin: Options =
serde_json::from_value(settings["database"]["postgres_test"].clone()).unwrap();
let mut options = settings["database"]["postgres_tenant_test"].clone();
let template = options["url"].as_str().unwrap();
assert_eq!(template.matches("{case}").count(), 1);
assert!(
template
.parse::<tokio_postgres::Config>()
.unwrap()
.get_dbname()
.unwrap()
.contains("{case}")
);
let token = crate::database::scalar::generated_uuid()
.unwrap()
.to_string()
.replace('-', "");
let url = template.replace("{case}", &token);
let control = url
.parse::<tokio_postgres::Config>()
.unwrap()
.get_dbname()
.unwrap()
.to_owned();
assert!(
control
.bytes()
.all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'_')
);
let prefix = format!("auth_{token}");
let names = [
format!("{prefix}_17"),
format!("{prefix}_18"),
control.clone(),
];
let admin = Database::open(Path::new("."), admin, scope).await.unwrap();
let mut executor = admin.executor().await.unwrap();
for name in &names {
assert_eq!(
executor
.query(
scope,
"SELECT COUNT(*) FROM pg_database WHERE datname=$1",
vec![Parameter::Text(name.clone())],
1,
|row| row.get::<_, i64>(0)
)
.await
.unwrap(),
[0]
);
}
executor
.postgres_operation(async |client| {
client
.batch_execute(&format!("CREATE DATABASE \"{control}\""))
.await
.map_err(Into::into)
})
.await
.unwrap();
options["url"] = url.into();
options["tenant_database_prefix"] = prefix.into();
options["max_connections"] = 1.into();
let result = AssertUnwindSafe(async {
let mut config: serde_json::Value =
serde_json::from_slice(&std::fs::read(&fixture.setting).unwrap()).unwrap();
config["database"]["default"] = options;
std::fs::write(&fixture.setting, config.to_string()).unwrap();
tokio::time::timeout(std::time::Duration::from_secs(30), check(&fixture, scope))
.await
.unwrap();
})
.catch_unwind()
.await;
for name in &names {
executor
.postgres_operation(async |client| {
client
.batch_execute(&format!(
"DROP DATABASE IF EXISTS \"{name}\" WITH (FORCE)"
))
.await
.map_err(Into::into)
})
.await
.expect("owned tenant authorization database cleanup failed");
}
drop(executor);
admin.close();
if let Err(panic) = result {
std::panic::resume_unwind(panic);
}
})
.unwrap();
}
#[test]
#[ignore = "requires explicit isolated postgres_tenant_test template and CREATEDB"]
fn postgres_two_physical_tenants_keep_roles_and_capabilities_out_of_the_control_catalog() {
fixture(async |fixture, scope| access_tests::routing(fixture, scope).await);
}
#[test]
#[ignore = "requires explicit isolated postgres_tenant_test template and CREATEDB"]
fn postgres_tenant_role_capability_and_model_rollback_uses_one_physical_transaction() {
fixture(async |fixture, scope| access_tests::rollback(fixture, scope).await);
}
+124
View File
@@ -0,0 +1,124 @@
use dever_runtime::time::{
self,
calendar::{Date, DateTime, Duration, Time},
};
#[test]
fn rfc3339_offsets_normalize_to_utc_at_millisecond_precision() {
assert_eq!(
DateTime::parse("2024-02-29T12:34:56.12+08:00")
.unwrap()
.format()
.unwrap(),
"2024-02-29T04:34:56.120Z"
);
for value in [
"1969-12-31T23:59:59.999Z",
"0000-01-01T00:00:00Z",
"9999-12-31T23:59:59.999Z",
] {
assert_eq!(DateTime::parse(value).unwrap().format().unwrap(), value);
}
for value in [
"2023-02-29T00:00:00Z",
"2024-02-29T00:00:00.1230Z",
"2024-02-29T00:00:00.1234Z",
"2024-12-31T23:59:60Z",
"2024-01-01T00:00:00",
"9999-12-31T23:30:00-01:00",
"0000-01-01T00:00:00+01:00",
] {
assert!(DateTime::parse(value).is_err(), "{value}");
}
}
#[test]
fn date_and_time_spellings_are_strict_and_zero_fractions_normalize() {
assert_eq!(
Date::parse("2024-02-29").unwrap().format().unwrap(),
"2024-02-29"
);
for value in ["2024-2-29", "2023-02-29", "10000-01-01", "2024-01-01 "] {
assert!(Date::parse(value).is_err(), "{value}");
}
assert_eq!(
Time::parse("12:34:56.000").unwrap().format().unwrap(),
"12:34:56"
);
assert_eq!(
Time::parse("12:34:56.001").unwrap().format().unwrap(),
"12:34:56.001"
);
for value in [
"24:00:00",
"23:60:00",
"23:59:60",
"12:34:56.1",
"12:34:56.12",
"12:34:56.1234",
" 12:34:56",
] {
assert!(Time::parse(value).is_err(), "{value}");
}
}
#[test]
fn arithmetic_keeps_i64_values_and_checks_overflow_separately_from_formatting() {
let epoch = DateTime::parse("1970-01-01T00:00:00Z").unwrap();
assert_eq!(
epoch
.checked_sub(Duration::from_millis(1))
.unwrap()
.format()
.unwrap(),
"1969-12-31T23:59:59.999Z"
);
let maximum = epoch.checked_add(Duration::from_millis(i64::MAX)).unwrap();
let minimum = epoch.checked_add(Duration::from_millis(i64::MIN)).unwrap();
assert!(maximum.format().is_err());
assert!(maximum.checked_add(Duration::from_millis(1)).is_err());
assert!(minimum.checked_sub(Duration::from_millis(1)).is_err());
assert!(maximum.difference(minimum).is_err());
assert_eq!(epoch.difference(epoch).unwrap().millis(), 0);
}
#[test]
fn temporal_values_are_inline_and_clocks_round_trip() {
assert_eq!(std::mem::size_of::<DateTime>(), 8);
assert_eq!(std::mem::size_of::<Date>(), 8);
assert_eq!(std::mem::size_of::<Time>(), 8);
assert_eq!(std::mem::size_of::<Duration>(), 8);
assert!(!std::mem::needs_drop::<DateTime>());
let now = DateTime::now().unwrap();
assert_eq!(DateTime::parse(&now.format().unwrap()).unwrap(), now);
assert!(time::unix_millis().is_ok());
let first = time::monotonic_nanos().unwrap();
assert!(time::monotonic_nanos().unwrap() >= first);
}
#[cfg(feature = "wire")]
#[test]
fn wire_uses_canonical_strings_and_integer_durations() {
let value: DateTime = serde_json::from_str(r#""2024-02-29T12:34:56.12+08:00""#).unwrap();
assert_eq!(
serde_json::to_string(&value).unwrap(),
r#""2024-02-29T04:34:56.120Z""#
);
let escaped: Date = serde_json::from_str(r#""\u0032024-02-29""#).unwrap();
assert_eq!(serde_json::to_string(&escaped).unwrap(), r#""2024-02-29""#);
let clock: Time = serde_json::from_str(r#""12:34:56.000""#).unwrap();
assert_eq!(serde_json::to_string(&clock).unwrap(), r#""12:34:56""#);
assert!(serde_json::from_str::<DateTime>("0").is_err());
assert!(serde_json::from_str::<Duration>("1.0").is_err());
assert!(serde_json::from_str::<Duration>(r#""1""#).is_err());
let duration: Duration = serde_json::from_str("9223372036854775807").unwrap();
assert_eq!(
serde_json::to_string(&duration).unwrap(),
"9223372036854775807"
);
let far = DateTime::parse("1970-01-01T00:00:00Z")
.unwrap()
.checked_add(duration)
.unwrap();
assert!(serde_json::to_string(&far).is_err());
}
+176
View File
@@ -0,0 +1,176 @@
use dever_runtime::{
channel::{Channel, Error},
task::{Limits, Runtime},
};
use std::{
future::{Future, poll_fn},
sync::{
Arc,
atomic::{AtomicUsize, Ordering},
},
task::Poll,
};
fn runtime() -> Runtime {
Runtime::new(Limits {
threads: 2,
workers: 1,
tasks: 8,
})
.unwrap()
}
async fn pending(future: std::pin::Pin<&mut impl Future>) {
let mut future = future;
poll_fn(|context| {
assert!(future.as_mut().poll(context).is_pending());
Poll::Ready(())
})
.await;
}
#[test]
fn capacity_is_bounded() {
for capacity in [-1, 0, 65537] {
assert!(matches!(
Channel::<i64>::new(capacity),
Err(Error::Capacity)
));
}
Channel::<i64>::new(1).unwrap();
Channel::<i64>::new(65536).unwrap();
}
#[test]
fn backpressure_preserves_fifo_and_nullable_items() {
runtime()
.run(async |_| {
let channel = Channel::new(1).unwrap();
channel.send(Some(1)).await.unwrap();
let mut sent = std::pin::pin!(channel.send(None));
pending(sent.as_mut()).await;
assert_eq!(channel.receive().await, Ok(Some(Some(1))));
sent.await.unwrap();
let alias = channel.clone();
alias.close().await.unwrap();
channel.close().await.unwrap();
assert_eq!(channel.receive().await, Ok(Some(None)));
assert_eq!(alias.receive().await, Ok(None));
})
.unwrap();
}
#[test]
fn close_wakes_senders_and_all_receivers_without_discarding_queued_values() {
runtime()
.run(async |_| {
let channel = Channel::new(1).unwrap();
channel.send(1).await.unwrap();
let mut sent = std::pin::pin!(channel.send(2));
pending(sent.as_mut()).await;
channel.close().await.unwrap();
assert_eq!(sent.await, Err(Error::Closed));
assert_eq!(channel.receive().await, Ok(Some(1)));
assert_eq!(channel.receive().await, Ok(None));
let empty = Channel::<i64>::new(1).unwrap();
let mut first = std::pin::pin!(empty.receive());
let mut second = std::pin::pin!(empty.receive());
pending(first.as_mut()).await;
pending(second.as_mut()).await;
empty.close().await.unwrap();
assert_eq!(first.await, Ok(None));
assert_eq!(second.await, Ok(None));
})
.unwrap();
}
#[test]
fn cancelling_waiters_returns_reserved_capacity_and_items() {
runtime()
.run(async |_| {
let channel = Channel::new(1).unwrap();
channel.send(1).await.unwrap();
{
let mut sent = std::pin::pin!(channel.send(2));
pending(sent.as_mut()).await;
assert_eq!(channel.receive().await, Ok(Some(1)));
}
channel.send(3).await.unwrap();
assert_eq!(channel.receive().await, Ok(Some(3)));
{
let mut received = std::pin::pin!(channel.receive());
pending(received.as_mut()).await;
channel.send(4).await.unwrap();
}
assert_eq!(channel.receive().await, Ok(Some(4)));
})
.unwrap();
}
#[test]
fn producers_and_consumers_transfer_every_item_once() {
runtime()
.run(async |scope| {
let channel = Channel::new(4).unwrap();
let mut producers = Vec::new();
let mut consumers = Vec::new();
for start in [0, 64] {
let sender = channel.clone();
producers.push(
scope
.spawn_leaf(move || async move {
for value in start..start + 64 {
sender.send(value).await.unwrap();
}
})
.unwrap(),
);
let channel = channel.clone();
consumers.push(
scope
.spawn_leaf(move || async move {
let mut values = Vec::new();
while let Some(value) = channel.receive().await.unwrap() {
values.push(value);
}
values
})
.unwrap(),
);
}
for task in producers {
task.wait().await.unwrap();
}
channel.close().await.unwrap();
let mut received = Vec::new();
for task in consumers {
received.extend(task.wait().await.unwrap());
}
received.sort();
assert_eq!(received, (0..128).collect::<Vec<_>>());
})
.unwrap();
}
struct Notice(Arc<AtomicUsize>);
impl Drop for Notice {
fn drop(&mut self) {
self.0.fetch_add(1, Ordering::SeqCst);
}
}
#[test]
fn last_alias_releases_nonclone_messages_once() {
let drops = Arc::new(AtomicUsize::new(0));
runtime()
.run(async |_| {
let channel = Channel::new(1).unwrap();
channel.send(Notice(drops.clone())).await.unwrap();
let alias = channel.clone();
drop(channel);
assert_eq!(drops.load(Ordering::SeqCst), 0);
drop(alias);
assert_eq!(drops.load(Ordering::SeqCst), 1);
})
.unwrap();
}
+78
View File
@@ -0,0 +1,78 @@
use dever_runtime::{crypto, value::secret::Secret};
fn hex(bytes: &[u8]) -> String {
bytes.iter().map(|byte| format!("{byte:02x}")).collect()
}
#[test]
fn secret_debug_is_redacted_and_token_bounds_are_fixed() {
let secret = Secret::from_input(b"test-only-sensitive-input".to_vec());
assert_eq!(format!("{secret:?}"), "Secret([REDACTED])");
assert_eq!(format!("{:?}", secret.clone()), "Secret([REDACTED])");
for length in [-1, 0, 15, 1025] {
assert!(crypto::token(length).is_err());
}
assert!(crypto::token(16).is_ok());
assert!(crypto::token(1024).is_ok());
}
#[test]
fn sha256_and_hmac_match_known_vectors() {
assert_eq!(
hex(&crypto::sha256(b"abc")),
"ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"
);
let key = Secret::from_input(vec![0x0b; 20]);
assert_eq!(
hex(&crypto::hmac_sha256(&key, b"Hi There")),
"b0344c61d8db38535ca8afceaf0bf12b881dc200c9833da726e9376c2e32cff7"
);
}
#[test]
fn content_comparison_has_the_declared_value_semantics() {
assert!(crypto::constant_time_eq(b"", b""));
assert!(crypto::constant_time_eq(b"same", b"same"));
assert!(!crypto::constant_time_eq(b"same", b"sXme"));
assert!(!crypto::constant_time_eq(b"same", b"sam"));
}
#[test]
fn passwords_use_the_existing_profile_and_distinguish_mismatch_from_invalid_hash() {
let password = Secret::from_input(b"test-only-password".to_vec());
let wrong = Secret::from_input(b"other-test-password".to_vec());
let encoded = crypto::password_hash(&password).unwrap();
assert!(encoded.starts_with("$argon2id$v=19$m=19456,t=2,p=1$"));
assert_eq!(crypto::password_verify(&password, &encoded), Ok(true));
assert_eq!(
crypto::password_verify(&password.clone(), &encoded),
Ok(true)
);
assert_eq!(crypto::password_verify(&wrong, &encoded), Ok(false));
assert!(crypto::password_verify(&password, "not a hash").is_err());
}
#[test]
fn raw_phc_costs_are_rejected_before_parameter_arithmetic_or_hashing() {
let password = Secret::from_input(b"test-only-password".to_vec());
let encoded =
"$argon2id$v=19$m=19456,t=2,p=1$c29tZXNhbHQ$AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA";
for invalid in [
encoded.replace("m=19456", "m=19457"),
encoded.replace("t=2", "t=3"),
encoded.replace("p=1", "p=2"),
encoded.replace("p=1", "p=536870912"),
encoded.replace("p=1", "p=0"),
encoded.replace("p=1", "p=invalid"),
encoded.replace("$v=19$", "$v=16$"),
encoded.replace("argon2id", "argon2i"),
encoded.replace("p=1", "p=1,data=YQ"),
encoded.replace("p=1", "p=1,keyid=YQ"),
"x".repeat(257),
] {
assert!(
crypto::password_verify(&password, &invalid).is_err(),
"{invalid}"
);
}
}
+366
View File
@@ -0,0 +1,366 @@
use dever_runtime::{
adapter::Document,
database::{Database, Error, Kind, Options, Parameter, Settings},
task::{Limits, Runtime},
};
use std::{
path::PathBuf,
sync::atomic::{AtomicU64, Ordering},
};
static NEXT: AtomicU64 = AtomicU64::new(0);
mod performance;
mod scalar;
mod schema;
struct Directory(PathBuf);
impl Directory {
fn new() -> Self {
let path = std::env::temp_dir().join(format!(
"dever-database-{}-{}",
std::process::id(),
NEXT.fetch_add(1, Ordering::Relaxed)
));
std::fs::create_dir(&path).unwrap();
Self(path)
}
}
impl Drop for Directory {
fn drop(&mut self) {
std::fs::remove_dir_all(&self.0).unwrap();
}
}
fn options() -> Options {
Options {
driver: "sqlite".into(),
path: "data/case.db".into(),
url: None,
tls: None,
tenant_directory: None,
tenant_database_prefix: None,
min_connections: 1,
max_connections: 1,
max_page_size: 20,
wait_timeout_ms: 100,
io_timeout_ms: 1000,
}
}
fn runtime() -> Runtime {
Runtime::new(Limits {
threads: 2,
workers: 2,
tasks: 16,
})
.unwrap()
}
const CREATE: &str = "CREATE TABLE account (id INTEGER PRIMARY KEY, name TEXT NOT NULL UNIQUE);";
#[test]
fn pool_and_checked_out_transaction_drop_outside_runtime_in_any_order() {
for order in 0..3 {
let directory = Directory::new();
let runtime = runtime();
let (database, executor) = runtime
.run(async |scope| {
let database = Database::open(&directory.0, options(), scope)
.await
.unwrap();
let mut executor = database.executor().await.unwrap();
executor.initialize(scope, CREATE).await.unwrap();
executor.begin(scope).await.unwrap();
executor
.execute(
scope,
"INSERT INTO account(name) VALUES ('uncommitted')",
vec![],
)
.await
.unwrap();
(database, executor)
})
.unwrap();
match order {
0 => {
drop(executor);
database.close();
drop(database);
drop(runtime);
}
1 => {
drop(database);
drop(executor);
drop(runtime);
}
_ => {
drop(runtime);
drop(database);
drop(executor);
}
}
self::runtime()
.run(async |scope| {
let database = Database::open(&directory.0, options(), scope)
.await
.unwrap();
let count = database
.executor()
.await
.unwrap()
.query(scope, "SELECT COUNT(*) FROM account", vec![], 1, |row| {
row.get::<_, i64>(0)
})
.await
.unwrap();
assert_eq!(count, [0]);
})
.unwrap();
}
}
#[test]
fn transaction_commit_rollback_and_typed_rows() {
let directory = Directory::new();
runtime()
.run(async |scope| {
let database = Database::open(&directory.0, options(), scope)
.await
.unwrap();
let mut executor = database.executor().await.unwrap();
executor.initialize(scope, CREATE).await.unwrap();
let owner = executor.begin(scope).await.unwrap();
assert!(owner);
executor
.execute(
scope,
"INSERT INTO account(name) VALUES (?1)",
vec![Parameter::Text("Dever 中文".into())],
)
.await
.unwrap();
let nested = executor.begin(scope).await.unwrap();
assert!(!nested);
executor.finish(scope, nested, true).await.unwrap();
executor.finish(scope, owner, true).await.unwrap();
let owner = executor.begin(scope).await.unwrap();
executor
.execute(
scope,
"INSERT INTO account(name) VALUES (?1)",
vec![Parameter::Text("rollback".into())],
)
.await
.unwrap();
executor.finish(scope, owner, false).await.unwrap();
let rows = executor
.query(
scope,
"SELECT id, name FROM account ORDER BY id",
vec![],
20,
|row| Ok((row.get::<_, i64>(0)?, row.get::<_, String>(1)?)),
)
.await
.unwrap();
assert_eq!(rows, vec![(1, "Dever 中文".into())]);
drop(executor);
drop(database.executor().await.unwrap());
database.close();
})
.unwrap();
}
#[test]
fn nested_failure_prevents_outer_commit_and_preserves_pool() {
let directory = Directory::new();
runtime()
.run(async |scope| {
let database = Database::open(&directory.0, options(), scope)
.await
.unwrap();
let mut executor = database.executor().await.unwrap();
executor.initialize(scope, CREATE).await.unwrap();
let outer = executor.begin(scope).await.unwrap();
executor
.execute(
scope,
"INSERT INTO account(name) VALUES ('temporary')",
vec![],
)
.await
.unwrap();
let inner = executor.begin(scope).await.unwrap();
executor.finish(scope, inner, false).await.unwrap();
assert_eq!(
executor.finish(scope, outer, true).await.unwrap_err().kind,
Kind::Database
);
drop(executor);
let mut executor = database.executor().await.unwrap();
let count = executor
.query(scope, "SELECT COUNT(*) FROM account", vec![], 1, |row| {
row.get::<_, i64>(0)
})
.await
.unwrap();
assert_eq!(count, vec![0]);
database.close();
})
.unwrap();
}
#[test]
fn binds_errors_and_result_bounds_are_real_database_checks() {
let directory = Directory::new();
runtime()
.run(async |scope| {
let database = Database::open(&directory.0, options(), scope)
.await
.unwrap();
let mut executor = database.executor().await.unwrap();
executor.initialize(scope, CREATE).await.unwrap();
let input = "'); DROP TABLE account; --";
executor
.execute(
scope,
"INSERT INTO account(name) VALUES (?1)",
vec![Parameter::Text(input.into())],
)
.await
.unwrap();
let error = executor
.execute(
scope,
"INSERT INTO account(name) VALUES (?1)",
vec![Parameter::Text(input.into())],
)
.await
.unwrap_err();
assert_eq!(error.kind, Kind::Constraint);
assert!(!error.to_string().contains(input));
let value = executor
.query(scope, "SELECT name FROM account", vec![], 1, |row| {
row.get::<_, String>(0)
})
.await
.unwrap();
assert_eq!(value, vec![input]);
let invalid = executor
.query(scope, "SELECT name FROM account", vec![], 1, |row| {
row.get::<_, i64>(0)
})
.await
.unwrap_err();
assert_eq!(invalid.kind, Kind::InvalidData);
let overflow = executor
.query(scope, "SELECT 1 UNION ALL SELECT 2", vec![], 1, |row| {
row.get::<_, i64>(0)
})
.await
.unwrap_err();
assert_eq!(overflow.kind, Kind::InvalidData);
let exhausted = database.executor().await.err().unwrap();
assert_eq!(exhausted.kind, Kind::PoolExhausted);
database.close();
})
.unwrap();
}
#[test]
fn timeout_discards_transaction_before_next_borrow() {
let directory = Directory::new();
runtime().run(async |scope| {
let mut config = options();
config.io_timeout_ms = 50;
let database = Database::open(&directory.0, config, scope).await.unwrap();
let mut executor = database.executor().await.unwrap();
executor.initialize(scope, CREATE).await.unwrap();
executor.begin(scope).await.unwrap();
executor.execute(scope, "INSERT INTO account(name) VALUES ('must rollback')", vec![]).await.unwrap();
let result = executor.query(scope, "WITH RECURSIVE numbers(value) AS (VALUES(1) UNION ALL SELECT value + 1 FROM numbers WHERE value < 1000000000) SELECT SUM(value) FROM numbers", vec![], 1, |row| row.get::<_, i64>(0)).await;
assert_eq!(result.unwrap_err().kind, Kind::Timeout);
drop(executor);
let mut replacement = database.executor().await.unwrap();
let count = replacement.query(scope, "SELECT COUNT(*) FROM account", vec![], 1, |row| row.get::<_, i64>(0)).await.unwrap();
assert_eq!(count, vec![0]);
database.close();
}).unwrap();
}
#[test]
fn configuration_and_rollback_cause_do_not_expose_secrets() {
for tenant in ["{}", "null"] {
let document = Document::parse(format!(r#"{{"tenant":{tenant},"database":{{"default":{{"type":"sqlite","path":"data/app.db"}}}}}}"#)).unwrap();
assert!(Settings::load(&document).is_err());
}
let settings = Settings::load(
&Document::parse(
r#"{"database":{"default":{"type":"sqlite","path":"data/app.db"}}}"#.into(),
)
.unwrap(),
)
.unwrap();
assert_eq!(settings.select("demo", None).unwrap().max_connections, 1);
assert!(settings.select("demo", Some("missing")).is_err());
for path in ["/tmp/escape", "../escape", "data/../escape", ""] {
let mut value = options();
value.path = path.into();
assert!(value.validate().is_err());
}
let error = Error::new(Kind::Constraint, "business failure")
.with_cause(Error::new(Kind::Connection, "rollback failed"))
.with_cause(Error::new(Kind::Pool, "pool close failed"));
assert_eq!(error.kind, Kind::Constraint);
assert!(error.to_string().contains("rollback failed"));
assert!(error.to_string().contains("pool close failed"));
let rollback = error.cause.as_ref().unwrap();
assert_eq!(rollback.kind, Kind::Connection);
assert_eq!(rollback.cause.as_ref().unwrap().kind, Kind::Pool);
}
#[test]
fn schema_drift_fails_before_touching_data_or_history() {
let directory = Directory::new();
runtime().run(async |scope| {
let database = Database::open(&directory.0, options(), scope).await.unwrap();
let mut executor = database.executor().await.unwrap();
const SCHEMA: &str = "CREATE TABLE account (id INTEGER PRIMARY KEY, name TEXT NOT NULL); CREATE INDEX account_name ON account(name);";
executor.schema(scope, "account", SCHEMA).await.unwrap();
executor.schema(scope, "account", SCHEMA).await.unwrap();
executor.execute(scope, "INSERT INTO account(name) VALUES ('preserved')", vec![]).await.unwrap();
executor.initialize(scope, "DROP INDEX account_name;").await.unwrap();
assert_eq!(executor.schema(scope, "account", SCHEMA).await.unwrap_err().kind, Kind::Migration);
let values = executor.query(scope, "SELECT name FROM account", vec![], 1, |row| row.get::<_, String>(0)).await.unwrap();
assert_eq!(values, vec!["preserved"]);
database.close();
}).unwrap();
}
#[test]
fn stopping_a_running_query_rolls_back_and_releases_its_worker() {
let directory = Directory::new();
runtime().run(async |scope| {
let database = Database::open(&directory.0, options(), scope).await.unwrap();
let mut setup = database.executor().await.unwrap();
setup.initialize(scope, CREATE).await.unwrap();
drop(setup);
let copy = database.clone();
let (started, ready) = tokio::sync::oneshot::channel();
let task = scope.spawn(move |child| async move {
let mut executor = copy.executor().await.unwrap();
executor.begin(&child).await.unwrap();
executor.execute(&child, "INSERT INTO account(name) VALUES ('cancelled')", vec![]).await.unwrap();
started.send(()).unwrap();
executor.query(&child, "WITH RECURSIVE numbers(value) AS (VALUES(1) UNION ALL SELECT value + 1 FROM numbers WHERE value < 1000000000) SELECT SUM(value) FROM numbers", vec![], 1, |row| row.get::<_, i64>(0)).await
}).unwrap();
ready.await.unwrap();
tokio::time::sleep(std::time::Duration::from_millis(10)).await;
assert!(task.stop().await.unwrap().is_none());
let mut executor = database.executor().await.unwrap();
let count = executor.query(scope, "SELECT COUNT(*) FROM account", vec![], 1, |row| row.get::<_, i64>(0)).await.unwrap();
assert_eq!(count, vec![0]);
database.close();
}).unwrap();
}
+120
View File
@@ -0,0 +1,120 @@
//! PostgreSQL 文档规定的 NUMERIC 分组字节;预期值独立于编码实现。
use super::*;
#[test]
fn numeric_encoding_matches_protocol_words() {
for (text, precision, scale, words) in [
("0", 5, 2, vec![0, 0, 0, 2]),
("-12.34", 5, 2, vec![2, 0, 0x4000, 2, 12, 3400]),
("0.00001", 5, 5, vec![1, -2, 0, 5, 1000]),
("10000", 5, 0, vec![1, 1, 0, 0, 1]),
("12345678.9012", 12, 4, vec![3, 1, 0, 4, 1234, 5678, 9012]),
(
"9999999999999999999999999999999999",
34,
0,
vec![
9, 8, 0, 0, 99, 9999, 9999, 9999, 9999, 9999, 9999, 9999, 9999,
],
),
] {
let value = Decimal::parse(text).unwrap();
let expected = words
.into_iter()
.flat_map(i16::to_be_bytes)
.collect::<Vec<_>>();
let mut output = BytesMut::new();
encode(value, precision, scale, &mut output).unwrap();
assert_eq!(output.as_ref(), expected, "{text}");
assert_eq!(decode(&output).unwrap(), value);
}
}
#[test]
fn native_numeric_preserves_decimal_extremes_and_zero() {
for text in [
"0",
"-0",
"0E-6176",
"1E-6176",
"1E6111",
"-123.456",
"1.0000",
"9.999999999999999999999999999999999E6144",
] {
let value = Decimal::parse(text).unwrap();
let mut output = BytesMut::new();
encode_native(value, &mut output);
assert_eq!(decode(&output).unwrap(), value, "{text}");
}
for (text, precision, scale) in [("1.001", 5, 2), ("1000", 5, 2), ("1", 35, 0), ("1", 2, 3)] {
assert!(
encode(
Decimal::parse(text).unwrap(),
precision,
scale,
&mut BytesMut::new()
)
.is_err()
);
}
}
#[test]
fn numeric_decode_rejects_malformed_or_inexact_values() {
for words in [
vec![1, 0, 0, 0],
vec![-1, 0, 0, 0],
vec![1, 0, 0, 0, 10000],
vec![0, 0, 0xc000_u16 as i16, 0],
vec![0, 0, 0, -1],
vec![1, 1537, 0, 0, 1],
vec![1, -1545, 0, 0, 1],
vec![
9, 8, 0, 0, 999, 9999, 9999, 9999, 9999, 9999, 9999, 9999, 9999,
],
] {
let bytes = words
.into_iter()
.flat_map(i16::to_be_bytes)
.collect::<Vec<_>>();
assert!(decode(&bytes).is_err(), "{bytes:?}");
}
}
#[test]
#[ignore = "explicit release performance probe"]
fn numeric_decode_cost() {
use std::{hint::black_box, time::Instant};
println!("\ncase,sample,iterations,elapsed_ns,checksum");
for (name, text) in [
("zero", "0"),
("fraction", "-1234567890123456789012345.123456789"),
("exponent", "9.999999999999999999999999999999999E6144"),
] {
let value = Decimal::parse(text).unwrap();
let mut encoded = BytesMut::new();
encode_native(value, &mut encoded);
let expected = decode(&encoded).unwrap();
assert_eq!(expected, value);
for _ in 0..100 {
black_box(decode(&encoded).unwrap());
}
for sample in 0..15 {
let started = Instant::now();
let mut decoded = Decimal::ZERO;
for _ in 0..100_000 {
decoded = black_box(decode(black_box(&encoded)).unwrap());
}
let elapsed = started.elapsed().as_nanos();
assert_eq!(decoded, expected);
let checksum: u64 = decoded
.to_be_bytes()
.iter()
.map(|byte| u64::from(*byte))
.sum();
println!("numeric_decode_{name},{sample},100000,{elapsed},{checksum}");
}
}
}
+99
View File
@@ -0,0 +1,99 @@
use super::*;
use std::{hint::black_box, time::Instant};
#[test]
fn json_constraint_keeps_validation_without_constructing_a_value() {
let directory = Directory::new();
runtime()
.run(async |scope| {
let database = Database::open(&directory.0, options(), scope)
.await
.unwrap();
let mut executor = database.executor().await.unwrap();
let sources = [
"null".to_owned(),
"1e9999".into(),
r#"{"name":"雪\n🙂","values":[1,true]}"#.into(),
r#"{"a":1,"a":2}"#.into(),
r#""\ud800""#.into(),
"[1,]".into(),
"null true".into(),
"[".repeat(257) + &"]".repeat(257),
format!("[{}0]", "0,".repeat(65535)),
format!("\"{}\"", "x".repeat(16 * 1024 * 1024)),
];
for source in sources {
let expected = dever_runtime::wire::json::Json::parse(&source).is_ok();
let actual = executor
.query(
scope,
"SELECT _dever_json(?1)",
vec![Parameter::Text(source)],
1,
|row| row.get::<_, bool>(0),
)
.await
.unwrap();
assert_eq!(actual, [expected]);
}
})
.unwrap();
}
#[test]
#[ignore = "explicit release SQL validation performance probe"]
fn sqlite_json_check_cost() {
let directory = Directory::new();
let runtime = runtime();
runtime
.run(async |scope| {
let database = Database::open(&directory.0, options(), scope)
.await
.unwrap();
let mut executor = database.executor().await.unwrap();
executor
.initialize(scope, "CREATE TABLE documents (body TEXT NOT NULL)")
.await
.unwrap();
println!("\ncase,sample,iterations,elapsed_ns,checksum");
for (bytes, iterations) in [(64, 100), (4096, 100), (65536, 10)] {
executor
.execute(scope, "DELETE FROM documents", vec![])
.await
.unwrap();
let source = format!("\"{}\"", "x".repeat(bytes - 2));
executor
.execute(
scope,
"WITH RECURSIVE rows(id) AS (SELECT 1 UNION ALL SELECT id+1 FROM rows WHERE id<256) INSERT INTO documents SELECT ?1 FROM rows",
vec![Parameter::Text(source)],
)
.await
.unwrap();
// 实际列逐行调用约束;避免常量折叠只验证一次参数。
for sample in 0..16 {
let start = Instant::now();
let mut checksum = 0i64;
for _ in 0..iterations {
let result = executor
.query(
scope,
"SELECT SUM(_dever_json(body)) FROM documents",
vec![],
1,
|row| row.get::<_, i64>(0),
)
.await
.unwrap();
checksum += black_box(result[0]);
}
let elapsed = start.elapsed().as_nanos();
assert_eq!(checksum, iterations * 256);
if sample != 0 {
println!("sqlite_json_check_{bytes},{},{iterations},{elapsed},{checksum}", sample - 1);
}
}
}
})
.unwrap();
}
+228
View File
@@ -0,0 +1,228 @@
use super::*;
use crate::database::{Error, Options, Parameter};
use futures_util::FutureExt;
use std::panic::AssertUnwindSafe;
use tokio_postgres::Client;
mod cases;
mod codec;
mod constraints;
#[cfg(feature = "net")]
mod registry;
const ID: Column = Column {
constraints: &[("account_pkey", "p", "PRIMARY KEY (\"id\")")],
..column("id", "bigint")
};
const NAME: Column = Column {
constraints: &[("_dever_unique_account_1", "u", "UNIQUE (\"name\")")],
..column("name", "text")
};
const TITLE: Column = Column {
constraints: &[("_dever_unique_account_1", "u", "UNIQUE (\"title\")")],
..column("title", "text")
};
const UID: Column = Column {
generated: true,
constraints: &[("_dever_unique_account_3", "u", "UNIQUE (\"uid\")")],
..column("uid", "uuid")
};
const INITIAL: &str = "CREATE TABLE \"account\" (id bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, name text NOT NULL CONSTRAINT _dever_unique_account_1 UNIQUE, legacy bigint NOT NULL DEFAULT 4, amount numeric(6,2) NOT NULL DEFAULT 1.25);";
const FINAL: &str = "CREATE TABLE \"account\" (id bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, title text NOT NULL CONSTRAINT _dever_unique_account_1 UNIQUE, amount numeric(8,3) NOT NULL DEFAULT 1.25, uid uuid NOT NULL CONSTRAINT _dever_unique_account_3 UNIQUE);";
const INDEX: &str = "CREATE INDEX account_title ON account(title);";
const HISTORICAL: Migration = Migration {
name: "historical",
revision: "historical-one",
drops: &["ancient"],
before: &[statement("UPDATE account SET ancient = 1")],
after: &[statement("UPDATE account SET ancient = 2")],
};
const UPGRADE: Migration = Migration {
name: "rename",
revision: "rename-one",
drops: &["legacy"],
before: &[statement("UPDATE account SET name = name || '_before'")],
after: &[statement("UPDATE account SET title = title || '_after'")],
};
const INITIAL_PLAN: Plan = Plan {
create: Sql::same(INITIAL),
postgres_columns: &[
ID,
NAME,
Column {
default: Some("4"),
..column("legacy", "bigint")
},
Column {
default: Some("1.25"),
..column("amount", "numeric(6,2)")
},
],
migrations: &[HISTORICAL],
seeds: &[Statement {
sql: Sql::same("INSERT INTO account(name) VALUES ($1) ON CONFLICT(name) DO NOTHING"),
parameters: || Ok(vec![Parameter::Text("seed".into())]),
}],
seed_revision: "initial-seed",
..EMPTY
};
const FINAL_PLAN: Plan = Plan {
create: Sql::same(FINAL),
indexes: Sql::same(INDEX),
postgres_columns: &[
ID,
TITLE,
Column {
default: Some("1.25"),
..column("amount", "numeric(8,3)")
},
UID,
],
migrations: &[HISTORICAL, UPGRADE],
renames: &[("title", "name")],
seeds: &[Statement {
sql: Sql::same(
"INSERT INTO account(title,uid) VALUES ($1,$2::uuid) ON CONFLICT(title) DO NOTHING",
),
parameters: || {
Ok(vec![
Parameter::Text("new".into()),
Parameter::Uuid(*crate::database::scalar::generated_uuid()?.as_bytes()),
])
},
}],
seed_revision: "final-seed",
};
const fn statement(sql: &'static str) -> Statement {
Statement {
sql: Sql::same(sql),
parameters: || Ok(vec![]),
}
}
#[test]
#[ignore = "requires explicit isolated config/setting.json database.postgres_test"]
fn schema_migration_seed_uuid_and_rollback() {
fixture(cases::migrations);
}
#[test]
#[ignore = "requires explicit isolated config/setting.json database.postgres_test"]
fn catalog_drift_fails_before_data_or_history_mutation() {
fixture(cases::drift);
}
#[test]
#[ignore = "requires explicit isolated config/setting.json database.postgres_test"]
fn new_database_records_final_history_without_old_transforms() {
fixture(cases::fresh);
}
#[test]
#[ignore = "requires explicit isolated config/setting.json database.postgres_test"]
fn native_binary_codecs_validate_precision_and_parameter_types() {
fixture(codec::roundtrip);
}
#[test]
#[ignore = "requires explicit isolated config/setting.json database.postgres_test"]
fn foreign_keys_and_tightening_validate_existing_data() {
fixture(constraints::foreign_keys);
}
#[test]
#[ignore = "requires explicit isolated config/setting.json database.postgres_test"]
fn numeric_migrations_reject_precision_loss() {
fixture(constraints::numeric);
}
fn configuration() -> serde_json::Value {
let path =
std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../../config/setting.json");
let document =
crate::adapter::Document::load(&path).expect("cannot read PostgreSQL test settings");
let root: serde_json::Value = document.decode().expect("invalid PostgreSQL test settings");
root.get("database")
.and_then(|database| database.get("postgres_test"))
.expect("database.postgres_test must explicitly select an isolated test database")
.clone()
}
fn options() -> Options {
let options: Options = serde_json::from_value(configuration())
.unwrap_or_else(|_| panic!("invalid PostgreSQL test options"));
assert_eq!(options.driver, "postgres");
options
}
fn fixture(check: impl AsyncFnOnce(&mut Client)) {
let runtime = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.unwrap();
runtime.block_on(isolated(options(), check));
}
#[cfg(feature = "net")]
fn registry_fixture(check: impl AsyncFnOnce(&mut Client, &crate::task::Scope<'_>)) {
let runtime = crate::task::Runtime::new(crate::task::Limits {
threads: 1,
workers: 1,
tasks: 16,
})
.unwrap();
runtime
.run(async |scope| isolated(options(), async |client| check(client, scope).await).await)
.unwrap();
}
async fn isolated(options: Options, check: impl AsyncFnOnce(&mut Client)) {
let database = super::super::Database::open(options).await.unwrap();
let mut client = database
.pool
.get()
.await
.unwrap_or_else(|_| panic!("cannot acquire PostgreSQL test connection"));
let name = format!(
"dever_test_{}",
crate::database::scalar::generated_uuid()
.unwrap()
.to_string()
.replace('-', "")
);
let quoted = schema::quote(&name);
client
.batch_execute(&format!("CREATE SCHEMA {quoted}"))
.await
.map_err(Error::from)
.unwrap();
let result = AssertUnwindSafe(async {
client
.batch_execute(&format!("SET search_path TO {quoted}"))
.await
.map_err(Error::from)
.unwrap();
tokio::time::timeout(std::time::Duration::from_secs(30), check(&mut client))
.await
.expect("PostgreSQL test deadline exceeded");
})
.catch_unwind()
.await;
let cleanup = tokio::time::timeout(
std::time::Duration::from_secs(10),
client.batch_execute(&format!(
"ROLLBACK; SET search_path TO pg_catalog; DROP SCHEMA {quoted} CASCADE"
)),
)
.await;
database.close();
cleanup
.expect("PostgreSQL test cleanup timed out")
.map_err(Error::from)
.expect("PostgreSQL test schema cleanup failed");
if let Err(panic) = result {
std::panic::resume_unwind(panic);
}
}
@@ -0,0 +1,229 @@
use super::*;
pub(super) async fn fresh(client: &mut Client) {
let definition = format!("{FINAL}{INDEX}");
schema::migrate(client, "account", &definition, Some(&FINAL_PLAN))
.await
.unwrap();
schema::migrate(client, "account", &definition, Some(&FINAL_PLAN))
.await
.unwrap();
let row = client
.query_one("SELECT title,uid::text FROM account", &[])
.await
.unwrap();
assert_eq!(row.get::<_, String>(0), "new");
assert_eq!(row.get::<_, String>(1).as_bytes()[14], b'7');
assert_eq!(
count(client, "SELECT count(*) FROM _dever_migration").await,
2
);
assert_eq!(count(client, "SELECT count(*) FROM _dever_seed").await, 1);
}
pub(super) async fn migrations(client: &mut Client) {
schema::migrate(client, "account", INITIAL, Some(&INITIAL_PLAN))
.await
.unwrap();
assert_eq!(count(client, "SELECT count(*) FROM account").await, 1);
client
.execute("UPDATE account SET name='business'", &[])
.await
.unwrap();
schema::migrate(client, "account", INITIAL, Some(&INITIAL_PLAN))
.await
.unwrap();
assert_eq!(count(client, "SELECT count(*) FROM account").await, 1);
client.batch_execute("INSERT INTO account(name) VALUES ('removed-high-id'); DELETE FROM account WHERE name='removed-high-id'").await.unwrap();
client
.execute(
"INSERT INTO account(name) SELECT 'row_' || value FROM generate_series(1,257) AS value",
&[],
)
.await
.unwrap();
let definition = format!("{FINAL}{INDEX}");
schema::migrate(client, "account", &definition, Some(&FINAL_PLAN))
.await
.unwrap();
schema::migrate(client, "account", &definition, Some(&FINAL_PLAN))
.await
.unwrap();
let rows = client
.query(
"SELECT id,title,amount::text,uid::text FROM account ORDER BY id",
&[],
)
.await
.unwrap();
assert_eq!(rows.len(), 259);
assert_eq!(rows[0].get::<_, String>(1), "business_before_after");
assert_eq!(rows[0].get::<_, String>(2), "1.250");
assert!(rows.last().unwrap().get::<_, i64>(0) > 259);
let mut generated = std::collections::BTreeSet::new();
for row in &rows {
let uuid: String = row.get(3);
assert_eq!(uuid.as_bytes()[14], b'7');
assert!(generated.insert(uuid));
}
let first: String = rows[0].get(3);
let second: String = rows[1].get(3);
assert_eq!(first.as_bytes()[14], b'7');
assert_eq!(second.as_bytes()[14], b'7');
assert_ne!(first, second);
let removed = Plan {
migrations: &[HISTORICAL],
..FINAL_PLAN
};
assert_eq!(
schema::migrate(client, "account", &definition, Some(&removed))
.await
.unwrap_err()
.kind,
Kind::Migration
);
let changed = const {
Plan {
migrations: &[HISTORICAL, step("rename", "changed", &["legacy"])],
..FINAL_PLAN
}
};
assert_eq!(
schema::migrate(client, "account", &definition, Some(&changed))
.await
.unwrap_err()
.kind,
Kind::Migration
);
const FAILED: &str = "CREATE TABLE \"account\" (id bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, title text NOT NULL CONSTRAINT _dever_unique_account_1 UNIQUE, amount numeric(8,3) NOT NULL DEFAULT 1.25, uid uuid NOT NULL CONSTRAINT _dever_unique_account_3 UNIQUE, note text);";
let failed = const {
Plan {
create: Sql::same(FAILED),
postgres_columns: &[
ID,
TITLE,
Column {
default: Some("1.25"),
..column("amount", "numeric(8,3)")
},
UID,
Column {
nullable: true,
..column("note", "text")
},
],
migrations: &[
HISTORICAL,
UPGRADE,
Migration {
name: "fail",
revision: "fail-one",
drops: &[],
before: &[statement("UPDATE account SET title=title || '_rollback'")],
after: &[statement("UPDATE account SET title=NULL")],
},
],
..FINAL_PLAN
}
};
assert_eq!(
schema::migrate(
client,
"account",
&format!("{FAILED}{INDEX}"),
Some(&failed)
)
.await
.unwrap_err()
.kind,
Kind::Constraint
);
assert_eq!(count(client, "SELECT count(*) FROM pg_attribute WHERE attrelid='account'::regclass AND attname='note' AND NOT attisdropped").await, 0);
assert_eq!(
count(client, "SELECT count(*) FROM _dever_migration").await,
2
);
assert_eq!(
client
.query_one("SELECT title FROM account WHERE id=1", &[])
.await
.unwrap()
.get::<_, String>(0),
"business_before_after"
);
schema::migrate(client, "account", &definition, Some(&FINAL_PLAN))
.await
.unwrap();
}
pub(super) async fn drift(client: &mut Client) {
let definition = format!("{FINAL}{INDEX}");
schema::migrate(client, "account", &definition, Some(&FINAL_PLAN))
.await
.unwrap();
let original: String = client
.query_one(
"SELECT catalog FROM _dever_history WHERE name='account'",
&[],
)
.await
.unwrap()
.get(0);
for (change, restore) in [
("DROP INDEX account_title", INDEX),
(
"ALTER TABLE account ALTER COLUMN amount SET DEFAULT 2.25",
"ALTER TABLE account ALTER COLUMN amount SET DEFAULT 1.25",
),
(
"ALTER TABLE account ALTER COLUMN title DROP NOT NULL",
"ALTER TABLE account ALTER COLUMN title SET NOT NULL",
),
(
"ALTER TABLE account ADD CONSTRAINT _dever_manual CHECK (amount>0)",
"ALTER TABLE account DROP CONSTRAINT _dever_manual",
),
(
"ALTER TABLE account ALTER COLUMN id SET INCREMENT BY 2",
"ALTER TABLE account ALTER COLUMN id SET INCREMENT BY 1",
),
] {
client.batch_execute(change).await.unwrap();
assert_eq!(
schema::migrate(client, "account", &definition, Some(&FINAL_PLAN))
.await
.unwrap_err()
.kind,
Kind::Migration
);
assert_eq!(
client
.query_one(
"SELECT catalog FROM _dever_history WHERE name='account'",
&[]
)
.await
.unwrap()
.get::<_, String>(0),
original
);
assert_eq!(count(client, "SELECT count(*) FROM account").await, 1);
client.batch_execute(restore).await.unwrap();
schema::migrate(client, "account", &definition, Some(&FINAL_PLAN))
.await
.unwrap();
}
client.batch_execute("DROP TABLE account").await.unwrap();
assert_eq!(
schema::migrate(client, "account", &definition, Some(&FINAL_PLAN))
.await
.unwrap_err()
.kind,
Kind::Migration
);
}
async fn count(client: &Client, sql: &str) -> i64 {
client.query_one(sql, &[]).await.unwrap().get(0)
}
@@ -0,0 +1,120 @@
use super::*;
use crate::{database::Row, value::decimal::Decimal};
pub(super) async fn roundtrip(client: &mut Client) {
for text in [
"0",
"-0.0001",
"12345.6789",
"9.999999999999999999999999999999999E+6144",
"1E-6176",
] {
let expected = Decimal::parse(text).unwrap();
let parameter = Parameter::NativeDecimal(expected);
let row = client
.query_one("SELECT $1::numeric", &[&parameter])
.await
.unwrap();
assert_eq!(Row::Postgres(&row).native_decimal(0).unwrap(), expected);
}
let uuid = crate::database::scalar::generated_uuid().unwrap();
let integer = Decimal::parse("9999999999999999999999999999999999").unwrap();
let fraction = Decimal::parse("-0.9999999999999999999999999999999999").unwrap();
let parameters = [
Parameter::Bool(true),
Parameter::Integer(i64::MIN),
Parameter::Real(1.25),
Parameter::Uuid(*uuid.as_bytes()),
Parameter::Decimal {
value: integer,
precision: 34,
scale: 0,
},
Parameter::Decimal {
value: fraction,
precision: 34,
scale: 34,
},
Parameter::Null,
Parameter::Text("雪'\\native".into()),
Parameter::Blob(vec![0, 128, 255]),
];
let references = parameters
.iter()
.map(|value| value as &(dyn tokio_postgres::types::ToSql + Sync))
.collect::<Vec<_>>();
let result = client.query_one("SELECT $1::boolean,$2::bigint,$3::float8,$4::uuid,$5::numeric(34,0),$6::numeric(34,34),$7::uuid,$8::text,$9::bytea", &references).await.unwrap();
let row = Row::Postgres(&result);
assert!(row.get::<_, bool>(0).unwrap());
assert_eq!(row.get::<_, i64>(1).unwrap(), i64::MIN);
assert_eq!(row.get::<_, f64>(2).unwrap(), 1.25);
assert_eq!(row.uuid(3).unwrap(), *uuid.as_bytes());
assert_eq!(row.decimal(4, 34, 0).unwrap(), integer);
assert_eq!(row.decimal(5, 34, 34).unwrap(), fraction);
assert!(row.is_null(6).unwrap());
assert_eq!(row.text(7).unwrap(), "雪'\\native");
assert_eq!(row.bytes(8).unwrap(), [0, 128, 255]);
for value in [
"0",
"0.0000000000000000000000000000000001",
"0.1234000000000000000000000000000000",
] {
let expected = Decimal::parse(value).unwrap();
let parameter = Parameter::Decimal {
value: expected,
precision: 34,
scale: 34,
};
let result = client
.query_one("SELECT $1::numeric(34,34)", &[&parameter])
.await
.unwrap();
assert_eq!(Row::Postgres(&result).decimal(0, 34, 34).unwrap(), expected);
}
for (sql, parameter) in [
("SELECT $1::integer", Parameter::Integer(1)),
("SELECT $1::text::uuid", Parameter::Uuid(*uuid.as_bytes())),
("SELECT $1::float8", Parameter::Real(f64::INFINITY)),
("SELECT $1::float8", Parameter::Real(f64::NAN)),
(
"SELECT $1::numeric(5,2)",
Parameter::Decimal {
value: Decimal::parse("0.001").unwrap(),
precision: 5,
scale: 2,
},
),
(
"SELECT $1::numeric(5,2)",
Parameter::Decimal {
value: Decimal::parse("1000").unwrap(),
precision: 5,
scale: 2,
},
),
] {
assert!(client.query_one(sql, &[&parameter]).await.is_err(), "{sql}");
}
let invalid = client
.query_one("SELECT 0.001::numeric,1000::numeric,'NaN'::numeric", &[])
.await
.unwrap();
for position in 0..3 {
assert_eq!(
Row::Postgres(&invalid)
.decimal(position, 5, 2)
.unwrap_err()
.kind,
Kind::InvalidData
);
}
let result = client
.query_one(
"SELECT $1::bigint,$2::float8",
&[&Parameter::Integer(i64::MAX), &Parameter::Real(-f64::MAX)],
)
.await
.unwrap();
assert_eq!(result.get::<_, i64>(0), i64::MAX);
assert_eq!(result.get::<_, f64>(1), -f64::MAX);
}
@@ -0,0 +1,230 @@
use super::*;
pub(super) async fn foreign_keys(client: &mut Client) {
const PARENT: &str = "CREATE TABLE parent(id bigint PRIMARY KEY);";
schema::migrate(client, "parent", PARENT, None)
.await
.unwrap();
schema::migrate(client, "parent", PARENT, None)
.await
.unwrap();
assert_eq!(
schema::migrate(
client,
"parent",
"CREATE TABLE parent(id bigint PRIMARY KEY, extra text);",
None
)
.await
.unwrap_err()
.kind,
Kind::Migration
);
client
.execute("INSERT INTO parent VALUES (1)", &[])
.await
.unwrap();
const START: &str = "CREATE TABLE \"child\" (id bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, parent_id bigint NOT NULL CONSTRAINT _dever_fk_parent REFERENCES parent(id));";
const REFERENCE: Column = Column {
constraints: &[(
"_dever_fk_parent",
"f",
"FOREIGN KEY (parent_id) REFERENCES parent(id)",
)],
..column("parent_id", "bigint")
};
const START_PLAN: Plan = Plan {
create: Sql::same(START),
postgres_columns: &[ID, REFERENCE],
..EMPTY
};
schema::migrate(client, "child", START, Some(&START_PLAN))
.await
.unwrap();
client
.execute("INSERT INTO child(parent_id) VALUES (1)", &[])
.await
.unwrap();
const NEXT: &str = "CREATE TABLE \"child\" (id bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, parent_id bigint NOT NULL CONSTRAINT _dever_fk_parent REFERENCES parent(id), label text NOT NULL DEFAULT 'ok' CONSTRAINT _dever_check_label CHECK (char_length(label)<=4));";
const NEXT_PLAN: Plan = Plan {
create: Sql::same(NEXT),
postgres_columns: &[
ID,
REFERENCE,
Column {
default: Some("'ok'"),
constraints: &[("_dever_check_label", "c", "CHECK (char_length(label)<=4)")],
..column("label", "text")
},
],
..EMPTY
};
schema::migrate(client, "child", NEXT, Some(&NEXT_PLAN))
.await
.unwrap();
schema::migrate(client, "child", NEXT, Some(&NEXT_PLAN))
.await
.unwrap();
assert!(
client
.execute("INSERT INTO child(parent_id) VALUES (2)", &[])
.await
.is_err()
);
assert!(
client
.execute("UPDATE child SET label='too long'", &[])
.await
.is_err()
);
assert!(
client
.execute("DELETE FROM parent WHERE id=1", &[])
.await
.is_err()
);
const TIGHT: &str = "CREATE TABLE \"child\" (id bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, parent_id bigint NOT NULL CONSTRAINT _dever_fk_parent REFERENCES parent(id), label text NOT NULL DEFAULT 'ok' CONSTRAINT _dever_check_label CHECK (char_length(label)<=1));";
const TIGHT_PLAN: Plan = Plan {
create: Sql::same(TIGHT),
postgres_columns: &[
ID,
REFERENCE,
Column {
default: Some("'ok'"),
constraints: &[("_dever_check_label", "c", "CHECK (char_length(label)<=1)")],
..column("label", "text")
},
],
..EMPTY
};
assert_eq!(
schema::migrate(client, "child", TIGHT, Some(&TIGHT_PLAN))
.await
.unwrap_err()
.kind,
Kind::Constraint
);
schema::migrate(client, "child", NEXT, Some(&NEXT_PLAN))
.await
.unwrap();
assert_eq!(
client
.query_one("SELECT label FROM child", &[])
.await
.unwrap()
.get::<_, String>(0),
"ok"
);
}
pub(super) async fn numeric(client: &mut Client) {
const INTEGER: &str = "CREATE TABLE \"numbers\" (id bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, value bigint NOT NULL);";
const FLOAT: &str = "CREATE TABLE \"numbers\" (id bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, value double precision NOT NULL);";
const INTEGER_PLAN: Plan = Plan {
create: Sql::same(INTEGER),
postgres_columns: &[ID, column("value", "bigint")],
..EMPTY
};
const FLOAT_PLAN: Plan = Plan {
create: Sql::same(FLOAT),
postgres_columns: &[ID, column("value", "double precision")],
..EMPTY
};
schema::migrate(client, "numbers", INTEGER, Some(&INTEGER_PLAN))
.await
.unwrap();
client
.execute(
"INSERT INTO numbers(value) VALUES (9007199254740993), (9223372036854775807)",
&[],
)
.await
.unwrap();
assert_eq!(
schema::migrate(client, "numbers", FLOAT, Some(&FLOAT_PLAN))
.await
.unwrap_err()
.kind,
Kind::Migration
);
assert_eq!(
client
.query_one("SELECT value FROM numbers ORDER BY id LIMIT 1", &[])
.await
.unwrap()
.get::<_, i64>(0),
9007199254740993
);
client.batch_execute("DELETE FROM numbers; INSERT INTO numbers(value) VALUES (9007199254740992), (-9223372036854775808), (9223372036854774784)").await.unwrap();
assert_eq!(
schema::migrate(client, "numbers", FLOAT, Some(&FLOAT_PLAN))
.await
.unwrap_err()
.kind,
Kind::Migration
);
assert_eq!(
client
.query_one("SELECT value FROM numbers ORDER BY id LIMIT 1", &[])
.await
.unwrap()
.get::<_, i64>(0),
9007199254740992
);
const WIDE: &str = "CREATE TABLE \"decimal_numbers\" (id bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, value numeric(8,3) NOT NULL);";
const NARROW: &str = "CREATE TABLE \"decimal_numbers\" (id bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, value numeric(5,2) NOT NULL);";
const WIDE_PLAN: Plan = Plan {
create: Sql::same(WIDE),
postgres_columns: &[ID, column("value", "numeric(8,3)")],
..EMPTY
};
const NARROW_PLAN: Plan = Plan {
create: Sql::same(NARROW),
postgres_columns: &[ID, column("value", "numeric(5,2)")],
..EMPTY
};
schema::migrate(client, "decimal_numbers", WIDE, Some(&WIDE_PLAN))
.await
.unwrap();
client
.execute("INSERT INTO decimal_numbers(value) VALUES (1.251)", &[])
.await
.unwrap();
assert_eq!(
schema::migrate(client, "decimal_numbers", NARROW, Some(&NARROW_PLAN))
.await
.unwrap_err()
.kind,
Kind::Migration
);
client
.execute("UPDATE decimal_numbers SET value=1000", &[])
.await
.unwrap();
assert_eq!(
schema::migrate(client, "decimal_numbers", NARROW, Some(&NARROW_PLAN))
.await
.unwrap_err()
.kind,
Kind::InvalidData
);
schema::migrate(client, "decimal_numbers", WIDE, Some(&WIDE_PLAN))
.await
.unwrap();
client
.execute("UPDATE decimal_numbers SET value=1.250", &[])
.await
.unwrap();
schema::migrate(client, "decimal_numbers", NARROW, Some(&NARROW_PLAN))
.await
.unwrap();
assert_eq!(
client
.query_one("SELECT value::text FROM decimal_numbers", &[])
.await
.unwrap()
.get::<_, String>(0),
"1.25"
);
}
@@ -0,0 +1,288 @@
use super::*;
use crate::database::{Binding, Context, Registry};
use std::{io::Write, os::unix::fs::OpenOptionsExt, path::PathBuf};
#[test]
#[ignore = "explicit release pagination performance probe with isolated PostgreSQL"]
fn postgres_page_cost() {
use crate::database::{Database, PageQuery};
use std::{hint::black_box, time::Instant};
registry_fixture(async |client, scope| {
let settings = options();
let timeout = settings.io_timeout();
let database = Database::open(std::path::Path::new("."), settings, scope)
.await
.unwrap();
let mut executor = database.executor().await.unwrap();
let count = "SELECT 1::bigint WHERE $1::text IS NOT NULL";
let select = "SELECT octet_length($1::text)::bigint LIMIT $2::bigint OFFSET $3::bigint";
let count_statement = client.prepare(count).await.unwrap();
let select_statement = client.prepare(select).await.unwrap();
println!("\ncase,sample,implementation,iterations,elapsed_ns,checksum");
for bytes in [64, 65536, 1048576] {
let source = "x".repeat(bytes);
for sample in 0..16 {
for offset in 0..2 {
let generated = (sample + offset) % 2 == 0;
let started = Instant::now();
let mut checksum = 0;
for _ in 0..20 {
let payload = black_box(source.clone());
let value = if generated {
let page = executor
.page(
scope,
PageQuery {
count: Sql::same(count),
select: Sql::same(select),
parameters: vec![Parameter::Text(payload)],
page: 1,
size: 1,
},
|row| row.get::<_, i64>(0),
)
.await
.unwrap();
assert_eq!((page.total, page.pages, page.items.len()), (1, 1, 1));
page.items[0]
} else {
// 原生驱动同样执行两个有独立期限的查询;载荷只持有一份。
let total = tokio::time::timeout(
timeout,
client.query_one(&count_statement, &[&payload]),
)
.await
.unwrap()
.unwrap();
assert_eq!(total.get::<_, i64>(0), 1);
tokio::time::timeout(
timeout,
client.query_one(&select_statement, &[&payload, &1i64, &0i64]),
)
.await
.unwrap()
.unwrap()
.get::<_, i64>(0)
};
assert_eq!(value, bytes as i64);
checksum += black_box(value);
}
let elapsed = started.elapsed().as_nanos();
if sample != 0 {
let implementation = if generated { "dever" } else { "rust" };
println!(
"postgres_page_{bytes},{},{implementation},20,{elapsed},{checksum}",
sample - 1
);
}
}
}
}
database.close();
});
}
const fn binding(
table: &'static str,
sql: &'static str,
database: Option<&'static str>,
references: &'static [usize],
) -> Binding {
Binding {
storage: crate::database::Storage::Global,
component: "demo",
database,
table,
schema: Sql::same(sql),
plan: None,
references,
}
}
#[test]
#[ignore = "requires explicit isolated config/setting.json database.postgres_test"]
fn first_create_orders_parent_before_alphabetically_earlier_child() {
registry_fixture(async |client, scope| {
let settings = SettingsFile::new(client).await;
let bindings = [
binding(
"a_child",
"CREATE TABLE a_child(id bigint PRIMARY KEY, parent_id bigint NOT NULL REFERENCES z_parent(id));",
None,
&[2],
),
binding(
"m_independent",
"CREATE TABLE m_independent(id bigint PRIMARY KEY);",
Some("secondary"),
&[],
),
binding(
"z_parent",
"CREATE TABLE z_parent(id bigint PRIMARY KEY);",
None,
&[],
),
binding(
"self_node",
"CREATE TABLE self_node(id bigint PRIMARY KEY, parent_id bigint REFERENCES self_node(id));",
None,
&[3],
),
];
let registry = Registry::load(&settings.path(), &bindings, scope)
.await
.unwrap();
registry.check_transactions(&[&[0, 2, 3]]).unwrap();
assert_eq!(
registry.check_transactions(&[&[0, 1]]).unwrap_err().kind,
Kind::Connection
);
let mut context = Context::new(registry);
context
.on(2, scope)
.await
.unwrap()
.execute(scope, Sql::same("INSERT INTO z_parent VALUES (42)"), vec![])
.await
.unwrap();
context
.on(0, scope)
.await
.unwrap()
.execute(
scope,
Sql::same("INSERT INTO a_child VALUES (1,42)"),
vec![],
)
.await
.unwrap();
context
.on(1, scope)
.await
.unwrap()
.execute(
scope,
Sql::same("INSERT INTO m_independent VALUES (1)"),
vec![],
)
.await
.unwrap();
context
.on(3, scope)
.await
.unwrap()
.execute(
scope,
Sql::same("INSERT INTO self_node VALUES (1,1)"),
vec![],
)
.await
.unwrap();
drop(context);
let repeated = Registry::load(&settings.path(), &bindings, scope)
.await
.unwrap();
repeated.check_transactions(&[&[0, 2]]).unwrap();
assert_eq!(
client
.query_one("SELECT parent_id FROM a_child", &[])
.await
.unwrap()
.get::<_, i64>(0),
42
);
assert_eq!(
client
.query_one("SELECT count(*) FROM _dever_history", &[])
.await
.unwrap()
.get::<_, i64>(0),
4
);
assert!(client.execute("DELETE FROM z_parent", &[]).await.is_err());
});
}
#[test]
#[ignore = "requires explicit isolated config/setting.json database.postgres_test"]
fn cyclic_postgres_bindings_fail_before_any_schema_or_history_ddl() {
registry_fixture(async |client, scope| {
let settings = SettingsFile::new(client).await;
let bindings = [
binding(
"independent",
"CREATE TABLE independent(id bigint PRIMARY KEY);",
None,
&[],
),
binding(
"cycle_left",
"CREATE TABLE cycle_left(id bigint PRIMARY KEY, parent_id bigint REFERENCES cycle_right(id));",
None,
&[2],
),
binding(
"cycle_right",
"CREATE TABLE cycle_right(id bigint PRIMARY KEY, parent_id bigint REFERENCES cycle_left(id));",
None,
&[1],
),
];
let error = Registry::load(&settings.path(), &bindings, scope)
.await
.err()
.unwrap();
assert_eq!(error.kind, Kind::Migration);
assert_eq!(client.query_one("SELECT count(*) FROM pg_class WHERE relnamespace=current_schema()::regnamespace", &[]).await.unwrap().get::<_, i64>(0), 0);
});
}
struct SettingsFile(PathBuf);
impl SettingsFile {
async fn new(client: &Client) -> Self {
let schema: String = client
.query_one("SELECT current_schema()", &[])
.await
.unwrap()
.get(0);
let mut connection = configuration();
let mut url = connection["url"].as_str().unwrap().to_owned();
let parsed: tokio_postgres::Config = url.parse().unwrap();
assert!(
parsed.get_options().is_none(),
"test connection cannot override search_path"
);
url.push(if url.contains('?') { '&' } else { '?' });
url.push_str(&format!("options=-csearch_path%3D{schema}"));
connection["url"] = serde_json::Value::String(url);
let directory = std::env::temp_dir().join(format!(
"dever_registry_{}",
crate::database::scalar::generated_uuid().unwrap()
));
std::fs::create_dir(&directory).unwrap();
let settings = Self(directory);
let mut file = std::fs::OpenOptions::new()
.write(true)
.create_new(true)
.mode(0o600)
.open(settings.path())
.unwrap();
let document =
serde_json::json!({"database":{"default":connection,"secondary":connection}});
file.write_all(&serde_json::to_vec(&document).unwrap())
.unwrap();
settings
}
fn path(&self) -> PathBuf {
self.0.join("setting.json")
}
}
impl Drop for SettingsFile {
fn drop(&mut self) {
std::fs::remove_dir_all(&self.0).unwrap();
}
}
+156
View File
@@ -0,0 +1,156 @@
use super::{alter, history, schema};
use crate::database::{Column, Kind, Migration, Plan, Sql, Statement};
use std::collections::BTreeMap;
mod live;
mod registry;
const EMPTY: Plan = Plan {
create: Sql::same(""),
indexes: Sql::same(""),
postgres_columns: &[],
renames: &[],
migrations: &[],
seeds: &[],
seed_revision: "empty",
};
const fn column(name: &'static str, kind: &'static str) -> Column {
Column {
name,
kind,
default: None,
nullable: false,
generated: false,
constraints: &[],
}
}
const fn step(
name: &'static str,
revision: &'static str,
drops: &'static [&'static str],
) -> Migration {
Migration {
name,
revision,
drops,
before: &[],
after: &[],
}
}
#[test]
fn migration_history_is_an_immutable_contiguous_prefix() {
let plan = const {
Plan {
migrations: &[step("first", "one", &[]), step("second", "two", &[])],
..EMPTY
}
};
assert_eq!(history::validate(&[], &plan).unwrap(), 0);
assert_eq!(
history::validate(&[(0, "first".into(), "one".into())], &plan).unwrap(),
1
);
for records in [
vec![(1, "first".into(), "one".into())],
vec![(0, "second".into(), "two".into())],
vec![(0, "first".into(), "modified".into())],
vec![
(0, "first".into(), "one".into()),
(2, "second".into(), "two".into()),
],
] {
assert_eq!(
history::validate(&records, &plan).unwrap_err().kind,
Kind::Migration
);
}
assert_eq!(
history::validate(&[(0, "first".into(), "one".into())], &EMPTY)
.unwrap_err()
.kind,
Kind::Migration
);
}
#[test]
fn column_mapping_requires_exact_unused_drops_and_unambiguous_renames() {
let old = BTreeMap::from([
("id".into(), "bigint".into()),
("name".into(), "text".into()),
("removed".into(), "bigint".into()),
]);
let plan = const {
Plan {
postgres_columns: &[
column("id", "bigint"),
column("title", "text"),
column("new", "uuid"),
],
renames: &[("title", "name")],
migrations: &[step("remove", "one", &["removed"])],
..EMPTY
}
};
assert_eq!(
alter::sources(&old, &plan, 0).unwrap(),
[Some("id"), Some("name"), None]
);
assert_eq!(
alter::sources(&old, &plan, 1).unwrap_err().kind,
Kind::Migration
);
let mut ambiguous = old.clone();
ambiguous.insert("title".into(), "text".into());
assert_eq!(
alter::sources(&ambiguous, &plan, 0).unwrap_err().kind,
Kind::Migration
);
let missing = Plan {
renames: &[("title", "absent")],
..plan
};
assert_eq!(
alter::sources(&old, &missing, 0).unwrap_err().kind,
Kind::Migration
);
let duplicated = Plan {
postgres_columns: const {
&[
column("id", "bigint"),
column("title", "text"),
column("other", "text"),
]
},
renames: &[("title", "name"), ("other", "name")],
..missing
};
assert_eq!(
alter::sources(&old, &duplicated, 0).unwrap_err().kind,
Kind::Migration
);
}
#[test]
fn only_supported_storage_conversions_reach_postgres_ddl() {
for (old, next) in [
("text", "text"),
("numeric(6,2)", "numeric(8,3)"),
("numeric(8,3)", "numeric(6,2)"),
] {
assert!(alter::conversion(old, next));
}
for (old, next) in [
("double precision", "bigint"),
("bigint", "double precision"),
("text", "uuid"),
("bigint", "numeric(8,2)"),
("bytea", "text"),
("numeric(6,2)", "numeric(3,4)"),
] {
assert!(!alter::conversion(old, next));
}
assert_eq!(schema::quote("a\"b"), "\"a\"\"b\"");
}
@@ -0,0 +1,54 @@
use crate::database::{Binding, Kind, Sql, context::initialization_order};
fn binding(references: &'static [usize]) -> Binding {
Binding {
storage: crate::database::Storage::Global,
component: "demo",
database: None,
table: "unused",
schema: Sql::same(""),
plan: None,
references,
}
}
#[test]
fn postgres_dependencies_precede_children_without_changing_binding_indices() {
let bindings = [
binding(&[0, 1, 2, 2]),
binding(&[2]),
binding(&[]),
binding(&[]),
];
assert_eq!(
initialization_order(&bindings, &[true; 4]).unwrap(),
[2, 1, 0, 3]
);
}
#[test]
fn postgres_cycles_fail_before_initialization_but_self_references_are_valid() {
assert_eq!(
initialization_order(&[binding(&[1]), binding(&[0])], &[true, true])
.unwrap_err()
.kind,
Kind::Migration
);
assert_eq!(
initialization_order(&[binding(&[0])], &[true]).unwrap(),
[0]
);
}
#[test]
fn sqlite_keeps_source_order_and_accepts_forward_and_cyclic_references() {
assert_eq!(
initialization_order(&[binding(&[1]), binding(&[0])], &[false, false]).unwrap(),
[0, 1]
);
let bindings = [binding(&[2]), binding(&[3]), binding(&[]), binding(&[1])];
assert_eq!(
initialization_order(&bindings, &[true, false, true, false]).unwrap(),
[1, 2, 0, 3]
);
}
+366
View File
@@ -0,0 +1,366 @@
use crate::{
adapter::Document,
database::{Binding, Context, Parameter, Plan, Registry, Settings, Sql, Statement, Storage},
task::{Limits, Runtime},
};
use std::{
path::PathBuf,
sync::atomic::{AtomicU64, Ordering},
};
static NEXT: AtomicU64 = AtomicU64::new(0);
struct Directory(PathBuf);
impl Directory {
fn new() -> Self {
let directory = Self(std::env::temp_dir().join(format!(
"dever-registry-{}-{}",
std::process::id(),
NEXT.fetch_add(1, Ordering::Relaxed)
)));
std::fs::create_dir(&directory.0).unwrap();
directory
}
}
impl Drop for Directory {
fn drop(&mut self) {
std::fs::remove_dir_all(&self.0).unwrap();
}
}
fn runtime() -> Runtime {
Runtime::new(Limits {
threads: 2,
workers: 2,
tasks: 16,
})
.unwrap()
}
const PARENT: &str = "CREATE TABLE z_parent(id INTEGER PRIMARY KEY);";
const CHILD: &str = "CREATE TABLE a_child(id INTEGER PRIMARY KEY, parent_id INTEGER NOT NULL REFERENCES z_parent(id));";
const PARENT_PLAN: Plan = Plan {
create: Sql::same(PARENT),
indexes: Sql::same(""),
postgres_columns: &[],
renames: &[],
migrations: &[],
seed_revision: "parent-v1",
seeds: &[Statement {
sql: Sql::same("INSERT INTO z_parent VALUES (?1)"),
parameters: || Ok(vec![Parameter::Integer(7)]),
}],
};
const CHILD_PLAN: Plan = Plan {
create: Sql::same(CHILD),
seed_revision: "child-v1",
seeds: &[Statement {
sql: Sql::same("INSERT INTO a_child VALUES (?1,?2)"),
parameters: || Ok(vec![Parameter::Integer(1), Parameter::Integer(7)]),
}],
..PARENT_PLAN
};
fn binding(
table: &'static str,
schema: &'static str,
plan: Option<&'static Plan>,
references: &'static [usize],
) -> Binding {
Binding {
storage: Storage::Global,
component: "demo",
database: None,
table,
schema: Sql::same(schema),
plan,
references,
}
}
fn settings() -> Settings {
Settings::load(&Document::parse(r#"{"database":{"default":{"type":"sqlite","path":"registry.db","max_connections":1}}}"#.into()).unwrap()).unwrap()
}
#[test]
fn shared_internal_bindings_initialize_once_and_reject_conflicting_schema() {
let directory = Directory::new();
let shared = binding(
"queue",
"CREATE TABLE queue(id INTEGER PRIMARY KEY);",
None,
&[],
);
let mut bindings = [shared.clone(), shared];
runtime()
.run(async |scope| {
for expected in [1, 2] {
let registry = Registry::prepare(&directory.0, settings(), &bindings, scope)
.await
.unwrap();
assert!(registry.same_pool(0, 1));
let mut context = Context::new(registry.clone());
context
.on(0, scope)
.await
.unwrap()
.execute(
scope,
"INSERT INTO queue VALUES (?1)",
vec![Parameter::Integer(expected)],
)
.await
.unwrap();
let count = context
.on(1, scope)
.await
.unwrap()
.query(scope, "SELECT COUNT(*) FROM queue", vec![], 1, |row| {
row.get::<_, i64>(0)
})
.await
.unwrap();
assert_eq!(count, [expected]);
drop(context);
registry.close().await;
}
bindings[1].schema =
Sql::same("CREATE TABLE queue(id INTEGER PRIMARY KEY, extra TEXT);");
let error = Registry::prepare(&directory.0, settings(), &bindings, scope)
.await
.err()
.expect("conflicting shared schema accepted");
assert_eq!(error.kind, crate::database::Kind::Migration);
})
.unwrap();
}
#[test]
fn sqlite_registry_seeds_forward_references_after_all_tables_exist() {
let directory = Directory::new();
let bindings = [
binding("a_child", CHILD, Some(&CHILD_PLAN), &[1]),
binding("z_parent", PARENT, Some(&PARENT_PLAN), &[]),
];
runtime()
.run(async |scope| {
for _ in 0..2 {
let registry = Registry::prepare(&directory.0, settings(), &bindings, scope)
.await
.unwrap();
let mut context = Context::new(registry.clone());
let executor = context.on(0, scope).await.unwrap();
assert_eq!(
executor
.query(scope, "SELECT parent_id FROM a_child", vec![], 1, |row| row
.get::<_, i64>(0))
.await
.unwrap(),
[7]
);
assert!(
executor
.execute(scope, "INSERT INTO a_child VALUES (2,999)", vec![])
.await
.is_err()
);
drop(context);
registry.close().await;
}
})
.unwrap();
}
#[test]
fn sqlite_registry_preserves_cyclic_and_self_references_with_and_without_seeds() {
const LEFT: &str = "CREATE TABLE cycle_left(id INTEGER PRIMARY KEY, right_id INTEGER REFERENCES cycle_right(id));";
const RIGHT: &str = "CREATE TABLE cycle_right(id INTEGER PRIMARY KEY, left_id INTEGER REFERENCES cycle_left(id));";
const SELF: &str = "CREATE TABLE self_node(id INTEGER PRIMARY KEY, parent_id INTEGER REFERENCES self_node(id));";
const LEFT_PLAN: Plan = Plan {
create: Sql::same(LEFT),
seeds: &[Statement {
sql: Sql::same("INSERT INTO cycle_left VALUES (1,2)"),
parameters: || Ok(vec![]),
}],
..PARENT_PLAN
};
const RIGHT_PLAN: Plan = Plan {
create: Sql::same(RIGHT),
seeds: &[Statement {
sql: Sql::same("INSERT INTO cycle_right VALUES (2,1)"),
parameters: || Ok(vec![]),
}],
..PARENT_PLAN
};
const SELF_PLAN: Plan = Plan {
create: Sql::same(SELF),
seeds: &[Statement {
sql: Sql::same("INSERT INTO self_node VALUES (1,1)"),
parameters: || Ok(vec![]),
}],
..PARENT_PLAN
};
const EMPTY_LEFT: Plan = Plan {
seeds: &[],
..LEFT_PLAN
};
const EMPTY_RIGHT: Plan = Plan {
seeds: &[],
..RIGHT_PLAN
};
const EMPTY_SELF: Plan = Plan {
seeds: &[],
..SELF_PLAN
};
for seeded in [false, true] {
let directory = Directory::new();
let bindings = [
binding(
"cycle_left",
LEFT,
Some(if seeded { &LEFT_PLAN } else { &EMPTY_LEFT }),
&[1],
),
binding(
"cycle_right",
RIGHT,
Some(if seeded { &RIGHT_PLAN } else { &EMPTY_RIGHT }),
&[0],
),
binding(
"self_node",
SELF,
Some(if seeded { &SELF_PLAN } else { &EMPTY_SELF }),
&[2],
),
];
runtime()
.run(async |scope| {
for _ in 0..2 {
let registry = Registry::prepare(&directory.0, settings(), &bindings, scope)
.await
.unwrap();
let mut context = Context::new(registry.clone());
let executor = context.on(0, scope).await.unwrap();
assert_eq!(
executor
.query(scope, "SELECT count(*) FROM cycle_left", vec![], 1, |row| {
row.get::<_, i64>(0)
})
.await
.unwrap(),
[i64::from(seeded)]
);
assert!(
executor
.execute(scope, "INSERT INTO cycle_left VALUES (3,999)", vec![])
.await
.is_err()
);
assert!(
executor
.execute(scope, "INSERT INTO self_node VALUES (3,999)", vec![])
.await
.is_err()
);
drop(context);
registry.close().await;
}
})
.unwrap();
}
}
#[test]
fn sqlite_registry_invalid_seed_rolls_back_the_physical_group() {
const INVALID: Plan = Plan {
seeds: &[Statement {
sql: Sql::same("INSERT INTO a_child VALUES (1,999)"),
parameters: || Ok(vec![]),
}],
..CHILD_PLAN
};
let directory = Directory::new();
runtime()
.run(async |scope| {
let bindings = [
binding("a_child", CHILD, Some(&INVALID), &[1]),
binding("z_parent", PARENT, Some(&PARENT_PLAN), &[]),
];
let error = Registry::prepare(&directory.0, settings(), &bindings, scope)
.await
.err()
.unwrap();
assert_eq!(error.kind, crate::database::Kind::Migration);
let options = settings().select("", None).unwrap().clone();
let database = crate::database::Database::open(&directory.0, options, scope)
.await
.unwrap();
let mut executor = database.executor().await.unwrap();
assert_eq!(
executor
.query(
scope,
"SELECT count(*) FROM sqlite_schema WHERE type='table'",
vec![],
1,
|row| row.get::<_, i64>(0)
)
.await
.unwrap(),
[0]
);
drop(executor);
database.close();
let bindings = [
binding("a_child", CHILD, Some(&CHILD_PLAN), &[1]),
bindings[1].clone(),
];
let registry = Registry::prepare(&directory.0, settings(), &bindings, scope)
.await
.unwrap();
registry.close().await;
})
.unwrap();
}
#[test]
fn sqlite_tenant_migration_batches_seed_dependencies_in_each_physical_database() {
let directory = Directory::new();
let mut config = settings();
config.0.get_mut("default").unwrap().tenant_directory = Some("tenants".into());
config.1 = Some(crate::database::TenantOptions {
database: "default".into(),
max_pools: 2,
idle_timeout_ms: 100,
});
let mut bindings = [
binding("a_child", CHILD, Some(&CHILD_PLAN), &[1]),
binding("z_parent", PARENT, Some(&PARENT_PLAN), &[]),
];
for binding in &mut bindings {
binding.storage = Storage::Tenant;
}
runtime()
.run(async |scope| {
let registry = Registry::prepare(&directory.0, config, &bindings, scope)
.await
.unwrap();
for tenant in [1, 2, 1] {
registry.migrate_tenant(scope, tenant).await.unwrap();
let mut context = Context::new(registry.clone());
context.select_tenant(scope, tenant).await.unwrap();
assert_eq!(
context
.on(0, scope)
.await
.unwrap()
.query(scope, "SELECT parent_id FROM a_child", vec![], 1, |row| row
.get::<_, i64>(0))
.await
.unwrap(),
[7]
);
}
registry.close().await;
})
.unwrap();
}
+273
View File
@@ -0,0 +1,273 @@
use super::*;
use dever_runtime::{database::scalar, value::decimal::Decimal};
#[test]
fn decimal_storage_covers_every_precision_and_scale() {
for precision in 1..=34 {
let limit = 10_i128.pow(precision);
for scale in 0..=precision {
for coefficient in [-limit + 1, -1, 0, 1, limit - 1] {
let value = Decimal::parse(&format!("{coefficient}E-{scale}")).unwrap();
let key = scalar::decimal::encode(value, precision, scale).unwrap();
assert_eq!(
key,
format!(
"{:0width$}",
coefficient + limit,
width = precision as usize + 1
)
);
assert_eq!(
scalar::decimal::decode(&key, precision, scale).unwrap(),
value
);
}
for coefficient in [-limit, limit] {
let value = Decimal::parse(&format!("{coefficient}E-{scale}")).unwrap();
assert!(scalar::decimal::encode(value, precision, scale).is_err());
}
}
}
}
#[test]
fn decimal_storage_rejects_non_digit_keys() {
for position in 0..6 {
for byte in 0..=127u8 {
if byte.is_ascii_digit() {
continue;
}
let mut key = b"100100".to_vec();
key[position] = byte;
assert!(scalar::decimal::decode(std::str::from_utf8(&key).unwrap(), 5, 2).is_err());
}
}
}
#[test]
fn decimal_keys_are_exact_unique_and_sort_like_numbers() {
let values = [
"-999.99", "-100", "-1.01", "-0.01", "0", "0.01", "1.01", "100", "999.99",
];
let encoded =
values.map(|text| scalar::decimal::encode(Decimal::parse(text).unwrap(), 5, 2).unwrap());
assert!(encoded.windows(2).all(|pair| pair[0] < pair[1]));
for (text, key) in values.iter().zip(&encoded) {
assert_eq!(
scalar::decimal::decode(key, 5, 2).unwrap(),
Decimal::parse(text).unwrap()
);
}
for text in ["1", "1.0", "1.00", "1.000", "1E0"] {
assert_eq!(
scalar::decimal::encode(Decimal::parse(text).unwrap(), 5, 2).unwrap(),
"100100"
);
}
for text in ["0", "-0", "0E-6176"] {
assert_eq!(
scalar::decimal::encode(Decimal::parse(text).unwrap(), 5, 2).unwrap(),
"100000"
);
}
for text in ["1000", "-1000", "0.001", "-0.001", "1E6144", "1E-6176"] {
assert_eq!(
scalar::decimal::encode(Decimal::parse(text).unwrap(), 5, 2)
.unwrap_err()
.kind,
Kind::InvalidData,
"{text}"
);
}
for (precision, scale, text) in [
(34, 0, "9999999999999999999999999999999999"),
(34, 34, "0.9999999999999999999999999999999999"),
(34, 4, "-999999999999999999999999999999.9999"),
] {
let value = Decimal::parse(text).unwrap();
let key = scalar::decimal::encode(value, precision, scale).unwrap();
assert_eq!(
scalar::decimal::decode(&key, precision, scale).unwrap(),
value
);
}
for key in [
"000000", "200000", "10001", "0100000", "+00001", "10000x", "100\0xx",
] {
assert_eq!(
scalar::decimal::decode(key, 5, 2).unwrap_err().kind,
Kind::InvalidData
);
}
let directory = Directory::new();
runtime()
.run(async |scope| {
let database = Database::open(&directory.0, options(), scope)
.await
.unwrap();
let mut executor = database.executor().await.unwrap();
executor
.initialize(
scope,
"CREATE TABLE numbers (amount TEXT UNIQUE, optional TEXT)",
)
.await
.unwrap();
for key in encoded.iter().rev() {
executor
.execute(
scope,
"INSERT INTO numbers(amount) VALUES (?1)",
vec![Parameter::Text(key.clone())],
)
.await
.unwrap();
}
let rows = executor
.query(
scope,
"SELECT amount, optional FROM numbers ORDER BY amount",
vec![],
20,
|row| {
assert_eq!(
row.get_ref(1)?.data_type(),
dever_runtime::database::DataType::Null
);
scalar::decimal(row, 0, 5, 2)
},
)
.await
.unwrap();
assert_eq!(rows, values.map(|text| Decimal::parse(text).unwrap()));
assert_eq!(
executor
.execute(
scope,
"INSERT INTO numbers(amount) VALUES (?1)",
vec![Parameter::Text(encoded[4].clone())]
)
.await
.unwrap_err()
.kind,
Kind::Constraint
);
})
.unwrap();
}
#[cfg(all(feature = "calendar", feature = "uuid"))]
#[test]
fn typed_scalars_preserve_storage_bytes_and_reject_corrupt_values() {
use dever_runtime::{
time::calendar::{Date, Time},
value::uuid::Uuid,
wire::json::Json,
};
let directory = Directory::new();
runtime()
.run(async |scope| {
let database = Database::open(&directory.0, options(), scope)
.await
.unwrap();
let mut executor = database.executor().await.unwrap();
executor
.initialize(
scope,
"CREATE TABLE values_test (uid BLOB, day INTEGER, time INTEGER, document TEXT)",
)
.await
.unwrap();
let uid = Uuid::parse("ffffffff-ffff-ffff-ffff-ffffffffffff").unwrap();
let document = "{\"value\":123456789012345678901234567890.0010,\"nested\":[true,null]}";
for (day, time) in [
("0000-01-01", "00:00:00"),
("1969-12-31", "23:59:59.999"),
("9999-12-31", "12:30:42.001"),
] {
let day = Date::parse(day).unwrap();
let time = Time::parse(time).unwrap();
executor
.execute(
scope,
"INSERT INTO values_test VALUES (?1,?2,?3,?4)",
vec![
Parameter::Blob(uid.as_bytes().to_vec()),
Parameter::Integer(day.millis()),
Parameter::Integer(time.millis()),
Parameter::Text(document.into()),
],
)
.await
.unwrap();
let actual = executor
.query(
scope,
"SELECT * FROM values_test ORDER BY rowid DESC LIMIT 1",
vec![],
1,
|row| {
assert_eq!(
row.get_ref(0)?.data_type(),
dever_runtime::database::DataType::Blob
);
assert_eq!(
row.get_ref(1)?.data_type(),
dever_runtime::database::DataType::Integer
);
Ok((
scalar::uuid(row, 0)?,
scalar::date(row, 1)?,
scalar::time(row, 2)?,
scalar::json(row, 3)?,
))
},
)
.await
.unwrap();
assert_eq!(actual, [(uid, day, time, Json::parse(document).unwrap())]);
assert_eq!(actual[0].3.as_str(), document);
}
executor
.execute(scope, "DELETE FROM values_test", vec![])
.await
.unwrap();
for (sql, kind) in [
("INSERT INTO values_test(uid) VALUES (zeroblob(15))", "Uuid"),
("INSERT INTO values_test(day) VALUES (1)", "Date"),
(
"INSERT INTO values_test(day) VALUES (-62167219200001)",
"Date",
),
("INSERT INTO values_test(time) VALUES (86400000)", "Time"),
("INSERT INTO values_test(time) VALUES (-1)", "Time"),
(
"INSERT INTO values_test(document) VALUES ('{\"a\":1,\"a\":2}')",
"Json",
),
(
"INSERT INTO values_test(document) VALUES ('invalid')",
"Json",
),
] {
executor.execute(scope, sql, vec![]).await.unwrap();
let failure = executor
.query(
scope,
"SELECT * FROM values_test ORDER BY rowid DESC LIMIT 1",
vec![],
1,
move |row| match kind {
"Uuid" => scalar::uuid(row, 0).map(|_| ()),
"Date" => scalar::date(row, 1).map(|_| ()),
"Time" => scalar::time(row, 2).map(|_| ()),
_ => scalar::json(row, 3).map(|_| ()),
},
)
.await
.unwrap_err();
assert_eq!(failure.kind, Kind::InvalidData, "{sql}");
}
})
.unwrap();
}
+163
View File
@@ -0,0 +1,163 @@
use super::*;
use dever_runtime::database::{Migration, Plan, Sql, Statement};
const BASE: &str = "CREATE TABLE \"account\" (id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL UNIQUE CHECK(_dever_length(name) <= 24), legacy TEXT, amount TEXT(5,2) NOT NULL);";
const NEXT: &str = "CREATE TABLE \"account\" (id INTEGER PRIMARY KEY AUTOINCREMENT, label TEXT NOT NULL UNIQUE CHECK(_dever_length(label) <= 8), amount TEXT(7,3) NOT NULL, enabled INTEGER NOT NULL DEFAULT 1 CHECK(enabled IN (0,1)));";
const BEFORE: Statement = Statement {
sql: Sql::same("UPDATE account SET name = ?1 WHERE name = ?2"),
parameters: || {
Ok(vec![
Parameter::Text("Editor".into()),
Parameter::Text("Legacy".into()),
])
},
};
const AFTER: Statement = Statement {
sql: Sql::same("UPDATE account SET label = ?1 WHERE label = ?2"),
parameters: || {
Ok(vec![
Parameter::Text("Final".into()),
Parameter::Text("Editor".into()),
])
},
};
const SEED: Statement = Statement {
sql: Sql::same(
"INSERT INTO account(label,amount) SELECT ?1,?2 WHERE NOT EXISTS(SELECT 1 FROM account WHERE label = ?1)",
),
parameters: || {
Ok(vec![
Parameter::Text("Seed".into()),
Parameter::Text("10001000".into()),
])
},
};
const STEP: Migration = Migration {
name: "normalize",
revision: "normalize-v1",
drops: &["legacy"],
before: &[BEFORE],
after: &[AFTER],
};
const INITIAL: Plan = Plan {
create: Sql::same(BASE),
indexes: Sql::same(""),
postgres_columns: &[],
renames: &[],
migrations: &[],
seeds: &[],
seed_revision: "empty",
};
const UPGRADE: Plan = Plan {
create: Sql::same(NEXT),
indexes: Sql::same(""),
postgres_columns: &[],
renames: &[("label", "name")],
migrations: &[STEP],
seeds: &[SEED],
seed_revision: "seed-v1",
};
#[test]
fn schema_upgrade_preserves_rows_decimal_foreign_keys_and_id_sequence() {
let directory = Directory::new();
runtime().run(async |scope| {
let database = Database::open(&directory.0,options(),scope).await.unwrap();
let mut executor = database.executor().await.unwrap();
executor.migrate(scope,"account",BASE,Some(&INITIAL)).await.unwrap();
executor.initialize(scope,"INSERT INTO account(id,name,legacy,amount) VALUES (1,'Legacy','old','101230'),(20,'Deleted','old','100000'); DELETE FROM account WHERE id=20; CREATE TABLE dependent(account_id INTEGER REFERENCES account(id) ON DELETE CASCADE); INSERT INTO dependent VALUES (1);").await.unwrap();
executor.migrate(scope,"account",NEXT,Some(&UPGRADE)).await.unwrap();
let rows = executor.query(scope,"SELECT id,label,amount,enabled FROM account ORDER BY id",vec![],20,|row|Ok((row.get::<_,i64>(0)?,row.get::<_,String>(1)?,dever_runtime::database::scalar::decimal(row,2,7,3)?.to_string(),row.get::<_,bool>(3)?))).await.unwrap();
assert_eq!(rows,[(1,"Final".into(),"12.3".into(),true),(21,"Seed".into(),"1".into(),true)]);
let child = executor.query(scope,"SELECT account_id FROM dependent",vec![],20,|row|row.get::<_,i64>(0)).await.unwrap();
assert_eq!(child,[1]);
assert!(executor.execute(scope,"INSERT INTO dependent VALUES (999)",vec![]).await.is_err());
executor.migrate(scope,"account",NEXT,Some(&UPGRADE)).await.unwrap();
assert_eq!(executor.query(scope,"SELECT COUNT(*) FROM account",vec![],1,|row|row.get::<_,i64>(0)).await.unwrap(),[2]);
let old = executor.migrate(scope,"account",BASE,Some(&INITIAL)).await.unwrap_err();
assert_eq!(old.kind,Kind::Migration);
const ALTERED: Plan = Plan { migrations:&[Migration { revision:"changed", ..STEP }], ..UPGRADE };
assert_eq!(executor.migrate(scope,"account",NEXT,Some(&ALTERED)).await.unwrap_err().kind,Kind::Migration);
}).unwrap();
}
#[test]
fn failed_after_step_or_narrowing_rolls_back_data_ddl_seed_and_history() {
let directory = Directory::new();
runtime().run(async |scope| {
let database = Database::open(&directory.0,options(),scope).await.unwrap();
let mut executor = database.executor().await.unwrap();
executor.migrate(scope,"account",BASE,Some(&INITIAL)).await.unwrap();
executor.initialize(scope,"INSERT INTO account(name,legacy,amount) VALUES ('Legacy','old','101230');").await.unwrap();
const FAIL: Plan = Plan { migrations:&[Migration { after:&[Statement { sql:Sql::same("UPDATE absent_table SET value = ?1"),parameters:||Ok(vec![Parameter::Integer(1)]) }], ..STEP }],..UPGRADE };
assert!(executor.migrate(scope,"account",NEXT,Some(&FAIL)).await.is_err());
let rows = executor.query(scope,"SELECT name,legacy,amount FROM account",vec![],20,|row|Ok((row.get::<_,String>(0)?,row.get::<_,String>(1)?,row.get::<_,String>(2)?))).await.unwrap();
assert_eq!(rows,[("Legacy".into(),"old".into(),"101230".into())]);
assert_eq!(executor.query(scope,"SELECT COUNT(*) FROM _dever_migration",vec![],1,|row|row.get::<_,i64>(0)).await.unwrap(),[0]);
assert_eq!(executor.query(scope,"SELECT definition FROM _dever_history WHERE name='account'",vec![],1,|row|row.get::<_,String>(0)).await.unwrap(),[BASE]);
executor.migrate(scope,"account",NEXT,Some(&UPGRADE)).await.unwrap();
const TIGHT: &str = "CREATE TABLE \"account\" (id INTEGER PRIMARY KEY AUTOINCREMENT, label TEXT NOT NULL UNIQUE CHECK(_dever_length(label) <= 4), amount TEXT(7,3) NOT NULL, enabled INTEGER NOT NULL DEFAULT 1 CHECK(enabled IN (0,1)));";
const TIGHT_PLAN: Plan = Plan {create:Sql::same(TIGHT),..UPGRADE};
assert_eq!(executor.migrate(scope,"account",TIGHT,Some(&TIGHT_PLAN)).await.unwrap_err().kind,Kind::Constraint);
executor.migrate(scope,"account",NEXT,Some(&UPGRADE)).await.unwrap();
assert_eq!(executor.query(scope,"SELECT label FROM account ORDER BY id",vec![],20,|row|row.get::<_,String>(0)).await.unwrap(),["Final","Seed"]);
}).unwrap();
}
#[test]
fn seed_conflict_rolls_back_prior_seed_rows_and_revision() {
let directory = Directory::new();
runtime().run(async |scope| {
let database = Database::open(&directory.0,options(),scope).await.unwrap();
let mut executor = database.executor().await.unwrap();
const SQL: &str = "CREATE TABLE \"account\" (id INTEGER PRIMARY KEY, name TEXT NOT NULL UNIQUE, email TEXT NOT NULL UNIQUE);";
const FIRST: Statement = Statement { sql:Sql::same("INSERT INTO account(name,email) SELECT ?1,?2 WHERE NOT EXISTS(SELECT 1 FROM account WHERE name=?1)"), parameters:||Ok(vec![Parameter::Text("first".into()),Parameter::Text("one".into())]) };
const START: Plan = Plan {create:Sql::same(SQL),indexes:Sql::same(""),postgres_columns:&[],renames:&[],migrations:&[],seeds:&[FIRST],seed_revision:"one"};
executor.migrate(scope,"account",SQL,Some(&START)).await.unwrap();
const MORE: Plan = Plan {seeds:&[FIRST,
Statement {sql:FIRST.sql,parameters:||Ok(vec![Parameter::Text("second".into()),Parameter::Text("two".into())])},
Statement {sql:FIRST.sql,parameters:||Ok(vec![Parameter::Text("third".into()),Parameter::Text("one".into())])}],seed_revision:"more",..START};
assert_eq!(executor.migrate(scope,"account",SQL,Some(&MORE)).await.unwrap_err().kind,Kind::Constraint);
assert_eq!(executor.query(scope,"SELECT name FROM account",vec![],20,|row|row.get::<_,String>(0)).await.unwrap(),["first"]);
assert_eq!(executor.query(scope,"SELECT revision FROM _dever_seed WHERE model='account'",vec![],1,|row|row.get::<_,String>(0)).await.unwrap(),["one"]);
}).unwrap();
}
#[test]
fn empty_rebuild_keeps_the_last_allocated_id() {
let directory = Directory::new();
runtime().run(async |scope| {
let database = Database::open(&directory.0,options(),scope).await.unwrap();
let mut executor = database.executor().await.unwrap();
executor.migrate(scope,"account",BASE,Some(&INITIAL)).await.unwrap();
executor.initialize(scope,"INSERT INTO account(id,name,amount) VALUES (20,'gone','100000'); DELETE FROM account;").await.unwrap();
const EMPTY: Plan = Plan { seeds:&[],seed_revision:"empty",..UPGRADE };
executor.migrate(scope,"account",NEXT,Some(&EMPTY)).await.unwrap();
executor.execute(scope,"INSERT INTO account(label,amount) VALUES ('new','10000000')",vec![]).await.unwrap();
assert_eq!(executor.query(scope,"SELECT id FROM account",vec![],1,|row|row.get::<_,i64>(0)).await.unwrap(),[21]);
}).unwrap();
}
#[test]
fn foreign_seed_failure_preserves_earlier_models_and_rolls_back_its_model() {
let directory = Directory::new();
runtime().run(async |scope| {
let database = Database::open(&directory.0,options(),scope).await.unwrap();
let mut executor = database.executor().await.unwrap();
const CHILD: &str = "CREATE TABLE \"child\" (id INTEGER PRIMARY KEY, parent_id INTEGER NOT NULL REFERENCES parent(id));";
const EMPTY: Plan = Plan {create:Sql::same(CHILD),indexes:Sql::same(""),postgres_columns:&[],renames:&[],migrations:&[],seeds:&[],seed_revision:"empty"};
// 没有记录时可以先建外键表;真正的 Seed 必须引用已存在的目标记录。
executor.migrate(scope,"child",CHILD,Some(&EMPTY)).await.unwrap();
const PARENT: &str = "CREATE TABLE \"parent\" (id INTEGER PRIMARY KEY, name TEXT NOT NULL);";
executor.schema(scope,"parent",PARENT).await.unwrap();
executor.execute(scope,"INSERT INTO parent VALUES (1,'preserved')",vec![]).await.unwrap();
const INVALID: Plan = Plan {seeds:&[Statement {sql:Sql::same("INSERT INTO child VALUES (1,999)"),parameters:||Ok(vec![])}],seed_revision:"invalid",..EMPTY};
assert!(executor.migrate(scope,"child",CHILD,Some(&INVALID)).await.is_err());
assert_eq!(executor.query(scope,"SELECT name FROM parent",vec![],1,|row|row.get::<_,String>(0)).await.unwrap(),["preserved"]);
assert_eq!(executor.query(scope,"SELECT COUNT(*) FROM child",vec![],1,|row|row.get::<_,i64>(0)).await.unwrap(),[0]);
assert_eq!(executor.query(scope,"SELECT revision FROM _dever_seed WHERE model='child'",vec![],1,|row|row.get::<_,String>(0)).await.unwrap(),["empty"]);
assert!(executor.execute(scope,"INSERT INTO child VALUES (1,999)",vec![]).await.is_err());
const VALID: Plan = Plan {seeds:&[Statement {sql:Sql::same("INSERT INTO child VALUES (1,1)"),parameters:||Ok(vec![])}],seed_revision:"valid",..EMPTY};
executor.migrate(scope,"child",CHILD,Some(&VALID)).await.unwrap();
}).unwrap();
}
+166
View File
@@ -0,0 +1,166 @@
use super::*;
use crate::{
adapter::Document,
database::{Binding, Settings, Storage},
task::{Limits, Runtime},
};
use std::{
path::PathBuf,
sync::atomic::{AtomicU64, AtomicUsize, Ordering},
};
static NEXT: AtomicU64 = AtomicU64::new(0);
struct Directory(PathBuf);
impl Directory {
fn new() -> Self {
let path = std::env::temp_dir().join(format!(
"dever-row-stream-{}-{}",
std::process::id(),
NEXT.fetch_add(1, Ordering::Relaxed)
));
std::fs::create_dir(&path).unwrap();
Self(path)
}
}
impl Drop for Directory {
fn drop(&mut self) {
std::fs::remove_dir_all(&self.0).unwrap();
}
}
async fn registry(directory: &Directory, scope: &Scope<'_>) -> Arc<Registry> {
let settings = Settings::load(&Document::parse(r#"{"database":{"default":{"type":"sqlite","path":"case.db","max_connections":1,"max_page_size":2,"wait_timeout_ms":1000}}}"#.into()).unwrap()).unwrap();
Registry::prepare(
&directory.0,
settings,
&[Binding {
component: "demo",
database: None,
table: "rows",
schema: Sql::same("CREATE TABLE rows (id INTEGER PRIMARY KEY)"),
plan: None,
references: &[],
storage: Storage::Global,
}],
scope,
)
.await
.unwrap()
}
const ROWS: Sql = Sql::same(
"WITH RECURSIVE items(value) AS (SELECT 1 UNION ALL SELECT value+1 FROM items WHERE value < 10000) SELECT value FROM items",
);
#[test]
fn bounded_stream_close_drop_and_decode_error_release_single_connection() {
let directory = Directory::new();
let runtime = Runtime::new(Limits {
threads: 2,
workers: 1,
tasks: 16,
})
.unwrap();
runtime
.run(async |scope| {
let registry = registry(&directory, scope).await;
let mut context = Context::new(registry.clone());
for mode in 0..3 {
let produced = Arc::new(AtomicUsize::new(0));
let counted = produced.clone();
let rows = context
.stream(0, scope, ROWS, vec![], move |row| {
counted.fetch_add(1, Ordering::Relaxed);
row.get::<_, i64>(0)
})
.await
.unwrap();
assert_eq!(rows.next().await.unwrap(), Some(1));
// 消费者暂停时最多只多解码通道一行与待发送一行。
tokio::time::sleep(std::time::Duration::from_millis(10)).await;
assert!(produced.load(Ordering::Relaxed) <= 3);
// SQLite 游标在阻塞线程中借用 statement;背压期间仍持有 worker。
assert!(
tokio::time::timeout(
std::time::Duration::from_millis(10),
scope.blocking(|| 42),
)
.await
.is_err()
);
match mode {
0 => {
rows.close();
assert_eq!(rows.next().await.unwrap(), None);
}
1 => drop(rows),
_ => {
let alias = rows.clone();
alias.close();
assert_eq!(rows.next().await.unwrap(), None);
}
}
assert_eq!(scope.blocking(|| 42).await.unwrap(), 42);
let found = context
.on(0, scope)
.await
.unwrap()
.query(scope, "SELECT 42", vec![], 1, |row| row.get::<_, i64>(0))
.await
.unwrap();
assert_eq!(found, [42]);
}
let rows = context
.stream(0, scope, Sql::same("SELECT 'bad'"), vec![], |row| {
row.get::<_, i64>(0)
})
.await
.unwrap();
let StreamError::Database(error) = rows.next().await.unwrap_err() else {
panic!("database error must retain its category")
};
assert_eq!(error.kind, Kind::InvalidData);
assert_eq!(rows.next().await.unwrap(), None);
assert!(context.on(0, scope).await.is_ok());
drop(context);
registry.close().await;
})
.unwrap();
}
#[test]
fn cancelled_stream_owner_is_drained_and_pool_remains_usable() {
let directory = Directory::new();
let runtime = Runtime::new(Limits {
threads: 2,
workers: 2,
tasks: 16,
})
.unwrap();
runtime
.run(async |scope| {
let registry = registry(&directory, scope).await;
let (ready, started) = tokio::sync::oneshot::channel();
let owned = registry.clone();
let task = scope
.spawn(move |child| async move {
let mut context = Context::new(owned);
let rows = context
.stream(0, &child, ROWS, vec![], |row| row.get::<_, i64>(0))
.await
.unwrap();
assert_eq!(rows.next().await.unwrap(), Some(1));
ready.send(()).unwrap();
std::future::pending::<()>().await;
drop(rows);
})
.unwrap();
started.await.unwrap();
drop(task);
let mut context = Context::new(registry.clone());
assert!(context.on(0, scope).await.is_ok());
drop(context);
registry.close().await;
})
.unwrap();
}
@@ -0,0 +1,71 @@
use super::*;
use crate::database::tenant::{Manager, Pool};
use std::{
sync::Arc,
time::{Duration, Instant},
};
#[test]
fn reaping_deadline_keeps_pending_and_active_pools_visible() {
let directory = Directory::new();
runtime()
.run(async |scope| {
let mut settings = settings(true, 2);
settings.1.as_mut().unwrap().idle_timeout_ms = 86_400_000;
let database = crate::database::Database::open(
&directory.0,
settings.0.remove("default").unwrap(),
scope,
)
.await
.unwrap();
let manager = Manager::new(
&directory.0,
settings.1.unwrap(),
std::slice::from_ref(&database),
0,
&[],
&[],
&["default"],
&[],
)
.unwrap();
let mut cache = manager.cache.lock().await;
let first_state = manager.reserve(&mut cache, (0, 1)).unwrap();
let active = Arc::new(Pool {
database: database.clone(),
});
{
let mut ready = first_state.try_lock().unwrap();
ready.pool = Some(active.clone());
ready.ready = true;
}
let first = Arc::downgrade(&first_state);
drop(first_state);
manager.evict(&mut cache);
assert_eq!(first.strong_count(), 1);
let pending_state = manager.reserve(&mut cache, (0, 2)).unwrap();
let pending = Arc::downgrade(&pending_state);
drop(pending_state);
manager.evict(&mut cache);
assert_eq!(pending.strong_count(), 0);
assert_eq!(first.strong_count(), 1);
cache.entries.get_mut(&(0, 1)).unwrap().touched =
Instant::now() - Duration::from_secs(172800);
cache.check_at = Instant::now();
manager.evict(&mut cache);
assert_eq!(first.strong_count(), 1);
drop(active);
manager.evict(&mut cache);
assert_eq!(first.strong_count(), 1);
assert!(cache.check_at > Instant::now());
cache.check_at = Instant::now();
manager.evict(&mut cache);
assert_eq!(first.strong_count(), 0);
drop(cache);
manager.close().await;
})
.unwrap();
}
@@ -0,0 +1,80 @@
use super::*;
#[test]
fn tenant_configuration_rejects_unknown_controls_and_escaping_locations() {
for tenant in [
serde_json::json!({"database":"missing"}),
serde_json::json!({"database":"default","mode":"table"}),
serde_json::json!({"database":"default","max_pools":0}),
serde_json::json!({"database":"default","idle_timeout_ms":0}),
serde_json::Value::Null,
] {
let document = serde_json::json!({"database":{"default":{"type":"sqlite","path":"app.db","tenant_directory":"tenants"}}, "tenant":tenant});
assert!(Settings::load(&Document::parse(document.to_string()).unwrap()).is_err());
}
for path in ["", "../tenants", "/tmp/tenants", "data/../tenants"] {
let mut config = settings(true, 2);
config.0.get_mut("default").unwrap().tenant_directory = Some(path.to_owned());
assert!(config.0["default"].validate().is_err());
}
}
#[test]
fn overlapping_tenant_namespaces_and_platform_files_are_rejected() {
let directory = Directory::new();
runtime()
.run(async |scope| {
let mut config = settings(true, 2);
let mut secondary = config.0["default"].clone();
secondary.path = "secondary.db".to_owned();
secondary.tenant_directory = Some("tenants//".to_owned());
config.0.insert("secondary".to_owned(), secondary);
let mut models = bindings();
models[0].database = Some("secondary");
assert_eq!(
Registry::prepare(&directory.0, config, &models, scope)
.await
.err()
.unwrap()
.kind,
Kind::Connection
);
let mut config = settings(true, 2);
config.0.get_mut("default").unwrap().path = "tenants/1.db".to_owned();
assert_eq!(
Registry::prepare(&directory.0, config, &bindings(), scope)
.await
.err()
.unwrap()
.kind,
Kind::Connection
);
})
.unwrap();
}
#[test]
fn control_database_is_selected_explicitly_and_multiple_physical_sources_migrate() {
let directory = Directory::new();
runtime().run(async |scope| {
let mut config = settings(true, 1);
let mut security = config.0["default"].clone();
security.path = "security.db".to_owned();
security.tenant_directory = Some("security-tenants".to_owned());
config.0.insert("security".to_owned(), security);
config.1.as_mut().unwrap().database = "security".to_owned();
let registry = Registry::prepare(&directory.0, config, &bindings(), scope).await.unwrap();
registry.migrate_tenant(scope, 1).await.unwrap();
let mut context = Context::new(registry.clone());
context.select_tenant(scope, 1).await.unwrap();
let marker = context.control(scope).await.unwrap().query(scope, "SELECT COUNT(*) FROM _dever_tenant_storage WHERE ready=1 AND length(fingerprint)=64", vec![], 1, |row| row.get::<_, i64>(0)).await.unwrap();
assert_eq!(marker, [2]);
assert!(registry.check_transactions(&[&[0,2]]).is_err());
assert!(context.select_tenant(scope, 2).await.is_err());
assert_eq!(context.tenant_id(), None);
assert!(context.on(0, scope).await.is_err());
registry.close().await;
assert!(directory.0.join("security-tenants/1.db").exists());
assert!(directory.0.join("tenants/1.db").exists());
}).unwrap();
}
@@ -0,0 +1,332 @@
use super::*;
#[test]
fn explicit_migration_routes_models_roles_and_control_to_distinct_physical_storage() {
let directory = Directory::new();
runtime()
.run(async |scope| {
let registry = Registry::prepare(&directory.0, settings(true, 4), &bindings(), scope)
.await
.unwrap();
assert!(registry.tenant_enabled());
assert!(registry.is_tenant(0));
let mut missing = Context::new(registry.clone());
assert_eq!(
missing.on(0, scope).await.err().unwrap().kind,
Kind::InvalidData
);
assert_eq!(
missing.select_tenant(scope, 1).await.unwrap_err().kind,
Kind::Migration
);
assert!(!directory.0.join("tenants").exists());
let rows = missing
.control(scope)
.await
.unwrap()
.query(
scope,
"SELECT COUNT(*) FROM sqlite_schema WHERE name='items'",
vec![],
1,
|row| row.get::<_, i64>(0),
)
.await
.unwrap();
assert_eq!(rows, [0]);
registry.migrate_tenant(scope, 1).await.unwrap();
registry.migrate_tenant(scope, 2).await.unwrap();
registry.migrate_tenant(scope, 1).await.unwrap();
assert_eq!(
registry.queue_tenants(0, scope).await.unwrap(),
[Some(1), Some(2)]
);
assert_eq!(registry.queue_tenants(1, scope).await.unwrap(), [None]);
for id in [1, 2] {
let mut context = Context::new(registry.clone());
context.select_tenant(scope, id).await.unwrap();
assert_eq!(context.tenant_id(), Some(id));
assert_eq!(context.detached().tenant_id(), Some(id));
let owner = context.enter();
context
.on(0, scope)
.await
.unwrap()
.execute(
scope,
"INSERT INTO items(id,value) VALUES(1,$1)",
vec![crate::database::Parameter::Text(format!("tenant {id}"))],
)
.await
.unwrap();
context
.on(2, scope)
.await
.unwrap()
.execute(scope, "INSERT INTO roles(id) VALUES(1)", vec![])
.await
.unwrap();
context.finish(scope, owner, true).await.unwrap();
let rows = context
.on(0, scope)
.await
.unwrap()
.query(scope, "SELECT value FROM items", vec![], 1, |row| {
row.get::<_, String>(0)
})
.await
.unwrap();
assert_eq!(rows, [format!("tenant {id}")]);
let count = context
.control(scope)
.await
.unwrap()
.query(scope, "SELECT COUNT(*) FROM roles", vec![], 1, |row| {
row.get::<_, i64>(0)
})
.await
.unwrap();
assert_eq!(count, [0]);
}
registry.close().await;
assert!(directory.0.join("tenants/1.db").is_file());
assert!(directory.0.join("tenants/2.db").is_file());
})
.unwrap();
}
#[test]
fn component_switches_are_immediate_validated_and_do_not_hide_ready_queues() {
let directory = Directory::new();
runtime()
.run(async |scope| {
let registry = Registry::prepare(&directory.0, settings(true, 2), &bindings(), scope)
.await
.unwrap();
registry.migrate_tenant(scope, 1).await.unwrap();
let mut context = Context::new(registry.clone());
context.select_tenant(scope, 1).await.unwrap();
assert!(context.components_enabled(scope, &["shop"]).await.unwrap());
registry
.set_component(scope, 1, "shop", false)
.await
.unwrap();
assert!(!context.components_enabled(scope, &["shop"]).await.unwrap());
assert!(context.require_components(scope, &["shop"]).await.is_err());
assert!(
context
.components_enabled(scope, &["platform"])
.await
.unwrap()
);
assert_eq!(registry.queue_tenants(0, scope).await.unwrap(), [Some(1)]);
for invalid in ["platform", "unknown", "dever"] {
assert_eq!(
registry
.set_component(scope, 1, invalid, false)
.await
.unwrap_err()
.kind,
Kind::InvalidData
);
}
registry
.set_component(scope, 1, "shop", true)
.await
.unwrap();
assert!(context.components_enabled(scope, &["shop"]).await.unwrap());
registry.close().await;
})
.unwrap();
}
#[test]
fn disabled_component_listing_paginates_and_observes_current_tenant_state() {
let directory = Directory::new();
runtime()
.run(async |scope| {
let mut models = bindings().to_vec();
for component in ["billing", "catalog"] {
models.push(crate::database::Binding {
component,
table: component,
schema: if component == "billing" {
Sql::same("CREATE TABLE billing(id BIGINT PRIMARY KEY)")
} else {
Sql::same("CREATE TABLE catalog(id BIGINT PRIMARY KEY)")
},
..bindings()[0].clone()
});
}
let registry = Registry::prepare(&directory.0, settings(true, 2), &models, scope)
.await
.unwrap();
registry.migrate_tenant(scope, 1).await.unwrap();
registry.migrate_tenant(scope, 2).await.unwrap();
let components = ["billing", "catalog", "shop", "platform", "unknown"]
.into_iter()
.collect();
let mut context = Context::new(registry.clone());
assert!(
context
.disabled_components(scope, &components)
.await
.is_err()
);
context.select_tenant(scope, 1).await.unwrap();
for component in ["billing", "catalog", "shop"] {
registry
.set_component(scope, 1, component, false)
.await
.unwrap();
}
assert_eq!(
context
.disabled_components(scope, &components)
.await
.unwrap(),
["billing", "catalog", "shop"]
.map(String::from)
.into_iter()
.collect()
);
registry
.set_component(scope, 1, "catalog", true)
.await
.unwrap();
assert_eq!(
context
.disabled_components(scope, &components)
.await
.unwrap(),
["billing", "shop"].map(String::from).into_iter().collect()
);
context.select_tenant(scope, 2).await.unwrap();
assert!(
context
.disabled_components(scope, &components)
.await
.unwrap()
.is_empty()
);
registry.close().await;
})
.unwrap();
}
#[test]
fn transactions_and_foreign_keys_cannot_cross_tenant_boundaries() {
let directory = Directory::new();
runtime()
.run(async |scope| {
let registry = Registry::prepare(&directory.0, settings(true, 2), &bindings(), scope)
.await
.unwrap();
assert!(registry.check_transactions(&[&[0, 1]]).is_err());
registry.check_transactions(&[&[0, 2]]).unwrap();
registry.check_transactions(&[&[1, 2]]).unwrap();
assert!(registry.check_transactions(&[&[2, 0, 1]]).is_err());
registry.migrate_tenant(scope, 1).await.unwrap();
let mut context = Context::new(registry.clone());
context.select_tenant(scope, 1).await.unwrap();
context
.on(1, scope)
.await
.unwrap()
.query(scope, "SELECT COUNT(*) FROM accounts", vec![], 1, |row| {
row.get::<_, i64>(0)
})
.await
.unwrap();
let owner = context.enter();
context
.on(0, scope)
.await
.unwrap()
.execute(
scope,
"INSERT INTO items(id,value) VALUES(1,'rollback')",
vec![],
)
.await
.unwrap();
assert!(context.on(1, scope).await.is_err());
assert!(context.select_tenant(scope, 2).await.is_err());
context.finish(scope, owner, false).await.unwrap();
assert_eq!(
context
.on(0, scope)
.await
.unwrap()
.query(scope, "SELECT COUNT(*) FROM items", vec![], 1, |row| row
.get::<_, i64>(0))
.await
.unwrap(),
[0]
);
let owner = context.enter();
context
.on(1, scope)
.await
.unwrap()
.execute(scope, "INSERT INTO accounts(id) VALUES(1)", vec![])
.await
.unwrap();
assert!(context.on(0, scope).await.is_err());
context.finish(scope, owner, true).await.unwrap();
assert_eq!(
context
.on(1, scope)
.await
.unwrap()
.query(scope, "SELECT COUNT(*) FROM accounts", vec![], 1, |row| row
.get::<_, i64>(0))
.await
.unwrap(),
[1]
);
drop(context);
registry.close().await;
let mut invalid = bindings();
invalid[0].references = &[1];
assert_eq!(
Registry::prepare(&directory.0, settings(true, 2), &invalid, scope)
.await
.err()
.unwrap()
.kind,
Kind::Connection
);
})
.unwrap();
}
#[test]
fn without_tenant_configuration_preserves_ordinary_database_routing() {
let directory = Directory::new();
runtime()
.run(async |scope| {
let registry = Registry::prepare(&directory.0, settings(false, 2), &bindings(), scope)
.await
.unwrap();
assert!(!registry.tenant_enabled());
registry.check_transactions(&[&[0, 1, 2]]).unwrap();
let mut context = Context::new(registry.clone());
assert!(context.select_tenant(scope, 1).await.is_err());
context
.on(0, scope)
.await
.unwrap()
.execute(
scope,
"INSERT INTO items(id,value) VALUES(1,'platform')",
vec![],
)
.await
.unwrap();
assert!(context.require_components(scope, &["shop"]).await.is_ok());
drop(context);
registry.close().await;
})
.unwrap();
}
@@ -0,0 +1,104 @@
use super::*;
#[test]
fn bounded_cache_never_evicts_an_active_tenant_pool() {
let directory = Directory::new();
runtime()
.run(async |scope| {
let registry = Registry::prepare(&directory.0, settings(true, 1), &bindings(), scope)
.await
.unwrap();
registry.migrate_tenant(scope, 1).await.unwrap();
registry.migrate_tenant(scope, 2).await.unwrap();
let mut first = Context::new(registry.clone());
first.select_tenant(scope, 1).await.unwrap();
first.on(0, scope).await.unwrap();
let mut second = Context::new(registry.clone());
assert_eq!(
second.select_tenant(scope, 2).await.unwrap_err().kind,
Kind::PoolExhausted
);
assert_eq!(
registry.migrate_tenant(scope, 1).await.unwrap_err().kind,
Kind::Pool
);
drop(first);
second.select_tenant(scope, 2).await.unwrap();
second.on(0, scope).await.unwrap();
drop(second);
registry.close().await;
})
.unwrap();
}
#[test]
fn stale_or_missing_markers_and_files_never_fall_back_or_recreate_storage() {
let directory = Directory::new();
runtime()
.run(async |scope| {
let registry = Registry::prepare(&directory.0, settings(true, 1), &bindings(), scope)
.await
.unwrap();
registry.migrate_tenant(scope, 1).await.unwrap();
let mut context = Context::new(registry.clone());
context.select_tenant(scope, 1).await.unwrap();
context
.on(0, scope)
.await
.unwrap()
.execute(
scope,
"UPDATE _dever_tenant_ready SET fingerprint='wrong'",
vec![],
)
.await
.unwrap();
drop(context);
let mut next = Context::new(registry.clone());
assert_eq!(
next.select_tenant(scope, 1).await.unwrap_err().kind,
Kind::Migration
);
registry.migrate_tenant(scope, 1).await.unwrap();
next.control(scope)
.await
.unwrap()
.execute(scope, "UPDATE _dever_tenant_storage SET ready=0", vec![])
.await
.unwrap();
assert!(next.select_tenant(scope, 1).await.is_err());
assert!(registry.queue_tenants(0, scope).await.unwrap().is_empty());
registry.migrate_tenant(scope, 1).await.unwrap();
std::fs::remove_file(directory.0.join("tenants/1.db")).unwrap();
assert!(next.select_tenant(scope, 1).await.is_err());
assert!(!directory.0.join("tenants/1.db").exists());
for id in [0, -1] {
assert_eq!(
registry.migrate_tenant(scope, id).await.unwrap_err().kind,
Kind::InvalidData
);
}
registry.close().await;
})
.unwrap();
}
#[test]
fn failed_schema_migration_stays_unready_and_never_becomes_a_cached_success() {
let directory = Directory::new();
runtime().run(async |scope| {
let registry = Registry::prepare(&directory.0, settings(true, 2), &bindings(), scope).await.unwrap();
registry.migrate_tenant(scope, 1).await.unwrap();
registry.close().await;
drop(registry);
let mut changed = bindings();
changed[0].schema = Sql::same("CREATE TABLE items(id BIGINT PRIMARY KEY, value TEXT NOT NULL, changed BIGINT NOT NULL)");
let registry = Registry::prepare(&directory.0, settings(true, 2), &changed, scope).await.unwrap();
let mut context = Context::new(registry.clone());
assert!(context.select_tenant(scope, 1).await.is_err());
assert_eq!(registry.migrate_tenant(scope, 1).await.unwrap_err().kind, Kind::Migration);
assert!(context.select_tenant(scope, 1).await.is_err());
assert!(registry.queue_tenants(0, scope).await.unwrap().is_empty());
registry.close().await;
}).unwrap();
}
+95
View File
@@ -0,0 +1,95 @@
use crate::{
adapter::Document,
database::{Binding, Context, Kind, Registry, Settings, Sql, Storage},
task::{Limits, Runtime},
};
use std::{
path::PathBuf,
sync::atomic::{AtomicU64, Ordering},
};
mod cache;
mod configuration;
mod isolation;
mod lifecycle;
mod performance;
#[cfg(feature = "postgres")]
mod postgres;
static NEXT: AtomicU64 = AtomicU64::new(0);
struct Directory(PathBuf);
impl Directory {
fn new() -> Self {
let directory = Self(std::env::temp_dir().join(format!(
"dever-tenant-{}-{}",
std::process::id(),
NEXT.fetch_add(1, Ordering::Relaxed)
)));
std::fs::create_dir(&directory.0).unwrap();
directory
}
}
impl Drop for Directory {
fn drop(&mut self) {
std::fs::remove_dir_all(&self.0).unwrap();
}
}
fn runtime() -> Runtime {
Runtime::new(Limits {
threads: 2,
workers: 2,
tasks: 32,
})
.unwrap()
}
fn settings(tenant: bool, pools: usize) -> Settings {
let mut document = serde_json::json!({"database":{"default":{"type":"sqlite","path":"platform.db","tenant_directory":"tenants","max_connections":2,"max_page_size":1,"wait_timeout_ms":100}}});
if tenant {
document["tenant"] =
serde_json::json!({"database":"default","max_pools":pools,"idle_timeout_ms":10});
}
Settings::load(&Document::parse(document.to_string()).unwrap()).unwrap()
}
fn bindings() -> [Binding; 4] {
[
Binding {
storage: Storage::Tenant,
component: "shop",
database: None,
table: "items",
schema: Sql::same("CREATE TABLE items(id BIGINT PRIMARY KEY, value TEXT NOT NULL)"),
plan: None,
references: &[],
},
Binding {
storage: Storage::Global,
component: "platform",
database: None,
table: "accounts",
schema: Sql::same("CREATE TABLE accounts(id BIGINT PRIMARY KEY)"),
plan: None,
references: &[],
},
Binding {
storage: Storage::Authorization,
component: "dever",
database: None,
table: "roles",
schema: Sql::same("CREATE TABLE roles(id BIGINT PRIMARY KEY)"),
plan: None,
references: &[],
},
Binding {
storage: Storage::Control,
component: "dever",
database: None,
table: "permissions",
schema: Sql::same("CREATE TABLE permissions(id BIGINT PRIMARY KEY)"),
plan: None,
references: &[],
},
]
}
@@ -0,0 +1,67 @@
use super::*;
use crate::database::tenant::{Manager, Pool};
use std::{hint::black_box, sync::Arc, time::Instant};
#[test]
#[ignore = "explicit release performance probe"]
fn cached_pool_reaping_cost() {
println!("\ncase,sample,iterations,elapsed_ns,checksum");
for count in [1, 32, 256] {
for busy in [false, true] {
let directory = Directory::new();
runtime()
.run(async |scope| {
let mut settings = settings(true, count);
settings.1.as_mut().unwrap().idle_timeout_ms = 86_400_000;
let database = crate::database::Database::open(
&directory.0,
settings.0.remove("default").unwrap(),
scope,
)
.await
.unwrap();
let manager = Manager::new(
&directory.0,
settings.1.unwrap(),
std::slice::from_ref(&database),
0,
&[],
&[],
&["default"],
&[],
)
.unwrap();
let mut cache = manager.cache.lock().await;
let mut states = Vec::new();
for tenant in 0..count {
let state = manager.reserve(&mut cache, (0, tenant as i64 + 1)).unwrap();
let mut ready = state.try_lock().unwrap();
ready.pool = Some(Arc::new(Pool {
database: database.clone(),
}));
ready.ready = true;
states.push(Arc::downgrade(&state));
}
// 只测回收检查,不包含建库、SQL 或池获取;Weak 不改变使用中状态。
let active = busy.then(|| states[0].upgrade().unwrap());
for _ in 0..100 {
manager.evict(&mut cache);
}
for sample in 0..15 {
let started = Instant::now();
for _ in 0..10_000 {
black_box(&manager).evict(black_box(&mut cache));
}
let elapsed = started.elapsed().as_nanos();
assert_eq!(states[0].strong_count(), if busy { 2 } else { 1 });
println!("tenant_cache_{count}_{busy},{sample},10000,{elapsed},{count}");
}
drop(active);
drop(cache);
manager.close().await;
assert!(states.iter().all(|state| state.strong_count() == 0));
})
.unwrap();
}
}
}
@@ -0,0 +1,100 @@
use super::*;
use crate::database::{Database, Lease, Options, Parameter};
use futures_util::FutureExt;
use std::panic::AssertUnwindSafe;
#[test]
#[ignore = "requires explicit isolated postgres_tenant_test template and CREATEDB"]
fn physical_databases_are_explicit_isolated_idempotent_and_fail_closed() {
let directory = Directory::new();
runtime().run(async |scope| {
let document = Document::load(&PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../../config/setting.json")).unwrap();
let settings: serde_json::Value = document.decode().unwrap();
let admin: Options = serde_json::from_value(settings["database"]["postgres_test"].clone()).unwrap();
let mut options: Options = serde_json::from_value(settings["database"]["postgres_tenant_test"].clone()).unwrap();
let template = options.url.as_ref().unwrap().parse::<tokio_postgres::Config>().unwrap();
assert!(template.get_dbname().unwrap().contains("{case}"));
assert_eq!(options.url.as_ref().unwrap().matches("{case}").count(), 1);
let token = crate::database::scalar::generated_uuid().unwrap().to_string().replace('-', "");
options.url = Some(options.url.unwrap().replace("{case}", &token));
let control_name = options.url.as_ref().unwrap().parse::<tokio_postgres::Config>().unwrap().get_dbname().unwrap().to_owned();
assert!((1..=63).contains(&control_name.len()));
assert!(control_name.bytes().all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'_'));
let prefix = format!("dv_{token}");
options.tenant_database_prefix = Some(prefix.clone());
options.max_connections = 2;
options.max_page_size = 1;
let tenant_options = options.clone();
let admin = Database::open(&directory.0, admin, scope).await.unwrap();
let mut admin_executor = admin.executor().await.unwrap();
let Lease::Postgres(lease) = &mut admin_executor.lease else { unreachable!() };
lease.call(async |client| client.batch_execute(&format!("CREATE DATABASE \"{control_name}\"")).await.map_err(Into::into)).await.unwrap();
let mut database = std::collections::BTreeMap::new();
database.insert("default".to_owned(), options);
let config = Settings(database, Some(crate::database::TenantOptions { database: "default".to_owned(), max_pools: 2, idle_timeout_ms: 100 }));
let mut registry = None;
let result = AssertUnwindSafe(async {
registry = Some(Registry::prepare(&directory.0, config, &bindings(), scope).await.unwrap());
let registry = registry.as_ref().unwrap();
let mut absent = Context::new(registry.clone());
assert_eq!(absent.select_tenant(scope, 1).await.unwrap_err().kind, Kind::Migration);
assert_eq!(admin_executor.query(scope, Sql::same("SELECT COUNT(*) FROM pg_database WHERE datname=$1"), vec![Parameter::Text(format!("{prefix}_1"))], 1, |row| row.get::<_, i64>(0)).await.unwrap(), [0]);
for id in [1, 2, 1] { registry.migrate_tenant(scope, id).await.unwrap(); }
assert_eq!(registry.queue_tenants(0, scope).await.unwrap(), [Some(1), Some(2)]);
for id in [1, 2] {
let mut context = Context::new(registry.clone());
context.select_tenant(scope, id).await.unwrap();
let owner = context.enter();
context.on(0, scope).await.unwrap().execute(scope, Sql::same("INSERT INTO items(id,value) VALUES(1,$1)"), vec![Parameter::Text(format!("tenant {id}"))]).await.unwrap();
context.on(2, scope).await.unwrap().execute(scope, "INSERT INTO roles(id) VALUES(1)", vec![]).await.unwrap();
assert!(context.on(1, scope).await.is_err());
context.finish(scope, owner, true).await.unwrap();
assert_eq!(context.on(0, scope).await.unwrap().query(scope, "SELECT value FROM items", vec![], 1, |row| row.get::<_, String>(0)).await.unwrap(), [format!("tenant {id}")]);
assert_eq!(context.control(scope).await.unwrap().query(scope, "SELECT COUNT(*) FROM roles", vec![], 1, |row| row.get::<_, i64>(0)).await.unwrap(), [0]);
}
// 冷租户的标记被真实表锁阻塞时,另一租户仍能进入;取消后可重新准备。
tokio::time::sleep(std::time::Duration::from_millis(110)).await;
Context::new(registry.clone()).select_tenant(scope, 1).await.unwrap();
let held = Database::open_existing(&directory.0, tenant_options.clone(), Some(&format!("{prefix}_2")), scope).await.unwrap();
let mut held_executor = held.executor().await.unwrap();
held_executor.begin(scope).await.unwrap();
held_executor.initialize(scope, "LOCK TABLE _dever_tenant_ready IN ACCESS EXCLUSIVE MODE").await.unwrap();
let copy = registry.clone();
let blocked = scope.spawn(move |child| async move { Context::new(copy).select_tenant(&child, 2).await }).unwrap();
let started = tokio::time::Instant::now();
loop {
let waiting = admin_executor.query(scope, Sql::same("SELECT COUNT(*) FROM pg_stat_activity WHERE datname=$1 AND wait_event_type='Lock'"), vec![Parameter::Text(format!("{prefix}_2"))], 1, |row| row.get::<_, i64>(0)).await.unwrap();
if waiting[0] > 0 { break; }
assert!(started.elapsed() < std::time::Duration::from_secs(2));
tokio::time::sleep(std::time::Duration::from_millis(5)).await;
}
tokio::time::timeout(std::time::Duration::from_millis(500), Context::new(registry.clone()).select_tenant(scope, 1)).await.unwrap().unwrap();
assert!(blocked.stop().await.unwrap().is_none());
held_executor.finish(scope, true, false).await.unwrap();
drop(held_executor);
held.close();
Context::new(registry.clone()).select_tenant(scope, 2).await.unwrap();
registry.set_component(scope, 1, "shop", false).await.unwrap();
let mut context = Context::new(registry.clone());
context.select_tenant(scope, 1).await.unwrap();
assert!(!context.components_enabled(scope, &["shop"]).await.unwrap());
assert_eq!(registry.queue_tenants(0, scope).await.unwrap(), [Some(1), Some(2)]);
context.on(0, scope).await.unwrap().execute(scope, "UPDATE _dever_tenant_ready SET ready=0", vec![]).await.unwrap();
drop(context);
assert_eq!(Context::new(registry.clone()).select_tenant(scope, 1).await.unwrap_err().kind, Kind::Migration);
}).catch_unwind().await;
if let Some(registry) = registry.take() {
registry.close().await;
drop(registry);
}
// Pool::close 释放驱动句柄后,让同一 runtime 的连接任务完成退出。
tokio::time::sleep(std::time::Duration::from_millis(30)).await;
let Lease::Postgres(lease) = &mut admin_executor.lease else { unreachable!() };
for name in [format!("{prefix}_1"), format!("{prefix}_2"), control_name] {
lease.call(async |client| client.batch_execute(&format!("DROP DATABASE IF EXISTS \"{name}\"")).await.map_err(Into::into)).await.unwrap();
}
drop(admin_executor);
admin.close();
if let Err(panic) = result { std::panic::resume_unwind(panic); }
}).unwrap();
}
+68
View File
@@ -0,0 +1,68 @@
use super::{Header, headers};
use crate::http::Limits;
use std::{hint::black_box, time::Instant};
#[test]
fn request_and_response_preserve_shared_body_storage() {
let shared = bytes::Bytes::from(vec![0x80; 65536]);
let request = hyper::Request::builder()
.method("POST")
.uri("/echo")
.header("host", "localhost")
.body(shared.clone())
.unwrap();
let request = super::request_value(request);
assert_eq!(request.body.as_ptr(), shared.as_ptr());
let response = super::response_value(super::Response {
status: 200,
headers: Vec::new(),
body: request.body,
})
.unwrap();
assert_eq!(response.body().as_ptr(), shared.as_ptr());
assert_eq!(response.body(), &shared);
}
#[test]
#[ignore = "explicit release performance probe"]
fn header_buffer_cost() {
let limits = Limits {
header_bytes: 131072,
..Limits::default()
}
.check()
.unwrap();
println!("\ncase,sample,iterations,elapsed_ns,checksum");
for (name, count, bytes) in [("short", 8, 32), ("wide", 32, 32), ("large", 4, 16384)] {
let input = (0..count)
.map(|index| Header {
name: format!("x-field-{index}"),
value: vec![0x80; bytes].into(),
})
.collect::<Vec<_>>();
let converted = headers(input.clone(), &limits, false, 80).unwrap();
for field in &input {
assert_eq!(
converted.get(field.name.as_str()).unwrap().as_bytes(),
field.value
);
}
let convert = || {
let output = headers(black_box(input.clone()), &limits, false, 80).unwrap();
black_box(output.values().map(|value| value.len()).sum::<usize>())
};
for _ in 0..100 {
assert_eq!(convert(), count * bytes);
}
for sample in 0..15 {
let started = Instant::now();
let mut checksum = 0;
for _ in 0..1000 {
checksum += convert();
}
let elapsed = started.elapsed().as_nanos();
assert_eq!(checksum, count * bytes * 1000);
println!("http_headers_{name},{sample},1000,{elapsed},{checksum}");
}
}
}
+206
View File
@@ -0,0 +1,206 @@
use super::*;
use http_body_util::{BodyExt, Full};
use hyper_util::rt::{TokioExecutor, TokioIo};
use tokio::sync::Notify;
fn limits() -> Limits {
Limits {
http2: Some(http::Http2Limits::default()),
..Limits::default()
}
}
#[test]
fn idle_http2_connection_releases_capacity_after_response() {
runtime()
.run(async |scope| {
let listener = net::listen("127.0.0.1", 0, scope).await.unwrap();
let port = listener.port().unwrap();
let incoming = listener.clone();
let limits = Limits {
connections: 1,
timeout_ms: 50,
..limits()
};
let serving = limits.clone();
let server = scope
.spawn(move |child| async move {
http::serve::<_, _, false>(&incoming, &serving, &child, route).await
})
.unwrap();
let stream = tokio::net::TcpStream::connect(("127.0.0.1", port as u16))
.await
.unwrap();
let (mut sender, connection) =
hyper::client::conn::http2::Builder::new(TokioExecutor::new())
.handshake::<_, Full<bytes::Bytes>>(TokioIo::new(stream))
.await
.unwrap();
let driver = scope.spawn_leaf(move || connection).unwrap();
let response = sender
.send_request(
hyper::Request::builder()
.uri("http://fixture.test/echo")
.body(Full::new(bytes::Bytes::from_static(b"first")))
.unwrap(),
)
.await
.unwrap();
assert_eq!(
response.into_body().collect().await.unwrap().to_bytes(),
b"first"[..]
);
tokio::time::timeout(Duration::from_secs(2), driver.wait())
.await
.unwrap()
.unwrap()
.unwrap();
assert_eq!(
http::send("127.0.0.1", port, request("/echo", b"second"), &limits)
.await
.unwrap()
.body
.as_ref(),
b"second"
);
listener.close().unwrap();
server.wait().await.unwrap().unwrap();
})
.unwrap();
}
#[test]
fn http2_single_request_preserves_body_headers_and_validates_protocol_options() {
runtime()
.run(async |scope| {
let listener = net::listen("127.0.0.1", 0, scope).await.unwrap();
let port = listener.port().unwrap();
let incoming = listener.clone();
let server = scope
.spawn(move |child| async move {
http::serve::<_, _, false>(&incoming, &limits(), &child, route).await
})
.unwrap();
let response = http::send("127.0.0.1", port, request("/echo", b"http2"), &limits())
.await
.unwrap();
assert_eq!(response.body.as_ref(), b"http2");
let headers = response
.headers
.iter()
.filter(|header| header.name == "x-data")
.collect::<Vec<_>>();
assert_eq!(headers.len(), 2);
assert_eq!(headers[0].value.as_ref(), [0x80]);
for header in ["Connection", "Keep-Alive", "Proxy-Connection", "TE"] {
let mut message = request("/", b"");
message.headers.push(Header {
name: header.into(),
value: b"invalid".to_vec().into(),
});
assert!(
http::send("127.0.0.1", port, message, &limits())
.await
.unwrap_err()
.contains("connection header")
);
}
listener.close().unwrap();
server.wait().await.unwrap().unwrap();
})
.unwrap();
}
#[test]
fn http2_multiplexes_independent_streams_normalizes_authority_and_drains_goaway() {
for threads in [1, 2] {
let runtime = Runtime::new(dever_runtime::task::Limits {
threads,
workers: 2,
tasks: 32,
})
.unwrap();
runtime.run(async |scope| {
let listener = net::listen("127.0.0.1", 0, scope).await.unwrap();
let port = listener.port().unwrap();
let incoming = listener.clone();
let started = Arc::new(Notify::new());
let ready = started.clone();
let release = Arc::new(Notify::new());
let released = release.clone();
let server = scope.spawn(move |child| async move {
http::serve::<_, _, false>(&incoming, &limits(), &child, move |request, _| {
let ready = ready.clone();
let released = released.clone();
async move {
assert!(request.headers.iter().any(|header| header.name == "host" && header.value.as_ref() == b"fixture.test"));
if request.target == "/slow" {
ready.notify_one();
released.notified().await;
}
Ok(Response { status: 200, headers: vec![], body: request.target.into_bytes().into() })
}
}).await
}).unwrap();
let stream = tokio::net::TcpStream::connect(("127.0.0.1", port as u16)).await.unwrap();
let (mut sender, connection) = hyper::client::conn::http2::Builder::new(TokioExecutor::new())
.handshake::<_, Full<bytes::Bytes>>(TokioIo::new(stream)).await.unwrap();
let driver = scope.spawn_leaf(move || connection).unwrap();
let message = |path: &str| hyper::Request::builder().uri(format!("http://fixture.test{path}"))
.body(Full::new(bytes::Bytes::new())).unwrap();
let slow = sender.send_request(message("/slow"));
tokio::pin!(slow);
tokio::select! {
result = &mut slow => panic!("slow stream finished before release: {result:?}"),
() = started.notified() => (),
() = tokio::time::sleep(Duration::from_secs(3)) => panic!("slow stream did not start"),
}
let fast = tokio::time::timeout(Duration::from_secs(3), sender.send_request(message("/fast"))).await.unwrap().unwrap();
assert_eq!(fast.into_body().collect().await.unwrap().to_bytes(), b"/fast"[..]);
listener.close().unwrap();
release.notify_one();
let slow = slow.await.unwrap();
assert_eq!(slow.into_body().collect().await.unwrap().to_bytes(), b"/slow"[..]);
drop(sender);
tokio::time::timeout(Duration::from_secs(3), server.wait()).await.unwrap().unwrap().unwrap();
driver.stop().await.unwrap();
}).unwrap();
}
}
#[test]
fn http2_limits_reject_invalid_windows_and_total_stream_capacity() {
runtime()
.run(async |_| {
for http2 in [
http::Http2Limits {
streams: 2049,
..http::Http2Limits::default()
},
http::Http2Limits {
streams: 0,
..http::Http2Limits::default()
},
http::Http2Limits {
stream_window_bytes: 0,
..http::Http2Limits::default()
},
http::Http2Limits {
connection_window_bytes: 65534,
..http::Http2Limits::default()
},
] {
let limits = Limits {
http2: Some(http2),
..Limits::default()
};
assert!(
http::send("127.0.0.1", 1, request("/", b""), &limits)
.await
.unwrap_err()
.contains("HTTP/2 limits")
);
}
})
.unwrap();
}
+304
View File
@@ -0,0 +1,304 @@
use super::*;
use crate::wire::document::{MAX_BYTES, MAX_VALUES};
#[derive(Debug, serde::Deserialize, serde::Serialize, PartialEq)]
#[serde(deny_unknown_fields)]
struct Arguments {
name: String,
count: i64,
enabled: bool,
}
#[test]
#[ignore = "explicit release performance probe"]
fn form_decode_cost() {
use std::{hint::black_box, time::Instant};
println!("\ncase,sample,implementation,iterations,elapsed_ns,checksum");
for bytes in [64, 4096, 65536] {
let text = "a".repeat(bytes);
for sample in 0..15 {
for offset in 0..2 {
let native = (sample + offset) % 2 == 1;
let started = Instant::now();
let mut checksum = 0;
for _ in 0..1000 {
let fields = [
("name".into(), black_box(&text).clone()),
("count".into(), "-7".into()),
("enabled".into(), "true".into()),
]
.into_iter()
.collect();
let result: Arguments = if native {
native_form(fields).unwrap()
} else {
Input::fields(fields).decode(crate::wire::decode).unwrap()
};
assert_eq!(result.count, -7);
assert!(result.enabled);
checksum += black_box(result.name.len());
}
let elapsed = started.elapsed().as_nanos();
let implementation = if native { "rust" } else { "dever" };
println!("form_{bytes},{sample},{implementation},1000,{elapsed},{checksum}");
}
}
}
}
fn native_form(mut fields: BTreeMap<String, String>) -> Option<Arguments> {
if fields.len() != 3 {
return None;
}
let result = Arguments {
name: fields.remove("name")?,
count: fields.remove("count")?.parse().ok()?,
enabled: fields.remove("enabled")?.parse().ok()?,
};
// 字段名固定;Int、Bool 和标点合计不超过 60 字节。
if result.name.len().saturating_mul(6).saturating_add(60) > MAX_BYTES
&& serde_json::to_vec(&result).ok()?.len() > MAX_BYTES
{
return None;
}
Some(result)
}
#[test]
fn form_fields_use_target_types_without_url_decoding() {
let fields = BTreeMap::from([
("name".into(), "%FF+雪".into()),
("count".into(), "-7".into()),
("enabled".into(), "true".into()),
]);
let parsed: Arguments = Input::fields(fields.clone())
.decode(crate::wire::decode)
.unwrap();
assert_eq!(parsed, native_form(fields.clone()).unwrap());
assert_eq!(parsed.name, "%FF+雪");
for (name, value) in [("count", "1.5"), ("enabled", "1"), ("extra", "x")] {
let mut invalid = fields.clone();
invalid.insert(name.into(), value.into());
assert!(
Input::fields(invalid.clone())
.decode::<Arguments>(crate::wire::decode)
.is_err()
);
assert!(native_form(invalid).is_none());
}
let mut missing = fields;
missing.remove("enabled");
assert!(
Input::fields(missing)
.decode::<Arguments>(crate::wire::decode)
.is_err()
);
}
fn query_request(query: &str) -> Request {
hyper::Request::builder()
.uri(format!("/test?{query}"))
.body(Default::default())
.unwrap()
}
#[test]
fn scalar_fields_decode_without_changing_argument_rules() {
let request = query_request("name=%E9%9B%AA+test&count=-7&enabled=true");
assert_eq!(
input(&request)
.unwrap()
.decode::<Arguments>(crate::wire::decode)
.unwrap(),
Arguments {
name: "雪 test".into(),
count: -7,
enabled: true
}
);
for query in [
"name=x&name=y",
"unknown=x",
"count=1.5",
"enabled=1",
"name=%FF",
"name=%",
] {
assert!(
input(&query_request(query))
.unwrap()
.decode::<Arguments>(crate::wire::decode)
.is_err(),
"{query}"
);
}
assert!(
input(&query_request("name=x"))
.unwrap()
.decode::<Arguments>(crate::wire::decode)
.is_err()
);
}
#[test]
fn form_budgets_count_json_escaping_and_root_value() {
let mut fields = BTreeMap::new();
fields.insert("text".into(), "\0".repeat(MAX_BYTES / 6));
assert!(
Input::fields(fields)
.decode::<BTreeMap<String, String>>(crate::wire::decode)
.is_err()
);
let fields = (0..MAX_VALUES - 1)
.map(|index| (index.to_string(), "false".to_owned()))
.collect::<BTreeMap<_, _>>();
assert!(
Input::fields(fields.clone())
.decode::<BTreeMap<String, String>>(crate::wire::decode)
.is_ok()
);
let mut fields = fields;
fields.insert("extra".into(), "true".to_owned());
assert!(
Input::fields(fields)
.decode::<BTreeMap<String, String>>(crate::wire::decode)
.is_err()
);
}
#[test]
fn query_rejects_bad_trailing_optional_fields_and_non_finite_numbers() {
#[derive(Debug, serde::Deserialize)]
#[serde(deny_unknown_fields)]
struct Scalars {
ratio: f64,
tag: Option<String>,
}
for query in [
"ratio=1&tag=%",
"ratio=1&tag=%FF",
"ratio=1&tag",
"ratio=1&tag=a&tag=b",
"ratio=1&extra=x",
"ratio=NaN",
"ratio=inf",
"ratio=1e999",
] {
assert!(
input(&query_request(query))
.unwrap()
.decode::<Scalars>(crate::wire::decode)
.is_err(),
"{query}"
);
}
let result: Scalars = input(&query_request("ratio=-0&tag=%E9%9B%AA+test"))
.unwrap()
.decode(crate::wire::decode)
.unwrap();
assert_eq!(result.ratio.to_bits(), (-0.0f64).to_bits());
assert_eq!(result.tag.as_deref(), Some("雪 test"));
}
#[test]
fn large_query_uses_exact_existing_json_byte_budget() {
#[derive(Debug, serde::Deserialize)]
#[serde(deny_unknown_fields)]
struct TextArgument {
text: String,
}
let overhead = serde_json::to_vec(&serde_json::json!({"text":""}))
.unwrap()
.len();
for (plain, count) in [
(true, MAX_BYTES / 6 + 1),
(true, MAX_BYTES - overhead),
(true, MAX_BYTES - overhead + 1),
(false, (MAX_BYTES - overhead) / 6),
(false, (MAX_BYTES - overhead) / 6 + 1),
] {
let text = if plain { "a" } else { "\0" }.repeat(count);
let encoded = if plain { "a" } else { "%00" }.repeat(count);
let source = serde_json::to_string(&serde_json::json!({"text": text})).unwrap();
let expected = crate::wire::decode::<TextArgument>(&source);
let fields = BTreeMap::from([("text".into(), text)]);
let form = Input::fields(fields).decode::<TextArgument>(crate::wire::decode);
assert_eq!(form.is_ok(), expected.is_ok());
if let (Ok(form), Ok(expected)) = (&form, &expected) {
assert_eq!(form.text, expected.text);
}
// 此处独立验证字段 codec 的 JSON 预算;超长 URI 会先被 HTTP 库拒绝。
let query = format!("text={encoded}");
let actual = Input(Data::Query(&query)).decode::<TextArgument>(crate::wire::decode);
assert_eq!(
actual.is_ok(),
expected.is_ok(),
"plain={plain}, count={count}"
);
if let (Ok(actual), Ok(expected)) = (actual, expected) {
assert_eq!(actual.text, expected.text);
}
}
}
#[cfg(all(feature = "calendar", feature = "uuid"))]
#[test]
fn query_uses_existing_scalar_codecs_and_optional_types() {
use crate::{
time::calendar::{Date, DateTime, Duration, Time},
value::{decimal::Decimal, id::Id, uuid::Uuid},
};
#[derive(Debug, PartialEq, serde::Deserialize)]
#[serde(deny_unknown_fields)]
struct Scalars {
decimal: Decimal,
id: Id,
uuid: Uuid,
date: Date,
time: Time,
datetime: DateTime,
duration: Duration,
count: Option<i64>,
enabled: Option<bool>,
ratio: Option<f64>,
}
let fields = serde_json::json!({
"decimal": "123456789.0123456789",
"id": "雪",
"uuid": "550e8400-e29b-41d4-a716-446655440000",
"date": "2026-10-26",
"time": "12:34:56.789",
"datetime": "2026-10-26T12:34:56Z",
"duration": -1500,
"count": 7,
"enabled": true,
"ratio": 1.25
});
let expected: Scalars = crate::wire::decode(&fields.to_string()).unwrap();
let query = "decimal=123456789.0123456789&id=%E9%9B%AA&uuid=550e8400-e29b-41d4-a716-446655440000&date=2026-10-26&time=12:34:56.789&datetime=2026-10-26T12:34:56Z&duration=-1500&count=7&enabled=true&ratio=1.25";
let actual: Scalars = input(&query_request(query))
.unwrap()
.decode(crate::wire::decode)
.unwrap();
assert_eq!(actual, expected);
for (valid, invalid) in [
("123456789.0123456789", "NaN"),
("550e8400-e29b-41d4-a716-446655440000", "bad-uuid"),
("date=2026-10-26", "date=2026-02-30"),
("time=12:34:56.789", "time=25:34:56.789"),
("duration=-1500", "duration=1.5"),
("count=7", "count=9223372036854775808"),
("enabled=true", "enabled=yes"),
("ratio=1.25", "ratio=NaN"),
] {
assert!(
input(&query_request(&query.replace(valid, invalid)))
.unwrap()
.decode::<Scalars>(crate::wire::decode)
.is_err(),
"{invalid}"
);
}
}
+311
View File
@@ -0,0 +1,311 @@
use super::*;
use std::sync::atomic::{AtomicUsize, Ordering};
use tokio::sync::Notify;
struct Running(Arc<AtomicUsize>);
impl Drop for Running {
fn drop(&mut self) {
self.0.fetch_sub(1, Ordering::SeqCst);
}
}
#[test]
fn timeout_and_stop_drain_handler_descendants() {
runtime()
.run(async |scope| {
for cancel in [false, true] {
let listener = net::listen("127.0.0.1", 0, scope).await.unwrap();
let port = listener.port().unwrap();
let incoming = listener.clone();
let running = Arc::new(AtomicUsize::new(0));
let live = running.clone();
let started = Arc::new(Notify::new());
let ready = started.clone();
let server = scope
.spawn(move |child| async move {
let limits = Limits {
timeout_ms: if cancel { 5000 } else { 100 },
..Limits::default()
};
http::serve::<_, _, true>(
&incoming,
&limits,
&child,
move |request, scope| {
let live = live.clone();
let ready = ready.clone();
async move {
let pending = scope
.spawn_leaf(move || async move {
live.fetch_add(1, Ordering::SeqCst);
let _running = Running(live);
ready.notify_one();
std::future::pending::<()>().await;
})
.unwrap();
pending.wait().await.unwrap();
route(request, scope).await
}
},
)
.await
})
.unwrap();
let client = scope
.spawn_leaf(move || async move {
http::send("127.0.0.1", port, request("/", b""), &Limits::default()).await
})
.unwrap();
tokio::time::timeout(Duration::from_secs(3), started.notified())
.await
.unwrap();
assert_eq!(running.load(Ordering::SeqCst), 1);
if cancel {
server.stop().await.unwrap();
assert!(client.wait().await.unwrap().is_err());
listener.close().unwrap();
} else {
assert_eq!(client.wait().await.unwrap().unwrap().status, 504);
listener.close().unwrap();
server.wait().await.unwrap().unwrap();
}
assert_eq!(running.load(Ordering::SeqCst), 0);
}
})
.unwrap();
}
#[test]
fn closing_listener_drains_an_inflight_response() {
runtime()
.run(async |scope| {
let listener = net::listen("127.0.0.1", 0, scope).await.unwrap();
let port = listener.port().unwrap();
let incoming = listener.clone();
let started = Arc::new(Notify::new());
let ready = started.clone();
let release = Arc::new(Notify::new());
let released = release.clone();
let server = scope
.spawn(move |child| async move {
http::serve::<_, _, false>(
&incoming,
&Limits::default(),
&child,
move |request, scope| {
let ready = ready.clone();
let released = released.clone();
async move {
ready.notify_one();
released.notified().await;
route(request, scope).await
}
},
)
.await
})
.unwrap();
let client = scope
.spawn_leaf(move || async move {
http::send(
"127.0.0.1",
port,
request("/", b"drained"),
&Limits::default(),
)
.await
})
.unwrap();
tokio::time::timeout(Duration::from_secs(3), started.notified())
.await
.unwrap();
listener.close().unwrap();
release.notify_one();
assert_eq!(
client.wait().await.unwrap().unwrap().body.as_ref(),
b"drained"
);
tokio::time::timeout(Duration::from_secs(3), server.wait())
.await
.unwrap()
.unwrap()
.unwrap();
})
.unwrap();
}
#[test]
fn client_deadline_drops_its_connection_driver() {
runtime()
.run(async |scope| {
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let port = i64::from(listener.local_addr().unwrap().port());
let peer = scope
.spawn(move |child| async move {
let (stream, _) = listener.accept().await.unwrap();
let mut stream = stream.into_std().unwrap();
stream.set_nonblocking(false).unwrap();
stream
.set_read_timeout(Some(Duration::from_secs(3)))
.unwrap();
child
.blocking(move || {
let mut data = Vec::new();
stream.read_to_end(&mut data).unwrap();
assert!(data.starts_with(b"POST / HTTP/1.1"));
})
.await
.unwrap();
})
.unwrap();
let limits = Limits {
timeout_ms: 100,
..Limits::default()
};
let error = http::send("127.0.0.1", port, request("/", b""), &limits)
.await
.unwrap_err();
assert_eq!(error, "HTTP request timed out");
peer.wait().await.unwrap();
})
.unwrap();
}
#[test]
fn invalid_service_configuration_keeps_listener_available() {
runtime()
.run(async |scope| {
let listener = net::listen("127.0.0.1", 0, scope).await.unwrap();
let limits = Limits {
connections: 0,
..Limits::default()
};
assert_eq!(
http::serve::<_, _, false>(&listener, &limits, scope, route).await,
Err(http::ServeError::Limits)
);
assert!(listener.port().unwrap() > 0);
listener.close().unwrap();
assert_eq!(
http::serve::<_, _, false>(&listener, &Limits::default(), scope, route).await,
Err(http::ServeError::Listener)
);
})
.unwrap();
}
#[test]
fn exhausted_task_pool_preserves_connections_and_resumes_accepting() {
capacity_recovery(CapacityAction::Resume);
}
#[test]
fn closing_listener_interrupts_waiting_for_task_capacity() {
capacity_recovery(CapacityAction::Close);
}
#[test]
fn cancelling_server_interrupts_waiting_for_task_capacity() {
capacity_recovery(CapacityAction::Cancel);
}
enum CapacityAction {
Resume,
Close,
Cancel,
}
fn capacity_recovery(action: CapacityAction) {
use http_body_util::{BodyExt, Empty};
use hyper_util::rt::TokioIo;
Runtime::new(dever_runtime::task::Limits {
threads: 2,
workers: 1,
tasks: 3,
})
.unwrap()
.run(async |scope| {
let listener = net::listen("127.0.0.1", 0, scope).await.unwrap();
let port = listener.port().unwrap() as u16;
let incoming = listener.clone();
let server = scope
.spawn(move |child| async move {
http::serve::<_, _, false>(&incoming, &Limits::default(), &child, route).await
})
.unwrap();
let mut clients = Vec::new();
let mut drivers = Vec::new();
for _ in 0..3 {
let socket = tokio::net::TcpStream::connect(("127.0.0.1", port))
.await
.unwrap();
let (sender, connection) = hyper::client::conn::http1::handshake(TokioIo::new(socket))
.await
.unwrap();
clients.push(sender);
drivers.push(tokio::spawn(connection));
}
let request = || {
hyper::Request::builder()
.uri("/echo")
.header("host", "fixture.test")
.body(Empty::<bytes::Bytes>::new())
.unwrap()
};
for client in &mut clients[..2] {
let response = client.send_request(request()).await.unwrap();
assert_eq!(response.status(), 200);
response.into_body().collect().await.unwrap();
}
let mut waiting = Box::pin(clients[2].send_request(request()));
assert!(
tokio::time::timeout(Duration::from_millis(100), &mut waiting)
.await
.is_err()
);
if matches!(action, CapacityAction::Resume) {
let response = clients[0].send_request(request()).await.unwrap();
assert_eq!(response.status(), 200);
response.into_body().collect().await.unwrap();
drivers[0].abort();
let response = tokio::time::timeout(Duration::from_secs(3), &mut waiting)
.await
.unwrap()
.unwrap();
assert_eq!(response.status(), 200);
response.into_body().collect().await.unwrap();
}
if matches!(action, CapacityAction::Cancel) {
tokio::time::timeout(Duration::from_secs(3), server.stop())
.await
.unwrap()
.unwrap();
listener.close().unwrap();
} else {
listener.close().unwrap();
tokio::time::timeout(Duration::from_secs(3), server.wait())
.await
.unwrap()
.unwrap()
.unwrap();
}
drop(waiting);
drop(clients);
for driver in drivers {
driver.abort();
let _ = driver.await;
}
assert_eq!(
scope
.spawn_leaf(|| async { 42 })
.unwrap()
.wait()
.await
.unwrap(),
42
);
})
.unwrap();
}
+103
View File
@@ -0,0 +1,103 @@
use super::*;
#[test]
fn body_budgets_timeouts_invalid_messages_and_recovery_are_bounded() {
runtime().run(async |scope| {
let listener = net::listen("127.0.0.1", 0, scope).await.unwrap();
let port = listener.port().unwrap();
let incoming = listener.clone();
let server = scope.spawn(move |child| async move {
let limits = Limits { body_bytes: 4, timeout_ms: 100, ..Limits::default() };
http::serve::<_, _, false>(&incoming, &limits, &child, route).await
}).unwrap();
scope.blocking(move || {
let query_only = format!("GET ?{} HTTP/1.1\r\nHost: t\r\n\r\n", "a".repeat(8163));
for (message, expected) in [
("POST /echo HTTP/1.1\r\nHost: fixture.test\r\nContent-Length: 5\r\n\r\n12345", 413),
("POST /echo HTTP/1.1\r\nHost: fixture.test\r\nContent-Length: 4\r\n\r\n1", 408),
("GET /wait HTTP/1.1\r\nHost: fixture.test\r\n\r\n", 504),
("GET /echo HTTP/1.1\r\nHost: one\r\nHost: two\r\n\r\n", 400),
(query_only.as_str(), 400),
] {
let mut stream = connect(port);
stream.get_mut().write_all(message.as_bytes()).unwrap();
assert_eq!(read_response(&mut stream, false).0, expected);
assert_eq!(stream.read(&mut [0]).unwrap(), 0);
}
let mut stream = connect(port);
stream.get_mut().write_all(b"GET / HTTP/1.1\r\nHost:").unwrap();
assert_eq!(stream.read(&mut [0]).unwrap(), 0);
}).await.unwrap();
let response = http::send("127.0.0.1", port, request("/echo", b"ok"), &Limits::default()).await.unwrap();
assert_eq!(response.body.as_ref(), b"ok");
listener.close().unwrap();
server.wait().await.unwrap().unwrap();
}).unwrap();
}
#[test]
fn outgoing_contracts_reject_framing_injection_and_bad_limits() {
runtime()
.run(async |_| {
for header in [
Header {
name: "Content-Length".into(),
value: b"0".to_vec().into(),
},
Header {
name: "Transfer-Encoding".into(),
value: b"chunked".to_vec().into(),
},
Header {
name: "x-test".into(),
value: b"a\r\nb".to_vec().into(),
},
] {
let mut message = request("/", b"");
message.headers.push(header);
let error = http::send("127.0.0.1", 1, message, &Limits::default())
.await
.unwrap_err();
assert!(!error.contains("connection failed"), "{error}");
}
for limits in [
Limits {
header_bytes: 0,
..Limits::default()
},
Limits {
body_bytes: -1,
..Limits::default()
},
Limits {
timeout_ms: 0,
..Limits::default()
},
Limits {
connections: 65537,
..Limits::default()
},
] {
assert!(
http::send("127.0.0.1", 1, request("/", b""), &limits)
.await
.is_err()
);
}
let long_target = format!("/{}", "x".repeat(8192));
for (method, target) in [
("CONNECT", "/"),
("GET", "http://example.test/"),
("GET", "/#fragment"),
("GET", long_target.as_str()),
] {
let mut message = request(target, b"");
message.method = method.into();
let error = http::send("127.0.0.1", 1, message, &Limits::default())
.await
.unwrap_err();
assert!(!error.contains("connection failed"), "{error}");
}
})
.unwrap();
}
+233
View File
@@ -0,0 +1,233 @@
use super::*;
use tokio::sync::Notify;
#[test]
fn head_and_disconnect_cancel_requests_before_reusing_the_connection() {
runtime()
.run(async |scope| {
let listener = net::listen("127.0.0.1", 0, scope).await.unwrap();
let incoming = listener.clone();
let active = Arc::new(AtomicUsize::new(0));
let running = active.clone();
let server = scope
.spawn(move |child| async move {
http::serve_live(
&incoming,
&Limits::default(),
&http::LiveLimits::default(),
&child,
move |request, reply, _| {
let active = active.clone();
async move {
assert_eq!(active.fetch_add(1, Ordering::SeqCst), 0);
let _running = Running(active);
reply.start(200, vec![]).await.unwrap();
if request.method == "HEAD" {
std::future::pending::<()>().await;
}
reply.write(b"data").await.unwrap();
std::future::pending::<Result<(), String>>().await
}
},
)
.await
})
.unwrap();
let (mut sender, driver) = client(listener.port().unwrap(), scope).await;
let response = sender.send_request(message("HEAD")).await.unwrap();
assert!(
response
.into_body()
.collect()
.await
.unwrap()
.to_bytes()
.is_empty()
);
assert_eq!(running.load(Ordering::SeqCst), 0);
let response = sender.send_request(message("GET")).await.unwrap();
let mut body = response.into_body();
assert_eq!(
body.frame().await.unwrap().unwrap().into_data().unwrap(),
b"data"[..]
);
drop(body);
drop(sender);
driver.wait().await.unwrap();
tokio::time::timeout(Duration::from_secs(2), async {
while running.load(Ordering::SeqCst) != 0 {
tokio::task::yield_now().await;
}
})
.await
.unwrap();
listener.close().unwrap();
server.wait().await.unwrap().unwrap();
})
.unwrap();
}
#[test]
fn slow_peer_bounds_writer_and_parent_stop_drains_blocking_descendants() {
for sse in [false, true] {
slow_peer_case(sse);
}
}
fn slow_peer_case(sse: bool) {
runtime()
.run(async |scope| {
let listener = net::listen("127.0.0.1", 0, scope).await.unwrap();
let incoming = listener.clone();
let count = Arc::new(AtomicUsize::new(0));
let written = count.clone();
let active = Arc::new(AtomicUsize::new(0));
let running = active.clone();
let ready = Arc::new(Notify::new());
let started = ready.clone();
let server = scope
.spawn(move |child| async move {
http::serve_live(
&incoming,
&Limits {
timeout_ms: 5000,
..Limits::default()
},
&http::LiveLimits::default(),
&child,
move |_, reply, scope| {
let written = written.clone();
let active = active.clone();
let ready = ready.clone();
async move {
let worker = scope
.spawn(move |child| async move {
child
.blocking(move || {
active.fetch_add(1, Ordering::SeqCst);
let _running = Running(active);
ready.notify_one();
std::thread::sleep(Duration::from_millis(400));
})
.await
})
.unwrap();
if sse {
reply.sse_start(vec![]).await.unwrap();
} else {
reply.start(200, vec![]).await.unwrap();
}
let bytes = vec![0; 65536];
let event = http::Event {
event: String::new(),
data: "a".repeat(65528),
id: None,
retry_ms: None,
};
for _ in 0..10000 {
let result = if sse {
reply.sse_send(&event).await
} else {
reply.write(&bytes).await
};
result.map_err(|error| error.to_string())?;
written.fetch_add(1, Ordering::SeqCst);
}
worker.wait().await.unwrap().unwrap();
Ok(())
}
},
)
.await
})
.unwrap();
let mut socket =
tokio::net::TcpStream::connect(("127.0.0.1", listener.port().unwrap() as u16))
.await
.unwrap();
use tokio::io::AsyncWriteExt;
socket
.write_all(b"GET / HTTP/1.1\r\nHost: fixture.test\r\n\r\n")
.await
.unwrap();
started.notified().await;
tokio::time::sleep(Duration::from_millis(100)).await;
assert!(count.load(Ordering::SeqCst) < 10000);
server.stop().await.unwrap();
assert_eq!(running.load(Ordering::SeqCst), 0);
listener.close().unwrap();
})
.unwrap();
}
#[test]
fn live_header_and_idle_deadlines_are_distinct_and_faults_truncate() {
runtime()
.run(async |scope| {
for mode in ["headers", "idle", "fault", "missing"] {
let gate = Arc::new(Semaphore::new(0));
let failed = gate.clone();
let listener = net::listen("127.0.0.1", 0, scope).await.unwrap();
let incoming = listener.clone();
let server = scope
.spawn(move |child| async move {
http::serve_live(
&incoming,
&Limits {
timeout_ms: 100,
..Limits::default()
},
&http::LiveLimits {
idle_ms: 60,
heartbeat_ms: 20,
chunk_bytes: 64,
},
&child,
move |_, reply, _| {
let gate = failed.clone();
async move {
if mode == "missing" {
return Ok(());
}
if mode == "headers" {
std::future::pending::<()>().await;
}
reply.start(200, vec![]).await.unwrap();
if mode == "idle" {
std::future::pending::<()>().await;
}
reply.write(b"partial").await.unwrap();
let _permit = gate.acquire().await.unwrap();
Err("located handler failure".into())
}
},
)
.await
})
.unwrap();
let (mut sender, driver) = client(listener.port().unwrap(), scope).await;
let response = sender.send_request(message("GET")).await.unwrap();
match mode {
"headers" => assert_eq!(response.status(), 504),
"missing" => assert_eq!(response.status(), 500),
_ => {
assert_eq!(response.status(), 200);
let mut body = response.into_body();
if mode == "fault" {
assert_eq!(
body.frame().await.unwrap().unwrap().into_data().unwrap(),
b"partial"[..]
);
gate.add_permits(1);
}
assert!(body.collect().await.is_err());
}
}
drop(sender);
driver.wait().await.unwrap();
listener.close().unwrap();
server.wait().await.unwrap().unwrap();
}
})
.unwrap();
}
+279
View File
@@ -0,0 +1,279 @@
use super::*;
use bytes::Bytes;
use http_body_util::{BodyExt, Empty};
use hyper_util::rt::TokioIo;
use std::sync::atomic::{AtomicUsize, Ordering};
use tokio::sync::Semaphore;
mod lifecycle;
mod performance;
mod transport;
async fn client(
port: i64,
scope: &Scope<'_>,
) -> (
hyper::client::conn::http1::SendRequest<Empty<Bytes>>,
dever_runtime::task::Task<()>,
) {
let socket = tokio::net::TcpStream::connect(("127.0.0.1", port as u16))
.await
.unwrap();
let (sender, connection) = hyper::client::conn::http1::handshake(TokioIo::new(socket))
.await
.unwrap();
let driver = scope
.spawn_leaf(move || async move {
let _ = connection.await;
})
.unwrap();
(sender, driver)
}
fn message(method: &str) -> hyper::Request<Empty<Bytes>> {
hyper::Request::builder()
.method(method)
.uri("/")
.header("Host", "fixture.test")
.body(Empty::new())
.unwrap()
}
struct Running(Arc<AtomicUsize>);
impl Drop for Running {
fn drop(&mut self) {
self.0.fetch_sub(1, Ordering::SeqCst);
}
}
#[test]
fn sse_encoding_preserves_lines_ids_and_exact_limit() {
let event = http::Event {
event: "change".into(),
data: "一\r\n二\r\n\r".into(),
id: Some("".into()),
retry_ms: Some(0),
};
let expected = "event: change\nid: \nretry: 0\ndata: 一\ndata: 二\ndata: \ndata: \n\n";
assert_eq!(event.encode(expected.len()).unwrap(), expected);
assert!(event.encode(expected.len() - 1).is_err());
for event in [
http::Event {
event: "bad\n".into(),
..event.clone()
},
http::Event {
id: Some("bad\0".into()),
..event.clone()
},
http::Event {
retry_ms: Some(-1),
..event.clone()
},
] {
assert!(event.encode(1024).is_err());
}
}
#[test]
fn first_chunk_arrives_before_handler_finishes_and_outlives_head_deadline() {
runtime()
.run(async |scope| {
let listener = net::listen("127.0.0.1", 0, scope).await.unwrap();
let port = listener.port().unwrap();
let incoming = listener.clone();
let gate = Arc::new(Semaphore::new(0));
let waiting = gate.clone();
let server = scope
.spawn(move |child| async move {
let limits = Limits {
timeout_ms: 100,
..Limits::default()
};
http::serve_live(
&incoming,
&limits,
&http::LiveLimits::default(),
&child,
move |_, reply, _| {
let gate = waiting.clone();
async move {
reply
.start(200, vec![])
.await
.map_err(|error| error.to_string())?;
reply
.write(b"first")
.await
.map_err(|error| error.to_string())?;
let _permit = gate.acquire().await.unwrap();
reply
.write(b"last")
.await
.map_err(|error| error.to_string())
}
},
)
.await
})
.unwrap();
let (mut sender, driver) = client(port, scope).await;
let response = sender.send_request(message("GET")).await.unwrap();
assert_eq!(response.status(), 200);
let mut body = response.into_body();
assert_eq!(
body.frame().await.unwrap().unwrap().into_data().unwrap(),
b"first"[..]
);
tokio::time::sleep(Duration::from_millis(180)).await;
gate.add_permits(1);
assert_eq!(body.collect().await.unwrap().to_bytes(), b"last"[..]);
drop(sender);
driver.wait().await.unwrap();
listener.close().unwrap();
server.wait().await.unwrap().unwrap();
})
.unwrap();
}
#[test]
fn sse_heartbeats_keep_session_alive_and_stop_drains_handler() {
runtime()
.run(async |scope| {
let listener = net::listen("127.0.0.1", 0, scope).await.unwrap();
let incoming = listener.clone();
let running = Arc::new(AtomicUsize::new(0));
let active = running.clone();
let server = scope
.spawn(move |child| async move {
http::serve_live(
&incoming,
&Limits {
timeout_ms: 80,
..Limits::default()
},
&http::LiveLimits {
chunk_bytes: 64,
idle_ms: 100,
heartbeat_ms: 20,
},
&child,
move |_, reply, _| {
let active = active.clone();
async move {
active.fetch_add(1, Ordering::SeqCst);
let _running = Running(active);
reply
.sse_start(vec![])
.await
.map_err(|error| error.to_string())?;
std::future::pending::<Result<(), String>>().await
}
},
)
.await
})
.unwrap();
let (mut sender, driver) = client(listener.port().unwrap(), scope).await;
let response = sender.send_request(message("GET")).await.unwrap();
assert_eq!(response.headers()["content-type"], "text/event-stream");
assert_eq!(response.headers()["cache-control"], "no-cache");
let mut body = response.into_body();
for _ in 0..8 {
assert_eq!(
body.frame().await.unwrap().unwrap().into_data().unwrap(),
b":\n\n"[..]
);
}
assert_eq!(running.load(Ordering::SeqCst), 1);
server.stop().await.unwrap();
assert_eq!(running.load(Ordering::SeqCst), 0);
drop(body);
drop(sender);
driver.wait().await.unwrap();
listener.close().unwrap();
})
.unwrap();
}
#[test]
fn invalid_reply_operations_preserve_started_response_and_close_aliases() {
runtime()
.run(async |scope| {
let listener = net::listen("127.0.0.1", 0, scope).await.unwrap();
let incoming = listener.clone();
let (sent, mut saved) = tokio::sync::mpsc::channel(1);
let server = scope
.spawn(move |child| async move {
http::serve_live(
&incoming,
&Limits::default(),
&http::LiveLimits {
chunk_bytes: 32,
..http::LiveLimits::default()
},
&child,
move |_, reply, _| {
let sent = sent.clone();
async move {
assert!(reply.write(b"before").await.is_err());
assert!(reply.start(204, vec![]).await.is_err());
reply.sse_start(vec![]).await.unwrap();
assert!(reply.start(200, vec![]).await.is_err());
assert!(reply.write(b"ordinary").await.is_err());
assert!(
reply
.sse_send(&http::Event {
event: "".into(),
data: "x".repeat(33),
id: None,
retry_ms: None
})
.await
.is_err()
);
reply
.sse_send(&http::Event {
event: "".into(),
data: "ok".into(),
id: None,
retry_ms: None,
})
.await
.unwrap();
reply.finish().await.unwrap();
assert!(reply.finish().await.is_err());
assert!(
reply
.sse_send(&http::Event {
event: "".into(),
data: "late".into(),
id: None,
retry_ms: None,
})
.await
.is_err()
);
sent.send(reply).await.unwrap();
Ok(())
}
},
)
.await
})
.unwrap();
let (mut sender, driver) = client(listener.port().unwrap(), scope).await;
let response = sender.send_request(message("GET")).await.unwrap();
assert_eq!(
response.into_body().collect().await.unwrap().to_bytes(),
b"data: ok\n\n"[..]
);
let alias = saved.recv().await.unwrap();
assert!(alias.start(200, vec![]).await.is_err());
drop(sender);
driver.wait().await.unwrap();
listener.close().unwrap();
server.wait().await.unwrap().unwrap();
})
.unwrap();
}
+205
View File
@@ -0,0 +1,205 @@
use super::*;
use futures_util::stream;
use http_body_util::StreamBody;
use hyper::{body::Frame, service::service_fn};
use std::{convert::Infallible, time::Instant};
use tokio::sync::mpsc;
async fn independent_server(
data: Arc<str>,
iterations: usize,
limit: usize,
) -> (i64, tokio::task::JoinHandle<()>) {
let listener = tokio::net::TcpListener::bind(("127.0.0.1", 0))
.await
.unwrap();
let port = i64::from(listener.local_addr().unwrap().port());
let server = tokio::spawn(async move {
let (socket, _) = listener.accept().await.unwrap();
socket.set_nodelay(true).unwrap();
let service = service_fn(move |_: hyper::Request<hyper::body::Incoming>| {
let data = data.clone();
async move {
let (sender, receiver) = mpsc::channel::<Bytes>(1);
let producer = tokio::spawn(async move {
for _ in 0..iterations {
assert!(!data.contains(['\r', '\n']));
assert!(data.len().checked_add(8).unwrap() <= limit);
let encoded = format!("data: {data}\n\n");
tokio::time::timeout(
Duration::from_secs(5),
sender.send(Bytes::from(encoded)),
)
.await
.unwrap()
.unwrap();
}
});
let frames = stream::unfold(
(receiver, Some(producer)),
|(mut receiver, mut producer)| async move {
match receiver.recv().await {
Some(bytes) => Some((
Ok::<_, Infallible>(Frame::data(bytes)),
(receiver, producer),
)),
None => {
producer.take().unwrap().await.unwrap();
None
}
}
},
);
Ok::<_, Infallible>(
hyper::Response::builder()
.status(200)
.header("content-type", "text/event-stream")
.header("cache-control", "no-cache")
.body(StreamBody::new(frames))
.unwrap(),
)
}
});
hyper::server::conn::http1::Builder::new()
.serve_connection(TokioIo::new(socket), service)
.await
.unwrap();
});
(port, server)
}
async fn sample(
sender: &mut hyper::client::conn::http1::SendRequest<Empty<Bytes>>,
expected: &[u8],
iterations: usize,
) -> (u128, usize) {
let started = Instant::now();
let (response, chunks) = tokio::time::timeout(Duration::from_secs(10), async {
let response = sender.send_request(message("GET")).await.unwrap();
let (response, mut body) = response.into_parts();
let mut chunks = Vec::with_capacity(iterations);
while let Some(frame) = body.frame().await {
chunks.push(frame.unwrap().into_data().unwrap());
}
(response, chunks)
})
.await
.unwrap();
let elapsed = started.elapsed().as_nanos();
assert_eq!(response.status, 200);
assert_eq!(response.headers["content-type"], "text/event-stream");
let total = chunks.iter().map(Bytes::len).sum::<usize>();
assert_eq!(total, expected.len() * iterations);
assert!(
chunks
.iter()
.flat_map(|bytes| bytes.iter().copied())
.eq(expected.iter().copied().cycle().take(total))
);
(elapsed, total)
}
#[test]
#[ignore = "explicit release performance probe"]
fn sse_send_cost() {
let runtime = Runtime::new(dever_runtime::task::Limits {
threads: 1,
workers: 1,
tasks: 32,
})
.unwrap();
runtime
.run(async |scope| {
eprintln!(
"sse_send: 15 fixed rounds; one async worker; persistent HTTP/1 loopback; \
both implementations encode each single-line data event and use a one-chunk queue; \
rust_independent uses format!, Bytes and Hyper/Tokio directly, without Dever's SSE encoder/send; \
timed request through complete body includes network and bounded send; \
setup, exact byte verification and teardown excluded; \
successful-stream workload only, not lifecycle/heartbeat parity"
);
println!("\ncase,sample,implementation,iterations,elapsed_ns,bytes");
for (size, iterations) in [(64, 4096), (65_536, 128), (1_048_576, 16)] {
let event = Arc::new(http::Event {
event: String::new(),
data: "a".repeat(size),
id: None,
retry_ms: None,
});
let expected = format!("data: {}\n\n", event.data);
let limit = expected.len();
let encoded = event.encode(limit).unwrap();
println!(
"sse_capacity,{size},{},{}",
encoded.len(),
encoded.capacity()
);
assert_eq!(encoded, expected);
drop(encoded);
let (independent_port, independent) = independent_server(
Arc::from(event.data.as_str()),
iterations,
limit,
)
.await;
let listener = net::listen("127.0.0.1", 0, scope).await.unwrap();
let incoming = listener.clone();
let server = scope
.spawn(move |child| async move {
http::serve_live(
&incoming,
&Limits {
timeout_ms: 5000,
..Limits::default()
},
&http::LiveLimits {
chunk_bytes: limit as i64,
heartbeat_ms: 30000,
idle_ms: 60000,
},
&child,
move |_, reply, _| {
let event = event.clone();
async move {
reply.sse_start(vec![]).await.unwrap();
for _ in 0..iterations {
reply.sse_send(&event).await.unwrap();
}
reply.finish().await.unwrap();
Ok(())
}
},
)
.await
})
.unwrap();
let (mut dever_client, dever_driver) =
client(listener.port().unwrap(), scope).await;
let (mut independent_client, independent_driver) =
client(independent_port, scope).await;
sample(&mut dever_client, expected.as_bytes(), iterations).await;
sample(&mut independent_client, expected.as_bytes(), iterations).await;
for round in 0..15 {
let mut order = ["dever", "rust_independent", "control"];
order.rotate_left(round % 3);
for label in order {
let sender = if label == "rust_independent" {
&mut independent_client
} else {
&mut dever_client
};
let (elapsed, total) = sample(sender, expected.as_bytes(), iterations).await;
println!("sse_send_{size},{round},{label},{iterations},{elapsed},{total}");
}
}
drop(dever_client);
drop(independent_client);
dever_driver.wait().await.unwrap();
independent_driver.wait().await.unwrap();
listener.close().unwrap();
server.wait().await.unwrap().unwrap();
independent.await.unwrap();
}
})
.unwrap();
}
+149
View File
@@ -0,0 +1,149 @@
use super::*;
use hyper_util::rt::TokioExecutor;
#[test]
fn http2_live_reset_isolates_sse_neighbor_and_tls_uses_same_body_contract() {
runtime()
.run(async |scope| {
for secure in [false, true] {
let listener = net::listen("127.0.0.1", 0, scope).await.unwrap();
let port = listener.port().unwrap();
let incoming = listener.clone();
let running = Arc::new(AtomicUsize::new(0));
let active = running.clone();
let server = scope
.spawn(move |child| async move {
let limits = Limits {
timeout_ms: 200,
http2: Some(http::Http2Limits::default()),
..Limits::default()
};
let live = http::LiveLimits {
heartbeat_ms: 20,
idle_ms: 1000,
..http::LiveLimits::default()
};
let route =
move |request: Request,
reply: http::HttpReply,
_: Arc<Scope<'static>>| {
let running = active.clone();
async move {
if request.target == "/ordinary" {
return reply
.respond(Response {
status: 200,
headers: vec![],
body: b"ok".to_vec().into(),
})
.await
.map_err(|error| error.to_string());
}
running.fetch_add(1, Ordering::SeqCst);
let _running = Running(running);
reply.sse_start(vec![]).await.unwrap();
std::future::pending::<Result<(), String>>().await
}
};
if secure {
let identity = dever_runtime::tls::server(
super::super::tls::CERTIFICATE,
super::super::tls::KEY,
)
.unwrap();
http::serve_live_tls(
&incoming, &limits, &live, &child, &identity, route,
)
.await
} else {
http::serve_live(&incoming, &limits, &live, &child, route).await
}
})
.unwrap();
let socket = tokio::net::TcpStream::connect(("127.0.0.1", port as u16))
.await
.unwrap();
let (mut sender, driver) = if secure {
let trust = dever_runtime::tls::client(super::super::tls::ROOT).unwrap();
let socket = trust.connect("localhost", socket, true).await.unwrap();
let (sender, connection) =
hyper::client::conn::http2::Builder::new(TokioExecutor::new())
.handshake::<_, Empty<Bytes>>(TokioIo::new(socket))
.await
.unwrap();
(
sender,
scope
.spawn_leaf(move || async move {
let _ = connection.await;
})
.unwrap(),
)
} else {
let (sender, connection) =
hyper::client::conn::http2::Builder::new(TokioExecutor::new())
.handshake::<_, Empty<Bytes>>(TokioIo::new(socket))
.await
.unwrap();
(
sender,
scope
.spawn_leaf(move || async move {
let _ = connection.await;
})
.unwrap(),
)
};
let request = |path| {
hyper::Request::builder()
.uri(format!(
"{}://localhost:{port}/{path}",
if secure { "https" } else { "http" }
))
.body(Empty::new())
.unwrap()
};
let mut first = sender
.send_request(request("first"))
.await
.unwrap()
.into_body();
let mut second = sender
.send_request(request("second"))
.await
.unwrap()
.into_body();
assert_eq!(
first.frame().await.unwrap().unwrap().into_data().unwrap(),
b":\n\n"[..]
);
assert_eq!(running.load(Ordering::SeqCst), 2);
drop(first);
tokio::time::timeout(Duration::from_secs(2), async {
while running.load(Ordering::SeqCst) != 1 {
tokio::task::yield_now().await;
}
})
.await
.unwrap();
assert_eq!(
second.frame().await.unwrap().unwrap().into_data().unwrap(),
b":\n\n"[..]
);
// 活动 SSE 响应超过连接空闲期限仍可继续传输。
tokio::time::sleep(Duration::from_millis(300)).await;
let ordinary = sender.send_request(request("ordinary")).await.unwrap();
assert_eq!(
ordinary.into_body().collect().await.unwrap().to_bytes(),
b"ok"[..]
);
drop(second);
drop(sender);
driver.wait().await.unwrap();
listener.close().unwrap();
server.wait().await.unwrap().unwrap();
assert_eq!(running.load(Ordering::SeqCst), 0);
}
})
.unwrap();
}
+145
View File
@@ -0,0 +1,145 @@
use dever_runtime::{
http::{self, Header, Limits, Request, Response},
net,
task::{Runtime, Scope},
};
use std::{
io::{BufRead, BufReader, Read, Write},
net::TcpStream,
sync::Arc,
time::Duration,
};
mod http2;
mod lifecycle;
mod limits;
mod live;
mod pool;
mod streaming;
mod tls;
mod websocket;
fn runtime() -> Runtime {
Runtime::new(dever_runtime::task::Limits {
threads: 2,
workers: 2,
tasks: 32,
})
.unwrap()
}
fn request(target: &str, body: &[u8]) -> Request {
Request {
method: "POST".into(),
target: target.into(),
headers: vec![Header {
name: "Host".into(),
value: b"fixture.test".to_vec().into(),
}],
body: body.to_vec().into(),
}
}
async fn route(request: Request, _: Arc<Scope<'static>>) -> Result<Response, String> {
if request.target == "/fail" {
return Err("owned fixture failure".into());
}
if request.target == "/wait" {
tokio::time::sleep(Duration::from_secs(60)).await;
}
Ok(Response {
status: 200,
headers: vec![
Header {
name: "x-data".into(),
value: vec![0x80].into(),
},
Header {
name: "x-data".into(),
value: b"second".to_vec().into(),
},
],
body: request.body,
})
}
fn connect(port: i64) -> BufReader<TcpStream> {
let stream = TcpStream::connect(("127.0.0.1", port as u16)).unwrap();
stream
.set_read_timeout(Some(Duration::from_secs(3)))
.unwrap();
stream
.set_write_timeout(Some(Duration::from_secs(3)))
.unwrap();
BufReader::new(stream)
}
fn read_response(stream: &mut BufReader<TcpStream>, head: bool) -> (u16, Vec<u8>) {
let mut line = Vec::new();
stream.read_until(b'\n', &mut line).unwrap();
let status = std::str::from_utf8(&line)
.unwrap()
.split_whitespace()
.nth(1)
.unwrap()
.parse()
.unwrap();
let mut size = 0;
loop {
line.clear();
assert!(stream.read_until(b'\n', &mut line).unwrap() > 0);
if line == b"\r\n" {
break;
}
if line.to_ascii_lowercase().starts_with(b"content-length:") {
size = std::str::from_utf8(&line[15..])
.unwrap()
.trim()
.parse()
.unwrap();
}
}
let mut body = vec![0; if head { 0 } else { size }];
stream.read_exact(&mut body).unwrap();
(status, body)
}
#[test]
fn client_and_independent_peer_cover_keepalive_chunking_and_request_isolation() {
runtime().run(async |scope| {
let listener = net::listen("127.0.0.1", 0, scope).await.unwrap();
let port = listener.port().unwrap();
let incoming = listener.clone();
let server = scope.spawn(move |child| async move { http::serve::<_, _, false>(&incoming, &Limits::default(), &child, route).await }).unwrap();
let response = http::send("127.0.0.1", port, request("/echo", b"owned body"), &Limits::default()).await.unwrap();
assert_eq!(response.body.as_ref(), b"owned body");
let headers = response.headers.iter().filter(|value| value.name == "x-data").map(|value| value.value.clone()).collect::<Vec<_>>();
assert_eq!(headers, vec![vec![0x80], b"second".to_vec()]);
scope.blocking(move || {
let mut stream = connect(port);
for message in [
&b"GET /echo HTTP/1.1\r\nHost: fixture.test\r\n\r\n"[..],
&b"POST /echo HTTP/1.1\r\nHost: fixture.test\r\nContent-Length: 0\r\n\r\n"[..],
&b"POST /echo HTTP/1.1\r\nHost: fixture.test\r\nTransfer-Encoding: chunked\r\n\r\n0\r\n\r\n"[..],
] {
stream.get_mut().write_all(message).unwrap();
assert_eq!(read_response(&mut stream, false), (200, vec![]));
}
for (path, status) in [("/echo", 200), ("/fail", 500), ("/echo", 200)] {
write!(stream.get_mut(), "POST {path} HTTP/1.1\r\nHost: fixture.test\r\nContent-Length: 3\r\n\r\nabc").unwrap();
let (actual, body) = read_response(&mut stream, false);
assert_eq!(actual, status);
if status == 200 { assert_eq!(body, b"abc"); }
}
stream.get_mut().write_all(b"HEAD /echo HTTP/1.1\r\nHost: fixture.test\r\nContent-Length: 3\r\n\r\nabc").unwrap();
assert_eq!(read_response(&mut stream, true), (200, vec![]));
stream.get_mut().write_all(b"POST /echo HTTP/1.1\r\nHost: fixture.test\r\nConnection: close\r\nTransfer-Encoding: chunked\r\n\r\n3\r\none\r\n3\r\ntwo\r\n0\r\n\r\n").unwrap();
assert_eq!(read_response(&mut stream, false), (200, b"onetwo".to_vec()));
let mut tail = Vec::new();
stream.read_to_end(&mut tail).unwrap();
assert!(tail.is_empty());
}).await.unwrap();
listener.close().unwrap();
server.wait().await.unwrap().unwrap();
}).unwrap();
}
+264
View File
@@ -0,0 +1,264 @@
use super::*;
use dever_runtime::{http::PoolLimits, tls};
fn message() -> Request {
Request {
method: "POST".into(),
target: "/echo".into(),
headers: vec![],
body: b"pooled".to_vec().into(),
}
}
#[test]
fn origins_reject_invalid_ports_before_connecting() {
runtime()
.run(async |scope| {
for host in ["127.0.0.1", "[::1]", "localhost"] {
for port in ["99999", "65536", "abc", "-1", ""] {
for scheme in ["http", "https"] {
assert!(
http::client(
&format!("{scheme}://{host}:{port}"),
&tls::system(),
&Limits::default(),
&PoolLimits::default(),
scope
)
.is_err()
);
}
for scheme in ["ws", "wss"] {
let error = http::websocket::open(
&format!("{scheme}://{host}:{port}/"),
&tls::system(),
&http::websocket::Limits::default(),
scope,
)
.await
.unwrap_err();
assert!(
error.contains("port") || error.contains("invalid WebSocket URL"),
"{error}"
);
}
}
for suffix in ["", ":80", ":443", ":65535"] {
let client = http::client(
&format!("http://{host}{suffix}"),
&tls::system(),
&Limits::default(),
&PoolLimits::default(),
scope,
)
.unwrap();
client.close().await.unwrap();
}
}
})
.unwrap();
}
#[test]
fn pool_bounds_http2_concurrency_and_cancels_active_requests() {
use std::sync::atomic::{AtomicUsize, Ordering};
runtime()
.run(async |scope| {
let listener = net::listen("127.0.0.1", 0, scope).await.unwrap();
let port = listener.port().unwrap();
let limits = Limits {
connections: 1,
http2: Some(http::Http2Limits {
streams: 2,
..Default::default()
}),
..Default::default()
};
let active = Arc::new(AtomicUsize::new(0));
let peak = Arc::new(AtomicUsize::new(0));
let peak_read = peak.clone();
let incoming = listener.clone();
let server_limits = limits.clone();
let server = scope
.spawn(move |child| async move {
http::serve::<_, _, false>(
&incoming,
&server_limits,
&child,
move |request, _| {
let active = active.clone();
let peak = peak.clone();
async move {
let count = active.fetch_add(1, Ordering::SeqCst) + 1;
peak.fetch_max(count, Ordering::SeqCst);
tokio::time::sleep(Duration::from_millis(20)).await;
active.fetch_sub(1, Ordering::SeqCst);
Ok(Response {
status: 200,
headers: vec![],
body: request.body,
})
}
},
)
.await
})
.unwrap();
let client = http::client(
&format!("http://127.0.0.1:{port}"),
&tls::system(),
&limits,
&PoolLimits::default(),
scope,
)
.unwrap();
let replies =
futures_util::future::join_all((0..8).map(|_| client.request(message()))).await;
for response in replies {
assert_eq!(response.unwrap().body.as_ref(), b"pooled");
}
assert_eq!(peak_read.load(Ordering::SeqCst), 2);
let alias = client.clone();
let pending = scope
.spawn_leaf(move || async move { alias.request(message()).await })
.unwrap();
tokio::time::sleep(Duration::from_millis(5)).await;
client.close().await.unwrap();
assert!(pending.wait().await.unwrap().is_err());
listener.close().unwrap();
server.wait().await.unwrap().unwrap();
})
.unwrap();
}
#[test]
fn pool_http1_http2_tls_reuse_close_and_scope_ownership() {
for protocol in [false, true] {
for secure in [false, true] {
let escaped = runtime()
.run(async |scope| {
let listener = net::listen("127.0.0.1", 0, scope).await.unwrap();
let port = listener.port().unwrap();
let limits = Limits {
connections: 1,
http2: protocol.then(Default::default),
..Limits::default()
};
let incoming = listener.clone();
let server_limits = limits.clone();
let server = scope
.spawn(move |child| async move {
if secure {
let config =
tls::server(super::tls::CERTIFICATE, super::tls::KEY).unwrap();
http::serve_tls::<_, _, false>(
&incoming,
&server_limits,
&child,
&config,
route,
)
.await
} else {
http::serve::<_, _, false>(&incoming, &server_limits, &child, route)
.await
}
})
.unwrap();
let origin = format!(
"{}://localhost:{port}",
if secure { "https" } else { "http" }
);
let config = tls::client(super::tls::ROOT).unwrap();
let client =
http::client(&origin, &config, &limits, &PoolLimits::default(), scope)
.unwrap();
for _ in 0..3 {
assert_eq!(
client.request(message()).await.unwrap().body.as_ref(),
b"pooled"
);
}
let mut changed = message();
changed.headers.push(Header {
name: "Host".into(),
value: b"other.test".to_vec().into(),
});
assert!(client.request(changed).await.is_err());
client.close().await.unwrap();
client.close().await.unwrap();
assert!(client.request(message()).await.is_err());
listener.close().unwrap();
server.wait().await.unwrap().unwrap();
http::client(&origin, &config, &limits, &PoolLimits::default(), scope).unwrap()
})
.unwrap();
runtime()
.run(async |_| {
assert!(
escaped
.request(message())
.await
.unwrap_err()
.contains("closed")
);
escaped.close().await.unwrap();
})
.unwrap();
}
}
}
#[test]
fn pool_reuses_one_tcp_connection_and_closes_it_before_returning() {
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
let port = listener.local_addr().unwrap().port();
let peer = std::thread::spawn(move || {
let (stream, _) = listener.accept().unwrap();
stream
.set_read_timeout(Some(Duration::from_secs(5)))
.unwrap();
let mut stream = BufReader::new(stream);
for _ in 0..3 {
let mut size = 0;
loop {
let mut line = String::new();
assert!(stream.read_line(&mut line).unwrap() > 0);
if line == "\r\n" {
break;
}
if let Some(length) = line.to_ascii_lowercase().strip_prefix("content-length:") {
size = length.trim().parse().unwrap();
}
}
let mut body = vec![0; size];
stream.read_exact(&mut body).unwrap();
assert_eq!(body, b"pooled");
stream
.get_mut()
.write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\nok")
.unwrap();
}
assert_eq!(stream.read(&mut [0]).unwrap(), 0);
});
runtime()
.run(async |scope| {
let client = http::client(
&format!("http://127.0.0.1:{port}"),
&tls::system(),
&Limits::default(),
&PoolLimits::default(),
scope,
)
.unwrap();
for _ in 0..3 {
assert_eq!(
client.request(message()).await.unwrap().body.as_ref(),
b"ok"
);
}
client.close().await.unwrap();
})
.unwrap();
peer.join().unwrap();
}
@@ -0,0 +1,239 @@
use super::*;
#[test]
fn headers_return_before_body_and_download_ignores_buffered_total_limit() {
let (opened, permit) = std::sync::mpsc::channel();
let (port, server) = peer(move |listener| {
let mut socket = accept(&listener);
headers(&mut socket);
socket
.get_mut()
.write_all(
b"HTTP/1.1 200 OK\r\nContent-Length: 100003\r\nX-Test: one\r\nX-Test: two\r\n\r\n",
)
.unwrap();
permit.recv_timeout(Duration::from_secs(2)).unwrap();
socket.get_mut().write_all(&vec![b'x'; 100003]).unwrap();
assert_eq!(socket.read(&mut [0]).unwrap(), 0);
});
runtime()
.run(async |scope| {
let client = client(
scope,
port,
&Limits {
body_bytes: 1,
..Default::default()
},
&PoolLimits {
chunk_bytes: 7,
..Default::default()
},
);
let response = client.open(message(), event).await.unwrap();
assert_eq!(
response
.headers
.iter()
.filter(|header| header.name == "x-test")
.count(),
2
);
opened.send(()).unwrap();
let body = collect(&response.body, 7).await;
assert_eq!(body, vec![b'x'; 100003]);
assert!(response.body.next().await.unwrap().is_none());
client.close().await.unwrap();
})
.unwrap();
server.join().unwrap();
}
#[test]
fn unread_body_holds_slot_and_idle_clock_starts_after_eof() {
let (port, server) = peer(|listener| {
let mut socket = accept(&listener);
for _ in 0..2 {
headers(&mut socket);
socket
.get_mut()
.write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 6\r\n\r\nabcdef")
.unwrap();
}
let start = std::time::Instant::now();
assert_eq!(socket.read(&mut [0]).unwrap(), 0);
assert!(start.elapsed() < Duration::from_secs(1));
});
runtime()
.run(async |scope| {
let client = client(
scope,
port,
&Limits {
connections: 1,
timeout_ms: 80,
..Default::default()
},
&PoolLimits {
idle_ms: 20,
chunk_bytes: 2,
..Default::default()
},
);
let response = client.open(message(), event).await.unwrap();
assert!(
client
.open(message(), event)
.await
.unwrap_err()
.contains("timed out")
);
assert_eq!(collect(&response.body, 2).await, b"abcdef");
assert_eq!(
client.request(message()).await.unwrap().body.as_ref(),
b"abcdef"
);
tokio::time::sleep(Duration::from_millis(80)).await;
client.close().await.unwrap();
})
.unwrap();
server.join().unwrap();
}
#[test]
fn early_close_of_buffered_frame_closes_http1_and_next_request_reconnects() {
let (port, server) = peer(|listener| {
let mut first = accept(&listener);
headers(&mut first);
first
.get_mut()
.write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 6\r\n\r\nabcdef")
.unwrap();
assert_eq!(first.read(&mut [0]).unwrap(), 0);
let mut second = accept(&listener);
headers(&mut second);
second
.get_mut()
.write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\nok")
.unwrap();
});
runtime()
.run(async |scope| {
let client = client(
scope,
port,
&Limits {
connections: 1,
..Default::default()
},
&PoolLimits::default(),
);
let response = client.open(message(), event).await.unwrap();
let alias = response.body.clone();
tokio::time::sleep(Duration::from_millis(30)).await;
response.body.close();
assert!(alias.next().await.unwrap().is_none());
assert_eq!(
client.request(message()).await.unwrap().body.as_ref(),
b"ok"
);
client.close().await.unwrap();
})
.unwrap();
server.join().unwrap();
}
#[test]
fn read_timeout_and_protocol_failure_emit_one_error_then_eof() {
for body in [
b"".as_slice(),
b"3\r\nabc\r\n0\r\nX-End: bad\r\n\r\n",
b"invalid\r\n",
] {
let (port, server) = peer(move |listener| {
let mut socket = accept(&listener);
headers(&mut socket);
socket
.get_mut()
.write_all(b"HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\n\r\n")
.unwrap();
socket.get_mut().write_all(body).unwrap();
let _ = socket.read(&mut [0]);
});
runtime()
.run(async |scope| {
let client = client(
scope,
port,
&Limits::default(),
&PoolLimits {
read_ms: 40,
..Default::default()
},
);
let response = client.open(message(), event).await.unwrap();
let mut failures = 0;
while let Some(item) = response.body.next().await.unwrap() {
failures += usize::from(item.is_err());
}
assert_eq!(failures, 1);
assert!(response.body.next().await.unwrap().is_none());
client.close().await.unwrap();
})
.unwrap();
server.join().unwrap();
}
}
#[test]
fn cancelling_consumer_closes_body_and_pool_close_wakes_body_and_slot_waiters() {
for cancel in [true, false] {
let (port, server) = peer(|listener| {
let mut socket = accept(&listener);
headers(&mut socket);
socket
.get_mut()
.write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 20\r\n\r\n")
.unwrap();
assert_eq!(socket.read(&mut [0]).unwrap(), 0);
});
runtime()
.run(async |scope| {
let client = client(
scope,
port,
&Limits {
connections: 1,
..Default::default()
},
&PoolLimits::default(),
);
let response = client.open(message(), event).await.unwrap();
let alias = response.body.clone();
if cancel {
let consume = async {
let _owner = alias.close_on_drop();
alias.next().await
};
assert!(
tokio::time::timeout(Duration::from_millis(20), consume)
.await
.is_err()
);
assert!(response.body.next().await.unwrap().is_none());
} else {
let (item, waiting, ()) =
tokio::join!(response.body.next(), client.open(message(), event), async {
tokio::time::sleep(Duration::from_millis(20)).await;
client.close().await.unwrap();
});
assert!(item.unwrap().unwrap().unwrap_err().contains("closed"));
assert!(waiting.unwrap_err().contains("closed"));
assert!(response.body.next().await.unwrap().is_none());
}
client.close().await.unwrap();
})
.unwrap();
server.join().unwrap();
}
}
+95
View File
@@ -0,0 +1,95 @@
//! 独立回环协议对端验证客户端按需拉取与连接所有权。
use super::*;
use bytes::Bytes;
use dever_runtime::{
async_stream::AsyncStream,
http::{HttpClient, PoolLimits, StreamRequest},
io::Error,
tls,
};
use std::net::TcpListener;
mod download;
mod protocol;
mod upload;
type Event = Result<Bytes, Error>;
fn event(value: Event) -> Event {
value
}
fn message() -> Request {
Request {
method: "POST".into(),
target: "/stream".into(),
headers: vec![],
body: bytes::Bytes::new(),
}
}
fn client(scope: &Scope<'_>, port: u16, limits: &Limits, pool: &PoolLimits) -> HttpClient {
http::client(
&format!("http://127.0.0.1:{port}"),
&tls::system(),
limits,
pool,
scope,
)
.unwrap()
}
fn peer(work: impl FnOnce(TcpListener) + Send + 'static) -> (u16, std::thread::JoinHandle<()>) {
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
let port = listener.local_addr().unwrap().port();
(port, std::thread::spawn(move || work(listener)))
}
fn accept(listener: &TcpListener) -> BufReader<TcpStream> {
listener.set_nonblocking(true).unwrap();
let start = std::time::Instant::now();
let socket = loop {
match listener.accept() {
Ok((socket, _)) => break socket,
Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => {
assert!(
start.elapsed() < Duration::from_secs(5),
"peer accept timed out"
);
std::thread::sleep(Duration::from_millis(2));
}
Err(error) => panic!("{error}"),
}
};
socket
.set_read_timeout(Some(Duration::from_secs(5)))
.unwrap();
socket
.set_write_timeout(Some(Duration::from_secs(5)))
.unwrap();
BufReader::new(socket)
}
fn headers(socket: &mut BufReader<TcpStream>) -> String {
let mut headers = String::new();
loop {
let mut line = String::new();
assert!(socket.read_line(&mut line).unwrap() > 0);
if line == "\r\n" {
return headers;
}
headers.push_str(&line);
}
}
async fn collect(body: &AsyncStream<Event>, chunk: usize) -> Vec<u8> {
let _guard = body.close_on_drop();
let mut bytes = vec![];
while let Some(event) = body.next().await.unwrap() {
let part = event.unwrap();
assert!(!part.is_empty() && part.len() <= chunk);
bytes.extend_from_slice(&part);
}
bytes
}
@@ -0,0 +1,173 @@
use super::*;
#[test]
fn http1_http2_tls_streams_roundtrip_and_buffered_limits_remain_enforced() {
for http2 in [false, true] {
for secure in [false, true] {
runtime()
.run(async |scope| {
let listener = net::listen("127.0.0.1", 0, scope).await.unwrap();
let port = listener.port().unwrap();
let limits = Limits {
connections: 1,
http2: http2.then(Default::default),
..Default::default()
};
let incoming = listener.clone();
let protocol = limits.clone();
let server = scope
.spawn(move |child| async move {
if secure {
let tls = tls::server(
super::super::tls::CERTIFICATE,
super::super::tls::KEY,
)
.unwrap();
http::serve_tls::<_, _, false>(
&incoming, &protocol, &child, &tls, route,
)
.await
} else {
http::serve::<_, _, false>(&incoming, &protocol, &child, route)
.await
}
})
.unwrap();
let origin = format!(
"{}://localhost:{port}",
if secure { "https" } else { "http" }
);
let trust = tls::client(super::super::tls::ROOT).unwrap();
let client = http::client(
&origin,
&trust,
&Limits {
body_bytes: 1,
..limits
},
&PoolLimits {
chunk_bytes: 3,
..Default::default()
},
scope,
)
.unwrap();
let body = AsyncStream::values([
Ok(Bytes::from_static(b"abc")),
Ok(Bytes::from_static(b"def")),
]);
let request = StreamRequest {
method: "POST".into(),
target: "/echo".into(),
headers: vec![],
body,
};
let response = client.open_stream(request, event, event).await.unwrap();
assert_eq!(collect(&response.body, 3).await, b"abcdef");
let mut request = message();
request.body = b"ab".to_vec().into();
assert!(
client
.open(request.clone(), event)
.await
.unwrap_err()
.contains("byte limit")
);
assert!(
client
.request(request)
.await
.unwrap_err()
.contains("byte limit")
);
client.close().await.unwrap();
listener.close().unwrap();
server.wait().await.unwrap().unwrap();
})
.unwrap();
}
}
}
#[test]
fn http2_closing_one_response_preserves_neighbor_and_releases_only_its_slot() {
runtime()
.run(async |scope| {
let listener = net::listen("127.0.0.1", 0, scope).await.unwrap();
let port = listener.port().unwrap();
let limits = Limits {
connections: 1,
http2: Some(http::Http2Limits {
streams: 2,
..Default::default()
}),
..Default::default()
};
let incoming = listener.clone();
let protocol = limits.clone();
let server = scope
.spawn(move |child| async move {
http::serve_live(
&incoming,
&protocol,
&http::LiveLimits::default(),
&child,
|request: Request, reply: http::HttpReply, _| async move {
if request.target == "/ordinary" {
return reply
.respond(Response {
status: 200,
headers: vec![],
body: b"ok".to_vec().into(),
})
.await
.map_err(|error| error.to_string());
}
reply
.start(200, vec![])
.await
.map_err(|error| error.to_string())?;
reply
.write(b"first")
.await
.map_err(|error| error.to_string())?;
tokio::time::sleep(Duration::from_millis(150)).await;
reply
.write(b"last")
.await
.map_err(|error| error.to_string())
},
)
.await
})
.unwrap();
let client = client(
scope,
port as u16,
&limits,
&PoolLimits {
idle_ms: 20,
..Default::default()
},
);
let first = client.open(message(), event).await.unwrap();
let second = client.open(message(), event).await.unwrap();
assert_eq!(
first.body.next().await.unwrap().unwrap().unwrap(),
b"first"[..]
);
assert_eq!(
second.body.next().await.unwrap().unwrap().unwrap(),
b"first"[..]
);
first.body.close();
let mut ordinary = message();
ordinary.target = "/ordinary".into();
assert_eq!(client.request(ordinary).await.unwrap().body.as_ref(), b"ok");
assert_eq!(collect(&second.body, 65536).await, b"last");
client.close().await.unwrap();
listener.close().unwrap();
server.wait().await.unwrap().unwrap();
})
.unwrap();
}
+163
View File
@@ -0,0 +1,163 @@
use super::*;
fn chunk(socket: &mut BufReader<TcpStream>) -> Vec<u8> {
let mut line = String::new();
assert!(socket.read_line(&mut line).unwrap() > 0);
let count = usize::from_str_radix(line.trim(), 16).unwrap();
let mut body = vec![0; count];
socket.read_exact(&mut body).unwrap();
let mut ending = [0; 2];
socket.read_exact(&mut ending).unwrap();
assert_eq!(&ending, b"\r\n");
body
}
fn request(body: AsyncStream<Event>) -> StreamRequest<Event> {
StreamRequest {
method: "POST".into(),
target: "/stream".into(),
headers: vec![],
body,
}
}
#[test]
fn duplex_upload_sends_first_chunk_before_source_eof_and_returns_response_early() {
let (port, server) = peer(|listener| {
let mut socket = accept(&listener);
let head = headers(&mut socket).to_ascii_lowercase();
assert!(head.contains("transfer-encoding: chunked"));
assert!(!head.contains("content-length:"));
assert_eq!(chunk(&mut socket), b"abc");
socket
.get_mut()
.write_all(b"HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\n\r\n3\r\nabc\r\n")
.unwrap();
assert_eq!(chunk(&mut socket), b"def");
assert!(chunk(&mut socket).is_empty());
socket
.get_mut()
.write_all(b"3\r\ndef\r\n0\r\n\r\n")
.unwrap();
assert_eq!(socket.read(&mut [0]).unwrap(), 0);
});
runtime()
.run(async |scope| {
let client = client(
scope,
port,
&Limits {
body_bytes: 1,
..Default::default()
},
&PoolLimits {
chunk_bytes: 3,
..Default::default()
},
);
let proceed = Arc::new(tokio::sync::Notify::new());
let source = proceed.clone();
let body = AsyncStream::new(futures_util::stream::unfold(0, move |index| {
let source = source.clone();
async move {
match index {
0 => Some((Ok(Ok(Bytes::from_static(b"abc"))), 1)),
1 => {
source.notified().await;
Some((Ok(Ok(Bytes::from_static(b"def"))), 2))
}
_ => None,
}
}
}));
let alias = body.clone();
let response = client
.open_stream(request(body), event, event)
.await
.unwrap();
assert_eq!(
response.body.next().await.unwrap().unwrap().unwrap(),
b"abc"[..]
);
proceed.notify_one();
assert_eq!(collect(&response.body, 3).await, b"def");
assert!(alias.next().await.unwrap().is_none());
client.close().await.unwrap();
})
.unwrap();
server.join().unwrap();
}
#[test]
fn upload_rejects_oversized_chunks_source_errors_and_stalled_reads() {
for mode in 0..3 {
let (port, server) = peer(|listener| {
let mut socket = accept(&listener);
let mut request = Vec::new();
socket.read_to_end(&mut request).unwrap();
assert!(!request.windows(8).any(|window| window == b"oversize"));
});
runtime()
.run(async |scope| {
let client = client(
scope,
port,
&Limits {
timeout_ms: 1000,
..Default::default()
},
&PoolLimits {
chunk_bytes: 3,
read_ms: 40,
..Default::default()
},
);
let body = match mode {
0 => AsyncStream::values([Ok(Bytes::from_static(b"oversize"))]),
1 => AsyncStream::values([Err("source failed".into())]),
_ => AsyncStream::new(futures_util::stream::pending()),
};
let alias = body.clone();
assert!(
client
.open_stream(request(body), event, event)
.await
.is_err()
);
assert!(alias.next().await.unwrap().is_none());
client.close().await.unwrap();
})
.unwrap();
server.join().unwrap();
}
}
#[test]
fn cancelling_request_before_headers_closes_upload_source_and_socket() {
let (port, server) = peer(|listener| {
let mut socket = accept(&listener);
let mut bytes = vec![];
socket.read_to_end(&mut bytes).unwrap();
});
runtime()
.run(async |scope| {
let client = client(scope, port, &Limits::default(), &PoolLimits::default());
let body = AsyncStream::<Event>::new(futures_util::stream::pending());
assert!(
tokio::time::timeout(
Duration::from_millis(40),
client.open_stream(request(body.clone()), event, event)
)
.await
.is_err()
);
tokio::time::timeout(Duration::from_secs(1), body.next())
.await
.unwrap()
.unwrap();
assert!(body.next().await.unwrap().is_none());
client.close().await.unwrap();
})
.unwrap();
server.join().unwrap();
}
+104
View File
@@ -0,0 +1,104 @@
use super::*;
use http_body_util::{BodyExt, Full};
use hyper_util::rt::{TokioExecutor, TokioIo};
pub(super) const ROOT: &[u8] = include_bytes!("../../../dever-tests/fixtures/tls/root.pem");
pub(super) const CERTIFICATE: &[u8] =
include_bytes!("../../../dever-tests/fixtures/tls/server.pem");
pub(super) const KEY: &[u8] = include_bytes!("../../../dever-tests/fixtures/tls/server-key.pem");
#[test]
fn tls_verifies_ca_hostname_alpn_and_keeps_bad_handshakes_isolated() {
runtime()
.run(async |scope| {
for http2 in [false, true] {
let listener = net::listen("127.0.0.1", 0, scope).await.unwrap();
let port = listener.port().unwrap();
let incoming = listener.clone();
let server = scope
.spawn(move |child| async move {
let tls = dever_runtime::tls::server(CERTIFICATE, KEY).unwrap();
let limits = Limits {
http2: http2.then(http::Http2Limits::default),
timeout_ms: 1000,
..Limits::default()
};
http::serve_tls::<_, _, false>(&incoming, &limits, &child, &tls, route)
.await
})
.unwrap();
let custom = dever_runtime::tls::client(ROOT).unwrap();
for (tls, host, protocol) in [
(dever_runtime::tls::system(), "localhost", http2),
(custom.clone(), "wrong.test", http2),
(custom.clone(), "localhost", !http2),
] {
let stream = tokio::net::TcpStream::connect(("127.0.0.1", port as u16))
.await
.unwrap();
assert!(tls.connect(host, stream, protocol).await.is_err());
}
let stream = tokio::net::TcpStream::connect(("127.0.0.1", port as u16))
.await
.unwrap();
let stream = custom.connect("localhost", stream, http2).await.unwrap();
let message = hyper::Request::builder()
.method("POST")
.uri(if http2 {
"https://localhost/echo"
} else {
"/echo"
})
.header("Host", "localhost")
.body(Full::new(bytes::Bytes::from_static(b"verified TLS")))
.unwrap();
let response = if http2 {
let (mut sender, connection) =
hyper::client::conn::http2::Builder::new(TokioExecutor::new())
.handshake(TokioIo::new(stream))
.await
.unwrap();
let driver = scope.spawn_leaf(move || connection).unwrap();
let response = sender.send_request(message).await.unwrap();
let body = response.into_body().collect().await.unwrap().to_bytes();
drop(sender);
driver.stop().await.unwrap();
body
} else {
let (mut sender, connection) =
hyper::client::conn::http1::handshake(TokioIo::new(stream))
.await
.unwrap();
let driver = scope.spawn_leaf(move || connection).unwrap();
let response = sender.send_request(message).await.unwrap();
let body = response.into_body().collect().await.unwrap().to_bytes();
drop(sender);
driver.stop().await.unwrap();
body
};
assert_eq!(response, b"verified TLS"[..]);
listener.close().unwrap();
server.wait().await.unwrap().unwrap();
}
})
.unwrap();
}
#[test]
fn tls_configuration_rejects_invalid_inputs_without_exposing_them() {
assert!(
dever_runtime::tls::client(b"private-input")
.unwrap_err()
.contains("empty")
);
let error = dever_runtime::tls::server(CERTIFICATE, b"private-key-input").unwrap_err();
assert!(!error.contains("private-key-input"));
assert!(dever_runtime::tls::server(ROOT, KEY).is_err());
let mut keys = KEY.to_vec();
keys.extend_from_slice(KEY);
assert!(
dever_runtime::tls::server(CERTIFICATE, &keys)
.unwrap_err()
.contains("one private key")
);
}
+60
View File
@@ -0,0 +1,60 @@
use super::Transport;
use std::{io, time::Duration};
use tokio::io::{AsyncReadExt, AsyncWrite, AsyncWriteExt};
#[test]
fn vectored_write_preserves_parts_and_stall_timeout() {
tokio::runtime::Builder::new_current_thread()
.enable_time()
.build()
.unwrap()
.block_on(async {
let (writer, mut reader) = tokio::io::duplex(8);
let mut transport = Transport::new(writer, Duration::from_millis(10));
assert!(transport.is_write_vectored());
let parts = [io::IoSlice::new(b"head"), io::IoSlice::new(b"body")];
assert_eq!(transport.write_vectored(&parts).await.unwrap(), 8);
let error =
tokio::time::timeout(Duration::from_secs(1), transport.write_vectored(&parts))
.await
.unwrap()
.unwrap_err();
assert_eq!(error.kind(), io::ErrorKind::TimedOut);
let mut received = [0; 8];
reader.read_exact(&mut received).await.unwrap();
assert_eq!(&received, b"headbody");
});
}
#[test]
fn vectored_write_preserves_partial_progress_and_large_bodies() {
tokio::runtime::Builder::new_current_thread()
.enable_time()
.build()
.unwrap()
.block_on(async {
for length in [0, 1, 31, 4095, 4096, 4097, 65536] {
let (writer, mut reader) = tokio::io::duplex(17);
let mut transport = Transport::new(writer, Duration::from_secs(2));
let header = b"header";
let body: Vec<u8> = (0..length).map(|index| (index % 251) as u8).collect();
let expected = [header.as_slice(), body.as_slice()].concat();
let send = async {
let mut parts = [io::IoSlice::new(header), io::IoSlice::new(&body)];
let mut remaining = &mut parts[..];
while !remaining.is_empty() {
let count = transport.write_vectored(remaining).await.unwrap();
assert!(count > 0);
io::IoSlice::advance_slices(&mut remaining, count);
}
transport.shutdown().await.unwrap();
};
let receive = async {
let mut received = Vec::new();
reader.read_to_end(&mut received).await.unwrap();
assert_eq!(received, expected);
};
tokio::join!(send, receive);
}
});
}
+61
View File
@@ -0,0 +1,61 @@
use super::*;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
#[test]
fn pooled_socket_preserves_vectored_writes_and_idle_expiry() {
tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.unwrap()
.block_on(async {
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let origin = Arc::new(
Origin::parse(&format!("http://{}", listener.local_addr().unwrap())).unwrap(),
);
let limits = crate::http::Limits {
timeout_ms: 100,
..Default::default()
}
.check()
.unwrap();
let mut connector = Connector::new(
origin.clone(),
&crate::tls::system(),
&limits,
Duration::from_millis(100),
);
let mut socket = connector
.call(origin.uri.clone())
.await
.unwrap()
.into_inner();
let (mut peer, _) = listener.accept().await.unwrap();
assert!(socket.is_write_vectored());
let body = vec![7; 65536];
let send = async {
let mut parts = [io::IoSlice::new(b"head"), io::IoSlice::new(&body)];
let mut remaining = &mut parts[..];
while !remaining.is_empty() {
let count = socket.write_vectored(remaining).await.unwrap();
assert_ne!(count, 0);
io::IoSlice::advance_slices(&mut remaining, count);
}
};
let receive = async {
let mut received = vec![0; 4 + body.len()];
peer.read_exact(&mut received).await.unwrap();
assert_eq!(&received[..4], b"head");
assert_eq!(&received[4..], body);
};
tokio::join!(send, receive);
tokio::time::sleep(Duration::from_millis(150)).await;
assert_eq!(
socket
.write_vectored(&[io::IoSlice::new(b"expired")])
.await
.unwrap_err()
.kind(),
io::ErrorKind::TimedOut
);
});
}
@@ -0,0 +1,325 @@
use super::*;
use futures_util::{SinkExt, StreamExt};
use std::sync::atomic::{AtomicUsize, Ordering};
use tokio::sync::oneshot;
#[test]
fn websocket_client_scope_ends_all_aliases_and_timeout_stream_fails_once() {
runtime()
.run(async |scope| {
let (listener, server) = server(scope).await;
let port = listener.port().unwrap();
let owner = scope
.spawn(move |child| async move {
ws::connect("127.0.0.1", port, "/", &limits(), &child)
.await
.unwrap()
})
.unwrap();
let escaped = owner.wait().await.unwrap();
assert!(escaped.send(Message::Text("closed".into())).await.is_err());
let socket = ws::connect(
"127.0.0.1",
port,
"/",
&ws::Limits {
idle_ms: 25,
..limits()
},
scope,
)
.await
.unwrap();
let stream = socket.messages(|message| message);
let alias = stream.clone();
let failure = stream.next().await.unwrap().unwrap().unwrap_err();
assert!(failure.contains("timed out"));
assert!(alias.next().await.unwrap().is_none());
assert!(socket.send(Message::Text("closed".into())).await.is_err());
listener.close().unwrap();
server.wait().await.unwrap().unwrap();
})
.unwrap();
}
#[test]
fn websocket_listener_drain_keeps_session_and_parent_stop_cleans_handler() {
runtime()
.run(async |scope| {
let (listener, mut server) = server(scope).await;
let socket = ws::connect("127.0.0.1", listener.port().unwrap(), "/", &limits(), scope)
.await
.unwrap();
listener.close().unwrap();
tokio::select! {
_ = &mut server => panic!("server must retain its upgraded session"),
() = tokio::time::sleep(Duration::from_millis(25)) => (),
}
socket
.send(Message::Text("after listener close".into()))
.await
.unwrap();
assert_eq!(
socket.receive().await.unwrap(),
Some(Message::Text("after listener close".into()))
);
socket.close(1000, "").await.unwrap();
server.wait().await.unwrap().unwrap();
let listener = net::listen("127.0.0.1", 0, scope).await.unwrap();
let incoming = listener.clone();
let active = Arc::new(AtomicUsize::new(0));
let running = active.clone();
let (sender, receiver) = oneshot::channel::<WebSocket>();
let sender = Arc::new(std::sync::Mutex::new(Some(sender)));
let server = scope
.spawn(move |child| async move {
http::serve_live(
&incoming,
&Limits::default(),
&http::LiveLimits::default(),
&child,
move |_, reply, scope| {
let sender = sender.clone();
let running = running.clone();
async move {
let socket = ws::accept(&reply, &limits(), &scope).await.unwrap();
running.fetch_add(1, Ordering::SeqCst);
let _running = Running(running);
let _ = sender.lock().unwrap().take().unwrap().send(socket.clone());
let _ = socket.receive().await;
Ok(())
}
},
)
.await
})
.unwrap();
let socket = ws::connect("127.0.0.1", listener.port().unwrap(), "/", &limits(), scope)
.await
.unwrap();
let escaped = receiver.await.unwrap();
server.stop().await.unwrap();
assert_eq!(active.load(Ordering::SeqCst), 0);
assert!(escaped.send(Message::Text("gone".into())).await.is_err());
assert!(socket.receive().await.is_err());
listener.close().unwrap();
})
.unwrap();
}
struct Running(Arc<AtomicUsize>);
impl Drop for Running {
fn drop(&mut self) {
self.0.fetch_sub(1, Ordering::SeqCst);
}
}
#[test]
fn websocket_owner_stop_wakes_receive_in_another_task() {
runtime()
.run(async |scope| {
let (listener, server) = server(scope).await;
let port = listener.port().unwrap();
let (sender, receiver) = oneshot::channel();
let owner = scope
.spawn(move |child| async move {
let socket = ws::connect("127.0.0.1", port, "/", &limits(), &child)
.await
.unwrap();
sender.send(socket).unwrap();
std::future::pending::<()>().await;
})
.unwrap();
let socket = receiver.await.unwrap();
let (started, ready) = oneshot::channel();
let reader = scope
.spawn_leaf(move || async move {
let receiving = socket.receive();
tokio::pin!(receiving);
assert!(futures_util::poll!(&mut receiving).is_pending());
started.send(()).unwrap();
tokio::time::timeout(Duration::from_millis(200), receiving)
.await
.unwrap()
.unwrap_err()
})
.unwrap();
ready.await.unwrap();
owner.stop().await.unwrap();
assert!(reader.wait().await.unwrap().contains("WebSocket is closed"));
listener.close().unwrap();
server.wait().await.unwrap().unwrap();
})
.unwrap();
}
#[test]
fn websocket_closing_message_stream_preserves_independent_socket_alias() {
runtime()
.run(async |scope| {
let (listener, server) = server(scope).await;
let socket = ws::connect("127.0.0.1", listener.port().unwrap(), "/", &limits(), scope)
.await
.unwrap();
let stream = socket.messages(|message| message);
stream.close();
assert!(stream.next().await.unwrap().is_none());
socket
.send(Message::Binary(vec![1, 2].into()))
.await
.unwrap();
assert_eq!(
socket.receive().await.unwrap(),
Some(Message::Binary(vec![1, 2].into()))
);
socket.close(1000, "").await.unwrap();
listener.close().unwrap();
server.wait().await.unwrap().unwrap();
})
.unwrap();
}
#[test]
fn websocket_cancelled_writer_closes_only_after_it_owns_the_direction() {
runtime()
.run(async |scope| {
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let port = listener.local_addr().unwrap().port();
let (started, receiving) = oneshot::channel();
let (release, released) = oneshot::channel();
let peer = scope
.spawn_leaf(move || async move {
let (stream, _) = listener.accept().await.unwrap();
let wire = tokio_tungstenite::accept_async(stream).await.unwrap();
wire.get_ref().readable().await.unwrap();
let _ = started.send(());
let _ = released.await;
})
.unwrap();
let socket = ws::connect(
"127.0.0.1",
i64::from(port),
"/",
&ws::Limits {
message_bytes: 16 * 1024 * 1024,
write_ms: 1000,
..limits()
},
scope,
)
.await
.unwrap();
let sender = socket.clone();
let mut writing = scope
.spawn_leaf(move || async move {
sender
.send(Message::Binary(vec![1; 16 * 1024 * 1024].into()))
.await
})
.unwrap();
receiving.await.unwrap();
let waiting = socket.clone();
let waiter = scope
.spawn_leaf(
move || async move { waiting.send(Message::Text("queued".into())).await },
)
.unwrap();
tokio::time::sleep(Duration::from_millis(10)).await;
waiter.stop().await.unwrap();
assert!(
tokio::time::timeout(Duration::from_millis(10), &mut writing)
.await
.is_err()
);
writing.stop().await.unwrap();
assert!(socket.send(Message::Text("closed".into())).await.is_err());
release.send(()).unwrap();
peer.wait().await.unwrap();
})
.unwrap();
}
#[test]
fn websocket_close_keeps_the_handshake_when_a_reader_is_waiting() {
runtime()
.run(async |scope| {
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let port = listener.local_addr().unwrap().port();
let (replied, reply) = oneshot::channel();
let peer = scope
.spawn_leaf(move || async move {
let (stream, _) = listener.accept().await.unwrap();
let mut wire = tokio_tungstenite::accept_async(stream).await.unwrap();
assert!(matches!(
wire.next().await,
Some(Ok(tokio_tungstenite::tungstenite::Message::Close(_)))
));
wire.flush().await.unwrap();
replied.send(()).unwrap();
})
.unwrap();
let socket = ws::connect("127.0.0.1", i64::from(port), "/", &limits(), scope)
.await
.unwrap();
let receiving = socket.receive();
tokio::pin!(receiving);
assert!(futures_util::poll!(&mut receiving).is_pending());
let closing = socket.close(1000, "");
tokio::pin!(closing);
assert!(futures_util::poll!(&mut closing).is_pending());
reply.await.unwrap();
tokio::time::sleep(Duration::from_millis(25)).await;
let received = receiving.await;
closing.await.unwrap();
assert!(received.unwrap_err().contains("WebSocket is closing"));
peer.wait().await.unwrap();
})
.unwrap();
}
#[test]
fn websocket_close_deadline_releases_all_aliases() {
runtime()
.run(async |scope| {
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let port = listener.local_addr().unwrap().port();
let (release, released) = oneshot::channel();
let peer = scope
.spawn_leaf(move || async move {
let (stream, _) = listener.accept().await.unwrap();
let _wire = tokio_tungstenite::accept_async(stream).await.unwrap();
let _ = released.await;
})
.unwrap();
let socket = ws::connect(
"127.0.0.1",
i64::from(port),
"/",
&ws::Limits {
write_ms: 50,
..limits()
},
scope,
)
.await
.unwrap();
let alias = socket.clone();
let receiving = alias.receive();
tokio::pin!(receiving);
assert!(futures_util::poll!(&mut receiving).is_pending());
let (closed, received) = tokio::join!(socket.close(1000, ""), receiving);
assert!(closed.unwrap_err().contains("timed out"));
assert!(received.unwrap_err().contains("WebSocket is closing"));
assert!(
alias
.send(Message::Text("closed".into()))
.await
.unwrap_err()
.contains("WebSocket is closed")
);
release.send(()).unwrap();
peer.wait().await.unwrap();
})
.unwrap();
}
+242
View File
@@ -0,0 +1,242 @@
use super::*;
use dever_runtime::http::websocket::{self as ws, Message, WebSocket};
mod lifecycle;
mod protocol;
mod shared;
#[path = "../../../performance/websocket/shared/mod.rs"]
mod shared_reference;
fn limits() -> ws::Limits {
ws::Limits {
idle_ms: 1000,
write_ms: 1000,
..ws::Limits::default()
}
}
async fn echo(
_: Request,
reply: http::HttpReply,
scope: Arc<Scope<'static>>,
) -> Result<(), String> {
let socket = ws::accept(&reply, &limits(), &scope)
.await
.map_err(|error| error.to_string())?;
while let Ok(Some(message)) = socket.receive().await {
if matches!(message, Message::Text(_) | Message::Binary(_)) {
socket
.send(message)
.await
.map_err(|error| error.to_string())?;
}
}
Ok(())
}
async fn server(
scope: &Scope<'_>,
) -> (
net::Listener,
dever_runtime::task::Task<Result<(), http::ServeError>>,
) {
let listener = net::listen("127.0.0.1", 0, scope).await.unwrap();
let incoming = listener.clone();
let task = scope
.spawn(move |child| async move {
http::serve_live(
&incoming,
&Limits::default(),
&http::LiveLimits::default(),
&child,
echo,
)
.await
})
.unwrap();
(listener, task)
}
#[test]
fn websocket_live_upgrade_echo_control_frames_and_close() {
runtime()
.run(async |scope| {
let (listener, server) = server(scope).await;
let socket = ws::connect(
"127.0.0.1",
listener.port().unwrap(),
"/echo?key=value",
&limits(),
scope,
)
.await
.unwrap();
for message in [
Message::Text("你好".into()),
Message::Binary(vec![0, 255, 17].into()),
] {
socket.send(message.clone()).await.unwrap();
assert_eq!(socket.receive().await.unwrap(), Some(message));
}
socket
.send(Message::Ping(b"ping".to_vec().into()))
.await
.unwrap();
assert_eq!(
socket.receive().await.unwrap(),
Some(Message::Pong(b"ping".to_vec().into()))
);
assert!(
socket
.send(Message::Ping(vec![0; 126].into()))
.await
.is_err()
);
assert!(socket.close(1005, "").await.is_err());
assert!(socket.close(1000, &"界".repeat(42)).await.is_err());
socket
.send(Message::Text("still open".into()))
.await
.unwrap();
assert_eq!(
socket.receive().await.unwrap(),
Some(Message::Text("still open".into()))
);
socket.close(1000, "结束").await.unwrap();
assert!(socket.receive().await.unwrap().is_none());
listener.close().unwrap();
server.wait().await.unwrap().unwrap();
})
.unwrap();
}
#[test]
fn websocket_aliases_send_whole_messages_while_receiving() {
runtime()
.run(async |scope| {
let (listener, server) = server(scope).await;
let socket = ws::connect("127.0.0.1", listener.port().unwrap(), "/", &limits(), scope)
.await
.unwrap();
let writers = futures_util::future::join_all((0u8..4).map(|writer| {
let socket = &socket;
async move {
for sequence in 0u8..16 {
let mut bytes = vec![writer; 65536];
bytes[1] = sequence;
socket.send(Message::Binary(bytes.into())).await.unwrap();
}
}
}));
let receiving = async {
let mut next = [0u8; 4];
for _ in 0..64 {
let Some(Message::Binary(bytes)) = socket.receive().await.unwrap() else {
panic!("expected a complete binary message");
};
assert_eq!(bytes.len(), 65536);
let writer = bytes[0];
assert_eq!(bytes[1], next[usize::from(writer)]);
assert!(bytes[2..].iter().all(|byte| *byte == writer));
next[usize::from(writer)] += 1;
}
assert_eq!(next, [16; 4]);
};
tokio::join!(writers, receiving);
socket.close(1000, "").await.unwrap();
listener.close().unwrap();
server.wait().await.unwrap().unwrap();
})
.unwrap();
}
#[test]
fn websocket_wss_uses_supplied_ca_and_dns_with_no_insecure_fallback() {
runtime()
.run(async |scope| {
let listener = net::listen("127.0.0.1", 0, scope).await.unwrap();
let port = listener.port().unwrap();
let incoming = listener.clone();
let server = scope
.spawn(move |child| async move {
let tls = dever_runtime::tls::server(tls::CERTIFICATE, tls::KEY).unwrap();
http::serve_live_tls(
&incoming,
&Limits::default(),
&http::LiveLimits::default(),
&child,
&tls,
echo,
)
.await
})
.unwrap();
let url = format!("wss://localhost:{port}/echo");
assert!(
ws::open(&url, &dever_runtime::tls::system(), &limits(), scope)
.await
.is_err()
);
let trusted = dever_runtime::tls::client(tls::ROOT).unwrap();
let socket = ws::open(&url, &trusted, &limits(), scope).await.unwrap();
socket
.send(Message::Text("verified TLS".into()))
.await
.unwrap();
assert_eq!(
socket.receive().await.unwrap(),
Some(Message::Text("verified TLS".into()))
);
socket.close(1000, "").await.unwrap();
listener.close().unwrap();
server.wait().await.unwrap().unwrap();
})
.unwrap();
}
#[test]
fn websocket_invalid_inputs_fail_before_connecting() {
runtime()
.run(async |scope| {
for limit in [
ws::Limits {
message_bytes: 0,
..limits()
},
ws::Limits {
idle_ms: 0,
..limits()
},
ws::Limits {
write_ms: -1,
..limits()
},
] {
assert!(
ws::connect("127.0.0.1", 0, "/", &limit, scope)
.await
.is_err()
);
}
for url in [
"http://localhost/",
"ws://user:password@localhost/",
"ws://localhost/#fragment",
"ws://localhost:99999/",
] {
assert!(
ws::open(url, &dever_runtime::tls::system(), &limits(), scope)
.await
.is_err()
);
}
for target in ["http://localhost/", "*", "/#fragment"] {
assert!(
ws::connect("127.0.0.1", 0, target, &limits(), scope)
.await
.is_err()
);
}
})
.unwrap();
}
@@ -0,0 +1,243 @@
use super::*;
use futures_util::{SinkExt, StreamExt};
use tokio_tungstenite::tungstenite::{
Message as Frame,
protocol::frame::{
Frame as RawFrame,
coding::{Data, OpCode},
},
};
#[test]
#[ignore = "explicit release WebSocket handshake performance probe"]
fn websocket_upgrade_cost() {
runtime()
.run(async |scope| {
let (listener, serving) = server(scope).await;
let port = listener.port().unwrap();
println!("\ncase,sample,iterations,elapsed_ns");
for sample in 0..16 {
let started = std::time::Instant::now();
for _ in 0..20 {
let socket = ws::connect("127.0.0.1", port, "/echo", &limits(), scope)
.await
.unwrap();
socket.send(Message::Text("check".into())).await.unwrap();
assert_eq!(
socket.receive().await.unwrap(),
Some(Message::Text("check".into()))
);
socket.close(1000, "").await.unwrap();
}
let elapsed = started.elapsed().as_nanos();
if sample != 0 {
println!("websocket_upgrade,{},20,{elapsed}", sample - 1);
}
}
listener.close().unwrap();
serving.wait().await.unwrap().unwrap();
})
.unwrap();
}
#[test]
fn websocket_client_limits_handshake_without_limiting_later_frames() {
runtime()
.run(async |scope| {
for header_bytes in [60000, 66000] {
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let port = listener.local_addr().unwrap().port();
let peer = scope
.spawn_leaf(move || async move {
let (stream, _) = listener.accept().await.unwrap();
let handshake = tokio_tungstenite::accept_hdr_async(
stream,
#[expect(clippy::result_large_err, reason = "Tungstenite 的握手回调要求直接返回 HTTP 错误响应")]
move |_: &tokio_tungstenite::tungstenite::handshake::server::Request,
mut response: tokio_tungstenite::tungstenite::handshake::server::Response| {
response.headers_mut().insert(
"x-padding",
"x".repeat(header_bytes).parse().unwrap(),
);
Ok(response)
},
)
.await;
if header_bytes == 60000 {
let mut wire = handshake.unwrap();
wire.send(Frame::Binary(vec![17; 70000].into())).await.unwrap();
assert!(matches!(wire.next().await, Some(Ok(Frame::Close(_)))));
wire.flush().await.unwrap();
}
})
.unwrap();
let connection = ws::connect("127.0.0.1", i64::from(port), "/", &limits(), scope).await;
if header_bytes == 60000 {
let socket = connection.unwrap();
assert_eq!(socket.receive().await.unwrap(), Some(Message::Binary(vec![17; 70000].into())));
socket.close(1000, "").await.unwrap();
} else {
assert!(connection.is_err());
}
peer.wait().await.unwrap();
}
})
.unwrap();
}
#[test]
fn websocket_client_reassembles_fragments_and_rejects_cumulative_limit_and_utf8() {
runtime()
.run(async |scope| {
for invalid in [false, true] {
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let port = listener.local_addr().unwrap().port();
let peer = scope
.spawn_leaf(move || async move {
let (stream, _) = listener.accept().await.unwrap();
let mut wire = tokio_tungstenite::accept_async(stream).await.unwrap();
wire.send(Frame::Frame(RawFrame::message(
vec![0xe4, 0xb8],
OpCode::Data(Data::Text),
false,
)))
.await
.unwrap();
wire.send(Frame::Frame(RawFrame::message(
vec![0xad],
OpCode::Data(Data::Continue),
true,
)))
.await
.unwrap();
if invalid {
wire.send(Frame::Frame(RawFrame::message(
vec![0xff],
OpCode::Data(Data::Text),
true,
)))
.await
.unwrap();
} else {
wire.send(Frame::Frame(RawFrame::message(
b"12345".to_vec(),
OpCode::Data(Data::Text),
false,
)))
.await
.unwrap();
wire.send(Frame::Frame(RawFrame::message(
b"6789".to_vec(),
OpCode::Data(Data::Continue),
true,
)))
.await
.unwrap();
}
let _ = wire.next().await;
})
.unwrap();
let socket = ws::connect(
"127.0.0.1",
i64::from(port),
"/",
&ws::Limits {
message_bytes: 8,
..limits()
},
scope,
)
.await
.unwrap();
assert_eq!(
socket.receive().await.unwrap(),
Some(Message::Text("中".into()))
);
assert!(socket.receive().await.is_err());
assert!(socket.send(Message::Text("closed".into())).await.is_err());
peer.wait().await.unwrap();
}
})
.unwrap();
}
#[test]
fn websocket_accept_failure_can_send_http_error_and_http2_cannot_upgrade() {
runtime()
.run(async |scope| {
for http2 in [false, true] {
let listener = net::listen("127.0.0.1", 0, scope).await.unwrap();
let port = listener.port().unwrap();
let incoming = listener.clone();
let limits = Limits {
http2: http2.then(http::Http2Limits::default),
..Limits::default()
};
let server_limits = limits.clone();
let server = scope
.spawn(move |child| async move {
http::serve_live(
&incoming,
&server_limits,
&http::LiveLimits::default(),
&child,
|_, reply, scope| async move {
assert!(
ws::accept(&reply, &super::limits(), &scope).await.is_err()
);
reply
.respond(Response {
status: 400,
headers: vec![],
body: b"not a websocket".to_vec().into(),
})
.await
.map_err(|error| error.to_string())
},
)
.await
})
.unwrap();
let response = http::send("127.0.0.1", port, request("/", &[]), &limits)
.await
.unwrap();
assert_eq!(response.status, 400);
assert_eq!(response.body.as_ref(), b"not a websocket");
listener.close().unwrap();
server.wait().await.unwrap().unwrap();
}
})
.unwrap();
}
#[test]
fn websocket_upgrade_preserves_prefetched_frames_and_rejects_unmasked_clients() {
runtime().run(async |scope| {
let (listener, server) = server(scope).await;
let port = listener.port().unwrap();
scope.blocking(move || {
let mut peer = TcpStream::connect(("127.0.0.1", port as u16)).unwrap();
peer.set_read_timeout(Some(Duration::from_secs(2))).unwrap();
peer.set_write_timeout(Some(Duration::from_secs(2))).unwrap();
let mut request = b"GET /echo HTTP/1.1\r\nHost: localhost\r\nConnection: Upgrade\r\nUpgrade: websocket\r\nSec-WebSocket-Version: 13\r\nSec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==\r\n\r\n".to_vec();
request.extend([0x81, 0x81, 1, 2, 3, 4, b'x' ^ 1]);
peer.write_all(&request).unwrap();
let mut peer = BufReader::new(peer);
let mut line = String::new();
peer.read_line(&mut line).unwrap();
assert!(line.starts_with("HTTP/1.1 101"), "{line}");
loop {
line.clear();
assert!(peer.read_line(&mut line).unwrap() > 0);
if line == "\r\n" { break; }
}
let mut echo = [0; 3];
peer.read_exact(&mut echo).unwrap();
assert_eq!(echo, [0x81, 1, b'x']);
peer.get_mut().write_all(&[0x81, 1, b'y']).unwrap();
assert_eq!(peer.read(&mut echo).unwrap(), 0);
}).await.unwrap();
listener.close().unwrap();
server.wait().await.unwrap().unwrap();
}).unwrap();
}
+185
View File
@@ -0,0 +1,185 @@
use super::shared_reference::{Limits, Owner, Socket};
use futures_util::{SinkExt, StreamExt};
use std::time::Duration;
use tokio::{
net::{TcpListener, TcpStream},
sync::oneshot,
};
use tokio_tungstenite::{
WebSocketStream,
tungstenite::{Message, protocol::WebSocketConfig},
};
fn limits() -> Limits {
Limits {
message: 16 * 1024 * 1024,
idle: Duration::from_secs(2),
write: Duration::from_secs(2),
}
}
async fn pair(limits: Limits) -> (Socket, Owner, WebSocketStream<TcpStream>) {
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let address = listener.local_addr().unwrap();
let config = WebSocketConfig::default()
.max_message_size(Some(limits.message))
.max_frame_size(Some(limits.message))
.write_buffer_size(0);
let client = async {
let stream = TcpStream::connect(address).await.unwrap();
stream.set_nodelay(true).unwrap();
tokio_tungstenite::client_async_with_config(
format!("ws://{address}/"),
stream,
Some(config),
)
.await
.unwrap()
.0
};
let server = async {
let (stream, _) = listener.accept().await.unwrap();
stream.set_nodelay(true).unwrap();
tokio_tungstenite::accept_async_with_config(stream, Some(config))
.await
.unwrap()
};
let (client, server) = tokio::join!(client, server);
let (socket, owner) = Socket::new(client, limits);
(socket, owner, server)
}
#[tokio::test]
async fn websocket_rust_reference_owner_exit_wakes_reader_and_releases_tcp() {
let (socket, owner, mut peer) = pair(limits()).await;
let alias = socket.clone();
let reading = alias.receive();
tokio::pin!(reading);
assert!(futures_util::poll!(&mut reading).is_pending());
drop(owner);
assert!(
tokio::time::timeout(Duration::from_millis(100), reading)
.await
.unwrap()
.is_err()
);
assert!(socket.send(Message::Text("closed".into())).await.is_err());
assert!(
tokio::time::timeout(Duration::from_millis(100), peer.next())
.await
.unwrap()
.unwrap()
.is_err()
);
}
#[tokio::test]
async fn websocket_rust_reference_full_duplex_aliases_preserve_messages() {
let (socket, _owner, mut peer) = pair(limits()).await;
let server = tokio::spawn(async move {
while let Some(message) = peer.next().await {
let message = message.unwrap();
if message.is_close() {
peer.flush().await.unwrap();
break;
}
peer.send(message).await.unwrap();
}
});
let sender = |writer: u8| {
let socket = socket.clone();
async move {
for sequence in 0..16 {
let mut bytes = vec![writer; 65536];
bytes[1] = sequence;
socket.send(Message::Binary(bytes.into())).await.unwrap();
}
}
};
let receiver = async {
let mut sequences = [0_u8; 2];
for _ in 0..32 {
let Message::Binary(bytes) = socket.receive().await.unwrap().unwrap() else {
panic!("binary expected")
};
let writer = usize::from(bytes[0]);
assert_eq!(bytes[1], sequences[writer]);
assert_eq!(bytes.len(), 65536);
assert!(bytes[2..].iter().all(|byte| *byte == writer as u8));
sequences[writer] += 1;
}
assert_eq!(sequences, [16, 16]);
};
tokio::time::timeout(Duration::from_secs(5), async {
tokio::join!(sender(0), sender(1), receiver);
})
.await
.unwrap();
socket.close(1000, "").await.unwrap();
server.await.unwrap();
}
#[tokio::test]
async fn websocket_rust_reference_close_has_priority_over_readable_reply() {
let (socket, _owner, mut peer) = pair(limits()).await;
let (sent, ready) = oneshot::channel();
let server = tokio::spawn(async move {
assert!(peer.next().await.unwrap().unwrap().is_close());
peer.flush().await.unwrap();
sent.send(()).unwrap();
});
let reading = socket.receive();
tokio::pin!(reading);
assert!(futures_util::poll!(&mut reading).is_pending());
let closing = socket.close(1000, "");
tokio::pin!(closing);
assert!(futures_util::poll!(&mut closing).is_pending());
ready.await.unwrap();
tokio::time::sleep(Duration::from_millis(25)).await;
assert!(reading.await.is_err());
closing.await.unwrap();
server.await.unwrap();
}
#[tokio::test]
async fn websocket_rust_reference_cancelled_writer_only_closes_after_lock() {
let (socket, _owner, _peer) = pair(limits()).await;
let mut writing = Box::pin(socket.send(Message::Binary(vec![1; limits().message].into())));
assert!(futures_util::poll!(&mut writing).is_pending());
let mut queued = Box::pin(socket.send(Message::Text("queued".into())));
assert!(futures_util::poll!(&mut queued).is_pending());
drop(queued);
assert!(futures_util::poll!(&mut writing).is_pending());
drop(writing);
assert!(socket.send(Message::Text("closed".into())).await.is_err());
}
#[tokio::test]
async fn websocket_rust_reference_limits_ping_and_idle_close() {
let (socket, _owner, mut peer) = pair(Limits {
message: 128,
idle: Duration::from_millis(25),
..limits()
})
.await;
assert!(
socket
.send(Message::Binary(vec![0; 129].into()))
.await
.is_err()
);
assert!(
socket
.send(Message::Ping(vec![0; 126].into()))
.await
.is_err()
);
peer.send(Message::Ping(vec![7].into())).await.unwrap();
assert!(socket.receive().await.unwrap().unwrap().is_ping());
assert_eq!(
peer.next().await.unwrap().unwrap(),
Message::Pong(vec![7].into())
);
assert!(socket.receive().await.unwrap_err().contains("timed out"));
assert!(socket.send(Message::Text("closed".into())).await.is_err());
}
+41
View File
@@ -0,0 +1,41 @@
use super::*;
#[test]
fn claim_orders_pending_and_unrecovered_expired_jobs_together() {
let directory = Directory::new();
Runtime::new(Limits::default()).unwrap().run(async |scope| {
let database = Database::open(&directory.0, options(), scope).await.unwrap();
let mut executor = database.executor().await.unwrap();
executor.schema(scope, job::schema::TABLE, job::schema::SQL).await.unwrap();
executor.initialize(scope, "WITH RECURSIVE rows(number) AS (SELECT 0 UNION ALL SELECT number+1 FROM rows WHERE number<299) INSERT INTO _dever_job(id,target,payload,schema,execution,task_key,state,attempt,retries,timeout_ms,available_at,created_at,token,lease_until,execution_tenant) SELECT printf('%06d',number),'demo.account.publish','null','zero','{\"kind\":\"System\",\"tenant\":null}',CAST(number AS TEXT),'running',1,2,50,CASE WHEN number=299 THEN 0 ELSE 100 END,0,'old',0,0 FROM rows").await.unwrap();
let mut request = submission("pending");
request.at = 50;
let pending = job::enqueue(&mut executor, scope, request, 1000).await.unwrap().into_text();
let first = job::claim(&mut executor, scope, 1000, 1000).await.unwrap().unwrap();
// 首轮只恢复 256 项;仍为 running 的过期任务也必须参与最早时间排序。
assert_eq!(first.id, "000299");
assert_eq!(first.attempt, 2);
assert_ne!(first.token, "old");
let second = job::claim(&mut executor, scope, 1000, 1000).await.unwrap().unwrap();
assert_eq!(second.id, pending);
let third = job::claim(&mut executor, scope, 1000, 1000).await.unwrap().unwrap();
assert_eq!(third.id, "000000");
}).unwrap();
}
#[test]
fn expired_cleanup_is_bounded_without_hiding_eligible_work() {
let directory = Directory::new();
Runtime::new(Limits::default()).unwrap().run(async |scope| {
let database = Database::open(&directory.0, options(), scope).await.unwrap();
let mut executor = database.executor().await.unwrap();
executor.schema(scope, job::schema::TABLE, job::schema::SQL).await.unwrap();
executor.initialize(scope, "WITH RECURSIVE rows(number) AS (SELECT 0 UNION ALL SELECT number+1 FROM rows WHERE number<300) INSERT INTO _dever_job(id,target,payload,schema,execution,task_key,state,attempt,retries,timeout_ms,available_at,created_at,token,lease_until,execution_tenant) SELECT printf('%06d',number),'demo.account.publish','null','zero','{\"kind\":\"System\",\"tenant\":null}',CAST(number AS TEXT),'running',1,CASE WHEN number=300 THEN 2 ELSE 1 END,50,0,0,'old',0,0 FROM rows").await.unwrap();
let claim = job::claim(&mut executor, scope, 1000, 1000).await.unwrap().unwrap();
assert_eq!(claim.id, "000300");
assert_eq!(claim.attempt, 2);
let counts = executor.query(scope, "SELECT (SELECT COUNT(*) FROM _dever_job WHERE state='dead'),(SELECT COUNT(*) FROM _dever_job WHERE state='running' AND token='old')", vec![], 1, |row| Ok((row.get::<_, i64>(0)?, row.get::<_, i64>(1)?))).await.unwrap();
assert_eq!(counts, vec![(256,44)]);
job::complete(&mut executor, scope, &claim, 1001).await.unwrap();
}).unwrap();
}
+84
View File
@@ -0,0 +1,84 @@
use super::*;
#[test]
fn fixed_execution_decode_preserves_strict_fields() {
use dever_runtime::wire;
for source in [
r#"{"kind":"System","tenant":null,"tenant":1}"#,
r#"{"kind":"System","kind":"System","tenant":null}"#,
r#"{"kind":"System","tenant":null,"extra":0}"#,
r#"{"kind":"System","tenant":"1"}"#,
r#"{"kind":"User","provider":"local"}"#,
] {
assert!(wire::decode::<Execution>(source).is_err(), "{source}");
assert!(wire::decode_fixed::<Execution>(source).is_err(), "{source}");
}
for source in [
r#"{"kind":"System","tenant":null}"#,
r#"{"kind":"System","tenant":42}"#,
] {
let original: Execution = wire::decode(source).unwrap();
let fixed: Execution = wire::decode_fixed(source).unwrap();
assert_eq!(original.tenant(), fixed.tenant());
}
}
#[test]
fn shared_queue_keeps_execution_tenants_in_distinct_idempotency_domains() {
let directory = Directory::new();
Runtime::new(Limits::default())
.unwrap()
.run(async |scope| {
let database = Database::open(&directory.0, options(), scope)
.await
.unwrap();
let mut executor = database.executor().await.unwrap();
executor
.schema(scope, job::schema::TABLE, job::schema::SQL)
.await
.unwrap();
let mut ids = Vec::new();
for tenant in [None, Some(1), Some(2)] {
let identity = Execution::System { tenant };
let mut request = submission("one-business-key");
request.execution = &identity;
let first = job::enqueue(&mut executor, scope, request, 1000)
.await
.unwrap()
.into_text();
let mut request = submission("one-business-key");
request.execution = &identity;
let second = job::enqueue(&mut executor, scope, request, 1000)
.await
.unwrap()
.into_text();
assert_eq!(first, second);
assert!(!ids.contains(&first));
ids.push(first);
}
let mut tenants = Vec::new();
for _ in 0..3 {
let claim = job::claim(&mut executor, scope, 1000, 1000)
.await
.unwrap()
.unwrap();
let identity: Execution = dever_runtime::wire::decode(&claim.execution).unwrap();
assert_eq!(identity.tenant().unwrap_or(0), claim.execution_tenant);
tenants.push(claim.execution_tenant);
job::complete(&mut executor, scope, &claim, 1001)
.await
.unwrap();
}
tenants.sort_unstable();
assert_eq!(tenants, [0, 1, 2]);
let identity = Execution::System { tenant: Some(0) };
let mut request = submission("invalid");
request.execution = &identity;
assert!(
job::enqueue(&mut executor, scope, request, 1000)
.await
.is_err()
);
})
.unwrap();
}
+206
View File
@@ -0,0 +1,206 @@
use super::*;
use std::{sync::Arc, time::Duration};
#[test]
fn expired_ack_is_rejected_after_waiting_for_the_write_lock() {
let directory = Directory::new();
let runtime = Runtime::new(Limits::default()).unwrap();
runtime
.run(async |scope| {
let database = Database::open(&directory.0, options(), scope)
.await
.unwrap();
let mut owner = database.executor().await.unwrap();
owner
.schema(scope, job::schema::TABLE, job::schema::SQL)
.await
.unwrap();
job::enqueue(&mut owner, scope, submission("locked"), 1000)
.await
.unwrap();
let claim = Arc::new(
job::claim(&mut owner, scope, 1000, 100)
.await
.unwrap()
.unwrap(),
);
let mut waiting = database.executor().await.unwrap();
let transaction = owner.begin(scope).await.unwrap();
let (ready, started) = tokio::sync::oneshot::channel();
let claim_copy = claim.clone();
let task = scope
.spawn(move |child| async move {
ready.send(()).unwrap();
job::complete(&mut waiting, &child, &claim_copy, 1001).await
})
.unwrap();
started.await.unwrap();
// 租约必须包含 SQL 锁外的等待时间,不能复用等待前的时间快照。
tokio::time::sleep(Duration::from_millis(200)).await;
owner.finish(scope, transaction, true).await.unwrap();
assert!(
task.wait().await.unwrap().is_err(),
"expired lease acknowledged after lock wait"
);
let next = job::claim(&mut owner, scope, 1300, 100)
.await
.unwrap()
.unwrap();
assert_eq!(next.id, claim.id);
assert_ne!(next.token, claim.token);
assert_eq!(next.attempt, 2);
})
.unwrap();
}
#[test]
fn claim_lease_starts_after_its_write_lock_wait() {
let directory = Directory::new();
let runtime = Runtime::new(Limits::default()).unwrap();
runtime
.run(async |scope| {
let database = Database::open(&directory.0, options(), scope)
.await
.unwrap();
let mut owner = database.executor().await.unwrap();
owner
.schema(scope, job::schema::TABLE, job::schema::SQL)
.await
.unwrap();
job::enqueue(&mut owner, scope, submission("claim"), 1000)
.await
.unwrap();
let mut waiting = database.executor().await.unwrap();
let transaction = owner.begin(scope).await.unwrap();
let (ready, started) = tokio::sync::oneshot::channel();
let task = scope
.spawn(move |child| async move {
ready.send(()).unwrap();
job::claim(&mut waiting, &child, 1000, 100)
.await
.unwrap()
.unwrap()
})
.unwrap();
started.await.unwrap();
tokio::time::sleep(Duration::from_millis(200)).await;
owner.finish(scope, transaction, true).await.unwrap();
let claim = task.wait().await.unwrap();
assert!(
claim.lease_until >= 1300,
"lease lost its duration while waiting for the write lock: {}",
claim.lease_until
);
})
.unwrap();
}
#[test]
fn cancelling_a_waiting_ack_leaves_the_claim_recoverable() {
let directory = Directory::new();
let runtime = Runtime::new(Limits::default()).unwrap();
runtime
.run(async |scope| {
let database = Database::open(&directory.0, options(), scope)
.await
.unwrap();
let mut owner = database.executor().await.unwrap();
owner
.schema(scope, job::schema::TABLE, job::schema::SQL)
.await
.unwrap();
job::enqueue(&mut owner, scope, submission("cancelled"), 1000)
.await
.unwrap();
let claim = Arc::new(
job::claim(&mut owner, scope, 1000, 1000)
.await
.unwrap()
.unwrap(),
);
let mut waiting = database.executor().await.unwrap();
let transaction = owner.begin(scope).await.unwrap();
let (ready, started) = tokio::sync::oneshot::channel();
let claim_copy = claim.clone();
let task = scope
.spawn(move |child| async move {
ready.send(()).unwrap();
job::complete(&mut waiting, &child, &claim_copy, 1001).await
})
.unwrap();
started.await.unwrap();
tokio::time::sleep(Duration::from_millis(50)).await;
task.cancel();
tokio::time::sleep(Duration::from_millis(10)).await;
owner.finish(scope, transaction, true).await.unwrap();
assert!(matches!(
task.wait().await,
Err(dever_runtime::task::Error::Cancelled)
));
let next = job::claim(&mut owner, scope, 3000, 100)
.await
.unwrap()
.unwrap();
assert_eq!(next.id, claim.id);
assert_ne!(next.token, claim.token);
assert_eq!(next.attempt, 2);
})
.unwrap();
}
#[test]
fn expired_ack_rolls_back_the_callers_business_transaction() {
let directory = Directory::new();
let runtime = Runtime::new(Limits::default()).unwrap();
runtime
.run(async |scope| {
let database = Database::open(&directory.0, options(), scope)
.await
.unwrap();
let mut executor = database.executor().await.unwrap();
executor
.schema(scope, job::schema::TABLE, job::schema::SQL)
.await
.unwrap();
executor
.schema(
scope,
"business",
"CREATE TABLE business(id INTEGER PRIMARY KEY);",
)
.await
.unwrap();
job::enqueue(&mut executor, scope, submission("atomic"), 1000)
.await
.unwrap();
let claim = job::claim(&mut executor, scope, 1000, 100)
.await
.unwrap()
.unwrap();
let owner = executor.begin(scope).await.unwrap();
executor
.execute(scope, "INSERT INTO business(id) VALUES (1)", vec![])
.await
.unwrap();
assert!(
job::complete(&mut executor, scope, &claim, claim.lease_until)
.await
.is_err()
);
assert!(executor.finish(scope, owner, true).await.is_err());
let rows = executor
.query(scope, "SELECT count(*) FROM business", vec![], 1, |row| {
row.get::<_, i64>(0)
})
.await
.unwrap();
assert_eq!(rows, vec![0]);
let next = job::claim(&mut executor, scope, claim.lease_until, 100)
.await
.unwrap()
.unwrap();
assert_eq!(next.id, claim.id);
assert_eq!(next.attempt, 2);
})
.unwrap();
}
+315
View File
@@ -0,0 +1,315 @@
use dever_runtime::{
database::{Database, Options, Parameter},
job::{self, Execution, Policy, Submission},
task::{Limits, Runtime},
wire::json::Json,
};
mod cleanup;
mod identity;
mod lease;
mod performance;
mod schedule;
use std::{
path::PathBuf,
sync::{
LazyLock,
atomic::{AtomicU64, Ordering},
},
};
static NEXT: AtomicU64 = AtomicU64::new(0);
struct Directory(PathBuf);
impl Directory {
fn new() -> Self {
let path = std::env::temp_dir().join(format!(
"dever-job-{}-{}",
std::process::id(),
NEXT.fetch_add(1, Ordering::Relaxed)
));
std::fs::create_dir(&path).unwrap();
Self(path)
}
}
impl Drop for Directory {
fn drop(&mut self) {
std::fs::remove_dir_all(&self.0).unwrap();
}
}
fn options() -> Options {
Options {
driver: "sqlite".into(),
path: "queue.db".into(),
url: None,
tls: None,
tenant_directory: None,
tenant_database_prefix: None,
min_connections: 1,
max_connections: 4,
max_page_size: 100,
wait_timeout_ms: 1000,
io_timeout_ms: 1000,
}
}
fn submission(key: &str) -> Submission<'_> {
static PAYLOAD: LazyLock<Json> = LazyLock::new(|| Json::parse(r#"{"number":1}"#).unwrap());
Submission {
target: "demo.account.publish",
payload: Some(&PAYLOAD),
schema: "schema-1",
execution: &Execution::System { tenant: None },
key,
policy: Policy {
retries: 2,
timeout_ms: 50,
},
at: 1000,
}
}
#[test]
fn durable_queue_keeps_idempotency_transactions_and_rejects_stale_leases() {
let directory = Directory::new();
let runtime = Runtime::new(Limits::default()).unwrap();
runtime
.run(async |scope| {
let database = Database::open(&directory.0, options(), scope)
.await
.unwrap();
let mut executor = database.executor().await.unwrap();
executor
.schema(scope, job::schema::TABLE, job::schema::SQL)
.await
.unwrap();
let id = job::enqueue(&mut executor, scope, submission("one"), 1000)
.await
.unwrap()
.into_text();
assert_eq!(
job::enqueue(&mut executor, scope, submission("one"), 1000)
.await
.unwrap()
.into_text(),
id
);
assert!(
job::claim(&mut executor, scope, 950, 100)
.await
.unwrap()
.is_none()
);
let first = job::claim(&mut executor, scope, 1000, 100)
.await
.unwrap()
.unwrap();
assert_eq!(first.id, id);
assert_eq!(first.attempt, 1);
assert!(
job::claim(&mut executor, scope, first.lease_until - 50, 100)
.await
.unwrap()
.is_none()
);
let second = job::claim(&mut executor, scope, first.lease_until, 100)
.await
.unwrap()
.unwrap();
assert_eq!(second.id, id);
assert_eq!(second.attempt, 2);
assert_ne!(first.token, second.token);
assert!(
job::complete(&mut executor, scope, &first, 1101)
.await
.is_err()
);
job::complete(&mut executor, scope, &second, 1101)
.await
.unwrap();
assert!(
job::complete(&mut executor, scope, &second, 1102)
.await
.is_err()
);
let next = job::enqueue(&mut executor, scope, submission("one"), 1102)
.await
.unwrap();
assert_ne!(next.into_text(), id);
let owner = executor.begin(scope).await.unwrap();
job::enqueue(&mut executor, scope, submission("rolled back"), 1102)
.await
.unwrap();
executor.finish(scope, owner, false).await.unwrap();
assert_eq!(
executor
.query(
scope,
"SELECT COUNT(*) FROM _dever_job WHERE task_key=?1",
vec![Parameter::Text("rolled back".into())],
1,
|row| row.get::<_, i64>(0)
)
.await
.unwrap(),
vec![0]
);
drop(executor);
database.close();
let database = Database::open(&directory.0, options(), scope)
.await
.unwrap();
let mut executor = database.executor().await.unwrap();
let persisted = job::claim(&mut executor, scope, 1103, 100)
.await
.unwrap()
.unwrap();
assert_ne!(persisted.id, id);
assert!(matches!(
dever_runtime::wire::decode::<Execution>(&persisted.execution).unwrap(),
Execution::System { tenant: None }
));
job::block(&mut executor, scope, &persisted, 1104)
.await
.unwrap();
assert!(
job::claim(&mut executor, scope, 1204, 100)
.await
.unwrap()
.is_none()
);
})
.unwrap();
}
#[test]
fn retry_backoff_and_exhaustion_survive_a_lost_worker() {
let directory = Directory::new();
let runtime = Runtime::new(Limits::default()).unwrap();
runtime
.run(async |scope| {
let database = Database::open(&directory.0, options(), scope)
.await
.unwrap();
let mut executor = database.executor().await.unwrap();
executor
.schema(scope, job::schema::TABLE, job::schema::SQL)
.await
.unwrap();
job::enqueue(&mut executor, scope, submission("retry"), 1000)
.await
.unwrap();
let first = job::claim(&mut executor, scope, 1000, 100)
.await
.unwrap()
.unwrap();
let config = job::Config {
retry_base_ms: 10,
retry_max_ms: 40,
..Default::default()
};
job::fail(&mut executor, scope, &first, 1001, &config)
.await
.unwrap();
let available = executor
.query(
scope,
"SELECT available_at FROM _dever_job",
vec![],
1,
|row| row.get::<_, i64>(0),
)
.await
.unwrap()[0];
assert!(
job::claim(&mut executor, scope, available - 50, 100)
.await
.unwrap()
.is_none()
);
let last = job::claim(&mut executor, scope, available, 100)
.await
.unwrap()
.unwrap();
assert_eq!(last.attempt, 2);
assert!(
job::claim(&mut executor, scope, last.lease_until, 100)
.await
.unwrap()
.is_none()
);
assert_eq!(
executor
.query(scope, "SELECT state FROM _dever_job", vec![], 1, |row| {
row.get::<_, String>(0)
})
.await
.unwrap(),
vec!["dead"]
);
assert!(
job::complete(&mut executor, scope, &last, 1112)
.await
.is_err()
);
for (attempt, delay) in [(1, 10), (2, 20), (3, 40), (100, 40)] {
assert_eq!(config.delay(attempt), delay);
}
assert!(
config
.validate(
&[Policy {
retries: 2,
timeout_ms: 60000
}],
4,
true,
)
.is_err()
);
assert!(
config
.validate(
&[Policy {
retries: 2,
timeout_ms: 50
}],
2,
true,
)
.is_err()
);
config
.validate(
&[Policy {
retries: 2,
timeout_ms: 50,
}],
4,
true,
)
.unwrap();
config
.validate(
&[Policy {
retries: 2,
timeout_ms: 60000,
}],
4,
false,
)
.unwrap();
assert!(
config
.validate(
&[Policy {
retries: 0,
timeout_ms: 60000
}],
4,
false
)
.is_err()
);
})
.unwrap();
}
+296
View File
@@ -0,0 +1,296 @@
//! 阶段计时使用预借出的单连接;独立 Rust 仅共享表结构和返回字段,不调用 Dever 运行库。
//! 原有生成程序基准另外覆盖连接借还、业务事务和完整分派,不能用这里替代它。
mod native;
mod sql;
use super::*;
use deadpool_sqlite::rusqlite::Connection;
use dever_runtime::{database::Executor, job::Claim, task::Scope};
use std::{
hint::black_box,
time::{Duration, Instant},
};
#[derive(Clone, Copy, Debug)]
enum Phase {
Enqueue,
Duplicate,
Claim,
Complete,
Cycle,
}
async fn clear(executor: &mut Executor, scope: &Scope<'_>) {
executor
.execute(scope, "DELETE FROM _dever_job", vec![])
.await
.unwrap();
}
async fn measure_dever(
executor: &mut Executor,
scope: &Scope<'_>,
phase: Phase,
keys: &[String],
payload: &Json,
) -> (u128, u64) {
clear(executor, scope).await;
if matches!(phase, Phase::Duplicate | Phase::Claim | Phase::Complete) {
for key in keys {
let mut value = submission(key);
value.payload = Some(payload);
job::enqueue(executor, scope, value, 1000).await.unwrap();
}
}
let mut claims = Vec::new();
if matches!(phase, Phase::Complete) {
for _ in keys {
claims.push(
job::claim(executor, scope, 1000, 60000)
.await
.unwrap()
.unwrap(),
);
}
}
let start = Instant::now();
let mut checksum = 0;
for (index, key) in keys.iter().enumerate() {
match phase {
Phase::Enqueue | Phase::Duplicate => {
let mut value = submission(key);
value.payload = Some(payload);
checksum += black_box(
job::enqueue(executor, scope, value, 1000)
.await
.unwrap()
.into_text(),
)
.len() as u64;
}
Phase::Claim => {
let claim = job::claim(executor, scope, 1000, 60000)
.await
.unwrap()
.unwrap();
checksum += black_box(claim).id.len() as u64;
}
Phase::Complete => {
job::complete(executor, scope, &claims[index], 1000)
.await
.unwrap();
checksum += 1;
}
Phase::Cycle => {
let mut value = submission(key);
value.payload = Some(payload);
let id = job::enqueue(executor, scope, value, 1000)
.await
.unwrap()
.into_text();
let claim = job::claim(executor, scope, 1000, 60000)
.await
.unwrap()
.unwrap();
assert_eq!(id, claim.id);
job::complete(executor, scope, &claim, 1000).await.unwrap();
checksum += black_box(claim).id.len() as u64;
}
}
}
let elapsed = start.elapsed().as_nanos();
let state = state(phase);
let count = executor
.query(
scope,
"SELECT COUNT(*) FROM _dever_job WHERE state=?1",
vec![Parameter::Text(state.into())],
1,
|row| row.get::<_, i64>(0),
)
.await
.unwrap()[0];
assert_eq!(count, keys.len() as i64);
(elapsed, checksum)
}
fn state(phase: Phase) -> &'static str {
match phase {
Phase::Enqueue | Phase::Duplicate => "pending",
Phase::Claim => "running",
Phase::Complete | Phase::Cycle => "completed",
}
}
async fn measure_native(
native: &mut native::Native,
phase: Phase,
keys: &[String],
payload: &str,
) -> (u128, u64) {
native
.call(|connection, _| connection.execute("DELETE FROM _dever_job", []))
.await
.unwrap();
if matches!(phase, Phase::Duplicate | Phase::Claim | Phase::Complete) {
for key in keys {
native.enqueue(key, payload).await;
}
}
let mut claims: Vec<Claim> = Vec::new();
if matches!(phase, Phase::Complete) {
for _ in keys {
claims.push(native.claim().await.unwrap());
}
}
let start = Instant::now();
let mut checksum = 0;
for (index, key) in keys.iter().enumerate() {
match phase {
Phase::Enqueue | Phase::Duplicate => {
checksum += black_box(native.enqueue(key, payload).await).len() as u64
}
Phase::Claim => checksum += black_box(native.claim().await.unwrap()).id.len() as u64,
Phase::Complete => {
native.complete(&claims[index]).await;
checksum += 1;
}
Phase::Cycle => {
let id = native.enqueue(key, payload).await;
let claim = native.claim().await.unwrap();
assert_eq!(id, claim.id);
native.complete(&claim).await;
checksum += black_box(claim).id.len() as u64;
}
}
}
let elapsed = start.elapsed().as_nanos();
let count: i64 = native
.call(move |connection, _| {
connection.query_row(
"SELECT COUNT(*) FROM _dever_job WHERE state=?1",
[state(phase)],
|row| row.get(0),
)
})
.await
.unwrap();
assert_eq!(count, keys.len() as i64);
(elapsed, checksum)
}
#[test]
#[ignore = "release-only owned SQLite phase timing; invoke explicitly"]
fn independent_sqlite_phases() {
let directory = Directory::new();
let native_runtime = tokio::runtime::Builder::new_multi_thread()
.worker_threads(1)
.max_blocking_threads(1)
.enable_all()
.build()
.unwrap();
let connection = Connection::open(directory.0.join("native.db")).unwrap();
connection.busy_timeout(Duration::from_secs(10)).unwrap();
connection
.execute_batch(
"PRAGMA journal_mode=WAL; PRAGMA foreign_keys=ON; PRAGMA synchronous=NORMAL;",
)
.unwrap();
connection.execute_batch(job::schema::SQL.sqlite).unwrap();
let mut native = native::Native::new(connection);
let runtime = Runtime::new(Limits {
threads: 1,
workers: 1,
tasks: 16,
})
.unwrap();
let mut options = options();
options.max_connections = 1;
options.io_timeout_ms = 10000;
let database = runtime
.run(async |scope| Database::open(&directory.0, options, scope).await)
.unwrap()
.unwrap();
let mut executor = runtime
.run(async |scope| {
let mut executor = database.executor().await.unwrap();
executor
.schema(scope, job::schema::TABLE, job::schema::SQL)
.await
.unwrap();
executor
})
.unwrap();
println!("case,sample,implementation,iterations,nanoseconds,checksum");
for (name, phase, count, payload) in [
(
"enqueue",
Phase::Enqueue,
1000,
Json::parse("{\"number\":1}").unwrap(),
),
(
"enqueue_64k",
Phase::Enqueue,
64,
Json::parse(&format!("\"{}\"", "x".repeat(65536))).unwrap(),
),
(
"duplicate",
Phase::Duplicate,
1000,
Json::parse("{\"number\":1}").unwrap(),
),
(
"claim",
Phase::Claim,
256,
Json::parse("{\"number\":1}").unwrap(),
),
(
"complete",
Phase::Complete,
256,
Json::parse("{\"number\":1}").unwrap(),
),
(
"cycle",
Phase::Cycle,
256,
Json::parse("{\"number\":1}").unwrap(),
),
] {
let keys: Vec<_> = (0..count).map(|index| format!("key-{index:04}")).collect();
for sample in 0..16 {
let mut order = [("dever", true), ("rust", false), ("control", true)];
order.rotate_left(sample % 3);
let mut expected = None;
for (label, generated) in order {
let (elapsed, checksum) = if generated {
runtime
.run(async |scope| {
measure_dever(&mut executor, scope, phase, &keys, &payload).await
})
.unwrap()
} else {
native_runtime.block_on(measure_native(
&mut native,
phase,
&keys,
payload.as_str(),
))
};
if let Some(expected) = expected {
assert_eq!(checksum, expected);
}
expected = Some(checksum);
if sample > 0 {
println!("{name},{},{label},{count},{elapsed},{checksum}", sample - 1);
}
}
}
}
native_runtime.block_on(native.close());
drop(executor);
database.close();
}
+109
View File
@@ -0,0 +1,109 @@
//! 独立 Rust 对照仅使用 Tokio/rusqlite;连接移交、取消、期限和退出等待不调用 Dever。
use deadpool_sqlite::rusqlite::{Connection, Result};
use std::{
sync::{
Arc,
atomic::{AtomicBool, Ordering},
},
time::{Duration, Instant},
};
use tokio::sync::Semaphore;
use tokio_util::task::TaskTracker;
const TIMEOUT: Duration = Duration::from_secs(10);
struct Cancel(Arc<AtomicBool>);
impl Drop for Cancel {
fn drop(&mut self) {
self.0.store(true, Ordering::Release);
}
}
pub(super) struct Native {
connection: Option<Connection>,
workers: Arc<Semaphore>,
tracker: TaskTracker,
}
impl Native {
pub(super) fn new(connection: Connection) -> Self {
Self {
connection: Some(connection),
workers: Arc::new(Semaphore::new(1)),
tracker: TaskTracker::new(),
}
}
pub(super) async fn call<Value: Send + 'static>(
&mut self,
operation: impl FnOnce(&mut Connection, &dyn Fn() -> Result<()>) -> Result<Value>
+ Send
+ 'static,
) -> Result<Value> {
let mut connection = self.connection.take().unwrap();
let cancelled = Cancel(Arc::new(AtomicBool::new(false)));
let signal = cancelled.0.clone();
let deadline = Instant::now() + TIMEOUT;
let workers = self.workers.clone();
let tracker = self.tracker.clone();
let work = async move {
let worker = workers.acquire_owned().await.unwrap();
let tracking = tracker.token();
tokio::task::spawn_blocking(move || {
let _worker = worker;
let check = || {
if signal.load(Ordering::Acquire) || Instant::now() >= deadline {
Err(deadpool_sqlite::rusqlite::Error::InvalidQuery)
} else {
Ok(())
}
};
let outcome = (|| {
check()?;
let progress = signal.clone();
connection.progress_handler(
1000,
Some(move || {
progress.load(Ordering::Acquire) || Instant::now() >= deadline
}),
)?;
let result = operation(&mut connection, &check);
connection.progress_handler(0, None::<fn() -> bool>)?;
check()?;
result
})();
(connection, outcome, tracking)
})
.await
.unwrap()
};
let (connection, result, _tracking) = tokio::time::timeout(TIMEOUT, work).await.unwrap();
self.connection = Some(connection);
result
}
pub(super) async fn transaction<Value: Send + 'static>(
&mut self,
operation: impl FnOnce(&Connection) -> Result<Value> + Send + 'static,
) -> Result<Value> {
self.call(move |connection, check| {
let transaction = connection.transaction_with_behavior(
deadpool_sqlite::rusqlite::TransactionBehavior::Immediate,
)?;
check()?;
let result = operation(&transaction)?;
check()?;
transaction.commit()?;
Ok(result)
})
.await
}
pub(super) async fn close(self) {
self.tracker.close();
self.tracker.wait().await;
drop(self.connection);
}
}
+83
View File
@@ -0,0 +1,83 @@
//! 同一表合同的独立 SQL 实现;不复用生产队列函数,也不新增生产解析或调度层。
use super::native::Native;
use deadpool_sqlite::rusqlite::{OptionalExtension, params};
use dever_runtime::job::{Claim, Policy};
use std::{fmt::Write, time::Instant};
#[derive(serde::Serialize)]
struct Execution {
kind: &'static str,
tenant: Option<i64>,
}
pub(super) fn identifier() -> String {
let mut bytes = [0u8; 16];
getrandom::getrandom(&mut bytes).unwrap();
let mut value = String::with_capacity(32);
write!(value, "{:032x}", u128::from_be_bytes(bytes)).unwrap();
value
}
impl Native {
pub(super) async fn enqueue(&mut self, key: &str, payload: &str) -> String {
assert!(!key.is_empty() && key.len() <= 1024);
let id = identifier();
let (key, payload, target, schema) = (
key.to_owned(),
payload.to_owned(),
"demo.account.publish".to_owned(),
"schema-1".to_owned(),
);
let execution = serde_json::to_string(&Execution {
kind: "System",
tenant: None,
})
.unwrap();
self.transaction(move |connection| {
let changed = connection.prepare_cached("INSERT INTO _dever_job(id,target,payload,schema,execution,task_key,state,retries,timeout_ms,available_at,created_at,execution_tenant) VALUES (?1,?2,?3,?4,?5,?6,'pending',?7,?8,?9,?10,?11) ON CONFLICT(execution_tenant,target,task_key) WHERE state IN ('pending','running') DO NOTHING")?.execute(params![id,target,payload,schema,execution,key,2,50,1000,1000,0])?;
if changed == 1 {
Ok(id)
} else {
connection.prepare_cached("SELECT id FROM _dever_job WHERE execution_tenant=?1 AND target=?2 AND task_key=?3 AND state IN ('pending','running')")?.query_row(params![0,target,key], |row| row.get(0))
}
}).await.unwrap()
}
pub(super) async fn claim(&mut self) -> Option<Claim> {
let started = Instant::now();
let token = identifier();
self.transaction(move |connection| {
let now = 1000i64.checked_add(started.elapsed().as_millis().try_into().unwrap()).unwrap();
let lease_until = now.checked_add(60000).unwrap();
connection.prepare_cached("UPDATE _dever_job SET state=CASE WHEN attempt >= retries THEN 'dead' ELSE 'pending' END, token=NULL,lease_until=NULL,error='lease expired' WHERE id IN (SELECT id FROM _dever_job WHERE state='running' AND lease_until <= ?1 ORDER BY lease_until,id LIMIT 256)")?.execute([now])?;
let value = connection.prepare_cached(
"SELECT id,target,substr(payload,1,16777217),schema,substr(execution,1,16777217),retries,timeout_ms,attempt,execution_tenant FROM _dever_job
WHERE id=(SELECT id FROM (
SELECT * FROM (SELECT id,available_at FROM _dever_job WHERE state='pending' AND attempt < retries AND available_at <= ?1 ORDER BY available_at,id LIMIT 1)
UNION ALL
SELECT * FROM (SELECT id,available_at FROM _dever_job WHERE state='running' AND attempt < retries AND lease_until <= ?1 AND available_at <= ?1 ORDER BY available_at,id LIMIT 1)
) ORDER BY available_at,id LIMIT 1)"
)?.query_row([now], |row| Ok(Claim {
id: row.get(0)?, target: row.get(1)?, payload: row.get(2)?, schema: row.get(3)?, execution: row.get(4)?,
policy: Policy { retries: row.get(5)?, timeout_ms: row.get(6)? }, attempt: row.get::<_,i64>(7)? + 1,
token, lease_until, execution_tenant: row.get(8)?,
})).optional()?;
if let Some(value) = &value {
connection.prepare_cached("UPDATE _dever_job SET state='running',attempt=?1,token=?2,lease_until=?3,error=CASE WHEN state='running' THEN 'lease expired' ELSE error END WHERE id=?4")?.execute(params![value.attempt,value.token,lease_until,value.id])?;
}
Ok(value)
}).await.unwrap()
}
pub(super) async fn complete(&mut self, claim: &Claim) {
let (id, token) = (claim.id.clone(), claim.token.clone());
let started = Instant::now();
self.transaction(move |connection| {
let now = 1000i64.checked_add(started.elapsed().as_millis().try_into().unwrap()).unwrap();
let changed = connection.prepare_cached("UPDATE _dever_job SET state=?1,available_at=?2,error=?3,token=NULL,lease_until=NULL WHERE id=?4 AND state='running' AND token=?5 AND lease_until > ?6")?.execute(params!["completed",now,None::<&str>,id,token,now])?;
assert_eq!(changed, 1);
Ok(())
}).await.unwrap();
}
}
+320
View File
@@ -0,0 +1,320 @@
use super::*;
use crate::job::{Failure, heartbeat, queue};
use std::{
future::pending,
sync::atomic::{AtomicBool, Ordering},
};
struct Finished(Arc<AtomicBool>);
impl Drop for Finished {
fn drop(&mut self) {
self.0.store(true, Ordering::SeqCst);
}
}
#[test]
#[ignore = "requires explicit isolated database.postgres_test"]
fn committed_handler_wins_renewal_race_while_scope_drains_children() {
fixture(async |case, scope| {
let registry = case.registry(scope).await;
case.executor(scope)
.await
.initialize(scope, "CREATE TABLE business(id BIGINT PRIMARY KEY)")
.await
.unwrap();
let claim = Arc::new(claimed(case, scope, "commit-race", 300).await);
let value = claim.clone();
let owned = case.clone();
let finished = Arc::new(AtomicBool::new(false));
let observed = finished.clone();
let handler = scope
.spawn(move |child| async move {
let mut executor = owned.executor(&child).await;
let owner = executor.begin(&child).await.unwrap();
executor
.execute(&child, "INSERT INTO business VALUES (1)", vec![])
.await
.unwrap();
job::complete(
&mut executor,
&child,
&value,
crate::database::now().unwrap(),
)
.await
.unwrap();
let (entered, ready) = tokio::sync::oneshot::channel();
let descendant = child
.spawn(move |grandchild| async move {
grandchild
.blocking(move || {
let _finished = Finished(observed);
entered.send(()).unwrap();
std::thread::sleep(Duration::from_millis(450));
})
.await
.unwrap();
})
.unwrap();
ready.await.unwrap();
tokio::time::sleep(Duration::from_millis(150)).await;
executor.finish(&child, owner, true).await.unwrap();
drop(descendant);
Ok(())
})
.unwrap();
let config = job::Config {
lease_ms: 300,
..Default::default()
};
heartbeat::run(scope, registry, (0, None), &config, &claim, handler)
.await
.unwrap();
assert!(finished.load(Ordering::SeqCst));
assert_eq!(case.count(scope, "SELECT COUNT(*) FROM business").await, 1);
assert_eq!(
case.count(
scope,
"SELECT COUNT(*) FROM _dever_job WHERE state='completed' AND attempt=1"
)
.await,
1
);
let mut executor = case.executor(scope).await;
assert!(
job::claim(
&mut executor,
scope,
crate::database::now().unwrap() + 1000,
300
)
.await
.unwrap()
.is_none()
);
});
}
async fn claimed(case: &Case, scope: &Scope<'_>, key: &str, lease: i64) -> Claim {
let mut executor = case.executor(scope).await;
let now = crate::database::now().unwrap();
let mut request = submission(key);
request.policy.timeout_ms = 3000;
job::enqueue(&mut executor, scope, request, now)
.await
.unwrap();
job::claim(&mut executor, scope, now, lease)
.await
.unwrap()
.unwrap()
}
#[test]
#[ignore = "requires explicit isolated database.postgres_test"]
fn heartbeat_keeps_business_transaction_alive_across_multiple_leases() {
fixture(async |case, scope| {
let registry = case.registry(scope).await;
let mut executor = case.executor(scope).await;
executor
.initialize(scope, "CREATE TABLE business(id BIGINT PRIMARY KEY)")
.await
.unwrap();
let claim = Arc::new(claimed(case, scope, "long-transaction", 200).await);
let value = claim.clone();
let owned = case.clone();
let handler = scope
.spawn(move |child| async move {
let mut executor = owned.executor(&child).await;
let owner = executor.begin(&child).await.unwrap();
executor
.execute(&child, "INSERT INTO business VALUES (1)", vec![])
.await
.unwrap();
executor
.postgres_operation(async |client| {
client
.query_one("SELECT pg_sleep(0.7)", &[])
.await
.map_err(Error::from)
})
.await
.unwrap();
job::complete(
&mut executor,
&child,
&value,
crate::database::now().unwrap(),
)
.await
.unwrap();
executor.finish(&child, owner, true).await.unwrap();
Ok(())
})
.unwrap();
let config = job::Config {
lease_ms: 200,
..Default::default()
};
let (result, ()) = tokio::join!(
heartbeat::run(scope, registry, (0, None), &config, &claim, handler),
async {
tokio::time::sleep(Duration::from_millis(450)).await;
assert!(
job::claim(&mut executor, scope, crate::database::now().unwrap(), 200)
.await
.unwrap()
.is_none()
);
},
);
result.unwrap();
assert_eq!(case.count(scope, "SELECT COUNT(*) FROM business").await, 1);
assert_eq!(case.count(scope, "SELECT attempt FROM _dever_job").await, 1);
assert_eq!(
case.count(
scope,
"SELECT COUNT(*) FROM _dever_job WHERE state='completed'"
)
.await,
1
);
});
}
#[test]
#[ignore = "requires explicit isolated database.postgres_test"]
fn lost_fence_cancels_long_handler_and_joins_children_before_returning() {
fixture(async |case, scope| {
let registry = case.registry(scope).await;
let mut executor = case.executor(scope).await;
executor
.initialize(scope, "CREATE TABLE business(id BIGINT PRIMARY KEY)")
.await
.unwrap();
let claim = Arc::new(claimed(case, scope, "lost-fence", 300).await);
let finished = Arc::new(AtomicBool::new(false));
let observed = finished.clone();
let (entered, ready) = tokio::sync::oneshot::channel();
let owned = case.clone();
let handler = scope
.spawn(move |child| async move {
let mut executor = owned.executor(&child).await;
executor
.execute(&child, "INSERT INTO business VALUES (1)", vec![])
.await
.unwrap();
let owner = executor.begin(&child).await.unwrap();
executor
.execute(&child, "INSERT INTO business VALUES (2)", vec![])
.await
.unwrap();
let descendant = child
.spawn(move |_| async move {
let _finished = Finished(observed);
entered.send(()).unwrap();
pending::<()>().await;
})
.unwrap();
descendant.wait().await.map_err(|_| Failure::Handler)?;
executor.finish(&child, owner, true).await.unwrap();
Ok(())
})
.unwrap();
let config = job::Config {
lease_ms: 300,
..Default::default()
};
let (result, ()) = tokio::join!(
heartbeat::run(scope, registry, (0, None), &config, &claim, handler),
async {
ready.await.unwrap();
executor
.execute(scope, "UPDATE _dever_job SET token='other-worker'", vec![])
.await
.unwrap();
},
);
assert!(matches!(result, Err(Failure::LeaseLost)));
assert!(finished.load(Ordering::SeqCst));
assert_eq!(case.count(scope, "SELECT COUNT(*) FROM business").await, 1);
assert_eq!(case.count(scope, "SELECT id FROM business").await, 1);
assert!(
job::complete(
&mut executor,
scope,
&claim,
crate::database::now().unwrap()
)
.await
.is_err()
);
assert_eq!(case.count(scope, "SELECT attempt FROM _dever_job").await, 1);
assert_eq!(
case.count(
scope,
"SELECT COUNT(*) FROM _dever_job WHERE token='other-worker' AND state='running'"
)
.await,
1
);
});
}
#[test]
#[ignore = "requires explicit isolated database.postgres_test"]
fn renewal_checks_expiry_after_waiting_for_row_lock() {
fixture(async |case, scope| {
let claim = claimed(case, scope, "row-wait", 100).await;
let mut owner = case.executor(scope).await;
let mut waiting = case.executor(scope).await;
let transaction = owner.begin(scope).await.unwrap();
owner
.query(
scope,
"SELECT id FROM _dever_job WHERE id=$1 FOR UPDATE",
vec![Parameter::Text(claim.id.clone())],
1,
|row| row.get::<_, String>(0),
)
.await
.unwrap();
let before = crate::database::now().unwrap();
let (result, ()) = tokio::join!(
queue::renew(&mut waiting, scope, &claim, before, 1000),
async {
tokio::time::sleep(Duration::from_millis(200)).await;
owner.finish(scope, transaction, true).await.unwrap();
},
);
assert!(result.is_err());
assert_eq!(
case.count(scope, "SELECT lease_until FROM _dever_job")
.await,
claim.lease_until
);
let replacement = job::claim(&mut waiting, scope, crate::database::now().unwrap(), 1000)
.await
.unwrap()
.unwrap();
assert_ne!(replacement.token, claim.token);
assert!(
queue::renew(
&mut waiting,
scope,
&claim,
crate::database::now().unwrap(),
1000
)
.await
.is_err()
);
job::complete(
&mut waiting,
scope,
&replacement,
crate::database::now().unwrap(),
)
.await
.unwrap();
});
}
+120
View File
@@ -0,0 +1,120 @@
use super::*;
use std::sync::Arc;
async fn locked_claim(case: &Case, scope: &Scope<'_>) -> (Executor, Executor, Claim, bool) {
let mut owner = case.executor(scope).await;
let waiting = case.executor(scope).await;
job::enqueue(&mut owner, scope, submission("locked"), NOW)
.await
.unwrap();
let claim = job::claim(&mut owner, scope, NOW, 100)
.await
.unwrap()
.unwrap();
let transaction = owner.begin(scope).await.unwrap();
owner
.query(
scope,
"SELECT id FROM _dever_job WHERE id=$1 FOR UPDATE",
vec![Parameter::Text(claim.id.clone())],
1,
|row| row.get::<_, String>(0),
)
.await
.unwrap();
(owner, waiting, claim, transaction)
}
#[test]
#[ignore = "requires explicit isolated database.postgres_test"]
fn lease_expiration_after_row_lock_wait_rolls_back_business_writes() {
fixture(async |case, scope| {
let (mut owner, mut waiting, claim, transaction) = locked_claim(case, scope).await;
waiting
.initialize(scope, "CREATE TABLE business(id BIGINT PRIMARY KEY)")
.await
.unwrap();
let business = waiting.begin(scope).await.unwrap();
waiting
.execute(scope, "INSERT INTO business VALUES (1)", vec![])
.await
.unwrap();
let (result, ()) =
tokio::join!(job::complete(&mut waiting, scope, &claim, NOW + 1), async {
tokio::time::sleep(Duration::from_millis(250)).await;
owner.finish(scope, transaction, true).await.unwrap();
},);
assert!(
result.is_err(),
"lease checked before the row lock was acquired"
);
assert!(waiting.finish(scope, business, true).await.is_err());
assert_eq!(case.count(scope, "SELECT COUNT(*) FROM business").await, 0);
let retry = job::claim(&mut waiting, scope, NOW + 1000, 1000)
.await
.unwrap()
.unwrap();
assert_ne!(retry.token, claim.token);
assert!(
job::complete(&mut waiting, scope, &claim, NOW + 1001)
.await
.is_err()
);
job::complete(&mut waiting, scope, &retry, NOW + 1001)
.await
.unwrap();
});
}
#[test]
#[ignore = "requires explicit isolated database.postgres_test"]
fn claim_clock_includes_table_lock_wait() {
fixture(async |case, scope| {
let mut owner = case.executor(scope).await;
let mut waiting = case.executor(scope).await;
job::enqueue(&mut owner, scope, submission("wait"), NOW)
.await
.unwrap();
let transaction = owner.begin(scope).await.unwrap();
owner
.initialize(scope, "LOCK TABLE _dever_job IN ACCESS EXCLUSIVE MODE")
.await
.unwrap();
let (result, ()) = tokio::join!(job::claim(&mut waiting, scope, NOW, 100), async {
tokio::time::sleep(Duration::from_millis(250)).await;
owner.finish(scope, transaction, true).await.unwrap();
},);
assert!(result.unwrap().unwrap().lease_until >= NOW + 300);
});
}
#[test]
#[ignore = "requires explicit isolated database.postgres_test"]
fn cancellation_discards_waiting_transaction_and_preserves_recovery() {
fixture(async |case, scope| {
let (mut owner, mut waiting, claim, transaction) = locked_claim(case, scope).await;
let claim = Arc::new(claim);
let copy = claim.clone();
let (started, ready) = tokio::sync::oneshot::channel();
let task = scope
.spawn(move |child| async move {
started.send(()).unwrap();
job::complete(&mut waiting, &child, &copy, NOW + 1).await
})
.unwrap();
ready.await.unwrap();
tokio::time::sleep(Duration::from_millis(50)).await;
assert!(task.stop().await.unwrap().is_none());
owner.finish(scope, transaction, true).await.unwrap();
let mut replacement = case.executor(scope).await;
let next = job::claim(&mut replacement, scope, NOW + 1000, 1000)
.await
.unwrap()
.unwrap();
assert_eq!(next.id, claim.id);
assert_ne!(next.token, claim.token);
job::complete(&mut replacement, scope, &next, NOW + 1001)
.await
.unwrap();
});
}
+194
View File
@@ -0,0 +1,194 @@
use crate::{
adapter::Document,
database::{Binding, Database, Error, Executor, Options, Parameter, Registry},
job::{self, Claim, Execution, Policy, Submission},
task::{Runtime, Scope},
wire::json::Json,
};
use futures_util::FutureExt;
use std::{
panic::AssertUnwindSafe,
path::PathBuf,
sync::{Arc, LazyLock},
time::Duration,
};
mod heartbeat;
mod lease;
mod queue;
mod schedule;
mod workers;
const NOW: i64 = 1_700_000_000_000;
#[derive(Clone)]
struct Case {
database: Database,
schema: String,
directory: PathBuf,
}
impl Case {
async fn registry(&self, scope: &Scope<'_>) -> Arc<Registry> {
Registry::load(
&self.directory.join("setting.json"),
&[Binding {
storage: crate::database::Storage::Global,
component: "demo",
database: Some("default"),
table: job::schema::TABLE,
schema: job::schema::SQL,
plan: None,
references: &[],
}],
scope,
)
.await
.unwrap()
}
async fn executor(&self, scope: &Scope<'_>) -> Executor {
let mut executor = self.database.executor().await.unwrap();
executor
.query(
scope,
"SELECT set_config('search_path',$1,false)",
vec![Parameter::Text(self.schema.clone())],
1,
|row| row.get::<_, String>(0),
)
.await
.unwrap();
executor
}
async fn count(&self, scope: &Scope<'_>, sql: &'static str) -> i64 {
self.executor(scope)
.await
.query(scope, sql, vec![], 1, |row| row.get::<_, i64>(0))
.await
.unwrap()[0]
}
}
fn fixture(check: impl AsyncFnOnce(&Case, &Scope<'_>)) {
let path =
std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../../config/setting.json");
let document = Document::load(&path).expect("cannot read PostgreSQL test settings");
let root: serde_json::Value = document.decode().expect("invalid PostgreSQL test settings");
let value = root
.get("database")
.and_then(|database| database.get("postgres_test"))
.expect("database.postgres_test must select an isolated test database");
let mut options: Options = serde_json::from_value(value.clone())
.unwrap_or_else(|_| panic!("invalid PostgreSQL test options"));
assert_eq!(options.driver, "postgres");
options.max_connections = 8;
options.wait_timeout_ms = 1000;
options.io_timeout_ms = 5000;
Runtime::new(Default::default())
.unwrap()
.run(async |scope| {
let database = Database::open(std::path::Path::new("."), options, scope)
.await
.unwrap();
let schema = format!("dever_job_test_{}", job::identifier().unwrap());
let directory = std::env::temp_dir().join(&schema);
std::fs::create_dir(&directory).unwrap();
let case = Case {
database,
schema,
directory,
};
let mut owner = case.database.executor().await.unwrap();
owner
.postgres_operation(async |client| {
client
.batch_execute(&format!("CREATE SCHEMA {}", case.schema))
.await
.map_err(Error::from)
})
.await
.unwrap();
let outcome = AssertUnwindSafe(async {
use std::{
io::Write,
os::unix::fs::{OpenOptionsExt, PermissionsExt},
};
std::fs::set_permissions(&case.directory, std::fs::Permissions::from_mode(0o700))
.unwrap();
let mut connection = value.clone();
connection["max_connections"] = 8.into();
let url = connection["url"].as_str().unwrap();
assert!(
url.parse::<tokio_postgres::Config>()
.unwrap()
.get_options()
.is_none()
);
let separator = if url.contains('?') { '&' } else { '?' };
connection["url"] =
format!("{url}{separator}options=-csearch_path%3D{}", case.schema).into();
let mut settings = std::fs::OpenOptions::new()
.write(true)
.create_new(true)
.mode(0o600)
.open(case.directory.join("setting.json"))
.unwrap();
settings
.write_all(
&serde_json::to_vec(
&serde_json::json!({"database":{"default":connection}}),
)
.unwrap(),
)
.unwrap();
let mut executor = case.executor(scope).await;
executor
.schema(scope, job::schema::TABLE, job::schema::SQL)
.await
.unwrap();
executor
.schema(scope, job::schema::TABLE, job::schema::SQL)
.await
.unwrap();
drop(executor);
tokio::time::timeout(Duration::from_secs(30), check(&case, scope))
.await
.expect("PostgreSQL Job test deadline exceeded");
})
.catch_unwind()
.await;
let cleanup = owner
.postgres_operation(async |client| {
client
.batch_execute(&format!("DROP SCHEMA {} CASCADE", case.schema))
.await
.map_err(Error::from)
})
.await;
case.database.close();
std::fs::remove_dir_all(&case.directory).unwrap();
cleanup.expect("PostgreSQL Job test schema cleanup failed");
if let Err(panic) = outcome {
std::panic::resume_unwind(panic);
}
})
.unwrap();
}
fn submission(key: &str) -> Submission<'_> {
static PAYLOAD: LazyLock<Json> = LazyLock::new(|| Json::parse(r#"{"number":1}"#).unwrap());
Submission {
target: "demo.account.publish",
payload: Some(&PAYLOAD),
schema: "schema-1",
execution: &Execution::System { tenant: None },
key,
policy: Policy {
retries: 2,
timeout_ms: 50,
},
at: NOW,
}
}
+322
View File
@@ -0,0 +1,322 @@
use super::*;
#[test]
#[ignore = "requires explicit isolated database.postgres_test"]
fn identity_idempotency_retry_and_terminal_keys() {
fixture(async |case, scope| {
let mut executor = case.executor(scope).await;
let identity = Execution::User {
provider: "session".into(),
subject: "user:7".into(),
session: "test-session".into(),
site: "admin".into(),
tenant: None,
tenant_id: None,
permission: "demo.account.admin.publish".into(),
};
let mut first = submission("same");
first.execution = &identity;
let id = job::enqueue(&mut executor, scope, first, NOW)
.await
.unwrap()
.into_text();
let mut duplicate = submission("same");
let payload = Json::parse(r#"{"number":999}"#).unwrap();
duplicate.payload = Some(&payload);
assert_eq!(
job::enqueue(&mut executor, scope, duplicate, NOW)
.await
.unwrap()
.into_text(),
id
);
assert!(
job::claim(&mut executor, scope, NOW - 1000, 1000)
.await
.unwrap()
.is_none()
);
let claim = job::claim(&mut executor, scope, NOW, 1000)
.await
.unwrap()
.unwrap();
assert_eq!(claim.id, id);
assert_eq!(claim.payload, r#"{"number":1}"#);
assert_eq!(claim.schema, "schema-1");
assert_eq!(
serde_json::from_str::<serde_json::Value>(&claim.execution).unwrap(),
serde_json::to_value(&identity).unwrap()
);
let config = job::Config::default();
job::fail(&mut executor, scope, &claim, NOW + 1, &config)
.await
.unwrap();
assert!(
job::claim(&mut executor, scope, NOW + 2, 1000)
.await
.unwrap()
.is_none()
);
let retry = job::claim(&mut executor, scope, NOW + 1 + config.delay(1) + 1000, 1000)
.await
.unwrap()
.unwrap();
assert_eq!(retry.id, id);
assert_eq!(retry.attempt, 2);
assert_ne!(retry.token, claim.token);
job::fail(
&mut executor,
scope,
&retry,
retry.lease_until - 500,
&config,
)
.await
.unwrap();
assert!(
job::claim(&mut executor, scope, NOW + 1_000_000, 1000)
.await
.unwrap()
.is_none()
);
assert_eq!(
case.count(scope, "SELECT COUNT(*) FROM _dever_job WHERE state='dead'")
.await,
1
);
let replacement = job::enqueue(&mut executor, scope, submission("same"), NOW)
.await
.unwrap()
.into_text();
assert_ne!(replacement, id);
let owner = executor.begin(scope).await.unwrap();
job::enqueue(&mut executor, scope, submission("rollback"), NOW)
.await
.unwrap();
executor.finish(scope, owner, false).await.unwrap();
assert_eq!(
case.count(
scope,
"SELECT COUNT(*) FROM _dever_job WHERE task_key='rollback'"
)
.await,
0
);
assert!(Json::parse("{").is_err());
assert_eq!(
case.count(
scope,
"SELECT COUNT(*) FROM _dever_job WHERE task_key='invalid'"
)
.await,
0
);
});
}
#[test]
#[ignore = "requires explicit isolated database.postgres_test"]
fn concurrent_enqueue_claim_and_skip_locked_keep_single_ownership() {
fixture(async |case, scope| {
let mut left = case.executor(scope).await;
let mut right = case.executor(scope).await;
let (left_id, right_id) = tokio::join!(
job::enqueue(&mut left, scope, submission("same"), NOW),
job::enqueue(&mut right, scope, submission("same"), NOW),
);
assert_eq!(left_id.unwrap().into_text(), right_id.unwrap().into_text());
let (first, second) = tokio::join!(
job::claim(&mut left, scope, NOW, 10_000),
job::claim(&mut right, scope, NOW, 10_000),
);
assert_eq!(
usize::from(first.unwrap().is_some()) + usize::from(second.unwrap().is_some()),
1
);
let locked = job::enqueue(&mut left, scope, submission("locked"), NOW)
.await
.unwrap()
.into_text();
let free = job::enqueue(&mut right, scope, submission("free"), NOW)
.await
.unwrap()
.into_text();
let owner = left.begin(scope).await.unwrap();
left.query(
scope,
"SELECT id FROM _dever_job WHERE id=$1 FOR UPDATE",
vec![Parameter::Text(locked)],
1,
|row| row.get::<_, String>(0),
)
.await
.unwrap();
let claim = tokio::time::timeout(
Duration::from_millis(500),
job::claim(&mut right, scope, NOW, 10_000),
)
.await
.unwrap()
.unwrap()
.unwrap();
assert_eq!(claim.id, free);
left.finish(scope, owner, true).await.unwrap();
job::block(&mut right, scope, &claim, NOW + 1)
.await
.unwrap();
assert_eq!(
case.count(
scope,
"SELECT COUNT(*) FROM _dever_job WHERE state='blocked'"
)
.await,
1
);
});
}
#[test]
#[ignore = "requires explicit isolated database.postgres_test"]
fn expired_claims_recover_or_exhaust_and_keep_token_fencing() {
fixture(async |case, scope| {
let mut executor = case.executor(scope).await;
for key in ["first", "second", "third"] {
job::enqueue(&mut executor, scope, submission(key), NOW)
.await
.unwrap();
}
let mut originals = Vec::new();
for _ in 0..3 {
originals.push(
job::claim(&mut executor, scope, NOW, 100)
.await
.unwrap()
.unwrap(),
);
}
let recovered = job::claim(&mut executor, scope, NOW + 1000, 1000)
.await
.unwrap()
.unwrap();
assert_eq!(recovered.attempt, 2);
assert_eq!(
case.count(
scope,
"SELECT COUNT(*) FROM _dever_job WHERE state='pending'"
)
.await,
2
);
for original in &originals {
assert!(
job::complete(&mut executor, scope, original, NOW + 1001)
.await
.is_err()
);
}
job::complete(&mut executor, scope, &recovered, NOW + 1001)
.await
.unwrap();
let exhausted = job::claim(&mut executor, scope, NOW + 1001, 100)
.await
.unwrap()
.unwrap();
let next = job::claim(&mut executor, scope, NOW + 2000, 1000)
.await
.unwrap()
.unwrap();
assert_ne!(next.id, exhausted.id);
assert_eq!(
case.count(scope, "SELECT COUNT(*) FROM _dever_job WHERE state='dead'")
.await,
1
);
job::complete(&mut executor, scope, &next, NOW + 2001)
.await
.unwrap();
assert!(
job::claim(&mut executor, scope, NOW + 10_000, 1000)
.await
.unwrap()
.is_none()
);
});
}
#[test]
#[ignore = "requires explicit isolated database.postgres_test"]
fn expired_cleanup_is_bounded_and_workers_skip_each_others_locks() {
fixture(async |case, scope| {
let mut left = case.executor(scope).await;
left.initialize(scope, "INSERT INTO _dever_job(id,target,payload,schema,execution,task_key,state,attempt,retries,timeout_ms,available_at,created_at,token,lease_until,execution_tenant) SELECT lpad(number::text,6,'0'),'demo.account.publish','null','zero','{\"kind\":\"System\",\"tenant\":null}',number::text,'running',1,2,50,0,0,'old',0,0 FROM generate_series(0,599) AS number").await.unwrap();
let mut right = case.executor(scope).await;
let owner = left.begin(scope).await.unwrap();
let first = job::claim(&mut left, scope, NOW, 10_000)
.await
.unwrap()
.unwrap();
let second = tokio::time::timeout(
Duration::from_millis(500),
job::claim(&mut right, scope, NOW, 10_000),
)
.await
.unwrap()
.unwrap()
.unwrap();
assert_eq!(first.id, "000000");
assert_eq!(second.id, "000257");
left.finish(scope, owner, true).await.unwrap();
assert_eq!(
case.count(
scope,
"SELECT COUNT(*) FROM _dever_job WHERE state='pending'"
)
.await,
512
);
assert_eq!(
case.count(
scope,
"SELECT COUNT(*) FROM _dever_job WHERE state='running' AND token='old'"
)
.await,
86
);
assert_eq!(
case.count(
scope,
"SELECT COUNT(*) FROM _dever_job WHERE state='running' AND token<>'old'"
)
.await,
2
);
});
}
#[test]
#[ignore = "requires explicit isolated database.postgres_test"]
fn exhausted_cleanup_does_not_hide_eligible_expired_work() {
fixture(async |case, scope| {
let mut executor = case.executor(scope).await;
executor.initialize(scope, "INSERT INTO _dever_job(id,target,payload,schema,execution,task_key,state,attempt,retries,timeout_ms,available_at,created_at,token,lease_until,execution_tenant) SELECT lpad(number::text,6,'0'),'demo.account.publish','null','zero','{\"kind\":\"System\",\"tenant\":null}',number::text,'running',1,CASE WHEN number=300 THEN 2 ELSE 1 END,50,0,0,'old',0,0 FROM generate_series(0,300) AS number").await.unwrap();
let claim = job::claim(&mut executor, scope, NOW, 1000)
.await
.unwrap()
.unwrap();
assert_eq!(claim.id, "000300");
assert_eq!(claim.attempt, 2);
assert_eq!(
case.count(scope, "SELECT COUNT(*) FROM _dever_job WHERE state='dead'")
.await,
256
);
assert_eq!(
case.count(
scope,
"SELECT COUNT(*) FROM _dever_job WHERE state='running' AND token='old'"
)
.await,
44
);
});
}
+117
View File
@@ -0,0 +1,117 @@
use super::*;
#[test]
#[ignore = "requires explicit isolated database.postgres_test"]
fn concurrent_cron_deduplicates_and_bounds_catchup() {
fixture(async |case, scope| {
let mut left = case.executor(scope).await;
let mut right = case.executor(scope).await;
let entry = job::Schedule {
binding: 0,
target: "demo.account.hourly",
schema: "zero",
cron: "* * * * *",
policy: Policy {
retries: 1,
timeout_ms: 50,
},
};
let (first, second) = tokio::join!(
job::tick(&mut left, scope, &entry, None, NOW),
job::tick(&mut right, scope, &entry, None, NOW)
);
assert_eq!(first.unwrap() + second.unwrap(), 1);
let claim = job::claim(&mut left, scope, NOW, 1000)
.await
.unwrap()
.unwrap();
assert_eq!(claim.payload, "null");
assert!(matches!(
serde_json::from_str::<Execution>(&claim.execution).unwrap(),
Execution::System { tenant: None }
));
job::complete(&mut left, scope, &claim, NOW + 1)
.await
.unwrap();
assert_eq!(
job::tick(&mut right, scope, &entry, None, NOW)
.await
.unwrap(),
0
);
assert_eq!(
job::tick(&mut left, scope, &entry, None, NOW + 60000 * 300)
.await
.unwrap(),
256
);
assert_eq!(
job::tick(&mut right, scope, &entry, None, NOW + 60000 * 300)
.await
.unwrap(),
44
);
assert_eq!(
job::tick(&mut left, scope, &entry, None, 0).await.unwrap(),
0
);
let changed = job::Schedule {
cron: "*/2 * * * *",
..entry
};
assert!(
job::tick(&mut right, scope, &changed, None, NOW)
.await
.is_err()
);
assert_eq!(
case.count(scope, "SELECT COUNT(*) FROM _dever_job_occurrence")
.await,
301
);
});
}
#[test]
#[ignore = "requires explicit isolated database.postgres_test"]
fn failed_scheduled_enqueue_rolls_back_cursor_and_occurrence() {
fixture(async |case, scope| {
let mut executor = case.executor(scope).await;
executor.initialize(scope, "CREATE FUNCTION reject_scheduled_job() RETURNS trigger LANGUAGE plpgsql AS 'BEGIN RAISE EXCEPTION ''test-only failure''; END'; CREATE TRIGGER reject_scheduled_job BEFORE INSERT ON _dever_job FOR EACH ROW EXECUTE FUNCTION reject_scheduled_job();").await.unwrap();
let entry = job::Schedule {
binding: 0,
target: "demo.account.hourly",
schema: "zero",
cron: "* * * * *",
policy: Policy {
retries: 1,
timeout_ms: 50,
},
};
assert!(
job::tick(&mut executor, scope, &entry, None, NOW)
.await
.is_err()
);
assert_eq!(
case.count(scope, "SELECT COUNT(*) FROM _dever_job_schedule")
.await,
0
);
assert_eq!(
case.count(scope, "SELECT COUNT(*) FROM _dever_job_occurrence")
.await,
0
);
executor
.initialize(scope, "DROP TRIGGER reject_scheduled_job ON _dever_job")
.await
.unwrap();
assert_eq!(
job::tick(&mut executor, scope, &entry, None, NOW)
.await
.unwrap(),
1
);
});
}
+229
View File
@@ -0,0 +1,229 @@
use super::*;
use std::{
future::pending,
sync::atomic::{AtomicBool, Ordering},
};
struct Finished(Arc<AtomicBool>);
impl Drop for Finished {
fn drop(&mut self) {
self.0.store(true, Ordering::SeqCst);
}
}
fn config() -> job::Config {
job::Config {
workers: 2,
poll_ms: 10,
lease_ms: 300,
..Default::default()
}
}
async fn enqueue(case: &Case, scope: &Scope<'_>) -> Policy {
let mut executor = case.executor(scope).await;
executor
.initialize(scope, "CREATE TABLE business(id BIGINT PRIMARY KEY)")
.await
.unwrap();
let mut value = submission("worker");
value.policy.timeout_ms = 3000;
let policy = value.policy;
job::enqueue(&mut executor, scope, value, crate::database::now().unwrap())
.await
.unwrap();
policy
}
#[test]
#[ignore = "requires explicit isolated database.postgres_test"]
fn short_lease_workers_renew_and_complete_once_without_replaying_handler() {
fixture(async |case, scope| {
let registry = case.registry(scope).await;
let policy = enqueue(case, scope).await;
let completed = Arc::new(tokio::sync::Notify::new());
let signal = completed.clone();
let owned = case.clone();
let workers = job::start(
scope,
registry,
config(),
&[job::Entry { binding: 0, policy }],
vec![],
move |_, _, claim, child| {
let (case, signal) = (owned.clone(), signal.clone());
async move {
let mut executor = case.executor(&child).await;
let owner = executor.begin(&child).await.unwrap();
executor
.execute(&child, "INSERT INTO business VALUES (1)", vec![])
.await
.unwrap();
executor
.postgres_operation(async |client| {
client
.query_one("SELECT pg_sleep(0.8)", &[])
.await
.map_err(Error::from)
})
.await
.unwrap();
job::complete(
&mut executor,
&child,
&claim,
crate::database::now().unwrap(),
)
.await
.unwrap();
executor.finish(&child, owner, true).await.unwrap();
signal.notify_one();
Ok(())
}
},
)
.await
.unwrap();
tokio::time::timeout(Duration::from_secs(3), completed.notified())
.await
.unwrap();
workers.stop.cancel();
workers.task.wait().await.unwrap().unwrap();
assert_eq!(case.count(scope, "SELECT COUNT(*) FROM business").await, 1);
assert_eq!(case.count(scope, "SELECT attempt FROM _dever_job").await, 1);
assert_eq!(
case.count(
scope,
"SELECT COUNT(*) FROM _dever_job WHERE state='completed'"
)
.await,
1
);
});
}
#[test]
#[ignore = "requires explicit isolated database.postgres_test"]
fn failed_renewal_cancels_transaction_and_preserves_claim_for_expiry() {
fixture(async |case, scope| {
let registry = case.registry(scope).await;
let policy = enqueue(case, scope).await;
case.executor(scope).await.initialize(scope, "CREATE FUNCTION reject_renewal() RETURNS trigger LANGUAGE plpgsql AS 'BEGIN IF OLD.state=''running'' AND NEW.state=''running'' AND OLD.token=NEW.token AND OLD.lease_until<>NEW.lease_until THEN RAISE EXCEPTION ''test renewal failure''; END IF; RETURN NEW; END'; CREATE TRIGGER reject_renewal BEFORE UPDATE ON _dever_job FOR EACH ROW EXECUTE FUNCTION reject_renewal()").await.unwrap();
let finished = Arc::new(AtomicBool::new(false));
let observed = finished.clone();
let owned = case.clone();
let workers = job::start(
scope,
registry,
config(),
&[job::Entry { binding: 0, policy }],
vec![],
move |_, _, _, child| {
let (case, observed) = (owned.clone(), observed.clone());
async move {
let _finished = Finished(observed);
let mut executor = case.executor(&child).await;
let owner = executor.begin(&child).await.unwrap();
executor
.execute(&child, "INSERT INTO business VALUES (1)", vec![])
.await
.unwrap();
pending::<()>().await;
executor.finish(&child, owner, true).await.unwrap();
Ok(())
}
},
)
.await
.unwrap();
tokio::time::timeout(Duration::from_secs(2), async {
while !finished.load(Ordering::SeqCst) {
tokio::time::sleep(Duration::from_millis(5)).await;
}
})
.await
.unwrap();
workers.stop.cancel();
workers.task.wait().await.unwrap().unwrap();
assert_eq!(case.count(scope, "SELECT COUNT(*) FROM business").await, 0);
assert_eq!(case.count(scope, "SELECT attempt FROM _dever_job").await, 1);
assert_eq!(
case.count(
scope,
"SELECT COUNT(*) FROM _dever_job WHERE state='running' AND error IS NULL"
)
.await,
1
);
});
}
#[test]
#[ignore = "requires explicit isolated database.postgres_test"]
fn cancelling_worker_drains_handler_and_pending_renewal_before_reclaim() {
fixture(async |case, scope| {
let registry = case.registry(scope).await;
let policy = enqueue(case, scope).await;
let entered = Arc::new(tokio::sync::Notify::new());
let ready = entered.clone();
let finished = Arc::new(AtomicBool::new(false));
let observed = finished.clone();
let owned = case.clone();
let workers = job::start(
scope,
registry,
config(),
&[job::Entry { binding: 0, policy }],
vec![],
move |_, _, _, child| {
let (case, observed, entered) = (owned.clone(), observed.clone(), entered.clone());
async move {
let _finished = Finished(observed);
let mut executor = case.executor(&child).await;
let owner = executor.begin(&child).await.unwrap();
executor
.execute(&child, "INSERT INTO business VALUES (1)", vec![])
.await
.unwrap();
entered.notify_one();
pending::<()>().await;
executor.finish(&child, owner, true).await.unwrap();
Ok(())
}
},
)
.await
.unwrap();
ready.notified().await;
let mut owner = case.executor(scope).await;
let transaction = owner.begin(scope).await.unwrap();
let token = owner
.query(
scope,
"SELECT token FROM _dever_job FOR UPDATE",
vec![],
1,
|row| row.get::<_, String>(0),
)
.await
.unwrap()
.remove(0);
tokio::time::sleep(Duration::from_millis(150)).await;
assert!(!finished.load(Ordering::SeqCst));
assert!(!matches!(workers.task.stop().await.unwrap(), Some(Ok(()))));
assert!(finished.load(Ordering::SeqCst));
owner.finish(scope, transaction, true).await.unwrap();
assert_eq!(case.count(scope, "SELECT COUNT(*) FROM business").await, 0);
let next = job::claim(
&mut owner,
scope,
crate::database::now().unwrap() + 1000,
300,
)
.await
.unwrap()
.unwrap();
assert_ne!(next.token, token);
assert_eq!(next.attempt, 2);
});
}
+110
View File
@@ -0,0 +1,110 @@
use super::*;
use dever_runtime::time::cron::Cron;
#[test]
fn utc_cron_validates_ranges_steps_and_calendar_or() {
let cron = Cron::parse("*/15 9-17/2 1,15 * 1-5").unwrap();
assert!(cron.matches(30, 11, 8, 10, 1));
assert!(cron.matches(45, 9, 15, 10, 0));
assert!(!cron.matches(45, 9, 8, 10, 0));
assert!(!cron.matches(10, 9, 15, 10, 0));
for source in [
"* * * *",
"60 * * * *",
"* 24 * * *",
"* * 0 * *",
"* * * 13 *",
"* * * * 7",
"*/0 * * * *",
"* * * * 5-1",
"* * * * -1",
] {
assert!(Cron::parse(source).is_err(), "{source}");
}
}
#[test]
fn schedule_cursor_and_completed_occurrences_are_durable() {
let directory = Directory::new();
let runtime = Runtime::new(Limits::default()).unwrap();
runtime
.run(async |scope| {
let database = Database::open(&directory.0, options(), scope)
.await
.unwrap();
let mut executor = database.executor().await.unwrap();
executor
.schema(scope, job::schema::TABLE, job::schema::SQL)
.await
.unwrap();
let entry = job::Schedule {
binding: 0,
target: "demo.account.job.hourly",
schema: "zero",
cron: "* * * * *",
policy: Policy {
retries: 1,
timeout_ms: 50,
},
};
assert_eq!(
job::tick(&mut executor, scope, &entry, None, 600000)
.await
.unwrap(),
1
);
let claim = job::claim(&mut executor, scope, 600000, 100)
.await
.unwrap()
.unwrap();
job::complete(&mut executor, scope, &claim, 600001)
.await
.unwrap();
assert_eq!(
job::tick(&mut executor, scope, &entry, None, 600020)
.await
.unwrap(),
0
);
assert_eq!(
job::tick(&mut executor, scope, &entry, None, 600000 + 60000 * 300)
.await
.unwrap(),
256
);
assert_eq!(
job::tick(&mut executor, scope, &entry, None, 600000 + 60000 * 300)
.await
.unwrap(),
44
);
assert_eq!(
job::tick(&mut executor, scope, &entry, None, 0)
.await
.unwrap(),
0
);
let changed = job::Schedule {
cron: "*/2 * * * *",
..entry
};
assert!(
job::tick(&mut executor, scope, &changed, None, 600000)
.await
.is_err()
);
drop(executor);
drop(database);
let database = Database::open(&directory.0, options(), scope)
.await
.unwrap();
let mut executor = database.executor().await.unwrap();
assert_eq!(
job::tick(&mut executor, scope, &entry, None, 600000 + 60000 * 300)
.await
.unwrap(),
0
);
})
.unwrap();
}
+482
View File
@@ -0,0 +1,482 @@
use dever_runtime::wire::{document, json::Json};
#[path = "json/encode.rs"]
mod encode;
#[path = "json/input.rs"]
mod input;
#[test]
fn fixed_wire_keeps_syntax_and_byte_limits_without_a_second_parse() {
use dever_runtime::wire;
for source in [r#""\ud800""#, r#""\udc00""#, r#""x" null"#] {
assert!(wire::decode_fixed::<String>(source).is_err(), "{source}");
}
let source = format!("\"{}\"", "x".repeat(document::MAX_BYTES - 2));
assert_eq!(
wire::decode_fixed::<String>(&source).unwrap().len(),
document::MAX_BYTES - 2
);
assert!(wire::decode_fixed::<String>(&(source + " ")).is_err());
let overhead = wire::response_fixed(&"").unwrap().len();
let value = "x".repeat(document::MAX_BYTES - overhead);
assert_eq!(
wire::response_fixed(&value).unwrap().len(),
document::MAX_BYTES
);
assert_eq!(
wire::response_fixed_bytes(&value).unwrap().len(),
document::MAX_BYTES
);
let oversized = value + "x";
assert!(wire::response_fixed(&oversized).is_err());
assert!(wire::response_fixed_bytes(&oversized).is_err());
let value = "雪\"\\\n";
assert_eq!(wire::response_fixed(&value), wire::response(&value));
assert_eq!(
wire::response_fixed_bytes(&value).unwrap(),
wire::response(&value).unwrap().into_bytes()
);
}
#[test]
fn parser_keeps_numbers_order_and_node_ids() {
let source = r#"{"b":1e+03,"a":[null,"雪",-0]}"#;
let document = document::parse(source).unwrap();
assert_eq!(document.root, 5);
assert_eq!(document::stringify(&document).unwrap(), source);
assert_eq!(document::property(&document, document.root, "b"), Some(0));
assert_eq!(document::element(&document, 4, 1), Some(2));
for source in ["{}", "[{},[]]", r#"{"":{}}"#] {
let parsed = document::parse(source).unwrap();
assert_eq!(document::stringify(&parsed).unwrap(), source);
assert_eq!(document::property(&parsed, parsed.root, "missing"), None);
}
}
#[test]
fn parser_rejects_duplicates_surrogates_and_trailing_content() {
for source in [
r#"{"name":1,"name":2}"#,
r#""\ud800""#,
r#"[1,]"#,
"null false",
r#""\udc00""#,
r#""\ud800\u0061""#,
r#""\u00""#,
"\"雪\n\"",
] {
assert!(document::parse(source).is_err(), "{source}");
}
let duplicate = r#"{"雪":0,"\u96ea":[}"#;
let error = document::parse(duplicate).unwrap_err();
assert_eq!(error.message, "duplicate JSON object key");
assert_eq!(
error.position,
duplicate[..duplicate.rfind(':').unwrap()].chars().count()
);
assert!(
Json::parse(duplicate)
.unwrap_err()
.to_string()
.contains("duplicate JSON object key")
);
assert_eq!(
document::parse(r#""\ud83d\ude42""#).unwrap().nodes,
vec![document::Value::String("🙂".into())]
);
for text in ["", "plain text", "雪🙂", "雪\"\\\n\0🙂", "\u{08}\u{0c}\r\t"] {
let source = serde_json::to_string(text).unwrap();
let parsed = document::parse(&source).unwrap();
assert_eq!(parsed.nodes, vec![document::Value::String(text.into())]);
assert_eq!(document::stringify(&parsed).unwrap(), source);
assert_eq!(Json::parse(&source).unwrap().as_text().unwrap(), text);
}
}
#[test]
fn document_string_escaping_matches_serde_for_unicode_and_control_boundaries() {
let encode = |text: String| {
let expected = serde_json::to_string(&text).unwrap();
let value = document::Document {
root: 0,
nodes: vec![document::Value::String(text)],
};
assert_eq!(document::stringify(&value).unwrap(), expected);
assert_eq!(document::parse(&expected).unwrap().nodes, value.nodes);
};
encode(
(0..=u32::from(char::MAX))
.filter_map(char::from_u32)
.collect(),
);
encode(
(128..=u32::from(char::MAX))
.filter_map(char::from_u32)
.collect(),
);
for character in (0..32).map(char::from).chain(['"', '\\']) {
for text in [
format!("雪🙂{character}𐀀a{character}"),
format!("{character}{character}雪🙂"),
] {
encode(text);
}
}
encode(String::new());
}
#[test]
fn json_property_update_and_scalar_reads_preserve_wire_text() {
let value = Json::parse(r#"{"a":1,"b":[true]}"#).unwrap();
assert_eq!(value.property("a").unwrap().unwrap().as_integer(), Ok(1));
assert_eq!(value.property("missing").unwrap(), None);
let updated = value
.set_property("a", &Json::parse("1.000e2").unwrap())
.unwrap();
assert_eq!(updated.as_str(), r#"{"a":1.000e2,"b":[true]}"#);
assert_eq!(
Json::parse(r#""hello""#).unwrap().as_text(),
Ok("hello".into())
);
}
#[test]
fn parser_enforces_depth_and_value_budgets() {
let nested = "[".repeat(document::MAX_DEPTH) + &"]".repeat(document::MAX_DEPTH);
assert!(document::parse(&nested).is_ok());
let too_deep = "[".repeat(document::MAX_DEPTH + 1) + &"]".repeat(document::MAX_DEPTH + 1);
assert!(document::parse(&too_deep).is_err());
let allowed = format!("[{}0]", "0,".repeat(document::MAX_VALUES - 2));
assert!(document::parse(&allowed).is_ok());
let many = format!("[{}0]", "0,".repeat(document::MAX_VALUES - 1));
assert!(document::parse(&many).is_err());
}
#[test]
fn invalid_utf8_escape_reports_a_codepoint_position_without_panicking() {
for source in [r#""\雪""#, r#""\u0雪雪""#] {
assert!(document::parse(source).is_err());
}
assert_eq!(document::parse("[\"雪\",]").unwrap_err().position, 5);
for source in ["\"plain", "\"雪🙂", "{\"雪🙂"] {
let error = document::parse(source).unwrap_err();
assert_eq!(error.message, "unterminated JSON string");
assert_eq!(error.position, source.chars().count());
}
}
#[test]
fn manual_documents_reject_forward_references() {
let document = document::Document {
root: 1,
nodes: vec![document::Value::Null, document::Value::Array(vec![1])],
};
assert!(document::stringify(&document).is_err());
}
#[test]
fn manual_document_numbers_preserve_json_lexical_rules() {
let encode = |raw: &str| {
document::stringify(&document::Document {
root: 0,
nodes: vec![document::Value::Number(raw.into())],
})
};
for raw in [
"0",
"-0",
"1.00",
"-0.125E+03",
"1e9999",
"1234567890123456789012345678901234e-12",
] {
assert_eq!(encode(raw).unwrap(), raw);
}
for raw in [
"",
"-",
"+1",
"01",
"-01",
"0x1",
"NaN",
"1.",
".1",
"1e",
"1e+",
"1_0",
" 1",
"1 ",
"1\n",
"null",
"[]",
"1true",
"1雪",
"0],\"x\":true",
] {
let error = encode(raw).unwrap_err();
assert_eq!(error.node, 0, "{raw}");
assert_eq!(error.message, "invalid JSON number", "{raw}");
}
}
#[test]
fn scalar_and_array_encoding_reuses_the_same_budgeted_parser() {
assert_eq!(Json::boolean(true).unwrap().as_str(), "true");
assert_eq!(Json::integer(42).unwrap().as_str(), "42");
assert_eq!(Json::string("雪").unwrap().as_str(), "\"雪\"");
assert!(Json::float(f64::NAN).is_err());
let array = Json::array(&[Json::integer(1).unwrap(), Json::boolean(false).unwrap()]).unwrap();
assert_eq!(array.as_str(), "[1,false]");
}
#[test]
fn source_document_keeps_the_256_container_contract() {
let nested = "[".repeat(256) + &"]".repeat(256);
let parsed = document::parse_with_depth(&nested, document::DOCUMENT_MAX_DEPTH).unwrap();
assert_eq!(
document::stringify_with_depth(&parsed, document::DOCUMENT_MAX_DEPTH).unwrap(),
nested
);
assert!(document::stringify_with_depth(&parsed, 255).is_err());
let too_deep = "[".repeat(257) + &"]".repeat(257);
assert!(document::parse_with_depth(&too_deep, document::DOCUMENT_MAX_DEPTH).is_err());
}
#[test]
fn raw_properties_preserve_subtree_bytes_and_distinguish_null_from_missing() {
let value = Json::parse(r#"{"payload": [ 1e9999, -0, "\u96ea" ], "empty": null}"#).unwrap();
assert_eq!(
value.property("payload").unwrap().unwrap().as_str(),
r#"[ 1e9999, -0, "\u96ea" ]"#
);
assert_eq!(value.property("empty").unwrap().unwrap().as_str(), "null");
assert_eq!(value.property("missing").unwrap(), None);
assert!(Json::integer(1).unwrap().property("key").is_err());
assert!(
Json::parse("[]")
.unwrap()
.set_property("key", &value)
.is_err()
);
let updated = value
.set_property("empty", &Json::boolean(true).unwrap())
.unwrap();
assert!(updated.as_str().contains(r#"[ 1e9999, -0, "\u96ea" ]"#));
assert_eq!(value.property("empty").unwrap().unwrap().as_str(), "null");
}
#[test]
fn property_keys_compare_decoded_text_without_changing_subtree_spelling() {
let source =
r#" {"\u0061": [ 1e9999, -0 ], "雪\"\\\n": "\u96ea", "\ud83d\ude42": null, "last": true} "#;
let value = Json::parse(source).unwrap();
for (key, expected) in [
("a", Some("[ 1e9999, -0 ]")),
("雪\"\\\n", Some(r#""\u96ea""#)),
("🙂", Some("null")),
("last", Some("true")),
(r"\u0061", None),
("missing", None),
] {
assert_eq!(
value.property(key).unwrap().as_ref().map(Json::as_str),
expected,
"{key}"
);
}
assert_eq!(value.as_str(), source);
for source in [r#"{"a":1,"\u0061":2}"#, r#"{"a":1,"tail":[}"#] {
assert!(Json::parse(source).is_err());
}
}
#[test]
fn shared_wire_checks_duplicate_names_and_all_three_budgets() {
use dever_runtime::wire;
for source in [r#"{"a":1,"\u0061":2}"#, r#"[{"a":1,"a":2}]"#, r#""\ud800""#] {
assert!(Json::parse(source).is_err(), "{source}");
assert!(
wire::decode::<serde_json::Value>(source).is_err(),
"{source}"
);
}
let maximum = format!("[{}0]", "0,".repeat(document::MAX_VALUES - 2));
assert!(Json::parse(&maximum).is_ok());
assert!(wire::decode::<Vec<i64>>(&maximum).is_ok());
let excessive = format!("[{}0]", "0,".repeat(document::MAX_VALUES - 1));
assert!(Json::parse(&excessive).is_err());
assert!(wire::decode::<Vec<i64>>(&excessive).is_err());
let nested = "[".repeat(document::MAX_DEPTH) + &"]".repeat(document::MAX_DEPTH);
assert!(Json::parse(&nested).is_ok());
assert!(Json::parse(&format!("[{nested}]")).is_err());
assert!(wire::response(&serde_json::from_str::<serde_json::Value>(&nested).unwrap()).is_err());
assert!(Json::string(&"x".repeat(document::MAX_BYTES - 2)).is_ok());
assert!(Json::string(&"x".repeat(document::MAX_BYTES - 1)).is_err());
}
#[test]
fn manual_document_shared_subgraphs_are_bounded_before_expansion() {
let mut graph = document::Document {
root: 0,
nodes: vec![document::Value::Null],
};
for index in 1..40 {
graph.nodes.push(document::Value::Array(vec![
index as i64 - 1,
index as i64 - 1,
]));
graph.root = index;
}
let error = document::stringify(&graph).unwrap_err();
assert!(error.node < 20, "{error}");
assert!(error.message.contains("65536"), "{error}");
let oversized = document::Document {
root: 1,
nodes: vec![
document::Value::String("x".repeat(document::MAX_BYTES / 2)),
document::Value::Array(vec![0, 0]),
],
};
assert!(
document::stringify(&oversized)
.unwrap_err()
.message
.contains("16 MiB")
);
}
#[test]
fn replacing_a_field_does_not_accumulate_obsolete_document_nodes() {
let source = format!(
r#"{{"count":0,"values":[{}0]}}"#,
"0,".repeat(document::MAX_VALUES - 4)
);
let original = Json::parse(&source).unwrap();
let updated = original
.set_property("count", &Json::integer(1).unwrap())
.unwrap();
assert_eq!(
updated.property("count").unwrap().unwrap().as_integer(),
Ok(1)
);
assert_eq!(
original.property("count").unwrap().unwrap().as_integer(),
Ok(0)
);
assert!(
updated
.set_property("extra", &Json::null().unwrap())
.is_err()
);
}
#[test]
fn fixed_record_encoding_keeps_byte_limits_and_escaping() {
#[derive(serde::Serialize)]
struct Record<'value> {
text: &'value str,
}
for text in ["", "雪\"\\\n"] {
let value = Record { text };
assert_eq!(Json::encode_proven(&value, 2, 1), Json::encode(&value));
}
let text = "x".repeat(document::MAX_BYTES - r#"{"text":""}"#.len());
assert_eq!(
Json::encode(&Record { text: &text })
.unwrap()
.as_str()
.len(),
document::MAX_BYTES
);
assert!(
Json::encode(&Record {
text: &(text + "x")
})
.is_err()
);
}
#[test]
fn decimal_wire_is_an_exact_string_not_a_floating_point_value() {
use dever_runtime::{value::decimal::Decimal, wire};
let value = wire::decode::<Decimal>(r#""1234567890123456789012345678901234""#).unwrap();
assert_eq!(
Json::encode(&value).unwrap().as_str(),
r#""1234567890123456789012345678901234""#
);
assert!(wire::decode::<Decimal>("1.25").is_err());
assert!(wire::decode::<Decimal>(r#""NaN""#).is_err());
assert!(wire::decode::<Decimal>(r#""12345678901234567890123456789012345""#).is_err());
}
#[test]
fn decimal_nullable_lists_preserve_extremes_and_normalized_output() {
use dever_runtime::{value::decimal::Decimal, wire};
let values = [
"-0.00",
"1.2300",
"1E-6176",
"9.999999999999999999999999999999999E6144",
];
let mut numbers = values
.map(|value| Some(Decimal::parse(value).unwrap()))
.to_vec();
numbers.push(None);
let encoded = Json::encode(&numbers).unwrap();
let expected = [
Some("0"),
Some("1.23"),
Some("1E-6176"),
Some("9.999999999999999999999999999999999E+6144"),
None,
];
assert_eq!(encoded.as_str(), serde_json::to_string(&expected).unwrap());
assert_eq!(
wire::decode::<Vec<Option<Decimal>>>(encoded.as_str()).unwrap(),
numbers
);
assert!(wire::decode::<Vec<Decimal>>(encoded.as_str()).is_err());
}
#[test]
fn raw_deserialization_preserves_subtree_text_and_enforces_bounds() {
let source = r#" {"huge": 1e9999, "text": "\u96ea", "array": [ 1, 2 ]} "#;
let value: Json = serde_json::from_str(source).unwrap();
assert_eq!(value.as_str(), source.trim());
assert_eq!(Json::parse(source).unwrap().as_str(), source);
let parsed = Json::parse(source.trim()).unwrap();
assert_eq!(value, parsed);
for value in [value, parsed] {
assert_eq!(format!("{value:?}"), format!("Json({:?})", source.trim()));
assert_eq!(
Json::encode_with(|output| output.json(&value))
.unwrap()
.as_str(),
source.trim()
);
assert_eq!(value.clone().into_text(), source.trim());
let child = value.property("array").unwrap().unwrap();
assert_eq!(
Json::encode_with(|output| output.json(&child))
.unwrap()
.as_str(),
"[ 1, 2 ]"
);
let updated = value.set_property("array", &child).unwrap();
assert_eq!(updated.property("array").unwrap().as_ref(), Some(&child));
assert_eq!(
updated.property("huge").unwrap().unwrap().as_str(),
"1e9999"
);
}
for source in [
r#"{"a":1,"\u0061":2}"#.to_owned(),
r#"["\ud800"]"#.to_owned(),
"[".repeat(65) + &"]".repeat(65),
format!("[{}0]", "0,".repeat(document::MAX_VALUES - 1)),
format!("\"{}\"", "x".repeat(document::MAX_BYTES - 1)),
] {
assert!(serde_json::from_str::<Json>(&source).is_err());
}
}
+208
View File
@@ -0,0 +1,208 @@
use dever_runtime::wire::{self, document, json::Json, serde_json};
#[test]
fn proven_fragments_keep_exact_counts_when_nested() {
for count in [0, 1, document::MAX_VALUES - 2, document::MAX_VALUES - 1] {
let values = vec![7i64; count];
let proven = Json::encode_proven(&values, count + 1, 1).unwrap();
let parsed = Json::parse(proven.as_str()).unwrap();
assert_eq!(Json::array(&[proven]), Json::array(&[parsed]));
}
let value = Json::encode_proven(&(), 1, 0).unwrap();
let mut wrapped = value;
for _ in 0..document::MAX_DEPTH {
wrapped = Json::array(&[wrapped]).unwrap();
}
assert!(Json::array(&[wrapped]).is_err());
}
#[test]
fn typed_output_counts_values_and_response_envelope() {
let maximum = vec![0i64; document::MAX_VALUES - 1];
let encoded = Json::encode(&maximum).unwrap();
assert_eq!(encoded.as_str(), serde_json::to_string(&maximum).unwrap());
assert!(Json::encode(&vec![0i64; document::MAX_VALUES]).is_err());
assert!(wire::response(&vec![0i64; document::MAX_VALUES - 4]).is_ok());
assert!(wire::response(&vec![0i64; document::MAX_VALUES - 3]).is_err());
}
#[test]
fn response_fragments_keep_the_envelope_and_its_budgets() {
for source in ["null", "[1e9999,-0]", r#"{"text":"\u96ea"}"#] {
let value = Json::parse(source).unwrap();
assert_eq!(
wire::response_with(|output| output.json(&value)).unwrap(),
format!("{{\"code\":0,\"message\":\"ok\",\"data\":{source}}}")
);
}
for count in [document::MAX_VALUES - 4, document::MAX_VALUES - 3] {
let values = vec![0; count];
let value = Json::encode(&values).unwrap();
let output = wire::response_with(|output| output.json(&value));
assert_eq!(output.is_ok(), count == document::MAX_VALUES - 4);
assert_eq!(output.is_ok(), wire::response(&values).is_ok());
}
let prefix = wire::response_with(|output| output.value(""))
.unwrap()
.len();
let value = Json::string(&"x".repeat(document::MAX_BYTES - prefix)).unwrap();
assert_eq!(
wire::response_with(|output| output.json(&value))
.unwrap()
.len(),
document::MAX_BYTES
);
let value = Json::string(&"x".repeat(document::MAX_BYTES - prefix + 1)).unwrap();
assert!(wire::response_with(|output| output.json(&value)).is_err());
}
#[test]
fn raw_subtrees_consume_the_outer_depth_and_value_budgets() {
let nested = "[".repeat(document::MAX_DEPTH) + &"]".repeat(document::MAX_DEPTH);
let typed: serde_json::Value = serde_json::from_str(&nested).unwrap();
assert_eq!(Json::encode(&typed).unwrap().as_str(), nested);
assert!(Json::encode(&vec![typed]).is_err());
for raw in [
Json::parse(&nested).unwrap(),
serde_json::from_str(&nested).unwrap(),
] {
assert_eq!(
Json::encode_with(|output| output.json(&raw))
.unwrap()
.as_str(),
nested
);
assert!(Json::array(&[raw]).is_err());
}
let maximum = Json::encode(&vec![0i64; document::MAX_VALUES - 1]).unwrap();
assert!(Json::encode_with(|output| output.json(&maximum)).is_ok());
assert!(Json::array(&[maximum]).is_err());
let allowed = Json::encode(&vec![0i64; document::MAX_VALUES - 2]).unwrap();
assert!(Json::array(&[allowed]).is_ok());
}
#[test]
fn raw_output_keeps_spelling_and_rejects_invalid_object_keys() {
let source = r#" {"雪": [1e9999, -0, "\u96ea"]} "#;
let raw = Json::parse(source).unwrap();
assert_eq!(
Json::array(&[raw]).unwrap().as_str(),
format!("[{}]", source.trim())
);
let duplicate =
serde_json::value::RawValue::from_string(r#"{"a":1,"\u0061":2}"#.into()).unwrap();
assert!(Json::encode(&duplicate).is_err());
let surrogate = serde_json::value::RawValue::from_string(r#""\ud800""#.into()).unwrap();
assert!(Json::encode(&surrogate).is_err());
}
#[test]
fn raw_arrays_match_serde_for_empty_and_mixed_inputs() {
let sources = [" null ", " [1e9999,-0] ", r#" {"雪":"\u96ea"} "#, " [] "];
for count in [0, 1, 4, 16] {
let sources: Vec<_> = sources.into_iter().cycle().take(count).collect();
let reference: Vec<Box<serde_json::value::RawValue>> = sources
.iter()
.map(|source| serde_json::from_str(source).unwrap())
.collect();
for values in [
sources
.iter()
.map(|source| Json::parse(source).unwrap())
.collect::<Vec<_>>(),
reference
.iter()
.cloned()
.map(Json::from_validated_raw)
.collect(),
] {
let encoded = Json::array(&values).unwrap();
assert_eq!(encoded.as_str(), serde_json::to_string(&reference).unwrap());
assert_eq!(
Json::array(std::slice::from_ref(&encoded))
.unwrap()
.as_str(),
format!("[{}]", encoded.as_str())
);
}
}
}
#[test]
fn validated_raw_transfers_its_original_allocation_into_text() {
let source = r#"{"huge":1e9999,"text":"\u96ea"}"#;
let raw: Box<serde_json::value::RawValue> = serde_json::from_str(source).unwrap();
let allocation = raw.get().as_ptr();
let value = Json::from_validated_raw(raw);
assert_eq!(value.as_str().as_ptr(), allocation);
let text = value.into_text();
assert_eq!(text.as_ptr(), allocation);
assert_eq!(text, source);
}
#[test]
fn raw_arrays_enforce_combined_value_and_separator_budgets() {
let mut values = vec![Json::null().unwrap(); document::MAX_VALUES - 1];
assert!(Json::array(&values).is_ok());
values.push(Json::null().unwrap());
assert!(Json::array(&values).is_err());
for extra in [0, 1] {
let values = [
Json::integer(0).unwrap(),
Json::string(&"x".repeat(document::MAX_BYTES - 6 + extra)).unwrap(),
];
let encoded = Json::array(&values);
if extra == 0 {
let encoded = encoded.unwrap();
assert_eq!(encoded.as_str().len(), document::MAX_BYTES);
assert!(Json::array(&[encoded]).is_err());
} else {
assert!(encoded.is_err());
}
}
}
#[test]
fn dynamic_output_keeps_byte_limits_and_counts_empty_containers() {
let allowed = vec!["x".repeat(document::MAX_BYTES - 4)];
assert_eq!(
Json::encode(&allowed).unwrap().as_str().len(),
document::MAX_BYTES
);
assert!(Json::encode(&vec!["x".repeat(document::MAX_BYTES - 3)]).is_err());
assert!(Json::encode(&vec![Vec::<i64>::new(); document::MAX_VALUES - 1]).is_ok());
assert!(Json::encode(&vec![Vec::<i64>::new(); document::MAX_VALUES]).is_err());
}
#[test]
fn validated_fragments_combine_with_serde_fields_and_keep_reusable_budgets() {
let fragment = Json::parse(" \n{\"雪\":[1e9999,-0,\"\\u96ea\"]}\t ").unwrap();
let nested = Json::encode_with(|output| {
output.object(|output| {
output.field_encoded("\"a\\\"雪\":", |output| output.json(&fragment))?;
output.field("b", false, |output| output.value(&vec![Some("雪\n"), None]))
})
})
.unwrap();
assert_eq!(
nested.as_str(),
"{\"a\\\"雪\":{\"雪\":[1e9999,-0,\"\\u96ea\"]},\"b\":[\"雪\\n\",null]}"
);
assert_eq!(
Json::array(std::slice::from_ref(&nested)).unwrap().as_str(),
format!("[{}]", nested.as_str())
);
assert_eq!(fragment.as_str(), " \n{\"雪\":[1e9999,-0,\"\\u96ea\"]}\t ");
let allowed =
Json::parse(&("[".repeat(document::MAX_DEPTH - 1) + &"]".repeat(document::MAX_DEPTH - 1)))
.unwrap();
let full = Json::array(&[allowed]).unwrap();
assert!(Json::array(&[full]).is_err());
let full =
Json::encode_with(|output| output.value(&vec![0; document::MAX_VALUES - 1])).unwrap();
assert!(Json::array(&[full]).is_err());
let allowed = Json::string(&"x".repeat(document::MAX_BYTES - 4)).unwrap();
let full = Json::array(&[allowed]).unwrap();
assert_eq!(full.as_str().len(), document::MAX_BYTES);
assert!(Json::array(&[full]).is_err());
}
+350
View File
@@ -0,0 +1,350 @@
use dever_runtime::wire::{self, document};
#[test]
#[ignore = "explicit strict-Unicode versus syntax-only performance diagnostic"]
fn escaped_string_validation_cost() {
use serde::{Deserializer, de::IgnoredAny};
use std::{hint::black_box, time::Instant};
let source = serde_json::to_string(&"雪\"\\\n".repeat(1024)).unwrap();
println!("case,sample,implementation,iterations,nanoseconds,checksum");
for sample in 0..15 {
for strict in if sample % 2 == 0 {
[true, false]
} else {
[false, true]
} {
let started = Instant::now();
for _ in 0..1000 {
let source = black_box(source.as_str());
if strict {
let mut decoder = serde_json::Deserializer::from_str(source);
decoder.deserialize_str(IgnoredAny).unwrap();
decoder.end().unwrap();
} else {
black_box(
serde_json::from_str::<&serde_json::value::RawValue>(source).unwrap(),
);
}
}
let elapsed = started.elapsed().as_nanos();
let label = if strict {
"rust_unicode"
} else {
"rust_syntax_only"
};
println!(
"escaped_string,{sample},{label},1000,{elapsed},{}",
source.len() * 1000
);
}
}
}
#[test]
fn from_text_preserves_unicode_validation_and_original_spelling() {
use wire::json::Json;
for unit in 0..=u16::MAX {
let source = format!("\"\\u{unit:04x}\"");
assert_eq!(
Json::parse(&source).is_ok(),
serde_json::from_str::<String>(&source).is_ok(),
"{source}"
);
}
for text in [
"",
"雪🙂",
"\\u1234",
"\\\"\\\\",
"\\\\\"\\\"",
"\"\"雪\"",
"\u{0}\n\t",
] {
let source = serde_json::to_string(text).unwrap();
for source in [source.clone(), format!(" \n{source}\t ")] {
let parsed = Json::parse(&source).unwrap();
assert_eq!(parsed.as_str(), source);
assert_eq!(parsed.as_text().unwrap(), text);
assert_eq!(
Json::array(&[parsed]).unwrap().into_text(),
format!("[{}]", source.trim())
);
}
}
for source in [
r#"{"雪":0,"\u96ea":1}"#,
r#"["\ud800"]"#,
r#"{"field":"\udc00"}"#,
r#"{"\ud800":0}"#,
r#""\ud800\u0061""#,
] {
assert!(Json::parse(source).is_err(), "{source}");
}
for source in [
r#""\ud83d\ude42""#,
r#"{"value":[true,false,null,-0,1e9999]}"#,
] {
assert_eq!(Json::parse(source).unwrap().as_str(), source);
}
}
#[test]
fn shaped_input_keeps_array_syntax_and_limits() {
for count in [0, 16, document::MAX_VALUES - 1] {
let source = format!("[{}]", vec!["0"; count].join(","));
assert_eq!(
wire::decode_shaped::<Vec<i64>>(&source),
wire::decode(&source)
);
assert_eq!(
wire::decode_shaped::<Vec<i64>>(&source).unwrap().len(),
count
);
}
for source in [
"[1,]",
"[null]",
"[true]",
"[{}]",
"[[1]]",
"[1.5]",
"[1] null",
"[9223372036854775808]",
"[1e9999]",
] {
assert!(wire::decode_shaped::<Vec<i64>>(source).is_err(), "{source}");
}
let source = format!("[{}]", vec!["0"; document::MAX_VALUES].join(","));
assert!(
wire::decode_shaped::<Vec<i64>>(&source)
.unwrap_err()
.contains("value")
);
}
#[test]
fn shaped_input_has_identical_acceptance_across_the_proven_byte_budget() {
for size in [
document::MAX_VALUES - 1,
document::MAX_VALUES,
document::MAX_VALUES + 1,
] {
let source = format!("[\"{}\"]", "x".repeat(size - 4));
assert_eq!(source.len(), size);
assert_eq!(
wire::decode_shaped::<Vec<String>>(&source),
wire::decode(&source)
);
let source = format!("[\"{}\\ud800\"]", "x".repeat(size - 10));
assert!(wire::decode_shaped::<Vec<String>>(&source).is_err());
}
let source = format!("[\"{}\"]", "x".repeat(document::MAX_BYTES - 4));
assert!(wire::decode_shaped::<Vec<String>>(&source).is_ok());
assert!(wire::decode_shaped::<Vec<String>>(&(source + " ")).is_err());
}
#[test]
fn single_pass_budget_counts_nested_containers_and_optional_values_once() {
use serde::Deserialize;
#[derive(Debug, Deserialize, PartialEq)]
#[serde(deny_unknown_fields)]
struct Fields {
first: Vec<Option<i64>>,
second: Option<Vec<Option<i64>>>,
}
for extra in [0, 1] {
let source = format!(
"{{\"first\":[{}],\"second\":[null]}}",
vec!["null"; document::MAX_VALUES - 4 + extra].join(",")
);
assert_eq!(wire::decode::<Fields>(&source).is_ok(), extra == 0);
assert_eq!(wire::decode_shaped::<Fields>(&source).is_ok(), extra == 0);
}
let padding = " ".repeat(document::MAX_VALUES);
for source in [
r#"{"first":[],"second":null}"#,
r#"{"first":[null,1],"second":[2,null]}"#,
r#"{"first":[],"second":null,"second":[]}"#,
r#"{"first":[],"fi\u0072st":[]}"#,
r#"{"first":[],"unknown":{}}"#,
] {
let source = format!("{padding}{source}");
let normal = wire::decode::<Fields>(&source);
let shaped = wire::decode_shaped::<Fields>(&source);
assert_eq!(normal.is_ok(), shaped.is_ok());
assert_eq!(normal.ok(), shaped.ok());
}
}
#[test]
fn single_pass_budget_preserves_serde_numeric_decoding() {
let padding = " ".repeat(document::MAX_VALUES);
for source in [
"[0,-0,-0.0,1,9223372036854775807,18446744073709551615]",
"[0.1,1.2345678901234567,1e-308,5e-324,1.7976931348623157e308]",
] {
let source = format!("{padding}{source}");
let normal = wire::decode::<Vec<f64>>(&source).unwrap();
let shaped = wire::decode_shaped::<Vec<f64>>(&source).unwrap();
assert_eq!(
normal
.iter()
.map(|value| value.to_bits())
.collect::<Vec<_>>(),
shaped
.iter()
.map(|value| value.to_bits())
.collect::<Vec<_>>()
);
}
for source in ["[1e9999]", "[null]", "[\"1\"]", "[NaN]"] {
assert!(wire::decode_shaped::<Vec<f64>>(&format!("{padding}{source}")).is_err());
}
let source = format!("{padding}[\"1.2300\",null,\"-1E-6176\"]");
assert_eq!(
wire::decode_shaped::<Vec<Option<dever_runtime::value::decimal::Decimal>>>(&source),
wire::decode(&source)
);
}
#[derive(Debug, serde::Deserialize, PartialEq)]
#[serde(deny_unknown_fields)]
struct JsonFields {
payload: wire::json::Json,
note: Option<String>,
nested: Option<JsonRecord>,
}
#[derive(Debug, serde::Deserialize, PartialEq)]
#[serde(deny_unknown_fields)]
struct JsonRecord {
payload: Option<wire::json::Json>,
count: i64,
}
fn decode_json_fields(source: &str) -> Result<JsonFields, String> {
wire::decode_json(source, |value: &JsonFields, budget| {
budget.open()?;
budget.value(3)?;
budget.json(&value.payload)?;
if let Some(nested) = &value.nested {
budget.open()?;
budget.value(2)?;
if let Some(payload) = &nested.payload {
budget.json(payload)?;
}
budget.close();
}
budget.close();
Ok(())
})
}
#[test]
fn json_input_budget_distinguishes_omitted_fields_from_explicit_null_at_limit() {
for count in [document::MAX_VALUES - 4, document::MAX_VALUES - 2] {
let payload = vec!["0"; count].join(",");
for suffix in ["", ",\"note\":null", ",\"note\":null,\"nested\":null"] {
let source = format!("{{\"payload\":[{payload}]{suffix}}}");
let expected = wire::decode::<JsonFields>(&source);
let actual = decode_json_fields(&source);
assert_eq!(actual.is_ok(), expected.is_ok(), "{count}/{suffix}");
assert_eq!(actual.ok(), expected.ok());
}
}
let payload = vec!["0"; document::MAX_VALUES - 2].join(",");
assert!(decode_json_fields(&format!("{{\"payload\":[{payload}]}}")).is_ok());
assert!(decode_json_fields(&format!("{{\"payload\":[{payload}],\"note\":null}}")).is_err());
}
#[test]
fn json_input_budget_combines_fixed_records_and_multiple_json_subtrees() {
for extra in [0, 1] {
let payload = vec!["0"; document::MAX_VALUES - 6 + extra].join(",");
let source =
format!("{{\"payload\":[{payload}],\"nested\":{{\"payload\":[0],\"count\":0}}}}");
let actual = decode_json_fields(&source);
assert_eq!(actual.is_ok(), extra == 0);
assert_eq!(actual.ok(), wire::decode::<JsonFields>(&source).ok());
}
let payload = vec!["0"; document::MAX_VALUES - 4].join(",");
for (field, accepted) in [("", true), (",\"payload\":null", false)] {
let source = format!("{{\"payload\":[{payload}],\"nested\":{{\"count\":0{field}}}}}");
assert_eq!(decode_json_fields(&source).is_ok(), accepted);
assert_eq!(wire::decode::<JsonFields>(&source).is_ok(), accepted);
}
}
#[test]
fn json_input_budget_counts_empty_containers_and_relative_subtree_depth() {
for (nested, parent_depth) in [(false, 1), (true, 2)] {
for extra in [0, 1] {
let depth = document::MAX_DEPTH - parent_depth + extra;
let payload = "[".repeat(depth) + &"]".repeat(depth);
let source = if nested {
format!("{{\"payload\":null,\"nested\":{{\"payload\":{payload},\"count\":0}}}}")
} else {
format!("{{\"payload\":{payload}}}")
};
assert_eq!(decode_json_fields(&source).is_ok(), extra == 0);
assert_eq!(wire::decode::<JsonFields>(&source).is_ok(), extra == 0);
}
}
for source in [
r#"{"payload":null,"nested":null}"#,
r#"{"payload":{},"nested":{"count":0}}"#,
r#"{"payload":[],"nested":{"payload":null,"count":0}}"#,
] {
assert_eq!(
decode_json_fields(source).unwrap(),
wire::decode(source).unwrap()
);
}
}
#[test]
fn json_input_keeps_raw_spelling_duplicate_keys_and_strict_unicode() {
let payload = r#"{ "value": [1e9999,-0,1.00e+2,"\u96ea","\ud83d\ude42"] }"#;
let source = format!("{{\"payload\":{payload}}}");
let decoded = decode_json_fields(&source).unwrap();
assert_eq!(decoded.payload.as_str(), payload);
assert_eq!(
wire::json::Json::array(&[decoded.payload])
.unwrap()
.as_str(),
format!("[{payload}]")
);
for source in [
r#"{"payload":null,"payl\u006fad":0}"#,
r#"{"payload":null,"note":null,"note":null}"#,
r#"{"payload":{"雪":0,"\u96ea":1}}"#,
r#"{"payload":"\ud800"}"#,
r#"{"payload":null,"note":"\udc00"}"#,
r#"{"payload":null,"nested":{"count":0,"count":1}}"#,
r#"{"payload":null,"extra":0}"#,
r#"{"payload":null} {}"#,
r#"{"payload":null,}"#,
] {
assert!(decode_json_fields(source).is_err(), "{source}");
assert!(wire::decode::<JsonFields>(source).is_err(), "{source}");
}
}
#[test]
fn json_input_limits_original_bytes_before_deserializing() {
let prefix = r#"{"payload":null,"note":""#;
let source = format!(
"{prefix}雪{}\"}}",
" ".repeat(document::MAX_BYTES - prefix.len() - 5)
);
assert_eq!(source.len(), document::MAX_BYTES);
assert!(decode_json_fields(&source).is_ok());
let oversized = source + " ";
let mut checked = false;
let result = wire::decode_json::<JsonFields>(&oversized, |_, _| {
checked = true;
Ok(())
});
assert!(result.unwrap_err().contains("16 MiB"));
assert!(!checked);
}
+345
View File
@@ -0,0 +1,345 @@
use super::Json;
use std::{hint::black_box, time::Instant};
#[test]
#[ignore = "explicit release performance probe"]
fn document_string_cost() {
use crate::wire::document::{self, Document, Value};
type Decode = fn(&str) -> Document;
let operations: [(&str, Decode); 2] = [
("dever", |source| document::parse(source).unwrap()),
// 独立 Serde 解码保留相同的单节点结果和字节预算。
("rust", |source| {
assert!(source.len() <= document::MAX_BYTES);
Document {
root: 0,
nodes: vec![Value::String(serde_json::from_str(source).unwrap())],
}
}),
];
println!("\ncase,sample,implementation,iterations,elapsed_ns,checksum");
for (name, text) in [
("short", "hello".to_owned()),
("ascii", "a".repeat(8192)),
("unicode", "雪🙂".repeat(1024)),
("escaped", "雪\"\\\n".repeat(1024)),
] {
let source = serde_json::to_string(&text).unwrap();
let expected = Document {
root: 0,
nodes: vec![Value::String(text.clone())],
};
for (_, operation) in operations {
assert_eq!(operation(&source), expected);
for _ in 0..100 {
black_box(operation(&source));
}
}
let iterations = if name == "short" { 10000 } else { 1000 };
for sample in 0..15 {
for offset in 0..operations.len() {
let (implementation, operation) = operations[(sample + offset) % operations.len()];
let started = Instant::now();
let mut checksum = 0;
for _ in 0..iterations {
let parsed = black_box(operation(black_box(&source)));
let Value::String(value) = &parsed.nodes[parsed.root] else {
panic!("expected string node");
};
checksum += value.len();
}
let elapsed = started.elapsed().as_nanos();
assert_eq!(checksum, text.len() * iterations);
println!(
"document_string_{name},{sample},{implementation},{iterations},{elapsed},{checksum}"
);
}
}
}
}
#[test]
#[ignore = "explicit release performance probe"]
fn encoded_fragment_cost() {
type Encode = fn(&str, bool) -> String;
println!("\ncase,sample,implementation,iterations,elapsed_ns,checksum");
for bytes in [64, 4096, 65536] {
let text = "a".repeat(bytes);
for nested in [false, true] {
let operations: [(&str, Encode); 4] = [
("proven", |text, nested| {
let value = Json::encode_proven(&(text, 7i64), 3, 1).unwrap();
if nested {
Json::array(&[value]).unwrap().into_text()
} else {
value.into_text()
}
}),
("shaped", |text, nested| {
let value = Json::encode(&(text, 7i64)).unwrap();
if nested {
Json::array(&[value]).unwrap().into_text()
} else {
value.into_text()
}
}),
// 一次写完用于观察融合输出的成本,不能当作中间 Json 的包装开销。
("rust_direct", |text, nested| {
assert!(text.len() <= (crate::wire::document::MAX_BYTES - 32) / 6);
if nested {
serde_json::to_string(&[(text, 7i64)]).unwrap()
} else {
serde_json::to_string(&(text, 7i64)).unwrap()
}
}),
// 与 Dever 一样先持有编码结果,再用标准库写入外层数组。
("rust_materialized", |text, nested| {
assert!(text.len() <= (crate::wire::document::MAX_BYTES - 32) / 6);
let value = serde_json::to_string(&(text, 7i64)).unwrap();
if nested {
let mut output = String::with_capacity(value.len() + 2);
output.push('[');
output.push_str(&value);
output.push(']');
output
} else {
value
}
}),
];
let expected = operations[2].1(&text, nested);
for (_, operation) in operations {
assert_eq!(operation(&text, nested), expected);
for _ in 0..100 {
black_box(operation(&text, nested));
}
}
let iterations = if bytes == 64 { 10_000 } else { 1000 };
for sample in 0..15 {
for offset in 0..operations.len() {
let (name, operation) = operations[(sample + offset) % operations.len()];
let started = Instant::now();
let mut checksum = 0;
for _ in 0..iterations {
checksum += black_box(operation(black_box(&text), nested)).len();
}
let elapsed = started.elapsed().as_nanos();
assert_eq!(checksum, expected.len() * iterations);
println!(
"encoded_{bytes}_{nested},{sample},{name},{iterations},{elapsed},{checksum}"
);
}
}
}
}
}
#[test]
#[ignore = "explicit release performance probe"]
fn command_json_decode_cost() {
use crate::wire;
#[derive(serde::Deserialize)]
#[serde(deny_unknown_fields)]
struct Input {
source: Json,
}
#[derive(serde::Deserialize)]
#[serde(deny_unknown_fields)]
struct Native {
source: Box<serde_json::value::RawValue>,
}
type Decoder = fn(&str) -> usize;
let decoders: [(&str, Decoder); 3] = [
("before", |source| {
wire::decode::<Input>(source).unwrap().source.as_str().len()
}),
("after", |source| {
wire::decode_json(source, |value: &Input, budget| {
budget.open()?;
budget.value(1)?;
budget.json(&value.source)?;
budget.close();
Ok(())
})
.unwrap()
.source
.as_str()
.len()
}),
// 原生 RawValue 不做去重和结构预算,只提供语法解码成本下界。
("rust_syntax_only", |source| {
serde_json::from_str::<Native>(source)
.unwrap()
.source
.get()
.len()
}),
];
println!("\ncase,sample,implementation,iterations,elapsed_ns,checksum");
for (name, text) in [
("short", "a".repeat(64)),
("plain_65536", "a".repeat(65536)),
("escaped", "雪\"\\\n".repeat(1024)),
] {
let payload = serde_json::to_string(&text).unwrap();
let source = format!("{{\"source\":{payload}}}");
let iterations = if name == "short" { 10_000 } else { 1000 };
for (_, decode) in decoders {
for _ in 0..100 {
assert_eq!(black_box(decode(&source)), payload.len());
}
}
for sample in 0..15 {
for offset in 0..decoders.len() {
let (label, decode) = decoders[(sample + offset) % decoders.len()];
let started = Instant::now();
let mut checksum = 0;
for _ in 0..iterations {
checksum += black_box(decode(black_box(&source)));
}
let elapsed = started.elapsed().as_nanos();
assert_eq!(checksum, payload.len() * iterations);
println!("command_json_{name},{sample},{label},{iterations},{elapsed},{checksum}");
}
}
}
}
#[test]
#[ignore = "explicit release performance probe"]
fn parse_object_keys_cost() {
println!("\ncase,sample,iterations,elapsed_ns,checksum");
for (name, suffix) in [
("short", String::new()),
("long", "field".repeat(32)),
("escaped", "雪\"\\\n".repeat(16)),
] {
let fields = (0..256)
.map(|index| (format!("key_{index:03}_{suffix}"), index))
.collect::<std::collections::BTreeMap<_, _>>();
let source = serde_json::to_string(&fields).unwrap();
assert_eq!(Json::parse(&source).unwrap().as_str(), source);
for _ in 0..100 {
black_box(Json::parse(&source).unwrap());
}
for sample in 0..15 {
let started = Instant::now();
let mut checksum = 0;
for _ in 0..1000 {
let parsed = Json::parse(black_box(&source)).unwrap();
checksum += black_box(parsed.as_str().len());
}
let elapsed = started.elapsed().as_nanos();
assert_eq!(checksum, source.len() * 1000);
println!("json_keys_{name},{sample},1000,{elapsed},{checksum}");
}
}
}
#[test]
#[ignore = "explicit release performance probe"]
fn parse_text_json_cost() {
println!("\ncase,sample,iterations,elapsed_ns,checksum");
for (name, text) in [
("ascii", "a".repeat(8192)),
("unicode", "雪🙂".repeat(1024)),
("escaped", "雪\"\\\n".repeat(1024)),
] {
let source = serde_json::to_string(&text).unwrap();
assert_eq!(Json::parse(&source).unwrap().as_text().unwrap(), text);
for _ in 0..100 {
black_box(Json::parse(&source).unwrap());
}
for sample in 0..15 {
let started = Instant::now();
let mut checksum = 0;
for _ in 0..1000 {
let parsed = Json::parse(black_box(&source)).unwrap();
checksum += black_box(parsed.as_str().len());
}
let elapsed = started.elapsed().as_nanos();
assert_eq!(checksum, source.len() * 1000);
println!("json_parse_{name},{sample},1000,{elapsed},{checksum}");
}
}
}
#[test]
#[ignore = "explicit release performance probe"]
fn deserialize_raw_json_cost() {
println!("\ncase,sample,iterations,elapsed_ns,checksum");
let values = (0..256).collect::<Vec<i64>>();
let text = serde_json::to_string(&"雪\"\\".repeat(512)).unwrap();
let array = format!(r#"{{"numbers":{values:?},"huge":1e9999}}"#);
let nested = "[".repeat(32) + &array + &"]".repeat(32);
for (name, source) in [("text", text), ("array", array), ("nested", nested)] {
let parsed: Json = serde_json::from_str(&source).unwrap();
assert_eq!(parsed.as_str(), source);
for _ in 0..100 {
black_box(serde_json::from_str::<Json>(&source).unwrap());
}
for sample in 0..15 {
let started = Instant::now();
let mut checksum = 0;
for _ in 0..1000 {
let parsed: Json = serde_json::from_str(black_box(&source)).unwrap();
checksum += black_box(parsed.as_str().len());
}
let elapsed = started.elapsed().as_nanos();
assert_eq!(checksum, source.len() * 1000);
println!("json_deserialize_{name},{sample},1000,{elapsed},{checksum}");
}
}
}
#[test]
#[ignore = "explicit release performance probe"]
fn property_lookup_cost() {
println!("\ncase,sample,iterations,elapsed_ns,checksum");
for (name, count, suffix) in [
("small", 4, ""),
("wide", 256, ""),
("escaped", 256, "雪\\\""),
] {
let names = (0..count)
.map(|index| format!("key_{index:03}_{suffix}"))
.collect::<Vec<_>>();
let fields = names
.iter()
.map(|key| format!("{}:1e9999", serde_json::to_string(key).unwrap()))
.collect::<Vec<_>>();
let source = format!("{{{}}}", fields.join(","));
let value = Json::parse(&source).unwrap();
let keys = [
names[0].as_str(),
&names[count / 2],
&names[count - 1],
"missing",
];
let lookup = || {
keys.iter()
.map(|key| {
value
.property(black_box(key))
.unwrap()
.map_or(0, |child| black_box(child.as_str().len()))
})
.sum::<usize>()
};
assert_eq!(lookup(), 18);
for _ in 0..100 {
black_box(lookup());
}
for sample in 0..15 {
let started = Instant::now();
let mut checksum = 0;
for _ in 0..1000 {
checksum += black_box(lookup());
}
let elapsed = started.elapsed().as_nanos();
assert_eq!(checksum, 18000);
println!("json_property_{name},{sample},1000,{elapsed},{checksum}");
}
assert_eq!(value.as_str(), source);
}
}
+221
View File
@@ -0,0 +1,221 @@
use dever_runtime::{
net::{self, Listener, Socket},
task::{Limits, Runtime, Scope},
};
use std::{
future::{Future, poll_fn},
task::Poll,
};
#[cfg(feature = "http")]
#[path = "net/performance.rs"]
mod performance;
fn runtime() -> Runtime {
Runtime::new(Limits {
threads: 2,
workers: 2,
tasks: 8,
})
.unwrap()
}
async fn pair(scope: &Scope<'_>) -> (Socket, Socket) {
let listener = net::listen("127.0.0.1", 0, scope).await.unwrap();
let client = net::connect_timeout("127.0.0.1", listener.port().unwrap(), 1000)
.await
.unwrap();
let server = listener.accept().await.unwrap();
(client, server)
}
async fn pending(future: std::pin::Pin<&mut impl Future>) {
let mut future = future;
poll_fn(|context| {
assert!(future.as_mut().poll(context).is_pending());
Poll::Ready(())
})
.await;
}
#[test]
fn dns_connect_bidirectional_io_and_shared_close_use_the_runtime() {
runtime()
.run(async |scope| {
let listener = net::listen("127.0.0.1", 0, scope).await.unwrap();
let port = listener.port().unwrap();
assert!((1..=65535).contains(&port));
let client = net::connect("localhost", port, scope).await.unwrap();
let server = listener.accept().await.unwrap();
client.write(b"abc").await.unwrap();
assert_eq!(server.read(2).await.unwrap(), Some(b"ab".to_vec()));
assert_eq!(server.read(2).await.unwrap(), Some(b"c".to_vec()));
server.write(b"reply").await.unwrap();
assert_eq!(client.read(5).await.unwrap(), Some(b"reply".to_vec()));
let alias = client.clone();
alias.close().unwrap();
assert!(client.write(b"closed").await.is_err());
assert!(client.read(1).await.is_err());
assert!(client.close().is_err());
assert_eq!(server.read(1).await.unwrap(), None);
listener.close().unwrap();
})
.unwrap();
}
#[test]
fn close_wakes_read_lock_waiters_and_pending_accepts() {
runtime()
.run(async |scope| {
let (client, _server) = pair(scope).await;
let mut first = std::pin::pin!(client.read(1));
let mut second = std::pin::pin!(client.read(1));
pending(first.as_mut()).await;
pending(second.as_mut()).await;
client.close().unwrap();
assert!(first.await.is_err());
assert!(second.await.is_err());
let listener = net::listen("127.0.0.1", 0, scope).await.unwrap();
let mut accepted = std::pin::pin!(listener.accept());
pending(accepted.as_mut()).await;
listener.clone().close().unwrap();
assert!(accepted.await.is_err());
assert!(listener.port().is_err());
})
.unwrap();
}
#[test]
fn read_timeout_includes_lock_waiting_and_does_not_consume_future_data() {
runtime()
.run(async |scope| {
let (client, server) = pair(scope).await;
{
let mut first = std::pin::pin!(client.read(1));
pending(first.as_mut()).await;
client.timeout(10).unwrap();
assert_eq!(
client.read(1).await.unwrap_err(),
"network operation timed out"
);
}
client.timeout(1000).unwrap();
server.write(b"x").await.unwrap();
assert_eq!(client.read(1).await.unwrap(), Some(b"x".to_vec()));
assert!(client.timeout(0).is_err());
assert!(client.read(0).await.is_err());
})
.unwrap();
}
#[test]
fn cancelled_partial_write_closes_but_cancelled_queued_write_preserves_socket() {
runtime()
.run(async |scope| {
let (client, server) = pair(scope).await;
let writer = client.clone();
let first = scope
.spawn_leaf(move || async move { writer.write(&vec![7; 32 * 1024 * 1024]).await })
.unwrap();
assert_eq!(server.read(1).await.unwrap(), Some(vec![7]));
let writer = client.clone();
let (started, ready) = tokio::sync::oneshot::channel();
let queued = scope
.spawn_leaf(move || async move {
let mut write = std::pin::pin!(writer.write(b"queued"));
pending(write.as_mut()).await;
started.send(()).unwrap();
write.await
})
.unwrap();
ready.await.unwrap();
assert!(queued.stop().await.unwrap().is_none());
client.timeout(1000).unwrap();
assert!(first.stop().await.unwrap().is_none());
assert!(client.timeout(1000).is_err());
server.timeout(1000).unwrap();
while server.read(65536).await.unwrap().is_some() {}
})
.unwrap();
}
#[test]
fn chunks_close_preserves_the_external_socket_and_terminal_errors_occur_once() {
runtime()
.run(async |scope| {
let (client, server) = pair(scope).await;
let chunks = client.chunks(2, std::convert::identity).unwrap();
let mut waiting = std::pin::pin!(chunks.next());
pending(waiting.as_mut()).await;
chunks.close();
assert!(waiting.await.unwrap().is_none());
server.write(b"ab").await.unwrap();
assert_eq!(client.read(2).await.unwrap(), Some(b"ab".to_vec()));
let chunks = client.chunks(2, std::convert::identity).unwrap();
client.close().unwrap();
assert!(chunks.next().await.unwrap().unwrap().is_err());
assert!(chunks.next().await.unwrap().is_none());
assert!(client.chunks(2, std::convert::identity).is_err());
})
.unwrap();
}
#[test]
fn connection_stream_is_lazy_and_closing_it_preserves_the_listener() {
runtime()
.run(async |scope| {
let listener: Listener = net::listen("127.0.0.1", 0, scope).await.unwrap();
let connections = listener.connections(std::convert::identity);
connections.close();
let _client = net::connect_timeout("127.0.0.1", listener.port().unwrap(), 1000)
.await
.unwrap();
let _server = listener.accept().await.unwrap();
let connections = listener.connections(std::convert::identity);
listener.close().unwrap();
assert!(connections.next().await.unwrap().unwrap().is_err());
assert!(connections.next().await.unwrap().is_none());
})
.unwrap();
}
#[test]
fn invalid_network_arguments_fail_before_waiting() {
runtime()
.run(async |scope| {
assert!(net::listen("127.0.0.1", -1, scope).await.is_err());
assert!(net::connect("127.0.0.1", 65536, scope).await.is_err());
assert_eq!(
net::connect_timeout("localhost", 80, 1000)
.await
.unwrap_err(),
"timed connect requires a numeric IP address"
);
assert!(net::connect_timeout("127.0.0.1", 80, 0).await.is_err());
})
.unwrap();
}
#[test]
fn short_reads_exact_limits_and_allocation_failure_preserve_remaining_bytes() {
runtime()
.run(async |scope| {
let (client, server) = pair(scope).await;
client.timeout(1000).unwrap();
server.write(&[0, 255, 1, 0, 2, 128, 3]).await.unwrap();
let mut received = Vec::new();
while received.len() != 7 {
let bytes = client.read(3).await.unwrap().unwrap();
assert!(!bytes.is_empty() && bytes.len() <= 3);
received.extend(bytes);
}
assert_eq!(received, [0, 255, 1, 0, 2, 128, 3]);
server.write(b"x").await.unwrap();
assert!(client.read(i64::MAX).await.is_err());
assert_eq!(client.read(1_048_576).await.unwrap(), Some(b"x".to_vec()));
server.close().unwrap();
assert_eq!(client.read(1_048_576).await.unwrap(), None);
assert_eq!(client.read(3).await.unwrap(), None);
})
.unwrap();
}
+141
View File
@@ -0,0 +1,141 @@
use super::{Limits, Runtime, Socket, net};
use bytes::BufMut;
use std::{io::ErrorKind, time::Instant};
use tokio::{io::AsyncWriteExt, net::TcpStream};
async fn independent_read(socket: &TcpStream, limit: usize) -> Option<Vec<u8>> {
socket.readable().await.unwrap();
let mut bytes = Vec::new();
bytes.try_reserve_exact(limit).unwrap();
loop {
let result = socket.try_read_buf(&mut (&mut bytes).limit(limit));
match result {
Ok(count) => return (count != 0).then_some(bytes),
Err(error) if error.kind() == ErrorKind::WouldBlock => {
socket.readable().await.unwrap();
}
Err(error) => panic!("independent TCP read failed: {error}"),
}
}
}
async fn sample(
peer: &mut TcpStream,
payload: &[u8],
limit: usize,
iterations: usize,
mut read: impl AsyncFnMut() -> Option<Vec<u8>>,
) -> (u128, usize, usize) {
let mut elapsed = 0;
let mut reads = 0;
let mut total = 0;
for _ in 0..iterations {
peer.write_all(payload).await.unwrap();
let mut received = 0;
loop {
let started = Instant::now();
let bytes = read().await;
elapsed += started.elapsed().as_nanos();
reads += 1;
let Some(bytes) = bytes else {
assert!(payload.is_empty());
break;
};
assert!(!bytes.is_empty() && bytes.len() <= limit);
assert!(received + bytes.len() <= payload.len());
assert_eq!(bytes, payload[received..received + bytes.len()]);
received += bytes.len();
total += bytes.len();
if received == payload.len() {
break;
}
}
}
assert_eq!(total, payload.len() * iterations);
(elapsed, reads, total)
}
#[test]
#[ignore = "explicit release performance probe; http enables the independent bytes/io-util peer"]
fn tcp_read_buffer_cost() {
let runtime = Runtime::new(Limits {
threads: 1,
workers: 1,
tasks: 8,
})
.unwrap();
runtime
.run(async |scope| {
eprintln!(
"tcp_read_buffer: 15 fixed rounds; one async worker; two persistent loopback pairs; \
peer writes, byte verification and result drops excluded from read timing; \
rust_independent uses Tokio try_read_buf and bytes::BufMut::limit directly; \
no shared Dever read implementation; direct peer excludes alias-close and read-lock checks; \
actual read counts are retained; empty payload cases measure established EOF"
);
println!("\ncase,sample,implementation,iterations,elapsed_ns,reads,bytes");
for (limit, payload_size, iterations) in [
(64, 64, 4096),
(65_536, 64, 2048),
(1_048_576, 64, 512),
(65_536, 65_536, 256),
(1_048_576, 65_536, 128),
(65_536, 0, 512),
(1_048_576, 0, 128),
] {
let listener = net::listen("127.0.0.1", 0, scope).await.unwrap();
let mut dever_peer =
TcpStream::connect(("127.0.0.1", listener.port().unwrap() as u16))
.await
.unwrap();
let socket: Socket = listener.accept().await.unwrap();
listener.close().unwrap();
let listener = tokio::net::TcpListener::bind(("127.0.0.1", 0)).await.unwrap();
let mut independent_peer = TcpStream::connect(listener.local_addr().unwrap())
.await
.unwrap();
let independent = listener.accept().await.unwrap().0;
independent.set_nodelay(true).unwrap();
dever_peer.set_nodelay(true).unwrap();
independent_peer.set_nodelay(true).unwrap();
drop(listener);
if payload_size == 0 {
dever_peer.shutdown().await.unwrap();
independent_peer.shutdown().await.unwrap();
}
let payload = (0..payload_size)
.map(|position| (position % 251) as u8)
.collect::<Vec<_>>();
sample(&mut dever_peer, &payload, limit, 16, async || {
socket.read(limit as i64).await.unwrap()
})
.await;
sample(&mut independent_peer, &payload, limit, 16, async || {
independent_read(&independent, limit).await
})
.await;
for round in 0..15 {
let mut order = ["dever", "rust_independent", "control"];
order.rotate_left(round % 3);
for label in order {
let (elapsed, reads, total) = if label == "rust_independent" {
sample(&mut independent_peer, &payload, limit, iterations, async || {
independent_read(&independent, limit).await
})
.await
} else {
sample(&mut dever_peer, &payload, limit, iterations, async || {
socket.read(limit as i64).await.unwrap()
})
.await
};
println!(
"tcp_read_{limit}_{payload_size},{round},{label},{iterations},{elapsed},{reads},{total}"
);
}
}
socket.close().unwrap();
}
})
.unwrap();
}
+262
View File
@@ -0,0 +1,262 @@
use dever_runtime::{
collection::parallel::{self, Error as ThreadError},
task::{Error, Group, Limits, Runtime, Task},
};
use std::{
future::{Future, poll_fn},
rc::Rc,
sync::{
Arc, Barrier,
atomic::{AtomicBool, AtomicUsize, Ordering},
},
task::Poll,
};
type Tasks = Group<Task<Result<(), &'static str>>, &'static str>;
fn runtime() -> Runtime {
Runtime::new(Limits {
threads: 2,
workers: 1,
tasks: 8,
})
.unwrap()
}
#[test]
fn synchronous_workers_process_values_once_with_bounded_overlap() {
let active = AtomicUsize::new(0);
let maximum = AtomicUsize::new(0);
let total = AtomicUsize::new(0);
let barrier = Barrier::new(2);
assert_eq!(
parallel::each(0..32, 2, |value| {
let count = active.fetch_add(1, Ordering::SeqCst) + 1;
maximum.fetch_max(count, Ordering::SeqCst);
barrier.wait();
total.fetch_add(value, Ordering::SeqCst);
active.fetch_sub(1, Ordering::SeqCst);
Ok::<_, &'static str>(())
}),
Ok(Ok(()))
);
assert_eq!(maximum.load(Ordering::SeqCst), 2);
assert_eq!(total.load(Ordering::SeqCst), (0..32).sum::<usize>());
assert_eq!(active.load(Ordering::SeqCst), 0);
}
#[test]
fn synchronous_failure_stops_a_non_send_producer_without_collecting_it() {
let pulls = Rc::new(std::cell::Cell::new(0));
let values = (0..100).inspect(|_| pulls.set(pulls.get() + 1));
assert_eq!(
parallel::each(values, 1, |_| Err("first failure")),
Ok(Err("first failure"))
);
assert!(pulls.get() <= 2, "{}", pulls.get());
}
#[test]
fn synchronous_limits_empty_sources_and_panics_are_explicit() {
for limit in [-1, 0, 257] {
assert_eq!(
parallel::each(0..0, limit, |_| Ok::<_, ()>(())),
Err(ThreadError::Limit)
);
}
assert_eq!(parallel::each(0..0, 256, |_| Ok::<_, ()>(())), Ok(Ok(())));
assert_eq!(
parallel::each(0..4, 1, |_| -> Result<(), ()> { panic!("worker failure") }),
Err(ThreadError::Panicked)
);
}
#[test]
fn group_waits_for_capacity_before_invoking_the_pull_factory() {
runtime()
.run(async |scope| {
let pulls = AtomicUsize::new(0);
let (release, ready) = tokio::sync::oneshot::channel();
let mut ready = Some(ready);
let mut consume = std::pin::pin!(Tasks::new(1).unwrap().each(
|| {
let index = pulls.fetch_add(1, Ordering::SeqCst);
std::future::ready(Ok((index < 2).then_some(index)))
},
|index| {
let ready = if index == 0 { ready.take() } else { None };
scope.spawn_leaf(move || async move {
if let Some(ready) = ready {
ready.await.unwrap();
}
Ok(())
})
},
));
poll_fn(|context| {
assert!(consume.as_mut().poll(context).is_pending());
Poll::Ready(())
})
.await;
assert_eq!(pulls.load(Ordering::SeqCst), 1);
release.send(()).unwrap();
consume.await.unwrap().unwrap();
assert_eq!(pulls.load(Ordering::SeqCst), 3);
})
.unwrap();
}
#[test]
fn cancellation_waits_for_children_while_the_source_is_pending() {
let dropped = Arc::new(AtomicBool::new(false));
let observed = dropped.clone();
runtime()
.run(async |scope| {
let (started, ready) = tokio::sync::oneshot::channel();
let parent = scope
.spawn(move |child| async move {
let mut owner = Some(Notice(dropped));
let mut started = Some(started);
let mut index = 0;
Tasks::new(2)
.unwrap()
.each(
|| {
index += 1;
let current = index;
let signal = if current == 2 { started.take() } else { None };
async move {
if current == 1 {
Ok(Some(1))
} else {
signal.unwrap().send(()).unwrap();
std::future::pending().await
}
}
},
|_| {
let owner = owner.take().unwrap();
child.spawn_leaf(move || async move {
let _owner = owner;
std::future::pending().await
})
},
)
.await
})
.unwrap();
ready.await.unwrap();
assert!(parent.stop().await.unwrap().is_none());
assert!(observed.load(Ordering::SeqCst));
})
.unwrap();
}
#[test]
fn group_observes_child_failure_while_the_producer_is_waiting() {
runtime()
.run(async |scope| {
let (pulled, ready) = tokio::sync::oneshot::channel();
let mut pulled = Some(pulled);
let mut ready = Some(ready);
let mut index = 0;
let result = Tasks::new(2)
.unwrap()
.each(
|| {
index += 1;
let current = index;
let signal = if current == 2 { pulled.take() } else { None };
async move {
if current == 1 {
Ok(Some(1))
} else {
signal.unwrap().send(()).unwrap();
std::future::pending().await
}
}
},
|_| {
let ready = ready.take().unwrap();
scope.spawn_leaf(move || async move {
ready.await.unwrap();
Err("while pulling")
})
},
)
.await;
assert_eq!(result, Ok(Err("while pulling")));
assert_eq!(index, 2);
})
.unwrap();
}
struct Notice(Arc<AtomicBool>);
impl Drop for Notice {
fn drop(&mut self) {
self.0.store(true, Ordering::SeqCst);
}
}
#[test]
fn producer_failure_drains_started_tasks_before_returning() {
let dropped = Arc::new(AtomicBool::new(false));
let observed = dropped.clone();
runtime()
.run(async |scope| {
let mut owner = Some(Notice(dropped));
let mut index = 0;
let result = Tasks::new(2)
.unwrap()
.each(
|| {
index += 1;
std::future::ready(if index == 1 {
Ok(Some(1))
} else {
Err("source failed")
})
},
|_| {
let owner = owner.take().unwrap();
scope.spawn_leaf(move || async move {
let _owner = owner;
std::future::pending().await
})
},
)
.await;
assert_eq!(result, Ok(Err("source failed")));
assert!(observed.load(Ordering::SeqCst));
})
.unwrap();
}
#[test]
fn unexpected_child_cancellation_is_not_treated_as_success() {
runtime()
.run(async |scope| {
let mut first = true;
let result = Tasks::new(2)
.unwrap()
.each(
|| {
let value = std::mem::take(&mut first);
async move {
if value {
Ok(Some(1))
} else {
std::future::pending().await
}
}
},
|_| {
let task = scope.spawn_leaf(std::future::pending).unwrap();
task.cancel();
Ok(task)
},
)
.await;
assert_eq!(result, Err(Error::Cancelled));
})
.unwrap();
}
+145
View File
@@ -0,0 +1,145 @@
use dever_runtime::value::{bytes, decimal::Decimal, parse, text};
#[test]
fn byte_sum_matches_checked_add_at_block_and_integer_boundaries() {
for length in [0, 1, 15, 16, 4095, 4096, 4097, 8193] {
let values = vec![255; length];
let sum = length as i64 * 255;
for initial in [
i64::MIN,
-1,
0,
i64::MAX - sum,
i64::MAX - sum + i64::from(length != 0),
i64::MAX,
] {
let expected = values
.iter()
.try_fold(initial, |total, value| total.checked_add(i64::from(*value)));
assert_eq!(
bytes::checked_sum(&values, initial),
expected,
"{length}/{initial}"
);
}
}
assert_eq!(bytes::checked_sum(&[0; 8193], i64::MAX), Some(i64::MAX));
}
#[test]
fn unicode_scalar_conversion_and_positions_keep_boundaries() {
assert_eq!(text::codepoint("🙂"), Some(128578));
assert_eq!(text::codepoint(""), None);
assert_eq!(text::codepoint("e\u{301}"), None);
assert_eq!(text::from_codepoint(0), Some("\0".into()));
assert_eq!(text::from_codepoint(128578), Some("🙂".into()));
for value in [-1, 0xd800, 0x110000] {
assert_eq!(text::from_codepoint(value), None);
}
let value = "a雪🙂e\u{301}";
assert_eq!(text::at(value, 2), Some("🙂".into()));
assert_eq!(text::slice(value, 1, 3), Some("雪🙂".into()));
assert_eq!(text::slice(value, 5, 5), Some("".into()));
assert_eq!(text::slice("", 0, 0), Some("".into()));
for (start, end) in [(-1, 1), (1, 0), (0, 6), (6, 6), (0, i64::MAX)] {
assert_eq!(text::slice(value, start, end), None);
}
assert_eq!(text::at(value, i64::MAX), None);
}
#[test]
fn search_counts_scalars_and_empty_separator_has_no_synthetic_edges() {
assert_eq!(text::index_of("a🙂雪", "雪"), Some(2));
assert_eq!(text::index_of("雪", ""), Some(0));
assert_eq!(text::index_of("雪", "x"), None);
assert_eq!(text::split("a🙂", ""), ["a", "🙂"]);
assert!(text::split("", "").is_empty());
assert_eq!(text::split(",a,,", ","), ["", "a", "", ""]);
}
#[test]
fn float_parsing_uses_the_existing_numeric_and_special_spellings() {
assert_eq!(parse::float("1.25e2"), Some(125.0));
assert!(parse::float("NaN").unwrap().is_nan());
assert_eq!(parse::float("inf"), Some(f64::INFINITY));
assert_eq!(parse::float("-inf"), Some(f64::NEG_INFINITY));
assert!(parse::float("-0").unwrap().is_sign_negative());
for value in [
"nan", "+inf", "Infinity", " 1", "1 ", "1_000", "", "+", "1e",
] {
assert_eq!(parse::float(value), None);
}
}
#[test]
fn decimal_text_is_finite_exact_and_normalized() {
assert_eq!(parse::decimal("1.2300").unwrap().to_string(), "1.23");
assert_eq!(parse::decimal("-0").unwrap().to_string(), "0");
assert_eq!(parse::decimal("1e2"), Some(Decimal::from_int(100)));
for value in [
"NaN",
"inf",
" 1",
"1 ",
"1_000",
"1e9999",
"12345678901234567890123456789012345",
] {
assert_eq!(parse::decimal(value), None, "{value}");
}
}
#[test]
fn decimal_rounding_uses_half_even_and_rejects_invalid_quanta() {
for (value, places, expected) in [
("1.25", 1, "1.2"),
("1.35", 1, "1.4"),
("-1.25", 1, "-1.2"),
("250", -2, "200"),
("5e-6176", 6176, "5e-6176"),
] {
assert_eq!(
Decimal::parse(value).unwrap().round(places),
Some(Decimal::parse(expected).unwrap()),
"{value}"
);
}
let value = Decimal::from_int(1);
for places in [i64::MIN, -6112, 6177, i64::MAX] {
assert_eq!(value.round(places), None);
}
assert_eq!(Decimal::parse("1e6144").unwrap().round(0), None);
}
#[test]
fn decimal_library_parser_preserves_the_strict_text_contract() {
// 逐字节插入检查 dec 的词法边界;不能只测普通数值后删除前置扫描。
for source in [
"", "0", "-1.25", "+.25", "1E-6176", "1e6144", "NaN", "Infinity",
] {
for position in 0..=source.len() {
for byte in 0..=127u8 {
let mut text = source.to_owned();
text.insert(position, char::from(byte));
let library = Decimal::parse(&text).ok();
assert_eq!(
parse::decimal(&text).map(Decimal::to_be_bytes),
library.map(Decimal::to_be_bytes),
"{text:?}"
);
}
}
}
for text in [
"12",
"١٢",
"1\u{a0}",
"\u{2003}1",
"-∞",
"1.2.3",
"1e+",
"++1",
] {
assert_eq!(parse::decimal(text), None, "{text:?}");
}
}
+159
View File
@@ -0,0 +1,159 @@
use super::{DropNotice, runtime};
use dever_runtime::task::{Error, Timed};
use std::{
future::{Future, poll_fn},
sync::{
Arc,
atomic::{AtomicBool, Ordering},
},
task::Poll,
time::Duration,
};
#[test]
fn dropped_stop_wait_keeps_started_work_tracked() {
let finished = Arc::new(AtomicBool::new(false));
let observed = finished.clone();
let runtime = runtime();
runtime
.run(async |scope| {
let (started, ready) = tokio::sync::oneshot::channel();
let (release, blocked) = std::sync::mpsc::channel();
let task = scope
.spawn_sync(move || {
started.send(()).unwrap();
blocked.recv_timeout(Duration::from_secs(2)).unwrap();
finished.store(true, Ordering::SeqCst);
})
.unwrap();
ready.await.unwrap();
{
let mut stopped = std::pin::pin!(task.stop());
poll_fn(|context| {
assert!(stopped.as_mut().poll(context).is_pending());
Poll::Ready(())
})
.await;
}
assert!(!observed.load(Ordering::SeqCst));
release.send(()).unwrap();
})
.unwrap();
assert!(observed.load(Ordering::SeqCst));
}
#[test]
fn nullable_and_unit_results_are_completions_not_expirations() {
runtime()
.run(async |scope| {
let nullable = scope.spawn_leaf(|| async { None::<i64> }).unwrap();
assert!(matches!(
nullable.timeout(10000).await.unwrap(),
Timed::Ready(None)
));
let unit = scope.spawn_leaf(|| async {}).unwrap();
assert!(matches!(
unit.timeout(10000).await.unwrap(),
Timed::Ready(())
));
let failed = scope
.spawn_leaf(|| async { Err::<i64, _>("original") })
.unwrap();
assert!(matches!(
failed.timeout(10000).await.unwrap(),
Timed::Ready(Err("original"))
));
})
.unwrap();
}
#[test]
fn expiration_and_invalid_duration_wait_for_captured_resources() {
for duration in [-1, 0, 1] {
let dropped = Arc::new(AtomicBool::new(false));
let observed = dropped.clone();
runtime()
.run(async |scope| {
let (started, ready) = tokio::sync::oneshot::channel();
let owner = DropNotice(dropped);
let pending = scope
.spawn_leaf(move || async move {
let _owner = owner;
started.send(()).unwrap();
std::future::pending::<()>().await;
})
.unwrap();
ready.await.unwrap();
let result = pending.timeout(duration).await;
if duration > 0 {
assert!(matches!(result, Ok(Timed::Expired(None))));
} else {
assert!(matches!(result, Err(Error::InvalidTimeout)));
}
assert!(observed.load(Ordering::SeqCst));
})
.unwrap();
}
}
#[test]
fn expiration_preserves_a_late_failure_from_started_blocking_work() {
runtime()
.run(async |scope| {
let (started, ready) = tokio::sync::oneshot::channel();
let (release, blocked) = std::sync::mpsc::channel();
let pending = scope
.spawn_sync(move || {
started.send(()).unwrap();
blocked.recv_timeout(Duration::from_secs(2)).unwrap();
Err::<i64, _>("late failure")
})
.unwrap();
ready.await.unwrap();
let mut timed = std::pin::pin!(pending.timeout(1));
poll_fn(|context| {
assert!(timed.as_mut().poll(context).is_pending());
Poll::Ready(())
})
.await;
tokio::time::sleep(Duration::from_millis(10)).await;
poll_fn(|context| {
assert!(timed.as_mut().poll(context).is_pending());
Poll::Ready(())
})
.await;
release.send(()).unwrap();
assert!(matches!(
timed.await.unwrap(),
Timed::Expired(Some(Err("late failure")))
));
})
.unwrap();
}
#[test]
fn cancelling_a_timeout_wait_drains_the_nested_task() {
let dropped = Arc::new(AtomicBool::new(false));
let observed = dropped.clone();
let (started, ready) = tokio::sync::oneshot::channel();
runtime()
.run(async |scope| {
let parent = scope
.spawn(move |child| async move {
let owner = DropNotice(dropped);
let pending = child
.spawn_leaf(move || async move {
let _owner = owner;
started.send(()).unwrap();
std::future::pending::<()>().await;
})
.unwrap();
pending.timeout(60000).await.unwrap();
})
.unwrap();
ready.await.unwrap();
assert!(parent.stop().await.unwrap().is_none());
assert!(observed.load(Ordering::SeqCst));
})
.unwrap();
}
+219
View File
@@ -0,0 +1,219 @@
use super::{DropNotice, runtime};
use dever_runtime::task::{Error, Group, Limits, Runtime, Task};
use std::{
future::{Future, poll_fn},
sync::{
Arc,
atomic::{AtomicBool, Ordering},
},
task::Poll,
time::Duration,
};
type Tasks = Group<Task<Result<(), &'static str>>, &'static str>;
#[test]
fn group_limits_are_checked_and_empty_groups_complete() {
for limit in [-1, 0, 65537] {
assert!(matches!(Tasks::new(limit), Err(Error::InvalidGroupLimit)));
}
runtime()
.run(async |_| {
Tasks::new(65536).unwrap().wait().await.unwrap().unwrap();
Tasks::new(1).unwrap().stop().await.unwrap().unwrap();
})
.unwrap();
}
#[test]
fn group_capacity_defers_the_next_factory() {
let called = Arc::new(AtomicBool::new(false));
runtime()
.run(async |scope| {
let mut group = Tasks::new(1).unwrap();
let (release, ready) = tokio::sync::oneshot::channel();
group
.run(|| {
scope.spawn_leaf(|| async {
ready.await.unwrap();
Ok(())
})
})
.await
.unwrap()
.unwrap();
{
let mut next = std::pin::pin!(group.run(|| {
called.store(true, Ordering::SeqCst);
scope.spawn_leaf(|| async { Ok(()) })
}));
poll_fn(|context| {
assert!(next.as_mut().poll(context).is_pending());
Poll::Ready(())
})
.await;
assert!(!called.load(Ordering::SeqCst));
release.send(()).unwrap();
next.await.unwrap().unwrap();
}
assert!(called.load(Ordering::SeqCst));
group.wait().await.unwrap().unwrap();
})
.unwrap();
}
#[test]
fn first_group_failure_cancels_and_drains_other_tasks() {
let dropped = Arc::new(AtomicBool::new(false));
let observed = dropped.clone();
runtime()
.run(async |scope| {
let mut group = Tasks::new(2).unwrap();
let owner = DropNotice(dropped);
group
.run(|| {
scope.spawn_leaf(move || async move {
let _owner = owner;
std::future::pending::<Result<(), &'static str>>().await
})
})
.await
.unwrap()
.unwrap();
group
.run(|| scope.spawn_leaf(|| async { Err("first failure") }))
.await
.unwrap()
.unwrap();
assert_eq!(group.wait().await, Ok(Err("first failure")));
assert!(observed.load(Ordering::SeqCst));
})
.unwrap();
}
#[test]
fn failed_group_never_invokes_a_waiting_factory() {
let called = Arc::new(AtomicBool::new(false));
runtime()
.run(async |scope| {
let mut group = Tasks::new(1).unwrap();
group
.run(|| scope.spawn_leaf(|| async { Err("first failure") }))
.await
.unwrap()
.unwrap();
let result = group
.run(|| {
called.store(true, Ordering::SeqCst);
scope.spawn_leaf(|| async { Ok(()) })
})
.await;
assert_eq!(result, Ok(Err("first failure")));
assert!(!called.load(Ordering::SeqCst));
assert_eq!(
group.run(|| scope.spawn_leaf(|| async { Ok(()) })).await,
Err(Error::Cancelled)
);
group.wait().await.unwrap().unwrap();
})
.unwrap();
}
#[test]
fn stopped_group_preserves_late_blocking_failures() {
runtime()
.run(async |scope| {
let mut group = Tasks::new(1).unwrap();
let (started, ready) = tokio::sync::oneshot::channel();
let (release, blocked) = std::sync::mpsc::channel();
group
.run(|| {
scope.spawn_sync(move || {
started.send(()).unwrap();
blocked.recv_timeout(Duration::from_secs(2)).unwrap();
Err("late failure")
})
})
.await
.unwrap()
.unwrap();
ready.await.unwrap();
let mut stopped = std::pin::pin!(group.stop());
poll_fn(|context| {
assert!(stopped.as_mut().poll(context).is_pending());
Poll::Ready(())
})
.await;
release.send(()).unwrap();
assert_eq!(stopped.await, Ok(Err("late failure")));
})
.unwrap();
}
#[test]
fn admission_failure_drains_existing_group_work() {
let runtime = Runtime::new(Limits {
threads: 2,
workers: 1,
tasks: 1,
})
.unwrap();
let dropped = Arc::new(AtomicBool::new(false));
let observed = dropped.clone();
runtime
.run(async |scope| {
let mut group = Tasks::new(2).unwrap();
let owner = DropNotice(dropped);
group
.run(|| {
scope.spawn_leaf(move || async move {
let _owner = owner;
std::future::pending::<Result<(), &'static str>>().await
})
})
.await
.unwrap()
.unwrap();
assert_eq!(
group.run(|| scope.spawn_leaf(|| async { Ok(()) })).await,
Err(Error::Capacity)
);
assert!(observed.load(Ordering::SeqCst));
assert_eq!(
scope
.spawn_leaf(|| async { 42 })
.unwrap()
.wait()
.await
.unwrap(),
42
);
group.wait().await.unwrap().unwrap();
})
.unwrap();
}
#[test]
fn dropping_a_group_cancels_unclaimed_work() {
let dropped = Arc::new(AtomicBool::new(false));
let observed = dropped.clone();
runtime()
.run(async |scope| {
let mut group = Tasks::new(2).unwrap();
let owner = DropNotice(dropped);
group
.run(|| {
scope.spawn_leaf(move || async move {
let _owner = owner;
std::future::pending::<Result<(), &'static str>>().await
})
})
.await
.unwrap()
.unwrap();
drop(group);
scope.finish().await;
assert!(observed.load(Ordering::SeqCst));
})
.unwrap();
}
+480
View File
@@ -0,0 +1,480 @@
use dever_runtime::task::{Error, Limits, Runtime};
mod deadline;
mod group;
mod panic;
use std::sync::{
Arc,
atomic::{AtomicBool, Ordering},
};
struct DropNotice(Arc<AtomicBool>);
impl Drop for DropNotice {
fn drop(&mut self) {
std::thread::sleep(std::time::Duration::from_millis(20));
self.0.store(true, Ordering::SeqCst);
}
}
fn runtime() -> Runtime {
Runtime::new(Limits {
threads: 2,
workers: 1,
tasks: 8,
})
.unwrap()
}
#[test]
fn scope_waits_for_blocking_work_after_async_parent_is_cancelled() {
let entered = Arc::new(AtomicBool::new(false));
let finished = Arc::new(AtomicBool::new(false));
runtime()
.run(async |scope| {
let parent_entered = entered.clone();
let parent_finished = finished.clone();
let parent = scope
.spawn(move |scope| async move {
scope
.blocking(move || {
parent_entered.store(true, Ordering::SeqCst);
std::thread::sleep(std::time::Duration::from_millis(30));
parent_finished.store(true, Ordering::SeqCst);
})
.await
.unwrap();
})
.unwrap();
while !entered.load(Ordering::SeqCst) {
tokio::task::yield_now().await;
}
assert!(parent.stop().await.unwrap().is_none());
assert!(finished.load(Ordering::SeqCst));
})
.unwrap();
}
#[test]
fn task_errors_and_capacity_are_observable_and_slots_are_reused() {
let runtime = Runtime::new(Limits {
threads: 1,
workers: 1,
tasks: 1,
})
.unwrap();
runtime
.run(async |scope| {
let pending = scope.spawn_leaf(std::future::pending::<()>).unwrap();
assert!(matches!(scope.spawn_sync(|| ()), Err(Error::Capacity)));
assert!(pending.stop().await.unwrap().is_none());
let value = scope.spawn_sync(|| 42).unwrap().wait().await.unwrap();
assert_eq!(value, 42);
let failure = scope
.spawn_leaf(|| async {
panic!("test panic");
})
.unwrap();
assert_eq!(failure.wait().await, Err(Error::Panicked));
let failure = scope.blocking(|| panic!("direct worker panic")).await;
assert_eq!(failure, Err(Error::Panicked));
assert_eq!(scope.blocking(|| 42).await.unwrap(), 42);
})
.unwrap();
}
#[test]
fn cancelled_function_wait_keeps_descendants_tracked_until_their_real_exit() {
let finished = Arc::new(AtomicBool::new(false));
let observed = finished.clone();
let (started, ready) = tokio::sync::oneshot::channel();
runtime()
.run(async |root| {
let parent = root
.spawn(move |child| async move {
let nested = child
.spawn_sync(move || {
started.send(()).unwrap();
std::thread::sleep(std::time::Duration::from_millis(30));
finished.store(true, Ordering::SeqCst);
})
.unwrap();
nested.wait().await.unwrap();
})
.unwrap();
ready.await.unwrap();
assert!(parent.stop().await.unwrap().is_none());
assert!(observed.load(Ordering::SeqCst));
})
.unwrap();
}
#[test]
fn cancelling_queued_worker_never_executes_its_call() {
let called = Arc::new(AtomicBool::new(false));
runtime()
.run(async |scope| {
let (started, ready) = tokio::sync::oneshot::channel();
let (release, blocked) = std::sync::mpsc::channel();
let first = scope
.spawn_sync(move || {
started.send(()).unwrap();
blocked
.recv_timeout(std::time::Duration::from_secs(2))
.unwrap();
})
.unwrap();
ready.await.unwrap();
let work_called = called.clone();
let queued = scope
.spawn_sync(move || work_called.store(true, Ordering::SeqCst))
.unwrap();
assert!(queued.stop().await.unwrap().is_none());
release.send(()).unwrap();
first.wait().await.unwrap();
assert!(!called.load(Ordering::SeqCst));
})
.unwrap();
}
#[test]
fn claimed_output_can_outlive_its_task_scope() {
let runtime = runtime();
for direct in [false, true] {
let value = runtime
.run(async |scope| {
let value = if direct {
scope
.blocking(|| String::from("owned output"))
.await
.unwrap()
} else {
let task = scope.spawn_sync(|| String::from("owned output")).unwrap();
task.wait().await.unwrap()
};
scope.finish().await;
assert!(matches!(
scope.spawn_leaf(|| async {}),
Err(Error::Cancelled)
));
assert_eq!(scope.blocking(|| 42).await, Err(Error::Cancelled));
value
})
.unwrap();
assert_eq!(value, "owned output");
}
}
#[test]
fn finished_empty_scope_rejects_new_work() {
runtime()
.run(async |scope| {
scope.finish().await;
assert!(matches!(
scope.spawn_leaf(|| async {}),
Err(Error::Cancelled)
));
assert_eq!(scope.blocking(|| 42).await, Err(Error::Cancelled));
})
.unwrap();
}
#[test]
fn root_panic_drains_workers_from_dropped_function_handles() {
let runtime = runtime();
let finished = Arc::new(AtomicBool::new(false));
let observed = finished.clone();
let result = runtime.run(async |root| {
let (started, ready) = tokio::sync::oneshot::channel();
let _task = root
.spawn_sync(move || {
started.send(()).unwrap();
std::thread::sleep(std::time::Duration::from_millis(30));
finished.store(true, Ordering::SeqCst);
})
.unwrap();
ready.await.unwrap();
panic!("root failure after worker started");
});
assert_eq!(result, Err(Error::Panicked));
assert!(observed.load(Ordering::SeqCst));
}
#[test]
fn root_panic_cancels_leaf_work_and_allows_the_next_entry() {
let runtime = runtime();
let dropped = Arc::new(AtomicBool::new(false));
let observed = dropped.clone();
let result = runtime.run(async |root| {
let (started, ready) = tokio::sync::oneshot::channel();
let _task = root
.spawn_leaf(move || async move {
let _owned = DropNotice(dropped);
started.send(()).unwrap();
std::future::pending::<()>().await;
})
.unwrap();
ready.await.unwrap();
panic!("root failure after leaf started");
});
assert_eq!(result, Err(Error::Panicked));
assert!(observed.load(Ordering::SeqCst));
let result = runtime.run(async |root| {
root.spawn_leaf(|| async { Err::<(), _>(String::from("business failure")) })
.unwrap()
.wait()
.await
.unwrap()
});
assert_eq!(result, Ok(Err(String::from("business failure"))));
}
#[test]
fn abandoned_task_results_are_dropped_before_scope_finish_returns() {
let runtime = runtime();
for direct in [false, true] {
let dropped = Arc::new(AtomicBool::new(false));
let observed = dropped.clone();
runtime
.run(async |scope| {
let (started, ready) = tokio::sync::oneshot::channel();
let call = move || {
started.send(()).unwrap();
std::thread::sleep(std::time::Duration::from_millis(20));
DropNotice(dropped)
};
let task = if direct {
scope
.spawn(move |child| async move { child.blocking(call).await.unwrap() })
.unwrap()
} else {
scope.spawn_sync(call).unwrap()
};
ready.await.unwrap();
drop(task);
scope.finish().await;
assert!(observed.load(Ordering::SeqCst));
})
.unwrap();
}
}
#[test]
fn finished_tasks_drop_unclaimed_outputs_before_scope_returns() {
let runtime = Runtime::new(Limits {
threads: 1,
workers: 1,
tasks: 8,
})
.unwrap();
for leaf in [false, true] {
let dropped = Arc::new(AtomicBool::new(false));
let observed = dropped.clone();
runtime
.run(async |root| {
let (completed, ready) = tokio::sync::oneshot::channel();
let call = move || async move {
// 唯一 worker 在本任务完成后才会运行 marker,不用睡眠猜测完成时点。
tokio::spawn(async move {
completed.send(()).unwrap();
});
DropNotice(dropped)
};
let task = if leaf {
root.spawn_leaf(call).unwrap()
} else {
root.spawn(move |_child| call()).unwrap()
};
ready.await.unwrap();
drop(task);
root.finish().await;
assert!(observed.load(Ordering::SeqCst));
})
.unwrap();
}
}
#[test]
fn unclaimed_panicking_leaf_drops_owned_values_before_scope_finishes() {
let finished = Arc::new(AtomicBool::new(false));
let observed = finished.clone();
let (started, ready) = tokio::sync::oneshot::channel();
runtime()
.run(async |scope| {
let task = scope
.spawn_leaf(move || async move {
let _owned = DropNotice(finished);
started.send(()).unwrap();
panic!("unclaimed leaf panic");
})
.unwrap();
ready.await.unwrap();
drop(task);
scope.finish().await;
assert!(observed.load(Ordering::SeqCst));
})
.unwrap();
}
#[test]
fn parent_panic_drains_started_workers_before_reporting_failure() {
let finished = Arc::new(AtomicBool::new(false));
let observed = finished.clone();
runtime()
.run(async |root| {
let parent = root
.spawn(move |child| async move {
let (started, ready) = tokio::sync::oneshot::channel();
let _worker = child
.spawn_sync(move || {
started.send(()).unwrap();
std::thread::sleep(std::time::Duration::from_millis(20));
finished.store(true, Ordering::SeqCst);
})
.unwrap();
ready.await.unwrap();
panic!("parent panic after worker started");
})
.unwrap();
assert_eq!(parent.wait().await, Err(Error::Panicked));
assert!(observed.load(Ordering::SeqCst));
})
.unwrap();
}
#[test]
fn cancelling_a_leaf_waits_for_its_pending_future_destructor() {
let finished = Arc::new(AtomicBool::new(false));
let observed = finished.clone();
let (started, ready) = tokio::sync::oneshot::channel();
runtime()
.run(async |root| {
let leaf = root
.spawn_leaf(move || async move {
let _owned = DropNotice(finished);
started.send(()).unwrap();
std::future::pending::<()>().await;
})
.unwrap();
ready.await.unwrap();
assert_eq!(leaf.stop().await, Ok(None));
assert!(observed.load(Ordering::SeqCst));
})
.unwrap();
}
#[test]
fn cancelling_a_parent_drains_its_suspended_leaf() {
let dropped = Arc::new(AtomicBool::new(false));
let observed = dropped.clone();
let (started, ready) = tokio::sync::oneshot::channel();
runtime()
.run(async |root| {
let parent = root
.spawn(move |child| async move {
let leaf = child
.spawn_leaf(move || async move {
let _owned = DropNotice(dropped);
started.send(()).unwrap();
std::future::pending::<()>().await;
})
.unwrap();
leaf.wait().await.unwrap();
})
.unwrap();
ready.await.unwrap();
assert_eq!(parent.stop().await, Ok(None));
assert!(observed.load(Ordering::SeqCst));
})
.unwrap();
}
#[test]
fn cancelling_completed_leaves_preserves_business_errors_and_panics() {
let runtime = Runtime::new(Limits {
threads: 1,
workers: 1,
tasks: 8,
})
.unwrap();
for panicked in [false, true] {
runtime
.run(async |root| {
let (completed, ready) = tokio::sync::oneshot::channel();
let task = root
.spawn_leaf(move || async move {
// 唯一 worker 只能在本任务完成清理后再运行 marker,避免靠睡眠猜测完成。
tokio::spawn(async move {
completed.send(()).unwrap();
});
if panicked {
panic!("completed leaf panic");
}
Err::<(), _>("completed failure")
})
.unwrap();
ready.await.unwrap();
let result = task.stop().await;
if panicked {
assert_eq!(result, Err(Error::Panicked));
} else {
assert_eq!(result, Ok(Some(Err("completed failure"))));
}
})
.unwrap();
}
}
#[test]
fn unpolled_leaf_inputs_are_dropped_before_scope_finishes() {
let runtime = Runtime::new(Limits {
threads: 1,
workers: 1,
tasks: 8,
})
.unwrap();
let dropped = Arc::new(AtomicBool::new(false));
let observed = dropped.clone();
let entered = Arc::new(AtomicBool::new(false));
let polled = entered.clone();
runtime
.run(async |root| {
// 单线程入口在这里不挂起,子任务第一次 poll 前就会收到中止。
let input = DropNotice(dropped);
let task = root
.spawn_leaf(move || async move {
entered.store(true, Ordering::SeqCst);
let _input = input;
std::future::pending::<()>().await;
})
.unwrap();
drop(task);
root.finish().await;
assert!(observed.load(Ordering::SeqCst));
assert!(!polled.load(Ordering::SeqCst));
})
.unwrap();
}
#[test]
fn leaf_entries_drop_future_state_and_preserve_results_and_panics() {
let runtime = runtime();
for panicked in [false, true] {
let dropped = Arc::new(AtomicBool::new(false));
let observed = dropped.clone();
let result = runtime.run_leaf(async move {
let _value = DropNotice(dropped);
tokio::task::yield_now().await;
if panicked {
panic!("leaf entry panic");
}
String::from("owned result")
});
assert!(observed.load(Ordering::SeqCst));
if panicked {
assert_eq!(result, Err(Error::Panicked));
} else {
assert_eq!(result.unwrap(), "owned result");
}
}
}
+39
View File
@@ -0,0 +1,39 @@
use super::{Error, runtime};
use dever_runtime::task::Scope;
use std::{
future::Future,
pin::Pin,
task::{Context, Poll},
};
fn failed_factory(_: &Scope<'_>) -> std::future::Ready<()> {
panic!("future construction failed");
}
struct FailedDrop;
impl Future for FailedDrop {
type Output = ();
fn poll(self: Pin<&mut Self>, _: &mut Context<'_>) -> Poll<()> {
Poll::Ready(())
}
}
impl Drop for FailedDrop {
fn drop(&mut self) {
panic!("completed future destruction failed");
}
}
fn failed_drop(_: &Scope<'_>) -> FailedDrop {
FailedDrop
}
#[test]
fn entry_catches_future_construction_and_completed_destruction_panics() {
let runtime = runtime();
assert_eq!(runtime.run(failed_factory), Err(Error::Panicked));
assert_eq!(runtime.run(failed_drop), Err(Error::Panicked));
assert_eq!(runtime.run(async |_| 42), Ok(42));
}
+73
View File
@@ -0,0 +1,73 @@
use super::*;
#[test]
fn quoted_filename_preserves_semicolons_and_escaped_quotes() {
let directory = Directory::new();
let bytes = String::from_utf8(content(b"data"))
.unwrap()
.replace("filename=image.bin", r#"filename="semi;with\"quote.bin""#)
.into_bytes();
Runtime::new(TaskLimits::default())
.unwrap()
.run(async |scope| {
let mut parsed = upload::decode(
body(&bytes, 1),
"multipart/form-data; boundary=\"boundary\"",
&directory.0,
&Limits::default(),
scope,
)
.await
.unwrap();
let file = parsed.take("file").unwrap();
assert_eq!(file.filename(), "semi;with\"quote.bin");
file.close();
assert_eq!(directory.temporary_count(), 0);
})
.unwrap();
}
#[cfg(unix)]
#[test]
fn parent_symlinks_cannot_redirect_temporary_or_stored_uploads() {
let directory = Directory::new();
let outside = Directory::new();
let bytes = content(b"data");
std::os::unix::fs::symlink(&outside.0, directory.0.join("linked")).unwrap();
Runtime::new(TaskLimits::default())
.unwrap()
.run(async |scope| {
for path in [directory.0.join("linked"), directory.0.join("linked/data")] {
assert!(
upload::decode(
body(&bytes, 7),
"multipart/form-data; boundary=boundary",
&path,
&Limits::default(),
scope
)
.await
.is_err()
);
assert_eq!(std::fs::read_dir(&outside.0).unwrap().count(), 0);
}
let mut parsed = upload::decode(
body(&bytes, 7),
"multipart/form-data; boundary=boundary",
&directory.0,
&Limits::default(),
scope,
)
.await
.unwrap();
std::os::unix::fs::symlink(&outside.0, directory.0.join("upload")).unwrap();
assert!(
upload::put(parsed.take("file").unwrap(), scope)
.await
.is_err()
);
assert_eq!(std::fs::read_dir(&outside.0).unwrap().count(), 0);
assert_eq!(directory.temporary_count(), 0);
})
.unwrap();
}
+302
View File
@@ -0,0 +1,302 @@
use bytes::Bytes;
use dever_runtime::{
task::{Limits as TaskLimits, Runtime},
upload::{self, Limits},
};
use futures_util::stream::{self, StreamExt};
use http_body_util::StreamBody;
use hyper::body::Frame;
use std::{
convert::Infallible,
path::PathBuf,
sync::atomic::{AtomicUsize, Ordering},
time::Duration,
};
mod headers;
mod performance;
mod transport;
struct Directory(PathBuf);
#[derive(Debug, PartialEq, serde::Deserialize)]
#[serde(deny_unknown_fields)]
struct Fields {
caption: String,
count: i64,
}
impl Directory {
fn new() -> Self {
static NEXT: AtomicUsize = AtomicUsize::new(0);
let path = std::env::temp_dir().join(format!(
"dever-upload-{}-{}",
std::process::id(),
NEXT.fetch_add(1, Ordering::Relaxed)
));
std::fs::create_dir(&path).unwrap();
Self(path)
}
fn temporary_count(&self) -> usize {
std::fs::read_dir(self.0.join("tmp")).map_or(0, |files| files.count())
}
}
impl Drop for Directory {
fn drop(&mut self) {
std::fs::remove_dir_all(&self.0).unwrap();
}
}
fn content(file: &[u8]) -> Vec<u8> {
let mut body = b"--boundary\r\nContent-Disposition: form-data; name=caption\r\n\r\nhello\r\n--boundary\r\nContent-Disposition: form-data; name=count\r\n\r\n7\r\n--boundary\r\nContent-Disposition: form-data; name=file; filename=image.bin\r\nContent-Type: application/octet-stream\r\n\r\n".to_vec();
body.extend_from_slice(file);
body.extend_from_slice(b"\r\n--boundary--\r\n");
body
}
fn body(
bytes: &[u8],
size: usize,
) -> impl hyper::body::Body<Data = Bytes, Error = Infallible> + Unpin + Send + 'static {
let chunks = bytes
.chunks(size)
.map(|chunk| Ok(Frame::data(Bytes::copy_from_slice(chunk))))
.collect::<Vec<_>>();
StreamBody::new(stream::iter(chunks))
}
#[test]
fn fragmented_files_preserve_false_boundaries_and_transfer_once() {
let directory = Directory::new();
let mut payload = vec![b'x'; 170_001];
payload.extend_from_slice(b"\r\n--boundaryX\r\n--boundary--X\r\n--boundary-");
let bytes = content(&payload);
Runtime::new(TaskLimits::default())
.unwrap()
.run(async |scope| {
for size in [1, 7, 16_384, 250_000] {
let mut decoded = upload::decode(
body(&bytes, size),
"multipart/form-data; boundary=boundary",
&directory.0,
&Limits::default(),
scope,
)
.await
.unwrap();
let file = decoded.take("file").unwrap();
assert_eq!(file.filename(), "image.bin");
assert_eq!(file.content_type(), "application/octet-stream");
assert_eq!(file.size(), payload.len() as i64);
assert_eq!(
decoded
.finish()
.unwrap()
.decode::<Fields>(dever_runtime::wire::decode)
.unwrap(),
Fields {
caption: "hello".into(),
count: 7
}
);
let key = upload::put(file, scope).await.unwrap();
assert_eq!(
std::fs::read(directory.0.join("upload").join(key.to_text())).unwrap(),
payload
);
assert_eq!(directory.temporary_count(), 0);
}
})
.unwrap();
}
#[test]
fn invalid_or_limited_bodies_remove_every_temporary_file() {
let directory = Directory::new();
let bytes = content(&vec![b'x'; 20_000]);
Runtime::new(TaskLimits::default())
.unwrap()
.run(async |scope| {
let invalid = [
bytes[..bytes.len() - 8].to_vec(),
[bytes.clone(), b"epilogue".to_vec()].concat(),
String::from_utf8(bytes.clone())
.unwrap()
.replace("image.bin", "../escape")
.into_bytes(),
];
for bytes in invalid {
assert!(
upload::decode(
body(&bytes, 7),
"multipart/form-data; boundary=boundary",
&directory.0,
&Limits::default(),
scope
)
.await
.is_err()
);
assert_eq!(directory.temporary_count(), 0);
}
for limits in [
Limits {
total_bytes: 10_000,
file_bytes: 10_000,
..Limits::default()
},
Limits {
file_bytes: 10_000,
..Limits::default()
},
Limits {
field_bytes: 2,
..Limits::default()
},
Limits {
parts: 2,
fields: 2,
..Limits::default()
},
Limits {
header_bytes: 10,
..Limits::default()
},
] {
assert!(matches!(
upload::decode(
body(&bytes, 7),
"multipart/form-data; boundary=boundary",
&directory.0,
&limits,
scope
)
.await,
Err(upload::Error::Limit)
));
assert_eq!(directory.temporary_count(), 0);
}
})
.unwrap();
}
#[test]
fn duplicate_names_across_fields_and_files_remove_temporary_files() {
let directory = Directory::new();
Runtime::new(TaskLimits::default())
.unwrap()
.run(async |scope| {
for parts in [
[("same", false), ("same", false)],
[("same", false), ("same", true)],
[("same", true), ("same", false)],
[("same", true), ("same", true)],
[("first", true), ("second", true)],
] {
let mut source = String::new();
for (name, file) in parts {
let filename = if file { "; filename=example.txt" } else { "" };
source.push_str(&format!(
"--boundary\r\nContent-Disposition: form-data; name={name}{filename}\r\n\r\nvalue\r\n"
));
}
source.push_str("--boundary--\r\n");
assert!(matches!(
upload::decode(
body(source.as_bytes(), 7),
"multipart/form-data; boundary=boundary",
&directory.0,
&Limits::default(),
scope,
)
.await,
Err(upload::Error::Invalid)
));
assert_eq!(directory.temporary_count(), 0);
}
})
.unwrap();
}
#[test]
fn cancellation_while_waiting_for_more_file_bytes_removes_the_file() {
let directory = Directory::new();
let path = directory.0.clone();
let mut bytes = content(&vec![b'x'; 32_768]);
bytes.truncate(bytes.len() - 16);
Runtime::new(TaskLimits::default())
.unwrap()
.run(async |scope| {
let task = scope
.spawn(move |child| async move {
let frames = stream::once(std::future::ready(Ok::<_, Infallible>(
Frame::data(Bytes::from(bytes)),
)))
.chain(stream::pending());
upload::decode(
StreamBody::new(frames),
"multipart/form-data; boundary=boundary",
&path,
&Limits::default(),
&child,
)
.await
})
.unwrap();
tokio::time::timeout(Duration::from_secs(3), async {
while directory.temporary_count() == 0 {
tokio::task::yield_now().await;
}
})
.await
.unwrap();
task.cancel();
assert!(matches!(
task.wait().await,
Err(dever_runtime::task::Error::Cancelled) | Ok(Err(upload::Error::Cancelled))
));
assert_eq!(directory.temporary_count(), 0);
})
.unwrap();
}
#[test]
fn dropping_unconsumed_file_and_failed_storage_remove_the_temporary() {
let directory = Directory::new();
let bytes = content(b"data");
Runtime::new(TaskLimits::default())
.unwrap()
.run(async |scope| {
let parsed = upload::decode(
body(&bytes, 7),
"multipart/form-data; boundary=boundary",
&directory.0,
&Limits::default(),
scope,
)
.await
.unwrap();
assert_eq!(directory.temporary_count(), 1);
drop(parsed);
assert_eq!(directory.temporary_count(), 0);
std::fs::write(directory.0.join("upload"), b"not a directory").unwrap();
let mut parsed = upload::decode(
body(&bytes, 7),
"multipart/form-data; boundary=boundary",
&directory.0,
&Limits::default(),
scope,
)
.await
.unwrap();
assert!(
upload::put(parsed.take("file").unwrap(), scope)
.await
.is_err()
);
assert_eq!(directory.temporary_count(), 0);
})
.unwrap();
}
+54
View File
@@ -0,0 +1,54 @@
use super::*;
use std::{collections::BTreeMap, hint::black_box, time::Instant};
#[test]
#[ignore = "explicit release performance probe"]
fn multipart_field_cost() {
let directory = Directory::new();
Runtime::new(TaskLimits::default())
.unwrap()
.run(async |scope| {
println!("\ncase,sample,iterations,elapsed_ns,checksum");
for count in [1, 16, 64] {
let mut source = String::new();
for index in 0..count {
source.push_str(&format!(
"--boundary\r\nContent-Disposition: form-data; name=field{index}\r\n\r\n{}\r\n",
"a".repeat(64)
));
}
source.push_str("--boundary--\r\n");
let bytes = Bytes::from(source);
let limits = Limits {
fields: count,
parts: count,
..Limits::default()
};
for sample in 0..15 {
let started = Instant::now();
let mut checksum = 0;
for _ in 0..1000 {
let multipart = upload::decode(
http_body_util::Full::new(black_box(bytes.clone())),
"multipart/form-data; boundary=boundary",
&directory.0,
&limits,
scope,
)
.await
.unwrap();
let fields = multipart
.finish()
.unwrap()
.decode::<BTreeMap<String, String>>(dever_runtime::wire::decode)
.unwrap();
checksum += black_box(fields.len());
}
let elapsed = started.elapsed().as_nanos();
assert_eq!(checksum, count * 1000);
println!("multipart_{count},{sample},1000,{elapsed},{checksum}");
}
}
})
.unwrap();
}
+85
View File
@@ -0,0 +1,85 @@
use super::*;
use dever_runtime::{api, http, net};
use std::sync::Arc;
#[test]
fn http1_and_http2_stream_uploads_above_the_json_body_limit() {
let directory = Directory::new();
Runtime::new(TaskLimits::default())
.unwrap()
.run(async |scope| {
for http2 in [false, true] {
let listener = net::listen("127.0.0.1", 0, scope).await.unwrap();
let port = listener.port().unwrap();
let incoming = listener.clone();
let data = Arc::new(directory.0.clone());
let transport = http::Limits {
body_bytes: 1024,
http2: http2.then(Default::default),
..Default::default()
};
let client_limits = http::Limits {
body_bytes: 1_048_576,
..transport.clone()
};
let server = scope
.spawn(move |child| async move {
api::serve_upload::<_, _, true>(
&incoming,
&transport,
&child,
None,
move |request, body, child, _| {
let data = data.clone();
async move {
let mut parsed = upload::read(
&request,
body,
&data,
&Limits::default(),
&child,
)
.await
.unwrap();
let file = parsed.take("file").unwrap();
let size = file.size();
parsed
.finish()
.unwrap()
.decode::<Fields>(dever_runtime::wire::decode)
.unwrap();
file.close();
Ok(api::response(size.to_string()))
}
},
)
.await
})
.unwrap();
let request = http::Request {
method: "POST".into(),
target: "/upload".into(),
headers: vec![
http::Header {
name: "host".into(),
value: b"fixture.test".to_vec().into(),
},
http::Header {
name: "content-type".into(),
value: b"multipart/form-data; boundary=boundary".to_vec().into(),
},
],
body: content(&vec![b'x'; 170_001]).into(),
};
let response = http::send("127.0.0.1", port, request, &client_limits)
.await
.unwrap();
assert_eq!(response.status, 200);
assert_eq!(response.body.as_ref(), b"170001");
assert_eq!(directory.temporary_count(), 0);
listener.close().unwrap();
server.wait().await.unwrap().unwrap();
}
})
.unwrap();
}
+59
View File
@@ -0,0 +1,59 @@
use dever_runtime::value::uuid::Uuid;
const LOWER: &str = "550e8400-e29b-41d4-a716-446655440000";
const UPPER: &str = "550E8400-E29B-41D4-A716-446655440000";
#[test]
fn uuid_is_an_inline_canonical_value_and_map_key() {
let value = Uuid::parse(UPPER).unwrap();
assert_eq!(value.to_text(), LOWER);
assert_eq!(value, Uuid::parse(LOWER).unwrap());
assert_eq!(std::mem::size_of::<Uuid>(), 16);
assert!(!std::mem::needs_drop::<Uuid>());
let mut values = dever_runtime::collection::Map::with_capacity(1);
values.insert(value, 1);
values.insert(Uuid::parse(LOWER).unwrap(), 2);
assert_eq!(values.len(), 1);
assert_eq!(values.get(&value), Some(&2));
}
#[test]
fn malformed_hyphens_hex_and_alternative_forms_are_rejected_without_panics() {
for value in [
"",
"550e8400e29b41d4a716446655440000",
"{550e8400-e29b-41d4-a716-446655440000}",
"urn:uuid:550e8400-e29b-41d4-a716-446655440000",
"00000000-0000-0000-0000-00000000000-",
] {
assert_eq!(Uuid::parse(value), None, "{value}");
}
for index in 0..36 {
let mut text = LOWER.as_bytes().to_vec();
text[index] = if matches!(index, 8 | 13 | 18 | 23) {
b'0'
} else {
b'-'
};
assert!(
Uuid::parse(std::str::from_utf8(&text).unwrap()).is_none(),
"{index}"
);
}
assert!(Uuid::parse("ffffffff-ffff-ffff-ffff-ffffffffffff").is_some());
assert!(Uuid::parse("00000000-0000-0000-0000-000000000000").is_some());
assert!(Uuid::parse(&format!("é{}", &LOWER[2..])).is_none());
}
#[cfg(feature = "wire")]
#[test]
fn uuid_wire_preserves_the_strict_source_format() {
let value: Uuid = serde_json::from_str(&format!("\"{UPPER}\"")).unwrap();
assert_eq!(
serde_json::to_string(&value).unwrap(),
format!("\"{LOWER}\"")
);
for value in ["0", "null", r#""550e8400e29b41d4a716446655440000""#] {
assert!(serde_json::from_str::<Uuid>(value).is_err());
}
}
+228
View File
@@ -0,0 +1,228 @@
//! 真实命令的字节管道、非零退出、超时和输出预算;所有写入都在当前测试目录。
use super::{Asset, Bundle, Directory, Limits, Runtime};
use dever_runtime::worker::command::{self, Limits as CommandLimits};
use sha2::{Digest, Sha256};
use std::{fs, path::PathBuf, process::Command, time::Duration};
#[test]
fn command_runs_owned_native_fixture_without_shell_and_drains_on_failure() {
let _execution = super::EXECUTOR.lock().unwrap();
let directory = Directory::new();
let fixture = directory.0.join("fixture");
let source = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("../../../test/rust/runtime/worker/fixture.rs");
let result = Command::new("rustup")
.args(["run", "1.95.0", "rustc", "--edition=2024", "-O"])
.arg(source)
.arg("-o")
.arg(&fixture)
.output()
.unwrap();
assert!(
result.status.success(),
"{}",
String::from_utf8_lossy(&result.stderr)
);
let runner = directory.0.join("runner");
fs::copy(std::env::current_exe().unwrap(), &runner).unwrap();
let result = Command::new(runner)
.args(["--exact", "command::owned_command_case", "--nocapture"])
.env_clear()
.env("DEVER_COMMAND_FIXTURE", fixture)
.output()
.unwrap();
assert!(
result.status.success(),
"{}\n{}",
String::from_utf8_lossy(&result.stdout),
String::from_utf8_lossy(&result.stderr)
);
}
#[test]
fn owned_command_case() {
let Some(fixture) = std::env::var_os("DEVER_COMMAND_FIXTURE") else {
return;
};
let bytes = fs::read(&fixture).unwrap();
fs::remove_file(fixture).unwrap();
let entry = "module/demo/tool/command";
let launch = serde_json::to_vec(&serde_json::json!({"format":"dever-command-launch-v1","ecosystem":"command","entry":entry,"executable":entry,"arguments":[],"working_directory":"module/demo/tool"})).unwrap();
let files = [
(entry, bytes, true),
("module/demo/tool/command.dever-command.json", launch, false),
]
.map(|(path, bytes, executable)| Asset {
path,
sha256: Box::leak(format!("{:x}", Sha256::digest(&bytes)).into_boxed_str()),
bytes: Box::leak(bytes.into_boxed_slice()),
executable,
});
let bundle: &'static Bundle = Box::leak(Box::new(Bundle {
files: Box::leak(Box::new(files)),
}));
let root = std::env::current_exe()
.unwrap()
.parent()
.unwrap()
.to_owned();
let runtime = Runtime::new(Limits {
threads: 2,
workers: 1,
tasks: 32,
})
.unwrap();
runtime
.run(async |scope| {
let arguments = ["--echo", "a b", "$(touch never)", "\"quoted\""].map(str::to_owned);
let output = command::run(
scope,
bundle,
entry,
&arguments,
Some(&[0, 255, 1]),
CommandLimits::default(),
)
.await
.unwrap();
assert_eq!(output.code, 7);
assert_eq!(output.stdout, [0, 255, 1]);
assert_eq!(output.stderr, b"a b\n$(touch never)\n\"quoted\"");
let empty = command::run(
scope,
bundle,
entry,
&["--echo".into()],
None,
CommandLimits::default(),
)
.await
.unwrap();
assert!(empty.stdout.is_empty());
let error = command::run(
scope,
bundle,
entry,
&["--flood".into()],
None,
CommandLimits {
timeout_ms: 1000,
output_limit: 100,
},
)
.await
.err()
.unwrap();
assert!(error.contains("limit"));
let marker = root.join("timed-pid");
let error = command::run(
scope,
bundle,
entry,
&["--hang".into(), marker.to_str().unwrap().into()],
None,
CommandLimits {
timeout_ms: 100,
output_limit: 100,
},
)
.await
.err()
.unwrap();
assert!(error.contains("timed out"));
let pid = fs::read_to_string(marker).unwrap();
assert!(!PathBuf::from(format!("/proc/{pid}")).exists());
let marker = root.join("cancelled-pid");
let arguments = ["--hang".into(), marker.to_str().unwrap().into()];
let running = command::run(
scope,
bundle,
entry,
&arguments,
None,
CommandLimits::default(),
);
tokio::pin!(running);
tokio::select! {
_ = &mut running => panic!("hanging command completed before cancellation"),
ready = tokio::time::timeout(Duration::from_secs(3), async {
while !marker.is_file() { tokio::time::sleep(Duration::from_millis(5)).await; }
}) => ready.unwrap(),
}
})
.unwrap();
let pid = fs::read_to_string(root.join("cancelled-pid")).unwrap();
assert!(!PathBuf::from(format!("/proc/{pid}")).exists());
runtime
.run(async |scope| {
let (started, ready) = tokio::sync::oneshot::channel();
let (release, receive) = std::sync::mpsc::channel();
let blocker = scope
.spawn_sync(move || {
started.send(()).unwrap();
receive.recv().unwrap();
})
.unwrap();
ready.await.unwrap();
let queued = scope
.spawn(move |child| async move {
command::run(
&child,
bundle,
entry,
&["--echo".into()],
None,
CommandLimits::default(),
)
.await
})
.unwrap();
let stopped =
tokio::time::timeout(Duration::from_millis(300), queued.timeout(30)).await;
release.send(()).unwrap();
blocker.wait().await.unwrap();
assert!(matches!(
stopped,
Ok(Ok(dever_runtime::task::Timed::Expired(None)))
));
worker_finish(scope).await;
})
.unwrap();
runtime
.run(async |scope| {
let marker = root.join("child-cancelled-pid");
let arguments = vec!["--hang".into(), marker.to_str().unwrap().into()];
let child = scope
.spawn(move |child| async move {
command::run(
&child,
bundle,
entry,
&arguments,
None,
CommandLimits::default(),
)
.await
})
.unwrap();
tokio::time::timeout(Duration::from_secs(3), async {
while !marker.is_file() {
tokio::time::sleep(Duration::from_millis(5)).await;
}
})
.await
.unwrap();
child.stop().await.unwrap();
let pid = fs::read_to_string(marker).unwrap();
assert!(!PathBuf::from(format!("/proc/{pid}")).exists());
worker_finish(scope).await;
})
.unwrap();
}
async fn worker_finish(scope: &dever_runtime::task::Scope<'_>) {
dever_runtime::worker::finish(scope, Ok(())).await.unwrap();
}
+181
View File
@@ -0,0 +1,181 @@
//! 独立 std-only 协议对端;测试从固定 Rust 工具链构建,不调用宿主语言环境。
use std::io::{Read, Write};
fn read() -> Option<String> {
let mut prefix = [0; 4];
std::io::stdin().read_exact(&mut prefix).ok()?;
let mut bytes = vec![0; u32::from_be_bytes(prefix) as usize];
std::io::stdin().read_exact(&mut bytes).unwrap();
Some(String::from_utf8(bytes).unwrap())
}
fn send(value: &str) {
let mut output = std::io::stdout().lock();
output
.write_all(&(value.len() as u32).to_be_bytes())
.unwrap();
output.write_all(value.as_bytes()).unwrap();
output.flush().unwrap();
}
fn main() {
let args = std::env::args().skip(1).collect::<Vec<_>>();
if let Some(mode) = args.first() {
match mode.as_str() {
"--echo" => {
let mut bytes = Vec::new();
std::io::stdin().read_to_end(&mut bytes).unwrap();
std::io::stdout().write_all(&bytes).unwrap();
std::io::stderr()
.write_all(args[1..].join("\n").as_bytes())
.unwrap();
std::process::exit(7);
}
"--hang" => {
std::fs::write(&args[1], std::process::id().to_string()).unwrap();
std::thread::sleep(std::time::Duration::from_secs(10));
return;
}
"--flood" => {
std::io::stdout().write_all(&[1; 4096]).unwrap();
std::io::stderr().write_all(&[2; 4096]).unwrap();
return;
}
_ => panic!("unknown test command"),
}
}
let Some(hello) = read() else {
return;
};
assert!(hello.contains("\"kind\":\"hello\""));
let (fields, setting) = hello.rsplit_once(",\"setting\":").unwrap();
if setting.contains("sensitive") {
assert!(setting.contains("sensitive\\\"\\\\雪"));
}
send(&format!("{fields}}}").replace("\"kind\":\"hello\"", "\"kind\":\"ready\""));
let health = read().unwrap();
assert!(health.contains("\"kind\":\"health\""));
send(&health);
let mut bad_shutdown = false;
while let Some(request) = read() {
if request == "{\"kind\":\"shutdown\"}" {
if bad_shutdown {
send("{}");
std::process::exit(7);
}
send(&request);
return;
}
let id = request
.split("\"id\":")
.nth(1)
.unwrap()
.split([',', '}'])
.next()
.unwrap();
if request.contains("\"kind\":\"cancel\"") {
send(&format!(
"{{\"kind\":\"error\",\"id\":{id},\"error\":\"dever.cancelled\",\"payload\":null}}"
));
continue;
}
if request.contains("\"operation\":\"measure\"") {
let payload = request
.split_once(",\"payload\":")
.unwrap()
.1
.strip_suffix('}')
.unwrap();
let length = payload.len();
let checksum: u64 = payload.bytes().map(u64::from).sum();
send(&format!(
"{{\"kind\":\"result\",\"id\":{id},\"payload\":{{\"length\":{length},\"checksum\":{checksum}}}}}"
));
continue;
}
if request.contains("\"operation\":\"crash\"") {
std::process::exit(7);
}
if request.contains("\"operation\":\"hang\"") {
continue;
}
if request.contains("\"operation\":\"ignore\"") {
std::thread::sleep(std::time::Duration::from_secs(10));
continue;
}
if request.contains("\"operation\":\"shutdown_bad\"") {
bad_shutdown = true;
}
if request.contains("\"operation\":\"native\"") {
if request.contains("\"input\":-4") || request.contains("\"input\":-5") {
bad_shutdown = true;
}
if request.contains("\"input\":-1") {
send(&format!(
"{{\"kind\":\"result\",\"id\":{id},\"payload\":{{\"value\":\"wrong-type\"}}}}"
));
continue;
}
if request.contains("\"input\":-2")
|| request.contains("\"input\":-3")
|| request.contains("\"input\":-5")
{
let error = if request.contains("\"input\":-2") {
"not.declared.Failed"
} else {
"dever.api.Error.Invalid"
};
send(&format!(
"{{\"kind\":\"error\",\"id\":{id},\"error\":\"{error}\",\"payload\":{{}}}}"
));
continue;
}
let value = if request.contains("\"input\":99}") {
std::process::id()
} else {
42
};
send(&format!(
"{{\"kind\":\"result\",\"id\":{id},\"payload\":{{\"value\":{value}}}}}"
));
continue;
}
if request.contains("\"operation\":\"bad\"") {
send("{\"kind\":\"result\",\"id\":900,\"payload\":{}}");
continue;
}
if request.contains("\"operation\":\"error\"") {
send(&format!(
"{{\"kind\":\"error\",\"id\":{id},\"error\":\"demo.test.Error.Failed\",\"payload\":{{\"message\":\"failed\"}}}}"
));
continue;
}
if let Some((_, fields)) = [
("null_error", "\"kind\":\"result\",\"error\":null"),
("missing_error", "\"kind\":\"error\""),
("extra_field", "\"kind\":\"result\",\"unexpected\":true"),
]
.into_iter()
.find(|(operation, _)| request.contains(&format!("\"operation\":\"{operation}\"")))
{
send(&format!("{{{fields},\"id\":{id},\"payload\":null}}"));
continue;
}
let response = format!(
"{{\"kind\":\"result\",\"id\":{id},\"payload\":{{\"value\":42,\"pid\":{}}}}}",
std::process::id()
);
if request.contains("\"operation\":\"fragment\"") {
let prefix = (response.len() as u32).to_be_bytes();
std::io::stdout().write_all(&prefix[..2]).unwrap();
std::io::stdout().flush().unwrap();
assert!(read().unwrap().contains("\"kind\":\"cancel\""));
std::io::stdout().write_all(&prefix[2..]).unwrap();
std::io::stdout().write_all(response.as_bytes()).unwrap();
std::io::stdout().flush().unwrap();
} else {
send(&response);
}
}
}
+259
View File
@@ -0,0 +1,259 @@
//! 逐调用取消与校验失败必须完成自己的清理,不等待另一个调用或阻塞任务。
use super::{CONTRACT, Directory, ENTRY, EXECUTOR, bundle, call, pid};
use dever_runtime::{
task::{Limits, Runtime, Timed},
wire::json::Json,
worker::{self, Setting},
};
use std::{fs, path::PathBuf, process::Command, time::Duration};
#[test]
fn worker_validation_cancellation_and_cleanup_are_owned() {
let _execution = EXECUTOR.lock().unwrap();
let directory = Directory::new();
let fixture = directory.0.join("fixture");
let source = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("../../../test/rust/runtime/worker/fixture.rs");
let built = Command::new("rustup")
.args(["run", "1.95.0", "rustc", "--edition=2024", "-O"])
.arg(source)
.arg("-o")
.arg(&fixture)
.output()
.unwrap();
assert!(
built.status.success(),
"{}",
String::from_utf8_lossy(&built.stderr)
);
let runner = directory.0.join("runner");
fs::copy(std::env::current_exe().unwrap(), &runner).unwrap();
let result = Command::new(runner)
.args(["--exact", "lifecycle::owned_lifecycle_case", "--nocapture"])
.env_clear()
.env("DEVER_LIFECYCLE_FIXTURE", fixture)
.output()
.unwrap();
assert!(
result.status.success(),
"{}\n{}",
String::from_utf8_lossy(&result.stdout),
String::from_utf8_lossy(&result.stderr)
);
}
#[test]
fn owned_lifecycle_case() {
let Some(fixture) = std::env::var_os("DEVER_LIFECYCLE_FIXTURE") else {
return;
};
let bundle = bundle(fs::read(&fixture).unwrap());
fs::remove_file(fixture).unwrap();
let runtime = Runtime::new(Limits {
threads: 2,
workers: 1,
tasks: 32,
})
.unwrap();
runtime
.run(async |scope| {
let first = pid(call(scope, bundle, "echo").await.unwrap());
let held = worker::call(
scope,
bundle,
ENTRY,
CONTRACT,
&Setting::empty(),
"echo",
Json::parse("{}").unwrap(),
)
.await
.unwrap();
let queued = scope
.spawn(move |child| async move { call(&child, bundle, "echo").await })
.unwrap();
let stopped =
tokio::time::timeout(Duration::from_millis(300), queued.timeout(30)).await;
assert!(matches!(stopped, Ok(Ok(Timed::Expired(None)))));
assert_eq!(first, pid(held.resolve(Ok).await.unwrap()));
let held = worker::call(
scope,
bundle,
ENTRY,
CONTRACT,
&Setting::empty(),
"echo",
Json::parse("{}").unwrap(),
)
.await
.unwrap();
let queued = scope
.spawn(move |child| async move { call(&child, bundle, "echo").await })
.unwrap();
tokio::time::sleep(Duration::from_millis(30)).await;
let failed = held
.resolve::<()>(|_| Err("invalid typed payload".into()))
.await
.unwrap_err();
assert!(failed.contains("invalid typed payload"));
assert!(!PathBuf::from(format!("/proc/{first}")).exists());
assert!(
queued
.wait()
.await
.unwrap()
.err()
.unwrap()
.contains("queued")
);
let second = pid(call(scope, bundle, "echo").await.unwrap());
assert_ne!(first, second);
let held = worker::call(
scope,
bundle,
ENTRY,
CONTRACT,
&Setting::empty(),
"echo",
Json::parse("{}").unwrap(),
)
.await
.unwrap();
let queued = scope
.spawn(move |child| async move { call(&child, bundle, "echo").await })
.unwrap();
tokio::time::sleep(Duration::from_millis(30)).await;
drop(held);
let failed = tokio::time::timeout(Duration::from_secs(3), queued.wait())
.await
.unwrap()
.unwrap()
.err()
.unwrap();
assert!(failed.contains("queued"));
assert!(!PathBuf::from(format!("/proc/{second}")).exists());
let third = pid(call(scope, bundle, "echo").await.unwrap());
assert_ne!(second, third);
let panicked = scope
.spawn(move |child| async move {
worker::call(
&child,
bundle,
ENTRY,
CONTRACT,
&Setting::empty(),
"echo",
Json::parse("{}").unwrap(),
)
.await
.unwrap()
.resolve::<()>(|_| panic!("typed decoder panicked"))
.await
})
.unwrap();
assert!(matches!(
tokio::time::timeout(Duration::from_secs(3), panicked.wait()).await,
Ok(Err(dever_runtime::task::Error::Panicked))
));
assert!(!PathBuf::from(format!("/proc/{third}")).exists());
worker::finish(scope, Ok(())).await.unwrap();
})
.unwrap();
runtime
.run(async |scope| {
#[derive(serde::Deserialize)]
struct Config {
#[serde(rename = "token")]
_token: dever_runtime::value::secret::Secret,
}
let document = dever_runtime::adapter::Document::parse(
r#"{"adapter":{"demo.test":{"setting":{"token":"sensitive\"\\雪"}}}}"#.into(),
)
.unwrap();
let schemas = [dever_runtime::adapter::Schema {
name: "demo.test",
implementations: &["default"],
}];
let selected = document.select(&schemas).unwrap();
let setting = selected[0].worker_setting::<Config>().unwrap();
let input = Json::parse("{}").unwrap();
let process = pid(worker::call(
scope,
bundle,
ENTRY,
CONTRACT,
&setting,
"echo",
input.clone(),
)
.await
.unwrap()
.resolve(Ok)
.await
.unwrap());
let error = worker::call(scope, bundle, ENTRY, CONTRACT, &setting, "bad", input)
.await
.err()
.unwrap();
assert!(!error.contains("sensitive"));
assert!(!PathBuf::from(format!("/proc/{process}")).exists());
worker::finish(scope, Ok(())).await.unwrap();
})
.unwrap();
runtime
.run(async |scope| {
let (started, ready) = tokio::sync::oneshot::channel();
let (release, receive) = std::sync::mpsc::channel();
let blocking = scope
.spawn_sync(move || {
started.send(()).unwrap();
receive.recv().unwrap();
})
.unwrap();
ready.await.unwrap();
let queued = scope
.spawn(move |child| async move { call(&child, bundle, "echo").await })
.unwrap();
let cancelled =
tokio::time::timeout(Duration::from_millis(300), queued.timeout(30)).await;
release.send(()).unwrap();
blocking.wait().await.unwrap();
assert!(matches!(cancelled, Ok(Ok(Timed::Expired(None)))));
worker::finish(scope, Ok(())).await.unwrap();
})
.unwrap();
runtime
.run(async |scope| {
let process = pid(call(scope, bundle, "echo").await.unwrap());
let hanging = scope
.spawn(move |child| async move { call(&child, bundle, "ignore").await })
.unwrap();
let result = tokio::time::timeout(Duration::from_secs(3), hanging.timeout(50))
.await
.unwrap()
.unwrap();
assert!(matches!(result, Timed::Expired(None)));
assert!(!PathBuf::from(format!("/proc/{process}")).exists());
worker::finish(scope, Ok(())).await.unwrap();
})
.unwrap();
runtime
.run(async |scope| {
let process = pid(call(scope, bundle, "shutdown_bad").await.unwrap());
let error = worker::finish::<()>(scope, Err("primary failure".into()))
.await
.unwrap_err();
assert!(
error.contains("primary failure") && error.contains("shutdown"),
"{error}"
);
assert!(!PathBuf::from(format!("/proc/{process}")).exists());
})
.unwrap();
}
+252
View File
@@ -0,0 +1,252 @@
//! 真实受管 Worker 与隔离资源目录;不访问已安装服务或生产缓存。
use dever_runtime::{
task::{Limits, Runtime},
wire::json::Json,
worker::{self, Asset, Bundle, Reply, Setting},
};
use sha2::{Digest, Sha256};
use std::{
fs,
os::unix::fs::{DirBuilderExt, PermissionsExt},
path::PathBuf,
process::Command,
time::Duration,
};
const ENTRY: &str = "module/demo/test/worker";
static EXECUTOR: std::sync::Mutex<()> = std::sync::Mutex::new(());
mod command;
mod lifecycle;
mod performance;
const CONTRACT: &str = r#"{"port":"demo.test","adapter":"default","ecosystem":"exec","entry":"module/demo/test/worker","schema":"owned-fixture","capabilities":[],"operations":["bad","crash","echo","error","extra_field","fragment","hang","ignore","measure","missing_error","null_error","shutdown_bad"],"inputs":{},"outputs":{},"errors":{"demo.test.Error.Failed":{}},"setting":null}"#;
struct Directory(PathBuf);
impl Directory {
fn new() -> Self {
let path = std::env::temp_dir().join(format!(
"dever-worker-{}-{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos()
));
fs::DirBuilder::new().mode(0o700).create(&path).unwrap();
Self(path)
}
}
impl Drop for Directory {
fn drop(&mut self) {
fs::remove_dir_all(&self.0).unwrap();
}
}
fn bundle(bytes: Vec<u8>) -> &'static Bundle {
let launch = serde_json::to_vec(&serde_json::json!({"format":"dever-worker-launch-v1","ecosystem":"exec","entry":ENTRY,"executable":ENTRY,"arguments":[],"working_directory":"module/demo/test"})).unwrap();
let files = [
(ENTRY, bytes, true),
("module/demo/test/worker.dever-worker.json", launch, false),
]
.map(|(path, bytes, executable)| Asset {
path,
sha256: Box::leak(format!("{:x}", Sha256::digest(&bytes)).into_boxed_str()),
bytes: Box::leak(bytes.into_boxed_slice()),
executable,
});
Box::leak(Box::new(Bundle {
files: Box::leak(Box::new(files)),
}))
}
fn pid(reply: Reply) -> u32 {
let Reply::Result(value) = reply else {
panic!("expected result")
};
let value: serde_json::Value = serde_json::from_str(&value).unwrap();
assert_eq!(value["value"], 42);
value["pid"].as_u64().unwrap() as u32
}
async fn call(
scope: &dever_runtime::task::Scope<'_>,
bundle: &'static Bundle,
operation: &str,
) -> Result<Reply, String> {
worker::call(
scope,
bundle,
ENTRY,
CONTRACT,
&Setting::empty(),
operation,
Json::parse("{}").unwrap(),
)
.await?
.resolve(Ok)
.await
}
#[test]
fn real_worker_runs_from_a_relocated_owned_executable() {
let _execution = EXECUTOR.lock().unwrap();
let directory = Directory::new();
let fixture = directory.0.join("fixture");
let source = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("../../../test/rust/runtime/worker/fixture.rs");
let result = Command::new("rustup")
.args(["run", "1.95.0", "rustc", "--edition=2024", "-O"])
.arg(source)
.arg("-o")
.arg(&fixture)
.output()
.unwrap();
assert!(
result.status.success(),
"{}",
String::from_utf8_lossy(&result.stderr)
);
let runner = directory.0.join("runner");
fs::copy(std::env::current_exe().unwrap(), &runner).unwrap();
let result = Command::new(runner)
.args(["--exact", "owned_process_protocol_case", "--nocapture"])
.env_clear()
.env("DEVER_WORKER_FIXTURE", &fixture)
.output()
.unwrap();
assert!(
result.status.success(),
"{}\n{}",
String::from_utf8_lossy(&result.stdout),
String::from_utf8_lossy(&result.stderr)
);
}
#[test]
fn owned_process_protocol_case() {
let Some(fixture) = std::env::var_os("DEVER_WORKER_FIXTURE") else {
return;
};
let bundle = bundle(fs::read(&fixture).unwrap());
fs::remove_file(fixture).unwrap();
let runtime = Runtime::new(Limits {
threads: 2,
workers: 1,
tasks: 32,
})
.unwrap();
let process = runtime
.run(async |scope| {
let first = pid(call(scope, bundle, "echo").await.unwrap());
assert_eq!(first, pid(call(scope, bundle, "echo").await.unwrap()));
for source in [
"null",
" \n{\"text\":\"雪\\u96ea\\n🙂\",\"value\":1e+02} \t",
] {
let response = worker::call(
scope,
bundle,
ENTRY,
CONTRACT,
&Setting::empty(),
"measure",
Json::parse(source).unwrap(),
)
.await
.unwrap()
.resolve(Ok)
.await
.unwrap();
let Reply::Result(payload) = response else {
panic!("expected measured result")
};
let measured: serde_json::Value = serde_json::from_str(&payload).unwrap();
let expected = source.trim_ascii();
assert_eq!(measured["length"], expected.len());
assert_eq!(
measured["checksum"],
expected.bytes().map(u64::from).sum::<u64>()
);
}
let Reply::Error { identity, payload } = call(scope, bundle, "error").await.unwrap()
else {
panic!("business error")
};
assert_eq!(identity, "demo.test.Error.Failed");
assert_eq!(payload, "{\"message\":\"failed\"}");
for operation in ["hang", "fragment"] {
assert!(
tokio::time::timeout(Duration::from_millis(30), call(scope, bundle, operation))
.await
.is_err()
);
assert_eq!(first, pid(call(scope, bundle, "echo").await.unwrap()));
}
assert!(call(scope, bundle, "crash").await.is_err());
let second = pid(call(scope, bundle, "echo").await.unwrap());
assert_ne!(first, second);
assert!(call(scope, bundle, "bad").await.is_err());
let third = pid(call(scope, bundle, "echo").await.unwrap());
assert_ne!(second, third);
worker::finish(scope, Ok(third)).await.unwrap()
})
.unwrap();
assert!(!PathBuf::from(format!("/proc/{process}")).exists());
for operation in ["null_error", "missing_error", "extra_field"] {
runtime
.run(async |scope| {
let process = pid(call(scope, bundle, "echo").await.unwrap());
assert!(call(scope, bundle, operation).await.is_err());
assert!(!PathBuf::from(format!("/proc/{process}")).exists());
worker::finish(scope, Ok(())).await.unwrap();
})
.unwrap();
}
}
#[test]
fn resource_publication_reuses_only_exact_owned_bytes_and_modes() {
let directory = Directory::new();
let resources = bundle(b"not executed".to_vec());
fs::create_dir(directory.0.join("data")).unwrap();
fs::set_permissions(directory.0.join("data"), fs::Permissions::from_mode(0o755)).unwrap();
let root = resources.prepare_in(&directory.0).unwrap();
assert_eq!(root, resources.prepare_in(&directory.0).unwrap());
fs::write(root.join("unlocked.py"), b"not part of bundle").unwrap();
assert!(
resources
.prepare_in(&directory.0)
.unwrap_err()
.contains("unexpected")
);
fs::remove_file(root.join("unlocked.py")).unwrap();
fs::set_permissions(root.join(ENTRY), fs::Permissions::from_mode(0o700)).unwrap();
assert!(
resources
.prepare_in(&directory.0)
.unwrap_err()
.contains("mode")
);
fs::write(root.join(ENTRY), b"bad resource").unwrap();
fs::set_permissions(root.join(ENTRY), fs::Permissions::from_mode(0o500)).unwrap();
assert!(resources.prepare_in(&directory.0).is_err());
let duplicate = Bundle {
files: Box::leak(
vec![Asset {
path: "../escape",
bytes: b"",
sha256: "",
executable: false,
}]
.into_boxed_slice(),
),
};
assert!(
duplicate
.prepare_in(&directory.0)
.unwrap_err()
.contains("path")
);
}
+166
View File
@@ -0,0 +1,166 @@
//! 真实复用 Worker 的逐调用成本;输入编码、进程启动和结果核验不计入样本。
use super::{CONTRACT, ENTRY, EXECUTOR, bundle};
use dever_runtime::{
task::{Limits, Runtime, Scope},
wire::json::Json,
worker::{self, Bundle, Reply, Setting},
};
use std::{fs, path::PathBuf, time::Instant};
mod native;
async fn request(scope: &Scope<'_>, bundle: &'static Bundle, input: Json) -> (usize, u64) {
let response = worker::call(
scope,
bundle,
ENTRY,
CONTRACT,
&Setting::empty(),
"measure",
input,
)
.await
.unwrap()
.resolve(Ok)
.await
.unwrap();
let Reply::Result(payload) = response else {
panic!("expected measured result")
};
#[derive(serde::Deserialize)]
struct Output {
length: usize,
checksum: u64,
}
let output: Output = serde_json::from_str(&payload).unwrap();
(output.length, output.checksum)
}
#[test]
#[ignore = "explicit release performance probe"]
fn worker_input_cost() {
let _execution = EXECUTOR.lock().unwrap();
let fixture = PathBuf::from(
std::env::var_os("DEVER_WORKER_BENCH_FIXTURE")
.expect("DEVER_WORKER_BENCH_FIXTURE must name the precompiled Worker fixture"),
);
let bundle = bundle(fs::read(&fixture).unwrap());
let runtime = Runtime::new(Limits {
threads: 1,
workers: 1,
tasks: 32,
})
.unwrap();
runtime
.run(async |scope| {
let mut native = native::Native::start(&fixture, CONTRACT).await.unwrap();
eprintln!(
"worker_input: 15 fixed rounds; one async worker; owned inputs prepared before timing; \
same executable, payload, frame protocol, deadlines and reply checks; \
rust_independent uses Tokio directly without Dever Worker; \
it excludes shared-session admission, caller-cancellation handshake and Scope cleanup; \
no compiler runs in this probe"
);
println!("\ncase,sample,implementation,iterations,elapsed_ns,checksum");
for text in ["null", " \n{\"text\":\"雪\\u96ea\\n🙂\",\"value\":1e+02} \t"] {
let trimmed = text.trim_ascii();
let expected = (trimmed.len(), trimmed.bytes().map(u64::from).sum::<u64>());
assert_eq!(
request(scope, bundle, Json::parse(text).unwrap()).await,
expected
);
assert_eq!(native.request(trimmed).await.unwrap(), expected);
}
for size in [64, 65_536, 1_048_576] {
let iterations = match size {
64 => 4096,
65_536 => 512,
_ => 64,
};
let input = Json::encode(&serde_json::json!({"text": "a".repeat(size)})).unwrap();
let expected = (
input.as_str().len(),
input.as_str().bytes().map(u64::from).sum::<u64>(),
);
for _ in 0..16 {
assert_eq!(request(scope, bundle, input.clone()).await, expected);
assert_eq!(native.request(input.as_str()).await.unwrap(), expected);
}
for sample in 0..15 {
let mut order = ["dever", "rust_independent", "control"];
order.rotate_left(sample % 3);
for label in order {
let mut results = Vec::with_capacity(iterations);
let elapsed = if label == "rust_independent" {
let inputs = (0..iterations)
.map(|_| input.as_str().to_owned())
.collect::<Vec<_>>();
let started = Instant::now();
for payload in inputs {
results.push(native.request(&payload).await.unwrap());
}
started.elapsed().as_nanos()
} else {
let inputs = vec![input.clone(); iterations];
let started = Instant::now();
for payload in inputs {
results.push(request(scope, bundle, payload).await);
}
started.elapsed().as_nanos()
};
assert!(results.iter().all(|&result| result == expected));
let checksum = expected.1 * results.len() as u64;
println!("worker_input_{size},{sample},{label},{iterations},{elapsed},{checksum}");
}
}
}
worker::finish(scope, Ok(())).await.unwrap();
native.finish().await.unwrap();
})
.unwrap();
}
#[test]
#[ignore = "explicit release performance probe"]
fn validated_reply_decode_cost() {
#[derive(Debug, PartialEq, serde::Deserialize)]
#[serde(deny_unknown_fields)]
struct Output {
text: String,
values: Vec<i64>,
}
for size in [64, 4096, 65_536] {
let payload =
serde_json::json!({"text": "a".repeat(size), "values": [1, 2, 3]}).to_string();
// Channel::read 的完整帧验证已完成;只计量其后的载荷解码,不代表 IPC 往返。
let input = Json::parse(&payload).unwrap();
let expected = dever_runtime::wire::decode::<Output>(input.as_str()).unwrap();
assert_eq!(
expected,
serde_json::from_str::<Output>(input.as_str()).unwrap()
);
for sample in 0..15 {
for before in if sample % 2 == 0 {
[true, false]
} else {
[false, true]
} {
let mut checksum = 0;
let started = Instant::now();
for _ in 0..2000 {
let text = std::hint::black_box(input.as_str());
let value = if before {
dever_runtime::wire::decode::<Output>(text).unwrap()
} else {
serde_json::from_str::<Output>(text).unwrap()
};
checksum += std::hint::black_box(&value).text.len() + value.values.len();
}
let elapsed = started.elapsed().as_nanos();
assert_eq!(checksum, 2000 * (size + 3));
let label = if before { "before" } else { "after" };
println!("worker_reply_{size},{sample},{label},{elapsed},{checksum}");
}
}
}
}
Loaded 100 of 1479 files, more files were not shown because too many files have changed in this diff. Show more