Host: fixes from the review, with regression tests

- one live instance per actor: check after the lock; dropping an instance kills it
- state snapshots keep target/ and .git/; only source copies skip them
- one shutdown deadline; output during shutdown is still routed
- deliveries route stdout meanwhile; a stuck stdin kills the instance
  (no partial lines) and the sender gets undeliverable; control requests
  are not blocked by a stuck delivery
- Host::stop aborts running broker and cargo requests first
- compile a version before installing it; roll back if it does not start
- unique archive names; a failed archive keeps the actor manageable
- commit on close: truncate without create; directory renames
- candidates see SAUSAGE_VERSION and the existing state of a new actor
- the host publishes PROTOCOL.md at /actor/broker/public/
This commit is contained in:
2026-10-15 12:03:26 +01:00
parent 69cf80a114
commit 9d575b6a62
11 changed files with 811 additions and 220 deletions
+59 -20
View File
@@ -131,19 +131,42 @@ pub struct InstanceConfig {
pub env: Vec<(String, String)>,
}
/// A running guest. Dropping it kills the guest.
pub struct Instance {
stdin: DuplexStream,
stdin: Option<DuplexStream>,
/// Lines that the guest writes to stdout. Closed when the guest ends.
pub stdout: mpsc::Receiver<String>,
task: JoinHandle<Exit>,
}
impl Drop for Instance {
fn drop(&mut self) {
self.task.abort();
}
}
impl Instance {
/// Write one line to the guest's stdin.
pub async fn send_line(&mut self, line: &str) -> std::io::Result<()> {
self.stdin.write_all(line.as_bytes()).await?;
self.stdin.write_all(b"\n").await?;
self.stdin.flush().await
/// Write one line to the guest's stdin, in one write. Meanwhile, pass the
/// lines that the guest writes to `route`: a guest that blocks on a full
/// stdout would never read its stdin.
pub async fn send_line(&mut self, line: &str, mut route: impl FnMut(String)) -> std::io::Result<()> {
let stdin = self.stdin.as_mut().ok_or(std::io::ErrorKind::BrokenPipe)?;
let bytes = format!("{line}\n");
let write = async {
stdin.write_all(bytes.as_bytes()).await?;
stdin.flush().await
};
tokio::pin!(write);
let mut open = true;
loop {
tokio::select! {
done = &mut write => return done,
out = self.stdout.recv(), if open => match out {
Some(out) => route(out),
None => open = false,
},
}
}
}
/// Wait until the guest ends.
@@ -159,19 +182,35 @@ impl Instance {
self.task.is_finished()
}
/// Send `shutdown`, close stdin, and wait. Kill the guest after `deadline`.
pub async fn stop(mut self, shutdown_line: &str, deadline: Duration) -> Exit {
let _ = self.send_line(shutdown_line).await;
let _ = self.stdin.shutdown().await;
drop(self.stdin);
match tokio::time::timeout(deadline, &mut self.task).await {
Ok(Ok(exit)) => exit,
Ok(Err(e)) => Exit::Trap(format!("host task failed: {e}")),
Err(_) => {
self.task.abort();
let _ = (&mut self.task).await;
Exit::Trap("killed after shutdown deadline".into())
}
/// Kill the guest. Its stdout closes when it has ended.
pub fn kill(&self) {
self.task.abort();
}
/// Send `shutdown_line`, close stdin, and wait until the guest ends.
/// Pass every line that the guest writes meanwhile to `route`. Kill the
/// guest when `deadline` has passed.
pub async fn stop(mut self, shutdown_line: &str, deadline: Duration, mut route: impl FnMut(String)) -> Exit {
let stdin = self.stdin.take();
let stdout = &mut self.stdout;
let finished = tokio::time::timeout(deadline, async {
let close = async {
if let Some(mut stdin) = stdin {
let _ = stdin.write_all(format!("{shutdown_line}\n").as_bytes()).await;
let _ = stdin.shutdown().await;
}
};
let drain = async {
while let Some(line) = stdout.recv().await {
route(line);
}
};
tokio::join!(close, drain);
})
.await;
match finished {
Ok(()) => self.wait().await,
Err(_) => Exit::Trap("killed after shutdown deadline".into()),
}
}
}
@@ -299,6 +338,6 @@ impl Runtime {
exit
});
Ok(Instance { stdin: host_in, stdout: line_rx, task })
Ok(Instance { stdin: Some(host_in), stdout: line_rx, task })
}
}