server: start and stop the app via the handshake
Assisted-by: Claude Opus 5.5 via Claude Code
diff --git a/Cargo.lock b/Cargo.lock
index 0a24174..b7d94b7 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -1026,10 +1026,12 @@ dependencies = [
"clap",
"clap_complete",
"fs-err",
+ "libc",
"miette",
"mlua",
"predicates",
"serde",
+ "serde_json",
"serde_path_to_error",
"tempfile",
"thiserror",
diff --git a/Cargo.toml b/Cargo.toml
index 785f3b3..5962549 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -12,10 +12,13 @@ camino = "*"
clap = { version = "*", features = ["derive", "env"] }
clap_complete = "*"
fs-err = "*"
+libc = "*"
miette = { version = "*", features = ["fancy"] }
mlua = { version = "*", features = ["lua54", "vendored", "send", "serde"] }
serde = { version = "*", features = ["derive"] }
+serde_json = "*"
serde_path_to_error = "*"
+tempfile = "*"
thiserror = "*"
tokio = { version = "*", features = ["full"] }
tracing = "*"
@@ -24,7 +27,6 @@ tracing-subscriber = { version = "*", features = ["env-filter"] }
[dev-dependencies]
assert_cmd = "*"
predicates = "*"
-tempfile = "*"
[lints]
workspace = true
diff --git a/src/error.rs b/src/error.rs
index da4c52a..c075db3 100644
--- a/src/error.rs
+++ b/src/error.rs
@@ -1,6 +1,8 @@
use camino::Utf8PathBuf;
use miette::Diagnostic;
+use std::io;
+use std::process::ExitStatus;
#[derive(Debug, thiserror::Error, Diagnostic)]
pub enum Error {
@@ -20,14 +22,51 @@ pub enum Error {
#[error("io error: {0}")]
#[diagnostic(code(parallax::io))]
- Io(#[from] std::io::Error),
+ Io(#[from] io::Error),
- #[error("running variants is not implemented yet")]
+ #[error("the server function failed")]
#[diagnostic(
- code(parallax::not_implemented),
+ code(parallax::server_function),
help(
- "loading and listing configs works; running the flow in every variant is the next task"
+ "the server function must return a table like {{:cmd [\"app\" \"serve\"] :timeout 30}}"
)
)]
- NotImplemented,
+ ServerFunction(#[source] mlua::Error),
+
+ #[error("could not start the server command: {cmd}")]
+ #[diagnostic(
+ code(parallax::server_spawn),
+ help("the first word of :cmd must name an executable on PATH or a path to one")
+ )]
+ ServerSpawn {
+ cmd: String,
+ #[source]
+ source: io::Error,
+ },
+
+ #[error("{variant}: the server exited before the handshake ({status})\n{tail}")]
+ #[diagnostic(
+ code(parallax::server_exited),
+ help(
+ "the app must write JSON with a url to $PARALLAX_HANDSHAKE once it listens; the quoted text is the end of its server.log"
+ )
+ )]
+ ServerExited {
+ variant: String,
+ status: ExitStatus,
+ tail: String,
+ },
+
+ #[error("{variant}: no handshake from the server after {timeout}s\n{tail}")]
+ #[diagnostic(
+ code(parallax::server_timeout),
+ help(
+ "the app must write JSON with a url to $PARALLAX_HANDSHAKE once it listens; the quoted text is the end of its server.log"
+ )
+ )]
+ ServerTimeout {
+ variant: String,
+ timeout: f64,
+ tail: String,
+ },
}
diff --git a/src/lib.rs b/src/lib.rs
index e4234c5..f712de4 100644
--- a/src/lib.rs
+++ b/src/lib.rs
@@ -1,5 +1,6 @@
mod error;
mod parallax;
+mod server;
mod spec;
pub use error::Error;
diff --git a/src/parallax.rs b/src/parallax.rs
index 8bfac56..96e94f2 100644
--- a/src/parallax.rs
+++ b/src/parallax.rs
@@ -6,11 +6,14 @@ use camino::Utf8PathBuf;
use mlua::ErrorContext as _;
use mlua::Function;
use mlua::Lua;
+use mlua::LuaSerdeExt as _;
use mlua::Table;
use mlua::Value;
use mlua::serde::Deserializer;
use crate::Error;
+use crate::server::Server;
+use crate::spec::ServerSpec;
use crate::spec::Setup;
use crate::spec::Variant;
@@ -76,8 +79,6 @@ fn pop_function(table: &Table, key: &str) -> Result<Option<Function>, mlua::Erro
/// A loaded parallax: the executable form of a config. The server
/// function and flow are handles into the embedded Lua state.
pub struct Parallax {
- // Consumed once running variants lands.
- #[allow(dead_code)]
server: Function,
// Consumed once running variants lands.
#[allow(dead_code)]
@@ -86,9 +87,8 @@ pub struct Parallax {
name: String,
#[allow(dead_code)]
path: Utf8PathBuf,
- // Never read, but the handles above only hold weak references;
- // this is what keeps the VM open.
- #[allow(dead_code)]
+ // The handles above only hold weak references; this is what
+ // keeps the VM open.
lua: Lua,
}
@@ -128,9 +128,35 @@ impl Parallax {
&self.name
}
- /// Runs the flow in every variant.
- pub async fn run(&self, _output: Option<&Utf8Path>) -> Result<(), Error> {
- Err(Error::NotImplemented)
+ /// Runs every variant: server up, flow, server down. The flow
+ /// itself joins once the browser driver lands; for now this
+ /// exercises the server lifecycle per variant.
+ pub async fn run(&self, output: Option<&Utf8Path>) -> Result<(), Error> {
+ // Runs without --output write to scratch space until the XDG
+ // cache defaults land; nothing should litter the cwd.
+ let scratch;
+ let root = match output {
+ Some(path) => path.as_std_path(),
+ None => {
+ scratch = tempfile::tempdir().map_err(Error::Io)?;
+ scratch.path()
+ }
+ };
+ for variant in &self.setup.variants {
+ let spec = self.server_spec()?;
+ let server = Server::start(&spec, variant.name.as_str(), root).await?;
+ // The flow runs here once the browser driver lands.
+ server.stop().await?;
+ }
+ Ok(())
+ }
+
+ /// Calls the config's server function and validates what it
+ /// returns. The design calls it once per variant, so a config can
+ /// vary the command per run.
+ fn server_spec(&self) -> Result<ServerSpec, Error> {
+ let value: Value = self.server.call(()).map_err(Error::ServerFunction)?;
+ self.lua.from_value(value).map_err(Error::ServerFunction)
}
}
diff --git a/src/server.rs b/src/server.rs
new file mode 100644
index 0000000..cf7b3bd
--- /dev/null
+++ b/src/server.rs
@@ -0,0 +1,495 @@
+//! The server supervisor: spawns each variant's app command in its own
+//! process group, waits for the handshake, and tears the group down
+//! with SIGTERM then SIGKILL.
+//!
+//! The lifecycle is deliberately blocking — `std::process` plus a poll
+//! loop — and the async surface wraps it in `spawn_blocking` so a run
+//! never occupies an executor thread. The child inherits descriptors
+//! straight into `server.log`, so there is no pipe plumbing to drain
+//! and the log is complete once the group is reaped.
+
+use std::io;
+use std::os::unix::process::CommandExt as _;
+use std::path::Path;
+use std::path::PathBuf;
+use std::process::Child;
+use std::process::Command;
+use std::process::ExitStatus;
+use std::process::Stdio;
+use std::time::Duration;
+use std::time::Instant;
+
+use serde::Deserialize;
+use serde_json::Value;
+
+use crate::Error;
+use crate::spec::ServerSpec;
+
+/// How long the group gets to exit after SIGTERM before SIGKILL.
+const GRACE: Duration = Duration::from_secs(5);
+/// How often the handshake file and the child are checked.
+const POLL: Duration = Duration::from_millis(20);
+/// The most bytes of `server.log` quoted in a failure.
+const TAIL: usize = 2048;
+
+/// The JSON an app writes to `$PARALLAX_HANDSHAKE` once it is
+/// listening. `data` is opaque to parallax; the flow reads it with
+/// `(app)`.
+#[derive(Debug, Deserialize)]
+pub(crate) struct Handshake {
+ // Read once the flow context lands.
+ #[allow(dead_code)]
+ pub(crate) url: String,
+
+ // Read once the flow context lands.
+ #[allow(dead_code)]
+ #[serde(default)]
+ pub(crate) data: Value,
+}
+
+/// A running app: the parsed handshake plus everything teardown
+/// needs. Dropping without calling `stop` SIGKILLs the group.
+#[derive(Debug)]
+pub(crate) struct Server {
+ handshake: Option<Handshake>,
+ child: Child,
+ /// The child's pid, which is also its pgid: it was spawned as the
+ /// leader of a fresh group.
+ pgid: i32,
+ log: PathBuf,
+ stopped: bool,
+ /// Keeps the directory holding the handshake file alive.
+ _dir: tempfile::TempDir,
+}
+
+impl Server {
+ pub(crate) async fn start(
+ spec: &ServerSpec,
+ variant: &str,
+ output: &Path,
+ ) -> Result<Server, Error> {
+ let spec = spec.clone();
+ let variant = variant.to_owned();
+ let output = output.to_owned();
+ let task =
+ tokio::task::spawn_blocking(move || Self::spawn_and_wait(&spec, &variant, &output));
+ task.await
+ .map_err(|error| Error::Io(io::Error::other(error)))?
+ }
+
+ // Read once the flow context lands.
+ #[allow(dead_code)]
+ pub(crate) fn handshake(&self) -> &Handshake {
+ #[allow(clippy::expect_used)] // start() only yields a Server once the handshake parsed
+ self.handshake
+ .as_ref()
+ .expect("a started server has a handshake")
+ }
+
+ /// SIGTERM the group, wait out the grace period, then SIGKILL
+ /// whatever is left — including stragglers a wrapper left behind
+ /// after its own exit.
+ #[allow(clippy::arithmetic_side_effects)] // Instant + Duration only panics near heat death
+ pub(crate) async fn stop(mut self) -> Result<(), Error> {
+ tokio::task::spawn_blocking(move || {
+ signal_group(self.pgid, libc::SIGTERM).map_err(Error::Io)?;
+ let deadline = Instant::now() + GRACE;
+ loop {
+ if self.child.try_wait().map_err(Error::Io)?.is_some() {
+ break;
+ }
+ if Instant::now() >= deadline {
+ break;
+ }
+ std::thread::sleep(POLL);
+ }
+ self.reap();
+ Ok(())
+ })
+ .await
+ .map_err(|error| Error::Io(io::Error::other(error)))?
+ }
+
+ fn spawn_and_wait(spec: &ServerSpec, variant: &str, output: &Path) -> Result<Server, Error> {
+ let dir = tempfile::tempdir().map_err(Error::Io)?;
+ let handshake_path = dir.path().join("handshake.json");
+
+ let variant_dir = output.join(variant);
+ fs_err::create_dir_all(&variant_dir).map_err(Error::Io)?;
+ let log = variant_dir.join("server.log");
+ let file = fs_err::File::create(&log).map_err(Error::Io)?;
+
+ // A fresh process group makes the child its own leader, so the
+ // group signal reaches everything a wrapper like `sh -c` or
+ // `bundle exec` spawned, and only those processes.
+ let mut command = Command::new(&spec.program);
+ command
+ .args(&spec.args)
+ .env("PARALLAX_HANDSHAKE", &handshake_path)
+ .stdin(Stdio::null())
+ // Stdio conversions need std files; fs_err has no Stdio
+ // bridge, and no path-bearing operation happens here.
+ .stdout({
+ #[allow(clippy::disallowed_types)]
+ let clone: std::fs::File = file.try_clone().map_err(Error::Io)?.into();
+ Stdio::from(clone)
+ })
+ .stderr({
+ #[allow(clippy::disallowed_types)]
+ let log: std::fs::File = file.into();
+ Stdio::from(log)
+ })
+ .process_group(0);
+ let child = command.spawn().map_err(|error| Error::ServerSpawn {
+ cmd: spec.command_line(),
+ source: error,
+ })?;
+ let mut server = Server {
+ handshake: None,
+ pgid: child.id() as i32,
+ child,
+ log,
+ stopped: false,
+ _dir: dir,
+ };
+
+ let child = &mut server.child;
+ let outcome = wait_for_handshake(&handshake_path, spec.timeout, || child.try_wait())
+ .map_err(Error::Io)?;
+ match outcome {
+ Ok(handshake) => {
+ server.handshake = Some(handshake);
+ Ok(server)
+ }
+ Err(HandshakeError::Exited(status)) => {
+ server.reap();
+ Err(Error::ServerExited {
+ variant: variant.to_owned(),
+ status,
+ tail: tail(&server.log),
+ })
+ }
+ Err(HandshakeError::Timeout) => {
+ server.reap();
+ Err(Error::ServerTimeout {
+ variant: variant.to_owned(),
+ timeout: spec.timeout,
+ tail: tail(&server.log),
+ })
+ }
+ }
+ }
+
+ /// SIGKILLs the group and reaps the child, making the log complete.
+ fn reap(&mut self) {
+ self.stopped = true;
+ let _ = signal_group(self.pgid, libc::SIGKILL);
+ let _ = self.child.wait();
+ }
+}
+
+impl Drop for Server {
+ fn drop(&mut self) {
+ if !self.stopped {
+ self.reap();
+ }
+ }
+}
+
+/// Why the app never became ready.
+enum HandshakeError {
+ Exited(ExitStatus),
+ Timeout,
+}
+
+/// Polls for the handshake until it exists and parses. Apps rename the
+/// file into place, so a successful read is a complete one; anything
+/// unreadable or unparseable — including a missing `url` — keeps
+/// waiting until the deadline. `exited` is `Child::try_wait`; the outer
+/// error is parallax failing to check, the inner one the app failing.
+#[allow(clippy::arithmetic_side_effects)] // Instant + Duration only panics near heat death
+fn wait_for_handshake(
+ path: &Path,
+ timeout: f64,
+ mut exited: impl FnMut() -> io::Result<Option<ExitStatus>>,
+) -> io::Result<Result<Handshake, HandshakeError>> {
+ let deadline = Instant::now() + Duration::from_secs_f64(timeout);
+ loop {
+ if let Some(handshake) = read_handshake(path) {
+ return Ok(Ok(handshake));
+ }
+ if let Some(status) = exited()? {
+ return Ok(Err(HandshakeError::Exited(status)));
+ }
+ if Instant::now() >= deadline {
+ return Ok(Err(HandshakeError::Timeout));
+ }
+ std::thread::sleep(POLL);
+ }
+}
+
+fn read_handshake(path: &Path) -> Option<Handshake> {
+ let text = fs_err::read_to_string(path).ok()?;
+ serde_json::from_str(&text).ok()
+}
+
+/// Signals the whole process group. A missing group is success: there
+/// is nothing left to signal.
+fn signal_group(pgid: i32, signal: libc::c_int) -> io::Result<()> {
+ // SAFETY: killpg sends a signal and touches none of our memory.
+ if unsafe { libc::killpg(pgid, signal) } == -1 {
+ let error = io::Error::last_os_error();
+ if error.raw_os_error() != Some(libc::ESRCH) {
+ return Err(error);
+ }
+ }
+ Ok(())
+}
+
+/// The end of the server's log, cut forward to a line boundary so the
+/// quote starts on one.
+fn tail(path: &Path) -> String {
+ #[allow(clippy::disallowed_methods)]
+ // a log tail is arbitrary bytes headed for an error message
+ fn lossy(bytes: &[u8]) -> String {
+ String::from_utf8_lossy(bytes).into_owned()
+ }
+
+ let Ok(bytes) = fs_err::read(path) else {
+ return String::new();
+ };
+ let start = bytes.len().saturating_sub(TAIL);
+ let slice = bytes.get(start..).unwrap_or_default();
+ if start == 0 {
+ return lossy(slice);
+ }
+ let mut split = slice.splitn(2, |&byte| byte == b'\n');
+ split.next(); // the line the window cut into
+ lossy(split.next().unwrap_or_default())
+}
+
+#[cfg(test)]
+mod tests {
+ #![allow(
+ clippy::arithmetic_side_effects,
+ clippy::expect_used,
+ clippy::unwrap_used
+ )]
+
+ use super::*;
+ use serde_json::json;
+
+ /// A shell step that completes the handshake, for tests about teardown.
+ const HANDSHAKE: &str = r#"printf '{"url":"http://127.0.0.1:1"}' > "$PARALLAX_HANDSHAKE""#;
+
+ fn spec(program: &str, args: &[&str], timeout: f64) -> ServerSpec {
+ ServerSpec {
+ program: program.to_owned(),
+ args: args.iter().map(|&arg| arg.to_owned()).collect(),
+ timeout,
+ }
+ }
+
+ fn sh(script: &str, timeout: f64) -> ServerSpec {
+ spec("sh", &["-c", script], timeout)
+ }
+
+ /// Polls for up to five seconds until no process remains in the
+ /// group, sleeping first since SIGKILLed orphans take a moment to
+ /// be reaped.
+ fn assert_group_gone(pgid: i32) {
+ let gone = (0..250).any(|_| {
+ std::thread::sleep(POLL);
+ // SAFETY: signal 0 only checks that the group exists.
+ (unsafe { libc::killpg(pgid, 0) }) == -1
+ });
+ assert!(gone, "process group {pgid} still has members");
+ }
+
+ #[tokio::test]
+ async fn reads_the_handshake_url_and_data() {
+ let output = tempfile::tempdir().expect("tempdir");
+ let script = r#"printf '{"url":"http://127.0.0.1:1","data":{"x":1}}' > "$PARALLAX_HANDSHAKE"; sleep 300"#;
+ let server = Server::start(&sh(script, 5.0), "desktop", output.path())
+ .await
+ .expect("server starts");
+ assert_eq!(server.handshake().url, "http://127.0.0.1:1");
+ assert_eq!(server.handshake().data, json!({"x": 1}));
+ server.stop().await.expect("server stops");
+ }
+
+ #[tokio::test]
+ async fn the_fixture_handshake_reaches_parallax() {
+ let output = tempfile::tempdir().expect("tempdir");
+ let cmd = spec(
+ "cargo",
+ &["run", "--quiet", "--package", "parallax-fixture"],
+ 120.0,
+ );
+ let server = Server::start(&cmd, "desktop", output.path())
+ .await
+ .expect("fixture starts");
+ let url = &server.handshake().url;
+ assert!(url.starts_with("http://127.0.0.1:"), "url was {url}");
+ assert_eq!(server.handshake().data, json!({"featured_item": 2}));
+ server.stop().await.expect("fixture stops");
+ }
+
+ #[tokio::test]
+ async fn wrappers_leave_no_orphans() {
+ let output = tempfile::tempdir().expect("tempdir");
+ let script = format!("sleep 300 & {HANDSHAKE}; sleep 300");
+ let server = Server::start(&sh(&script, 5.0), "desktop", output.path())
+ .await
+ .expect("server starts");
+ let pgid = server.pgid;
+ server.stop().await.expect("server stops");
+ assert_group_gone(pgid);
+ }
+
+ #[tokio::test]
+ async fn dropping_a_server_kills_its_group() {
+ let output = tempfile::tempdir().expect("tempdir");
+ let script = format!("sleep 300 & {HANDSHAKE}; sleep 300");
+ let server = Server::start(&sh(&script, 5.0), "desktop", output.path())
+ .await
+ .expect("server starts");
+ let pgid = server.pgid;
+ drop(server);
+ assert_group_gone(pgid);
+ }
+
+ #[tokio::test]
+ async fn sigterm_runs_before_sigkill() {
+ let output = tempfile::tempdir().expect("tempdir");
+ // sh logs from its TERM handler before exiting; SIGKILL first
+ // would kill it before the handler could run. The handshake
+ // comes after the trap, so the handler is installed by then.
+ let script = format!("trap 'echo terminated-by-term' TERM; {HANDSHAKE}; sleep 300");
+ let server = Server::start(&sh(&script, 5.0), "desktop", output.path())
+ .await
+ .expect("server starts");
+ let log = server.log.clone();
+ server.stop().await.expect("server stops");
+ let text = fs_err::read_to_string(&log).expect("log readable");
+ assert!(text.contains("terminated-by-term"));
+ }
+
+ #[tokio::test]
+ async fn sigkill_escalates_when_sigterm_is_ignored() {
+ let output = tempfile::tempdir().expect("tempdir");
+ // trap '' leaves SIGTERM ignored, and the fg sleep inherits
+ // that, so only SIGKILL can take the group down.
+ let script = format!("trap '' TERM; {HANDSHAKE}; sleep 300");
+ let server = Server::start(&sh(&script, 5.0), "desktop", output.path())
+ .await
+ .expect("server starts");
+ let pgid = server.pgid;
+ let started = Instant::now();
+ server.stop().await.expect("server stops");
+ let waited = started.elapsed();
+ assert!(waited >= GRACE, "stop waited only {waited:?}");
+ assert_group_gone(pgid);
+ }
+
+ #[tokio::test]
+ async fn an_app_that_exits_before_the_handshake_fails_with_its_tail() {
+ let output = tempfile::tempdir().expect("tempdir");
+ let error = Server::start(&sh("echo boom >&2; exit 3", 5.0), "desktop", output.path())
+ .await
+ .expect_err("server must fail");
+ assert!(
+ matches!(&error, Error::ServerExited { variant, tail, .. }
+ if variant == "desktop" && tail.contains("boom")),
+ "got {error:?}"
+ );
+ assert!(error.to_string().contains("exit status: 3"));
+ }
+
+ #[tokio::test]
+ async fn a_server_that_never_handshakes_times_out() {
+ let output = tempfile::tempdir().expect("tempdir");
+ let cmd = sh("echo starting >&2; sleep 300", 0.25);
+ let error = Server::start(&cmd, "desktop", output.path())
+ .await
+ .expect_err("server must fail");
+ assert!(
+ matches!(&error, Error::ServerTimeout { timeout, tail, .. }
+ if *timeout == 0.25 && tail.contains("starting")),
+ "got {error:?}"
+ );
+ }
+
+ #[tokio::test]
+ async fn a_handshake_without_a_url_keeps_waiting() {
+ let output = tempfile::tempdir().expect("tempdir");
+ let script = r#"printf '{"data":{"x":1}}' > "$PARALLAX_HANDSHAKE"; sleep 300"#;
+ let error = Server::start(&sh(script, 0.25), "desktop", output.path())
+ .await
+ .expect_err("server must fail");
+ assert!(matches!(error, Error::ServerTimeout { .. }));
+ }
+
+ #[tokio::test]
+ async fn a_missing_command_is_named() {
+ let output = tempfile::tempdir().expect("tempdir");
+ let cmd = spec("parallax-no-such-command-xyzzy", &["serve"], 1.0);
+ let error = Server::start(&cmd, "desktop", output.path())
+ .await
+ .expect_err("spawn must fail");
+ assert!(
+ matches!(&error, Error::ServerSpawn { cmd, .. }
+ if cmd == "parallax-no-such-command-xyzzy serve"),
+ "got {error:?}"
+ );
+ }
+
+ #[tokio::test]
+ async fn server_output_lands_in_the_log() {
+ let output = tempfile::tempdir().expect("tempdir");
+ let script = format!("echo an-out-line; echo an-err-line >&2; {HANDSHAKE}; sleep 300");
+ let server = Server::start(&sh(&script, 5.0), "desktop", output.path())
+ .await
+ .expect("server starts");
+ let log = server.log.clone();
+ assert_eq!(log, output.path().join("desktop").join("server.log"));
+ server.stop().await.expect("server stops");
+ let text = fs_err::read_to_string(&log).expect("log readable");
+ assert!(text.contains("an-out-line"));
+ assert!(text.contains("an-err-line"));
+ }
+
+ #[test]
+ fn a_failed_exit_check_is_an_io_error() {
+ let dir = tempfile::tempdir().expect("tempdir");
+ let path = dir.path().join("handshake.json");
+ let outcome = wait_for_handshake(&path, 5.0, || Err(io::Error::other("no child")));
+ assert!(outcome.is_err());
+ }
+
+ #[test]
+ fn signalling_reports_errors_other_than_a_missing_group() {
+ // Group 0 is our own; the out-of-range signal is rejected
+ // before anything could be delivered.
+ let error = signal_group(0, 9999).expect_err("invalid signal");
+ assert_eq!(error.raw_os_error(), Some(libc::EINVAL));
+ }
+
+ #[test]
+ fn the_tail_of_a_missing_log_is_empty() {
+ let dir = tempfile::tempdir().expect("tempdir");
+ assert_eq!(tail(&dir.path().join("server.log")), "");
+ }
+
+ #[test]
+ fn the_tail_of_a_long_log_starts_on_a_line() {
+ let dir = tempfile::tempdir().expect("tempdir");
+ let path = dir.path().join("server.log");
+ // 300 ten-byte lines; the 2048-byte window cuts into line 95.
+ let text: String = (0..300).map(|n| format!("line-{n:04}\n")).collect();
+ fs_err::write(&path, text).expect("log written");
+ let quoted = tail(&path);
+ assert!(quoted.starts_with("line-0096\n"), "tail was {quoted:?}");
+ assert!(quoted.ends_with("line-0299\n"));
+ }
+}
diff --git a/src/spec.rs b/src/spec.rs
index d881555..f0204f9 100644
--- a/src/spec.rs
+++ b/src/spec.rs
@@ -147,19 +147,56 @@ pub(crate) struct BaselineSource {
}
/// The table the registered server function returns, once per
-/// variant. Validated when the function is called — a future task.
-#[derive(Debug, Deserialize)]
-#[serde(deny_unknown_fields)]
-#[allow(dead_code)] // constructed when running configs lands
+/// variant.
+#[derive(Clone, Debug)]
pub struct ServerSpec {
- pub cmd: Vec<String>,
+ /// The first word of `:cmd`, split off so an empty command is
+ /// unrepresentable.
+ pub program: String,
+ pub args: Vec<String>,
- #[serde(default = "default_timeout")]
pub timeout: f64,
}
-/// Referenced by ServerSpec's serde default; constructed when running configs lands.
-#[allow(dead_code)]
+impl ServerSpec {
+ /// The command as the config spelled it, for messages.
+ pub fn command_line(&self) -> String {
+ std::iter::once(&self.program)
+ .chain(&self.args)
+ .map(String::as_str)
+ .collect::<Vec<_>>()
+ .join(" ")
+ }
+}
+
+impl<'de> Deserialize<'de> for ServerSpec {
+ fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
+ #[derive(Deserialize)]
+ #[serde(deny_unknown_fields)]
+ struct Table {
+ cmd: Vec<String>,
+
+ #[serde(default = "default_timeout")]
+ timeout: f64,
+ }
+ let table = Table::deserialize(deserializer)?;
+ let mut words = table.cmd.into_iter();
+ let Some(program) = words.next() else {
+ return Err(serde::de::Error::custom("cmd must not be empty"));
+ };
+ if !table.timeout.is_finite() || table.timeout <= 0.0 {
+ return Err(serde::de::Error::custom(
+ "timeout must be a positive number of seconds",
+ ));
+ }
+ Ok(ServerSpec {
+ program,
+ args: words.collect(),
+ timeout: table.timeout,
+ })
+ }
+}
+
fn default_timeout() -> f64 {
30.0
}
@@ -268,16 +305,52 @@ mod tests {
fn parses_a_server_table_with_defaults() {
with_value(r#"return {cmd = {"true"}}"#, |lua, value| {
let server: ServerSpec = lua.from_value(value).expect("server table");
- assert_eq!(server.cmd, vec!["true".to_owned()]);
+ assert_eq!(server.program, "true");
+ assert!(server.args.is_empty());
assert_eq!(server.timeout, 30.0);
});
}
#[test]
fn parses_a_server_table_with_a_timeout() {
- with_value(r#"return {cmd = {"true"}, timeout = 5}"#, |lua, value| {
- let server: ServerSpec = lua.from_value(value).expect("server table");
- assert_eq!(server.timeout, 5.0);
- });
+ with_value(
+ r#"return {cmd = {"app", "serve"}, timeout = 5}"#,
+ |lua, value| {
+ let server: ServerSpec = lua.from_value(value).expect("server table");
+ assert_eq!(server.program, "app");
+ assert_eq!(server.args, vec!["serve".to_owned()]);
+ assert_eq!(server.command_line(), "app serve");
+ assert_eq!(server.timeout, 5.0);
+ },
+ );
+ }
+
+ #[test]
+ fn rejects_bad_server_tables() {
+ let cases = [
+ (r#"return {timeout = 5}"#, "missing field `cmd`"),
+ (r#"return {cmd = {}}"#, "cmd must not be empty"),
+ (r#"return {cmd = {"true"}, extra = 1}"#, "unknown field"),
+ (r#"return {cmd = "true"}"#, "invalid type"),
+ (
+ r#"return {cmd = {"true"}, timeout = 0}"#,
+ "timeout must be a positive",
+ ),
+ (
+ r#"return {cmd = {"true"}, timeout = -1}"#,
+ "timeout must be a positive",
+ ),
+ ];
+ for (body, fragment) in cases {
+ with_value(body, |lua, value| {
+ let error = lua
+ .from_value::<ServerSpec>(value)
+ .expect_err("expected an invalid server table");
+ assert!(
+ error.to_string().contains(fragment),
+ "expected {error:?} to contain {fragment:?}"
+ );
+ });
+ }
}
}
diff --git a/tests/cli.rs b/tests/cli.rs
index 387d93c..7b2978f 100644
--- a/tests/cli.rs
+++ b/tests/cli.rs
@@ -13,6 +13,10 @@ const VALID: &str = r#"{:variants {:mobile-dark {:viewport [375 812] :scheme :da
:server (fn [] {:cmd ["bundle" "exec" "rake" "snapshots:serve"]})
:flow (fn [{: goto : wait-for : click : snap : variant}] nil)}"#;
+const RUNNABLE: &str = r#"{:variants {:desktop {:viewport [1280 900]}}
+ :server (fn [] {:cmd ["cargo" "run" "--quiet" "--package" "parallax-fixture"] :timeout 120})
+ :flow (fn [] nil)}"#;
+
fn config_file(source: &str) -> (TempDir, PathBuf) {
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("config.fnl");
@@ -132,15 +136,34 @@ fn flow_must_be_a_function() {
}
#[test]
-fn running_a_config_is_not_implemented_yet() {
- let (_dir, path) = config_file(VALID);
+fn run_starts_and_stops_the_server_per_variant() {
+ let (_dir, path) = config_file(RUNNABLE);
+ let output = tempfile::tempdir().expect("tempdir");
cargo_bin_cmd!("parallax")
.arg("run")
.arg("--config")
.arg(&path)
+ .arg("--output")
+ .arg(output.path())
.assert()
- .failure()
- .stderr(predicate::str::contains("not implemented"));
+ .success();
+ let log = output.path().join("desktop").join("server.log");
+ assert!(
+ fs_err::metadata(&log)
+ .map(|meta| meta.is_file())
+ .unwrap_or(false)
+ );
+}
+
+#[test]
+fn run_without_an_output_dir_still_works() {
+ let (_dir, path) = config_file(RUNNABLE);
+ cargo_bin_cmd!("parallax")
+ .arg("run")
+ .arg("--config")
+ .arg(&path)
+ .assert()
+ .success();
}
#[test]