OXYGENHUB

Crash safety: durable writes and the op head as commit point 4dcb8c0916b7

Every durable update now goes through oxy_core::durable: a uniquely
named temporary file, fsync, rename, fsync of the directory. Loose
objects, packs (pack before index), signature and seal indexes use it.

Fixes a crash window found by the new test: a command inside a write
batch moved .oxy/ops/head before the batch's objects, including the new
operation, were on disk, so a crash there left a repository that would
not open. Store::flush now makes pending objects durable before the
head moves.

A test stops commit (20 durable steps) and push (32) at every step and
requires both repositories to open, pass fsck and accept the command
again. Spec Draft 0.8h adds §13.6.
Author
Andy McCall @andymccall <[email protected]>
When
· 2026-09-27 23:52 UTC
Signature
✔ signed by Andy McCall
Hash
4dcb8c0916b7cd6536097350a735aaf3354262451d2695e8454478c5dd8e8e20
Depends on
1983686fb7a7 387335ff7f4d 5b9c6ad45f6e 75167672ebcd 8f10a07a1dbe 982864068a59 ff0af1ee299d
Depended on by
ef7d4ea6f33b 8b3c9b2ba502 a39754d02c58 648189388f1a 236f35c0baba b30a880fa409 ff1f62fee654 314db9b4f3f8 fc1a466a1e66 0bdc3f91a39f 19555025a2ac
In bookmarks
agents-in-readme bare-hashes carried-not-removed carry-identical doctor-trust identical-sides keyless-tags main merge-remote-bookmark only-help-103 paths-for-resolutions-and-amend push-notes show-stat subrepo-pin-state switch-says
In tags
v0.1.0 v0.10.0 v0.11.0 v0.2.0 v0.3.0 v0.3.1 v0.4.0 v0.4.1 v0.4.2 v0.4.3 v0.4.4 v0.4.5 v0.4.6 v0.5.0 v0.5.1 v0.6.0 v0.7.0 v0.8.0 v0.9.0
Edits
17 atoms, 1 tree operation, 6 files

Browse the tree at this change

