Skip to main content

xtask/
demo.rs

1//! `cargo xtask demo` — the collaboration demo on one machine: a server, a
2//! fresh log, and N editors connected to it.
3//!
4//! Doing this by hand is four terminals and a URL typed three times. What it
5//! cannot do by hand at all is the interesting half: **freezing the server**.
6//! Two people editing at human speed almost never have unacknowledged commits
7//! in flight at the same moment, so the concurrent case — the one the whole
8//! server-centric model exists for — is the one a manual demo never reaches.
9//! `f` stops the server process, both editors keep authoring into their
10//! queues, and `t` lets it sequence them.
11//!
12//! Freeze only once the editors say `live` in their titles: a stopped server
13//! does not drop the connection, but it cannot complete a handshake either,
14//! so freezing before they connect leaves them `[disconnected]` — and nothing
15//! reconnects them, because resume-on-reconnect is not built yet (R3 in
16//! `docs/collab-migration-playbook.md`).
17
18use std::io::{BufRead, Write};
19use std::net::{SocketAddr, TcpStream};
20use std::path::PathBuf;
21use std::process::{Child, Command};
22use std::time::{Duration, Instant};
23
24use anyhow::{Context, Result, bail};
25use clap::Args;
26
27use crate::{cargo, workspace_root};
28
29#[derive(Args)]
30pub struct DemoArgs {
31    /// How many editors to open on the log.
32    #[arg(long, default_value_t = 2)]
33    clients: usize,
34
35    /// Address the server listens on.
36    #[arg(long, default_value = "127.0.0.1:4100")]
37    listen: String,
38
39    /// Log to serve. Defaults to a scratch file that is removed on exit;
40    /// naming one keeps it, so a second run resumes the same document.
41    #[arg(long)]
42    db: Option<PathBuf>,
43
44    /// Narrate every document mutation in each editor's console
45    /// (`RUST_LOG=edit=debug`).
46    #[arg(long)]
47    narrate: bool,
48
49    /// Build and run the optimized binaries.
50    #[arg(long)]
51    release: bool,
52}
53
54pub fn run(args: &DemoArgs) -> Result<()> {
55    if args.clients == 0 {
56        bail!("--clients must be at least 1");
57    }
58    let addr: SocketAddr = args
59        .listen
60        .parse()
61        .with_context(|| format!("--listen {} is not an address", args.listen))?;
62
63    build(args)?;
64
65    let scratch = args.db.is_none();
66    let db = args.db.clone().unwrap_or_else(|| {
67        workspace_root()
68            .join("target")
69            .join("demo-scratch.blockworx.db")
70    });
71    if scratch && db.exists() {
72        std::fs::remove_file(&db).ok();
73    }
74
75    let mut session = Session {
76        server: spawn_server(args, &db, &args.listen)?,
77        clients: Vec::new(),
78        db: scratch.then_some(db.clone()),
79        frozen: false,
80    };
81    wait_for(addr).context("the server never accepted a connection")?;
82
83    let url = format!("ws://{addr}/ws");
84    for _ in 0..args.clients {
85        session.clients.push(spawn_client(args, &url)?);
86    }
87
88    eprintln!();
89    eprintln!("  {} editors on {}", args.clients, db.display());
90    eprintln!("  serving {url}");
91    eprintln!();
92    eprintln!("  Edit in either window; the other follows. To see the case hands");
93    eprintln!("  cannot reach, wait for both titles to say `live`, then freeze,");
94    eprintln!("  edit the same block in both, and thaw: the later commit wins and");
95    eprintln!("  both canvases agree.");
96    eprintln!();
97    console(&mut session)
98}
99
100/// Children plus what has to happen to them on the way out. `Drop` covers
101/// every exit from [`console`] — including `?` — but not a signal: Ctrl-C in a
102/// terminal reaches the whole process group, so the children take it too.
103struct Session {
104    server: Child,
105    clients: Vec<Child>,
106    db: Option<PathBuf>,
107    frozen: bool,
108}
109
110impl Drop for Session {
111    fn drop(&mut self) {
112        if self.frozen {
113            // A stopped process ignores SIGTERM until it runs again.
114            signal(&self.server, "-CONT");
115        }
116        for child in self.clients.iter_mut().chain([&mut self.server]) {
117            child.kill().ok();
118            child.wait().ok();
119        }
120        if let Some(db) = &self.db {
121            std::fs::remove_file(db).ok();
122        }
123    }
124}
125
126fn console(session: &mut Session) -> Result<()> {
127    let stdin = std::io::stdin();
128    loop {
129        eprint!(
130            "{}  [f]reeze [t]haw [q]uit > ",
131            if session.frozen { "frozen" } else { "live  " }
132        );
133        std::io::stderr().flush().ok();
134
135        let mut line = String::new();
136        if stdin.lock().read_line(&mut line)? == 0 {
137            return Ok(());
138        }
139        match line.trim() {
140            "f" | "freeze" if !session.frozen => {
141                signal(&session.server, "-STOP");
142                session.frozen = true;
143                eprintln!("  server stopped — edits now queue in each editor, unacknowledged");
144            }
145            "t" | "thaw" if session.frozen => {
146                signal(&session.server, "-CONT");
147                session.frozen = false;
148                eprintln!("  server running — the queues flush and the titles catch up");
149            }
150            "f" | "t" | "freeze" | "thaw" => eprintln!("  already there"),
151            "q" | "quit" | "" => return Ok(()),
152            other => eprintln!("  don't know {other:?}"),
153        }
154    }
155}
156
157fn build(args: &DemoArgs) -> Result<()> {
158    let mut argv = vec![
159        "build",
160        "-p",
161        "blockworx",
162        "--bin",
163        "blockworx",
164        "-p",
165        "blockworx-server",
166        "--bin",
167        "blockworx-server",
168    ];
169    if args.release {
170        argv.push("--release");
171    }
172    cargo(&argv).run().context("building the demo binaries")?;
173    Ok(())
174}
175
176fn bin(args: &DemoArgs, name: &str) -> PathBuf {
177    workspace_root()
178        .join("target")
179        .join(if args.release { "release" } else { "debug" })
180        .join(name)
181}
182
183fn spawn_server(args: &DemoArgs, db: &PathBuf, listen: &str) -> Result<Child> {
184    Command::new(bin(args, "blockworx-server"))
185        .arg(db)
186        .args(["--listen", listen])
187        .current_dir(workspace_root())
188        .spawn()
189        .context("starting blockworx-server")
190}
191
192fn spawn_client(args: &DemoArgs, url: &str) -> Result<Child> {
193    let mut cmd = Command::new(bin(args, "blockworx"));
194    cmd.args(["--connect", url]).current_dir(workspace_root());
195    if args.narrate {
196        cmd.env("RUST_LOG", "blockworx=info,edit=debug");
197    }
198    cmd.spawn().context("starting blockworx")
199}
200
201/// `kill` rather than a signal crate: xtask's whole dependency list is three
202/// build helpers, and this is the only place that needs one.
203fn signal(child: &Child, sig: &str) {
204    Command::new("kill")
205        .args([sig, &child.id().to_string()])
206        .status()
207        .ok();
208}
209
210fn wait_for(addr: SocketAddr) -> Result<()> {
211    let deadline = Instant::now() + Duration::from_secs(20);
212    while Instant::now() < deadline {
213        if TcpStream::connect_timeout(&addr, Duration::from_millis(200)).is_ok() {
214            return Ok(());
215        }
216        std::thread::sleep(Duration::from_millis(100));
217    }
218    bail!("{addr} did not come up within 20s")
219}