Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
5b0e603
types: read the wall clock through one target-split helper, and probe…
EnRaiha Sep 22, 2026
98c6d54
crdt,sql,array,vector: route the remaining wall-clock reads through t…
EnRaiha Sep 22, 2026
d422130
types,ci: cover the clock helper with tests and check the shared crat…
EnRaiha Sep 22, 2026
a7c5ab7
types,ddl: one clock read in the clone path, and retire the funnel TODO
EnRaiha Sep 22, 2026
5f298b2
codec: scale a decoded element count in 64-bit so 32-bit targets repo…
EnRaiha Sep 27, 2026
9ca77bf
codec: ignore the zstd encoder tests on a target that has no zstd enc…
EnRaiha Sep 27, 2026
d941cec
query: check the additions that a hostile length field drives
EnRaiha Sep 27, 2026
8347fd4
vector: gate the test targets that drive wasm-unavailable modules
EnRaiha Sep 27, 2026
d675c39
ci: run the shared crates' tests on wasm, and prove the probe cannot …
EnRaiha Sep 27, 2026
6ce5a4d
wal: write the segment on wasm, where cfg(unix) is false
EnRaiha Sep 27, 2026
c6677d7
docs: record which WAL guarantees a WASI target cannot give
EnRaiha Sep 27, 2026
cab09f6
wal/mem/crdt/client: keep tokio's full feature off wasm test builds
EnRaiha Sep 27, 2026
942fcbc
wal: create test temp dirs without env::temp_dir on wasi
EnRaiha Sep 27, 2026
8e7726d
wal/mem: gate the tests wasi cannot run
EnRaiha Sep 27, 2026
805efa0
wal/mem: gate whole native-only cases and keep the wasm build warning…
EnRaiha Sep 27, 2026
3240904
wal: point the case list at where the reader gates actually live
EnRaiha Sep 27, 2026
0e42096
docs: say why the double-write buffer stays unavailable on wasm
EnRaiha Sep 27, 2026
5166090
wal: record why pwrite_all reports Unsupported instead of falling back
EnRaiha Sep 27, 2026
434d584
planner: write the vector-primary key guard as is_none_or
EnRaiha Sep 27, 2026
cb89954
ci: clear the native linker flags for the wasm test job
EnRaiha Sep 27, 2026
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
8 changes: 8 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,12 @@
# - Actions → CI → Run workflow, against any branch.
#
# While the label is on, every subsequent push to the PR re-runs the suite.
#
# A nightly schedule probes the same suites against `main` itself. Nothing
# else reports a red `main`: the label gate keeps upstream churn out of PR
# runs, so without this the first run to notice a broken main is the next PR
# a maintainer labels. GitHub sends failure notifications for a scheduled run
# to whoever last edited the cron line.

name: CI

Expand All @@ -18,6 +24,8 @@ on:
branches: [main]
types: [labeled, synchronize, reopened, ready_for_review]
workflow_dispatch:
schedule:
- cron: "0 4 * * *" # 04:00 UTC daily; staggered from NodeDB-Lite's 03:00

concurrency:
group: ci-${{ github.ref }}
Expand Down
4 changes: 4 additions & 0 deletions .github/workflows/static-gates.yml
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,10 @@ jobs:
timeout-minutes: 10
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
- name: Scheduled-main and wasm-probe gate
run: |
python3 scripts/ci/check_wasm_probe.py --self-test
python3 scripts/ci/check_wasm_probe.py
- name: Warm-storage ObjectStore gate
run: bash scripts/ci/check_warm_storage_gate.sh
- name: Format-path .expect() gate
Expand Down
85 changes: 85 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,10 @@ jobs:
run: cargo fmt --all -- --check
- name: Run clippy
run: cargo clippy --workspace --all-targets --all-features --profile ci -- -D warnings
- name: Scheduled-main and wasm-probe gate
run: |
python3 scripts/ci/check_wasm_probe.py --self-test
python3 scripts/ci/check_wasm_probe.py
- name: Warm-storage ObjectStore gate
run: bash scripts/ci/check_warm_storage_gate.sh
- name: Format-path .expect() gate
Expand Down Expand Up @@ -93,6 +97,87 @@ jobs:
- name: Dependency audit
run: cargo deny check

