From 42dbf4acf5eeb9093283671a4d069e16717958ae Mon Sep 17 00:00:00 2001 From: EnRaiha <15997552+EnRaiha@users.noreply.github.com> Date: Wed, 23 Sep 2026 01:01:53 +0800 Subject: [PATCH 1/2] wasm: read the clock through one helper, and test a commit on wasm32 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit wasm32-unknown-unknown has no std clock: SystemTime::now() panics with "time not implemented on this platform". Two sites read it directly and both sit in the commit path, so the first write from an embedded build panicked: - WriteTxn::commit builds CommitHistoryMeta, whose timestamp is the history entry's unix_seconds. - The age-based retention policy computes its pruning threshold from the clock on every commit. Route both through crate::clock::unix_seconds(): js_sys on the OPFS build, std elsewhere (wasm32-wasip1 included), and 0 for a wasm build without the JS bindings — the history ordering tolerates that instead of panicking. The btree allocation-cost test keeps its loop on every target but runs the wall-clock bound only where a clock exists. wasm-smoke/ is a separate crate because the pagedb dev-dependencies (tokio rt-multi-thread, tempfile) do not compile for wasm32, and Cargo builds every dev-dependency for a crate's test targets. Its two tests fail with the panic above before this change and pass after; the new `wasm-tests` CI job runs them under node, so the compile-only wasm job is no longer the last line of defence. --- .github/workflows/test.yml | 33 +++++++++ CHANGELOG.md | 4 ++ Cargo.lock | 117 +++++++++++++++++++++++++++++++ Cargo.toml | 5 +- src/btree/tree/core.rs | 18 +++-- src/clock.rs | 33 +++++++++ src/lib.rs | 1 + src/txn/db/catalog/history.rs | 4 +- src/txn/write/commit.rs | 4 +- wasm-smoke/Cargo.toml | 19 +++++ wasm-smoke/src/lib.rs | 1 + wasm-smoke/tests/commit_smoke.rs | 58 +++++++++++++++ 12 files changed, 284 insertions(+), 13 deletions(-) create mode 100644 src/clock.rs create mode 100644 wasm-smoke/Cargo.toml create mode 100644 wasm-smoke/src/lib.rs create mode 100644 wasm-smoke/tests/commit_smoke.rs diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 6a61b09..7048873 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -262,6 +262,39 @@ jobs: - name: cargo check --lib run: cargo check -p pagedb --target ${{ matrix.target }} --lib ${{ matrix.features }} + # ───────────────────────────────────────────────────────────────────────── + # Runtime wasm coverage. The `wasm` job above only compiles: a wall-clock + # read inside the txn layer compiles fine on wasm32 and panics at the first + # commit ("time not implemented on this platform"), which is invisible to a + # check. This job runs the smoke crate under node. + wasm-tests: + name: WASM / node smoke (wasm32) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - name: Install Rust + target + uses: dtolnay/rust-toolchain@4cda84d5c5c54efe2404f9d843567869ab1699d4 # stable + with: + targets: wasm32-unknown-unknown + + - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2 + with: + prefix-key: wasm-smoke + + # The runner must match the wasm-bindgen version the lockfile resolves, + # so read it from `cargo metadata` instead of pinning a number here. + - name: Install wasm-bindgen-test-runner + run: | + version=$(cargo metadata --format-version 1 --locked \ + | python3 -c "import json,sys; d=json.load(sys.stdin); print(next(p['version'] for p in d['packages'] if p['name']=='wasm-bindgen'))") + cargo install wasm-bindgen-cli --version "$version" --locked + + - name: Run wasm smoke tests (node) + env: + CARGO_TARGET_WASM32_UNKNOWN_UNKNOWN_RUNNER: wasm-bindgen-test-runner + run: cargo test -p pagedb-wasm-smoke --target wasm32-unknown-unknown --test commit_smoke + # ───────────────────────────────────────────────────────────────────────── features: name: Feature matrix (${{ matrix.flags }}) diff --git a/CHANGELOG.md b/CHANGELOG.md index f670844..bf182e7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,10 @@ No version has been released yet. Pre-releases are published as `0.1.0-beta.N`; - **Open refusals name the parameter, not the store** — `KeyMismatch`, `PageSizeMismatch`, and `RealmMismatch`, each decided before anything is read or written, and none reported as corruption. - **Failures report themselves** — an unreadable free-list chain, main file, or segment catalog fails `stats()` instead of reporting zero; compaction never skips a catalog entry whose file it cannot open; segment open distinguishes a missing file from a permission or backend error; and only genuine contention is reported as contention. Persisted named-counter rows are validated at open, and commit-history keys are rejected unless exactly eight bytes. +### Fixed + +- **Commits no longer need a wall clock.** `WriteTxn::commit` and the age-based retention threshold read `SystemTime::now()` directly, which panics on `wasm32-unknown-unknown` ("time not implemented on this platform") — the first write from an embedded build failed. Both go through `clock::unix_seconds()` now: `js_sys::Date::now()` on the OPFS build, std elsewhere, and `0` for a wasm build without the JS bindings instead of a panic. `wasm-smoke/` commits once per policy under node so the target stays covered. + ### Security - Threat model documented in the README; disclosure policy in `SECURITY.md`. diff --git a/Cargo.lock b/Cargo.lock index 6e8ea5c..2ab8dd6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -111,6 +111,17 @@ version = "1.0.104" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" +[[package]] +name = "async-trait" +version = "0.1.92" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + [[package]] name = "autocfg" version = "1.5.1" @@ -219,6 +230,12 @@ dependencies = [ "pkg-config", ] +[[package]] +name = "cast" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37b2a672a2cb129a2e41c10b1224bb368f9f37a2b16b612598138befd7b37eb5" + [[package]] name = "cc" version = "1.4.0" @@ -973,6 +990,12 @@ version = "0.2.189" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + [[package]] name = "librocksdb-sys" version = "0.17.3+10.4.2" @@ -1070,6 +1093,16 @@ dependencies = [ "libc", ] +[[package]] +name = "minicov" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3aa3aa12b448ac225b3102217d1ac5cc717908f02722926524b0599c933c7a0" +dependencies = [ + "cc", + "walkdir", +] + [[package]] name = "minimal-lexical" version = "0.2.1" @@ -1133,6 +1166,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" dependencies = [ "autocfg", + "libm", ] [[package]] @@ -1162,6 +1196,12 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" +[[package]] +name = "oorandom" +version = "11.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6790f58c7ff633d8771f42965289203411a5e5c68388703c06e14f24770b41e" + [[package]] name = "opaque-debug" version = "0.3.1" @@ -1204,6 +1244,7 @@ dependencies = [ "tracing-subscriber", "wasm-bindgen", "wasm-bindgen-futures", + "wasm-bindgen-test", "web-sys", "windows-sys", "zeroize", @@ -1223,6 +1264,15 @@ dependencies = [ "tokio", ] +[[package]] +name = "pagedb-wasm-smoke" +version = "0.0.0" +dependencies = [ + "pagedb", + "tokio", + "wasm-bindgen-test", +] + [[package]] name = "parking_lot" version = "0.12.5" @@ -1622,6 +1672,15 @@ dependencies = [ "wait-timeout", ] +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + [[package]] name = "scopeguard" version = "1.2.0" @@ -2081,6 +2140,16 @@ dependencies = [ "libc", ] +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + [[package]] name = "wasi" version = "0.11.1+wasi-snapshot-preview1" @@ -2151,6 +2220,45 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "wasm-bindgen-test" +version = "0.3.76" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a0d555ca874445df8d314f94f5c948a4e74e5418f332c89f660a3d8310a96f4" +dependencies = [ + "async-trait", + "cast", + "js-sys", + "libm", + "minicov", + "nu-ansi-term", + "num-traits", + "oorandom", + "serde", + "serde_json", + "wasm-bindgen", + "wasm-bindgen-futures", + "wasm-bindgen-test-macro", + "wasm-bindgen-test-shared", +] + +[[package]] +name = "wasm-bindgen-test-macro" +version = "0.3.76" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94eb68555b95bcea5e8cf4abe280b529049479fa995bfc23734af96a6aedc120" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "wasm-bindgen-test-shared" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c31d56021e873866c968588ed85ccdf56db5c426e44afdb4618c39895104b920" + [[package]] name = "web-sys" version = "0.3.103" @@ -2171,6 +2279,15 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys", +] + [[package]] name = "windows-core" version = "0.62.2" diff --git a/Cargo.toml b/Cargo.toml index 94a0c60..bb06b54 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,5 +1,5 @@ [workspace] -members = ["benchmarks/engine-comparison"] +members = ["benchmarks/engine-comparison", "wasm-smoke"] default-members = ["."] resolver = "3" @@ -132,6 +132,9 @@ opfs = [ "dep:futures", ] +[target.'cfg(all(target_arch = "wasm32", target_os = "unknown"))'.dev-dependencies] +wasm-bindgen-test = "0.3" + [dev-dependencies] tokio = { version = "1", features = [ "rt", diff --git a/src/btree/tree/core.rs b/src/btree/tree/core.rs index daa85a8..8bbd290 100644 --- a/src/btree/tree/core.rs +++ b/src/btree/tree/core.rs @@ -620,15 +620,21 @@ mod tests { tree.free_page(id); } + // The loop is functional coverage on every target; the bound is a + // wall-clock regression guard, so it only runs where a clock exists. + #[cfg(not(all(target_arch = "wasm32", target_os = "unknown")))] let start = std::time::Instant::now(); for _ in 0..N { tree.allocate_page(); } - let elapsed = start.elapsed(); - assert!( - elapsed < std::time::Duration::from_secs(10), - "{N} allocations against {N} held-back frees took {elapsed:?} — \ - allocation is scanning the freed list again" - ); + #[cfg(not(all(target_arch = "wasm32", target_os = "unknown")))] + { + let elapsed = start.elapsed(); + assert!( + elapsed < std::time::Duration::from_secs(10), + "{N} allocations against {N} held-back frees took {elapsed:?} — \ + allocation is scanning the freed list again" + ); + } } } diff --git a/src/clock.rs b/src/clock.rs new file mode 100644 index 0000000..f9f1b3d --- /dev/null +++ b/src/clock.rs @@ -0,0 +1,33 @@ +// SPDX-License-Identifier: MIT OR Apache-2.0 + +//! Wall-clock access. +//! +//! `wasm32-unknown-unknown` has no std clock: `SystemTime::now()` panics with +//! "time not implemented on this platform". The OPFS build reads the host +//! clock through `js_sys` instead; a wasm build without that feature degrades +//! to `0` rather than panicking. Every other target, `wasm32-wasip1` +//! included, uses std. + +/// Seconds since the Unix epoch. +/// +/// Feeds the commit-history timestamp and the age-based retention threshold. +/// A `0` (no clock in this configuration) keeps both ordered by commit +/// sequence instead of inventing a time. +#[cfg(all(target_arch = "wasm32", target_os = "unknown", feature = "opfs"))] +pub(crate) fn unix_seconds() -> u64 { + (js_sys::Date::now() / 1000.0) as u64 +} + +/// No clock in this configuration: `wasm32-unknown-unknown` without the JS +/// bindings. Callers get `0`, which the commit-history ordering tolerates. +#[cfg(all(target_arch = "wasm32", target_os = "unknown", not(feature = "opfs")))] +pub(crate) fn unix_seconds() -> u64 { + 0 +} + +#[cfg(not(all(target_arch = "wasm32", target_os = "unknown")))] +pub(crate) fn unix_seconds() -> u64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map_or(0, |d| d.as_secs()) +} diff --git a/src/lib.rs b/src/lib.rs index 6f26987..206a1f5 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -16,6 +16,7 @@ // must not be able to reach around. pub(crate) mod btree; pub(crate) mod catalog; +pub(crate) mod clock; pub(crate) mod compaction; pub(crate) mod crypto; pub(crate) mod diag; diff --git a/src/txn/db/catalog/history.rs b/src/txn/db/catalog/history.rs index c0006da..443cba6 100644 --- a/src/txn/db/catalog/history.rs +++ b/src/txn/db/catalog/history.rs @@ -148,9 +148,7 @@ impl Db { state.commit_history_count = Some(total.saturating_sub(deleted)); } crate::options::RetainPolicy::Age(duration) => { - let now_secs = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .map_or(0, |d| d.as_secs()); + let now_secs = crate::clock::unix_seconds(); let threshold = now_secs.saturating_sub(duration.as_secs()); // History keys are the commit id big-endian, so lexicographic // key order is commit order and the prunable rows are always a diff --git a/src/txn/write/commit.rs b/src/txn/write/commit.rs index a96d193..af23e8c 100644 --- a/src/txn/write/commit.rs +++ b/src/txn/write/commit.rs @@ -110,9 +110,7 @@ impl WriteTxn<'_, V> { // Commit-history entry (also materialized here). Its frees are never // reader-pinned, so they fold into the free-list like any other. - let unix_seconds = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .map_or(0, |d| d.as_secs()); + let unix_seconds = crate::clock::unix_seconds(); let history_meta = CommitHistoryMeta { active_root_page_id: new_root, catalog_root_page_id: new_catalog_root, diff --git a/wasm-smoke/Cargo.toml b/wasm-smoke/Cargo.toml new file mode 100644 index 0000000..b6eb3e9 --- /dev/null +++ b/wasm-smoke/Cargo.toml @@ -0,0 +1,19 @@ +# wasm32 smoke tests for pagedb. +# +# Separate crate on purpose: the pagedb dev-dependencies (tokio +# rt-multi-thread, tempfile) do not compile for wasm32, and Cargo builds every +# dev-dependency for a crate's test targets. This crate depends on pagedb with +# the `opfs` feature only. +[package] +name = "pagedb-wasm-smoke" +version = "0.0.0" +edition = "2024" +publish = false + +[lib] +path = "src/lib.rs" + +[target.'cfg(all(target_arch = "wasm32", target_os = "unknown"))'.dependencies] +wasm-bindgen-test = "0.3" +tokio = { version = "1", features = ["rt", "macros", "sync", "io-util", "time"] } +pagedb = { path = "..", features = ["opfs"] } diff --git a/wasm-smoke/src/lib.rs b/wasm-smoke/src/lib.rs new file mode 100644 index 0000000..a596e85 --- /dev/null +++ b/wasm-smoke/src/lib.rs @@ -0,0 +1 @@ +//! wasm32 smoke-test crate; see tests/. diff --git a/wasm-smoke/tests/commit_smoke.rs b/wasm-smoke/tests/commit_smoke.rs new file mode 100644 index 0000000..3738bac --- /dev/null +++ b/wasm-smoke/tests/commit_smoke.rs @@ -0,0 +1,58 @@ +//! wasm32 commit smoke tests: a write must not need a wall clock. +//! +//! `wasm32-unknown-unknown` has no std clock. `WriteTxn::commit` reads the +//! clock for the commit-history entry, and the age-based retention policy +//! reads it for the pruning threshold. Before the fix both called +//! `SystemTime::now()` directly, so every commit panicked with +//! "time not implemented on this platform" and an embedded wasm build could +//! not write at all. +//! +//! Run with `wasm-pack test --node wasm-smoke` or the CI job's +//! `wasm-bindgen-test-runner` invocation. + +#![cfg(all(target_arch = "wasm32", target_os = "unknown"))] + +use pagedb::vfs::memory::MemVfs; +use pagedb::{Db, OpenOptions, RealmId, RetainPolicy}; +use wasm_bindgen_test::*; + +wasm_bindgen_test_configure!(run_in_node_experimental); + +const PAGE: usize = 4096; +const KEK: [u8; 32] = [7u8; 32]; +const REALM: RealmId = RealmId::new([1u8; 16]); + +/// A current-thread runtime keeps the async plumbing identical to native. +/// The memory VFS never yields to the JS event loop, so `block_on` completes +/// without blocking a host callback. +fn block_on(future: F) -> F::Output { + tokio::runtime::Builder::new_current_thread() + .build() + .expect("current-thread runtime") + .block_on(future) +} + +async fn commit_once(policy: RetainPolicy) { + let opts = OpenOptions::default().with_commit_history_retain(policy); + let db = Db::open(MemVfs::new(), KEK, PAGE, REALM, opts) + .await + .expect("open"); + let mut w = db.begin_write().await.expect("begin_write"); + w.put(b"k", b"v").await.expect("put"); + w.commit().await.expect("commit"); +} + +/// The commit-history entry carries a timestamp; writing it must not panic. +#[wasm_bindgen_test] +fn commit_writes_a_history_entry() { + block_on(commit_once(RetainPolicy::Unbounded)); +} + +/// The age-based retention policy computes a threshold from the clock on +/// every commit; pruning must not panic either. +#[wasm_bindgen_test] +fn commit_under_age_retention_prunes_without_a_panic() { + block_on(commit_once(RetainPolicy::Age( + std::time::Duration::from_secs(60), + ))); +} From ec148ad5bb0638ce927557cc6224ea333f957066 Mon Sep 17 00:00:00 2001 From: EnRaiha <15997552+EnRaiha@users.noreply.github.com> Date: Sun, 27 Sep 2026 19:51:17 +0800 Subject: [PATCH 2/2] wasm: refuse age retention without a clock, and yield instead of sleeping MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two defects blocked merge on the wasm clock work. Both are one class: a clock the build does not have, replaced by a value that looks valid. **The zero fallback silently disabled age retention.** clock::unix_seconds() returned 0 for a wasm32-unknown-unknown build without opfs. The ordering tolerates that; the policy does not. threshold = 0.saturating_sub(d) is 0, the prune walk ends at the first row whose timestamp is >= it, and Age behaves as Unbounded while still reporting itself age-based — an unbounded history nobody asked for, reported by nothing. unix_seconds() now returns Option, and clock_available() answers the separate question a policy has to ask. Age is refused at open with PagedbError::RetainPolicyNeedsClock. Unbounded, Count and Disabled consult no clock and are served unchanged. The refusal is checked at the top of open_with_mode, before any lock, probe or read, which covers Db::open, open_read_only and open_observer; and it is checked again at the top of open_existing_inner_with_counterpart, which every reopen funnels through. The second site is not redundant: review found that Db::open_existing_with_counterpart_kek is public, bypasses open_with_mode, and would otherwise have reached the age policy unchecked. The check is a pure function over (policy, clock_available), so both branches are tested on a target that has a clock. Disabling the guard fails the refusal test with "age retention must be refused by name when no clock exists" and leaves the others passing. **The page-read retry slept where no time driver exists.** pager/core.rs called tokio::time::sleep between AEAD attempts. An embedder driving these futures through wasm-bindgen-futures has no runtime, and one built without enable_time() panics on first poll — replacing the Corruption the loop exists to report with a panic. On wasm32-unknown-unknown it now yields; every other target keeps the backoff. Reachability: the sleep needs attempt > 0, and the retry budget is zeroed for every mode whose capabilities report allows_observer_retry false — Standalone, Follower and ReadOnly. Review found that zeroing happened only in open_with_mode, so the same normalization now also runs in open_existing_inner_with_counterpart; without it a Standalone handle opened through the rekey-resume entry could still have reached the loop. With both sites normalized, only Observer reaches it. **Coverage.** The smoke tests now state the invariant rather than the pre-fix code, and add the case the retry loop exists for: every page past the two header slots is flipped mid-page, an Observer handle reads through the retry loop, and the result must be PagedbError::Corruption rather than a panic. That test against the pre-fix `sleep` arm fails with "time not implemented on this platform" at sys/time/unsupported.rs:13; against the `yield_now` arm all four wasm tests pass. clock.rs also asserts the wasm32 configurations at compile time, in the direction a build can get wrong silently. The no-clock configuration has a crate of its own now, wasm-smoke-no-clock, which depends on pagedb without `opfs` and is the only build that can produce it: wasm-smoke's own dependency on opfs fixes the feature for every target in that build, so no test inside it can reach the refusal. The new crate asserts at runtime that every opening entry point refuses Age with PagedbError::RetainPolicyNeedsClock — including Db::open_existing_with_counterpart_kek, whose call site this commit adds, and on an empty store, so an entry point that probed before checking would report NotFound instead. Deleting that call site makes the test fail with `got Some(Io(Kind(NotFound)))`. The same crate asserts the refusal is not a blanket one: Count, Unbounded and Disabled still open and commit on a build whose clock answers None. history.rs gains the clocked half of the same invariant, which no wasm test can reach because it has to wait for the wall clock: with a real clock, RetainPolicy::Age(Duration::ZERO) must prune an entry older than the current second, after which begin_read_at on that commit reports CommitGone while the commit just made stays readable. Against a clock that answers Some(0) it fails with "an entry older than the age threshold must be pruned" — the silent Unbounded observed rather than asserted. What is still not asserted: the pruned row count through the public API, and the timestamp a clockless commit records. Neither Db nor DbStats exposes a commit's time, and on the no-clock build the only reader of it, Age, is refused. Both gaps are stated in the test files and here rather than implied covered. **Also, from review:** the redundant wasm-bindgen-test dev-dependency on the pagedb crate is gone (only wasm-smoke uses it, and declares it itself), and the maintainer-owned CHANGELOG hunk is removed. 535 passed, 0 failed natively; 4 passed on the opfs wasm32 build and 3 on the no-clock one, both under wasm-bindgen-test-runner. Preflight is clean except C5: src/errors.rs goes from 994 to 1003 non-test lines. That file is already around twice the 500-line cap on main and holds no #[cfg(test)] block, so every line counts and any addition trips the ratchet; the nine lines are the typed variant this change needs, and no existing variant names both the policy and the missing clock. Splitting that file is a change of its own and out of scope here, so the exception is recorded in the PR body rather than hidden. fmt clean, clippy clean for this change (the crate's pre-existing clippy::unused_async_trait_impl sites are unknown to clippy 1.96.1 and unrelated). --- .github/workflows/test.yml | 14 ++- CHANGELOG.md | 4 - Cargo.lock | 10 +- Cargo.toml | 5 +- src/clock.rs | 72 ++++++++++--- src/errors.rs | 9 ++ src/pager/core.rs | 13 ++- src/txn/db/catalog/history.rs | 57 +++++++++- src/txn/db/open/existing.rs | 24 +++++ src/txn/db/open/modes.rs | 83 ++++++++++++++ src/txn/write/commit.rs | 4 +- wasm-smoke-no-clock/Cargo.toml | 25 +++++ wasm-smoke-no-clock/src/lib.rs | 1 + wasm-smoke-no-clock/tests/refusal.rs | 148 +++++++++++++++++++++++++ wasm-smoke/tests/commit_smoke.rs | 156 +++++++++++++++++++++++---- 15 files changed, 575 insertions(+), 50 deletions(-) create mode 100644 wasm-smoke-no-clock/Cargo.toml create mode 100644 wasm-smoke-no-clock/src/lib.rs create mode 100644 wasm-smoke-no-clock/tests/refusal.rs diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 7048873..2159ddf 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -266,7 +266,9 @@ jobs: # Runtime wasm coverage. The `wasm` job above only compiles: a wall-clock # read inside the txn layer compiles fine on wasm32 and panics at the first # commit ("time not implemented on this platform"), which is invisible to a - # check. This job runs the smoke crate under node. + # check. This job runs both smoke crates under node — the `opfs` build, where + # a clock exists, and the crate that builds `pagedb` without `opfs` at all, + # where it does not and age retention has to be refused. wasm-tests: name: WASM / node smoke (wasm32) runs-on: ubuntu-latest @@ -295,6 +297,16 @@ jobs: CARGO_TARGET_WASM32_UNKNOWN_UNKNOWN_RUNNER: wasm-bindgen-test-runner run: cargo test -p pagedb-wasm-smoke --target wasm32-unknown-unknown --test commit_smoke + # Same target, the other configuration: `pagedb-wasm-smoke-no-clock` + # depends on `pagedb` without `opfs`, so `clock_available()` is false + # there. This is what runs the refusal — which `wasm-smoke` cannot reach, + # because its own dependency on `opfs` fixes the feature for every test + # target in that build. + - name: Run no-clock wasm smoke tests (node) + env: + CARGO_TARGET_WASM32_UNKNOWN_UNKNOWN_RUNNER: wasm-bindgen-test-runner + run: cargo test -p pagedb-wasm-smoke-no-clock --target wasm32-unknown-unknown --test refusal + # ───────────────────────────────────────────────────────────────────────── features: name: Feature matrix (${{ matrix.flags }}) diff --git a/CHANGELOG.md b/CHANGELOG.md index bf182e7..f670844 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,10 +21,6 @@ No version has been released yet. Pre-releases are published as `0.1.0-beta.N`; - **Open refusals name the parameter, not the store** — `KeyMismatch`, `PageSizeMismatch`, and `RealmMismatch`, each decided before anything is read or written, and none reported as corruption. - **Failures report themselves** — an unreadable free-list chain, main file, or segment catalog fails `stats()` instead of reporting zero; compaction never skips a catalog entry whose file it cannot open; segment open distinguishes a missing file from a permission or backend error; and only genuine contention is reported as contention. Persisted named-counter rows are validated at open, and commit-history keys are rejected unless exactly eight bytes. -### Fixed - -- **Commits no longer need a wall clock.** `WriteTxn::commit` and the age-based retention threshold read `SystemTime::now()` directly, which panics on `wasm32-unknown-unknown` ("time not implemented on this platform") — the first write from an embedded build failed. Both go through `clock::unix_seconds()` now: `js_sys::Date::now()` on the OPFS build, std elsewhere, and `0` for a wasm build without the JS bindings instead of a panic. `wasm-smoke/` commits once per policy under node so the target stays covered. - ### Security - Threat model documented in the README; disclosure policy in `SECURITY.md`. diff --git a/Cargo.lock b/Cargo.lock index 2ab8dd6..74c2d0f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1244,7 +1244,6 @@ dependencies = [ "tracing-subscriber", "wasm-bindgen", "wasm-bindgen-futures", - "wasm-bindgen-test", "web-sys", "windows-sys", "zeroize", @@ -1273,6 +1272,15 @@ dependencies = [ "wasm-bindgen-test", ] +[[package]] +name = "pagedb-wasm-smoke-no-clock" +version = "0.0.0" +dependencies = [ + "pagedb", + "tokio", + "wasm-bindgen-test", +] + [[package]] name = "parking_lot" version = "0.12.5" diff --git a/Cargo.toml b/Cargo.toml index bb06b54..1e8757e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,5 +1,5 @@ [workspace] -members = ["benchmarks/engine-comparison", "wasm-smoke"] +members = ["benchmarks/engine-comparison", "wasm-smoke", "wasm-smoke-no-clock"] default-members = ["."] resolver = "3" @@ -132,9 +132,6 @@ opfs = [ "dep:futures", ] -[target.'cfg(all(target_arch = "wasm32", target_os = "unknown"))'.dev-dependencies] -wasm-bindgen-test = "0.3" - [dev-dependencies] tokio = { version = "1", features = [ "rt", diff --git a/src/clock.rs b/src/clock.rs index f9f1b3d..5de1ede 100644 --- a/src/clock.rs +++ b/src/clock.rs @@ -4,30 +4,72 @@ //! //! `wasm32-unknown-unknown` has no std clock: `SystemTime::now()` panics with //! "time not implemented on this platform". The OPFS build reads the host -//! clock through `js_sys` instead; a wasm build without that feature degrades -//! to `0` rather than panicking. Every other target, `wasm32-wasip1` +//! clock through `js_sys` instead; every other target, `wasm32-wasip1` //! included, uses std. +//! +//! A wasm build *without* the JS bindings has no clock at all, and that case is +//! represented rather than papered over: [`unix_seconds`] returns `None`, and +//! [`clock_available`] lets a caller refuse a policy that a frozen clock would +//! silently neuter. Returning `0` instead would be worse than useless — the +//! commit-history ordering tolerates a zero timestamp, but the age-based +//! retention policy reads it as "nothing is older than the threshold" and stops +//! pruning, so `RetainPolicy::Age` would quietly behave as `Unbounded`. -/// Seconds since the Unix epoch. +/// Seconds since the Unix epoch, or `None` when this build has no clock. /// -/// Feeds the commit-history timestamp and the age-based retention threshold. -/// A `0` (no clock in this configuration) keeps both ordered by commit -/// sequence instead of inventing a time. +/// `None` on `wasm32-unknown-unknown` without the `opfs` feature. Every other +/// target answers, `wasm32-wasip1` included. #[cfg(all(target_arch = "wasm32", target_os = "unknown", feature = "opfs"))] -pub(crate) fn unix_seconds() -> u64 { - (js_sys::Date::now() / 1000.0) as u64 +pub(crate) fn unix_seconds() -> Option { + Some((js_sys::Date::now() / 1000.0) as u64) } /// No clock in this configuration: `wasm32-unknown-unknown` without the JS -/// bindings. Callers get `0`, which the commit-history ordering tolerates. +/// bindings. Callers either tolerate the absence (commit-history ordering keeps +/// its total order without timestamps) or refuse the configuration up front +/// (age retention) — see [`clock_available`]. #[cfg(all(target_arch = "wasm32", target_os = "unknown", not(feature = "opfs")))] -pub(crate) fn unix_seconds() -> u64 { - 0 +pub(crate) fn unix_seconds() -> Option { + None } #[cfg(not(all(target_arch = "wasm32", target_os = "unknown")))] -pub(crate) fn unix_seconds() -> u64 { - std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .map_or(0, |d| d.as_secs()) +// Clippy sees only this arm's body, where the answer is always `Some`, and +// calls the wrapper unnecessary. It is not: the `wasm32-unknown-unknown` arm +// above returns `None`, and one signature across the three arms is what lets a +// caller ask "is there a clock" without a second, drift-prone cfg. +#[allow(clippy::unnecessary_wraps)] +pub(crate) fn unix_seconds() -> Option { + Some( + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map_or(0, |d| d.as_secs()), + ) } + +/// Whether this build can read a wall clock at all. +/// +/// A caller that needs time to *mean* something refuses its configuration when +/// this is false; a caller that only needs a total order can read +/// [`unix_seconds`]'s absence as "no timestamp". +pub(crate) const fn clock_available() -> bool { + cfg!(not(all( + target_arch = "wasm32", + target_os = "unknown", + not(feature = "opfs") + ))) +} + +// The three arms above are selected by a cfg triple, and a cfg that is always +// true compiles into a configuration nobody builds. This pins the direction a +// build can get wrong silently: a `wasm32-unknown-unknown` build without the JS +// bindings that reports a clock anyway, which would leave `Age` unrefused. The +// other direction — `opfs` present, clock absent — is caught at runtime by the +// smoke tests, which commit under age retention on exactly that build. Both +// configurations are compiled in CI: `wasm-smoke` brings `opfs`, and +// `wasm-smoke-no-clock` is the build that has none. +#[cfg(all(target_arch = "wasm32", target_os = "unknown"))] +const _: () = assert!( + cfg!(feature = "opfs") || !clock_available(), + "a wasm32-unknown-unknown build without `opfs` must report no clock" +); diff --git a/src/errors.rs b/src/errors.rs index b081296..ab7704a 100644 --- a/src/errors.rs +++ b/src/errors.rs @@ -189,6 +189,15 @@ pub enum PagedbError { )] HeaderCapabilityUnsupported { unknown_flags: u32 }, + /// A policy that prunes against `now` was requested on a build with no + /// clock, where a zero stand-in would silently prune nothing. Refused at + /// open, before the store is touched. + #[error( + "retain policy {policy} needs a wall clock and this target has none — \ + use Unbounded or Count, or build with the `opfs` feature" + )] + RetainPolicyNeedsClock { policy: &'static str }, + /// The caller opened the store with a different page size than it was /// created with. /// diff --git a/src/pager/core.rs b/src/pager/core.rs index d11b264..fdead26 100644 --- a/src/pager/core.rs +++ b/src/pager/core.rs @@ -1028,7 +1028,8 @@ impl Pager { let file_handle = self.open_file_handle(file).await?; // Observer-mode retry loop: on AEAD failure retry up to - // `observer_retry_count` times (10 ms backoff) to absorb torn reads + // `observer_retry_count` times (10 ms backoff, a yield where no time + // driver exists — see the per-target split below) to absorb torn reads // from a concurrent writer. In non-observer mode (retry_count == 0) // the loop body executes exactly once and any AEAD failure is a hard // corruption signal. @@ -1044,7 +1045,17 @@ impl Pager { let mut last_envelope: Option = None; for attempt in 0..max_attempts { if attempt > 0 { + // Yield the executor on `wasm32-unknown-unknown` rather than + // sleeping: this loop runs on an embedder's single-threaded + // executor, which has no Tokio time driver, and + // `tokio::time::sleep` panics without one — turning the + // corruption this loop exists to absorb into a panic before it + // can report `PagedbError::Corruption`. Every other target + // sleeps, so a torn read still gets its backoff. + #[cfg(not(all(target_arch = "wasm32", target_os = "unknown")))] tokio::time::sleep(std::time::Duration::from_millis(10)).await; + #[cfg(all(target_arch = "wasm32", target_os = "unknown"))] + tokio::task::yield_now().await; } let mut buf = vec![0u8; page_size]; { diff --git a/src/txn/db/catalog/history.rs b/src/txn/db/catalog/history.rs index 443cba6..4d3445c 100644 --- a/src/txn/db/catalog/history.rs +++ b/src/txn/db/catalog/history.rs @@ -148,7 +148,10 @@ impl Db { state.commit_history_count = Some(total.saturating_sub(deleted)); } crate::options::RetainPolicy::Age(duration) => { - let now_secs = crate::clock::unix_seconds(); + // `Age` is refused at open on a build without a clock, so + // the clock is present on every path that reaches this arm. + let now_secs = crate::clock::unix_seconds() + .ok_or(PagedbError::RetainPolicyNeedsClock { policy: "Age" })?; let threshold = now_secs.saturating_sub(duration.as_secs()); // History keys are the commit id big-endian, so lexicographic // key order is commit order and the prunable rows are always a @@ -454,4 +457,56 @@ mod tests { assert!(matches!(err, PagedbError::Corruption(_))); } } + + /// Age retention must actually prune, which it can only do when `now` is a + /// real time. + /// + /// This is the clocked half of the defect the wasm work fixed at the other + /// end. There the clock answered `0` for the whole build; the arithmetic is + /// the same one — `threshold = 0.saturating_sub(d)` is `0`, every recorded + /// timestamp compares as not older than it, the walk ends at the first row, + /// and `Age` silently behaves as `Unbounded`. So the assertion is that an + /// older entry really disappears: a `now` of zero cannot produce that, and + /// no error reports its absence. + /// + /// `Age(ZERO)` prunes every entry older than the current second, so the + /// wait only has to cross one second boundary, and no commit prunes the row + /// it just inserted. + #[tokio::test(flavor = "current_thread")] + async fn age_retention_prunes_entries_older_than_its_threshold() { + use std::time::Duration; + + let db = Db::open_internal_with_options( + MemVfs::new(), + [7u8; 32], + PAGE, + REALM, + OpenOptions::default().with_commit_history_retain(RetainPolicy::Age(Duration::ZERO)), + ) + .await + .unwrap(); + + let oldest = db.begin_write().await.unwrap().commit().await.unwrap(); + assert!( + db.begin_read_at(oldest).await.is_ok(), + "the only commit so far must be readable" + ); + + // Integer-second timestamps: 1.2 s crosses a boundary whichever + // fraction of a second the first commit landed on. + std::thread::sleep(Duration::from_millis(1200)); + let newest = db.begin_write().await.unwrap().commit().await.unwrap(); + + assert!( + db.begin_read_at(newest).await.is_ok(), + "a commit must not prune the row it inserts" + ); + let Err(pruned) = db.begin_read_at(oldest).await else { + panic!("an entry older than the age threshold must be pruned"); + }; + assert!( + matches!(pruned, PagedbError::CommitGone { .. }), + "a pruned commit is gone, not corruption and not a stall: {pruned:?}" + ); + } } diff --git a/src/txn/db/open/existing.rs b/src/txn/db/open/existing.rs index 2f060ef..3630f66 100644 --- a/src/txn/db/open/existing.rs +++ b/src/txn/db/open/existing.rs @@ -121,6 +121,30 @@ impl Db { mode: DbMode, ) -> Result { let main_db_path = "/main.db".to_string(); + // Every reopen path funnels through here — including the public + // `open_existing_with_counterpart_kek`, which does not pass through + // `open_with_mode` and therefore cannot rely on the check there. + // Repeating it is deliberate: this is the chokepoint that makes "no + // clock means no age retention" true of the API surface, not just of + // `Db::open`. + super::modes::check_policy_needs_clock( + &options.commit_history_retain, + crate::clock::clock_available(), + )?; + // Same derivation as `open_with_mode`, for the same reason: a mode + // that does not allow observer retries must not inherit a caller's + // retry budget, and this path is reachable without passing through + // that normalization. Without it the page-read retry loop — and its + // platform-specific backoff — would be reachable from a `Standalone` + // handle, contradicting the reachability the loop documents. + let options = if mode.open_capabilities().allows_observer_retry() { + options + } else { + OpenOptions { + observer_retry_count: 0, + ..options + } + }; let capabilities = mode.open_capabilities(); let file_mode = capabilities.main_db_open_mode(); let read_only = capabilities.read_only_file_access(); diff --git a/src/txn/db/open/modes.rs b/src/txn/db/open/modes.rs index 9d97dfc..c1bccf3 100644 --- a/src/txn/db/open/modes.rs +++ b/src/txn/db/open/modes.rs @@ -236,6 +236,14 @@ impl Db { options: OpenOptions, mode: DbMode, ) -> Result { + // Refuse a clock-dependent policy on a build with no clock, before + // anything is opened or touched; see + // `PagedbError::RetainPolicyNeedsClock`. + check_policy_needs_clock( + &options.commit_history_retain, + crate::clock::clock_available(), + )?; + let capabilities = mode.open_capabilities(); let options = if capabilities.allows_observer_retry() { options @@ -454,3 +462,78 @@ fn map_lock_contention( error } } + +/// Refuse a retention policy that cannot work without a wall clock. +/// +/// `RetainPolicy::Age` decides what to prune by comparing each entry's recorded +/// timestamp against `now - duration`. On a build with no clock there is no +/// `now`: a stand-in zero makes every threshold zero, so no entry is ever older +/// than it, the prune walk ends at its first row, and the policy behaves as +/// `Unbounded` while still reporting itself as age-based — an unbounded history +/// nobody asked for, and nothing in the API says so. +/// +/// `Unbounded` prunes nothing by definition, `Count` prunes by ordinal, and +/// `Disabled` keeps no history index at all, so none of the three consults time +/// and none is refused. Taking `clock_available` as a parameter rather than +/// reading it here is what makes both branches testable on a target that has a +/// clock. +pub(crate) fn check_policy_needs_clock( + policy: &crate::options::RetainPolicy, + clock_available: bool, +) -> Result<()> { + if let crate::options::RetainPolicy::Age(_) = policy + && !clock_available + { + return Err(PagedbError::RetainPolicyNeedsClock { policy: "Age" }); + } + Ok(()) +} + +#[cfg(test)] +mod policy_clock_tests { + use super::check_policy_needs_clock; + use crate::options::RetainPolicy; + use crate::{PagedbError, Result}; + use std::time::Duration; + + /// The defect this guards: without a clock, age retention must be refused + /// rather than run against a zero threshold and silently prune nothing. + #[test] + fn age_is_refused_when_the_build_has_no_clock() { + let refused: Result<()> = + check_policy_needs_clock(&RetainPolicy::Age(Duration::from_secs(60)), false); + assert!( + matches!( + refused, + Err(PagedbError::RetainPolicyNeedsClock { policy: "Age" }) + ), + "age retention must be refused by name when no clock exists" + ); + } + + /// With a clock it is served, so the refusal cannot be a blanket one. + #[test] + fn age_is_served_when_the_build_has_a_clock() { + assert!( + check_policy_needs_clock(&RetainPolicy::Age(Duration::from_secs(60)), true).is_ok() + ); + } + + /// `Unbounded` never prunes, `Count` prunes by ordinal, and `Disabled` + /// keeps no history index, so none reads the clock and none may be caught + /// by this check. + #[test] + fn clock_free_policies_are_never_refused() { + for policy in [ + RetainPolicy::Unbounded, + RetainPolicy::Count(4), + RetainPolicy::Disabled, + ] { + assert!( + check_policy_needs_clock(&policy, false).is_ok(), + "consults no clock and must open without one" + ); + assert!(check_policy_needs_clock(&policy, true).is_ok()); + } + } +} diff --git a/src/txn/write/commit.rs b/src/txn/write/commit.rs index af23e8c..0b8e19d 100644 --- a/src/txn/write/commit.rs +++ b/src/txn/write/commit.rs @@ -110,7 +110,9 @@ impl WriteTxn<'_, V> { // Commit-history entry (also materialized here). Its frees are never // reader-pinned, so they fold into the free-list like any other. - let unix_seconds = crate::clock::unix_seconds(); + // Ordering is by commit id, so a clockless build records 0; `Age` is + // refused at open there. + let unix_seconds = crate::clock::unix_seconds().unwrap_or(0); let history_meta = CommitHistoryMeta { active_root_page_id: new_root, catalog_root_page_id: new_catalog_root, diff --git a/wasm-smoke-no-clock/Cargo.toml b/wasm-smoke-no-clock/Cargo.toml new file mode 100644 index 0000000..4788815 --- /dev/null +++ b/wasm-smoke-no-clock/Cargo.toml @@ -0,0 +1,25 @@ +# wasm32 smoke tests for the OTHER wasm configuration: pagedb without `opfs`. +# +# Its sibling `wasm-smoke` depends on pagedb with `opfs`, which is the build +# where a clock exists, so it can never compile this configuration. Nothing else +# can either: `clock_available()` is decided by pagedb's own feature, and a +# feature cannot be turned off from a test. A second crate is the only way to +# build and run the no-clock configuration, and a crate of its own is needed for +# the same reason as its sibling — pagedb's dev-dependencies (tokio +# rt-multi-thread, tempfile) do not compile for wasm32, and Cargo builds every +# dev-dependency for a crate's test targets. +[package] +name = "pagedb-wasm-smoke-no-clock" +version = "0.0.0" +edition = "2024" +publish = false + +[lib] +path = "src/lib.rs" + +# Gated like the sibling crate so a native `cargo check --workspace` neither +# builds pagedb twice nor needs the wasm toolchain. +[target.'cfg(all(target_arch = "wasm32", target_os = "unknown"))'.dependencies] +wasm-bindgen-test = "0.3" +tokio = { version = "1", features = ["rt", "macros", "sync", "io-util"] } +pagedb = { path = ".." } diff --git a/wasm-smoke-no-clock/src/lib.rs b/wasm-smoke-no-clock/src/lib.rs new file mode 100644 index 0000000..7ad7e40 --- /dev/null +++ b/wasm-smoke-no-clock/src/lib.rs @@ -0,0 +1 @@ +//! wasm32 no-clock smoke-test crate; see tests/. diff --git a/wasm-smoke-no-clock/tests/refusal.rs b/wasm-smoke-no-clock/tests/refusal.rs new file mode 100644 index 0000000..dce41b3 --- /dev/null +++ b/wasm-smoke-no-clock/tests/refusal.rs @@ -0,0 +1,148 @@ +//! The no-clock wasm32 build: what it must refuse, and what it must not. +//! +//! This crate depends on `pagedb` without the `opfs` feature (default features +//! only), so on `wasm32-unknown-unknown` `clock::clock_available()` is false and +//! `unix_seconds()` answers `None`. An age policy cannot be served there — +//! there is no `now` to compare a recorded timestamp against, so the threshold +//! would be a stand-in and the policy would prune nothing while still reporting +//! itself age-based. +//! +//! What that configuration owes an embedder is a typed refusal at open, before +//! anything is read or written, from every entry point; and clock-free policies +//! still working. Both are asserted here at runtime. This crate exists because +//! nothing else can make those assertions: the sibling `wasm-smoke` crate +//! depends on `pagedb` with `opfs` and can never compile this configuration, +//! and `policy_clock_tests` covers the decision function rather than the call +//! sites that feed it. +//! +//! Deliberately not asserted: that a commit on this build records a zero +//! timestamp. Nothing in the public API exposes a commit's recorded time, and +//! `RetainPolicy::Age` — the only reader of it — is refused here by design. +//! +//! Run with the CI job's `wasm-bindgen-test-runner` invocation. + +#![cfg(all(target_arch = "wasm32", target_os = "unknown"))] + +use pagedb::vfs::memory::MemVfs; +use pagedb::{Db, OpenOptions, PagedbError, RealmId, RetainPolicy}; +use wasm_bindgen_test::*; + +wasm_bindgen_test_configure!(run_in_node_experimental); + +const PAGE: usize = 4096; +const KEK: [u8; 32] = [5u8; 32]; +const REALM: RealmId = RealmId::new([2u8; 16]); + +/// No Tokio time driver on purpose: an embedder driving these futures through +/// `wasm-bindgen-futures` has no Tokio runtime at all, so a test that needed a +/// timer would pass here and fail there. +fn block_on(future: F) -> F::Output { + tokio::runtime::Builder::new_current_thread() + .build() + .expect("current-thread runtime") + .block_on(future) +} + +fn age_policy() -> OpenOptions { + OpenOptions::default() + .with_commit_history_retain(RetainPolicy::Age(std::time::Duration::from_secs(60))) +} + +/// Whether this is the store missing rather than the policy being refused. +/// +/// The memory VFS holds no store in these tests, so an entry point that probed +/// before checking reports a missing file instead: `NotFound` from the probe, or +/// the backend's own `Io`/`NotFound` out of `Vfs::open`. Refusing first is the +/// property being pinned, so the two are named apart from a genuine mismatch. +fn probed_before_checking(err: &PagedbError) -> bool { + match err { + PagedbError::NotFound => true, + PagedbError::Io(io) => io.kind() == std::io::ErrorKind::NotFound, + _ => false, + } +} + +/// The refusal, from whichever entry point produced it. +fn assert_refused(err: Option, entry: &str) { + match err { + Some(PagedbError::RetainPolicyNeedsClock { policy: "Age" }) => {} + Some(other) if probed_before_checking(&other) => { + panic!("{entry} reached the store before checking the retention policy: {other:?}"); + } + other => panic!("{entry} must refuse age retention without a clock, got {other:?}"), + } +} + +/// Every opening entry point refuses an age policy, because none of them can +/// serve it: there is no `now` to compute a pruning threshold from. +#[wasm_bindgen_test] +fn age_retention_is_refused_by_every_open_entry_point() { + block_on(async { + assert_refused( + Db::open(MemVfs::new(), KEK, PAGE, REALM, age_policy()) + .await + .err(), + "Db::open", + ); + assert_refused( + Db::open_read_only(MemVfs::new(), KEK, PAGE, REALM, age_policy()) + .await + .err(), + "Db::open_read_only", + ); + assert_refused( + Db::open_observer(MemVfs::new(), KEK, PAGE, REALM, age_policy()) + .await + .err(), + "Db::open_observer", + ); + }); +} + +/// The rekey-resume entry point does not pass through the shared mode path, so +/// it carries the refusal itself. Without that, this is the one public way to +/// reach an age policy on this build — and it would fail later, silently, at +/// the first commit instead of at open. +#[wasm_bindgen_test] +fn age_retention_is_refused_by_the_rekey_resume_entry_point() { + block_on(async { + assert_refused( + Db::open_existing_with_counterpart_kek( + MemVfs::new(), + KEK, + KEK, + PAGE, + REALM, + age_policy(), + ) + .await + .err(), + "Db::open_existing_with_counterpart_kek", + ); + }); +} + +/// The refusal is not a blanket one: the policies that consult no clock keep +/// working on this build, commit included. +#[wasm_bindgen_test] +fn clock_free_policies_still_open_and_commit_without_a_clock() { + block_on(async { + for policy in [ + RetainPolicy::Count(4), + RetainPolicy::Unbounded, + RetainPolicy::Disabled, + ] { + let opts = OpenOptions::default().with_commit_history_retain(policy.clone()); + let db = Db::open(MemVfs::new(), KEK, PAGE, REALM, opts) + .await + .unwrap_or_else(|e| { + panic!("{policy:?} consults no clock and must open without one: {e}") + }); + let mut w = db.begin_write().await.expect("begin_write"); + w.put(b"k", b"v").await.expect("put"); + w.commit() + .await + .unwrap_or_else(|e| panic!("{policy:?} must commit without a clock: {e}")); + } + }); +} diff --git a/wasm-smoke/tests/commit_smoke.rs b/wasm-smoke/tests/commit_smoke.rs index 3738bac..bee75a5 100644 --- a/wasm-smoke/tests/commit_smoke.rs +++ b/wasm-smoke/tests/commit_smoke.rs @@ -1,11 +1,30 @@ -//! wasm32 commit smoke tests: a write must not need a wall clock. +//! wasm32 commit smoke tests, on the build an embedder actually ships. //! -//! `wasm32-unknown-unknown` has no std clock. `WriteTxn::commit` reads the -//! clock for the commit-history entry, and the age-based retention policy -//! reads it for the pruning threshold. Before the fix both called -//! `SystemTime::now()` directly, so every commit panicked with -//! "time not implemented on this platform" and an embedded wasm build could -//! not write at all. +//! The invariants these protect, all on `wasm32-unknown-unknown`: +//! +//! - Nothing on the commit path may call `SystemTime::now()`, which panics with +//! "time not implemented on this platform". The clock is reached only through +//! `clock::unix_seconds()`. +//! - A corrupted page must be reported as `PagedbError::Corruption`, not turned +//! into a panic by the read loop's backoff. +//! +//! This crate depends on `pagedb` with the `opfs` feature, which is the build +//! an embedded consumer uses — so the clock exists here, `unix_seconds()` +//! answers `Some`, and the commit tests pin the succeeding path end to end: +//! open, commit, and commit under age retention, none of them touching std's +//! clock. The runtime is built without a Tokio time driver on purpose, so the +//! corrupted-page test exercises the retry loop's `yield_now` backoff for real. +//! +//! The **no-clock** configuration is a different build (`pagedb` without +//! `opfs`) and is not reachable from here: this crate cannot produce it, because +//! its own dependency on `pagedb` fixes the feature for every target in the +//! build. It has its own crate, `wasm-smoke-no-clock`, which depends on `pagedb` +//! without `opfs` and asserts the refusal at every entry point; +//! `txn::db::open::modes::policy_clock_tests` covers the decision function +//! itself, on any target. +//! +//! Not asserted anywhere: the pruned row count. Neither `Db` nor `DbStats` +//! exposes the commit history, and these tests see only the public API. //! //! Run with `wasm-pack test --node wasm-smoke` or the CI job's //! `wasm-bindgen-test-runner` invocation. @@ -13,7 +32,8 @@ #![cfg(all(target_arch = "wasm32", target_os = "unknown"))] use pagedb::vfs::memory::MemVfs; -use pagedb::{Db, OpenOptions, RealmId, RetainPolicy}; +use pagedb::vfs::{OpenMode, Vfs, VfsFile}; +use pagedb::{Db, OpenOptions, PagedbError, RealmId, RetainPolicy}; use wasm_bindgen_test::*; wasm_bindgen_test_configure!(run_in_node_experimental); @@ -25,6 +45,12 @@ const REALM: RealmId = RealmId::new([1u8; 16]); /// A current-thread runtime keeps the async plumbing identical to native. /// The memory VFS never yields to the JS event loop, so `block_on` completes /// without blocking a host callback. +/// +/// Deliberately built **without** `enable_time()`. An embedder driving these +/// futures through `wasm-bindgen-futures` has no Tokio runtime at all, so a +/// test that quietly depended on a time driver would pass here and panic +/// there. The page-read retry loop yields instead of sleeping for the same +/// reason. fn block_on(future: F) -> F::Output { tokio::runtime::Builder::new_current_thread() .build() @@ -32,27 +58,113 @@ fn block_on(future: F) -> F::Output { .block_on(future) } -async fn commit_once(policy: RetainPolicy) { - let opts = OpenOptions::default().with_commit_history_retain(policy); - let db = Db::open(MemVfs::new(), KEK, PAGE, REALM, opts) - .await - .expect("open"); +async fn commit_once(policy: &RetainPolicy) -> pagedb::Result<()> { + let opts = OpenOptions::default().with_commit_history_retain(policy.clone()); + let db = Db::open(MemVfs::new(), KEK, PAGE, REALM, opts).await?; let mut w = db.begin_write().await.expect("begin_write"); w.put(b"k", b"v").await.expect("put"); w.commit().await.expect("commit"); + Ok(()) +} + +/// Opening and committing must not reach the std clock on this target. +#[wasm_bindgen_test] +fn commit_does_not_read_the_std_clock() { + block_on(async { + commit_once(&RetainPolicy::Unbounded) + .await + .expect("Unbounded consults no clock, so it must open and commit"); + }); } -/// The commit-history entry carries a timestamp; writing it must not panic. +/// Every clock-free policy must clear a commit, not just the one a default +/// configuration happens to use. #[wasm_bindgen_test] -fn commit_writes_a_history_entry() { - block_on(commit_once(RetainPolicy::Unbounded)); +fn commit_clears_every_clock_free_policy() { + block_on(async { + for policy in [RetainPolicy::Unbounded, RetainPolicy::Count(4)] { + commit_once(&policy) + .await + .unwrap_or_else(|e| panic!("a clock-free policy must commit: {e}")); + } + }); } -/// The age-based retention policy computes a threshold from the clock on -/// every commit; pruning must not panic either. +/// Age retention reads the clock on every commit — its pruning threshold is +/// `now - duration` — so it is the policy whose commitment to a wall clock has +/// to be met on the build an embedder ships, not just on the one the default +/// configuration happens to use. #[wasm_bindgen_test] -fn commit_under_age_retention_prunes_without_a_panic() { - block_on(commit_once(RetainPolicy::Age( - std::time::Duration::from_secs(60), - ))); +fn commit_under_age_retention_does_not_read_the_std_clock() { + block_on(async { + commit_once(&RetainPolicy::Age(std::time::Duration::from_secs(60))) + .await + .expect("a build with a clock must serve age retention"); + }); +} + +/// Corrupted pages must be reported, not panicked on. +/// +/// This is the case the page-read retry loop exists for: a read-only handle +/// retries an AEAD failure `observer_retry_count` times before reporting +/// corruption, and the backoff between those attempts is the one platform +/// split in this change — `tokio::time::sleep` where a time driver exists, +/// `yield_now` on `wasm32-unknown-unknown`, where an embedder's executor has +/// none and sleeping would panic with "time not implemented on this platform". +/// A panic here would replace a reportable `Corruption` with a crash, so this +/// test is what makes that split load-bearing rather than cosmetic. +#[wasm_bindgen_test] +fn a_corrupted_page_is_reported_as_corruption_not_a_panic() { + block_on(async { + let vfs = MemVfs::new(); + let db = Db::open(vfs.clone(), KEK, PAGE, REALM, OpenOptions::default()) + .await + .expect("open"); + let mut w = db.begin_write().await.expect("begin_write"); + w.put(b"k", b"v").await.expect("put"); + w.commit().await.expect("commit"); + drop(db); + + // Pages 0 and 1 hold the A/B header slots, whose MAC covers the slot; + // corrupting those would fail *open* with a header error instead of + // exercising a verified page read. Every page after them is flipped, so + // whichever page the read path reaches is corrupt. The flip lands + // mid-page: the page's first bytes carry its cleartext cipher id, and + // overwriting that reports `Unsupported` before the authenticated read + // ever fails. + let mut file = vfs + .open("/main.db", OpenMode::ReadWrite) + .await + .expect("raw open"); + let len = file.len().await.expect("len") as usize; + let mut byte = [0u8; 1]; + let mut offset = 2 * PAGE; + while offset + PAGE <= len { + let mid = (offset + PAGE / 2) as u64; + file.read_at(mid, &mut byte).await.expect("read"); + byte[0] ^= 0xFF; + file.write_at(mid, &byte).await.expect("write"); + offset += PAGE; + } + assert!( + offset > 2 * PAGE, + "the store must have pages past the two header slots" + ); + + // `Observer` is the mode whose retry budget turns this into four + // attempts on the corrupted page; `open_observer` on defaults is what a + // reader on a live store does. + let outcome: pagedb::Result<()> = async { + let db = Db::open_observer(vfs, KEK, PAGE, REALM, OpenOptions::default()).await?; + let txn = db.begin_read().await?; + txn.get(b"k").await.map(|_| ()) + } + .await; + + let err = outcome.expect_err("a corrupted page must not read cleanly"); + assert!( + matches!(err, PagedbError::Corruption(_)), + "a corrupted page must be reported as Corruption, got {err:?}" + ); + }); }