Docs

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,
    },
}