# Compile *and run* the shared crates for a wasm target. The server, cluster
# and raft crates are not wasm targets; these are the crates NodeDB-Lite
# compiles into its browser/wasm build, so a wasm-incompatible dependency or a
# target-specific cfg mistake fails here instead of in the Lite repo.
#
# A check is not enough, which is the whole point of the job: wasm-only code
# that compiles and then traps at run time is invisible to `cargo check`. The
# crates carry `#[cfg(target_arch = "wasm32")]` arms — the ruzstd decoder in
# `nodedb-codec`, the wasm SIMD filter and aggregate kernels in `nodedb-query`,
# the raw-IO shims in `nodedb-wal` — and until this job existed none of them
# was ever executed anywhere. `wasmtime` runs the test binaries, so a panic in
# one of those arms fails the run.
#
# `wasm32-wasip1` on purpose: `wasm32-unknown-unknown` needs a getrandom
# backend the workspace does not select, so the job would fail on the random
# source rather than on the code under change, and it has no way to execute a
# libtest binary.
wasm:
name: WASM tests (wasip1)
runs-on: ubuntu-24.04-arm
env:
# Mirrors the `RUST_MIN_STACK` ceiling the native test job sets: these are
# unoptimized `ci`-profile test binaries, and wasmtime's own default
# (512 KiB) is far below what tokio's and libtest's frames need. Without
# it the run dies on a stack overflow rather than on a test failure.
CARGO_TARGET_WASM32_WASIP1_RUNNER: wasmtime -W max-wasm-stack=33554432 --dir=.
# Clear the workflow-level `RUSTFLAGS`, which points the linker at mold
# (`-C link-arg=-fuse-ld=mold`). Mold is a native linker; this target links
# through the `wasm32-wasip1` spec with `rust-lld`, which rejects that
# argument outright — so every test binary that has to *link* fails with
# "rust-lld: error: unknown argument: -fuse-ld=mold". The compile-only job
# this replaces never linked, which is why it went unnoticed until tests
# were actually executed here.
#
# This has to be `RUSTFLAGS` and not `CARGO_TARGET_WASM32_WASIP1_RUSTFLAGS`:
# cargo gives the un-prefixed variable precedence, verified by building a
# scratch crate with both set — the mold argument still reached rustc. A
# job-level `env` overrides the workflow-level one, so the native jobs keep
# mold and this job gets a clean linker command.
RUSTFLAGS: ""
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7

- name: Install Rust + wasm target
uses: dtolnay/rust-toolchain@4cda84d5c5c54efe2404f9d843567869ab1699d4 # stable
with:
targets: wasm32-wasip1

- name: Install wasmtime
# v1 of the collection, pinned like every other action here. The version
# is explicit rather than `latest`: the runner's default stack size is
# part of what the command above depends on.
uses: bytecodealliance/actions/wasmtime/setup@9152e710e9f7182e4c29ad218e4f335a7b203613 # v1
with:
version: "35.0.0"

- uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2
with:
prefix-key: wasm-wasip1

