Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
f8907fe
docs: spec for event-based waits in Playwright suite
claude Apr 28, 2026
fbcedba
docs: harden event-based-waits spec via 3 review passes
claude Apr 28, 2026
b7525c9
docs: PR-1 implementation plan for event-based waits foundation
claude Apr 28, 2026
401b51b
feat(web): add test-hooks cargo feature
claude Apr 28, 2026
af06a8b
feat(web): add gated test_hooks module skeleton
claude Apr 28, 2026
9821656
build: lockfile update for serde-wasm-bindgen optional dep
claude Apr 28, 2026
8ebafa7
feat(web): add SnapshotDto / AuthorHeadDto / ChannelDto
claude Apr 28, 2026
dbbe107
style(web): fix module ordering in lib.rs (rustfmt)
claude Apr 28, 2026
9b7097a
feat(web): add WillowTestHooks event_count + last_event
claude Apr 28, 2026
f86ef4a
docs: PR-1 errata — correct API shape and feature-gating
claude Apr 28, 2026
ba90f50
fix(test-hooks): apply PR-1 errata — gate accessors, split feature
claude Apr 28, 2026
5ccdf0a
fix(test-hooks): use ChannelKind directly in ChannelDto
claude Apr 28, 2026
7c8dfd2
feat(test-hooks): add WillowTestHooks::heads() returning Promise<head…
claude Apr 28, 2026
614667c
feat(test-hooks): add WillowTestHooks::snapshot() returning Promise<S…
claude Apr 28, 2026
3bcf35a
feat(test-hooks): add ClientEvent wire-shape conversion
claude Apr 28, 2026
b48955e
feat(test-hooks): add push dispatcher with buffer drain + overflow si…
claude Apr 28, 2026
7a0d226
fix(test-hooks): surface JS callback errors and clarify abort assertion
claude Apr 28, 2026
2c10133
feat(test-hooks): mount WillowTestHooks under test-hooks feature
claude Apr 28, 2026
29fc0b6
build: add FEATURES variable to dev / e2e / check-all recipes
claude Apr 28, 2026
950e376
ci: add test-hooks symbol-leak guard to check-all
claude Apr 28, 2026
bd1f725
ci(e2e): forbid new waitForTimeout calls; allowlist existing
claude Apr 28, 2026
d30d5c4
fix(test-hooks): final-review polish
claude Apr 28, 2026
f07dc5c
fix(test-hooks): close mount-race + scope test-fixture System lifetime
claude Apr 29, 2026
4f9dabe
Merge remote-tracking branch 'origin/main' into claude/event-based-wa…
claude Apr 29, 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
12 changes: 12 additions & 0 deletions Cargo.lock

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

27 changes: 27 additions & 0 deletions crates/actor/src/broker.rs
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,20 @@ impl<T: Message<Result = ()>> Message for BrokerSubscribe<T> {
type Result = SubscriptionId;
}

/// Fire-and-forget subscribe. Behaves like [`BrokerSubscribe`] but returns
/// `()` so callers in synchronous contexts can use [`crate::Addr::do_send`]
/// to enqueue the subscription without awaiting confirmation.
///
/// Because the broker's mailbox is FIFO, any [`Publish`] enqueued after
/// this call is processed after the subscription is registered — no
/// events are lost as long as no publish was enqueued before this call
/// returns.
pub struct BrokerAttach<T: Message<Result = ()>>(pub Recipient<T>);

impl<T: Message<Result = ()>> Message for BrokerAttach<T> {
type Result = ();
}

/// Unsubscribe by ID.
pub struct BrokerUnsubscribe(pub SubscriptionId);

Expand Down Expand Up @@ -90,6 +104,19 @@ impl<T: Message<Result = ()> + Clone> Handler<BrokerSubscribe<T>> for Broker<T>
}
}

impl<T: Message<Result = ()> + Clone> Handler<BrokerAttach<T>> for Broker<T> {
fn handle(
&mut self,
msg: BrokerAttach<T>,
_ctx: &mut Context<Self>,
) -> impl Future<Output = ()> + Send {
let id = SubscriptionId(self.next_id);
self.next_id += 1;
self.subscribers.push((id, msg.0));
async {}
}
}

