server: start and stop the app via the handshake
Assisted-by: Claude Opus 5.5 via Claude Code
change loyntvyqnzzvvtuwylutnuuotmqpmllm
commit 701710bf00a457dd1fb2a29f7ec050becce34afa
author Alpha Chen <alpha@kejadlen.dev>
date
parent vtwxpzxk
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]