mirror
Source: crates/lvcs-ws/src/mirror/mod.rs.
/// Path of a file relative to the mirror root, `/`-separated.
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
#[serde(transparent)]
pub struct WsPath(pub String);
/// SHA-256 of a file's bytes (`lvcs_vocab::ids::HASH`), computed by the
/// shell. Serializes as a 32-byte byte string.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct ContentHash(#[serde(with = "cbor::fixed")] pub [u8; 32]);
/// What the shell found at a path.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum DiskState {
/// A file, as the shell read it.
Present {
/// Its bytes.
#[serde(with = "cbor::bytes")]
content: Vec<u8>,
/// SHA-256 of `content`.
hash: ContentHash,
/// Its executable bit.
executable: bool,
},
/// No file (removed, or never written).
Absent,
}
/// A `notify` change, a rescan after restart, or a refused
/// [`MirrorOutput::WriteFile`] or [`MirrorOutput::RemoveFile`], with what
/// the shell read.
///
/// Paths under `.lvcs/`, never-sync paths, and symbolic links never arrive.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct FsChanged {
/// The file's path.
pub path: WsPath,
/// What the shell read there.
pub state: DiskState,
/// The `Origin` of every `DiskEdit` this change produces, filled in by
/// the shell: the daemon's default participant (decision 64) and the
/// Unix time in milliseconds when it read the file.
pub origin: Origin,
/// The `heads` of the `WriteFile` or `RemoveFile` this change answers
/// when the shell refused it because the file's hash wasn't its
/// `expect`, and `None` for a `notify` change or a rescan (decision
/// 75). Serializes as null or an array of 32-byte byte strings.
#[serde(with = "cbor::opt_heads")]
pub refused: Option<Vec<lvcs_doc::ChangeHash>>,
}
/// The shell finished a [`MirrorOutput::WriteFile`] or
/// [`MirrorOutput::RemoveFile`]; advances the disk base. After a removal
/// `hash` is `None` and `executable` is false.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct WriteDone {
/// The file's path.
pub path: WsPath,
/// SHA-256 of what was written; `None` after a removal.
pub hash: Option<ContentHash>,
/// The executable bit written; false after a removal.
pub executable: bool,
/// Heads of the document version that was written.
#[serde(with = "cbor::heads")]
pub heads: Vec<lvcs_doc::ChangeHash>,
}
/// A document's `DocOutput::Rendered`, addressed to the mirror by the shell,
/// which also hashes the rendered bytes (`None` when the body is deleted).
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct DocRendered {
/// The file's path.
pub path: WsPath,
/// The rendered version's heads.
#[serde(with = "cbor::heads")]
pub heads: Vec<lvcs_doc::ChangeHash>,
/// The rendered body.
pub body: FileBody,
/// SHA-256 of the rendered bytes; `None` for a deleted body.
pub hash: Option<ContentHash>,
/// The rendered executable bit.
pub executable: bool,
/// `DocOutput::Rendered.after`: the `stamp` of this mirror's `DiskEdit`
/// whose apply produced the render, `None` for any other render
/// (decision 75).
pub after: Option<u64>,
}
/// The shell restarted this mirror: the first event of every incarnation
/// after the first, once the actor is rebuilt from its journal (design §2).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub struct Restarted;
/// A hook read the file at `path`, and the bytes it read hash to `hash`
/// (`track_read`, decision 117): the exact base of the participant's next
/// save of the file.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Loaded {
/// The file the hook read.
pub path: WsPath,
/// SHA-256 of the bytes it read.
pub hash: ContentHash,
}
/// A hook's tool is about to edit the file at `path` (`track_edit`,
/// decision 117): which version does it edit?
///
/// Answered with exactly one [`MirrorOutput::Located`] echoing `req`, the
/// shell's id for the request.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Locate {
/// The shell's id for the request, which the answer echoes.
pub req: RequestId,
/// The file the tool edits.
pub path: WsPath,
}
/// The protected-path gate held a render of the file at `path` (decision
/// 122): the document's `heads` and the render's `after`, which the
/// shell sends in place of the [`DocRendered`] it held.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Held {
/// The file whose render was held.
pub path: WsPath,
/// The document's heads in the held render.
#[serde(with = "cbor::heads")]
pub heads: Vec<lvcs_doc::ChangeHash>,
/// The held render's `after`.
pub after: Option<u64>,
}
/// The shell could not run a [`MirrorOutput::WriteFile`] because another
/// process holds the file open (Windows: reading or replacing it failed
/// with a sharing violation, raw OS error 32, or access denied, 5; the
/// held-rename row refining decision 252): the write's own payload, so the
/// mirror can write it again, and the shell's time. Never sent elsewhere.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct WriteHeld {
/// The file the write was for.
pub path: WsPath,
/// The bytes it would have written.
#[serde(with = "cbor::bytes")]
pub content: Vec<u8>,
/// SHA-256 of `content`.
pub hash: ContentHash,
/// The executable bit it would have set.
pub executable: bool,
/// The heads of the document version it would have written.
#[serde(with = "cbor::heads")]
pub heads: Vec<lvcs_doc::ChangeHash>,
/// The shell's Unix time in milliseconds when the write was held.
pub now_ms: i64,
}
/// The time a [`MirrorOutput::Wake`] for `path` named came: the shell's
/// clock reached it (decision 161's wall-clock loop).
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Tick {
/// The file the wake was for.
pub path: WsPath,
/// The wake's `at_ms`, which ties the tick to the wake it answers.
pub at_ms: i64,
/// The shell's Unix time in milliseconds when it sent the tick.
pub now_ms: i64,
}
/// The mirror actor's events, generated by its sml table: its mailbox and
/// journal type (decision 286).
///
/// The rules below rely on these obligations of the shell (`M1.daemon`):
/// - Only syncable paths arrive: never `.lvcs/`, never-sync paths, or
/// symbolic links. The mirror never filters.
/// - Each `Fs` event carries what the shell read, its hash, and the
/// `Origin` the shell built (decision 75). Each `DocOutput::Rendered` of a
/// mirrored file's document arrives, in the order the document emitted
/// them, as a `Doc` event with the rendered bytes' hash, or for a
/// protected path as what `protect::gate` decides (decision 122): the
/// render, a `Doc` render at the path's approved heads carrying the
/// gated render's `after`, or a `Held` carrying it. When approved heads
/// move, the shell re-gates the path's latest render, and that `Doc` or
/// `Held` carries no `after`, since the render's `after` already arrived.
/// Each render's `after` arrives exactly once, on a `Doc` or a `Held`, and
/// each `DiskEdit` reaches the document in the order the mirror emitted
/// them.
/// - A file's writes and removals run in the order the mirror emitted
/// them. Each first compares the file's hash with its `expect` and is
/// answered by exactly one `Written` or, when it is refused, one `Fs`
/// event with `refused` set to its heads (decision 75), or, for a
/// `WriteFile` on Windows that another process holds the file open
/// against, one `WriteHeld` carrying the write.
/// - Each `Wake` is answered by one `Tick` for its path, carrying a time
/// no earlier than its `at_ms`.
/// - A write's `Written` arrives before any `Fs` event the shell reads after
/// that write lands (decision 78).
/// - After a restart, `Restarted` is the first event, and the shell answers
/// the `Rescan`s before any document re-renders to the mirror (below).
/// - A `Loaded` carries the SHA-256 of the bytes a hook read from the file
/// (decision 117).
///
/// The document actor (`M1.doc`) answers each `DiskEdit` with exactly one
/// `Rendered` whose `after` is its stamp, whether the edit committed,
/// changed nothing, or faulted (decision 78).
///
/// Rules the actor keeps per file (design §8, decisions 63, 75, and 78). The
/// file's disk state is `(hash, executable)`, or absent, as last reported by
/// an `Fs` event or a `Written` confirmation, or as reset by `Restarted`; a
/// file never reported is absent.
/// - An `Fs` event that is no refusal and equals the file's disk state
/// changes nothing at all, not even the echo ledger (decision 236): a
/// repeated read of a landed write, or of a save already reported.
/// - The echo ledger holds the `(hash, executable)` pairs (`None` hash for a
/// removal) of writes the actor has emitted, oldest first, until their
/// `Written` arrives (below). An `Fs` event matching an entry is an echo,
/// unless it is a refusal that reports (below): the newest matching
/// entry and every older entry are dropped, since `notify` may coalesce
/// several writes into one event.
/// - Any other `Fs` event is reported to the document actor as the
/// `DiskEdit`s in which it differs from the disk state, all with the
/// event's `origin`: `Text` (valid UTF-8; its candidates, newest first,
/// are the heads of the file's in-flight writes, then of its unconfirmed
/// writes, then the ring's, leaving out every version that puts no file
/// on disk, such as a removal or the genesis render of a file opened
/// before it existed (design §8), and the document puts the author's
/// latest change ahead of them, decision 75) or `Binary` when the content
/// changed, `Removed` when the file is gone, and `Executable` when the bit
/// changed on a present file.
/// - `Binary`, `Executable`, and `Removed` carry as `base` the heads of the
/// file's newest disk base, the version the author last saw on disk, or
/// no heads when it has none, which the document reads as the genesis
/// heads (decision 78). No in-flight write is that base: the shell
/// delivers a write's `Written` before any `Fs` event it reads after the
/// write lands (decision 78), so an `Fs` event handled while the write is
/// in flight was read before it landed. Nor is an unconfirmed write,
/// which may never have landed.
/// - An `Fs` event with `refused` is a refusal, classified before the echo
/// test (decision 78). It ends the oldest in-flight write with those
/// heads, which never landed. If the file holds exactly what that write
/// would have put there, its version becomes a disk base as if it had
/// landed, and the event goes on to the echo test like any other `Fs`
/// event, where the write's own echo-ledger entry, while it remains,
/// makes it an echo. Any other refusal,
/// also one naming no in-flight write, drops one echo-ledger entry equal
/// to what its write would have put on disk. When a save of the file is
/// pending and the shell found exactly the file's disk state, that save
/// already reported what the disk holds (the watcher's read of it came
/// first), so the refusal reports nothing: the save's render decides
/// what the file holds and is written over the disk unless the two are
/// equal, and reporting the same save again would have the document
/// refuse a stale save twice (decision 298). Otherwise it reports its
/// content (`Text`, `Binary`, or `Removed`) even when it equals the disk
/// state or an echo-ledger entry, plus `Executable` when the bit changed,
/// so the document decides what the file should hold (decision 75) and
/// what the shell found is never taken for an earlier write's echo. Its
/// next render is written over what the shell found unless the two are
/// equal.
/// A write chained on the refused one expected its hash, so the shell
/// refuses that write too.
/// - Each `DiskEdit` gets the mirror's next `stamp`, one more than the
/// last, so stamps increase per mirror across all files and incarnations
/// (decision 75); an `Fs` event reporting both content and the bit emits
/// two `DiskEdit`s with consecutive stamps. The stamp joins the file's
/// pending saves (oldest first) until a render echoes it as `after`.
/// - Each disk base is `(heads, hash, executable)`; the ring keeps the last
/// `DISK_BASE_RING` distinct versions, and every `Written` confirmation
/// makes its version the newest (one already in the ring moves to the
/// front).
/// - A `Written` sets the disk state to what its write put on disk: the
/// shell delivers it before any `Fs` event it reads after the write lands
/// (decision 78), so every `Fs` event handled before it read the file
/// before the write landed, and none saw anything newer. This holds even
/// when such an event already dropped the write's echo-ledger entry, as an
/// older write's echo with the same content does. A `Written` also drops
/// the newest echo-ledger entry equal to what its write put on disk and
/// every older one (decision 236): every read after the write lands comes
/// after it, so a read equal to that state is the disk state, and one
/// that differs is a save, even one that restores those exact bytes after
/// an overwrite the watcher read first, which the entry would have taken
/// for an echo. Entries outlive their writes' `Written` only for writes
/// in flight at a `Restarted`, whose `Written` never arrives (below).
/// - A write or removal the actor emits is in flight until a `Written` or a
/// refusal with its heads arrives; each ends the oldest such write.
/// - A `Doc` render first ends the pending save its `after` names: every
/// `DiskEdit` gets exactly one render with its stamp as `after` (decision
/// 78), and renders arrive in the order the document emitted them, so
/// every earlier save's render came first. While a save is still pending
/// the render is not written and changes nothing else (decision 75): a
/// render with `after` answers an earlier save, and one with no `after`
/// is a version the document produced before it applied the pending
/// save, so a render of an earlier version never overwrites a newer save
/// on disk.
/// - With no write in flight, a `Doc` render whose hash and executable bit
/// equal the file's disk state (for example, right after a save the
/// document just applied) is not written: its version becomes the newest
/// disk base, so the next save is diffed against the saved text and never
/// re-applied. While a write is in flight the disk state is about to
/// change, so a render equal to it (a revert, say) is written like any
/// other.
/// - Any other `Doc` render is written (`RemoveFile` when its body is
/// deleted), even one at the newest disk base's heads while the disk holds
/// something else, as after a `DiskEdit` that made no commit (decision
/// 75): its `(hash, executable)` joins the echo ledger and the write is in
/// flight. A `WriteFile` or `RemoveFile` expects what the newest
/// in-flight write puts on disk (its hash, or no file for a removal), or
/// the disk state when none is in flight. A render with no hash for a
/// text or binary body is malformed and ignored.
/// - `Restarted`: the earlier incarnation's effects may not have run and its
/// watcher's events never arrive. For every tracked file the disk state is
/// reset to its newest disk base (absent when it has none), the echo
/// ledger is replaced by the `(hash, executable)` of its in-flight writes,
/// oldest first, those writes become unconfirmed writes (after any an
/// earlier restart left), pending saves are forgotten (the earlier
/// incarnation's `DiskEdit`s may never have reached the document, so their
/// renders may never arrive, and the re-render below has no `after`), and
/// a `Rescan` carrying that base is emitted. With a new watcher, the shell
/// then sends `Fs` for each tracked file whose `(hash, executable)`
/// differs from its `Rescan` and for each file on disk that got no
/// `Rescan`. Only after that rescan does every document re-render to the
/// mirror, so an edit made on disk while the mirror was down is reported
/// before a render could overwrite it.
/// - An unconfirmed write may have landed and been loaded by an editor, so
/// its heads stay candidates (after the in-flight writes', ahead of the
/// ring's, decision 75) until a disk base is pushed, which drops every
/// unconfirmed write. An echo of what an unconfirmed write puts on disk
/// makes it and every older unconfirmed write disk bases, oldest first,
/// as their `Written`s would have, and so drops the newer ones.
/// - So after `Restarted`, an in-flight write that landed before the crash
/// comes back in the rescan as an echo, even if its `notify` had already
/// drained it; one that never landed is written again by the re-render,
/// whose `Written` drains both entries. A confirmed write's entry is
/// dropped: the `Rescan` covers it, and its content can reappear only
/// through an edit. An entry outlives the rescan only when its version
/// never renders again, the one false-echo risk the ledger still takes.
/// - A save the document applied before the crash, whose render the mirror
/// never handled, is reported again by the rescan. Decision 75 absorbs
/// it: for a text save the document puts the heads of the author's
/// latest change first among the candidates, and a `DiskEdit` that
/// changes nothing relative to its chosen base (equal text, bytes, or
/// bit, or `Removed` on a deleted file) makes no commit. A refusal's
/// report of content the document already has at one of its candidates
/// is absorbed the same way.
/// - A `Loaded` looks its hash up among the file's text candidates, in
/// their order (its in-flight writes, newest first, then its unconfirmed
/// writes, then the ring's, leaving out every version that puts no file
/// on disk), and the first match's heads become the file's loaded heads
/// (decision 117). With no match, nothing is recorded and the file keeps
/// any loaded heads it had; an untracked file has no candidates and
/// stays untracked. The file's next `Text` report takes its loaded
/// heads, which `DiskChange::Text` carries to the document as `loaded`.
/// - A `Locate` changes nothing and is answered with one `Located`: the
/// file's loaded heads, else the heads of its newest disk base, else
/// none (an untracked file, or one no write or render has confirmed).
/// It leaves the loaded heads in place for the file's next `Text`
/// report (decision 117).
/// - A `Held` (decision 122) settles pending saves as a `Doc` render does,
/// ending the one its `after` names, and changes nothing else: it writes
/// nothing and doesn't advance the disk base, so the file keeps what it
/// holds. A release is no new event: the shell sends a `Doc` render at
/// the approved heads with the gated render's `after`, never `None`
/// in its place, so the rules above end the save it answers and write the
/// approved version over a save that committed nothing or faulted
/// (decision 78); with `None` that save would stay pending and every
/// later render of the file would be dropped. A re-gated `Doc` or `Held`
/// carries `None`.
/// - A `WriteHeld` (Windows only, the held-rename row refining decision
/// 252) ends its write as a refusal does, which never landed, without
/// reporting anything: the disk keeps what the mirror last knew. When a
/// newer write of the file is in flight, which supersedes it (and which
/// the shell refuses, since it expected the held write's hash), that is
/// all. Otherwise the file's held write is that write, with the time it was first held: a
/// `WriteHeld` of the held write's heads keeps that time, and any other
/// starts it at the event's `now_ms`. Unless a wake for the path is
/// pending, it emits `Wake` at `now_ms + HELD_RETRY_MS` and records that
/// time as the pending wake; so at most one wake per path is pending.
/// - A `Tick` whose `at_ms` is not the path's pending wake's is stale and
/// changes nothing. Any other ends the pending wake. With no held write
/// it does nothing more. While a save of the file is pending, the mirror
/// forgets the held write without a report: the save's render always
/// comes and writes the merged version, so the held version must never
/// overwrite the save. Otherwise, once `now_ms` reaches the held write's
/// first time plus `HELD_DEADLINE_MS`, the mirror stops: it forgets the
/// held write, marks the path reported, and emits `HeldReport`. Before
/// that, it writes the held write again, expecting what the newest
/// in-flight write puts on disk or the disk state, as a new write in
/// flight.
/// - A `Written` for the path forgets its held write, and so does any
/// render that is well formed and answers no pending save, which
/// supersedes it; so does `Restarted`, after which the re-render writes
/// again, and which also clears the reported mark.
/// - A reported path stays reported until a write of it lands or a render
/// finds the disk already holding it (the disk base advances), either of
/// which clears the mark and emits `HeldCleared`.
///
/// Its events, each the payload type its variant is named for:
/// - [`FsChanged`]: The shell read a file.
/// - [`WriteDone`]: The shell finished a write or removal.
/// - [`DocRendered`]: A document rendered the file.
/// - [`Restarted`]: The shell restarted the mirror.
/// - [`Loaded`]: A hook read a file.
/// - [`Locate`]: A hook asks which version of a file its tool edits.
/// - [`Held`]: The protected-path gate held a render.
/// - [`WriteHeld`]: Another process held a file open against a write (Windows).
/// - [`Tick`]: A wake the mirror asked for came.
pub use machine::{MirrorSyncEvents, UNCHANGED_GUARD};
/// Outputs of the mirror actor.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum MirrorOutput {
/// Write `content` to `path` with the executable bit; the shell answers
/// with [`WriteDone`]. `expect` is the content hash the mirror believes
/// the file has when the shell runs this write (`None`: no file). The
/// shell writes only if the file on disk has that hash; otherwise it
/// leaves the file alone and answers with an [`FsChanged`] reporting
/// what it found, `refused` set to `heads`, instead of [`WriteDone`], so
/// the mirror never overwrites a save it hasn't seen (decision 75).
WriteFile {
/// The file to write.
path: WsPath,
/// The bytes to write.
content: Vec<u8>,
/// SHA-256 of `content`.
hash: ContentHash,
/// The executable bit to set.
executable: bool,
/// The heads of the document version written.
heads: Vec<lvcs_doc::ChangeHash>,
/// The content hash the file must have for the write to run;
/// `None`: no file.
expect: Option<ContentHash>,
},
/// Remove `path`. `expect` works as for `WriteFile`: when the file has
/// hash `expect`, or for `None` when there is no file, the shell removes
/// it (nothing to do when absent) and answers with [`WriteDone`] with no
/// hash; otherwise it leaves the file alone and answers with an
/// [`FsChanged`], `refused` set to `heads`, so a removal never deletes a
/// save the mirror hasn't seen (decision 75).
RemoveFile {
/// The file to remove.
path: WsPath,
/// The heads of the document version removed, whose body is
/// deleted.
heads: Vec<lvcs_doc::ChangeHash>,
/// The content hash the file must have for the removal to run;
/// `None`: no file.
expect: Option<ContentHash>,
},
/// A disk change that is not an echo, for the document actor at `path`.
DiskEdit {
/// The document's path.
path: WsPath,
/// The change.
edit: DiskEdit,
},
/// The answer to [`Locate`] `req`: the version of the file at `path` a
/// hook's tool edits (decision 117), the loaded heads or the newest
/// disk base's, or `None` when the mirror knows neither.
Located {
/// The [`Locate`] request answered.
req: RequestId,
/// The file located.
path: WsPath,
/// The version the tool edits; `None` when the mirror knows none.
heads: Option<Vec<lvcs_doc::ChangeHash>>,
},
/// Answers [`Restarted`] for one tracked file with its newest disk base:
/// `hash` is `None` (and `executable` false) when the file should be
/// absent. The shell reads `path` and sends `Fs` if it differs.
Rescan {
/// The tracked file.
path: WsPath,
/// Its newest disk base's content hash; `None` when it should be
/// absent.
hash: Option<ContentHash>,
/// Its newest disk base's executable bit.
executable: bool,
},
/// Answer with a [`Tick`] for `path` once the shell's clock reaches
/// `at_ms`: the time to write a held file again.
Wake {
/// The held file.
path: WsPath,
/// When to tick, Unix milliseconds.
at_ms: i64,
},
/// The mirror stopped writing `path` after `HELD_DEADLINE_MS`: another
/// process kept it open, so the file on disk is older than its
/// document at `heads` (`write_held`).
HeldReport {
/// The held file.
path: WsPath,
/// The heads of the version the mirror could not write.
heads: Vec<lvcs_doc::ChangeHash>,
},
/// A path a [`MirrorOutput::HeldReport`] named is no longer behind its
/// document: a write of it landed, or the disk already holds its
/// render.
HeldCleared {
/// The file.
path: WsPath,
},
}