added crates/oxy-core/src/durable.rs +129 −0
1 +
//! Durable file updates (spec §13.6): every write that must survive a
2 +
//! crash goes through here.
3 +
//!
4 +
//! A file is replaced by writing a uniquely named temporary file in the
5 +
//! same directory, syncing it, renaming it over the target, and syncing
6 +
//! the directory so the rename itself is on disk. A crash at any point
7 +
//! leaves either the old file or the new one, never a torn one; at worst
8 +
//! a stray temporary file, which nothing reads.
9 +
//!
10 +
//! In tests, `crash_after(n)` makes the n-th durable step on the current
11 +
//! thread fail as if the process had died there, so a test can stop an
12 +
//! operation at every step and check what is left behind.
13 +
14 +
use rand::RngCore;
15 +
use std::fs;
16 +
use std::io::{self, Write};
17 +
use std::path::Path;
18 +
19 +
/// Replace `path` with `data`, atomically and durably.
20 +
pub fn write_atomic(path: &Path, data: &[u8]) -> io::Result<()> {
21 +
    let dir = path.parent().unwrap_or(Path::new("."));
22 +
    let name = path
23 +
        .file_name()
24 +
        .map(|n| n.to_string_lossy().into_owned())
25 +
        .unwrap_or_default();
26 +
    let mut nonce = [0u8; 6];
27 +
    rand::rngs::OsRng.fill_bytes(&mut nonce);
28 +
    let tmp = dir.join(format!(".{name}.tmp-{}", hex::encode(nonce)));
29 +
    step("create temporary file")?;
30 +
    let written = (|| {
31 +
        let mut f = fs::File::create(&tmp)?;
32 +
        f.write_all(data)?;
33 +
        f.sync_all()?;
34 +
        step("rename into place")?;
35 +
        fs::rename(&tmp, path)
36 +
    })();
37 +
    if let Err(e) = written {
38 +
        if !is_injected(&e) {
39 +
            let _ = fs::remove_file(&tmp);
40 +
        }
41 +
        return Err(e);
42 +
    }
43 +
    sync_dir(dir)
44 +
}
45 +
46 +
/// Rename `from` to `to` and make the rename durable.
47 +
pub fn rename(from: &Path, to: &Path) -> io::Result<()> {
48 +
    step("rename")?;
49 +
    fs::rename(from, to)?;
50 +
    sync_dir(to.parent().unwrap_or(Path::new(".")))
51 +
}
52 +
53 +
/// Flush a directory's entries (new, renamed or removed files) to disk.
54 +
/// A no-op where directories cannot be opened for syncing.
55 +
pub fn sync_dir(dir: &Path) -> io::Result<()> {
56 +
    #[cfg(unix)]
57 +
    {
58 +
        fs::File::open(dir)?.sync_all()
59 +
    }
60 +
    #[cfg(not(unix))]
61 +
    {
62 +
        let _ = dir;
63 +
        Ok(())
64 +
    }
65 +
}
66 +
67 +
/// A point where a crash may happen. Outside tests it does nothing.
68 +
pub fn step(what: &str) -> io::Result<()> {
69 +
    #[cfg(test)]
70 +
    {
71 +
        failpoint::hit(what)
72 +
    }
73 +
    #[cfg(not(test))]
74 +
    {
75 +
        let _ = what;
76 +
        Ok(())
77 +
    }
78 +
}
79 +
80 +
const INJECTED: &str = "injected crash";
81 +
82 +
fn is_injected(e: &io::Error) -> bool {
83 +
    e.to_string().starts_with(INJECTED)
84 +
}
85 +
86 +
#[cfg(test)]
87 +
pub(crate) mod failpoint {
88 +
    use std::cell::Cell;
89 +
    use std::io;
90 +
91 +
    thread_local! {
92 +
        static COUNTDOWN: Cell<Option<usize>> = const { Cell::new(None) };
93 +
        static FIRED: Cell<bool> = const { Cell::new(false) };
94 +
    }
95 +
96 +
    /// Fail the `n`-th durable step from now (1-based) on this thread,
97 +
    /// and every step after it, as a dead process would.
98 +
    pub fn crash_after(n: usize) {
99 +
        COUNTDOWN.with(|c| c.set(Some(n)));
100 +
        FIRED.with(|f| f.set(false));
101 +
    }
102 +
103 +
    /// Stop injecting; returns whether a crash was injected.
104 +
    pub fn disarm() -> bool {
105 +
        COUNTDOWN.with(|c| c.set(None));
106 +
        FIRED.with(|f| f.replace(false))
107 +
    }
108 +
109 +
    pub(super) fn hit(what: &str) -> io::Result<()> {
110 +
        if FIRED.with(|f| f.get()) {
111 +
            return Err(io::Error::other(format!(
112 +
                "{} (after) at {what}",
113 +
                super::INJECTED
114 +
            )));
115 +
        }
116 +
        COUNTDOWN.with(|c| match c.get() {
117 +
            Some(1) => {
118 +
                c.set(None);
119 +
                FIRED.with(|f| f.set(true));
120 +
                Err(io::Error::other(format!("{} at {what}", super::INJECTED)))
121 +
            }
122 +
            Some(n) => {
123 +
                c.set(Some(n - 1));
124 +
                Ok(())
125 +
            }
126 +
            None => Ok(()),
127 +
        })
128 +
    }
129 +
}
modified crates/oxy-core/src/lib.rs +1 −0
…
6 unchanged lines
7 7
pub mod cbor;
8 8
pub mod chunk;
9 9
pub mod diff;
10 +
pub mod durable;
10 11
pub mod graph;
11 12
pub mod hash;
12 13
pub mod identity;
…
11 unchanged lines
modified crates/oxy-core/src/repo/mod.rs +3 −8
…
102 unchanged lines
103 103
pub use workspace::{SnapshotLock, WorkspaceInfo};
104 104
105 105
fn write_atomic(path: &Path, data: &[u8]) -> Result<()> {
106 -
    let tmp = path.with_extension("tmp");
107 -
    {
108 -
        let mut f = fs::File::create(&tmp)?;
109 -
        f.write_all(data)?;
110 -
        f.sync_all()?;
111 -
    }
112 -
    fs::rename(&tmp, path)?;
113 -
    Ok(())
106 +
    Ok(crate::durable::write_atomic(path, data)?)
114 107
}
115 108
116 109
pub struct LogEntry {
…
447 unchanged lines
564 557
            view,
565 558
        };
566 559
        let r = self.store.put(ObjectType::Op, &op.encode())?;
560 +
        // The head is the commit point: everything it names must be on disk first.
561 +
        self.store.flush()?;
567 562
        write_atomic(&self.oxy.join("ops").join("head"), r.to_hex().as_bytes())?;
568 563
        Ok(r)
569 564
    }
