P2 slice 3: opening and closing the books (plan 3.9, D-21, D-23)

One wanderbooks executable. The books are open while the viewer, an
agent session or a terminal holds them: a host process takes leases on
a socket, starts the server (the hidden mode `wanderbooks server`) with
a pipe on its input, and closes it 10 s after the last lease ends. No
background service. One host even in a race (run/host.lock).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T3gXMVt7XJPi6ens1or9Dj
This commit is contained in:
swaitsandClaude Opus 5.5 committed 2026-10-20 11:14:19 -06:00
1 parent 168e5c9846
commit a298d154b6
31 files changed
+11937 -112

No files matched your search

File diff suppressed because it is too large. Load diff
+940
View File
@@ -0,0 +1,940 @@
//! The owner's commands: `wanderbooks serve`, `close` and `books` (plan
//! 3.9, 5.5).
//!
//! ```text
//! GOAL: from a terminal the owner holds the books open, closes them, and
//! makes, lists and adopts books, without ever naming or touching
//! the books' private folder.
//! SOURCE: plan/served.md 3.9 (a terminal running `wanderbooks serve`, and
//! any command that needs the server, holds a lease; "Close the
//! books" ends every lease), 3.1 (the CLI talks to admin.sock and
//! never reads the books), 5.5 (adopt; preview and confirmation at
//! the terminal).
//! INVARIANTS:
//! 1. A command that needs the server holds a lease while it runs, and
//! lets go when it ends.
//! 2. Nothing printed names the data root.
//! 3. Adoption is previewed first and goes ahead only on a yes at the
//! terminal, or --yes; a vault beside the books must be named (or
//! --no-vault given): it is never silently left behind.
//! ```
use crate::knobs::{FLAG, Knobs};
use crate::lease::{Ask, HostMsg, Intent, Kind, Reason};
use crate::opener::{HoldError, Lease, Opener, Starter};
use ledger_mcp::admin::{self, Request, Response};
use std::path::{Path, PathBuf};
use std::time::Duration;
/// How long a command waits for the books' host to answer one question.
const HOST_WAIT: Duration = Duration::from_secs(5);
/// How long a command waits for the books server to list the books.
const LIST_WAIT: Duration = Duration::from_secs(30);
/// How long `close` waits to see the books closed.
const CLOSE_WAIT: Duration = Duration::from_secs(120);
/// Common flags: `--root DIR` and the test knobs.
#[derive(Default)]
struct Common {
root: Option<PathBuf>,
knobs: Knobs,
}
impl Common {
/// Take a common flag (`Ok(true)`), or leave the argument.
fn take(&mut self, arg: &str, args: &mut impl Iterator<Item = String>) -> Result<bool, String> {
match arg {
"--root" => {
self.root = Some(PathBuf::from(args.next().ok_or("--root needs a folder")?));
Ok(true)
}
FLAG => {
self.knobs = Knobs::parse(&args.next().ok_or("--test-knobs needs a value")?)?;
Ok(true)
}
_ => Ok(false),
}
}
}
fn exit(code: i32) -> Box<dyn std::error::Error> {
Box::new(crate::Exit(code))
}
/// Hold the books and wait until they are open (INVARIANT 1).
async fn open(opener: &mut Opener) -> Result<Lease, HoldError> {
let deadline = tokio::time::Instant::now() + opener.knobs().open_wait;
let too_long = || -> HoldError {
"the books didn't open in time: run `wanderbooks doctor` to see why".into()
};
loop {
let mut lease = tokio::time::timeout_at(deadline, opener.hold())
.await
.map_err(|_| too_long())??;
loop {
match tokio::time::timeout_at(deadline, lease.next()).await {
Err(_) => return Err(too_long()),
Ok(Some(HostMsg::Ready)) => {
opener.opened();
return Ok(lease);
}
Ok(Some(HostMsg::Ending {
reason: Reason::ClosedByOwner,
..
})) => {
return Err(HoldError::ClosedByOwner);
}
Ok(Some(HostMsg::Ending {
reason: Reason::Restarting | Reason::Closing,
..
})) => break,
Ok(Some(HostMsg::Ending { .. })) | Ok(None) => {
// The wait before the next try counts against the
// deadline too.
tokio::time::timeout_at(deadline, opener.failed())
.await
.map_err(|_| too_long())??;
break;
}
Ok(Some(_)) => {}
}
}
}
}
/// Ask the server's admin socket, holding the books open meanwhile.
fn with_server(common: &Common, request: Request) -> Result<Response, String> {
let rt = tokio::runtime::Builder::new_multi_thread()
.enable_all()
.build()
.map_err(|e| e.to_string())?;
let outcome = rt.block_on(async {
let mut opener = Opener::new(
common.root.as_deref(),
common.knobs,
Starter::Direct,
Kind::Cli,
None,
)?;
let lease = open(&mut opener).await.map_err(|e| match e {
HoldError::ClosedByOwner => {
"the books were closed just now (Close the books): nothing was done".to_string()
}
HoldError::Failed(why) => why,
})?;
let socket = opener.run_dir().join(ledger_mcp::local::ADMIN_SOCKET);
// A list is quick; a create or an adoption takes what it takes.
let wait = matches!(request, Request::List).then_some(LIST_WAIT);
let answer =
tokio::task::spawn_blocking(move || admin::call_within(&socket, request, wait))
.await
.map_err(|e| e.to_string())?
.map_err(|e| e.to_string());
drop(lease);
answer
});
// Past its deadline, the command ends: nothing pending holds it.
rt.shutdown_timeout(Duration::from_secs(1));
outcome
}
/// Where the books stand while `close` waits for them to close.
#[derive(Debug, PartialEq, Eq)]
enum Closing {
/// No host, and no server finishing: closed.
Closed,
/// A new host, opened by something since: closed, then opened again.
Reopened,
/// Still on their way (a server finishing, or a host not answering).
Going,
}
/// What the host's answer (`asked`) and the server's lock say.
fn closing(
asked: &Result<Option<HostMsg>, String>,
server_lock_held: impl FnOnce() -> bool,
) -> Closing {
match asked {
Ok(None) if server_lock_held() => Closing::Going,
Ok(None) => Closing::Closed,
Ok(Some(HostMsg::Leases { .. })) => Closing::Reopened,
_ => Closing::Going,
}
}
/// Whether `deadline` has passed at `now`.
fn past(deadline: std::time::Instant, now: std::time::Instant) -> bool {
now > deadline
}
/// What `doctor` says of books no host holds.
fn no_host(server_lock_held: bool) -> &'static str {
if server_lock_held {
"The books are closing: a books server is finishing its last call."
} else {
"The books are closed."
}
}
/// `wanderbooks serve`: hold the books open until Ctrl-C.
pub fn serve(
prog: &str,
args: impl IntoIterator<Item = String>,
) -> Result<(), Box<dyn std::error::Error>> {
let mut common = Common::default();
let mut args = args.into_iter();
while let Some(arg) = args.next() {
if common.take(&arg, &mut args)? {
continue;
}
match arg.as_str() {
"--help" | "-h" => {
eprintln!("usage: {prog}");
eprintln!(" Opens the books and holds them open until Ctrl-C. They close 10 s");
eprintln!(" after nothing else (the app, an agent session) holds them.");
return Ok(());
}
other => {
eprintln!("unknown argument: {other}");
eprintln!("usage: {prog}");
return Err(exit(2));
}
}
}
let rt = tokio::runtime::Builder::new_multi_thread()
.enable_all()
.build()?;
let outcome = rt.block_on(async {
let mut opener = Opener::new(
common.root.as_deref(),
common.knobs,
Starter::Direct,
Kind::Serve,
None,
)?;
// Ctrl-C, SIGTERM and SIGHUP are heard from the start, whatever
// serve is doing (opening, waiting to try again): none is missed.
let stop = std::sync::Arc::new(tokio::sync::Notify::new());
for kind in [
tokio::signal::unix::SignalKind::interrupt(),
tokio::signal::unix::SignalKind::terminate(),
tokio::signal::unix::SignalKind::hangup(),
] {
let mut signals = tokio::signal::unix::signal(kind)?;
let stop = stop.clone();
tokio::spawn(async move {
while signals.recv().await.is_some() {
stop.notify_one();
}
});
}
let let_go = || {
println!("Let go of the books.");
Ok(())
};
let mut said_open = false;
loop {
println!("Opening the books…");
let opened = tokio::select! {
opened = open(&mut opener) => opened,
() = stop.notified() => return let_go(),
};
let mut lease = match opened {
Ok(lease) => lease,
Err(HoldError::ClosedByOwner) => {
println!("The books were closed (Close the books).");
return Ok(());
}
Err(HoldError::Failed(why)) => {
eprintln!("error: {why}");
return Err(exit(1));
}
};
if !said_open {
println!(
"The books are open. Press Ctrl-C to let go of them: they close when nothing else holds them."
);
said_open = true;
} else {
println!("The books are open again.");
}
let ended = tokio::select! {
() = stop.notified() => None,
msg = lease.next() => Some(msg),
};
match ended {
None => {
drop(lease);
return let_go();
}
Some(Some(HostMsg::Ending {
reason: Reason::ClosedByOwner,
..
})) => {
println!("The books were closed (Close the books).");
return Ok(());
}
Some(Some(HostMsg::Ending {
reason: Reason::Restarting,
..
})) => {
println!("The books are restarting (a new version)…");
}
Some(Some(HostMsg::Ending {
reason: Reason::Closing,
..
})) => {}
Some(_) => {
println!("The books server stopped; opening the books again…");
let waited = tokio::select! {
waited = opener.failed() => waited,
() = stop.notified() => return let_go(),
};
if let Err(why) = waited {
eprintln!("error: {why}");
return Err(exit(1));
}
}
}
}
});
// A holder that gave up isn't held back by work still pending.
rt.shutdown_timeout(Duration::from_secs(1));
outcome
}
/// `wanderbooks close`: close the books now; every holder lets go.
pub fn close(
prog: &str,
args: impl IntoIterator<Item = String>,
) -> Result<(), Box<dyn std::error::Error>> {
let mut common = Common::default();
let mut args = args.into_iter();
while let Some(arg) = args.next() {
if common.take(&arg, &mut args)? {
continue;
}
match arg.as_str() {
"--help" | "-h" => {
eprintln!("usage: {prog}");
eprintln!(" Closes the books now: the app, agent sessions and `serve` let go.");
return Ok(());
}
other => {
eprintln!("unknown argument: {other}");
eprintln!("usage: {prog}");
return Err(exit(2));
}
}
}
let root = ledger_store::root::Root::find(common.root.as_deref()).map_err(|e| e.to_string())?;
let run = root.run_dir().map_err(|e| e.to_string())?;
let asked = crate::opener::ask_once_within(&run, Ask::Intent(Intent::Close), HOST_WAIT)
.map_err(|why| {
// Asked, and no answer: the host may yet close them.
eprintln!(
"error: {why}: the books may still close; `wanderbooks doctor` shows whether they did"
);
exit(1)
})?;
match asked {
None => {
println!("The books are already closed.");
Ok(())
}
Some(HostMsg::Done { .. }) => {
println!("Closing the books: everything holding them lets go…");
let deadline = std::time::Instant::now() + CLOSE_WAIT;
loop {
let asked = crate::opener::ask_once_within(&run, Ask::Leases, HOST_WAIT);
match closing(&asked, || crate::opener::server_lock_held(&run)) {
Closing::Closed => break,
Closing::Reopened => {
println!("The books were closed; something has opened them again.");
return Ok(());
}
Closing::Going => {}
}
if past(deadline, std::time::Instant::now()) {
println!("They are still closing (a request is finishing).");
return Ok(());
}
std::thread::sleep(Duration::from_millis(50));
}
println!("The books are closed.");
Ok(())
}
Some(HostMsg::Refused(why)) => {
eprintln!("error: {why}");
Err(exit(1))
}
Some(other) => {
eprintln!("error: the books' host answered oddly: {other:?}");
Err(exit(1))
}
}
}
fn books_usage(prog: &str) {
eprintln!("usage: {prog} list");
eprintln!(" {prog} create ID [--currency CODE]");
eprintln!(
" {prog} adopt DATA-DIR --id ID [--toml FILE] [--config-json FILE] [--vault DIR --vault-key FILE | --no-vault] [--skip-unknown] [--yes]"
);
eprintln!(" list the books, and whether each is open");
eprintln!(" create start new, empty books (base currency USD unless given)");
eprintln!(" adopt copy existing books in, verified; the original is left as it was");
}
/// `wanderbooks books list|create|adopt`.
pub fn books(
prog: &str,
args: impl IntoIterator<Item = String>,
) -> Result<(), Box<dyn std::error::Error>> {
let mut common = Common::default();
let mut words: Vec<String> = Vec::new();
let mut currency = "USD".to_string();
let mut id: Option<String> = None;
let mut toml: Option<PathBuf> = None;
let mut config_json: Option<PathBuf> = None;
let mut vault: Option<PathBuf> = None;
let mut vault_key: Option<PathBuf> = None;
let mut no_vault = false;
let mut skip_unknown = false;
let mut yes = false;
let mut args = args.into_iter();
while let Some(arg) = args.next() {
if common.take(&arg, &mut args)? {
continue;
}
let mut value = |what: &str| args.next().ok_or_else(|| format!("{arg} needs {what}"));
match arg.as_str() {
"--currency" => currency = value("a currency code")?,
"--id" => id = Some(value("an id")?),
"--toml" => toml = Some(PathBuf::from(value("a file")?)),
"--config-json" => config_json = Some(PathBuf::from(value("a file")?)),
"--vault" => vault = Some(PathBuf::from(value("a folder")?)),
"--vault-key" => vault_key = Some(PathBuf::from(value("a file")?)),
"--no-vault" => no_vault = true,
"--skip-unknown" => skip_unknown = true,
"--yes" => yes = true,
"--help" | "-h" => {
books_usage(prog);
return Ok(());
}
s if s.starts_with('-') => {
eprintln!("unknown flag: {s}");
books_usage(prog);
return Err(exit(2));
}
_ => words.push(arg),
}
}
let words: Vec<&str> = words.iter().map(String::as_str).collect();
let answer = match words.as_slice() {
["list"] => with_server(&common, Request::List),
["create", new] => with_server(
&common,
Request::Create {
id: (*new).to_string(),
currency: currency.clone(),
},
),
["adopt", source] => {
let Some(id) = id else {
eprintln!("error: name the adopted books with --id ID");
return Err(exit(2));
};
return adopt(
&common,
Path::new(source),
id,
AdoptFiles {
toml,
config_json,
vault,
vault_key,
no_vault,
},
skip_unknown,
yes,
);
}
_ => {
books_usage(prog);
return Err(exit(2));
}
};
match answer {
Ok(Response::Books(books)) if books.is_empty() => {
println!("No books yet: `wanderbooks books create ID` starts some.");
Ok(())
}
Ok(Response::Books(books)) => {
for b in books {
match b.problem {
None => println!("{} open", b.id),
Some(why) => println!("{} didn't open: {why}", b.id),
}
}
Ok(())
}
Ok(Response::Created { id, problem: None }) => {
println!("Made new books \"{id}\" ({currency}).");
Ok(())
}
Ok(Response::Created {
id,
problem: Some(why),
}) => {
eprintln!(
"error: new books \"{id}\" were made but didn't open: {why}. Closing the books and opening them again tries again."
);
Err(exit(1))
}
Ok(Response::Refused(why)) | Err(why) => {
eprintln!("error: {why}");
Err(exit(1))
}
Ok(other) => {
eprintln!("error: the books server answered oddly: {other:?}");
Err(exit(1))
}
}
}
/// The files an adoption brings in with the books.
struct AdoptFiles {
toml: Option<PathBuf>,
config_json: Option<PathBuf>,
vault: Option<PathBuf>,
vault_key: Option<PathBuf>,
no_vault: bool,
}
/// A hash's first twelve characters, for people.
fn short_hash(hash: &str) -> String {
hash.chars().take(12).collect()
}
/// Where a P1 books dir keeps its vault: `vault/` beside the data dir.
fn vault_beside(source: &Path) -> Option<PathBuf> {
let source = std::fs::canonicalize(source).ok()?;
let vault = source.parent()?.join("vault");
vault.is_dir().then_some(vault)
}
/// Whether standard input is a terminal.
fn at_a_terminal() -> bool {
// SAFETY: isatty has no preconditions.
unsafe { libc::isatty(0) == 1 }
}
/// `wanderbooks books adopt` (INVARIANT 3).
fn adopt(
common: &Common,
source: &Path,
id: String,
files: AdoptFiles,
skip_unknown: bool,
yes: bool,
) -> Result<(), Box<dyn std::error::Error>> {
let fail = |why: String| -> Result<(), Box<dyn std::error::Error>> {
eprintln!("error: {why}");
Err(exit(1))
};
let shown = source.display().to_string();
if files.no_vault && (files.vault.is_some() || files.vault_key.is_some()) {
return fail("--no-vault and --vault can't both be given".into());
}
match (&files.vault, &files.vault_key) {
(Some(_), None) => {
return fail("give the vault's key too, with --vault-key FILE (often ~/.config/wanderbooks/vault.key)".into());
}
(None, Some(_)) => return fail("give the vault's folder too, with --vault DIR".into()),
_ => {}
}
if files.vault.is_none()
&& !files.no_vault
&& let Some(beside) = vault_beside(source)
{
return fail(format!(
"there is a TIN vault beside these books ({}): name it with --vault DIR and its key with --vault-key FILE, or give --no-vault to leave it behind",
beside.display()
));
}
let read = |path: &Option<PathBuf>| -> Result<Option<String>, String> {
path.as_ref()
.map(|p| {
std::fs::read_to_string(p).map_err(|e| format!("can't read {}: {e}", p.display()))
})
.transpose()
};
let settings_toml = match read(&files.toml) {
Ok(t) => t,
Err(why) => return fail(why),
};
let config_json = match read(&files.config_json) {
Ok(t) => t,
Err(why) => return fail(why),
};
let source = std::path::absolute(source).unwrap_or_else(|_| source.to_path_buf());
let request = |expect_head: Option<(u64, String)>| Request::Adopt {
id: id.clone(),
source: source.clone(),
settings_toml: settings_toml.clone(),
config_json: config_json.clone(),
vault_dir: files.vault.clone(),
vault_key: files.vault_key.clone(),
confirmed: expect_head.is_some(),
expect_head,
skip_unknown,
};
let preview = match with_server(common, request(None)) {
Ok(Response::Preview(p)) => p,
Ok(Response::Refused(why)) | Err(why) => return fail(format!("{shown}: {why}")),
Ok(other) => return fail(format!("the books server answered oddly: {other:?}")),
};
println!(
"Adopting the books at {} as \"{id}\": through event {} ({}).",
source.display(),
preview.source_head.0,
short_hash(&preview.source_head.1)
);
if preview.keeps_settings {
println!("They keep their own settings.");
if files.toml.is_some() {
println!("(The settings file given isn't used.)");
}
} else if preview.settings.is_empty() {
println!("No settings move in.");
} else {
println!("These settings move in from the settings file:");
for s in &preview.settings {
match &s.value {
Some(v) => println!(" {} = {v}", s.key),
None => println!(" {} (cleared)", s.key),
}
}
}
for n in &preview.notes {
println!(" note: {n}");
}
for u in &preview.unknown {
println!(" not moved in (the books don't know it): {u}");
}
if !preview.comments.is_empty() {
println!(
"The settings file's comments (not settings: keep what matters as a convention or a decision):"
);
for c in &preview.comments {
println!(" # {c}");
}
}
match &files.vault {
Some(v) => println!(
"The TIN vault at {} is copied in with its key ({} records, each checked to open).",
v.display(),
preview.vault_records
),
None => println!("No TIN vault is copied."),
}
if !preview.unknown.is_empty() && !skip_unknown {
return fail(
"the settings file has entries the books don't know (above): take them out, or adopt with --skip-unknown to leave them behind".into(),
);
}
println!("The original is left exactly as it is.");
if !yes {
if !at_a_terminal() {
return fail("not adopted: confirm at a terminal, or run again with --yes".into());
}
print!("Adopt them? [y/N] ");
use std::io::Write;
std::io::stdout().flush()?;
let mut answer = String::new();
std::io::stdin().read_line(&mut answer)?;
if !matches!(answer.trim(), "y" | "Y" | "yes" | "Yes") {
println!("Not adopted.");
return Ok(());
}
}
match with_server(common, request(Some(preview.source_head.clone()))) {
Ok(Response::Adopted(a)) => {
for line in adopted_lines(&a) {
println!("{line}");
}
if let Some(why) = a.problem {
return fail(format!(
"the adopted books didn't open: {why}. Closing the books and opening them again tries again."
));
}
Ok(())
}
Ok(Response::Refused(why)) | Err(why) => fail(format!("{shown}: {why}")),
Ok(other) => fail(format!("the books server answered oddly: {other:?}")),
}
}
/// What an adoption did, a line each.
fn adopted_lines(a: &admin::Adopted) -> Vec<String> {
let mut extra = String::new();
if a.unnamed_blobs > 0 {
extra.push_str(&format!(
", {} the books don't name (kept)",
a.unnamed_blobs
));
}
if a.skipped_files > 0 {
extra.push_str(&format!(
", {} unfinished or hidden files left behind",
a.skipped_files
));
}
let mut lines = vec![format!(
"Adopted as \"{}\": {} stored files copied, {} of them checked against the books{extra}.",
a.id, a.blob_files, a.blobs_verified
)];
// A vault is adopted only with its key, so a copied key says a vault
// came too (with its records, perhaps none).
if a.key_copied {
lines.push(format!("TIN vault: {} records and its key.", a.vault_files));
}
if a.settings_imported > 0 {
lines.push(format!("{} settings moved in.", a.settings_imported));
}
lines.push(format!(
"Head: event {} ({}).",
a.head.0,
short_hash(&a.head.1)
));
lines
}
/// How long `doctor` waits for the books server to answer.
const DOCTOR_WAIT: Duration = Duration::from_secs(5);
/// How many of each log's last lines `doctor` shows.
const DOCTOR_LINES: usize = 12;
/// The last `n` lines of a log in `run/`, with the root's path taken out.
fn log_tail(run: &Path, name: &str, root: &Path, n: usize) -> Vec<String> {
let Ok(text) = std::fs::read_to_string(run.join(name)) else {
return Vec::new();
};
// The root as found, and as it really is (through any link).
let real = std::fs::canonicalize(root).unwrap_or_else(|_| root.to_path_buf());
let spellings = [root.display().to_string(), real.display().to_string()];
let lines: Vec<&str> = text.lines().collect();
lines[lines.len().saturating_sub(n)..]
.iter()
.map(|l| {
let l = spellings.iter().fold(l.to_string(), |l, s| {
l.replace(s.as_str(), ledger_store::root::THE_ROOT)
});
// A log line can carry a panic's text: no full TIN is shown.
ledger_core::parties::redact_full_tins(&l).into_owned()
})
.collect()
}
/// `wanderbooks doctor`: whether the books are open, who holds them, and
/// what the host and the server last said. It names no folder.
pub fn doctor(
prog: &str,
args: impl IntoIterator<Item = String>,
) -> Result<(), Box<dyn std::error::Error>> {
let mut common = Common::default();
let mut args = args.into_iter();
while let Some(arg) = args.next() {
if common.take(&arg, &mut args)? {
continue;
}
match arg.as_str() {
"--help" | "-h" => {
eprintln!("usage: {prog}");
eprintln!(
" Says whether the books are open, what holds them, and what went wrong."
);
return Ok(());
}
other => {
eprintln!("unknown argument: {other}");
eprintln!("usage: {prog}");
return Err(exit(2));
}
}
}
let root = match ledger_store::root::Root::find(common.root.as_deref()) {
Ok(root) => root,
Err(e) => {
println!("The books' private folder: {e}");
return Err(exit(1));
}
};
println!("The books' private folder: there, and only your account can open it.");
println!("Isolation: same_user (advisory): hidden from agents, not locked against them.");
let run = root.run_dir().map_err(|e| e.to_string())?;
match crate::opener::ask_once_within(&run, Ask::Leases, DOCTOR_WAIT) {
Ok(None) => println!("{}", no_host(crate::opener::server_lock_held(&run))),
Ok(Some(HostMsg::Leases { ready, leases })) => {
if ready {
println!("The books are open.");
// Each book, and why one didn't open.
let socket = run.join(ledger_mcp::local::ADMIN_SOCKET);
// A quick look: a wedged server doesn't hang the doctor.
let listed = admin::call_within(&socket, Request::List, Some(DOCTOR_WAIT));
if let Err(why) = &listed {
println!(" The books server doesn't answer: {why}");
}
if let Ok(admin::Response::Books(books)) = listed {
for b in books {
match b.problem {
None => println!(" {} open", b.id),
Some(why) => println!(" {} didn't open: {why}", b.id),
}
}
}
} else {
println!("The books are opening (the books server isn't ready yet).");
}
if leases.is_empty() {
println!("Nothing holds them: they close soon.");
} else {
println!("Held by:");
for l in leases {
match l.book {
Some(book) => println!(" {} (pid {}), on {book}", l.kind.label(), l.pid),
None => println!(" {} (pid {})", l.kind.label(), l.pid),
}
}
}
}
Ok(Some(HostMsg::Ending { .. })) => println!("The books are closing."),
Ok(Some(other)) => println!("The books' host answered oddly: {other:?}"),
Err(why) => println!("The books' host can't be asked: {why}"),
}
for (name, what) in [
(crate::start::HOST_LOG, "The host"),
(crate::host::SERVER_LOG, "The books server"),
] {
let tail = log_tail(&run, name, root.path(), DOCTOR_LINES);
if !tail.is_empty() {
println!("{what}, last said:");
for line in tail {
println!(" {line}");
}
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn close_reads_the_books_state_for_what_it_is() {
let leases = Ok(Some(HostMsg::Leases {
ready: true,
leases: vec![],
}));
assert_eq!(closing(&Ok(None), || false), Closing::Closed);
assert_eq!(
closing(&Ok(None), || true),
Closing::Going,
"a server finishing"
);
assert_eq!(closing(&leases, || false), Closing::Reopened);
assert_eq!(closing(&Err("no".into()), || false), Closing::Going);
assert_eq!(closing(&Ok(Some(HostMsg::Ready)), || false), Closing::Going);
let now = std::time::Instant::now();
let later = now + Duration::from_secs(1);
assert!(!past(later, now));
assert!(!past(now, now), "not yet at the deadline itself");
assert!(past(now, later));
}
#[test]
fn doctor_tells_closing_from_closed() {
assert!(no_host(true).contains("closing"));
assert_eq!(no_host(false), "The books are closed.");
}
fn adopted() -> admin::Adopted {
admin::Adopted {
id: "old".into(),
source_head: (3, "a".repeat(64)),
head: (4, "0123456789abcdef".repeat(4)),
blob_files: 5,
blobs_verified: 4,
unnamed_blobs: 0,
skipped_files: 0,
vault_files: 0,
key_copied: false,
settings_imported: 0,
settings_ignored: false,
source_lock_created: false,
problem: None,
}
}
#[test]
fn an_adoption_says_what_it_did_and_only_that() {
let plain = adopted_lines(&adopted());
assert_eq!(
plain,
[
"Adopted as \"old\": 5 stored files copied, 4 of them checked against the books.",
"Head: event 4 (0123456789ab).",
]
);
let all = adopted_lines(&admin::Adopted {
unnamed_blobs: 1,
skipped_files: 2,
vault_files: 3,
key_copied: true,
settings_imported: 6,
..adopted()
});
assert_eq!(
all,
[
"Adopted as \"old\": 5 stored files copied, 4 of them checked against the books, 1 the books don't name (kept), 2 unfinished or hidden files left behind.",
"TIN vault: 3 records and its key.",
"6 settings moved in.",
"Head: event 4 (0123456789ab).",
]
);
// A vault with no records still came, with its key.
let empty = adopted_lines(&admin::Adopted {
key_copied: true,
..adopted()
});
assert!(empty.contains(&"TIN vault: 0 records and its key.".to_string()));
}
#[test]
fn the_log_lines_shown_name_no_folder_and_no_tin() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path().join("root");
std::fs::create_dir(&root).unwrap();
let run = root.join("run");
std::fs::create_dir(&run).unwrap();
std::fs::write(
run.join("server.log"),
format!(
"old line\nthread panicked at {}/books/acme: mid-write 241-40-4915\nlast\n",
root.display()
),
)
.unwrap();
let tail = log_tail(&run, "server.log", &root, 2);
assert_eq!(tail.len(), 2);
let text = tail.join("\n");
assert!(!text.contains(&root.display().to_string()), "{text}");
assert!(!text.contains("241-40-4915"), "{text}");
assert!(text.contains(ledger_store::root::THE_ROOT), "{text}");
assert_eq!(tail[1], "last");
assert!(log_tail(&run, "none.log", &root, 5).is_empty());
}
}
+335
View File
@@ -0,0 +1,335 @@
//! `wanderbooks helper`: the small unconfined process a confined mode
//! keeps beside it (plan 3.9, "Starting the host cleanly").
//!
//! ```text
//! GOAL: a mode that confines itself (the viewer, the bridge) can still
//! start the host cleanly and hand off the few things a sandbox
//! forbids, through a channel that can ask for nothing else.
//! SOURCE: plan/served.md 3.9 (the helper: spawned over an inherited
//! socket pair before any thread, a fixed message set; it never
//! accepts a path or a command line).
//! INVARIANTS:
//! 1. It talks only over the socket it was handed (descriptor 3), one
//! JSON line each way, and exits when that closes.
//! 2. It accepts exactly start_host, open_with (an attachment id),
//! stage_backup and start_updater, none carrying a path or command;
//! anything else is refused.
//! 3. start_host starts the host cleanly (start.rs).
//! 4. Spawned before the caller starts any thread.
//! 5. A message is exactly its version and one ask, on a line of at
//! most MAX_LINE bytes; anything else ends the helper.
//! ```
use crate::knobs::{FLAG, Knobs};
use serde::{Deserialize, Serialize};
use std::io::{BufRead, Write};
use std::os::fd::{AsRawFd, FromRawFd};
use std::os::unix::net::UnixStream;
use std::os::unix::process::CommandExt;
use std::path::{Path, PathBuf};
/// This protocol's version.
pub const PROTO: u32 = 1;
/// The descriptor the helper is handed its socket on.
const FD: i32 = 3;
/// The longest line either side sends.
const MAX_LINE: u64 = 64 * 1024;
/// A message to the helper (INVARIANT 2).
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct Message {
/// The sender's protocol version.
pub proto: u32,
/// What it asks.
#[serde(flatten)]
pub ask: HelperAsk,
}
/// What the helper can be asked: exactly these (INVARIANT 2).
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case", deny_unknown_fields)]
pub enum HelperAsk {
/// Start the books' host.
StartHost,
/// Open a document with the system's app for it.
OpenWith {
/// The attachment's id.
attachment: u64,
},
/// Ask the owner for a backup file and stage it for a restore.
StageBackup,
/// Start the updater.
StartUpdater,
}
/// The helper's answer.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case", deny_unknown_fields)]
pub enum Answer {
/// Done.
Done,
/// Refused, with why.
Refused(String),
}
/// The caller's end: a running helper.
pub struct Client {
stream: std::sync::Mutex<UnixStream>,
child: std::sync::Mutex<std::process::Child>,
}
impl Client {
/// Spawn a helper for the root (`root`, else this account's), with
/// these knobs. Call it before starting any thread (INVARIANT 4).
pub fn spawn(root: Option<&Path>, knobs: &Knobs) -> Result<Self, String> {
let (ours, theirs) = UnixStream::pair().map_err(|e| e.to_string())?;
let exe = crate::start::this_executable()?;
let mut c = std::process::Command::new(exe);
c.arg("helper");
if let Some(root) = root {
let found = ledger_store::root::Root::find(Some(root)).map_err(|e| e.to_string())?;
c.arg("--root").arg(found.path());
}
c.args(knobs.args())
.stdin(std::process::Stdio::null())
.stdout(std::process::Stdio::null());
let fd = theirs.as_raw_fd();
// SAFETY: dup2 and fcntl are async-signal-safe; they give the child
// its end of the pair as descriptor 3, without close-on-exec (which
// a descriptor already numbered 3 would still have).
unsafe {
c.pre_exec(move || {
let ok =
(fd == FD || libc::dup2(fd, FD) >= 0) && libc::fcntl(FD, libc::F_SETFD, 0) >= 0;
if !ok {
return Err(std::io::Error::last_os_error());
}
Ok(())
});
}
let child = c
.spawn()
.map_err(|e| format!("can't start the helper: {e}"))?;
drop(theirs);
Ok(Self {
stream: std::sync::Mutex::new(ours),
child: std::sync::Mutex::new(child),
})
}
/// Ask the helper one thing (blocking).
pub fn ask(&self, ask: HelperAsk) -> Result<Answer, String> {
let stream = self.stream.lock().unwrap_or_else(|e| e.into_inner());
let mut line =
serde_json::to_string(&Message { proto: PROTO, ask }).map_err(|e| e.to_string())?;
line.push('\n');
(&*stream)
.write_all(line.as_bytes())
.map_err(|e| format!("the helper is gone: {}", e.kind()))?;
let mut answer = String::new();
std::io::BufReader::new(std::io::Read::take(&*stream, MAX_LINE))
.read_line(&mut answer)
.map_err(|e| format!("the helper didn't answer: {}", e.kind()))?;
serde_json::from_str(answer.trim_end()).map_err(|_| "the helper is gone".to_string())
}
/// Start the books' host (INVARIANT 3).
pub fn start_host(&self) -> Result<(), String> {
match self.ask(HelperAsk::StartHost)? {
Answer::Done => Ok(()),
Answer::Refused(why) => Err(why),
}
}
}
impl Drop for Client {
fn drop(&mut self) {
// Closing our end ends the helper (INVARIANT 1); reap it.
let stream = self.stream.lock().unwrap_or_else(|e| e.into_inner());
let _ = stream.shutdown(std::net::Shutdown::Both);
drop(stream);
let _ = self.child.lock().unwrap_or_else(|e| e.into_inner()).wait();
}
}
/// What the helper does with one message.
fn answer(ask: HelperAsk, root: Option<&Path>, knobs: &Knobs) -> Answer {
match ask {
HelperAsk::StartHost => match crate::start::start_host(root, knobs) {
Ok(()) => Answer::Done,
Err(why) => Answer::Refused(why),
},
HelperAsk::OpenWith { .. } => {
Answer::Refused("opening documents comes with the viewer's menu".into())
}
HelperAsk::StageBackup => Answer::Refused("restoring isn't built yet".into()),
HelperAsk::StartUpdater => Answer::Refused("updates come with the installers".into()),
}
}
/// `wanderbooks helper`: serve the socket on descriptor 3 until it closes
/// (INVARIANT 1).
pub fn run(
prog: &str,
args: impl IntoIterator<Item = String>,
) -> Result<(), Box<dyn std::error::Error>> {
let mut root: Option<PathBuf> = None;
let mut knobs = Knobs::default();
let mut args = args.into_iter();
while let Some(arg) = args.next() {
match arg.as_str() {
"--root" => root = Some(PathBuf::from(args.next().ok_or("--root needs a folder")?)),
FLAG => knobs = Knobs::parse(&args.next().ok_or("--test-knobs needs a value")?)?,
"--help" | "-h" => {
eprintln!("usage: {prog} [--root DIR]");
eprintln!(" Started by a confined part of wanderbooks, over descriptor 3.");
return Ok(());
}
other => {
eprintln!("unknown argument: {other}");
return Err(Box::new(crate::Exit(2)));
}
}
}
// SAFETY: descriptor 3 is ours by INVARIANT 1; if it isn't a socket,
// the peer check below refuses it.
let stream = unsafe { UnixStream::from_raw_fd(FD) };
// Taken: nothing this helper starts inherits it.
// SAFETY: fcntl on our own descriptor.
unsafe { libc::fcntl(FD, libc::F_SETFD, libc::FD_CLOEXEC) };
if !ledger_mcp::local::peer_is_us(&stream) {
eprintln!("{prog}: descriptor 3 isn't a socket to this account's own process");
return Err(Box::new(crate::Exit(2)));
}
serve(std::io::BufReader::new(&stream), &stream, |ask| {
answer(ask, root.as_deref(), &knobs)
});
Ok(())
}
/// Answer each message read from `reader` on `writer`, a line each, with
/// `reply` for the asks of this version, until the input ends or a line
/// is too long or cut off (INVARIANTS 1, 5).
fn serve<R: BufRead, W: Write>(mut reader: R, mut writer: W, reply: impl Fn(HelperAsk) -> Answer) {
loop {
// INVARIANT 5: never more than a line's worth read.
let mut line = String::new();
match std::io::Read::take(&mut reader, MAX_LINE + 1).read_line(&mut line) {
Ok(0) | Err(_) => break,
Ok(_) if !line.ends_with('\n') => break,
Ok(_) => {}
}
let parsed = crate::lease::strict_pair(line.trim_end())
.and_then(|v| serde_json::from_value::<Message>(v).map_err(|e| e.to_string()));
let answer = match parsed {
Ok(m) if m.proto == PROTO => reply(m.ask),
Ok(_) => Answer::Refused("the helper is another version: restart".into()),
Err(e) => Answer::Refused(format!("not understood: {e}")),
};
let mut text = serde_json::to_string(&answer).unwrap_or_default();
text.push('\n');
if writer.write_all(text.as_bytes()).is_err() {
break;
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn exactly_four_asks_and_none_carries_a_path() {
for ok in [
json!({"proto": 1, "start_host": null}),
json!({"proto": 1, "open_with": {"attachment": 7}}),
json!({"proto": 1, "stage_backup": null}),
json!({"proto": 1, "start_updater": null}),
] {
assert!(
serde_json::from_value::<Message>(ok.clone()).is_ok(),
"{ok}"
);
}
for bad in [
json!({"proto": 1, "open_with": {"attachment": 7, "path": "/etc/passwd"}}),
json!({"proto": 1, "open_with": {"path": "/x"}}),
json!({"proto": 1, "run": {"command": "sh"}}),
json!({"proto": 1, "start_host": {"root": "/x"}}),
json!({"proto": 1}),
] {
assert!(
serde_json::from_value::<Message>(bad.clone()).is_err(),
"{bad}"
);
}
// Two asks in one message: refused, not half-read.
let two = r#"{"proto":1,"start_host":null,"start_updater":null}"#;
assert!(crate::lease::strict_pair(two).is_err());
}
/// Serve `input`; the answers, a line each.
fn served(input: &[u8]) -> Vec<String> {
let mut out: Vec<u8> = Vec::new();
serve(input, &mut out, |ask| match ask {
HelperAsk::StartHost => Answer::Done,
_ => Answer::Refused("no".into()),
});
String::from_utf8(out)
.unwrap()
.lines()
.map(str::to_string)
.collect()
}
#[test]
fn each_line_is_answered_and_a_bad_one_ends_it() {
let ok = r#"{"proto":1,"start_host":null}"#;
let other = r#"{"proto":2,"start_host":null}"#;
let got = served(format!("{ok}\n{other}\nnonsense\n{ok}\n").as_bytes());
assert_eq!(got[0], r#""done""#);
assert!(got[1].contains("another version"), "{}", got[1]);
assert!(got[2].contains("not understood"), "{}", got[2]);
assert_eq!(got[3], r#""done""#);
assert_eq!(got.len(), 4);
// A line at the limit is read; one byte more ends the helper,
// and so does one cut off without its newline.
let at = format!("{ok}{}\n", " ".repeat(MAX_LINE as usize - ok.len()));
assert_eq!(served(at.as_bytes()), [r#""done""#]);
let over = format!(
"{ok}{}\n{ok}\n",
" ".repeat(MAX_LINE as usize - ok.len() + 1)
);
assert!(served(over.as_bytes()).is_empty());
assert!(served(ok.as_bytes()).is_empty());
// An answer that can't be written ends it.
struct Broken;
impl Write for Broken {
fn write(&mut self, _: &[u8]) -> std::io::Result<usize> {
Err(std::io::ErrorKind::BrokenPipe.into())
}
fn flush(&mut self) -> std::io::Result<()> {
Ok(())
}
}
let asked = std::cell::Cell::new(0);
serve(format!("{ok}\n{ok}\n").as_bytes(), Broken, |_| {
asked.set(asked.get() + 1);
Answer::Done
});
assert_eq!(asked.get(), 1, "nothing more after the first answer failed");
}
#[test]
fn what_isnt_built_yet_is_refused() {
let k = Knobs::default();
for ask in [
HelperAsk::OpenWith { attachment: 1 },
HelperAsk::StageBackup,
HelperAsk::StartUpdater,
] {
assert!(matches!(answer(ask, None, &k), Answer::Refused(_)));
}
}
}
File diff suppressed because it is too large. Load diff
+150
View File
@@ -0,0 +1,150 @@
//! The host's and bridge's timings, which tests shorten (`--test-knobs`).
//!
//! Every mode that starts another passes its knobs on, so a test sets
//! them once, on the holder it starts. Nothing else sets them: the
//! defaults are the plan's (3.9).
use std::time::Duration;
/// The timings in force.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct Knobs {
/// How long the host waits after the last lease ends before it
/// closes the books (plan: 10 s).
pub close_after: Duration,
/// How often the host looks whether its executable was replaced.
pub exe_poll: Duration,
/// The first wait before a holder starts a failed host again; it
/// doubles each time (plan: 1, 2, 4 … 60 s, five tries).
pub backoff: Duration,
/// How long the host waits before starting the server (tests of a
/// slow start only; 0 otherwise).
pub server_delay: Duration,
/// How long the bridge waits for the server before answering
/// `initialize` itself (plan: 5 s).
pub init_wait: Duration,
/// How long the bridge holds a call while the books open (plan: 60 s).
pub queue_wait: Duration,
/// How long a command waits for the books to open.
pub open_wait: Duration,
}
impl Default for Knobs {
fn default() -> Self {
Self {
close_after: Duration::from_secs(10),
exe_poll: Duration::from_secs(5),
backoff: Duration::from_secs(1),
server_delay: Duration::ZERO,
init_wait: Duration::from_secs(5),
queue_wait: Duration::from_secs(60),
open_wait: Duration::from_secs(120),
}
}
}
/// The flag that carries them.
pub const FLAG: &str = "--test-knobs";
impl Knobs {
/// Read `name=millis,...` (any subset; the rest stay default).
pub fn parse(text: &str) -> Result<Self, String> {
let mut k = Self::default();
for pair in text.split(',').filter(|p| !p.is_empty()) {
let (name, value) = pair
.split_once('=')
.ok_or_else(|| format!("{FLAG}: {pair:?} isn't name=milliseconds"))?;
let ms: u64 = value
.parse()
.map_err(|_| format!("{FLAG}: {value:?} isn't a number of milliseconds"))?;
let d = Duration::from_millis(ms);
match name {
"close_after" => k.close_after = d,
"exe_poll" => k.exe_poll = d,
"backoff" => k.backoff = d,
"server_delay" => k.server_delay = d,
"init_wait" => k.init_wait = d,
"queue_wait" => k.queue_wait = d,
"open_wait" => k.open_wait = d,
other => return Err(format!("{FLAG}: there is no knob {other:?}")),
}
}
Ok(k)
}
/// The flag's value that gives these knobs, or `None` for the
/// defaults (then nothing is passed on).
pub fn to_arg(&self) -> Option<String> {
let d = Self::default();
let mut parts = Vec::new();
for (name, now, default) in [
("close_after", self.close_after, d.close_after),
("exe_poll", self.exe_poll, d.exe_poll),
("backoff", self.backoff, d.backoff),
("server_delay", self.server_delay, d.server_delay),
("init_wait", self.init_wait, d.init_wait),
("queue_wait", self.queue_wait, d.queue_wait),
("open_wait", self.open_wait, d.open_wait),
] {
if now != default {
parts.push(format!("{name}={}", now.as_millis()));
}
}
(!parts.is_empty()).then(|| parts.join(","))
}
/// The arguments that pass these knobs on to another mode.
pub fn args(&self) -> Vec<String> {
match self.to_arg() {
Some(v) => vec![FLAG.to_string(), v],
None => Vec::new(),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_defaults_are_the_plans() {
let k = Knobs::default();
assert_eq!(k.close_after, Duration::from_secs(10));
assert_eq!(k.backoff, Duration::from_secs(1));
assert_eq!(k.init_wait, Duration::from_secs(5));
assert_eq!(k.queue_wait, Duration::from_secs(60));
assert_eq!(k.exe_poll, Duration::from_secs(5));
assert_eq!(k.server_delay, Duration::ZERO);
assert_eq!(k.open_wait, Duration::from_secs(120));
assert_eq!(k.to_arg(), None);
assert!(k.args().is_empty());
assert_eq!(Knobs::parse("").unwrap(), k);
}
#[test]
fn knobs_pass_on_exactly() {
let k = Knobs::parse(
"close_after=500,backoff=20,server_delay=3000,init_wait=100,queue_wait=900,exe_poll=50,open_wait=70",
)
.unwrap();
assert_eq!(k.close_after, Duration::from_millis(500));
assert_eq!(k.backoff, Duration::from_millis(20));
assert_eq!(k.server_delay, Duration::from_millis(3000));
assert_eq!(k.init_wait, Duration::from_millis(100));
assert_eq!(k.queue_wait, Duration::from_millis(900));
assert_eq!(k.exe_poll, Duration::from_millis(50));
assert_eq!(k.open_wait, Duration::from_millis(70));
let args = k.args();
assert_eq!(args[0], FLAG);
assert_eq!(Knobs::parse(&args[1]).unwrap(), k);
let one = Knobs::parse("close_after=1").unwrap();
assert_eq!(one.to_arg().as_deref(), Some("close_after=1"));
}
#[test]
fn a_bad_knob_is_refused() {
for bad in ["close_after", "close_after=x", "nope=1", "close_after=-1"] {
assert!(Knobs::parse(bad).is_err(), "{bad}");
}
}
}
+391
View File
@@ -0,0 +1,391 @@
//! The lease socket's messages (`run/lease.sock`, plan 3.9).
//!
//! ```text
//! GOAL: holders hold the books open, and the owner's few requests reach
//! the server, through one socket that accepts nothing else.
//! SOURCE: plan/served.md 3.9 ("Who holds them", "Three local channels":
//! leases record their kind and PID; exactly five owner intents;
//! consent and a replacing restore carry the owner passphrase;
//! "Closing": the host says why on each lease; protocol versions N
//! and N-1).
//! INVARIANTS:
//! 1. A connection's first line is one Ask; a Hold keeps the
//! connection (the lease lasts until it closes), anything else is
//! answered once and the connection closed.
//! 2. Exactly five intents exist: close, back_up_now, new_books,
//! restore (a staged backup's id, never a path) and
//! consent_format_upgrade; any other request is refused.
//! 3. consent_format_upgrade, and a restore that replaces a book, carry
//! the owner passphrase; without one they are refused before
//! anything is done.
//! 4. Before the host ends a lease it sends the reason; a lease that
//! ends with no reason means the host is gone.
//! 5. Each side accepts its own protocol version and the one before.
//! 6. Lines are at most MAX_LINE bytes.
//! 7. A first line is exactly its version and one ask: anything more
//! (two asks, an unknown key) is refused, never half-read.
//! ```
use serde::{Deserialize, Serialize};
/// This protocol's version (INVARIANT 5).
pub const PROTO: u32 = 1;
/// The oldest version still accepted (there is none before the first).
pub const OLDEST: u32 = 1;
/// The host's socket, in the root's `run/`.
pub const LEASE_SOCKET: &str = "lease.sock";
/// Held by the one host for a root.
pub const HOST_LOCK: &str = "host.lock";
/// Held while the host restarts; openers wait on it.
pub const UPDATE_LOCK: &str = "update.lock";
/// The longest line either side sends (INVARIANT 6).
pub const MAX_LINE: usize = 64 * 1024;
/// Whether a message of protocol version `proto` is accepted.
pub fn accepts(proto: u32) -> bool {
(OLDEST..=PROTO).contains(&proto)
}
/// What kind of holder a lease is.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Kind {
/// The app (the viewer).
App,
/// An agent session's bridge (`wanderbooks mcp`).
Bridge,
/// A terminal running `wanderbooks serve`.
Serve,
/// A command that needs the server (`wanderbooks books ...`).
Cli,
}
impl Kind {
/// How a person names it.
pub fn label(self) -> &'static str {
match self {
Kind::App => "the app",
Kind::Bridge => "an agent session",
Kind::Serve => "wanderbooks serve",
Kind::Cli => "a wanderbooks command",
}
}
}
/// A connection's first line (INVARIANT 1).
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct Hello {
/// The sender's protocol version.
pub proto: u32,
/// What it asks.
#[serde(flatten)]
pub ask: Ask,
}
/// What a connection asks.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case", deny_unknown_fields)]
pub enum Ask {
/// Hold the books open while this connection lasts.
Hold {
/// Who holds them.
kind: Kind,
/// The holder's process.
pid: u32,
/// The book it works on, if one.
#[serde(default)]
book: Option<String>,
},
/// The leases held now (the app and `wanderbooks doctor` list them).
Leases,
/// One of the owner's intents.
Intent(Intent),
}
/// The owner's intents: exactly these five (INVARIANT 2).
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case", deny_unknown_fields)]
pub enum Intent {
/// Close the books now: every lease ends.
Close,
/// Back a book up now.
BackUpNow {
/// Which.
book: String,
},
/// Start new books.
NewBooks {
/// Their id.
id: String,
/// Their base currency.
currency: String,
},
/// Restore a staged backup, as its own book or replacing one.
Restore {
/// The staged backup's id (never a path).
staged: String,
/// The book it replaces, if it replaces one.
#[serde(default)]
replace: Option<String>,
/// The owner passphrase: needed to replace (INVARIANT 3).
#[serde(default)]
passphrase: Option<String>,
},
/// Let the server upgrade a book's journal format.
ConsentFormatUpgrade {
/// Which.
book: String,
/// The owner passphrase (INVARIANT 3).
#[serde(default)]
passphrase: Option<String>,
},
}
impl Intent {
/// Refuse an intent that needs the passphrase and has none
/// (INVARIANT 3).
pub fn check_passphrase(&self) -> Result<(), String> {
let missing = |p: &Option<String>| p.as_deref().is_none_or(|p| p.is_empty());
match self {
Intent::ConsentFormatUpgrade { passphrase, .. } if missing(passphrase) => Err(
"consenting to a format upgrade needs the owner passphrase, typed in the app"
.into(),
),
Intent::Restore {
replace: Some(_),
passphrase,
..
} if missing(passphrase) => Err(
"a restore that replaces books needs the owner passphrase, typed in the app".into(),
),
_ => Ok(()),
}
}
}
/// Why the host ends a lease (INVARIANT 4).
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Reason {
/// The owner closed the books ("Close the books", `wanderbooks
/// close`): reopen only in a new session.
ClosedByOwner,
/// The host is restarting (an update): hold again once it is back.
Restarting,
/// The host is closing because nothing held it, or its restart or
/// start went wrong (never the owner's close, which is
/// `ClosedByOwner`): hold again, and a new host will answer.
Closing,
/// The books server stopped or couldn't start.
ServerFailed,
}
/// One lease, as listed.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct LeaseInfo {
/// Its number.
pub id: u64,
/// Who holds it.
pub kind: Kind,
/// The holder's process.
pub pid: u32,
/// The book it works on, if one.
#[serde(default)]
pub book: Option<String>,
}
/// What the host sends.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case", deny_unknown_fields)]
pub enum HostMsg {
/// The lease is held; the books are open or opening.
Held {
/// The lease's number.
lease: u64,
},
/// The books server is ready.
Ready,
/// The lease ends, and why (INVARIANT 4).
Ending {
/// Why.
reason: Reason,
/// More, in plain words (no path).
#[serde(default)]
detail: Option<String>,
},
/// An intent was carried out.
Done {
/// What came of it, in plain words.
#[serde(default)]
detail: Option<String>,
},
/// An intent was sent on, and no answer came back: it may or may not
/// have been carried out, in plain words.
Uncertain(String),
/// The leases held now, and whether the books are open yet.
Leases {
/// The books server is ready.
ready: bool,
/// The leases.
leases: Vec<LeaseInfo>,
},
/// Refused, with why.
Refused(String),
}
/// A connection's first line, read strictly (INVARIANT 7).
pub fn parse_hello(text: &str) -> Result<Hello, String> {
strict_pair(text).and_then(|v| serde_json::from_value(v).map_err(|e| e.to_string()))
}
/// `text` as a JSON object of exactly two keys, `proto` and one more.
pub(crate) fn strict_pair(text: &str) -> Result<serde_json::Value, String> {
let value: serde_json::Value = serde_json::from_str(text).map_err(|e| e.to_string())?;
match value.as_object() {
Some(o) if o.len() == 2 && o.contains_key("proto") => Ok(value),
Some(_) => Err("a message is its protocol version and exactly one request".into()),
None => Err("a message is a JSON object".into()),
}
}
/// One line: the message as JSON and a newline.
pub fn line<T: Serialize>(message: &T) -> String {
let mut text = serde_json::to_string(message).expect("a lease message is plain data");
text.push('\n');
text
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
fn parse(v: serde_json::Value) -> Result<Hello, serde_json::Error> {
serde_json::from_value(v)
}
#[test]
fn a_hold_names_its_kind_pid_and_book() {
let h = parse(json!({"proto": 1, "hold": {"kind": "bridge", "pid": 7, "book": "acme"}}))
.unwrap();
assert_eq!(
h.ask,
Ask::Hold {
kind: Kind::Bridge,
pid: 7,
book: Some("acme".into())
}
);
assert_eq!(
serde_json::to_value(&h).unwrap(),
json!({"proto": 1, "hold": {"kind": "bridge", "pid": 7, "book": "acme"}})
);
}
#[test]
fn exactly_five_intents_are_understood() {
for intent in [
json!("close"),
json!({"back_up_now": {"book": "acme"}}),
json!({"new_books": {"id": "acme", "currency": "USD"}}),
json!({"restore": {"staged": "s1"}}),
json!({"consent_format_upgrade": {"book": "acme", "passphrase": "x"}}),
] {
let h = parse(json!({"proto": 1, "intent": intent})).unwrap();
assert!(matches!(h.ask, Ask::Intent(_)), "{intent}");
}
for bad in [
json!({"proto": 1, "intent": "delete"}),
json!({"proto": 1, "intent": {"restore": {"path": "/x"}}}),
json!({"proto": 1, "intent": {"restore": {"staged": "s1", "path": "/x"}}}),
json!({"proto": 1, "intent": {"run": {"command": "sh"}}}),
json!({"proto": 1, "exec": "sh"}),
json!({"proto": 1}),
] {
assert!(parse(bad.clone()).is_err(), "{bad}");
}
}
#[test]
fn a_hello_is_exactly_its_version_and_one_ask() {
assert!(parse_hello(r#"{"proto":1,"leases":null}"#).is_ok());
for bad in [
r#"{"proto":1,"intent":{"back_up_now":{"book":"a"}},"leases":null}"#,
r#"{"proto":1,"leases":null,"extra":1}"#,
r#"{"leases":null,"intent":"close"}"#,
r#"{"proto":1}"#,
r#"[1]"#,
"not json",
] {
assert!(parse_hello(bad).is_err(), "{bad}");
}
}
#[test]
fn consent_and_a_replacing_restore_need_the_passphrase() {
let consent = |p: Option<&str>| Intent::ConsentFormatUpgrade {
book: "acme".into(),
passphrase: p.map(str::to_string),
};
assert!(consent(None).check_passphrase().is_err());
assert!(consent(Some("")).check_passphrase().is_err());
assert!(consent(Some("pw")).check_passphrase().is_ok());
let restore = |replace: Option<&str>, p: Option<&str>| Intent::Restore {
staged: "s1".into(),
replace: replace.map(str::to_string),
passphrase: p.map(str::to_string),
};
assert!(restore(None, None).check_passphrase().is_ok());
assert!(restore(Some("acme"), None).check_passphrase().is_err());
assert!(restore(Some("acme"), Some("")).check_passphrase().is_err());
assert!(restore(Some("acme"), Some("pw")).check_passphrase().is_ok());
for other in [
Intent::Close,
Intent::BackUpNow { book: "a".into() },
Intent::NewBooks {
id: "a".into(),
currency: "USD".into(),
},
] {
assert!(other.check_passphrase().is_ok());
}
}
#[test]
fn versions_this_one_and_the_one_before() {
assert!(accepts(PROTO));
assert!(!accepts(PROTO + 1));
assert!(!accepts(0));
}
#[test]
fn host_messages_round_trip_as_one_line() {
for m in [
HostMsg::Held { lease: 3 },
HostMsg::Ready,
HostMsg::Ending {
reason: Reason::Restarting,
detail: None,
},
HostMsg::Done {
detail: Some("closed".into()),
},
HostMsg::Leases {
ready: true,
leases: vec![LeaseInfo {
id: 1,
kind: Kind::Serve,
pid: 9,
book: None,
}],
},
HostMsg::Refused("no".into()),
HostMsg::Uncertain("maybe".into()),
] {
let text = line(&m);
assert!(text.ends_with('\n') && !text[..text.len() - 1].contains('\n'));
assert_eq!(serde_json::from_str::<HostMsg>(text.trim_end()).unwrap(), m);
}
assert_eq!(Kind::Bridge.label(), "an agent session");
}
}
+38
View File
@@ -0,0 +1,38 @@
//! The books open while something holds them (plan 3.9, D-21, D-23).
//!
//! Nothing runs in the background: the first holder (the app, an agent's
//! bridge, `wanderbooks serve`, or a command that needs the books) starts
//! the **host**, which starts the books server and holds it open while
//! any lease on its socket lasts, and closes it 10 s after the last one
//! ends. Each part is a mode of the one `wanderbooks` executable:
//! [`host::run`] (`wanderbooks host`), [`helper::run`] (`wanderbooks
//! helper`), [`bridge::run`] (`wanderbooks mcp`), and the commands in
//! [`commands`] (`serve`, `close`, `books`).
#[cfg(unix)]
pub mod bridge;
#[cfg(unix)]
pub mod commands;
#[cfg(unix)]
pub mod helper;
#[cfg(unix)]
pub mod host;
pub mod knobs;
pub mod lease;
#[cfg(unix)]
pub mod opener;
#[cfg(unix)]
mod start;
/// How a mode stops when it can't go on: the reason is already printed,
/// and the number is the process's exit code.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Exit(pub i32);
impl std::fmt::Display for Exit {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "exit code {}", self.0)
}
}
impl std::error::Error for Exit {}
+512
View File
@@ -0,0 +1,512 @@
//! Holding the books: how every holder takes its lease (plan 3.9).
//!
//! ```text
//! GOAL: a holder gets its lease on the one host, starting one when none
//! answers, even when several holders start at once; across a
//! restart it simply holds again; a host that keeps failing is
//! given up on, plainly, instead of retried forever.
//! SOURCE: plan/served.md 3.9 ("One host, even in a race", "Closing":
//! backoff 1, 2, 4 … 60 s, at most five tries; "Restarting":
//! openers wait on update.lock); tests host_respawns,
//! respawn_backs_off.
//! INVARIANTS:
//! 1. Before each try the holder waits while a restart holds
//! run/update.lock.
//! 2. No host answering: it starts one (its Starter) and keeps trying;
//! a host that is closing or restarting turns it away and it tries
//! again until a host takes the lease.
//! 3. A start that brings no lease within START_WAIT (or whose host
//! died before taking its lock), a host that doesn't answer, and a
//! lease that ends with server_failed or with no reason at all, are
//! failures: the holder tries again after knobs.backoff, doubling
//! each time (at most 60 s), MAX_TRIES times; the failure after that
//! gives up, naming `wanderbooks doctor`. Books that stayed open for
//! STABLE clear the count; a server that fails soon after opening
//! doesn't.
//! 4. The host's end of the socket must be this account.
//! 5. A holder that has held before, turned away because the owner
//! closed the books, stays closed (HoldError::ClosedByOwner); a new
//! holder waits for a new host.
//! ```
use crate::knobs::Knobs;
use crate::lease::{self, Ask, Hello, HostMsg, Kind, Reason};
use std::path::{Path, PathBuf};
use std::time::Duration;
use tokio::io::{AsyncBufReadExt, AsyncReadExt, AsyncWriteExt};
use tokio::time::Instant;
/// How long a started host has to take a lease (longer than a new host
/// waits for an old one's lock).
const START_WAIT: Duration = Duration::from_secs(45);
/// How long a started host has to take its lock: after that, no host
/// holding it means the one started has died.
const LOCK_WAIT: Duration = Duration::from_secs(2);
/// How long the host has to answer a hello.
const ANSWER_WAIT: Duration = Duration::from_secs(10);
/// Tries after a failure before giving up (INVARIANT 3).
pub const MAX_TRIES: u32 = 5;
/// How long the books must stay open for earlier failures to be
/// forgotten (INVARIANT 3).
const STABLE: Duration = Duration::from_secs(60);
/// The longest wait between tries.
const MAX_BACKOFF: Duration = Duration::from_secs(60);
/// What a holder says when the host keeps failing.
pub const KEEPS_FAILING: &str =
"the books server keeps failing to start: run `wanderbooks doctor` to see why";
/// Why no lease was taken.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum HoldError {
/// The owner closed the books, and this holder had held them
/// (INVARIANT 5).
ClosedByOwner,
/// Anything else, in plain words.
Failed(String),
}
impl std::fmt::Display for HoldError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
HoldError::ClosedByOwner => write!(f, "the owner closed the books"),
HoldError::Failed(why) => f.write_str(why),
}
}
}
impl From<String> for HoldError {
fn from(why: String) -> Self {
HoldError::Failed(why)
}
}
impl From<&str> for HoldError {
fn from(why: &str) -> Self {
HoldError::Failed(why.to_string())
}
}
/// How a holder starts a host when none answers.
pub enum Starter {
/// Start it itself (a holder that isn't confined).
Direct,
/// Ask its helper to (a confined holder).
Helper(std::sync::Arc<crate::helper::Client>),
}
/// Takes leases for one holder.
pub struct Opener {
root: Option<PathBuf>,
run: PathBuf,
knobs: Knobs,
starter: Starter,
kind: Kind,
book: Option<String>,
failures: u32,
/// When the books last opened on a lease.
opened_at: Option<std::time::Instant>,
/// Whether this holder has held a lease (INVARIANT 5).
has_held: bool,
}
/// A held lease: the books stay open while it lives.
pub struct Lease {
/// Its number.
pub id: u64,
lines: tokio::io::Lines<tokio::io::BufReader<tokio::io::Take<tokio::net::unix::OwnedReadHalf>>>,
_write: tokio::net::unix::OwnedWriteHalf,
}
impl Lease {
/// The host's next word: `Ready`, or `Ending` with why; `None` when
/// the host is gone without a word.
pub async fn next(&mut self) -> Option<HostMsg> {
loop {
match self.lines.next_line().await {
Ok(Some(line)) => match serde_json::from_str::<HostMsg>(&line) {
Ok(msg) => return Some(msg),
Err(_) => continue,
},
_ => return None,
}
}
}
}
impl Opener {
/// An opener for the root (`root`, else the one this account's host
/// finds), holding as `kind` for `book`.
pub fn new(
root: Option<&Path>,
knobs: Knobs,
starter: Starter,
kind: Kind,
book: Option<String>,
) -> Result<Self, String> {
let found = ledger_store::root::Root::find(root).map_err(|e| e.to_string())?;
let run = found.run_dir().map_err(|e| e.to_string())?;
Ok(Self {
root: root.map(|_| found.path().to_path_buf()),
run,
knobs,
starter,
kind,
book,
failures: 0,
opened_at: None,
has_held: false,
})
}
/// The timings in force.
pub fn knobs(&self) -> Knobs {
self.knobs
}
/// The root's `run/` folder.
pub fn run_dir(&self) -> &Path {
&self.run
}
/// The books opened on a lease (INVARIANT 3).
pub fn opened(&mut self) {
self.opened_at = Some(std::time::Instant::now());
}
/// Count a failure and wait before the next try, or give up
/// (INVARIANT 3).
pub async fn failed(&mut self) -> Result<(), String> {
if self
.opened_at
.take()
.is_some_and(|at| at.elapsed() >= STABLE)
{
self.failures = 0;
}
self.failures += 1;
if self.failures > MAX_TRIES {
return Err(KEEPS_FAILING.into());
}
tokio::time::sleep(backoff(self.knobs.backoff, self.failures)).await;
Ok(())
}
/// Take a lease, starting a host if none answers (INVARIANTS 1-4).
pub async fn hold(&mut self) -> Result<Lease, HoldError> {
let mut started: Option<Instant> = None;
loop {
wait_for_restart(&self.run).await;
let short = ledger_store::root::SocketPath::new(&self.run, lease::LEASE_SOCKET)
.map_err(|e| format!("can't reach the books' host: {e}"))?;
match tokio::net::UnixStream::connect(short.path()).await {
Ok(stream) => {
if !ledger_mcp::local::peer_is_us(&stream) {
return Err(
"the books' host socket belongs to another account: not used".into(),
);
}
match self.ask(stream).await? {
Asked::Held(lease) => {
self.has_held = true;
return Ok(lease);
}
Asked::OwnerClosed if self.has_held => {
return Err(HoldError::ClosedByOwner);
}
Asked::OwnerClosed => {
tokio::time::sleep(Duration::from_millis(100)).await;
}
Asked::Again => tokio::time::sleep(Duration::from_millis(100)).await,
Asked::Failed => {
started = None;
self.failed().await?;
}
}
}
// No host answering, or one going away as we came.
Err(e) if no_host(&e) => {
// A host holding the lock is starting or closing: wait
// for it rather than start another.
let held = host_lock_held(&self.run);
match started {
// A start that fails (a spawn error, the helper
// gone) is a failure like any other (INVARIANT 3).
None if !held => match self.start().await {
Ok(()) => started = Some(Instant::now()),
Err(_) => self.failed().await?,
},
None => {}
// The one started died (no host holds the lock),
// or never answered.
Some(at)
if (!held && at.elapsed() > LOCK_WAIT) || at.elapsed() > START_WAIT =>
{
started = None;
self.failed().await?;
}
Some(_) => {}
}
tokio::time::sleep(Duration::from_millis(50)).await;
}
Err(e) => {
return Err(format!("can't reach the books' host: {}", e.kind()).into());
}
}
}
}
/// Start a host, through this holder's starter.
async fn start(&self) -> Result<(), String> {
match &self.starter {
Starter::Direct => {
let root = self.root.clone();
let knobs = self.knobs;
tokio::task::spawn_blocking(move || {
crate::start::start_host(root.as_deref(), &knobs)
})
.await
.map_err(|e| e.to_string())?
}
Starter::Helper(helper) => {
let helper = helper.clone();
tokio::task::spawn_blocking(move || helper.start_host())
.await
.map_err(|e| e.to_string())?
}
}
}
/// Say hello on a connection and read the host's first word.
async fn ask(&self, stream: tokio::net::UnixStream) -> Result<Asked, String> {
let (read, mut write) = stream.into_split();
let hello = Hello {
proto: lease::PROTO,
ask: Ask::Hold {
kind: self.kind,
pid: std::process::id(),
book: self.book.clone(),
},
};
if write
.write_all(lease::line(&hello).as_bytes())
.await
.is_err()
{
return Ok(Asked::Again);
}
let mut lines = tokio::io::BufReader::new(read.take(lease::MAX_LINE as u64 * 4)).lines();
let first = match tokio::time::timeout(ANSWER_WAIT, lines.next_line()).await {
Ok(Ok(Some(line))) => serde_json::from_str::<HostMsg>(&line)
.map_err(|e| format!("the books' host answered oddly: {e}"))?,
// Gone mid-hello (closing, or a crash): try again.
Ok(_) => return Ok(Asked::Again),
// Not answering: a failure, tried again after a wait.
Err(_) => return Ok(Asked::Failed),
};
Ok(match first {
HostMsg::Held { lease } => Asked::Held(Lease {
id: lease,
lines,
_write: write,
}),
HostMsg::Ending {
reason: Reason::ServerFailed,
..
} => Asked::Failed,
HostMsg::Ending {
reason: Reason::ClosedByOwner,
..
} => Asked::OwnerClosed,
HostMsg::Ending { .. } => Asked::Again,
HostMsg::Refused(why) => return Err(why),
other => return Err(format!("the books' host answered oddly: {other:?}")),
})
}
}
/// What a hello came to.
enum Asked {
Held(Lease),
Again,
Failed,
/// Turned away: the owner is closing the books (INVARIANT 5).
OwnerClosed,
}
/// The wait before try `n` (1-based): `base`, doubling, at most 60 s.
pub fn backoff(base: Duration, n: u32) -> Duration {
let factor = 1u32 << n.saturating_sub(1).min(16);
base.saturating_mul(factor).min(MAX_BACKOFF)
}
/// Whether a connect error means no host is answering (none there, or
/// one closing as we came), rather than something wrong.
fn no_host(e: &std::io::Error) -> bool {
matches!(
e.kind(),
std::io::ErrorKind::NotFound
| std::io::ErrorKind::ConnectionRefused
| std::io::ErrorKind::ConnectionReset
| std::io::ErrorKind::ConnectionAborted
| std::io::ErrorKind::BrokenPipe
)
}
/// Whether a books server holds run/server.lock now (one finishing after
/// its host is gone included).
pub fn server_lock_held(run: &Path) -> bool {
match std::fs::File::open(run.join(ledger_mcp::local::SERVER_LOCK)) {
Ok(file) => matches!(
file.try_lock_shared(),
Err(std::fs::TryLockError::WouldBlock)
),
Err(_) => false,
}
}
/// Whether a host holds run/host.lock now (starting, serving or closing).
pub fn host_lock_held(run: &Path) -> bool {
match std::fs::File::open(run.join(lease::HOST_LOCK)) {
Ok(file) => matches!(
file.try_lock_shared(),
Err(std::fs::TryLockError::WouldBlock)
),
Err(_) => false,
}
}
/// Wait while a restart holds run/update.lock (INVARIANT 1).
async fn wait_for_restart(run: &Path) {
// Looked at, not waited on in a thread: a holder that gives up while
// a restart runs is never stuck behind it.
let path = run.join(lease::UPDATE_LOCK);
loop {
// No lock file: no restart has ever happened, so none is on.
let restarting = std::fs::File::open(&path)
.is_ok_and(|f| matches!(f.try_lock_shared(), Err(std::fs::TryLockError::WouldBlock)));
if !restarting {
return;
}
tokio::time::sleep(Duration::from_millis(50)).await;
}
}
/// Ask the host one thing on its own connection (leases, an intent):
/// `Ok(None)` when no host is running.
pub fn ask_once(run: &Path, ask: Ask) -> Result<Option<HostMsg>, String> {
ask_once_within(run, ask, Duration::from_secs(120))
}
/// [`ask_once`], waiting at most `wait` for the answer (a probe).
pub fn ask_once_within(run: &Path, ask: Ask, wait: Duration) -> Result<Option<HostMsg>, String> {
use std::io::{BufRead, Write};
let short = ledger_store::root::SocketPath::new(run, lease::LEASE_SOCKET)
.map_err(|e| format!("can't reach the books' host: {e}"))?;
let stream = match std::os::unix::net::UnixStream::connect(short.path()) {
Ok(s) => s,
Err(e) if no_host(&e) => return Ok(None),
Err(e) => return Err(format!("can't reach the books' host: {}", e.kind())),
};
if !ledger_mcp::local::peer_is_us(&stream) {
return Err("the books' host socket belongs to another account: not used".into());
}
stream
.set_read_timeout(Some(wait))
.map_err(|e| e.to_string())?;
let not_answering = |e: std::io::Error| match e.kind() {
std::io::ErrorKind::WouldBlock | std::io::ErrorKind::TimedOut => {
"the books' host isn't answering".to_string()
}
kind => format!("the books' host didn't answer: {kind}"),
};
(&stream)
.write_all(
lease::line(&Hello {
proto: lease::PROTO,
ask,
})
.as_bytes(),
)
.map_err(|e| format!("can't reach the books' host: {}", e.kind()))?;
let mut answer = String::new();
std::io::BufReader::new(std::io::Read::take(&stream, lease::MAX_LINE as u64 * 4))
.read_line(&mut answer)
.map_err(not_answering)?;
if answer.is_empty() {
return Ok(None);
}
serde_json::from_str(answer.trim_end())
.map(Some)
.map_err(|e| format!("the books' host answered oddly: {e}"))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_wait_doubles_from_the_base_up_to_a_minute() {
let s = Duration::from_secs;
let waits: Vec<Duration> = (1..=7).map(|n| backoff(s(1), n)).collect();
assert_eq!(waits, [s(1), s(2), s(4), s(8), s(16), s(32), s(60)]);
assert_eq!(backoff(s(1), 0), s(1));
assert_eq!(backoff(s(1), 400), s(60));
assert_eq!(
backoff(Duration::from_millis(10), 3),
Duration::from_millis(40)
);
}
#[tokio::test]
async fn after_five_failures_in_a_row_it_gives_up() {
let dir = tempfile::tempdir().unwrap();
let mut o = Opener {
root: None,
run: dir.path().to_path_buf(),
knobs: Knobs {
backoff: Duration::from_millis(1),
..Knobs::default()
},
starter: Starter::Direct,
kind: Kind::Cli,
book: None,
failures: 0,
opened_at: None,
has_held: false,
};
for _ in 0..MAX_TRIES {
o.failed().await.unwrap();
}
assert_eq!(o.failed().await.unwrap_err(), KEEPS_FAILING);
// Opening briefly doesn't clear the count; staying open does.
o.opened();
assert!(o.failed().await.is_err(), "a brief open cleared the count");
o.opened_at = Some(std::time::Instant::now() - STABLE);
assert!(o.failed().await.is_ok(), "staying open clears the count");
assert_eq!(o.failures, 1);
}
#[test]
fn a_host_going_away_is_no_host() {
use std::io::{Error, ErrorKind};
for kind in [
ErrorKind::NotFound,
ErrorKind::ConnectionRefused,
ErrorKind::ConnectionReset,
ErrorKind::ConnectionAborted,
ErrorKind::BrokenPipe,
] {
assert!(no_host(&Error::from(kind)), "{kind:?}");
}
for kind in [ErrorKind::PermissionDenied, ErrorKind::InvalidInput] {
assert!(!no_host(&Error::from(kind)), "{kind:?}");
}
}
#[test]
fn no_host_is_none() {
let dir = tempfile::tempdir().unwrap();
assert_eq!(ask_once(dir.path(), Ask::Leases).unwrap(), None);
}
}
+378
View File
@@ -0,0 +1,378 @@
//! Starting the host cleanly (plan 3.9, "Starting the host cleanly").
//!
//! ```text
//! GOAL: the host never inherits anything from whoever opened the books:
//! not their environment, descriptors, session, umask, or the app's
//! scope (which would take the host down with the app).
//! SOURCE: plan/served.md 3.9; test host_starts_clean, host_survives_app_scope.
//! INVARIANTS:
//! 1. The host's environment is a fixed list: PATH (fixed), HOME (from
//! the account's record, never the opener's), and XDG_RUNTIME_DIR
//! and DBUS_SESSION_BUS_ADDRESS only when they point at this
//! account's own private runtime folder. Nothing else passes (a
//! scope's own identifiers, such as INVOCATION_ID, are systemd's).
//! 2. Its standard input and output are null, its errors go to
//! run/host.log (0600); it starts a new session with umask 077. Every
//! other descriptor is closed (std opens them close-on-exec, and the
//! host closes any stray one at start).
//! 3. Under a systemd app scope it starts through a transient
//! `systemd-run --user --scope` (nothing installed or kept); if the
//! scope couldn't be made (systemd-run failed without the host ever
//! writing its first line), or there is no such scope, directly. A
//! host that ran and stopped is never started a second time.
//! 4. It runs this executable's file as it is on disk now.
//! 5. The opener doesn't wait for it, and reaps it when it ends.
//! ```
use crate::knobs::Knobs;
use std::os::unix::fs::{MetadataExt, OpenOptionsExt};
use std::os::unix::process::CommandExt;
use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};
use std::time::{Duration, Instant};
/// The host's PATH (INVARIANT 1).
const PATH: &str = "/usr/local/bin:/usr/bin:/bin";
/// Where systemd-run may be (never looked up on the opener's PATH).
const SYSTEMD_RUN: [&str; 2] = ["/usr/bin/systemd-run", "/bin/systemd-run"];
/// The host's log, in the root's `run/`.
pub const HOST_LOG: &str = "host.log";
/// A log larger than this is moved aside when a host starts.
const LOG_LIMIT: u64 = 8 * 1024 * 1024;
/// This executable's file as it is on disk now (INVARIANT 4): on Linux a
/// replaced file reads as "… (deleted)"; the path, not the old file, is
/// what a new host runs.
pub fn this_executable() -> Result<PathBuf, String> {
let exe = std::env::current_exe().map_err(|e| format!("can't find this program: {e}"))?;
let text = exe.to_string_lossy();
if let Some(path) = text.strip_suffix(" (deleted)")
&& !exe.exists()
{
return Ok(PathBuf::from(path));
}
Ok(exe)
}
/// This account's user ID.
fn own_uid() -> u32 {
// SAFETY: geteuid has no preconditions and can't fail.
unsafe { libc::geteuid() }
}
/// `XDG_RUNTIME_DIR` from `value`, if it is this account's own private
/// runtime folder: absolute, a real folder (no link), owned by this
/// account, and closed to everyone else.
pub fn own_runtime_dir(value: Option<&std::ffi::OsStr>) -> Option<PathBuf> {
// Written plainly (no trailing slash, no `.`), as a bus address
// names it.
let dir: PathBuf = PathBuf::from(value?).components().collect();
if !dir.is_absolute() {
return None;
}
let meta = std::fs::symlink_metadata(&dir).ok()?;
(meta.is_dir() && meta.uid() == own_uid() && meta.mode() & 0o077 == 0).then_some(dir)
}
/// `DBUS_SESSION_BUS_ADDRESS` from `value`, if it is the bus in `runtime`
/// (`unix:path=<runtime>/bus`, perhaps with a guid after a comma).
pub fn own_bus(value: Option<&std::ffi::OsStr>, runtime: &Path) -> Option<String> {
let value = value?.to_str()?;
let want = format!("unix:path={}/bus", runtime.display());
let rest = value.strip_prefix(&want)?;
(rest.is_empty() || rest.starts_with(',')).then(|| value.to_string())
}
/// This account's runtime folder: the one named in the environment if it
/// is ours and private; else, when the opener's environment has none (a
/// client that scrubs it, as Codex does), the system's for this account,
/// `/run/user/<uid>`, under the same checks.
fn runtime_dir() -> Option<PathBuf> {
// The one named, if it passes; else (none named, or one that isn't
// ours or private) the system's for this account.
std::env::var_os("XDG_RUNTIME_DIR")
.and_then(|named| own_runtime_dir(Some(&named)))
.or_else(|| own_runtime_dir(Some(format!("/run/user/{}", own_uid()).as_ref())))
}
/// The session bus in `runtime`: the one named in the environment if it
/// is there, else its usual socket there, if one exists.
fn session_bus(runtime: &Path) -> Option<String> {
match std::env::var_os("DBUS_SESSION_BUS_ADDRESS") {
Some(named) => own_bus(Some(&named), runtime),
None => {
use std::os::unix::fs::FileTypeExt;
let socket = runtime.join("bus");
std::fs::symlink_metadata(&socket)
.is_ok_and(|m| m.file_type().is_socket() && m.uid() == own_uid())
.then(|| format!("unix:path={}", socket.display()))
}
}
}
/// The host's environment (INVARIANT 1).
fn host_env(home: &Path) -> Vec<(String, std::ffi::OsString)> {
let mut env = vec![
("PATH".to_string(), PATH.into()),
("HOME".to_string(), home.as_os_str().to_os_string()),
];
if let Some(runtime) = runtime_dir() {
if let Some(bus) = session_bus(&runtime) {
env.push(("DBUS_SESSION_BUS_ADDRESS".into(), bus.into()));
}
env.push(("XDG_RUNTIME_DIR".into(), runtime.into_os_string()));
}
env
}
/// Whether this process sits in a unit under this account's user manager
/// (an app's scope or service, as uwsm makes for every app), whose stop
/// would take the host with it; going by the unified cgroup line of
/// `/proc/self/cgroup`. The manager's own `init.scope` is not such a
/// unit.
pub fn in_app_scope(cgroup: &str, uid: u32) -> bool {
let manager = format!("/user@{uid}.service/");
cgroup.lines().any(|line| {
line.strip_prefix("0::").is_some_and(|path| {
path.split_once(manager.as_str())
.is_some_and(|(_, unit)| !unit.is_empty() && unit != "init.scope")
})
})
}
/// systemd-run, if this process is in an app scope and the user manager
/// can be reached (INVARIANT 3).
fn systemd_run() -> Option<&'static str> {
let cgroup = std::fs::read_to_string("/proc/self/cgroup").ok()?;
if !in_app_scope(&cgroup, own_uid()) {
return None;
}
// systemd-run reaches the user manager through the runtime folder,
// which host_env gives it even when the opener's environment didn't.
runtime_dir()?;
SYSTEMD_RUN.into_iter().find(|p| Path::new(p).is_file())
}
/// Start a host for the root (`root`, else the one the host finds
/// itself), with these knobs (INVARIANTS 1-5).
pub fn start_host(root: Option<&Path>, knobs: &Knobs) -> Result<(), String> {
let found = ledger_store::root::Root::find(root).map_err(|e| e.to_string())?;
let run = found.run_dir().map_err(|e| e.to_string())?;
let home = ledger_store::root::account_home().map_err(|e| e.to_string())?;
let exe = this_executable()?;
let mut args: Vec<std::ffi::OsString> = vec!["host".into()];
// An explicit root goes on as found (absolute): the host starts in /.
if root.is_some() {
args.push("--root".into());
args.push(found.path().as_os_str().to_os_string());
}
args.extend(knobs.args().into_iter().map(Into::into));
let log = || {
let path = run.join(HOST_LOG);
// A large log is moved aside first (to `.1`), never from under a
// host writing it.
if std::fs::metadata(&path).is_ok_and(|m| m.len() > LOG_LIMIT)
&& !crate::opener::host_lock_held(&run)
{
let _ = std::fs::rename(&path, path.with_extension("log.1"));
}
std::fs::OpenOptions::new()
.create(true)
.read(true)
.append(true)
.mode(0o600)
.open(&path)
.map_err(|e| format!("can't open the host's log: {}", e.kind()))
};
// Through systemd-run (`Some`), or directly.
let command = |through: Option<&str>, log: std::fs::File| -> Result<Command, String> {
let mut c = match through {
Some(systemd_run) => {
let mut c = Command::new(systemd_run);
c.args(["--user", "--scope", "--quiet", "--collect", "--"])
.arg(&exe);
c
}
None => Command::new(&exe),
};
c.args(&args)
.env_clear()
.envs(host_env(&home))
.current_dir("/")
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(log);
// SAFETY: setsid and umask are async-signal-safe and touch only
// the child.
unsafe {
c.pre_exec(|| {
libc::setsid();
libc::umask(0o077);
Ok(())
});
}
Ok(c)
};
if let Some(systemd_run) = systemd_run() {
// The log as this start opened it, and where it stands now: what
// the host writes after this is read back from that same file,
// even if another opener moves the log aside meanwhile.
let file = log()?;
let from = file.metadata().map_or(0, |m| m.len());
let watch = file
.try_clone()
.map_err(|e| format!("can't open the host's log: {}", e.kind()))?;
if let Ok(mut child) = command(Some(systemd_run), file)?.spawn()
&& scope_made(&mut child, &watch, from)
{
reap(child);
return Ok(());
}
}
let child = command(None, log()?)?
.spawn()
.map_err(|e| format!("can't start the books' host: {e}"))?;
reap(child);
Ok(())
}
/// How long systemd-run has to make its scope and run the host.
const SCOPE_WAIT: Duration = Duration::from_secs(2);
/// Whether systemd-run made its scope and ran the host (INVARIANT 3): the
/// host's first line is in its log after `from`, or systemd-run is still
/// running when SCOPE_WAIT is up (it became the host). Only systemd-run
/// failing with no host line means it didn't: then the host is started
/// directly. (systemd-run's own complaints go to the same log, so the
/// log growing proves nothing; the host's line does.)
fn scope_made(child: &mut std::process::Child, log: &std::fs::File, from: u64) -> bool {
let host_ran = || {
use std::os::unix::fs::FileExt;
let len = log.metadata().map_or(0, |m| m.len());
let mut tail = vec![0u8; len.saturating_sub(from).min(64 * 1024) as usize];
log.read_exact_at(&mut tail, from).is_ok()
&& String::from_utf8_lossy(&tail).contains(crate::host::STARTED)
};
let deadline = Instant::now() + SCOPE_WAIT;
loop {
if host_ran() {
return true;
}
match child.try_wait() {
Ok(Some(status)) => return status.success() || host_ran(),
Ok(None) if Instant::now() < deadline => {
std::thread::sleep(Duration::from_millis(10));
}
_ => return true,
}
}
}
/// Reap a started host when it ends, without waiting for it (INVARIANT 5).
fn reap(mut child: std::process::Child) {
let _ = std::thread::Builder::new()
.name("reap-host".into())
.spawn(move || {
let _ = child.wait();
});
}
#[cfg(test)]
mod tests {
use super::*;
use std::os::unix::fs::PermissionsExt;
#[test]
fn an_app_scope_is_known_by_its_unified_cgroup() {
let uwsm = "0::/user.slice/user-1000.slice/user@1000.service/app.slice/app-Hyprland-kitty-1234.scope\n";
assert!(in_app_scope(uwsm, 1000));
assert!(!in_app_scope(uwsm, 1001), "another account's manager");
// An app run as a service unit (uwsm's app-…@….service) too.
assert!(in_app_scope(
"0::/user.slice/user-1000.slice/user@1000.service/app.slice/app-foo@x.service\n",
1000
));
for not in [
"0::/user.slice/user-1000.slice/session-2.scope\n",
"0::/user.slice/user-1000.slice/user@1000.service/init.scope\n",
"0::/user.slice/user-1000.slice/user@1000.service/\n",
"0::/\n",
"1:name=systemd:/user.slice/user-1000.slice/user@1000.service/app.slice/x.scope\n",
"",
] {
assert!(!in_app_scope(not, 1000), "{not}");
}
}
#[test]
fn only_our_own_private_runtime_folder_passes() {
let dir = tempfile::tempdir().unwrap();
let rt = dir.path().join("rt");
std::fs::create_dir(&rt).unwrap();
std::fs::set_permissions(&rt, std::fs::Permissions::from_mode(0o700)).unwrap();
assert_eq!(own_runtime_dir(Some(rt.as_os_str())), Some(rt.clone()));
std::fs::set_permissions(&rt, std::fs::Permissions::from_mode(0o750)).unwrap();
assert_eq!(
own_runtime_dir(Some(rt.as_os_str())),
None,
"group can enter"
);
std::fs::set_permissions(&rt, std::fs::Permissions::from_mode(0o700)).unwrap();
let link = dir.path().join("link");
std::os::unix::fs::symlink(&rt, &link).unwrap();
assert_eq!(own_runtime_dir(Some(link.as_os_str())), None, "a link");
assert_eq!(own_runtime_dir(Some("relative/rt".as_ref())), None);
assert_eq!(
own_runtime_dir(Some(dir.path().join("none").as_os_str())),
None
);
assert_eq!(own_runtime_dir(None), None);
// Owned by root, not us.
if own_uid() != 0 {
assert_eq!(own_runtime_dir(Some("/".as_ref())), None);
}
}
#[test]
fn only_the_bus_in_our_runtime_folder_passes() {
let rt = Path::new("/run/user/1000");
assert_eq!(
own_bus(Some("unix:path=/run/user/1000/bus".as_ref()), rt).as_deref(),
Some("unix:path=/run/user/1000/bus")
);
assert!(own_bus(Some("unix:path=/run/user/1000/bus,guid=ab".as_ref()), rt).is_some());
for bad in [
"unix:path=/tmp/evil/bus",
"unix:path=/run/user/1000/bus2",
"unix:path=/run/user/1000/busx,guid=1",
"tcp:host=evil.example,port=1",
"unix:abstract=/run/user/1000/bus",
] {
assert_eq!(own_bus(Some(bad.as_ref()), rt), None, "{bad}");
}
assert_eq!(own_bus(None, rt), None);
}
#[test]
fn the_host_environment_carries_nothing_else() {
let env = host_env(Path::new("/home/someone"));
let names: Vec<&str> = env.iter().map(|(n, _)| n.as_str()).collect();
assert_eq!(&names[..2], ["PATH", "HOME"]);
for n in &names[2..] {
assert!(
["XDG_RUNTIME_DIR", "DBUS_SESSION_BUS_ADDRESS"].contains(n),
"{n}"
);
}
assert_eq!(env[0].1, PATH);
assert_eq!(env[1].1, "/home/someone");
}
#[test]
fn this_executable_is_a_file_on_disk() {
let exe = this_executable().unwrap();
assert!(exe.is_file(), "{exe:?}");
}
}