Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
Show all changes
50 commits
Select commit Hold shift + click to select a range
2ceca7e
feat(client): add canonical low-level builder
jlucaso1 Jul 21, 2026
a569b50
refactor(bot): build clients through canonical pipeline
jlucaso1 Jul 21, 2026
de5d5bc
feat(client): add generation-scoped extension lifecycle
jlucaso1 Jul 21, 2026
1f28c48
fix(client): retain superseded scopes until cleanup
jlucaso1 Jul 21, 2026
a0da3e2
fix(client): harden extension lifecycle callbacks
jlucaso1 Jul 21, 2026
b42a2df
feat(events): add removable filtered subscriptions
jlucaso1 Jul 21, 2026
c2ead6c
docs(client): clarify lifecycle termination semantics
jlucaso1 Jul 21, 2026
bc16830
fix(client): reference-count raw node forwarding
jlucaso1 Jul 21, 2026
3bd4bd5
fix(client): make lifecycle teardown cancellation-safe
jlucaso1 Jul 21, 2026
66f464a
docs(client): clarify lifecycle test hook constraints
jlucaso1 Jul 21, 2026
835d61d
feat(plugins): add transactional native plugin host
jlucaso1 Jul 21, 2026
7c2e584
perf(plugins): keep native host opt-in
jlucaso1 Jul 21, 2026
e41901a
fix(plugins): harden host lifecycle ownership
jlucaso1 Jul 21, 2026
510c55e
Merge updated main into plugin architecture
jlucaso1 Jul 21, 2026
9e7aba1
fix(plugins): make terminal cleanup cancellation-safe
jlucaso1 Jul 21, 2026
776f907
fix(core): harden reentrant teardown races
jlucaso1 Jul 21, 2026
35d3315
fix(plugins): avoid reentrant teardown deadlock
jlucaso1 Jul 21, 2026
2137fcb
fix(plugins): close late lifecycle ownership races
jlucaso1 Jul 21, 2026
9cef55d
feat(plugins): add bounded custom event routing
jlucaso1 Jul 21, 2026
57ec05d
feat(plugins): prove native metrics vertical slice
jlucaso1 Jul 21, 2026
06050d0
fix(lifecycle): guarantee terminal cleanup signaling
jlucaso1 Jul 21, 2026
5b4a3b4
fix(plugins): drain scoped tasks before teardown
jlucaso1 Jul 21, 2026
47dcf10
fix(lifecycle): bound and compact callback backlog
jlucaso1 Jul 21, 2026
1ee952b
fix(lifecycle): retire scopes when reconnect starts
jlucaso1 Jul 21, 2026
c86f6d5
fix(plugins): seal construction and rollback races
jlucaso1 Jul 21, 2026
63bb4d9
perf(plugins): make lifecycle integration opt in
jlucaso1 Jul 21, 2026
54cbf95
fix(plugins): make terminal rollback unwind safe
jlucaso1 Jul 21, 2026
271d842
fix(plugins): isolate resource teardown panics
jlucaso1 Jul 21, 2026
e2f6bc4
fix(plugins): harden lifecycle cleanup under faults
jlucaso1 Jul 21, 2026
e72f272
fix(lifecycle): preserve all scope closure callbacks
jlucaso1 Jul 21, 2026
667689a
feat(plugins): add native observability
jlucaso1 Jul 21, 2026
3244e32
docs(lifecycle): clarify scope closure lock contract
jlucaso1 Jul 21, 2026
dc55c1e
docs(plugins): define host and adapter contracts
jlucaso1 Jul 21, 2026
4788a1d
Merge remote-tracking branch 'origin/main' into agent/plugin-architec…
jlucaso1 Jul 21, 2026
69aaca2
fix(lifecycle): bind ready publication to its generation
jlucaso1 Jul 21, 2026
fb263ec
fix(plugins): attribute spawned task panics
jlucaso1 Jul 21, 2026
a09f007
docs(plugins): clarify feature and task contracts
jlucaso1 Jul 21, 2026
7f025d3
fix(lifecycle): gate direct connect during construction
jlucaso1 Jul 22, 2026
b4b9c63
fix(plugins): attribute evictions to event owners
jlucaso1 Jul 22, 2026
f1a8138
fix(plugins): publish APIs at construction commit
jlucaso1 Jul 22, 2026
719011e
fix(plugins): close lifecycle review races
jlucaso1 Jul 22, 2026
38d20ab
Merge remote-tracking branch 'origin/main' into agent/plugin-architec…
jlucaso1 Jul 22, 2026
d4abef1
Merge remote-tracking branch 'origin/main' into agent/plugin-architec…
jlucaso1 Jul 22, 2026
29481d9
feat(plugins): add manifest-keyed untyped adapters
jlucaso1 Jul 22, 2026
03c4b8b
feat(plugins): own core event subscriptions
jlucaso1 Jul 22, 2026
1444153
feat(plugins): support cooperative task draining
jlucaso1 Jul 22, 2026
60fda0b
fix(plugins): bound partial installation cleanup
jlucaso1 Jul 22, 2026
bfdb269
fix(plugins): release closed subscription registry entries
jlucaso1 Jul 22, 2026
21b430c
docs(plugins): define subscription ownership
jlucaso1 Jul 22, 2026
30d06ff
refactor(plugins): leave capability expansion room
jlucaso1 Jul 22, 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
125 changes: 27 additions & 98 deletions src/bot.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
use crate::cache_config::CacheConfig;
use crate::client::Client;
use crate::client::{Client, ClientBuilderError};
use crate::pair_code::PairCodeOptions;
use crate::store::commands::DeviceCommand;
use crate::store::error::StoreError;
Expand Down Expand Up @@ -87,49 +87,8 @@ pub enum BotBuilderError {
/// Initializing the device row in the storage backend failed.
#[error("failed to initialize the device store: {0}")]
Store(#[from] StoreError),
/// An inbound durability hook was registered with a backend that does not
/// implement the pending-inbound buffer it requires.
#[error("the configured backend does not support the inbound durability hook: {0}")]
UnsupportedDurabilityBackend(String),
}

/// Verify the backend round-trips a pending-inbound buffer entry before we accept
/// an inbound durability hook. A backend relying on the no-op/`Err` trait
/// defaults fails here instead of silently looping every inbound message unacked.
async fn probe_durability_backend(
backend: &std::sync::Arc<dyn Backend>,
) -> std::result::Result<(), BotBuilderError> {
// A real JID (a backend may validate the format) and an id unique per probe
// invocation (pid + atomic counter) so concurrent builders on the same store
// never race on a shared probe row and false-fail.
use portable_atomic::{AtomicU64, Ordering};
static PROBE_SEQ: AtomicU64 = AtomicU64::new(0);
const PROBE_JID: &str = "0@s.whatsapp.net";
const PROBE_PAYLOAD: &[u8] = b"probe";
let probe_id = format!(
"__wa_durability_probe_{}_{}__",
std::process::id(),
PROBE_SEQ.fetch_add(1, Ordering::Relaxed)
);
let map_err = |e: StoreError| BotBuilderError::UnsupportedDurabilityBackend(e.to_string());
backend
.store_pending_inbound(PROBE_JID, PROBE_JID, &probe_id, PROBE_PAYLOAD)
.await
.map_err(map_err)?;
let got = backend
.get_pending_inbound(PROBE_JID, PROBE_JID, &probe_id)
.await
.map_err(map_err)?;
backend
.delete_pending_inbound(PROBE_JID, PROBE_JID, &probe_id)
.await
.map_err(map_err)?;
if got.as_deref() != Some(PROBE_PAYLOAD) {
return Err(BotBuilderError::UnsupportedDurabilityBackend(
"pending-inbound buffer did not round-trip".to_string(),
));
}
Ok(())
#[error(transparent)]
Client(#[from] ClientBuilderError),
}

/// `message` is `Arc` so cloning the context across spawned tasks only bumps a
Expand Down Expand Up @@ -1278,18 +1237,8 @@ impl BotBuilder<Provided, Provided, Provided, Provided> {
unreachable!("typestate guarantees all required fields are Provided")
};

// Instrument the runtime before anything spawns through it, so every
// internal task (noise sender, saver, workers) reports to the hook.
// Default (None): the original runtime is used untouched. The Bot
// keeps its own copy for the `run()` path (see the field doc).
let task_instrument = self.task_instrument;
let alloc_meter = self.alloc_meter;
let runtime: Arc<dyn Runtime> = match task_instrument.clone() {
Some(instrument) => {
Arc::new(wacore::stats::InstrumentedRuntime::new(runtime, instrument))
}
None => runtime,
};

// Note: For multi-account mode, create the backend with SqliteStore::new_for_device()
// before passing it to with_backend_arc()
Expand Down Expand Up @@ -1331,57 +1280,37 @@ impl BotBuilder<Provided, Provided, Provided, Provided> {
}

info!("Creating client...");
let (client, sync_task_receiver) = Client::new_with_cache_config(
runtime.clone(),
persistence_manager.clone(),
transport_factory,
http_client,
self.override_version,
self.cache_config,
)
.await;

let saver_handle = persistence_manager.run_background_saver(
runtime,
std::time::Duration::from_secs(30),
client.shutdown_signal(),
);
// Tie the saver task to Arc<Client> so extracting client() and outliving
// Bot keeps periodic persistence alive. Client::drop on the last Arc
// drops the AbortHandle and aborts the task.
let _ = client.saver_handle.set(saver_handle);

// Typed alloc-meter handle for resource_report (its poll hooks are
// already wired via task_instrument above).
if let Some(meter) = alloc_meter {
let _ = client.alloc_meter.set(meter);
let mut client_builder = Client::builder()
.with_runtime_arc(runtime)
.with_persistence_manager(persistence_manager)
.with_transport_factory_arc(transport_factory)
.with_http_client_arc(http_client)
.with_cache_config(self.cache_config)
.with_custom_enc_handlers(self.custom_enc_handlers)
.with_skip_history_sync(self.skip_history_sync)
.with_background_saver_interval(std::time::Duration::from_secs(30));

if let Some(version) = self.override_version {
client_builder = client_builder.with_version_override(version);
}

// Register custom enc handlers. Immutable after build, so set the whole
// map once; the receive hot path then reads it lock-free.
let _ = client.custom_enc_handlers.set(self.custom_enc_handlers);

// Inbound durability hook (opt-in). Immutable after build; the receive
// path reads it lock-free. Probe the backend first: a backend that does
// not implement the pending-inbound buffer would otherwise leave every
// inbound message unacked and looping forever at runtime, so reject it
// here with a clear error instead.
if let Some(hook) = self.inbound_durability_hook {
probe_durability_backend(&client.persistence_manager.backend()).await?;
let _ = client.inbound_durability_hook.set(hook);
}

if self.skip_history_sync {
client.set_skip_history_sync(true);
client_builder = client_builder.with_inbound_durability_hook_arc(hook);
}

if let Some(count) = self.wanted_pre_key_count {
client.set_wanted_pre_key_count(count);
client_builder = client_builder.with_wanted_pre_key_count(count);
}

if let Some((burst, refill_per_min)) = self.resend_rate_limit {
client.set_resend_rate_limit(burst, refill_per_min);
client_builder = client_builder.with_resend_rate_limit(burst, refill_per_min);
}
client_builder = match alloc_meter {
Some(meter) => client_builder.with_alloc_meter(meter),
None => match task_instrument.clone() {
Some(instrument) => client_builder.with_task_instrument(instrument),
None => client_builder,
},
};

let (client, sync_task_receiver) = client_builder.build().await?.into_parts();

Ok(Bot {
client,
Expand Down
8 changes: 8 additions & 0 deletions src/client.rs
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
mod accessors;
mod adapters;
mod app_state;
mod builder;
mod context_impl;
mod device_registry;
pub(crate) mod device_topology;
mod extension_lifecycle;
mod iq_ops;
mod lid_pn;
mod lifecycle;
Expand All @@ -13,6 +15,10 @@ pub(crate) mod offline_resume;
mod sender_keys;
mod sessions;
mod voip;
use builder::ClientAssembly;
pub use builder::{ClientBuild, ClientBuilder, ClientBuilderError};
use extension_lifecycle::LifecycleRegistration;
pub use extension_lifecycle::{ClientLifecycle, ConnectionScope, ConnectionScopeState};
pub use voip::{CallError, Voip};

use crate::cache::Cache;
Expand Down Expand Up @@ -662,6 +668,8 @@ pub struct Client {
/// error / connect_failure / disconnect. Per-connection subscribers
/// (keepalive, request waiters, read loop, offline flush) observe this.
pub(crate) connection_shutdown: std::sync::Mutex<wacore::runtime::ShutdownNotifier>,
/// Allocated only when an extension host installs lifecycle callbacks.
lifecycle: Option<Arc<LifecycleRegistration>>,
Comment thread
jlucaso1 marked this conversation as resolved.
/// Per-session wire I/O and activity counters. Written at the transport
/// chokepoints (noise sender task, read loop); the keepalive dead-socket
/// watchdog reads its activity timestamps. Snapshot via [`Client::stats`].
Expand Down
Loading
Loading