Skip to content

fix: atomic file writes in ghost-consensus and ghost-mpc - #863

Merged
defenwycke merged 1 commit into
mainfrom
fix/atomic-file-writes-consensus-mpc
Sep 8, 2026
Merged

defenwycke merged 1 commit into
mainfrom
fix/atomic-file-writes-consensus-mpc

Conversation

@defenwycke

Copy link
Copy Markdown
Contributor

Five writers staged their atomic write through a fixed .tmp sibling of the target. That path is shared by every concurrent writer of that file: two racing writers truncate each other's staging file, and once the winner has renamed it away the loser's rename fails with ENOENT. The same defect in the wallet's stores took down request handlers under concurrency, which is what prompted this sweep.

Two of them were also missing durability rather than only racing.

mesh.rs::write_sequence_ceiling staged with a bare fs::write and fsynced neither the file nor its directory, so a power loss could take back a ceiling already reported as persisted — and that ceiling exists precisely so a restart cannot reuse a sequence number.

All three ghost-mpc writers fsynced the staging file and then renamed without ever fsyncing the directory. A rename is a metadata change: without that sync the file's own fsync survives a crash and the rename does not, losing a write that reported success.

Shape of the fix

ghost_common::atomic_file now holds the one implementation. All three crates already depend on ghost-common, so nothing new enters the dependency graph. It exposes:

  • write_atomic — for callers with a finished body.
  • staging_path + sync_parent_dir — for callers that stream into the staging file. The MPC writers persist proving keys that must not be buffered into memory just to reuse a whole-body helper.

Verification

  • ghost-common unit tests cover the race directly: 16 concurrent writers to one target, asserting no writer fails, the result is a whole body rather than a mixture, and no staging files are left behind.
  • ghost-mpc's realcrypto_lifecycle drives the real save/load/copy paths and passes. It needs --features mpc-test-cap; without that flag the harness refuses to run, which is a guard rather than a failure of this change.
  • ghost-consensus: 380 tests pass.
  • Clippy clean under the CI lint set on all three crates.

Follow-up

feat/locks-wraith-wallet (#853) carries the same helper as ghost_lock::atomic_file, because the wallet crates cannot reach ghost-common without a new dependency edge. Whichever of the two lands second should collapse them into one.

https://claude.ai/code/session_01NM8n9eAt68PPGbEvyrD78z

Five writers staged their atomic write through a fixed `.tmp` sibling of the
target. That path is shared by every concurrent writer of that file: two
racing writers truncate each other's staging file, and once the winner has
renamed it away the loser's `rename` fails with ENOENT. The same defect in the
wallet's stores took down request handlers under concurrency.

Two of them were also missing durability rather than only racing.

`mesh.rs::write_sequence_ceiling` staged with a bare `fs::write` and fsynced
neither the file nor its directory, so a power loss could take back a ceiling
already reported as persisted — and that ceiling exists precisely so a restart
cannot reuse a sequence number.

All three `ghost-mpc` writers fsynced the staging file and then renamed without
ever fsyncing the directory. A rename is a metadata change: without that sync
the file's own fsync survives a crash and the rename does not, losing a write
that reported success.

`ghost_common::atomic_file` now holds the one implementation. All three crates
already depend on `ghost-common`, so nothing new enters the graph. It exposes
`write_atomic` for callers with a finished body, and `staging_path` +
`sync_parent_dir` for callers that stream into the staging file — the MPC ones
write proving keys that must not be buffered in memory just to reuse a
whole-body helper.

`ghost-mpc`'s `realcrypto_lifecycle` drives the real save/load/copy paths and
passes (it needs `--features mpc-test-cap`; without it the harness refuses to
run, which is not a failure of this change).

Follow-up: `feat/locks-wraith-wallet` carries the same helper as
`ghost_lock::atomic_file` for the wallet crates, which cannot reach
`ghost-common` without a new dependency edge. Whichever lands second should
collapse the two into one.

Claude-Session: https://claude.ai/code/session_01NM8n9eAt68PPGbEvyrD78z
@defenwycke
defenwycke merged commit fba8a2a into main Sep 8, 2026
13 checks passed
@defenwycke
defenwycke deleted the fix/atomic-file-writes-consensus-mpc branch September 8, 2026 21:25
defenwycke added a commit that referenced this pull request Sep 8, 2026
* chore: one atomic-file implementation, not two

#853 and #863 each carried a copy of the same durable-write helper, because
the wallet crates and the consensus/MPC crates could not reach each other's
homes for it. Both have now landed, so the duplication is real rather than
prospective, and it is exactly the shape of defect that produced them: the
fixed `.tmp` staging path was duplicated across seven stores and every copy
carried the same race.

`ghost_common::atomic_file` is the implementation. `ghost_lock::atomic_file`
becomes a re-export of it, which is the route the wallet crates take — they all
depend on `ghost-lock` already and none depended on `ghost-common`, so this
collapses the code without threading a new dependency through four crates.

`ghost-lock` takes `ghost-common` with `default-features = false`, dropping its
UNIX-only `zmq`/`tmq` feature, which the wallet cannot build for Windows.

`grep -rl "pub fn write_atomic"` now returns exactly one file. Workspace builds,
clippy clean under the CI lint set, machete clean, 1,242 tests pass under
--no-fail-fast, regtest smoke 13/13.

* chore: refresh the fuzz lockfile for the new dependency edge

CI's Format job builds the fuzz crate with `--locked`, and adding `ghost-common`
to `ghost-lock` changed the dependency graph, so `fuzz/Cargo.lock` no longer
matched: "cannot update the lock file because --locked was passed".

One line, `ghost-common` under ghost-lock's dependencies.

Worth noting where the blind spot was: workspace-level fmt, clippy and machete
all passed, because none of them look at the fuzz crate's lockfile. The seven
shell checks in that job are the ones that would have caught it, and I had not
run them. All seven pass now.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant