Windows steps 3 of D-62. Every `cfg(unix)` gate on the host, the bridge, the opener, the books server and the command line is gone: they build on every OS, and the Unix behaviour is unchanged (the existing over-the-wire suites pass on Linux). `ledger_mcp::ipc` is the one seam for the channels. Unix: the same 0600 sockets as before, peer uid unchanged (the peer functions moved here). Windows: a named pipe `\\.\pipe\wanderbooks-<hash of the root's run/>-<name>` made with a DACL of one allow entry for this account's SID, remote clients refused, the first instance made once (a second server is refused, as a held lock refuses it on Unix), and 16 idle instances kept waiting so a client never finds all busy while the server makes the next. Where Unix reads `SO_PEERCRED`, each end of a pipe takes the other's process id (`GetNamedPipeClientProcessId`/`...ServerProcessId`) and compares that process's token user with its own SID; any doubt is "not us". Clients open with SECURITY_IDENTIFICATION so a pipe server cannot act as the client. A blocking client with a read limit is polled with PeekNamedPipe (a pipe read cannot be cut short), and a pipe that has gone reads as the end of the stream, as a closed socket does. Stop signals are Ctrl+C, Ctrl+Break, console close and shutdown where Unix has SIGTERM, SIGINT and SIGHUP. The host starts detached (DETACHED_PROCESS | CREATE_NEW_PROCESS_GROUP, leaving the opener's job when the job lets it: an agent's job must not take the host down, as an app's systemd scope must not on Linux) with a fixed environment; `close_range` and `seccomp` stay Linux-only (D-32). A replaced program is told by size and last write. Git for Windows is found and a quiet git is stopped with its tree (`taskkill /T`). The test harness of ledger-cli finds its root by `default_root`, cleans a child's environment per OS and reaches the server over its pipe; the bridge's scripted-server tests stay Unix-only. No new dependency (tokio's named pipes and reqwest's `windows_named_pipe` were already in the build); `windows-sys` stays in ledger-store. Tests: ledger-mcp `ipc::tests` (four on every OS, one Windows only), ledger-host `tests/windows_pipe.rs::a_second_user_cannot_open_the_pipe`, Windows variants of the host and start tests. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HS8WZziDnyxunC23MUxrLp
514 lines
18 KiB
Rust
514 lines
18 KiB
Rust
//! 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 (cleanly, start.rs) 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())
|
|
}
|
|
}
|
|
|
|
/// Takes leases for one holder.
|
|
pub struct Opener {
|
|
root: Option<PathBuf>,
|
|
run: PathBuf,
|
|
knobs: Knobs,
|
|
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,
|
|
/// Flags the server this opener starts is started with (P9a):
|
|
/// `--isolation` and `--http`. Empty for every ordinary holder.
|
|
server_flags: Vec<String>,
|
|
}
|
|
|
|
/// 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<ledger_mcp::ipc::ConnectedRead>>>,
|
|
_write: ledger_mcp::ipc::ConnectedWrite,
|
|
}
|
|
|
|
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,
|
|
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,
|
|
kind,
|
|
book,
|
|
failures: 0,
|
|
opened_at: None,
|
|
has_held: false,
|
|
server_flags: Vec::new(),
|
|
})
|
|
}
|
|
|
|
/// Start the server this opener brings up with these flags (P9a:
|
|
/// `--isolation`, `--http`). Only `serve` uses it: on the owner's own
|
|
/// machine it is the one holder, so the host it starts is the server's
|
|
/// only one. A host already running was started with other flags, and
|
|
/// this one has no way to change it.
|
|
pub fn starting_with(mut self, flags: Vec<String>) -> Self {
|
|
self.server_flags = flags;
|
|
self
|
|
}
|
|
|
|
/// 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(())
|
|
}
|
|
|
|
/// Start counting failures over (a holder that waited a while after
|
|
/// giving up tries again); what it has held is kept (INVARIANT 5).
|
|
pub fn forget_failures(&mut self) {
|
|
self.failures = 0;
|
|
}
|
|
|
|
/// 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 endpoint = ledger_mcp::ipc::Endpoint::new(&self.run, lease::LEASE_SOCKET)
|
|
.map_err(|e| format!("can't reach the books' host: {e}"))?;
|
|
match ledger_mcp::ipc::connect(&endpoint).await {
|
|
Ok(stream) => {
|
|
if !ledger_mcp::ipc::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) 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, cleanly (start.rs).
|
|
async fn start(&self) -> Result<(), String> {
|
|
let root = self.root.clone();
|
|
let knobs = self.knobs;
|
|
let flags = self.server_flags.clone();
|
|
tokio::task::spawn_blocking(move || {
|
|
crate::start::start_host(root.as_deref(), &knobs, &flags)
|
|
})
|
|
.await
|
|
.map_err(|e| e.to_string())?
|
|
}
|
|
|
|
/// Say hello on a connection and read the host's first word.
|
|
async fn ask(&self, stream: ledger_mcp::ipc::Connected) -> Result<Asked, String> {
|
|
let (read, mut write) = ledger_mcp::ipc::split_connected(stream);
|
|
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 endpoint = ledger_mcp::ipc::Endpoint::new(run, lease::LEASE_SOCKET)
|
|
.map_err(|e| format!("can't reach the books' host: {e}"))?;
|
|
let stream = match ledger_mcp::ipc::connect_std(&endpoint) {
|
|
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::ipc::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()
|
|
},
|
|
kind: Kind::Cli,
|
|
book: None,
|
|
failures: 0,
|
|
opened_at: None,
|
|
has_held: false,
|
|
server_flags: Vec::new(),
|
|
};
|
|
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);
|
|
}
|
|
}
|