From f4aeaddea98d139f84f03db45262a3dec3e808f7 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] 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 fca4bae..1723a91 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), + ))); +}