Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 33 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }})
Expand Down
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Expand Down
117 changes: 117 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 4 additions & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
[workspace]
members = ["benchmarks/engine-comparison"]
members = ["benchmarks/engine-comparison", "wasm-smoke"]
default-members = ["."]
resolver = "3"

Expand Down Expand Up @@ -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",
Expand Down
18 changes: 12 additions & 6 deletions src/btree/tree/core.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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"
);
}
}
}
33 changes: 33 additions & 0 deletions src/clock.rs
Original file line number Diff line number Diff line change
@@ -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())
}
1 change: 1 addition & 0 deletions src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
4 changes: 1 addition & 3 deletions src/txn/db/catalog/history.rs
Original file line number Diff line number Diff line change
Expand Up @@ -148,9 +148,7 @@ impl<V: Vfs + Clone> Db<V> {
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
Expand Down
4 changes: 1 addition & 3 deletions src/txn/write/commit.rs
Original file line number Diff line number Diff line change
Expand Up @@ -110,9 +110,7 @@ impl<V: Vfs + Clone> 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,
Expand Down
19 changes: 19 additions & 0 deletions wasm-smoke/Cargo.toml
Original file line number Diff line number Diff line change
@@ -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"] }
1 change: 1 addition & 0 deletions wasm-smoke/src/lib.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
//! wasm32 smoke-test crate; see tests/.
Loading
Loading