@@ -422,6 +422,33 @@ impl NodeClient {
422422 Ok ( ( ) )
423423 }
424424
425+ /// Publish raw payload on the namespace topic `ns/<hex(namespace_id)>`
426+ /// immediately, without the "wait for mesh, then publish anyway" loop of
427+ /// [`publish_on_namespace`](Self::publish_on_namespace).
428+ ///
429+ /// Returns the mesh peer count observed at publish time so a caller running
430+ /// its own re-announce loop (e.g. the fleet-join admission wait) can tell a
431+ /// publish into a live mesh apart from a publish into an empty mesh — the
432+ /// latter is lost forever because gossipsub does not replay. Re-announcing
433+ /// each poll cycle means a *later* mesh window still receives a fresh copy,
434+ /// which a single up-front publish (the bug this fixes) never could.
435+ ///
436+ /// Kept separate from [`publish_on_namespace`](Self::publish_on_namespace)
437+ /// so the up-front wait-then-publish semantics other callers rely on are
438+ /// untouched; this is the opt-in, per-cycle building block.
439+ pub async fn publish_on_namespace_now (
440+ & self ,
441+ namespace_id : [ u8 ; 32 ] ,
442+ payload : Vec < u8 > ,
443+ ) -> eyre:: Result < usize > {
444+ let topic_str = format ! ( "ns/{}" , hex:: encode( namespace_id) ) ;
445+ let topic = TopicHash :: from_raw ( topic_str) ;
446+
447+ let mesh_peers = self . network_client . mesh_peer_count ( topic. clone ( ) ) . await ;
448+ let _ignored = self . network_client . publish ( topic, payload) . await ?;
449+ Ok ( mesh_peers)
450+ }
451+
425452 pub async fn get_peers_count ( & self , context : Option < & ContextId > ) -> usize {
426453 let Some ( context) = context else {
427454 return self . network_client . peer_count ( ) . await ;
@@ -731,3 +758,217 @@ impl NodeClient {
731758 . await
732759 }
733760}
761+
762+ #[ cfg( test) ]
763+ mod publish_on_namespace_now_tests {
764+ //! Unit tests for the re-announce building block
765+ //! [`NodeClient::publish_on_namespace_now`] and the re-announce-until-
766+ //! admitted loop it powers in the fleet-join handler.
767+ //!
768+ //! The fleet-join admission wait lives in `calimero-server` (it needs the
769+ //! `ctx_client` admission read, which this crate does not see), so the loop
770+ //! itself is reproduced here against the same publish primitive. This is the
771+ //! smallest real unit: a live `NetworkClient` backed by a stub network actor
772+ //! that counts `Publish` messages and reports a settable mesh peer count —
773+ //! no libp2p transport, no server crate. The full owner-side admission path
774+ //! is covered by `calimero-node`'s `local_governance_node_e2e.rs`.
775+ //!
776+ //! Runs under `#[actix::test]` (single-threaded actix System) so the stub
777+ //! actor's mailbox is pumped by the same runtime that drives the client's
778+ //! `.await`s — `Actor::create` + `LazyRecipient::init`, the documented
779+ //! pattern from `calimero-utils-actix`'s own `lazy_tests.rs`.
780+
781+ use std:: sync:: atomic:: { AtomicUsize , Ordering } ;
782+ use std:: sync:: Arc ;
783+ use std:: time:: Duration ;
784+
785+ use actix:: Actor ;
786+ use calimero_blobstore:: config:: BlobStoreConfig ;
787+ use calimero_blobstore:: { BlobManager as BlobStore , FileSystem } ;
788+ use calimero_network_primitives:: client:: NetworkClient ;
789+ use calimero_network_primitives:: messages:: { MessageId , NetworkMessage } ;
790+ use calimero_store:: db:: InMemoryDB ;
791+ use calimero_store:: Store ;
792+ use calimero_utils_actix:: LazyRecipient ;
793+ use tokio:: sync:: { broadcast, mpsc} ;
794+
795+ use super :: { BlobManager , NodeClient , SyncClient } ;
796+
797+ /// Stub network actor: records how many times a `Publish` is requested and
798+ /// reports whatever mesh peer count the test sets via the shared atomic.
799+ /// Resolves `Publish`/`MeshPeerCount` outcomes so the awaiting client future
800+ /// completes; every other variant is dropped (none are reached here).
801+ struct CountingNetworkActor {
802+ publish_count : Arc < AtomicUsize > ,
803+ mesh_peers : Arc < AtomicUsize > ,
804+ }
805+
806+ impl Actor for CountingNetworkActor {
807+ type Context = actix:: Context < Self > ;
808+ }
809+
810+ impl actix:: Handler < NetworkMessage > for CountingNetworkActor {
811+ type Result = ( ) ;
812+
813+ fn handle ( & mut self , msg : NetworkMessage , _ctx : & mut Self :: Context ) -> Self :: Result {
814+ match msg {
815+ NetworkMessage :: MeshPeerCount { outcome, .. } => {
816+ let _ = outcome. send ( self . mesh_peers . load ( Ordering :: SeqCst ) ) ;
817+ }
818+ NetworkMessage :: Publish { outcome, .. } => {
819+ let _prev = self . publish_count . fetch_add ( 1 , Ordering :: SeqCst ) ;
820+ let _ = outcome. send ( Ok ( MessageId ( b"stub" . to_vec ( ) ) ) ) ;
821+ }
822+ _ => { }
823+ }
824+ }
825+ }
826+
827+ /// Build a `NodeClient` whose `network_client` is wired to a freshly started
828+ /// [`CountingNetworkActor`] on the current actix System. Only the network
829+ /// path is exercised by `publish_on_namespace_now`; the remaining fields are
830+ /// minimal real stubs. Returns the client plus the shared publish-count and
831+ /// mesh-peer atomics for assertions. The `TempDir` is returned so the
832+ /// caller keeps the blobstore filesystem alive for the test's duration.
833+ async fn make_client ( ) -> (
834+ NodeClient ,
835+ Arc < AtomicUsize > ,
836+ Arc < AtomicUsize > ,
837+ tempfile:: TempDir ,
838+ ) {
839+ let tmp = tempfile:: tempdir ( ) . expect ( "tempdir" ) ;
840+ let store = Store :: new ( Arc :: new ( InMemoryDB :: owned ( ) ) ) ;
841+
842+ let blob_cfg =
843+ BlobStoreConfig :: new ( tmp. path ( ) . to_path_buf ( ) . try_into ( ) . expect ( "utf8 blob path" ) ) ;
844+ let fs = FileSystem :: new ( & blob_cfg) . await . expect ( "blob fs" ) ;
845+ let blob_manager = BlobManager :: new ( BlobStore :: new ( store. clone ( ) , fs) ) ;
846+
847+ let network_recipient = LazyRecipient :: < NetworkMessage > :: new ( ) ;
848+ let network_client = NetworkClient :: new ( network_recipient. clone ( ) ) ;
849+
850+ let publish_count = Arc :: new ( AtomicUsize :: new ( 0 ) ) ;
851+ let mesh_peers = Arc :: new ( AtomicUsize :: new ( 0 ) ) ;
852+
853+ let actor = CountingNetworkActor {
854+ publish_count : Arc :: clone ( & publish_count) ,
855+ mesh_peers : Arc :: clone ( & mesh_peers) ,
856+ } ;
857+ let _addr = CountingNetworkActor :: create ( move |ctx| {
858+ assert ! ( network_recipient. init( ctx) , "network recipient init" ) ;
859+ actor
860+ } ) ;
861+
862+ let ( event_sender, _) = broadcast:: channel ( 16 ) ;
863+ let ( ctx_sync_tx, _ctx_sync_rx) = mpsc:: channel ( 8 ) ;
864+ let ( ns_sync_tx, _ns_sync_rx) = mpsc:: channel ( 8 ) ;
865+ let ( ns_join_tx, _ns_join_rx) = mpsc:: channel ( 8 ) ;
866+ let ( open_subgroup_join_tx, _open_rx) = mpsc:: channel ( 8 ) ;
867+ let sync_client =
868+ SyncClient :: new ( ctx_sync_tx, ns_sync_tx, ns_join_tx, open_subgroup_join_tx) ;
869+
870+ let node_client = NodeClient :: new (
871+ store,
872+ blob_manager,
873+ network_client,
874+ LazyRecipient :: new ( ) ,
875+ event_sender,
876+ sync_client,
877+ String :: new ( ) ,
878+ None ,
879+ ) ;
880+
881+ ( node_client, publish_count, mesh_peers, tmp)
882+ }
883+
884+ /// `publish_on_namespace_now` publishes exactly once per call and reports the
885+ /// mesh peer count observed at publish time (here: 0 — the empty-mesh case
886+ /// that silently dropped the one-shot announce before this fix).
887+ #[ actix:: test]
888+ async fn publishes_once_and_reports_empty_mesh ( ) {
889+ let ( client, publish_count, _mesh, _tmp) = make_client ( ) . await ;
890+
891+ let observed = client
892+ . publish_on_namespace_now ( [ 0x11 ; 32 ] , b"announce" . to_vec ( ) )
893+ . await
894+ . expect ( "publish_on_namespace_now" ) ;
895+
896+ assert_eq ! ( observed, 0 , "no mesh peers were set" ) ;
897+ assert_eq ! (
898+ publish_count. load( Ordering :: SeqCst ) ,
899+ 1 ,
900+ "exactly one publish per call"
901+ ) ;
902+ }
903+
904+ /// `publish_on_namespace_now` surfaces a non-zero mesh peer count when one
905+ /// is present — the signal a caller uses to know the announce landed in a
906+ /// live mesh rather than an empty one.
907+ #[ actix:: test]
908+ async fn reports_live_mesh_peer_count ( ) {
909+ let ( client, _publish_count, mesh, _tmp) = make_client ( ) . await ;
910+ mesh. store ( 2 , Ordering :: SeqCst ) ;
911+
912+ let observed = client
913+ . publish_on_namespace_now ( [ 0x33 ; 32 ] , b"announce" . to_vec ( ) )
914+ . await
915+ . expect ( "publish_on_namespace_now" ) ;
916+
917+ assert_eq ! ( observed, 2 , "must report the live mesh peer count" ) ;
918+ }
919+
920+ /// Locks in the P1 fix: a re-announce loop that publishes every cycle while
921+ /// not admitted publishes MORE THAN ONCE over the wait window (the one-shot
922+ /// bug published exactly once), and STOPS the moment admission is observed —
923+ /// no further announces after admitted. This mirrors the integrated loop in
924+ /// `crates/server/src/admin/handlers/tee/fleet_join.rs`.
925+ #[ actix:: test]
926+ async fn reannounce_loop_publishes_more_than_once_then_stops_on_admission ( ) {
927+ let ( client, publish_count, _mesh, _tmp) = make_client ( ) . await ;
928+
929+ // Mirror the fleet-join handler loop: publish up front, then on each
930+ // not-yet-admitted cycle re-check admission, sleep, then re-publish
931+ // (re-publish AFTER the sleep, as the handler does, so the first
932+ // re-announce doesn't fire back-to-back with the up-front publish).
933+ // Admission flips true after a few cycles; once true the loop must break
934+ // BEFORE publishing again. A fast poll keeps the test sub-second.
935+ const ADMIT_AFTER_CYCLES : usize = 3 ;
936+ const POLL : Duration = Duration :: from_millis ( 10 ) ;
937+ const MAX_CYCLES : usize = 50 ; // hard safety bound
938+
939+ // First (up-front) announce, as the handler does before its loop.
940+ let _ = client
941+ . publish_on_namespace_now ( [ 0x22 ; 32 ] , b"announce" . to_vec ( ) )
942+ . await
943+ . expect ( "first announce" ) ;
944+
945+ let mut cycles = 0 ;
946+ let mut admitted = false ;
947+ while cycles < MAX_CYCLES {
948+ // Admission check FIRST so we never re-announce after admitted.
949+ if cycles >= ADMIT_AFTER_CYCLES {
950+ admitted = true ;
951+ break ;
952+ }
953+ tokio:: time:: sleep ( POLL ) . await ;
954+ let _ = client
955+ . publish_on_namespace_now ( [ 0x22 ; 32 ] , b"announce" . to_vec ( ) )
956+ . await
957+ . expect ( "re-announce" ) ;
958+ cycles += 1 ;
959+ }
960+
961+ assert ! ( admitted, "loop must observe admission" ) ;
962+ let total = publish_count. load ( Ordering :: SeqCst ) ;
963+ assert ! (
964+ total > 1 ,
965+ "re-announce must publish more than once over the wait window, got {total}"
966+ ) ;
967+ // up-front (1) + one per not-yet-admitted cycle (ADMIT_AFTER_CYCLES).
968+ assert_eq ! (
969+ total,
970+ 1 + ADMIT_AFTER_CYCLES ,
971+ "must stop announcing the instant admission is observed"
972+ ) ;
973+ }
974+ }
0 commit comments