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:
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();