Skip to content

Commit 4a6c853

Browse files
committed
fix(txn): roll back writer state when the freed-page invariant fires
A rejected candidate commit previously restored only the commit-history fields, leaving next_page_id and friends advanced past pages the abandoned candidate had already claimed. Capture and restore the full writer state so the next writer bump-allocates cleanly instead of leaking page ids, and harden the regression test to assert the child process actually ran the invariant check rather than trusting a bare exit status.
1 parent eeaa3fa commit 4a6c853

1 file changed

Lines changed: 159 additions & 35 deletions

File tree

src/txn/write/commit.rs

Lines changed: 159 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -57,17 +57,13 @@ impl<V: Vfs + Clone> WriteTxn<'_, V> {
5757
// Note: span is not entered via `.entered()` to keep this async fn's
5858
// future `Send`. Use `tracing::instrument` or enter in sync sections only.
5959
let new_commit_id = self.guard.latest_commit_id + 1;
60-
let precommit_history_state =
61-
std::env::var_os("PAGEDB_INVARIANT_CHECKS")
62-
.is_some()
63-
.then(|| {
64-
(
65-
self.guard.next_page_id,
66-
self.guard.commit_history_root_page_id,
67-
self.guard.commit_history_root_version,
68-
self.guard.commit_history_count,
69-
)
70-
});
60+
// `Some` is both the enablement signal for the opt-in invariant and the
61+
// means of undoing what materialization is about to advance — the check
62+
// is never reachable without the rollback it needs. Presence of the
63+
// variable enables it, including a non-Unicode value.
64+
let invariant_rollback = std::env::var_os("PAGEDB_INVARIANT_CHECKS")
65+
.is_some()
66+
.then(|| InvariantRollback::capture(&self.guard));
7167

7268
// ── Materialize all trees first ──────────────────────────────────────
7369
// Done before accounting freed pages so every copy-on-write spine free
@@ -180,27 +176,27 @@ impl<V: Vfs + Clone> WriteTxn<'_, V> {
180176
// caller observes as failed. Check every live tree, including commit
181177
// history, and keep the strict dangling-pointer walk that detects pages
182178
// freed in an earlier commit.
183-
if let Some(precommit_history_state) = precommit_history_state {
179+
if let Some(rollback) = invariant_rollback {
184180
let freed = entries.iter().map(|&(_, page_id)| page_id).collect();
181+
let roots = [
182+
("data", new_root),
183+
("catalog", new_catalog_root),
184+
("commit-history", self.guard.commit_history_root_page_id),
185+
];
185186
if let Err(violation) = assert_freed_pages_unreachable(
186187
self.db,
187-
[
188-
new_root,
189-
new_catalog_root,
190-
self.guard.commit_history_root_page_id,
191-
],
188+
roots,
192189
self.guard.next_page_id,
193190
&freed,
194191
new_commit_id,
195192
)
196193
.await
197194
{
198-
(
199-
self.guard.next_page_id,
200-
self.guard.commit_history_root_page_id,
201-
self.guard.commit_history_root_version,
202-
self.guard.commit_history_count,
203-
) = precommit_history_state;
195+
// Unwind to exactly the state the still-durable header
196+
// describes, so the next writer bump-allocates over the pages
197+
// this candidate abandoned instead of leaking their ids.
198+
// `Drop` discards the dirty pages themselves.
199+
rollback.restore(&mut self.guard);
204200
panic!("{violation}");
205201
}
206202
}
@@ -330,6 +326,41 @@ impl<V: Vfs + Clone> WriteTxn<'_, V> {
330326
}
331327
}
332328

