lib

Core libraries for Radroots
git clone https://radroots.dev/git/lib.git
Log | Files | Refs | README

commit c0cf137e7edae899cc0188aa2058ea3b0d399df0
parent 81a3248a8e9b72e7e741eb1f84edffe4274ab7ea
Author: triesap <tyson@radroots.org>
Date:   Wed, 12 Aug 2026 08:25:20 +0000

nostrdb: stabilize empty native filters

Encode empty filter specs with a neutral since-zero constraint.

Prevent nostrdb 0.9 zero-size finalize and drop corruption.

Cover repeated drops and match-all query and subscription behavior.

Qualify the correction under local and parallel Linux Nix tests.

Diffstat:
Mcrates/nostrdb/src/filter.rs | 25++++++++++++++++++++++++-
Mcrates/nostrdb/src/nostrdb.rs | 42++++++++++++++++++++++++++++++++++++++++++
2 files changed, 66 insertions(+), 1 deletion(-)

diff --git a/crates/nostrdb/src/filter.rs b/crates/nostrdb/src/filter.rs @@ -91,8 +91,24 @@ impl RadrootsNostrdbFilterSpec { } pub(crate) fn to_nostrdb_filter(&self) -> Result<nostrdb::Filter, RadrootsNostrdbError> { + let is_empty = self.event_ids_hex.is_empty() + && self.authors_hex.is_empty() + && self.kinds.is_empty() + && self.since_unix.is_none() + && self.until_unix.is_none() + && self.limit.is_none() + && self.search.is_none(); + let mut builder = nostrdb::Filter::new(); + // nostrdb 0.9 finalizes an empty native filter through realloc(ptr, 0), + // which can free the allocation before its Rust Drop frees it again. + // A lower bound of zero preserves the match-all meaning for u64 times + // while ensuring the native filter owns a nonempty finalized buffer. + if is_empty { + builder = builder.since(0); + } + if !self.event_ids_hex.is_empty() { let event_ids = self .event_ids_hex @@ -193,7 +209,14 @@ mod tests { assert_eq!(spec.search(), Some("coffee")); let empty = RadrootsNostrdbFilterSpec::new(); - let _ = empty.to_nostrdb_filter().expect("empty nostrdb filter"); + let empty_filter = empty.to_nostrdb_filter().expect("empty nostrdb filter"); + assert_eq!(empty_filter.num_elements(), 1); + assert_eq!(empty_filter.since(), Some(0)); + + for _ in 0..512 { + let filter = empty.to_nostrdb_filter().expect("repeated empty filter"); + assert_eq!(filter.since(), Some(0)); + } } #[test] diff --git a/crates/nostrdb/src/nostrdb.rs b/crates/nostrdb/src/nostrdb.rs @@ -691,6 +691,48 @@ mod tests { } #[test] + fn empty_filter_preserves_match_all_query_and_subscription_semantics() { + let _guard = test_hooks_guard(); + reset_test_flags(); + let tmp_dir = TempDir::new().expect("tempdir should open"); + let db_dir = tmp_dir.path().join("nostrdb"); + let config = RadrootsNostrdbConfig::new(&db_dir); + let nostrdb = RadrootsNostrdb::open(config).expect("database should open"); + + let subscription = + RadrootsNostrdbSubscriptionSpec::single(RadrootsNostrdbFilterSpec::new()); + let handle = nostrdb + .subscribe(&subscription) + .expect("empty-filter subscription should succeed"); + + let keys = RadrootsNostrKeys::generate(); + let event = EventBuilder::text_note("empty filter match-all") + .sign_with_keys(&keys) + .expect("event should sign"); + nostrdb + .ingest_event(&event, RadrootsNostrdbIngestSource::client()) + .expect("ingest should succeed"); + + let query = RadrootsNostrdbQuerySpec::single(RadrootsNostrdbFilterSpec::new(), 50); + let mut queried = Vec::new(); + let mut notified = Vec::new(); + for _ in 0..40 { + queried = nostrdb.query_notes(&query).expect("query should succeed"); + notified = nostrdb.poll_for_note_keys(handle, 32); + if !queried.is_empty() && !notified.is_empty() { + break; + } + std::thread::sleep(Duration::from_millis(25)); + } + + assert!(queried.iter().any(|note| note.id_hex == event.id.to_hex())); + assert!(!notified.is_empty()); + nostrdb + .unsubscribe(handle) + .expect("unsubscribe should succeed"); + } + + #[test] fn query_notes_empty_filters_returns_empty() { let _guard = test_hooks_guard(); reset_test_flags();