…
656 unchanged lines
modified crates/oxy-core/src/repo/tests.rs +117 −0
…
3971 unchanged lines
3972 3972
    fs::write(&head, saved).unwrap();
3973 3973
    assert_eq!(a.fsck().unwrap().problems, vec![]);
3974 3974
}
3975 +
3976 +
/// Copy a directory tree (for restoring a pristine repository).
3977 +
fn copy_tree(from: &Path, to: &Path) {
3978 +
    fs::create_dir_all(to).unwrap();
3979 +
    for e in fs::read_dir(from).unwrap() {
3980 +
        let e = e.unwrap();
3981 +
        let target = to.join(e.file_name());
3982 +
        if e.file_type().unwrap().is_dir() {
3983 +
            copy_tree(&e.path(), &target);
3984 +
        } else {
3985 +
            fs::copy(e.path(), &target).unwrap();
3986 +
        }
3987 +
    }
3988 +
}
3989 +
3990 +
/// Spec §13.6: stop `commit` and `push` at every durable step, as a crash
3991 +
/// would, and check that both repositories still open, pass `fsck`, and
3992 +
/// accept the same operation again.
3993 +
#[test]
3994 +
fn crash_at_every_step_of_commit_and_push_leaves_sound_repositories() {
3995 +
    use crate::durable::failpoint;
3996 +
    hermetic();
3997 +
    let dir = tempfile::tempdir().unwrap();
3998 +
    let pristine = dir.path().join("pristine");
3999 +
    let origin_path = pristine.join("origin");
4000 +
    Repo::init(&origin_path).unwrap();
4001 +
    let mut a = Repo::clone(&origin_path.to_string_lossy(), &pristine.join("a"))
4002 +
        .unwrap()
4003 +
        .with_home(dir.path().join("home"));
4004 +
    a.create_identity("Ada", "[email protected]", IdentityScope::Repo)
4005 +
        .unwrap();
4006 +
    write(&pristine.join("a"), "f.txt", b"one\n");
4007 +
    a.commit("first", &[]).unwrap();
4008 +
    assert!(a.push("origin", false).unwrap()[0].ok);
4009 +
    drop(a);
4010 +
4011 +
    type Op = fn(&Repo) -> Result<()>;
4012 +
    let edit_and_commit: Op = |r| {
4013 +
        write(r.root(), "f.txt", b"one\ntwo\n");
4014 +
        write(r.root(), "big.bin", &vec![7u8; 300_000]);
4015 +
        r.commit("second", &[]).map(|_| ())
4016 +
    };
4017 +
    let push: Op = |r| {
4018 +
        let res = r.push("origin", false)?;
4019 +
        if res.iter().all(|x| x.ok) {
4020 +
            Ok(())
4021 +
        } else {
4022 +
            Err(RepoError::Msg(format!("{res:?}")))
4023 +
        }
4024 +
    };
4025 +
4026 +
    for (name, prepare, op) in [
4027 +
        ("commit", None, edit_and_commit),
4028 +
        ("push", Some(edit_and_commit), push),
4029 +
    ] {
4030 +
        let mut steps = 0;
4031 +
        for n in 1.. {
4032 +
            let run = dir.path().join(format!("{name}-{n}"));
4033 +
            copy_tree(&pristine, &run);
4034 +
            // Clones record their remote by path; point it at this copy.
4035 +
            let open = |p: &Path| {
4036 +
                let mut r = Repo::open(p).unwrap().with_home(dir.path().join("home"));
4037 +
                r.config
4038 +
                    .set("remote.origin.url", &run.join("origin").to_string_lossy())
4039 +
                    .unwrap();
4040 +
                r.save_config().unwrap();
4041 +
                r
4042 +
            };
4043 +
            let a = open(&run.join("a"));
4044 +
            if let Some(prep) = prepare {
4045 +
                prep(&a).unwrap();
4046 +
            }
4047 +
            failpoint::crash_after(n);
4048 +
            let outcome = op(&a);
4049 +
            let crashed = failpoint::disarm();
4050 +
            drop(a);
4051 +
            if !crashed {
4052 +
                outcome.unwrap_or_else(|e| panic!("{name} failed without a crash: {e}"));
4053 +
                steps = n - 1;
4054 +
                break;
4055 +
            }
4056 +
            assert!(
4057 +
                outcome.is_err(),
4058 +
                "{name}: a crash at step {n} was swallowed"
4059 +
            );
4060 +
            for repo in ["a", "origin"] {
4061 +
                let r = Repo::open(&run.join(repo)).unwrap_or_else(|e| {
4062 +
                    panic!("{name} crash at step {n}: {repo} no longer opens: {e}")
4063 +
                });
4064 +
                let rep = r.fsck().unwrap();
4065 +
                assert_eq!(
4066 +
                    rep.errors(),
4067 +
                    0,
4068 +
                    "{name} crash at step {n}: {repo} fails fsck: {:?}",
4069 +
                    rep.problems
4070 +
                );
4071 +
            }
4072 +
            // The interrupted operation can simply be run again.
4073 +
            let a = open(&run.join("a"));
4074 +
            // A crash after the commit point leaves the commit done.
4075 +
            let landed = a
4076 +
                .bookmark_heads()
4077 +
                .unwrap()
4078 +
                .iter()
4079 +
                .any(|h| a.store().get_change(h).unwrap().message == "second");
4080 +
            let again = match (name, landed) {
4081 +
                ("commit", true) => Ok(()),
4082 +
                ("commit", false) => a.commit("second", &[]).map(|_| ()),
4083 +
                _ => op(&a),
4084 +
            };
4085 +
            again.unwrap_or_else(|e| panic!("{name} crash at step {n}: retry failed: {e}"));
4086 +
            assert_eq!(a.fsck().unwrap().errors(), 0);
4087 +
            fs::remove_dir_all(&run).unwrap();
4088 +
        }
4089 +
        assert!(steps >= 3, "{name} has only {steps} durable steps?");
4090 +
    }
4091 +
}
modified crates/oxy-core/src/store.rs +34 −8
…
437 unchanged lines
438 438
            f.flush()?;