329+
/// The writer fields a candidate commit advances before the opt-in invariant
330+
/// runs, captured so a rejected candidate can be unwound.
331+
///
332+
/// These four are exactly what tree materialization and
333+
/// `write_commit_history_entry` assign. Everything else the pre-invariant
334+
/// stretch of `commit` touches is either transaction-local (dies with the
335+
/// `WriteTxn`) or rebuilt from the durable free-list by the next
336+
/// `begin_write` — the shared allocator caches among them — so it needs no
337+
/// rollback of its own.
338+
#[derive(Clone, Copy)]
339+
struct InvariantRollback {
340+
next_page_id: u64,
341+
commit_history_root_page_id: u64,
342+
commit_history_root_version: u64,
343+
commit_history_count: Option<u64>,
344+
}
345+
346+
impl InvariantRollback {
347+
fn capture(state: &super::super::db::WriterState) -> Self {
348+
Self {
349+
next_page_id: state.next_page_id,
350+
commit_history_root_page_id: state.commit_history_root_page_id,
351+
commit_history_root_version: state.commit_history_root_version,
352+
commit_history_count: state.commit_history_count,
353+
}
354+
}
355+
356+
fn restore(self, state: &mut super::super::db::WriterState) {
357+
state.next_page_id = self.next_page_id;
358+
state.commit_history_root_page_id = self.commit_history_root_page_id;
359+
state.commit_history_root_version = self.commit_history_root_version;
360+
state.commit_history_count = self.commit_history_count;
361+
}
362+
}
363+
333364
#[derive(Debug)]
334365
enum FreedPageInvariantViolation {
335366
Dangling {
@@ -382,19 +413,26 @@ impl std::fmt::Display for FreedPageInvariantViolation {
382413
}
383414
}
384415

416+
/// Reject a candidate commit that would free a page still reachable from any
417+
/// of its live trees, or whose live trees cannot be authenticated and decoded.
418+
///
419+
/// Each root is walked twice, and the two walks are not redundant.
420+
/// `find_dangling` selects a node's decoder from the node body's own header
421+
/// byte and reports the first bad *pointer* with its parent, which is what
422+
/// localizes a use-after-free. `collect_all_page_ids` selects the decoder from
423+
/// the *authenticated* envelope kind, so it also catches a page whose envelope
424+
/// and body disagree — invisible to the first walk — and its error must be
425+
/// propagated rather than dropped, or the freed-set check below would pass
426+
/// merely because the reachable set came back short.
385427
async fn assert_freed_pages_unreachable<V: Vfs + Clone>(
386428
db: &super::super::db::Db<V>,
387-
roots: [u64; 3],
429+
roots: [(&'static str, u64); 3],
388430
next_page_id: u64,
389431
freed: &HashSet<u64>,
390432
commit_id: u64,
391433
) -> std::result::Result<(), FreedPageInvariantViolation> {
392434
let mut reachable = std::collections::BTreeSet::new();
393-
for (tree_name, root) in ["data", "catalog", "commit-history"]
394-
.into_iter()
395-
.zip(roots)
396-
.filter(|(_, root)| *root != 0)
397-
{
435+
for (tree_name, root) in roots.into_iter().filter(|(_, root)| *root != 0) {
398436
let tree = crate::btree::BTree::open(
399437
db.pager.clone(),
400438
db.realm_id,
@@ -457,14 +495,17 @@ mod tests {
457495

458496
use super::*;
459497
use crate::OpenOptions;
498+
use crate::pager::PageKind;
460499
use crate::vfs::memory::MemVfs;
461500

501+
const REALM: crate::RealmId = crate::RealmId::new([3u8; 16]);
502+
462503
async fn populated_db() -> crate::Db<MemVfs> {
463504
let db = crate::Db::open_internal_with_options(
464505
MemVfs::new(),
465506
[7u8; 32],
466507
4096,
467-
crate::RealmId::new([3u8; 16]),
508+
REALM,
468509
OpenOptions::default(),
469510
)
470511
.await
@@ -486,7 +527,11 @@ mod tests {
486527

487528
let violation = assert_freed_pages_unreachable(
488529
&db,
489-
[0, 0, history_root],
530+
[
531+
("data", 0),
532+
("catalog", 0),
533+
("commit-history", history_root),
534+
],
490535
next_page_id,
491536
&HashSet::from([history_root]),
492537
2,
@@ -508,7 +553,11 @@ mod tests {
508553

509554
let violation = assert_freed_pages_unreachable(
510555
&db,
511-
[unreadable_root, 0, 0],
556+
[
557+
("data", unreadable_root),
558+
("catalog", 0),
559+
("commit-history", 0),
560+
],
512561
unreadable_root + 1,
513562
&HashSet::new(),
514563
2,
@@ -525,19 +574,94 @@ mod tests {
525574
));
526575
}
527576

577+
/// The two walks are complementary, not redundant, and the invariant has to
578+
/// fail closed when only the second one objects.
579+
///
580+
/// `find_dangling` picks a node's decoder from the body's own header byte;
581+
/// `collect_all_page_ids` picks it from the authenticated envelope kind. A
582+
/// page whose envelope says internal while its body is a valid leaf
583+
/// therefore satisfies the first walk and fails the second. Dropping that
584+
/// error would leave a short reachable set that the freed-page check would
585+
/// then clear vacuously.
586+
#[tokio::test(flavor = "current_thread")]
587+
async fn invariant_rejects_a_root_whose_authenticated_kind_contradicts_its_body() {
588+
let db = populated_db().await;
589+
let (data_root, next_page_id) = {
590+
let state = db.writer.lock().await;
591+
(state.root_page_id, state.next_page_id)
592+
};
593+
594+
// Re-persist the real leaf root's bytes under the internal-node
595+
// envelope kind. Copying a live page keeps the body structurally valid,
596+
// so the disagreement is purely the authenticated kind.
597+
let (guard, kind) = db.pager.read_main_node(data_root, REALM).await.unwrap();
598+
assert_eq!(kind, PageKind::BTreeLeaf, "the fixture root must be a leaf");
599+
let body = guard.body_ref().to_vec();
600+
drop(guard);
601+
let forged = next_page_id;
602+
db.pager
603+
.write_main_page(forged, REALM, PageKind::BTreeInternal, &body)
604+
.await
605+
.unwrap();
606+
db.pager.flush_main(REALM).await.unwrap();
607+
db.pager.reset_main_pages();
608+
609+
let tree =
610+
crate::btree::BTree::open(db.pager.clone(), REALM, forged, forged + 1, db.page_size);
611+
assert!(
612+
tree.find_dangling().await.is_none(),
613+
"the strict pointer walk is expected to miss a kind disagreement; \
614+
if it now catches it, this test no longer covers the traversal path"
615+
);
616+
617+
let violation = assert_freed_pages_unreachable(
618+
&db,
619+
[("data", forged), ("catalog", 0), ("commit-history", 0)],
620+
forged + 1,
621+
&HashSet::new(),
622+
2,
623+
)
624+
.await
625+
.unwrap_err();
626+
assert!(
627+
matches!(
628+
violation,
629+
FreedPageInvariantViolation::Traversal {
630+
tree_name: "data",
631+
root,
632+
..
633+
} if root == forged
634+
),
635+
"expected a traversal violation, got {violation:?}"
636+
);
637+
}
638+
528639
#[test]
529640
fn invariant_failure_precedes_durable_publication() {
530-
let status = Command::new(std::env::current_exe().unwrap())
641+
let output = Command::new(std::env::current_exe().unwrap())
531642
.args([
532643
"--ignored",
533644
"--exact",
534645
"txn::write::commit::tests::invariant_failure_precedes_durable_publication_child",
535646
"--nocapture",
536647
])
537648
.env("PAGEDB_INVARIANT_CHECKS", "1")
538-
.status()
649+
.output()
539650
.unwrap();
540-
assert!(status.success(), "invariant subprocess failed: {status}");
651+
let report = String::from_utf8_lossy(&output.stdout).into_owned()
652+
+ &String::from_utf8_lossy(&output.stderr);
653+
// libtest exits 0 when a filter matches nothing, so a successful status
654+
// on its own would keep reporting green if this path ever drifted —
655+
// renaming the child, the module, or the file would silently delete the
656+
// regression. Require evidence that the child actually ran.
657+
assert!(
658+
report.contains("1 passed"),
659+
"the child helper did not run; the filter no longer names it:\n{report}"
660+
);
661+
assert!(
662+
output.status.success(),
663+
"invariant subprocess failed:\n{report}"
664+
);
541665
}
542666

543667
#[tokio::test(flavor = "current_thread")]

0 commit comments

Comments
 (0)