Skip to main content

xtask/
collab.rs

1//! Running the collaboration stack on this machine.
2//!
3//! Three commands, because they are wanted at three granularities:
4//!
5//! - `server` and `client` are the primitives — one process each, in the
6//!   foreground, so they compose across terminals and take their defaults
7//!   from each other (a bare `server` and a bare `client` find one another).
8//!   In its own terminal the server also gets freeze and thaw for free:
9//!   Ctrl-Z stops it, `fg` resumes it, no tooling involved. Nothing is
10//!   spawned in the background, so nothing can be left behind.
11//! - `demo` is both at once for people who do not want three terminals: a
12//!   scratch log, N editors, and a console that stops and continues the
13//!   server without also stopping the terminal it is typed in.
14//!
15//! Freezing is the point of all this. Two people editing at human speed
16//! almost never hold unacknowledged commits at the same moment, so the
17//! concurrent case — the one the server-centric model exists for — is the one
18//! a live demo never reaches by accident. Stopping the server makes it
19//! reachable on purpose.
20//!
21//! Freeze only once the editors say `live` in their titles: a stopped server
22//! does not drop the connection, but it cannot complete a handshake either,
23//! so freezing before they connect leaves them `[disconnected]` — and nothing
24//! reconnects them, because resume-on-reconnect is not built yet (R3 in
25//! `docs/collab-migration-playbook.md`).
26
27use std::io::{BufRead, Write};
28use std::net::{SocketAddr, TcpStream};
29use std::path::PathBuf;
30use std::process::{Child, Command};
31use std::time::{Duration, Instant};
32
33use anyhow::{Context, Result, bail};
34use clap::Args;
35
36use crate::{cargo, workspace_root};
37
38/// Where a bare `server` listens and a bare `client` looks, so the two
39/// compose with no flags at all.
40const LISTEN: &str = "127.0.0.1:4100";
41
42/// The log a bare `server` serves: disposable, ignored by git, and the same
43/// one across runs so a document survives a restart.
44fn default_db() -> PathBuf {
45    workspace_root().join("target").join("blockworx.db")
46}
47
48fn ws_url(listen: &str) -> String {
49    format!("ws://{listen}/ws")
50}
51
52/// Which build the demo spawns — it names both the cargo flag and the
53/// directory the binaries are found in, so the two cannot disagree.
54#[derive(Clone, Copy)]
55enum Profile {
56    Debug,
57    Release,
58}
59
60impl From<bool> for Profile {
61    fn from(release: bool) -> Self {
62        if release {
63            Profile::Release
64        } else {
65            Profile::Debug
66        }
67    }
68}
69
70impl Profile {
71    fn dir(self) -> &'static str {
72        match self {
73            Profile::Debug => "debug",
74            Profile::Release => "release",
75        }
76    }
77
78    fn flag(self) -> Option<&'static str> {
79        matches!(self, Profile::Release).then_some("--release")
80    }
81}
82
83/// Whether an editor narrates its document mutations on the console.
84#[derive(Clone, Copy)]
85enum Narration {
86    Quiet,
87    Narrating,
88}
89
90impl From<bool> for Narration {
91    fn from(narrate: bool) -> Self {
92        if narrate {
93            Narration::Narrating
94        } else {
95            Narration::Quiet
96        }
97    }
98}
99
100impl Narration {
101    fn filter(self) -> Option<&'static str> {
102        matches!(self, Narration::Narrating).then_some("blockworx=info,edit=debug")
103    }
104}
105
106#[derive(Args)]
107pub struct ServerArgs {
108    /// Log to serve. Created if missing; kept across runs. Defaults to
109    /// `target/blockworx.db`.
110    #[arg(long)]
111    db: Option<PathBuf>,
112
113    /// Address to listen on.
114    #[arg(long, default_value = LISTEN)]
115    listen: String,
116
117    /// Build and run optimized.
118    #[arg(long)]
119    release: bool,
120}
121
122#[derive(Args)]
123pub struct ClientArgs {
124    /// Server to edit against. Defaults to a `server` on this machine.
125    #[arg(long)]
126    connect: Option<String>,
127
128    /// Narrate every document mutation on the console
129    /// (`RUST_LOG=edit=debug`).
130    #[arg(long)]
131    narrate: bool,
132
133    /// Build and run optimized.
134    #[arg(long)]
135    release: bool,
136
137    /// Arguments forwarded to the editor after `--`.
138    #[arg(last = true)]
139    app_args: Vec<String>,
140}
141
142#[derive(Args)]
143pub struct DemoArgs {
144    /// How many editors to open on the log.
145    #[arg(long, default_value_t = 2)]
146    clients: usize,
147
148    /// Address the server listens on.
149    #[arg(long, default_value = LISTEN)]
150    listen: String,
151
152    /// Log to serve. Defaults to a scratch file removed on exit; naming one
153    /// keeps it, so a second run resumes the same document.
154    #[arg(long)]
155    db: Option<PathBuf>,
156
157    /// Narrate every document mutation in each editor's console.
158    #[arg(long)]
159    narrate: bool,
160
161    /// Build and run optimized.
162    #[arg(long)]
163    release: bool,
164}
165
166/// One server in the foreground. Ctrl-Z and `fg` are freeze and thaw.
167pub fn server(args: &ServerArgs) -> Result<()> {
168    let db = args.db.clone().unwrap_or_else(default_db);
169    if let Some(parent) = db.parent() {
170        std::fs::create_dir_all(parent).ok();
171    }
172    eprintln!("  {} on {}", db.display(), ws_url(&args.listen));
173    eprintln!("  Ctrl-Z freezes it (editors queue their commits), `fg` thaws it.");
174    eprintln!();
175
176    let db = db.to_string_lossy().into_owned();
177    let mut argv = vec!["run", "-p", "blockworx-server", "--bin", "blockworx-server"];
178    argv.extend(Profile::from(args.release).flag());
179    argv.extend(["--", &db, "--listen", &args.listen]);
180    cargo(&argv).run().context("running blockworx-server")?;
181    Ok(())
182}
183
184/// One editor in the foreground, connected to a server.
185pub fn client(args: &ClientArgs) -> Result<()> {
186    let url = args.connect.clone().unwrap_or_else(|| ws_url(LISTEN));
187    eprintln!("  editing {url}");
188    eprintln!();
189
190    let mut argv = vec!["run", "-p", "blockworx", "--bin", "blockworx"];
191    argv.extend(Profile::from(args.release).flag());
192    argv.extend(["--", "--connect", &url]);
193    argv.extend(args.app_args.iter().map(String::as_str));
194
195    let mut command = cargo(&argv);
196    if let Some(filter) = Narration::from(args.narrate).filter() {
197        command = command.env("RUST_LOG", filter);
198    }
199    command.run().context("running blockworx")?;
200    Ok(())
201}
202
203/// A server and N editors from one terminal, with a console that freezes the
204/// server without also stopping the terminal it is typed in.
205pub fn demo(args: &DemoArgs) -> Result<()> {
206    if args.clients == 0 {
207        bail!("--clients must be at least 1");
208    }
209    let addr: SocketAddr = args
210        .listen
211        .parse()
212        .with_context(|| format!("--listen {} is not an address", args.listen))?;
213
214    let profile = Profile::from(args.release);
215    let narration = Narration::from(args.narrate);
216    build(profile)?;
217
218    let scratch = args.db.is_none();
219    let db = args.db.clone().unwrap_or_else(|| {
220        workspace_root()
221            .join("target")
222            .join("demo-scratch.blockworx.db")
223    });
224    if scratch && db.exists() {
225        std::fs::remove_file(&db).ok();
226    }
227
228    let mut session = Session {
229        server: spawn_server(profile, &db, &args.listen)?,
230        clients: Vec::new(),
231        db: scratch.then(|| db.clone()),
232        frozen: false,
233    };
234    wait_for(addr).context("the server never accepted a connection")?;
235
236    let url = ws_url(&args.listen);
237    for _ in 0..args.clients {
238        session
239            .clients
240            .push(spawn_client(profile, narration, &url)?);
241    }
242
243    eprintln!();
244    eprintln!("  {} editors on {}", args.clients, db.display());
245    eprintln!("  serving {url}");
246    eprintln!();
247    eprintln!("  Edit in either window; the other follows. To see the case hands");
248    eprintln!("  cannot reach, wait for both titles to say `live`, then freeze,");
249    eprintln!("  edit the same block in both, and thaw: the later commit wins and");
250    eprintln!("  both canvases agree.");
251    eprintln!();
252    console(&mut session)
253}
254
255/// Children plus what has to happen to them on the way out.
256///
257/// Two exits are covered and one is not, which is worth knowing before
258/// leaving a `demo` running: `q` (and any `?` out of [`console`]) runs `Drop`,
259/// and Ctrl-C reaches the whole process group so the children take it
260/// directly. A **SIGTERM to xtask itself skips `Drop` and orphans them** —
261/// measured, not assumed. Closing a signal that narrow costs either a signal
262/// crate or `PR_SET_PDEATHSIG`, and xtask's entire dependency list is three
263/// build helpers; `server` and `client` spawn nothing in the background and
264/// have no such hole.
265struct Session {
266    server: Child,
267    clients: Vec<Child>,
268    db: Option<PathBuf>,
269    frozen: bool,
270}
271
272impl Drop for Session {
273    fn drop(&mut self) {
274        if self.frozen {
275            // A stopped process ignores SIGTERM until it runs again.
276            signal(&self.server, "-CONT");
277        }
278        for child in self.clients.iter_mut().chain([&mut self.server]) {
279            child.kill().ok();
280            child.wait().ok();
281        }
282        if let Some(db) = &self.db {
283            std::fs::remove_file(db).ok();
284        }
285    }
286}
287
288fn console(session: &mut Session) -> Result<()> {
289    let stdin = std::io::stdin();
290    loop {
291        eprint!(
292            "{}  [f]reeze [t]haw [q]uit > ",
293            if session.frozen { "frozen" } else { "live  " }
294        );
295        std::io::stderr().flush().ok();
296
297        let mut line = String::new();
298        if stdin.lock().read_line(&mut line)? == 0 {
299            return Ok(());
300        }
301        match line.trim() {
302            "f" | "freeze" if !session.frozen => {
303                signal(&session.server, "-STOP");
304                session.frozen = true;
305                eprintln!("  server stopped — edits now queue in each editor, unacknowledged");
306            }
307            "t" | "thaw" if session.frozen => {
308                signal(&session.server, "-CONT");
309                session.frozen = false;
310                eprintln!("  server running — the queues flush and the titles catch up");
311            }
312            "f" | "t" | "freeze" | "thaw" => eprintln!("  already there"),
313            "q" | "quit" | "" => return Ok(()),
314            other => eprintln!("  don't know {other:?}"),
315        }
316    }
317}
318
319fn build(profile: Profile) -> Result<()> {
320    let mut argv = vec![
321        "build",
322        "-p",
323        "blockworx",
324        "--bin",
325        "blockworx",
326        "-p",
327        "blockworx-server",
328        "--bin",
329        "blockworx-server",
330    ];
331    argv.extend(profile.flag());
332    cargo(&argv).run().context("building the demo binaries")?;
333    Ok(())
334}
335
336fn bin(profile: Profile, name: &str) -> PathBuf {
337    workspace_root()
338        .join("target")
339        .join(profile.dir())
340        .join(name)
341}
342
343fn spawn_server(profile: Profile, db: &PathBuf, listen: &str) -> Result<Child> {
344    Command::new(bin(profile, "blockworx-server"))
345        .arg(db)
346        .args(["--listen", listen])
347        .current_dir(workspace_root())
348        .spawn()
349        .context("starting blockworx-server")
350}
351
352fn spawn_client(profile: Profile, narration: Narration, url: &str) -> Result<Child> {
353    let mut cmd = Command::new(bin(profile, "blockworx"));
354    cmd.args(["--connect", url]).current_dir(workspace_root());
355    if let Some(filter) = narration.filter() {
356        cmd.env("RUST_LOG", filter);
357    }
358    cmd.spawn().context("starting blockworx")
359}
360
361/// `kill` rather than a signal crate: xtask's whole dependency list is three
362/// build helpers, and this is the only place that needs one.
363fn signal(child: &Child, sig: &str) {
364    Command::new("kill")
365        .args([sig, &child.id().to_string()])
366        .status()
367        .ok();
368}
369
370fn wait_for(addr: SocketAddr) -> Result<()> {
371    let deadline = Instant::now() + Duration::from_secs(20);
372    while Instant::now() < deadline {
373        if TcpStream::connect_timeout(&addr, Duration::from_millis(200)).is_ok() {
374            return Ok(());
375        }
376        std::thread::sleep(Duration::from_millis(100));
377    }
378    bail!("{addr} did not come up within 20s")
379}