impl<T: Message<Result = ()> + Clone> Handler<BrokerUnsubscribe> for Broker<T> {
fn handle(
&mut self,
Expand Down
4 changes: 3 additions & 1 deletion crates/actor/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,9 @@ pub mod system;

pub use actor::{Actor, Handler, Message, StreamHandler};
pub use addr::{Addr, AnyAddr, Recipient};
pub use broker::{Broker, BrokerSubscribe, BrokerUnsubscribe, Publish, SubscriptionId};
pub use broker::{
Broker, BrokerAttach, BrokerSubscribe, BrokerUnsubscribe, Publish, SubscriptionId,
};
pub use context::{Context, IntervalHandle, TimerHandle};
pub use debounce::{Debounce, Enqueue, Throttle};
pub use derived::{derived, DeriveSource, DerivedActor};
Expand Down
5 changes: 5 additions & 0 deletions crates/client/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,11 @@ description = "UI-agnostic client library for the Willow P2P chat network"

[features]
test-utils = ["willow-network/test-utils"]
# Read-only test instrumentation hooks. Distinct from `test-utils`:
# `test-hooks` exposes narrow accessors for the web crate's
# `WillowTestHooks` (Promise-returning JS pull API) without enabling
# `MemNetwork`. See `docs/specs/2026-04-27-event-based-waits-design.md`.
test-hooks = []

[dependencies]
willow-actor = { path = "../actor" }
Expand Down
28 changes: 28 additions & 0 deletions crates/client/src/accessors.rs
Original file line number Diff line number Diff line change
Expand Up @@ -208,3 +208,31 @@ impl<N: willow_network::Network> ClientHandle<N> {
.await
}
}

// ── Test-only address getters (test-hooks feature) ────────────────────────
//
// Gated behind `test-hooks` so non-test consumers (`willow-agent`,
// `willow-replay`, etc.) never see them. The address itself doesn't grant
// write access without an active mutator — these are a read-only handle
// for `WillowTestHooks` in the web crate, which cannot hold a generic
// `ClientHandle<N>` across the wasm_bindgen boundary.

#[cfg(feature = "test-hooks")]
impl<N: willow_network::Network> ClientHandle<N> {
/// Clone the per-author Merkle-DAG actor address. Test-only.
pub fn dag_addr_clone(
&self,
) -> willow_actor::Addr<willow_actor::StateActor<crate::state_actors::DagState>> {
self.dag_addr.clone()
}

/// Clone the materialised `ServerState` actor address. Test-only.
///
/// Used by the snapshot builder in `WillowTestHooks` to read the
/// channels view for assertion-style polling.
pub fn event_state_addr_clone(
&self,
) -> willow_actor::Addr<willow_actor::StateActor<willow_state::ServerState>> {
self.event_state_addr.clone()
}
}
64 changes: 63 additions & 1 deletion crates/client/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -123,7 +123,7 @@ pub enum ClientError {
/// Helper to bridge `Broker<ClientEvent>` into an async stream receiver.
pub mod event_receiver {
use crate::events::ClientEvent;
use willow_actor::{Actor, Addr, Broker, BrokerSubscribe, Context, Handler};
use willow_actor::{Actor, Addr, Broker, BrokerAttach, BrokerSubscribe, Context, Handler};

/// Async receiver for [`ClientEvent`]s from a [`Broker`].
///
Expand All @@ -147,6 +147,31 @@ pub mod event_receiver {
Self { rx }
}

/// Subscribe synchronously without awaiting confirmation.
///
/// Queues `BrokerSubscribe` via `do_send`, returning the receiver
/// immediately. The broker's mailbox is FIFO, so any `Publish`
/// queued AFTER this call is processed AFTER the subscription
/// is registered — no events are lost as long as no publish has
/// been queued before this call returns.
///
/// Use from synchronous contexts (mount blocks, `Drop`, etc.)
/// where awaiting the async [`subscribe`] would create a window
/// in which events emitted between scheduling and confirmation
/// would be missed.
///
/// [`subscribe`]: Self::subscribe
pub fn subscribe_now(
broker: &Addr<Broker<ClientEvent>>,
system: &willow_actor::SystemHandle,
) -> Self {
let (tx, rx) =
willow_actor::runtime::channel(willow_actor::runtime::DEFAULT_MAILBOX_CAPACITY);
let addr = system.spawn(ForwarderActor { tx });
broker.do_send(BrokerAttach(addr.into())).ok();
Self { rx }
}

/// Await the next event. Returns `None` if the broker is closed.
pub async fn recv(&mut self) -> Option<ClientEvent> {
self.rx.recv().await
Expand Down Expand Up @@ -2127,4 +2152,41 @@ mod tests {
"unmute must remove the channel from the set"
);
}

/// `subscribe_now` registers the recipient before any subsequent
/// `Publish` is processed by the broker, eliminating the race where
/// the async `subscribe` would miss events emitted between scheduling
/// and confirmation.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn subscribe_now_captures_event_published_synchronously_after_subscribe() {
use crate::events::ClientEvent;
use willow_actor::{Broker, Publish, System};

let sys = System::new();
let broker = sys.spawn(Broker::<ClientEvent>::default());

// Subscribe + publish without any await between them. The async
// `EventReceiver::subscribe` would create a window here in which
// the publish could overtake the BrokerSubscribe registration.
let mut rx = EventReceiver::subscribe_now(&broker, &sys.handle());
broker
.do_send(Publish(ClientEvent::SyncCompleted { ops_applied: 7 }))
.unwrap();

let received = tokio::time::timeout(std::time::Duration::from_secs(1), async {
loop {
if let Some(event) = rx.try_recv() {
return event;
}
tokio::task::yield_now().await;
}
})
.await
.expect("subscribe_now must capture events published synchronously after subscription");

match received {
ClientEvent::SyncCompleted { ops_applied } => assert_eq!(ops_applied, 7),
other => panic!("unexpected event: {other:?}"),
}
}
}
5 changes: 5 additions & 0 deletions crates/web/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,12 @@ futures = { workspace = true }
gloo-timers = { version = "0.4", features = ["futures"] }
serde = { workspace = true }
serde_json = "1"
serde-wasm-bindgen = { version = "0.6", optional = true }
blake3 = { workspace = true }

[dev-dependencies]
wasm-bindgen-test = "0.3"

[features]
default = []
test-hooks = ["dep:serde-wasm-bindgen", "willow-client/test-hooks"]
25 changes: 25 additions & 0 deletions crates/web/src/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,31 @@ pub fn App() -> impl IntoView {
let handle_inner = (*handle).clone().with_trust_store(trust_store.clone());
let handle: WebClientHandle = SendWrapper::new(handle_inner);

#[cfg(feature = "test-hooks")]
{
let inner_for_hooks = (*handle).clone();
let hooks = crate::test_hooks::WillowTestHooks::new(&inner_for_hooks);
if let Some(window) = web_sys::window() {
let _ = js_sys::Reflect::set(
&window,
&"__willow".into(),
&wasm_bindgen::JsValue::from(hooks),
);
}
// Subscribe synchronously: any ClientEvent published after this
// call is guaranteed to land in the dispatcher (broker mailbox is
// FIFO). An async subscribe would create a window between mount
// and confirmation in which boot-time events would be lost.
let rx = willow_client::event_receiver::EventReceiver::subscribe_now(
inner_for_hooks.event_broker(),
inner_for_hooks.system(),
);
let dispatcher = crate::test_hooks::install_push_dispatcher(rx);
// Leak: dispatcher must live for app lifetime; in wasm32 the
// process IS the app, so leaking is fine.
std::mem::forget(dispatcher);
}

// Provide context so child components can access the handle and state.
provide_context(handle.clone());
provide_context(app_state);
Expand Down
2 changes: 2 additions & 0 deletions crates/web/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ pub mod profile;
pub mod service_worker_bridge;
pub mod state;
pub mod state_bridge;
#[cfg(feature = "test-hooks")]
pub mod test_hooks;
pub mod trust_store;
pub mod util;
pub mod voice;
Loading