- name: Run tests (shared crates)
# `top1_recall_on_training_set` is the one test skipped by name. It
# asserts an approximate-recall floor of 0.70 on a ten-vector synthetic
# set; it measures 0.60 on this target and passes natively. The training
# path is deliberately seeded, so the gap is float behaviour on a
# different codegen path, not a random draw — and the dataset is too
# small for the assertion to survive it. It is skipped rather than
# weakened, and it still runs on every PR in the native suite.
#
# nodedb-codec's other native-only tests — the eight that roundtrip
# through `encode()` or drive `ZstdEncoder` — are ignored in-source by
# `#[cfg_attr(target_arch = "wasm32", ignore = ...)]`, because there the
# reason is categorical: this target has no zstd encoder.
run: |
cargo test --profile ci --target wasm32-wasip1 \
-p nodedb-types -p nodedb-codec -p nodedb-physical -p nodedb-mem \
-p nodedb-crdt -p nodedb-columnar -p nodedb-fts -p nodedb-graph \
-p nodedb-spatial -p nodedb-strict -p nodedb-sql -p nodedb-query \
-p nodedb-array -p nodedb-vector -p nodedb-client -p nodedb-wal \
-- --skip top1_recall

# Single test job: runs the fast suite first, then the 3-node cluster
# suite (`nodedb-cluster-tests`) on the same runner. Combining them
# avoids a second cold compile of the whole workspace; the cluster
Expand Down
1 change: 1 addition & 0 deletions Cargo.lock

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

28 changes: 27 additions & 1 deletion docs/wasm.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,10 @@

NodeDB-Lite compiles to WebAssembly and exposes the same `NodeDb` trait you use in native Lite. To talk to an Origin cluster from the browser, use Lite-WASM locally and replicate via CRDT sync over WebSocket — never run Origin in the browser.

**Status: Experimental.** Lite-WASM support is feature-complete for all eight engines. Testing and CI integration are ongoing; treat the build as preview-quality. Report issues via GitHub.
**Status: Experimental.** Lite-WASM support is feature-complete for all eight engines. The shared
crates are compiled and their test suites executed for a WASI target in CI, so a target-specific
build or runtime failure is caught here rather than in the Lite repo; treat the build as
preview-quality and report issues via GitHub.

## Building for WASM

