feat: release Dever 0.1.5 with direct workers and long transactions

This commit is contained in:
Codex committed 2026-10-11 11:17:33 +00:00
1 parent 89629c2197
commit a900d7e8db
153 files changed
+3676 -3708

No files matched your search

+2 -2
View File
@@ -13,14 +13,14 @@ default = []
auth-crypto = ["dep:sha2", "dep:hmac", "dep:subtle"]
crypto = ["auth-crypto", "dep:argon2", "dep:getrandom"]
wire = ["dep:serde", "dep:serde_json", "serde_json/raw_value", "dep:base64", "dep:getrandom"]
external = ["wire", "dep:sha2", "dep:dever-sandbox"]
external = ["wire", "dep:sha2", "dep:dever-process"]
api = ["wire", "auth-crypto"]
database = ["wire", "dep:getrandom"]
sqlite = ["database", "dep:deadpool-sqlite"]
postgres = ["database", "dep:deadpool-postgres", "dep:tokio-postgres", "dep:tokio-postgres-rustls"]
[dependencies]
dever-sandbox = { path = "../dever-sandbox", optional = true }
dever-process = { path = "../dever-process", optional = true }
dec.workspace = true
indexmap.workspace = true
tokio.workspace = true
+29 -18
View File
@@ -5,7 +5,13 @@ use std::sync::Arc;
use std::time::{Duration, Instant};
use tokio::io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt};
use tokio::process::{Child, ChildStderr, ChildStdin, ChildStdout, Command};
#[cfg(not(feature = "external"))]
use tokio::process::Child;
use tokio::process::{ChildStderr, ChildStdin, ChildStdout, Command};
#[cfg(feature = "external")]
mod child;
#[cfg(feature = "external")]
use child::Child;
use tokio::sync::{Mutex, mpsc, oneshot};
mod command;
@@ -99,7 +105,6 @@ fn session() -> Result<Arc<Session>, String> {
current().ok_or_else(|| "external Adapter requires a Dever invocation".into())
}
/// One settings snapshot owns both Adapter selection and deployment grants.
pub fn adapter_settings() -> Result<Arc<crate::config::Settings>, String> {
#[cfg(feature = "api")]
if let Some(application) = crate::application::current() {
@@ -359,14 +364,11 @@ async fn supervise(
let mut command = worker_command(&definition)?;
command
.arg("--dever-component")
.env_clear()
.stdin(std::process::Stdio::piped())
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped())
.kill_on_drop(true);
let mut child = command
.spawn()
.map_err(|error| format!("cannot start external Adapter worker: {error}"))?;
let mut child = spawn_child(&mut command)?;
let Some(stdin) = child.stdin.take() else {
terminate_child(&mut child).await?;
return Err("external Adapter worker has no stdin".into());
@@ -395,7 +397,7 @@ fn worker_command(definition: &Definition) -> Result<Command, String> {
#[cfg(not(feature = "external"))]
{
let _ = definition;
Err("external Worker sandbox runtime feature is not enabled".into())
Err("external Worker process_runtime runtime feature is not enabled".into())
}
}
@@ -530,21 +532,30 @@ async fn wait_for_exit(child: &mut Child) -> Result<(), String> {
}
}
async fn terminate_child(child: &mut Child) -> Result<(), String> {
match child.try_wait() {
Ok(Some(_)) => return Ok(()),
Ok(None) => {}
Err(error) => return Err(format!("cannot inspect external Adapter worker: {error}")),
}
if let Err(error) = child.start_kill()
&& !matches!(child.try_wait(), Ok(Some(_)))
fn spawn_child(command: &mut Command) -> Result<Child, String> {
let child = command
.spawn()
.map_err(|error| format!("cannot start external program: {error}"))?;
#[cfg(feature = "external")]
{
return Err(format!("cannot stop external Adapter worker: {error}"));
Child::new(child)
}
#[cfg(not(feature = "external"))]
{
Ok(child)
}
}
async fn terminate_child(child: &mut Child) -> Result<(), String> {
if child.id().is_some() {
child
.start_kill()
.map_err(|error| format!("cannot stop external program: {error}"))?;
}
match tokio::time::timeout(SHUTDOWN_GRACE, child.wait()).await {
Ok(Ok(_)) => Ok(()),
Ok(Err(error)) => Err(format!("cannot wait for external Adapter worker: {error}")),
Err(_) => Err("external Adapter worker could not be reaped before its deadline".into()),
Ok(Err(error)) => Err(format!("cannot reap external program: {error}")),
Err(_) => Err("external program exceeded its cleanup deadline".into()),
}
}
@@ -0,0 +1,76 @@
use std::io;
use std::ops::{Deref, DerefMut};
use std::process::ExitStatus;
pub(super) struct Child {
child: tokio::process::Child,
#[cfg(target_os = "linux")]
group: Option<tokio::io::unix::AsyncFd<dever_process::ProcessGroup>>,
}
impl Child {
pub(super) fn new(mut child: tokio::process::Child) -> Result<Self, String> {
#[cfg(target_os = "linux")]
{
let result =
dever_process::ProcessGroup::pin(child.id().ok_or("new child has no PID")?)
.and_then(|group| {
tokio::io::unix::AsyncFd::new(group).map_err(|error| error.to_string())
});
match result {
Ok(group) => Ok(Self {
child,
group: Some(group),
}),
Err(error) => {
let _ = child.start_kill();
Err(error)
}
}
}
#[cfg(not(target_os = "linux"))]
{
let _ = child.start_kill();
Err("managed external programs currently require Linux".into())
}
}
fn stop_group(&mut self) -> io::Result<()> {
#[cfg(target_os = "linux")]
if let Some(group) = self.group.take() {
group.get_ref().terminate().map_err(io::Error::other)?;
}
Ok(())
}
pub(super) fn start_kill(&mut self) -> io::Result<()> {
self.stop_group()?;
self.child.start_kill()
}
pub(super) async fn wait(&mut self) -> io::Result<ExitStatus> {
#[cfg(target_os = "linux")]
if let Some(group) = &self.group {
let _ready = group.readable().await?;
}
self.stop_group()?;
self.child.wait().await
}
}
impl Deref for Child {
type Target = tokio::process::Child;
fn deref(&self) -> &Self::Target {
&self.child
}
}
impl DerefMut for Child {
fn deref_mut(&mut self) -> &mut Self::Target {
&mut self.child
}
}
impl Drop for Child {
fn drop(&mut self) {
let _ = self.stop_group();
}
}
+17 -46
View File
@@ -1,14 +1,13 @@
//! One ordinary child per request, owned by the existing Adapter supervisor.
use super::{Definition, Reply, Request, adapter_settings, terminate_child, worker_command};
#[cfg(feature = "external")]
use super::spawn_child;
use super::{Child, Definition, Reply, Request, adapter_settings, terminate_child, worker_command};
use crate::bytes::Bytes;
use crate::config::CommandLimits;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::process::{Child, ChildStderr, ChildStdin, ChildStdout};
use tokio::process::{ChildStderr, ChildStdin, ChildStdout};
use tokio::sync::{mpsc, oneshot};
mod observation;
use observation::Observation;
#[cfg(feature = "external")]
const ARGUMENT_BYTES: usize = 65_536;
#[cfg(feature = "external")]
@@ -71,7 +70,6 @@ pub(super) async fn supervise(
) -> Result<(), String> {
let prepared: Result<CommandLimits, String> = (|| {
let limits = adapter_settings()?.command_limits(&definition.port)?;
// Validate resources and grants without starting the user's executable.
worker_command(&definition)?;
Ok(limits)
})();
@@ -155,31 +153,27 @@ fn input(payload: &str) -> Result<Input, String> {
}
#[cfg(feature = "external")]
fn spawn(definition: &Definition, payload: &str) -> Result<(Input, Child, Observation), String> {
fn spawn(definition: &Definition, payload: &str) -> Result<(Input, Child), String> {
let input = input(payload)?;
let (command, observation) = crate::external::observed_command(definition)?;
let observation = Observation::new(observation)?;
let command = crate::external::worker_command(definition)?;
let mut command = tokio::process::Command::from(command);
// Resource arguments were mapped by the sandbox owner. Call arguments
// are opaque values, including absolute sandbox paths and shell syntax.
// Resource arguments were mapped by the process_runtime owner. Call arguments
// are opaque values, including absolute process_runtime paths and shell syntax.
command
.args(&input.arguments)
.env_clear()
.stdin(std::process::Stdio::piped())
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped())
.kill_on_drop(true);
let child = command
.spawn()
.map_err(|error| format!("cannot start external command: {error}"))?;
let child = spawn_child(&mut command)?;
// Dropping Command closes the parent's copies of mapped pipe descriptors.
drop(command);
Ok((input, child, observation))
Ok((input, child))
}
#[cfg(not(feature = "external"))]
fn spawn(_: &Definition, _: &str) -> Result<(Input, Child, Observation), String> {
Err("external command sandbox runtime feature is not enabled".into())
fn spawn(_: &Definition, _: &str) -> Result<(Input, Child), String> {
Err("external command process_runtime runtime feature is not enabled".into())
}
async fn execute(
@@ -188,7 +182,7 @@ async fn execute(
request: &mut Request,
stopping: &mut oneshot::Receiver<()>,
) -> Execution {
let (input, mut child, mut observation) = match spawn(definition, &request.payload) {
let (input, mut child) = match spawn(definition, &request.payload) {
Ok(prepared) => prepared,
Err(error) => {
return Execution {
@@ -198,12 +192,9 @@ async fn execute(
};
}
};
let wrapper = child
.id()
.expect("newly spawned command launcher has a PID");
let pipes = (child.stdin.take(), child.stdout.take(), child.stderr.take());
let (Some(stdin), Some(stdout), Some(stderr)) = pipes else {
let cleanup_error = cleanup(&mut child, &mut observation, wrapper).await.err();
let cleanup_error = terminate_child(&mut child).await.err();
return Execution {
reply: Err(cleanup_error
.clone()
@@ -215,8 +206,7 @@ async fn execute(
let mut stopped = false;
let result = {
let completion = async {
let (_, _, (stdout, stderr), status) = tokio::try_join!(
observation.started(wrapper),
let (_, (stdout, stderr), status) = tokio::try_join!(
write_stdin(stdin, input.stdin),
capture(stdout, stderr, limits.output_limit),
async {
@@ -228,7 +218,7 @@ async fn execute(
)?;
let code = status
.code()
.ok_or("external command sandbox terminated without an exit code")?;
.ok_or("external command process_runtime terminated without an exit code")?;
output(code, stdout, stderr)
};
tokio::select! {
@@ -243,7 +233,7 @@ async fn execute(
}
}
};
let cleanup_error = cleanup(&mut child, &mut observation, wrapper).await.err();
let cleanup_error = terminate_child(&mut child).await.err();
let reply = match (result, &cleanup_error) {
(result, None) => result,
(Ok(_), Some(cleanup)) => Err(cleanup.clone()),
@@ -256,25 +246,6 @@ async fn execute(
}
}
async fn cleanup(
child: &mut Child,
observation: &mut Observation,
wrapper: u32,
) -> Result<(), String> {
let namespace = tokio::time::timeout(super::SHUTDOWN_GRACE, observation.terminate(wrapper))
.await
.unwrap_or_else(|_| Err("external command namespace exceeded its cleanup deadline".into()));
// Reap the owned launcher even if namespace observation itself failed.
// Both stages have independent finite deadlines; neither error is lost.
let launcher = terminate_child(child).await;
match (namespace, launcher) {
(Err(namespace), Err(launcher)) => {
Err(format!("{namespace}; launcher cleanup failed: {launcher}"))
}
(namespace, launcher) => namespace.and(launcher),
}
}
async fn write_stdin(mut stdin: ChildStdin, input: Option<Bytes>) -> Result<(), String> {
if let Some(input) = input {
match stdin.write_all(input.values()).await {
@@ -1,135 +0,0 @@
#[cfg(all(feature = "external", target_os = "linux"))]
use tokio::io::AsyncReadExt;
pub(super) struct Observation {
#[cfg(all(feature = "external", target_os = "linux"))]
startup: tokio::net::unix::pipe::Receiver,
#[cfg(all(feature = "external", target_os = "linux"))]
information: tokio::net::unix::pipe::Receiver,
#[cfg(all(feature = "external", target_os = "linux"))]
information_bytes: Vec<u8>,
#[cfg(all(feature = "external", target_os = "linux"))]
release: Option<std::fs::File>,
#[cfg(all(feature = "external", target_os = "linux"))]
namespace: Option<tokio::io::unix::AsyncFd<dever_sandbox::CommandNamespace>>,
}
impl Observation {
#[cfg(all(feature = "external", target_os = "linux"))]
pub(super) fn new(observation: dever_sandbox::CommandObservation) -> Result<Self, String> {
use tokio::net::unix::pipe::Receiver;
Ok(Self {
startup: Receiver::from_file(observation.startup).map_err(|error| error.to_string())?,
information: Receiver::from_file(observation.namespace)
.map_err(|error| error.to_string())?,
information_bytes: Vec::new(),
release: Some(observation.release),
namespace: None,
})
}
#[cfg(all(feature = "external", not(target_os = "linux")))]
pub(super) fn new(_: dever_sandbox::CommandObservation) -> Result<Self, String> {
Err("external commands require Linux".into())
}
#[cfg(all(feature = "external", target_os = "linux"))]
async fn pin(&mut self, wrapper: u32) -> Result<(), String> {
if self.namespace.is_some() {
return Ok(());
}
let mut buffer = [0; 1024];
loop {
let count = self
.information
.read(&mut buffer)
.await
.map_err(|error| format!("cannot read command namespace identity: {error}"))?;
if count == 0 {
break;
}
if self.information_bytes.len() + count > 4096 {
return Err("command namespace identity exceeds its byte limit".into());
}
self.information_bytes.extend_from_slice(&buffer[..count]);
}
let text = std::str::from_utf8(&self.information_bytes)
.map_err(|_| "command namespace identity is not UTF-8")?;
let node = crate::wire::parse(text)?;
let pid = node
.object()?
.get("child-pid")
.ok_or("command namespace identity is missing")?
.int()?;
let namespace = dever_sandbox::CommandNamespace::pin(
i32::try_from(pid).map_err(|_| "invalid command namespace PID")?,
wrapper,
)?;
self.namespace = Some(
tokio::io::unix::AsyncFd::new(namespace)
.map_err(|error| format!("cannot observe command namespace exit: {error}"))?,
);
Ok(())
}
pub(super) async fn started(&mut self, wrapper: u32) -> Result<(), String> {
#[cfg(all(feature = "external", target_os = "linux"))]
{
self.pin(wrapper).await?;
// EOF releases bwrap only after its namespace is pinned. Neither
// normal cancellation nor a very short process can race PID reuse.
drop(self.release.take());
let mut status = Vec::new();
(&mut self.startup)
.take(4096)
.read_to_end(&mut status)
.await
.map_err(|error| format!("cannot read command startup status: {error}"))?;
match status.as_slice() {
b"R" => Ok(()),
[] => Err("external command sandbox failed before starting its guard".into()),
status => {
let message = status
.strip_prefix(b"RE")
.or_else(|| status.strip_prefix(b"E"));
Err(match message {
Some(message) => format!(
"external command startup failed: {}",
String::from_utf8_lossy(message)
),
None => "invalid external command startup status".into(),
})
}
}
}
#[cfg(not(all(feature = "external", target_os = "linux")))]
{
let _ = wrapper;
Err("external commands require Linux".into())
}
}
pub(super) async fn terminate(&mut self, wrapper: u32) -> Result<(), String> {
#[cfg(all(feature = "external", target_os = "linux"))]
{
self.pin(wrapper).await?;
let namespace = self
.namespace
.as_ref()
.expect("namespace pinned before cleanup");
namespace.get_ref().terminate()?;
// pidfd readiness follows kernel namespace teardown, unlike pipe
// EOF, which occurs while the init is still closing its files.
let _exited = namespace
.readable()
.await
.map_err(|error| format!("cannot wait for command namespace exit: {error}"))?;
Ok(())
}
#[cfg(not(all(feature = "external", target_os = "linux")))]
{
let _ = wrapper;
Err("external commands require Linux".into())
}
}
}
+1 -72
View File
@@ -11,7 +11,7 @@ pub use command::CommandLimits;
pub use compilation::{CompilationBindings, CompilationSite, DatabaseDriver};
const DATA_DIRECTORIES: [&str; 5] = ["db", "upload", "log", "cache", "tmp"];
const ADAPTER_FIELDS: &[&str] = &["use", "setting", "files", "command"];
const ADAPTER_FIELDS: &[&str] = &["use", "setting", "command"];
static SETTINGS: OnceLock<Settings> = OnceLock::new();
pub const MAX_JOB_TIMEOUT_MS: u32 = 3_600_000;
pub const MAX_JOB_LEASE_MS: i64 = 2 * MAX_JOB_TIMEOUT_MS as i64;
@@ -764,77 +764,6 @@ impl Settings {
Ok((index, setting))
}
/// File access is a deployment grant for the selected Port, not Worker
/// business settings. Paths stay below the application and appear at /data.
#[cfg(feature = "external")]
pub fn external_grants(
&self,
identity: &str,
capabilities: &[String],
) -> Result<Vec<dever_sandbox::Grant>, String> {
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct FileGrant {
path: PathBuf,
#[serde(default)]
write: bool,
}
let Some(adapter) = &self.adapter else {
return Ok(Vec::new());
};
let node = crate::wire::parse(adapter.get())?;
let Some(binding) = node.object()?.get(identity) else {
return Ok(Vec::new());
};
let binding = binding.fields(ADAPTER_FIELDS)?;
let Some(files) = binding.get("files") else {
return Ok(Vec::new());
};
if !capabilities.iter().any(|capability| capability == "file") {
return Err("Adapter file grants require its checked file capability".into());
}
let files: BTreeMap<String, FileGrant> = serde_json::from_str(files.raw())
.map_err(|_| "Adapter files must map names to {path, write} grants")?;
if files.len() > 64 {
return Err("Adapter has too many file grants".into());
}
let mut grants = Vec::new();
for (name, grant) in files {
if name.is_empty()
|| name.len() > 64
|| !name
.bytes()
.all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'_')
{
return Err("Adapter file grant names must contain lowercase letters, digits or underscores".into());
}
if grant.path.as_os_str().is_empty()
|| grant.path.is_absolute()
|| grant
.path
.components()
.any(|part| !matches!(part, Component::Normal(_)))
{
return Err("Adapter file grant path must be relative to the application".into());
}
let executable_cache = Path::new("data/cache/lib");
if grant.write
&& (grant.path.starts_with(executable_cache)
|| executable_cache.starts_with(&grant.path))
{
return Err(
"Adapter writable file grant overlaps the executable resource cache".into(),
);
}
grants.push(dever_sandbox::Grant {
source: self.executable_directory.join(grant.path),
destination: Path::new("/data").join(name),
writable: grant.write,
});
}
Ok(grants)
}
pub fn initialize_data_directories(&self) -> Result<(), String> {
for name in DATA_DIRECTORIES {
let path = self.executable_directory.join("data").join(name);
+6 -39
View File
@@ -87,59 +87,26 @@ struct LiteralArgument {
pub fn worker_command(
definition: &crate::component::Definition,
) -> Result<std::process::Command, String> {
sandbox_launch(definition, |launch| launch.command())
}
pub(crate) fn observed_command(
definition: &crate::component::Definition,
) -> Result<(std::process::Command, dever_sandbox::CommandObservation), String> {
sandbox_launch(definition, |launch| launch.observed_command())
}
fn sandbox_launch<T>(
definition: &crate::component::Definition,
build: impl FnOnce(dever_sandbox::Launch<'_>) -> Result<T, String>,
) -> Result<T, String> {
let session =
crate::component::current().ok_or("external resources require a Dever invocation")?;
let bundle = session
.resources
.get()
.ok_or("external Worker resources and sandbox were not prepared")?;
.ok_or("external Worker resources were not prepared")?;
let launch = verified_worker_launch(
&bundle.directory,
&bundle.resources,
&definition.entry,
&definition.ecosystem,
)?;
let settings = crate::component::adapter_settings()?
.external_grants(&definition.port, &definition.capabilities)?;
if definition
.capabilities
.iter()
.any(|capability| capability == "gpu")
{
return Err("GPU Worker requires a supported device isolation backend".into());
}
let arguments = launch.arguments;
build(dever_sandbox::Launch {
assets: &bundle.directory.join("sandbox"),
dever_process::Launch {
assets: &bundle.directory.join("process-runtime"),
worker: &launch.working_directory,
executable: &launch.executable,
arguments: &arguments,
arguments: &launch.arguments,
working_directory: &launch.working_directory,
capabilities: dever_sandbox::Capabilities {
network: definition
.capabilities
.iter()
.any(|capability| capability == "network"),
process: definition
.capabilities
.iter()
.any(|capability| capability == "process"),
},
grants: &settings,
})
}
.command()
}
pub fn verified_worker_launch(