Skip to content

Commit 46e6802

Browse files
Merge upstream develop
2 parents 36b194b + 344385f commit 46e6802

6 files changed

Lines changed: 169 additions & 27 deletions

File tree

packages/react-native/src/create-polycentric-client.shared.ts

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,55 @@ export function normalizeDatabaseName(databaseName?: string) {
1010
return (databaseName ?? 'polycentric').trim() || 'polycentric';
1111
}
1212

13+
/**
14+
* Build a non-blocking log sink for `setLogger`.
15+
*
16+
* Every rs-core log crosses the FFI boundary as a synchronous host call.
17+
* Calling `console.log` inline for each one stalls the JS thread under a
18+
* burst (RN's `console.log` is slow, worse with a debugger attached), which
19+
* can freeze the UI. Instead we enqueue messages and flush them in a single
20+
* batched `console.log` on the next tick, and drop oldest under backpressure
21+
* so a flood can neither block the caller nor grow unbounded.
22+
*/
23+
export function createBatchingLogSink(maxQueue = 1000): {
24+
log: (message: string) => void;
25+
} {
26+
const queue: string[] = [];
27+
let scheduled = false;
28+
let dropped = 0;
29+
30+
const flush = () => {
31+
scheduled = false;
32+
if (dropped > 0) {
33+
// eslint-disable-next-line no-console
34+
console.warn(
35+
`[polycentric] dropped ${dropped} log message(s) (backpressure)`,
36+
);
37+
dropped = 0;
38+
}
39+
if (queue.length === 0) return;
40+
const batch = queue.splice(0, queue.length).join('\n');
41+
// eslint-disable-next-line no-console
42+
console.log(batch);
43+
};
44+
45+
return {
46+
log: (message: string) => {
47+
if (queue.length >= maxQueue) {
48+
dropped++;
49+
return;
50+
}
51+
queue.push(message);
52+
if (!scheduled) {
53+
scheduled = true;
54+
// Defer off the FFI caller so the Rust->JS callback returns
55+
// immediately; many logs in one tick collapse into one console.log.
56+
setTimeout(flush, 0);
57+
}
58+
},
59+
};
60+
}
61+
1362
/**
1463
* Publishes a new Identity document authorized by the client's current
1564
* keypair, and registers `server` so the new identity is synced there.

packages/react-native/src/create-polycentric-client.ts

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { createReactNativeStorageDriver } from './datastore/expo-sqlite';
44
import { createReactNativeFileStoreDriver } from './filestore/expo-file-system';
55
import { ReactNativeCryptoManager } from './crypto/react-native-crypto-manager';
66
import {
7+
createBatchingLogSink,
78
createIdentity,
89
normalizeDatabaseName,
910
type CreatePolycentricClientConfig,
@@ -13,11 +14,7 @@ let loggerInstalled = false;
1314
function installConsoleLogger() {
1415
if (loggerInstalled) return;
1516
loggerInstalled = true;
16-
setLogger({
17-
log: (message: string) => {
18-
console.log(message);
19-
},
20-
});
17+
setLogger(createBatchingLogSink());
2118
}
2219

2320
export async function createPolycentricClient(

packages/react-native/src/create-polycentric-client.web.ts

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import {
1010
uniffiInitAsync,
1111
} from '@polycentric/rs-core-uniffi-web';
1212
import {
13+
createBatchingLogSink,
1314
createIdentity,
1415
normalizeDatabaseName,
1516
type CreatePolycentricClientConfig,
@@ -19,11 +20,7 @@ let loggerInstalled = false;
1920
function installConsoleLogger() {
2021
if (loggerInstalled) return;
2122
loggerInstalled = true;
22-
setLogger({
23-
log: (message: string) => {
24-
console.log(message);
25-
},
26-
});
23+
setLogger(createBatchingLogSink());
2724
}
2825

2926
export async function createPolycentricClient(

packages/rs-core/src/logging.rs

Lines changed: 76 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,30 +1,97 @@
11
//! Foreign-implemented log sink. Hosts (JS, Kotlin, Swift) register
2-
//! an implementation via `set_logger`; rs-core code that calls
3-
//! `log_msg` forwards messages to it. Cross-target by construction —
4-
//! native targets and wasm both reach the host through the same FFI
5-
//! callback uniffi already builds for us.
2+
//! an implementation via `set_logger`; rs-core code logs through the
3+
//! leveled helpers below. Cross-target by construction — native targets
4+
//! and wasm both reach the host through the same FFI callback uniffi
5+
//! already builds for us.
6+
//!
7+
//! Every message crosses the FFI boundary as a synchronous host call, so a
8+
//! burst of messages can stall the host's thread (on React Native, each call
9+
//! marshals across the bridge into a `console.log`). Two guards keep that in
10+
//! check:
11+
//! * **Level filtering** — messages below the host-configured threshold
12+
//! ([`set_log_level`], default [`LogLevel::Info`]) are dropped in Rust and
13+
//! never cross FFI. The message string isn't even formatted (the helpers
14+
//! take a closure), so filtered logging is nearly free.
15+
//! * Hosts are still expected to make their sink non-blocking (batch/drop)
16+
//! for defense in depth.
617
18+
use std::sync::atomic::{AtomicU8, Ordering};
719
use std::sync::{Arc, Mutex};
820

21+
/// Severity of a log message. Hosts set a minimum threshold via
22+
/// [`set_log_level`]; anything below it is dropped before crossing FFI.
23+
#[derive(Clone, Copy, PartialEq, Eq, Debug, uniffi::Enum)]
24+
pub enum LogLevel {
25+
Trace,
26+
Debug,
27+
Info,
28+
Warn,
29+
Error,
30+
/// Disables all logging.
31+
Off,
32+
}
33+
34+
impl LogLevel {
35+
fn rank(self) -> u8 {
36+
match self {
37+
LogLevel::Trace => 0,
38+
LogLevel::Debug => 1,
39+
LogLevel::Info => 2,
40+
LogLevel::Warn => 3,
41+
LogLevel::Error => 4,
42+
LogLevel::Off => 5,
43+
}
44+
}
45+
}
46+
947
#[uniffi::export(with_foreign)]
1048
pub trait Logger: Send + Sync {
1149
fn log(&self, message: String);
1250
}
1351

1452
static LOGGER: Mutex<Option<Arc<dyn Logger>>> = Mutex::new(None);
53+
/// Minimum level that crosses FFI. Defaults to `Info` so debug/trace
54+
/// floods are dropped unless a host explicitly opts in.
55+
static MIN_LEVEL: AtomicU8 = AtomicU8::new(2);
1556

1657
/// Register the foreign logger. Replaces any previously-set value.
1758
#[uniffi::export]
1859
pub fn set_logger(logger: Arc<dyn Logger>) {
1960
*LOGGER.lock().unwrap() = Some(logger);
2061
}
2162

22-
/// Forward `message` to the registered foreign logger (if any). The
23-
/// Arc is cloned out of the mutex before the foreign call so a
24-
/// re-entrant logger impl can't deadlock against `set_logger`.
25-
pub(crate) fn log_msg(message: impl Into<String>) {
63+
/// Set the minimum level forwarded to the host. Messages below this are
64+
/// dropped in Rust without crossing the FFI boundary.
65+
#[uniffi::export]
66+
pub fn set_log_level(level: LogLevel) {
67+
MIN_LEVEL.store(level.rank(), Ordering::Relaxed);
68+
}
69+
70+
/// Log at `level`. The message is only built (and only crosses FFI) when
71+
/// `level` is at or above the configured threshold — pass a closure so the
72+
/// `format!` cost is skipped for filtered messages.
73+
pub(crate) fn log_at(level: LogLevel, message: impl FnOnce() -> String) {
74+
if level.rank() < MIN_LEVEL.load(Ordering::Relaxed) {
75+
return;
76+
}
77+
// Clone the Arc out of the mutex before the foreign call so a
78+
// re-entrant logger impl can't deadlock against `set_logger`.
2679
let logger = LOGGER.lock().unwrap().clone();
2780
if let Some(l) = logger {
28-
l.log(message.into());
81+
l.log(message());
2982
}
3083
}
84+
85+
pub(crate) fn log_debug(message: impl FnOnce() -> String) {
86+
log_at(LogLevel::Debug, message);
87+
}
88+
89+
#[allow(dead_code)]
90+
pub(crate) fn log_info(message: impl FnOnce() -> String) {
91+
log_at(LogLevel::Info, message);
92+
}
93+
94+
#[allow(dead_code)]
95+
pub(crate) fn log_warn(message: impl FnOnce() -> String) {
96+
log_at(LogLevel::Warn, message);
97+
}

packages/rs-core/src/query/event.rs

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -135,6 +135,7 @@ fn merge_event(
135135
client: &std::sync::Arc<std::sync::Mutex<crate::client::PolycentricClient>>,
136136
) -> Vec<u8> {
137137
let c = client.lock().unwrap();
138+
let mut first_error: Option<String> = None;
138139
for v in values {
139140
if v.is_empty() {
140141
continue;
@@ -148,10 +149,15 @@ fn merge_event(
148149
match c.validate_event(signed, &bundle.event_proofs) {
149150
Ok(()) => return v.clone(),
150151
Err(e) => {
151-
crate::logging::log_msg(format!("[merge_event] dropping bundle: {e:?}"));
152+
first_error.get_or_insert_with(|| format!("{e:?}"));
152153
}
153154
}
154155
}
156+
if let Some(reason) = first_error {
157+
crate::logging::log_debug(|| {
158+
format!("[merge_event] no valid bundle; first reason: {reason}")
159+
});
160+
}
155161
Vec::new()
156162
}
157163

Lines changed: 33 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,38 +1,64 @@
11
//! Shared validation passes for per-RPC merge functions. Each merge
22
//! function combines per-server responses and then runs these to drop
33
//! any bundles/hints that don't pass `PolycentricClient::validate_event`.
4+
//!
5+
//! Dropping is expected and routine — e.g. an identity whose chain can't be
6+
//! fully reconstructed (a deleted or not-yet-synced identity event leaves a
7+
//! gap) makes every later event fail validation. To avoid flooding the log
8+
//! (and freezing the UI) we emit at most one aggregated line per pass rather
9+
//! than one per dropped item.
410
511
use polycentric_common::models::protos_v2::{EventBundle, EventHint};
612

713
use crate::client::PolycentricClient;
814

915
/// Retain only bundles whose signed event passes `validate_event`.
1016
pub(crate) fn retain_validated_bundles(client: &PolycentricClient, bundles: &mut Vec<EventBundle>) {
17+
let before = bundles.len();
18+
let mut first_error: Option<String> = None;
1119
bundles.retain(|b| match b.signed_event.as_ref() {
1220
Some(se) => match client.validate_event(se, &b.event_proofs) {
1321
Ok(()) => true,
1422
Err(e) => {
15-
crate::logging::log_msg(format!("[merge] dropping bundle: {e:?}"));
23+
first_error.get_or_insert_with(|| format!("{e:?}"));
1624
false
1725
}
1826
},
1927
None => false,
2028
});
29+
log_dropped("bundle", before - bundles.len(), first_error);
2130
}
2231

2332
/// Retain only hints whose signed event passes `validate_event`.
2433
pub(crate) fn retain_validated_hints(client: &PolycentricClient, hints: &mut Vec<EventHint>) {
25-
hints.retain(|h| match h.event_bundle.as_ref() {
26-
Some(b) => match b.signed_event.as_ref() {
27-
Some(se) => match client.validate_event(se, &b.event_proofs) {
34+
let before = hints.len();
35+
let mut first_error: Option<String> = None;
36+
hints.retain(|h| {
37+
match h
38+
.event_bundle
39+
.as_ref()
40+
.and_then(|b| b.signed_event.as_ref().map(|se| (b, se)))
41+
{
42+
Some((b, se)) => match client.validate_event(se, &b.event_proofs) {
2843
Ok(()) => true,
2944
Err(e) => {
30-
crate::logging::log_msg(format!("[merge] dropping hint: {e:?}"));
45+
first_error.get_or_insert_with(|| format!("{e:?}"));
3146
false
3247
}
3348
},
3449
None => false,
35-
},
36-
None => false,
50+
}
51+
});
52+
log_dropped("hint", before - hints.len(), first_error);
53+
}
54+
55+
/// Emit a single aggregated line when a pass dropped anything.
56+
fn log_dropped(kind: &str, count: usize, first_error: Option<String>) {
57+
if count == 0 {
58+
return;
59+
}
60+
let reason = first_error.unwrap_or_else(|| "missing signed event".to_string());
61+
crate::logging::log_debug(|| {
62+
format!("[merge] dropped {count} unvalidated {kind}(s); first reason: {reason}")
3763
});
3864
}

0 commit comments

Comments
 (0)