Expand Down Expand Up @@ -198,6 +201,29 @@ See [NodeDB-Lite](https://github.com/NodeDB-Lab/nodedb-lite) for full CRDT sync
- **Module size** — measure your own build; gzip before serving
- **Browser compatibility** — Requires WebAssembly support (all modern browsers + Node.js 14+)

### The WAL on a WASI target

The shared crates are compiled and their test suites executed for `wasm32-wasip1` in CI, so the
coverage below is measured rather than assumed. Three guarantees the native WAL provides are weaker
or absent there:

- **Directory fsync does not exist.** WASI preview 1 has no directory fsync — `File::sync_all()` on a
directory returns `EBADF`, and there is no weaker syscall with the same effect. File creation,
rename and file fsync all still work; what is lost is the guarantee that a rename survives a host
crash. `fsync_directory` is a documented no-op on this target.
- **Positional writes do not exist.** There is no `pwrite`, so the writer seeks to its offset and
writes sequentially instead. This is equivalent for an append-only log, which is what the WAL is.
- **The double-write buffer is unavailable.** It depends on positional reads as well as writes, and
`recover_record` is a stub on this target, so a slot written here could never be read back.
`raw_io::pwrite_all` therefore returns `WalError::Unsupported` for DWB Direct mode rather than
falling back to a seek-and-write: a fallback would mirror the slot, let the writer report
`DwbProtection::Active`, and deliver no protection at all. As it stands the append still succeeds —
DWB failures are deliberately not fatal to the WAL — while `dwb_protection()` reports
`Degraded(WriteFailed)` and every record is counted as unprotected.

`std::thread::spawn` is also unavailable, so any WAL coordination that spawns a thread is
native-only.

## Deployment

### Browser (Web App)
Expand Down
8 changes: 3 additions & 5 deletions nodedb-array/src/sync/hlc.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,6 @@
//! spec's u48 intent while keeping byte alignment simple.

use std::sync::Mutex;
use std::time::{SystemTime, UNIX_EPOCH};

use serde::{Deserialize, Serialize};

Expand Down Expand Up @@ -165,11 +164,10 @@ impl HlcGenerator {

/// Return the current wall-clock milliseconds since Unix epoch.
fn now_ms() -> ArrayResult<u64> {
SystemTime::now()
.duration_since(UNIX_EPOCH)
nodedb_types::clock::since_epoch()
.map(|d| d.as_millis() as u64)
.map_err(|e| ArrayError::InvalidHlc {
detail: format!("system clock before Unix epoch: {e}"),
.ok_or_else(|| ArrayError::InvalidHlc {
detail: "system clock before Unix epoch".to_owned(),
})
}

Expand Down
21 changes: 20 additions & 1 deletion nodedb-client/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -36,4 +36,23 @@ tokio-rustls = { workspace = true, optional = true }
rustls-pemfile = { workspace = true, optional = true }

[dev-dependencies]
tokio = { workspace = true, features = ["rt", "macros"] }
# The only default-feature tokio use is `#[tokio::test]` in
# src/traits/core/trait_def.rs (8 tests, default current-thread flavour), which
# needs `macros` + `rt`. This narrow set is also the only shape that is legal on
# wasm: `tokio = { workspace = true }` inherits `features = ["full"]`, whose
# fs/io-std/net/process/rt-multi-thread/signal arms trip tokio's wasm
# compile_error. The native/remote feature-gated modules do reach
# net/spawn/io-util/time/sync, but enabling those features re-enables the
# optional production `tokio` dependency below, which carries `full` again.
tokio = { version = "1", default-features = false, features = [
"io-util",
"macros",
"rt",
"sync",
"time",
] }

# Restores the pre-split native resolution exactly: off wasm the union of this
# entry and the narrow one above is `full`, as it was before.
[target.'cfg(not(target_arch = "wasm32"))'.dev-dependencies]
tokio = { workspace = true }
37 changes: 32 additions & 5 deletions nodedb-codec/src/bounds.rs
Original file line number Diff line number Diff line change
Expand Up @@ -63,13 +63,27 @@ pub(crate) fn decoded_len(value: usize, codec: &str) -> Result<usize, CodecError
/// The returned value is the original element count, suitable as the only
/// argument to `Vec::with_capacity`. This centralizes overflow and resource
/// checks for counts read from untrusted frames.
///
/// The element-size multiplication is carried out in 64-bit arithmetic rather
/// than in `usize`. A 32-bit target cannot multiply a hostile `u32` count by a
/// wide element at all, so doing it in `usize` turns a frame that is merely too
/// large into a `Corrupt` "overflows usize" — the same frame is a clean
/// `ResourceLimit` where `usize` is 64 bits. The ceiling is 64 MiB, far inside
/// any `usize` the workspace builds for, so the 64-bit product is always within
/// range when the check succeeds; only the comparison needed the wider type.
pub(crate) fn checked_capacity(
count: usize,
element_size: usize,
context: &str,
) -> Result<usize, CodecError> {
let bytes = checked_mul(count, element_size, context)?;
decoded_len(bytes, context)?;
let bytes = (count as u64).saturating_mul(element_size as u64);
if bytes > MAX_DECODED_BYTES as u64 {
return Err(CodecError::ResourceLimit {
resource: format!("{context} decoded bytes"),
requested: usize::try_from(bytes).unwrap_or(usize::MAX),
limit: MAX_DECODED_BYTES,
});
}
Ok(count)
}

Expand Down Expand Up @@ -102,8 +116,21 @@ mod tests {
}

#[test]
fn checked_capacity_rejects_usize_overflow() {
let error = checked_capacity(usize::MAX, 2, "test");
assert!(matches!(error, Err(CodecError::Corrupt { .. })));
fn checked_capacity_rejects_oversized_element_count() {
// `usize::MAX` elements of eight bytes each. Multiplying those in
// `usize` overflows, and on a 32-bit target even `u32::MAX` elements
// do; the classification is the resource limit either way, on every
// target the workspace builds for.
let error = checked_capacity(usize::MAX, 8, "test");
assert!(matches!(error, Err(CodecError::ResourceLimit { .. })));
}

#[test]
fn checked_capacity_rejects_wide_element_count_on_every_target() {
// The 32-bit case in the large: `u32::MAX` elements of eight bytes is
// 32 GiB at an element size that cannot be expressed in a 32-bit
// `usize`.
let error = checked_capacity(u32::MAX as usize, 8, "test");
assert!(matches!(error, Err(CodecError::ResourceLimit { .. })));
}
}
10 changes: 5 additions & 5 deletions nodedb-codec/src/delta.rs
Original file line number Diff line number Diff line change
Expand Up @@ -20,9 +20,7 @@

use std::mem::size_of;

use crate::bounds::{
checked_add, checked_capacity, checked_mul, decoded_len, encode_input_len, u32_to_usize,
};
use crate::bounds::{checked_add, checked_capacity, checked_mul, encode_input_len, u32_to_usize};
use crate::error::CodecError;

// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -198,8 +196,10 @@ fn encode_value_count(len: usize, codec: &str) -> Result<u32, CodecError> {
}

fn validate_value_count(count: usize, codec: &str) -> Result<(), CodecError> {
let bytes = checked_mul(count, size_of::<i64>(), "integer decoded bytes")?;
decoded_len(bytes, codec)?;
// See `checked_capacity`: the byte count is scaled in 64-bit arithmetic so
// a 32-bit target reports the resource limit rather than a `usize`
// overflow for a count it simply cannot express.
checked_capacity(count, size_of::<i64>(), codec)?;
Ok(())
}

Expand Down
10 changes: 5 additions & 5 deletions nodedb-codec/src/double_delta.rs
Original file line number Diff line number Diff line change
Expand Up @@ -31,9 +31,7 @@

use std::mem::size_of;

use crate::bounds::{
checked_add, checked_capacity, checked_mul, decoded_len, encode_input_len, u32_to_usize,
};
use crate::bounds::{checked_add, checked_capacity, encode_input_len, u32_to_usize};
use crate::error::CodecError;

// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -334,8 +332,10 @@ fn encode_value_count(len: usize, codec: &str) -> Result<u32, CodecError> {
}

fn validate_value_count(count: usize, codec: &str) -> Result<(), CodecError> {
let bytes = checked_mul(count, size_of::<i64>(), "integer decoded bytes")?;
decoded_len(bytes, codec)?;
// See `checked_capacity`: the byte count is scaled in 64-bit arithmetic so
// a 32-bit target reports the resource limit rather than a `usize`
// overflow for a count it simply cannot express.
checked_capacity(count, size_of::<i64>(), codec)?;
Ok(())
}

Expand Down
7 changes: 4 additions & 3 deletions nodedb-codec/src/fastlanes/header.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@

use std::mem::size_of;

use crate::bounds::{checked_mul, checked_range, decoded_len, u32_to_usize};
use crate::bounds::{checked_capacity, checked_mul, checked_range, u32_to_usize};
use crate::error::CodecError;

use super::block::skip_block;
Expand All @@ -23,8 +23,9 @@ pub(super) fn parse_header(data: &[u8]) -> Result<(usize, usize), CodecError> {
u32::from_le_bytes([header[0], header[1], header[2], header[3]]),
"FastLanes value count",
)?;
let decoded_bytes = checked_mul(total_count, size_of::<i64>(), "FastLanes decoded bytes")?;
decoded_len(decoded_bytes, "FastLanes")?;
// Rejects a hostile count before it is used for block math; see
// `checked_capacity` for why the scaling is done in 64-bit arithmetic.
checked_capacity(total_count, size_of::<i64>(), "FastLanes")?;
let block_count = usize::from(u16::from_le_bytes([header[4], header[5]]));
let expected_blocks = total_count.div_ceil(BLOCK_SIZE);
if block_count != expected_blocks {
Expand Down
Loading
Loading