439 439
            f.get_ref().sync_all()?;
440 440
        }
441 -
        fs::rename(tmp_pack, &pack_path)?;
442 -
        fs::rename(&tmp_idx, &idx_path)?;
441 +
        // The pack first: an index never names a pack that is not there.
442 +
        crate::durable::rename(tmp_pack, &pack_path)?;
443 +
        crate::durable::rename(&tmp_idx, &idx_path)?;
443 444
        self.reload_packs()
444 445
    }
445 446
…
129 unchanged lines
575 576
        let Some(pending) = self.pending.borrow_mut().take() else {
576 577
            return Ok(());
577 578
        };
579 +
        self.write_pending(pending)
580 +
    }
581 +
582 +
    /// Make every object written so far durable, even inside an open
583 +
    /// batch, which stays open. Call before a reference that names them
584 +
    /// is moved (spec §13.6): the operation head, a bookmark pointer.
585 +
    pub fn flush(&self) -> Result<()> {
586 +
        let Some(pending) = self.pending.borrow_mut().take() else {
587 +
            return Ok(());
588 +
        };
589 +
        self.write_pending(pending)?;
590 +
        *self.pending.borrow_mut() = Some(Pending {
591 +
            buffered: vec![],
592 +
            buffered_bytes: 0,
593 +
            lookup: Default::default(),
594 +
            pack: None,
595 +
            report: PackReport::default(),
596 +
        });
597 +
        Ok(())
598 +
    }
599 +
600 +
    fn write_pending(&self, pending: Pending) -> Result<()> {
578 601
        match pending.pack {
579 602
            None => {
580 603
                for (ty, body) in &pending.buffered {
…
154 unchanged lines
735 758
        if path.is_file() {
736 759
            return Ok(r);
737 760
        }
738 -
        fs::create_dir_all(path.parent().unwrap())?;
761 +
        let fanout = path.parent().unwrap();
762 +
        if !fanout.is_dir() {
763 +
            fs::create_dir_all(fanout)?;
764 +
            crate::durable::sync_dir(&self.dir.join("loose"))?;
765 +
        }
739 766
        let mut nonce = [0u8; 8];
740 767
        rand::rngs::OsRng.fill_bytes(&mut nonce);
741 768
        let tmp = self
742 769
            .dir
743 770
            .join("loose")
744 771
            .join(format!("tmp-{}", hex::encode(nonce)));
772 +
        crate::durable::step("write loose object")?;
745 773
        {
746 774
            let mut f = fs::File::create(&tmp)?;
747 775
            f.write_all(&header(ty, body.len()))?;
748 776
            f.write_all(body)?;
749 777
            f.sync_all()?;
750 778
        }
751 -
        fs::rename(&tmp, &path)?;
779 +
        crate::durable::rename(&tmp, &path)?;
752 780
        Ok(r)
753 781
    }
754 782
…
105 unchanged lines
860 888
        let path = self.seal_index_path(change);
861 889
        fs::create_dir_all(path.parent().unwrap())?;
862 890
        let text: String = seals.iter().map(|s| format!("{}\n", s.to_hex())).collect();
863 -
        let tmp = path.with_extension("tmp");
864 -
        fs::write(&tmp, text)?;
865 -
        fs::rename(&tmp, &path)?;
891 +
        crate::durable::write_atomic(&path, text.as_bytes())?;
866 892
        Ok(())
867 893
    }
868 894
…
44 unchanged lines
913 939
            f.write_all(&Signature::encode_list(&sigs))?;
914 940
            f.sync_all()?;
915 941
        }
916 -
        fs::rename(&tmp, &path)?;
942 +
        crate::durable::rename(&tmp, &path)?;
917 943
        Ok(())
918 944
    }
