mesh-core: one steward per journal — the chain detects a second, it cannot stop one #347
Loading…
Reference in a new issue
No description provided.
Delete branch "fix/mesh-journal-single-writer"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
§2.2 requires a steward that can restart to keep a durable journal and to re-establish spend by replaying it. Nothing required the journal to have one writer, and
FileJournal::openwas:with no lock at all. This is the sibling of #346, found while fixing it.
Mesh's chain changes the failure without removing it
Mesh's journal is hash-chained (
{seq, prev}, BLAKE3), and §2.2 says a journal that does not form one chain means the steward MUST NOT serve from it. So the chain catches concurrent writers. That is not the same as stopping them:Two stewards sharing a journal each replay it, each re-establish the same spend and each serve against it — the grant is overspent while both are running. Only afterwards do their records collide on
seq, the chain stop forming, andMesh::with_journal(verified atmesh-core/src/lib.rs:220) refuse the journal. So the detection arrives after the overspend and costs the steward its ability to restart at all.openalso truncates a torn trailing line, so a concurrent opener can cut the file out from under an appender.The fix
opentakes an exclusive lock, before the read and the truncation — an opener refused afterwards would already have cut the holder's file. The ordering is the property, not merely the presence of a lock.The lock is
std::fs::File::try_lock, stable since Rust 1.89:unsafe— which this workspace denies, and which is what sent me looking for a better primitive than theflockbinding this started as;mesh-coreis named injoulehook's lockfile too, viamesh-openai, so a dependency here would have staled that one);Verified on the toolchain rather than assumed: a second
try_lockon a held file returnsWouldBlock, and the lock releases when the file is dropped. It is also released when the process dies, so a crash leaves no stale lock — a lock-file sidecar would need a story for that case, and that story is where such schemes go wrong.Where a platform cannot lock at all,
openfails rather than proceeding. Serving against a record another steward may also be writing is the overspend this exists to stop, and a platform limit is not a licence for it.Falsification
the_single_writer_claim_matches_what_open_does+ 1 FAILEDa_refused_open_does_not_truncate_the_holders_journalFAILEDthe_single_writer_claim_matches_what_open_does+ 1 FAILEDthe_single_writer_claim_matches_what_open_does+ 2 FAILEDa_platform_that_cannot_lock_fails_closedFAILEDM5 survived the first round, and that was the finding. Swallowing
TryLockError::Errorbroke no test, because that arm is unreachable on a platform whose locks work — and an unreachable arm is exactly where a fail-open sits unnoticed.lock_outcomeis therefore split out from the I/O so all three outcomes are reachable from a test, and M5 now fails only its own. This is the third fix running where the untested thing turned out to be the central claim rather than an edge case.M2 is the other one worth reading: the lock is still there, just in the wrong place, and only the truncation test notices.
A blocking-lock mutation was deliberately not used — it would hang the suite rather than fail a test, and a mutation that hangs reports nothing.
Verification
cargo check --workspace --all-targets --locked(mesh-rs) — exit 0cargo test --workspace --locked— exit 0, 22 test binaries green, no failurescargo check -p mesh-engine-gguf -p mesh-openai --features mesh-engine-gguf/gguf,mesh-openai/server --all-targets --locked— exit 0 (CI's feature leg)cargo clippy -p mesh-core --all-targets --no-deps --locked -- -D warnings— exit 0check-lockfiles— 42 lockfiles, all current;check-spec-refs1673 citations / 16 standards;check-comparative-proseclean§2.2 gains the requirement, since the gap was in the spec before it was in the code — prose alone would not stop a reimplementation repeating it. No conformance clause is added: the pack has no vector for it, and numbering a clause nothing tests would claim coverage that does not exist.
🤖 Generated with Claude Code
§2.2 requires a steward that can restart to keep a durable journal and to re-establish spend by replaying it. Nothing required the journal to have one writer, and `FileJournal::open` was OpenOptions::new().read(true).append(true).create(true) with no lock at all. Mesh's journal is hash-chained (`{seq, prev}`), and that changes the failure from jcp-wire's without removing it. **The chain detects a second writer; it does not prevent one.** Two stewards sharing a journal each replay it, each re-establish the same spend and each serve against it, so the grant is overspent *while both are running*. Their records then collide on `seq`, the chain no longer forms, and `Mesh::with_journal` refuses the journal entirely — so the detection arrives after the overspend and costs the steward its ability to restart. `open` also truncates a torn trailing line, so a concurrent opener can cut the file out from under an appender. `open` now takes an exclusive lock, **before** the read and the truncation: an opener refused afterwards would already have cut the holder's file. The lock is `std::fs::File::try_lock`, stable since Rust 1.89 — no `unsafe`, which this workspace denies, no new dependency, no lockfile change in any workspace, and no platform caveat, because std covers Windows as well as unix. Verified on the toolchain rather than assumed: a second `try_lock` on a held file returns `WouldBlock`, and the lock releases when the file is dropped. It is also released when the process dies, so a crash leaves no stale lock to recover, which a lock-file sidecar would need a story for. Where a platform cannot lock at all, `open` **fails** rather than proceeding: serving against a record another steward may also be writing is the overspend this exists to stop. Falsification, five mutations, all killed by their named test. **M5 survived the first round** — swallowing the unsupported-platform error broke nothing, because that arm is unreachable on a platform whose locks work, and an unreachable arm is exactly where a fail-open hides. `lock_outcome` is therefore split out from the I/O so every arm is reachable from a test, and M5 now fails only its own. This is the third fix running where the untested thing was the central claim. M2 is the other one worth reading: the lock still present but taken after the truncation fails **only** the no-truncation test, isolating the ordering from the existence of the lock. §2.2 gains the requirement, since the gap was in the spec before it was in the code. `jcp-wire` carried the identical defect and is fixed the same way in #346; the two standards deliberately keep their own copies of this machinery, as `mesh-receipt` already vendors JCR-1 rather than depending on `jcp-schema`. Co-Authored-By: Claude Opus 5 <[email protected]>