Initial Dever 0.0.1
This commit is contained in:
commit
81233d098c
1479 files changed
+163782
No files matched your search
@@ -0,0 +1,64 @@
|
||||
//! 原始 argv 不消费配置参数或命令名;非法 UTF-8 通过语言失败合同返回。
|
||||
|
||||
use super::{Fixture, Project, build, write};
|
||||
use std::{ffi::OsString, os::unix::ffi::OsStringExt, process::Command};
|
||||
|
||||
#[test]
|
||||
fn native_arguments_preserve_all_values_and_reject_invalid_utf8() {
|
||||
let fixture = Fixture::new(&[]);
|
||||
let root = &fixture.directory.0;
|
||||
write(
|
||||
root,
|
||||
"arguments.dever",
|
||||
"arguments() (values: List<Text>) { values = dever.process.arguments() }\n",
|
||||
);
|
||||
let project = Project::load(&root.join("arguments.dever")).unwrap();
|
||||
let program = project.check().unwrap();
|
||||
let binary = build::build(
|
||||
&program,
|
||||
&project.sources,
|
||||
"arguments",
|
||||
&root.join("arguments"),
|
||||
)
|
||||
.unwrap();
|
||||
for arguments in [
|
||||
vec![],
|
||||
vec![
|
||||
"--config",
|
||||
"配置.json",
|
||||
"demo.run",
|
||||
"",
|
||||
"a b",
|
||||
"$(touch never)",
|
||||
"\"quoted\"",
|
||||
"雪☃",
|
||||
],
|
||||
] {
|
||||
let result = Command::new(&binary)
|
||||
.args(&arguments)
|
||||
.env_clear()
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(
|
||||
result.status.success(),
|
||||
"{}",
|
||||
String::from_utf8_lossy(&result.stderr)
|
||||
);
|
||||
assert_eq!(
|
||||
String::from_utf8(result.stdout).unwrap(),
|
||||
format!("{arguments:?}\n")
|
||||
);
|
||||
}
|
||||
let result = Command::new(binary)
|
||||
.arg(OsString::from_vec(vec![0xff, 0xfe]))
|
||||
.env_clear()
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(!result.status.success());
|
||||
assert!(result.stdout.is_empty());
|
||||
let error = String::from_utf8(result.stderr).unwrap();
|
||||
assert!(
|
||||
error.contains("ArgumentsResult.Failed") && error.contains("lib/dever/process.dever:"),
|
||||
"{error}"
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
//! 真实 Dever command Port 经过检查、Rust 生成与原生执行,删除源码后仍独立运行。
|
||||
|
||||
use super::{BuildTarget, Fixture, Project, Resource, app, build, command, write};
|
||||
use std::{fs, process::Command};
|
||||
|
||||
#[test]
|
||||
fn native_command_preserves_binary_streams_argv_and_nonzero_exit() {
|
||||
let fixture = Fixture::new(&[]);
|
||||
let root = &fixture.directory.0;
|
||||
let body = "output = port.execute([\"--echo\", \"a b\", \"$(touch never)\", \"雪☃\"], dever.bytes.fromInts([0, 255, 1]))\n answer = output.code == 7 and output.stdout == dever.bytes.fromInts([0, 255, 1]) and output.stderr == dever.bytes.fromText(\"a b\\n$(touch never)\\n雪☃\") and input == 8";
|
||||
write(
|
||||
root,
|
||||
"module/demo/value/app.dever",
|
||||
app("0")
|
||||
.replace("answer: Int", "answer: Bool")
|
||||
.replace("answer = 0", body),
|
||||
);
|
||||
write(root, "module/demo/value/api.dever", command());
|
||||
write(
|
||||
root,
|
||||
"module/demo/value/port.dever",
|
||||
"execute(args: List<Text>, stdin: Bytes?) (output: dever.process.Output) fails dever.process.Error\n",
|
||||
);
|
||||
write(
|
||||
root,
|
||||
"module/demo/value/adapter.dever",
|
||||
"external command \"command\" {}\n",
|
||||
);
|
||||
let project = Project::load(root).unwrap();
|
||||
let program = project.check().unwrap();
|
||||
let external = program.external_workers().next().unwrap();
|
||||
let fixture = root.join("fixture");
|
||||
let source = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("../test/rust/runtime/worker/fixture.rs");
|
||||
let compiled = Command::new("rustup")
|
||||
.args(["run", "1.95.0", "rustc", "--edition=2024", "-O"])
|
||||
.arg(source)
|
||||
.arg("-o")
|
||||
.arg(&fixture)
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(
|
||||
compiled.status.success(),
|
||||
"{}",
|
||||
String::from_utf8_lossy(&compiled.stderr)
|
||||
);
|
||||
let launch = serde_json::to_vec(&serde_json::json!({"format":"dever-command-launch-v1","ecosystem":"command","entry":external.entry,"executable":external.entry,"arguments":[],"working_directory":"module/demo/value"})).unwrap();
|
||||
let resources = [
|
||||
(external.entry.clone(), fs::read(&fixture).unwrap(), true),
|
||||
(
|
||||
format!("{}.dever-command.json", external.entry),
|
||||
launch,
|
||||
false,
|
||||
),
|
||||
]
|
||||
.into_iter()
|
||||
.map(|(path, bytes, executable)| Resource {
|
||||
path,
|
||||
bytes,
|
||||
executable,
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
let binary = build::build_with_resources(
|
||||
&program,
|
||||
&project.sources,
|
||||
"main",
|
||||
&root.join("app"),
|
||||
BuildTarget::host().unwrap(),
|
||||
&resources,
|
||||
)
|
||||
.unwrap();
|
||||
fs::remove_dir_all(root.join("module")).unwrap();
|
||||
fs::remove_file(fixture).unwrap();
|
||||
for _ in 0..2 {
|
||||
let result = Command::new(&binary)
|
||||
.env_clear()
|
||||
.args(["demo.value.value", "{\"input\":8}"])
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(
|
||||
result.status.success(),
|
||||
"{}",
|
||||
String::from_utf8_lossy(&result.stderr)
|
||||
);
|
||||
assert_eq!(
|
||||
result.stdout,
|
||||
b"{\"code\":0,\"message\":\"ok\",\"data\":true}\n"
|
||||
);
|
||||
}
|
||||
write(
|
||||
root,
|
||||
"config/setting.json",
|
||||
r#"{"adapter":{"demo.value":{"command":{"output_limit":1}}}}"#,
|
||||
);
|
||||
let failed = Command::new(&binary)
|
||||
.env_clear()
|
||||
.args(["demo.value.value", "{\"input\":8}"])
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(!failed.status.success());
|
||||
let error = String::from_utf8_lossy(&failed.stderr);
|
||||
assert!(
|
||||
error.contains("external command failed") && error.contains("adapter.dever:"),
|
||||
"{error}"
|
||||
);
|
||||
let output = serde_json::from_slice::<serde_json::Value>(&failed.stdout);
|
||||
assert!(output.is_err() || output.unwrap()["code"] != 0);
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
//! 在同一 Worker 原生产物上验证持久 Job 的 worker-only 服务根。
|
||||
|
||||
use super::{roots::Server, write};
|
||||
use serde_json::{Value, json};
|
||||
use std::{
|
||||
fs,
|
||||
path::Path,
|
||||
process::{Command, Stdio},
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
|
||||
pub(super) fn prepare(root: &Path) {
|
||||
let app = root.join("module/demo/value/app.dever");
|
||||
let source = fs::read_to_string(&app).unwrap();
|
||||
fs::write(app, format!("{source}{APP}")).unwrap();
|
||||
let api = root.join("module/demo/value/api.dever");
|
||||
let source = fs::read_to_string(&api).unwrap();
|
||||
fs::write(api, format!("{source}\ncmd submit = app.submit\n")).unwrap();
|
||||
write(root, "module/demo/value/job.dever", JOB);
|
||||
}
|
||||
|
||||
pub(super) fn run(binary: &Path, root: &Path) {
|
||||
let config = root.join("setting-worker.json");
|
||||
fs::write(
|
||||
&config,
|
||||
json!({
|
||||
"database":{"default":{"type":"sqlite","path":"data/worker.db","max_connections":4}},
|
||||
"sites":{"public":{"path":"","hosts":["fixture.test"]}},
|
||||
"http":{"host":"worker-only-no-listener","port":0},
|
||||
"runtime":{"mode":"worker","shutdown_ms":1000},
|
||||
"job":{"workers":1,"poll_ms":10,"lease_ms":2000}
|
||||
})
|
||||
.to_string(),
|
||||
)
|
||||
.unwrap();
|
||||
let database = root.join("data/worker.db");
|
||||
for input in [8, -4] {
|
||||
let submitted = Command::new(binary)
|
||||
.env_clear()
|
||||
.arg("--config")
|
||||
.arg(&config)
|
||||
.args([
|
||||
"demo.value.submit",
|
||||
&json!({"input":input,"key":format!("root-{input}")}).to_string(),
|
||||
])
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(
|
||||
submitted.status.success(),
|
||||
"{}",
|
||||
String::from_utf8_lossy(&submitted.stderr)
|
||||
);
|
||||
let response: Value = serde_json::from_slice(&submitted.stdout).unwrap();
|
||||
let id = response["data"].as_str().unwrap();
|
||||
let pending = queue_row(&database, id);
|
||||
assert_eq!(pending["state"], "pending");
|
||||
assert_eq!(pending["attempt"], 0);
|
||||
assert_eq!(pending["execution"], json!({"kind":"System","tenant":null}));
|
||||
assert_eq!(pending["tenant"], 0);
|
||||
let log = root.join("job.stderr");
|
||||
let mut server = Server(
|
||||
Command::new(binary)
|
||||
.env_clear()
|
||||
.arg("--config")
|
||||
.arg(&config)
|
||||
.stdout(Stdio::null())
|
||||
.stderr(fs::File::create(&log).unwrap())
|
||||
.spawn()
|
||||
.unwrap(),
|
||||
);
|
||||
let deadline = Instant::now() + Duration::from_secs(10);
|
||||
let completed = loop {
|
||||
assert!(
|
||||
server.0.try_wait().unwrap().is_none(),
|
||||
"worker root exited: {}",
|
||||
fs::read_to_string(&log).unwrap()
|
||||
);
|
||||
let row = queue_row(&database, id);
|
||||
if row["state"] == "completed" {
|
||||
break row;
|
||||
}
|
||||
assert!(
|
||||
row["state"] == "pending" || row["state"] == "running",
|
||||
"{row}"
|
||||
);
|
||||
assert!(Instant::now() < deadline, "Job did not complete: {row}");
|
||||
std::thread::sleep(Duration::from_millis(20));
|
||||
};
|
||||
assert_eq!(completed["attempt"], 1);
|
||||
assert_eq!(completed["execution"], pending["execution"]);
|
||||
assert_eq!(completed["rows"], 0, "readonly Job changed Model data");
|
||||
server.shutdown(&log, input == -4);
|
||||
assert_eq!(queue_row(&database, id), completed);
|
||||
}
|
||||
}
|
||||
|
||||
fn queue_row(database: &Path, id: &str) -> Value {
|
||||
// 使用 dev 已有 sqlite3 模块只读检查本测试队列;不向私有队列表注入数据。
|
||||
let output = Command::new("python3")
|
||||
.args(["-c", r#"import json, pathlib, sqlite3, sys
|
||||
with sqlite3.connect(pathlib.Path(sys.argv[1]).as_uri() + '?mode=ro', uri=True) as database:
|
||||
row = database.execute('SELECT state,attempt,execution,execution_tenant FROM _dever_job WHERE id=?', (sys.argv[2],)).fetchone()
|
||||
rows = database.execute('SELECT COUNT(*) FROM demo_value_model').fetchone()[0]
|
||||
print(json.dumps(dict(state=row[0], attempt=row[1], execution=json.loads(row[2]), tenant=row[3], rows=rows)))
|
||||
"#])
|
||||
.arg(database)
|
||||
.arg(id)
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(
|
||||
output.status.success(),
|
||||
"{}",
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
serde_json::from_slice(&output.stdout).unwrap()
|
||||
}
|
||||
|
||||
const APP: &str = r#"type Work { input: Int }
|
||||
|
||||
submit(input: Int, key: Text) (id: Id) {
|
||||
id = dever.job.enqueue(job.perform, Work { input = input }, key)
|
||||
}
|
||||
|
||||
perform(input: Work) () { answer = stored(input.input) }
|
||||
"#;
|
||||
|
||||
const JOB: &str = r#"database default
|
||||
|
||||
long job perform(input: app.Work) () retry(1) timeout(1000) {
|
||||
app.perform(input)
|
||||
}
|
||||
"#;
|
||||
@@ -0,0 +1,158 @@
|
||||
//! 同一原生产物覆盖普通 CMD、只读数据库 CMD 和服务根的 Worker 收口。
|
||||
|
||||
use super::{app, command, command_cases, write};
|
||||
use std::{
|
||||
fs,
|
||||
io::{Read, Write},
|
||||
net::{TcpListener, TcpStream},
|
||||
path::Path,
|
||||
process::{Child, Command, Stdio},
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
|
||||
pub(super) fn prepare(root: &Path) {
|
||||
let mut source = app("port.native(input) + input");
|
||||
source.push_str(
|
||||
r#"stored(input: Int) (answer: Int) {
|
||||
count = model.count()
|
||||
answer = count + port.native(input) + input
|
||||
}
|
||||
"#,
|
||||
);
|
||||
write(root, "module/demo/value/app.dever", source);
|
||||
let mut source = command();
|
||||
source.push_str(
|
||||
r#"cmd stored = app.stored
|
||||
|
||||
public get stored = app.stored
|
||||
"#,
|
||||
);
|
||||
write(root, "module/demo/value/api.dever", source);
|
||||
write(
|
||||
root,
|
||||
"module/demo/value/model.dever",
|
||||
r#"global type Value { label: Text }
|
||||
"#,
|
||||
);
|
||||
write(
|
||||
root,
|
||||
"config/setting.json",
|
||||
r#"{"database":{"default":{"type":"sqlite","path":"data/worker.db","max_connections":1}}}"#,
|
||||
);
|
||||
}
|
||||
|
||||
pub(super) fn commands(binary: &Path) {
|
||||
for entry in ["demo.value.value", "demo.value.stored"] {
|
||||
command_cases(binary, entry);
|
||||
}
|
||||
assert!(binary.parent().unwrap().join("data/worker.db").is_file());
|
||||
}
|
||||
|
||||
pub(super) fn http(binary: &Path, root: &Path) {
|
||||
for bad_shutdown in [false, true] {
|
||||
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
|
||||
let port = listener.local_addr().unwrap().port();
|
||||
drop(listener);
|
||||
let config = root.join("setting-http.json");
|
||||
fs::write(
|
||||
&config,
|
||||
serde_json::to_vec(&serde_json::json!({
|
||||
"http":{"host":"127.0.0.1","port":port},
|
||||
"sites":{"public":{"path":"","hosts":["fixture.test"]}},
|
||||
"runtime":{"mode":"api","shutdown_ms":1000},
|
||||
"database":{"default":{"type":"sqlite","path":"data/worker.db","max_connections":1}}
|
||||
}))
|
||||
.unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
let log = root.join("http.stderr");
|
||||
let mut server = Server(
|
||||
Command::new(binary)
|
||||
.env_clear()
|
||||
.arg("--config")
|
||||
.arg(config)
|
||||
.stdout(Stdio::null())
|
||||
.stderr(fs::File::create(&log).unwrap())
|
||||
.spawn()
|
||||
.unwrap(),
|
||||
);
|
||||
let deadline = Instant::now() + Duration::from_secs(5);
|
||||
while TcpStream::connect(("127.0.0.1", port)).is_err() {
|
||||
assert!(
|
||||
server.0.try_wait().unwrap().is_none(),
|
||||
"server exited during startup: {}",
|
||||
fs::read_to_string(&log).unwrap()
|
||||
);
|
||||
assert!(Instant::now() < deadline, "server did not bind");
|
||||
std::thread::sleep(Duration::from_millis(10));
|
||||
}
|
||||
assert_eq!(request(port, 8), (200, 50));
|
||||
let (status, identity) = request(port, 99);
|
||||
assert_eq!(status, 200);
|
||||
assert!(identity > 99, "Worker PID was not returned");
|
||||
assert_eq!(request(port, -3).0, 400);
|
||||
if bad_shutdown {
|
||||
// 当前 HTTP 调用先成功;停机错误只能在共享服务根结束时传播。
|
||||
assert_eq!(request(port, -4), (200, 38));
|
||||
}
|
||||
for _ in 0..2 {
|
||||
assert_eq!(request(port, 99), (200, identity));
|
||||
}
|
||||
server.shutdown(&log, bad_shutdown);
|
||||
}
|
||||
}
|
||||
|
||||
fn request(port: u16, input: i64) -> (u16, i64) {
|
||||
let mut socket = TcpStream::connect(("127.0.0.1", port)).unwrap();
|
||||
socket
|
||||
.set_read_timeout(Some(Duration::from_secs(3)))
|
||||
.unwrap();
|
||||
write!(socket, "GET /demo/value/stored?input={input} HTTP/1.1\r\nHost: fixture.test\r\nConnection: close\r\n\r\n").unwrap();
|
||||
let mut response = String::new();
|
||||
socket.read_to_string(&mut response).unwrap();
|
||||
let (head, body) = response.split_once("\r\n\r\n").unwrap();
|
||||
let status = head.split_whitespace().nth(1).unwrap().parse().unwrap();
|
||||
let body: serde_json::Value = serde_json::from_str(body).unwrap();
|
||||
if status == 200 {
|
||||
assert_eq!(body["code"], 0);
|
||||
(status, body["data"].as_i64().unwrap())
|
||||
} else {
|
||||
assert_eq!(body["code"], status);
|
||||
assert!(body["data"].is_null());
|
||||
(status, 0)
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) struct Server(pub(super) Child);
|
||||
|
||||
impl Server {
|
||||
pub(super) fn shutdown(&mut self, log: &Path, bad_shutdown: bool) {
|
||||
rustix::process::kill_process(
|
||||
rustix::process::Pid::from_raw(self.0.id() as i32).unwrap(),
|
||||
rustix::process::Signal::TERM,
|
||||
)
|
||||
.unwrap();
|
||||
let deadline = Instant::now() + Duration::from_secs(5);
|
||||
let status = loop {
|
||||
if let Some(status) = self.0.try_wait().unwrap() {
|
||||
break status;
|
||||
}
|
||||
assert!(Instant::now() < deadline, "service root did not finish");
|
||||
std::thread::sleep(Duration::from_millis(10));
|
||||
};
|
||||
let error = fs::read_to_string(log).unwrap();
|
||||
assert_eq!(status.success(), !bad_shutdown, "{error}");
|
||||
assert_eq!(
|
||||
error.contains("invalid Worker shutdown acknowledgement"),
|
||||
bad_shutdown,
|
||||
"{error}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for Server {
|
||||
fn drop(&mut self) {
|
||||
let _ = self.0.kill();
|
||||
let _ = self.0.wait();
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user