919 945
…
396 unchanged lines
modified docs/spec.md +5 −1
…
550 unchanged lines
551 551
552 552
A blob or chunk may be **absent** in a partial repository (§17). After a fetch with a blob filter, the repository records every blob its new changes reference but did not receive, together with the remote that withheld it (`.oxy/lazy/<ref>`), and fetches it through `objects` on first read, dropping the record once stored. Changes and checkpoints are never lazy: a repository that has a change has all of its inline data.
553 553
554 +
### 13.6 Durability
555 +
556 +
Every update that must survive a crash replaces a file atomically: write a uniquely named temporary file in the same directory, `fsync` it, rename it over the target, and `fsync` the directory. Loose objects and packs are written the same way, a pack before its index. The operation head (`.oxy/ops/head`) is the commit point of every command: before it moves, every object the new operation names MUST be durable, including objects still held by an open write batch (§13.4), which is flushed first. A crash therefore leaves the repository at the operation before or after the interrupted command, never between; stray temporary files may remain and are ignored. The reference implementation's tests stop `commit` and `push` at each durable step and require both repositories to open, pass `oxy fsck` (§21.1) and accept the same command again.
557 +
554 558
## 14. Identity, signatures, and trust policy
555 559
556 560
### 14.1 Algorithms
…
367 unchanged lines
924 928
925 929
---
926 930
927 -
*Changelog.* Draft 0.8h (2026-09-28): `oxy fsck` (§21.1). Draft 0.8g (2026-09-27): delegates of a rule's identities may sign reference attestations (§14.5); local-path pushes bind unbound repositories like HTTP ones. Draft 0.8f (2026-09-27): challenge signatures (§14.3) for hosts. Draft 0.8e (2026-09-27): anonymous changes (zero author) are authenticated by a seal from any key, so imported and pre-identity history can be pushed under a policy (§14.4). Draft 0.8d (2026-09-27): unbound hosted repositories adopt the first pusher's id (§16.1). Draft 0.8c (2026-09-27): bare repositories (`oxy init --bare`, §12.2: no workspace, no working copy; hosting) and the reference server as an embeddable library (§15.1). Draft 0.8b (2026-09-27): the machine interface for editors (§24): `root`, `cat`, `version`, `hunks`, `discard`, and `--json` on the reporting commands.
931 +
*Changelog.* Draft 0.8h (2026-09-28): `oxy fsck` (§21.1); durability rules and the operation head as commit point (§13.6). Draft 0.8g (2026-09-27): delegates of a rule's identities may sign reference attestations (§14.5); local-path pushes bind unbound repositories like HTTP ones. Draft 0.8f (2026-09-27): challenge signatures (§14.3) for hosts. Draft 0.8e (2026-09-27): anonymous changes (zero author) are authenticated by a seal from any key, so imported and pre-identity history can be pushed under a policy (§14.4). Draft 0.8d (2026-09-27): unbound hosted repositories adopt the first pusher's id (§16.1). Draft 0.8c (2026-09-27): bare repositories (`oxy init --bare`, §12.2: no workspace, no working copy; hosting) and the reference server as an embeddable library (§15.1). Draft 0.8b (2026-09-27): the machine interface for editors (§24): `root`, `cat`, `version`, `hunks`, `discard`, and `--json` on the reporting commands.
928 932
929 933
Draft 0.8a (2026-09-27): every Draft 0.8 addition is implemented in the reference implementation (§26.1). Clarifications from doing so: an unregistered nested repository is left alone and a tracked subrepo whose directory is absent keeps its pin (§7.8); Oxygen pins are omitted from Git export (§23).
930 934
…
59 unchanged lines