feat(brahman-handshake): Fase 1 — handshake brahman sobre stream libp2p

Segundo paso del plan "el encuentro entre Entes no se restringe a
local". El protocolo brahman (Hello / HelloAck / Ping / Pong /
MatchEvent / Farewell, frames postcard length-prefixed) ahora tambien
viaja sobre streams libp2p de la malla brahman-net — el mismo Init
acepta sesiones por Unix socket Y por libp2p indistintamente, y un
consumer remoto puede dial-ar al multiaddr y completar handshake.

Cambios:

- Session<S> y Client<S> genericos: ambos dejan de estar atados a
  UnixStream y pasan a ser genericos sobre S: AsyncRead + AsyncWrite
  + Unpin + Send + 'static. El path Unix queda como
  Client = Client<UnixStream> (default generico). Constructores
  nuevos: Server::session_from_stream(stream),
  Client::connect_with_stream(stream, card, wit).

- Refactor del post-handshake con split: tokio::select! sobre
  &mut self.stream requeria S: Sync indirectamente, y libp2p::Stream
  no es Sync. Reemplazado por tokio::io::split(stream) -> reader loop
  principal + writer task separada que drena el push channel. Writer
  compartido bajo Arc<Mutex<WriteHalf<S>>> para serializar Pong/Error
  inline con los MatchEvents pusheados. Cleanup garantizado en todas
  las ramas. La logica del post-handshake migra a funciones libres
  (run_post_handshake, handle_inbound_frame, cleanup,
  broadcast_match_diffs, do_handshake, register_session,
  validate_hello).

- Nuevo modulo brahman-handshake::network:
  - BRAHMAN_HANDSHAKE_PROTOCOL = "/brahman/handshake/1.0.0"
  - LibP2pHandshakeStream = Compat<libp2p::Stream>
  - run_libp2p_accept_loop(server, net): accept loop que delega cada
    stream entrante a session_from_stream(stream.compat()). Sesiones
    libp2p y Unix conviven en el mismo Server — comparten broker,
    push table, last_matches.
  - connect_libp2p(net, peer, card, wit): abre stream libp2p al peer
    y arranca handshake.
  - NetworkError tipado.

Deps: brahman-handshake gana brahman-net, futures, tokio-util.
brahman-net re-exporta Multiaddr, PeerId, Stream, StreamProtocol,
Protocol, OpenStreamError para que callers no necesiten dep directa
a libp2p.

Tests: 9 verdes en el path Unix (sin regresion). Nuevo
tests/network_libp2p.rs E2E que arma server con BrahmanNet, hace
listen TCP, monta accept loop; cliente con su propio BrahmanNet
dial-ea al peer_id, completa handshake remoto, ping, farewell.
Verifica que la sesion se registro durante la conversacion y se
removio tras farewell.
This commit is contained in:
Sergio
2026-05-09 12:51:43 +00:00
parent ad0d475a2e
commit 73dadbb166
9 changed files with 723 additions and 281 deletions
+55
View File
@@ -6,6 +6,61 @@ ratio/diff ver `git show <sha>`.
## 2026-05-09 ## 2026-05-09
### feat(brahman-handshake): Fase 1 — handshake brahman sobre stream libp2p
Segundo paso del plan "el encuentro entre Entes no se restringe a
local". El protocolo brahman (Hello / HelloAck / Ping / Pong /
MatchEvent / Farewell, frames postcard length-prefixed) ahora también
viaja sobre streams libp2p de la malla `brahman-net` — el mismo Init
acepta sesiones por Unix socket Y por libp2p indistintamente, y un
consumer remoto puede dial-ar al multiaddr y completar handshake.
Cambios:
- **`Session<S>` y `Client<S>` genéricos**: ambos dejan de estar atados
a `UnixStream` y pasan a ser genéricos sobre `S: AsyncRead +
AsyncWrite + Unpin + Send + 'static`. El path Unix queda como
`Client = Client<UnixStream>` (default genérico). Constructores
nuevos: `Server::session_from_stream(stream)`,
`Client::connect_with_stream(stream, card, wit)`.
- **Refactor del post-handshake con split**: `tokio::select!` sobre
`&mut self.stream` requería `S: Sync` indirectamente, y
`libp2p::Stream` no es Sync. Reemplazado por
`tokio::io::split(stream)` → reader loop principal + writer task
separada que drena el push channel. Writer compartido bajo
`Arc<Mutex<WriteHalf<S>>>` para serializar Pong/Error inline con
los MatchEvents pusheados. Cleanup garantizado en todas las ramas.
La lógica del post-handshake migra a funciones libres
(`run_post_handshake`, `handle_inbound_frame`, `cleanup`,
`broadcast_match_diffs`, `do_handshake`, `register_session`,
`validate_hello`).
- **Nuevo módulo `brahman-handshake::network`**:
- `BRAHMAN_HANDSHAKE_PROTOCOL = "/brahman/handshake/1.0.0"`.
- `LibP2pHandshakeStream = Compat<libp2p::Stream>` (alias del
stream una vez convertido al mundo `tokio::io`).
- `run_libp2p_accept_loop(server, net)`: bucle accept sobre el
protocolo que delega cada stream entrante a una `Session`
construida vía `server.session_from_stream(stream.compat())`.
Sesiones libp2p y Unix conviven en el mismo `Server`
comparten broker, push table, last_matches.
- `connect_libp2p(net, peer, card, wit)`: abre stream libp2p al
`peer` y arranca handshake.
- `NetworkError` tipado (`OpenStream`, `Handshake`, `AcceptStream`).
Deps: `brahman-handshake` gana `brahman-net`, `futures`, `tokio-util`.
`brahman-net` re-exporta `Multiaddr`, `PeerId`, `Stream`,
`StreamProtocol`, `Protocol`, `OpenStreamError` para que callers no
necesiten dep directa a libp2p.
Tests:
- 9 tests unit + integration verdes (sin regresión en el path Unix).
- Nuevo `tests/network_libp2p.rs`: test E2E que arma server con
Unix socket + BrahmanNet, hace listen TCP, monta el accept loop;
cliente con su propio BrahmanNet dial-ea al peer_id, completa
handshake remoto, pinguea, farewell. Verifica que la sesión se
registró durante la conversación y se removió tras farewell.
Próximo: Fase 2 (discovery remoto vía DHT — anunciar Cards bajo
flow type, broker query local + remoto).
### feat(brahman-net): capa P2P compartida — Fase 0 (extracción del swarm libp2p) ### feat(brahman-net): capa P2P compartida — Fase 0 (extracción del swarm libp2p)
Primer paso del plan "el encuentro entre Entes no se restringe a local". Primer paso del plan "el encuentro entre Entes no se restringe a local".
El swarm libp2p que vivía dentro de `minga-p2p::network` (282 LOC) sale El swarm libp2p que vivía dentro de `minga-p2p::network` (282 LOC) sale
Generated
+3
View File
@@ -1201,11 +1201,14 @@ dependencies = [
"anyhow", "anyhow",
"brahman-broker", "brahman-broker",
"brahman-card", "brahman-card",
"brahman-net",
"futures",
"postcard", "postcard",
"serde", "serde",
"tempfile", "tempfile",
"thiserror 2.0.18", "thiserror 2.0.18",
"tokio", "tokio",
"tokio-util",
"tracing", "tracing",
"ulid", "ulid",
] ]
+4 -1
View File
@@ -6,14 +6,17 @@ rust-version.workspace = true
license.workspace = true license.workspace = true
authors.workspace = true authors.workspace = true
publish.workspace = true publish.workspace = true
description = "Brahman — handshake runtime Init↔módulo sobre Unix socket (postcard frames)." description = "Brahman — handshake runtime Init↔módulo. Local sobre Unix socket; remoto sobre stream libp2p (brahman-net)."
[dependencies] [dependencies]
brahman-card = { path = "../brahman-card" } brahman-card = { path = "../brahman-card" }
brahman-broker = { path = "../brahman-broker" } brahman-broker = { path = "../brahman-broker" }
brahman-net = { path = "../../shared/brahman-net" }
futures = { workspace = true }
serde = { workspace = true } serde = { workspace = true }
postcard = { workspace = true } postcard = { workspace = true }
tokio = { workspace = true } tokio = { workspace = true }
tokio-util = { workspace = true }
thiserror = { workspace = true } thiserror = { workspace = true }
ulid = { workspace = true } ulid = { workspace = true }
tracing = { workspace = true } tracing = { workspace = true }
+32 -9
View File
@@ -6,6 +6,7 @@ use std::time::Duration;
use brahman_card::{Card, WitInterface, CARD_SCHEMA_VERSION}; use brahman_card::{Card, WitInterface, CARD_SCHEMA_VERSION};
use thiserror::Error; use thiserror::Error;
use tokio::io::{AsyncRead, AsyncWrite};
use tokio::net::UnixStream; use tokio::net::UnixStream;
use crate::codec::{read_frame, write_frame}; use crate::codec::{read_frame, write_frame};
@@ -34,33 +35,55 @@ pub enum ClientError {
/// y tiene su `SessionId`. Los `MatchEvent` recibidos durante operaciones /// y tiene su `SessionId`. Los `MatchEvent` recibidos durante operaciones
/// request/response se buferean en `pending_events` y se obtienen vía /// request/response se buferean en `pending_events` y se obtienen vía
/// [`Client::take_event`] o [`Client::await_event`]. /// [`Client::take_event`] o [`Client::await_event`].
///
/// Genérico sobre el transport (`AsyncRead + AsyncWrite + Unpin + Send`):
/// funciona indistintamente sobre `UnixStream` (path local) o sobre un
/// stream libp2p wrapped con `tokio_util::compat` (path remoto, vía
/// `brahman_handshake::network`).
#[derive(Debug)] #[derive(Debug)]
pub struct Client { pub struct Client<S = UnixStream> {
stream: UnixStream, stream: S,
session: SessionId, session: SessionId,
server_info: HelloAck, server_info: HelloAck,
pending_events: VecDeque<MatchEvent>, pending_events: VecDeque<MatchEvent>,
} }
impl Client { impl Client<UnixStream> {
/// Conecta como módulo agnóstico (sin WIT). Equivalente a /// Conecta como módulo agnóstico (sin WIT) sobre Unix socket.
/// `connect_with(path, card, None)`. /// Equivalente a `connect_with(path, card, None)`.
pub async fn connect(path: impl AsRef<Path>, card: Card) -> Result<Self, ClientError> { pub async fn connect(path: impl AsRef<Path>, card: Card) -> Result<Self, ClientError> {
Self::connect_with(path, card, None).await Self::connect_with(path, card, None).await
} }
/// Conecta al socket enviando Hello con la Card dada y opcionalmente /// Conecta al socket Unix enviando Hello con la Card dada y
/// una `WitInterface` ya extraída. Si `wit` es `Some`, el server /// opcionalmente una `WitInterface` ya extraída. Si `wit` es `Some`,
/// registra el módulo como "consciente". /// el server registra el módulo como "consciente".
pub async fn connect_with( pub async fn connect_with(
path: impl AsRef<Path>, path: impl AsRef<Path>,
card: Card, card: Card,
wit: Option<WitInterface>, wit: Option<WitInterface>,
) -> Result<Self, ClientError> {
let stream = UnixStream::connect(path).await?;
Self::connect_with_stream(stream, card, wit).await
}
}
impl<S> Client<S>
where
S: AsyncRead + AsyncWrite + Unpin + Send,
{
/// Constructor genérico: arranca el handshake sobre un stream
/// arbitrario que ya esté abierto. Es el punto de entrada para
/// transports alternativos (libp2p, in-memory para tests, etc.)
/// que reusan toda la lógica del handshake post-stream-open.
pub async fn connect_with_stream(
mut stream: S,
card: Card,
wit: Option<WitInterface>,
) -> Result<Self, ClientError> { ) -> Result<Self, ClientError> {
card.validate() card.validate()
.map_err(|e| ClientError::InvalidCard(e.to_string()))?; .map_err(|e| ClientError::InvalidCard(e.to_string()))?;
let mut stream = UnixStream::connect(path).await?;
let hello = Hello { let hello = Hello {
schema_version: CARD_SCHEMA_VERSION, schema_version: CARD_SCHEMA_VERSION,
protocol_version: brahman_card::PROTOCOL_VERSION.to_string(), protocol_version: brahman_card::PROTOCOL_VERSION.to_string(),
+1
View File
@@ -21,6 +21,7 @@ pub mod codec;
pub mod messages; pub mod messages;
pub mod server; pub mod server;
pub mod client; pub mod client;
pub mod network;
pub mod transport; pub mod transport;
pub use brahman_card::PROTOCOL_VERSION; pub use brahman_card::PROTOCOL_VERSION;
@@ -0,0 +1,140 @@
//! Backend libp2p del handshake brahman: el mismo protocolo (Hello /
//! HelloAck / Ping / Pong / MatchEvent / Farewell, frames postcard
//! length-prefixed) ahora también viaja sobre streams libp2p de la
//! malla `brahman-net`.
//!
//! El servidor expone el bucle [`run_libp2p_accept_loop`] que acepta
//! streams del protocolo `BRAHMAN_HANDSHAKE_PROTOCOL` y los delega al
//! mismo `Server` que ya escucha por Unix socket — la `Session` es
//! genérica sobre el transporte, así que ambas vías comparten broker,
//! tablas de sesiones, push de MatchEvents, todo.
//!
//! El cliente se conecta vía [`connect_libp2p`]: abre un stream
//! libp2p hacia un `PeerId` ya conocido y arranca el handshake como
//! cualquier `Client`.
//!
//! Identidad: cada nodo libp2p tiene su `PeerId` (ed25519 derivado).
//! La identidad del Ente (Card.id ULID + futura firma) viaja en el
//! Hello, como en el path Unix. Trust remoto (verificación de firma
//! antes de aceptar el Hello) es Fase 3.
//!
//! Ejemplo (servidor — Arje):
//! ```ignore
//! let server = Arc::new(Server::bind("/run/brahman-init.sock", config)?);
//! let net = Arc::new(BrahmanNet::new()?);
//! net.listen("/ip4/0.0.0.0/tcp/4101".parse()?).await;
//!
//! tokio::spawn(brahman_handshake::network::run_libp2p_accept_loop(
//! server.clone(),
//! net.clone(),
//! ));
//! // Server::run sigue escuchando Unix en paralelo.
//! ```
//!
//! Ejemplo (cliente — sidecar de un Ente remoto):
//! ```ignore
//! let net = BrahmanNet::new()?;
//! net.dial(remote_multiaddr);
//! let mut client = brahman_handshake::network::connect_libp2p(
//! &net, peer_id, my_card, None,
//! ).await?;
//! client.ping().await?;
//! ```
use std::sync::Arc;
use brahman_card::{Card, WitInterface};
use brahman_net::{BrahmanNet, OpenStreamError, PeerId, Stream, StreamProtocol};
use futures::StreamExt;
use tokio_util::compat::{Compat, FuturesAsyncReadCompatExt};
use tracing::warn;
use crate::client::{Client, ClientError};
use crate::server::Server;
/// Sub-protocolo del handshake brahman sobre streams libp2p.
pub const BRAHMAN_HANDSHAKE_PROTOCOL: StreamProtocol =
StreamProtocol::new("/brahman/handshake/1.0.0");
/// Tipo del stream que ve la lógica del handshake una vez convertido
/// del mundo `futures::AsyncRead/Write` (libp2p) al mundo
/// `tokio::io::AsyncRead/Write` (resto del crate).
pub type LibP2pHandshakeStream = Compat<Stream>;
/// Errores específicos del backend libp2p.
#[derive(Debug, thiserror::Error)]
pub enum NetworkError {
#[error("abrir stream libp2p: {0}")]
OpenStream(#[from] OpenStreamError),
#[error("handshake: {0}")]
Handshake(#[from] ClientError),
#[error("aceptar stream libp2p: {0}")]
AcceptStream(String),
}
/// Loop de aceptación de streams libp2p del protocolo handshake.
/// Cada stream entrante se construye como `Session` reutilizando las
/// tablas compartidas del `Server`, así que conviven con sesiones
/// Unix indistinguibles.
///
/// Vive hasta que el control libp2p se cierre o el caller drop el
/// future. Errores por sesión se loggean (no tumban el loop).
pub async fn run_libp2p_accept_loop(
server: Arc<Server>,
net: Arc<BrahmanNet>,
) -> Result<(), NetworkError> {
let mut control = net.control.clone();
let mut incoming = control
.accept(BRAHMAN_HANDSHAKE_PROTOCOL)
.map_err(|e| NetworkError::AcceptStream(e.to_string()))?;
while let Some((peer, stream)) = incoming.next().await {
let server = server.clone();
// .compat() debe pasar al spawn ADENTRO; si lo hacemos afuera
// y capturamos `Compat<Stream>` en la closure, el future
// resultante hereda traits que dyn AsyncReadWrite no satisface
// (compatibility con thread-safety de tokio::spawn).
tokio::spawn(handle_libp2p_session(server, stream, peer));
}
Ok(())
}
async fn handle_libp2p_session(
server: Arc<Server>,
stream: Stream,
peer: PeerId,
) {
let session = server.session_from_stream(stream.compat());
if let Err(e) = session.handle().await {
warn!(
target: "brahman_handshake::network",
peer = %peer,
error = %e,
"sesión libp2p terminó con error"
);
}
}
/// Conecta como cliente a un Ente remoto vía libp2p y completa el
/// handshake. Requiere que `net` ya tenga conexión (o pueda dial-ar)
/// al `peer`; típicamente el caller hace `net.dial(multiaddr)` antes.
///
/// Devuelve un `Client` típico — los métodos `ping`, `await_event`,
/// `farewell` funcionan idéntico al path Unix. El stream subyacente
/// es libp2p convertido vía `tokio_util::compat`.
pub async fn connect_libp2p(
net: &BrahmanNet,
peer: PeerId,
card: Card,
wit: Option<WitInterface>,
) -> Result<Client<LibP2pHandshakeStream>, NetworkError> {
let mut control = net.control.clone();
let stream = control
.open_stream(peer, BRAHMAN_HANDSHAKE_PROTOCOL)
.await?;
let client = Client::connect_with_stream(stream.compat(), card, wit).await?;
Ok(client)
}
+201 -102
View File
@@ -7,6 +7,7 @@ use std::time::{SystemTime, UNIX_EPOCH};
use brahman_broker::{Broker, Endpoint}; use brahman_broker::{Broker, Endpoint};
use brahman_card::{Card, ResolvedCard, WitInterface, CARD_SCHEMA_VERSION}; use brahman_card::{Card, ResolvedCard, WitInterface, CARD_SCHEMA_VERSION};
use tokio::io::{split, AsyncRead, AsyncWrite, WriteHalf};
use tokio::net::{UnixListener, UnixStream}; use tokio::net::{UnixListener, UnixStream};
use tokio::sync::{mpsc, Mutex}; use tokio::sync::{mpsc, Mutex};
use tracing::{debug, warn}; use tracing::{debug, warn};
@@ -93,17 +94,34 @@ impl Server {
self.sessions.clone() self.sessions.clone()
} }
/// Acepta UNA conexión, devuelve la `Session` lista para `handle()`. /// Acepta UNA conexión Unix, devuelve la `Session` lista para `handle()`.
/// No corre el handler — eso es responsabilidad del llamante. /// No corre el handler — eso es responsabilidad del llamante.
pub async fn accept_one(&self) -> std::io::Result<Session> { pub async fn accept_one(&self) -> std::io::Result<Session<UnixStream>> {
let (stream, _addr) = self.listener.accept().await?; let (stream, _addr) = self.listener.accept().await?;
Ok(Session { Ok(self.session_from_stream(stream))
}
/// Construye una `Session` a partir de un stream arbitrario que
/// implemente `AsyncRead + AsyncWrite + Unpin + Send`. Es el
/// punto de entrada para transports alternativos (libp2p en
/// `brahman_handshake::network`, in-memory para tests, etc.) que
/// quieren reutilizar la lógica del handshake sin venir por el
/// listener Unix.
///
/// Las tablas compartidas (sessions/push/last_matches/broker) se
/// clonan, así que sesiones construidas por esta vía conviven
/// indistinguibles en el mismo `Server`.
pub fn session_from_stream<S>(&self, stream: S) -> Session<S>
where
S: AsyncRead + AsyncWrite + Unpin + Send + 'static,
{
Session {
stream, stream,
sessions: self.sessions.clone(), sessions: self.sessions.clone(),
push_table: self.push_table.clone(), push_table: self.push_table.clone(),
last_matches: self.last_matches.clone(), last_matches: self.last_matches.clone(),
config: self.config.clone(), config: self.config.clone(),
}) }
} }
/// Loop de aceptación: cada conexión se despacha en una task separada. /// Loop de aceptación: cada conexión se despacha en una task separada.
@@ -131,81 +149,160 @@ impl Drop for Server {
} }
} }
/// Conexión individual aceptada por el servidor. /// Conexión individual aceptada por el servidor. Genérica sobre el
pub struct Session { /// transport — funciona indistinguiblemente sobre `UnixStream` (modo
stream: UnixStream, /// local), libp2p stream wrapped con `tokio_util::compat`, in-memory
/// duplex (tests), etc.
pub struct Session<S> {
stream: S,
sessions: SessionRegistry, sessions: SessionRegistry,
push_table: SessionTxTable, push_table: SessionTxTable,
last_matches: LastMatches, last_matches: LastMatches,
config: ServerConfig, config: ServerConfig,
} }
impl Session { impl<S> Session<S>
/// Procesa la conexión hasta `Farewell` o EOF: handshake + loop de pings. where
/// Garantiza cleanup (sessions + broker) sin importar la rama de salida. S: AsyncRead + AsyncWrite + Unpin + Send + 'static,
pub async fn handle(mut self) -> std::io::Result<()> { {
let session_id = match self.do_handshake().await? { /// Procesa la conexión hasta `Farewell` o EOF.
///
/// Estructura: handshake (sobre el stream entero) → split en
/// halves (read + write) → reader loop principal + writer task
/// que drena el push channel. Garantiza cleanup (sessions + broker
/// + canales) sin importar la rama de salida.
///
/// El split es necesario para soportar streams `!Sync` como
/// `libp2p::Stream`: `tokio::select!` sobre `&mut self.stream`
/// requeriría `S: Sync`. Con `tokio::io::split` cada mitad va a
/// su propia task, eliminando el requirement y permitiendo que
/// la misma `Session` sirva indistinta sobre Unix socket o
/// stream libp2p remoto.
pub async fn handle(self) -> std::io::Result<()> {
let Self {
mut stream,
sessions,
push_table,
last_matches,
config,
} = self;
let session_id = match do_handshake(&mut stream, &config, &sessions).await? {
Some(id) => id, Some(id) => id,
None => return Ok(()), // Hello rechazado, no se registró nada None => return Ok(()), // Hello rechazado, no se registró nada
}; };
let result = self.run_post_handshake(session_id).await; let result = run_post_handshake(
self.cleanup(session_id).await; stream,
session_id,
push_table.clone(),
last_matches.clone(),
config.clone(),
)
.await;
cleanup(
session_id,
&sessions,
&push_table,
&last_matches,
&config,
)
.await;
result result
} }
async fn run_post_handshake(&mut self, session_id: SessionId) -> std::io::Result<()> { }
// ============================================================================
// Free functions (post-refactor): la lógica del post-handshake corre sobre
// halves del stream; no necesita más `&mut Session<S>` y por eso vive afuera.
// ============================================================================
async fn run_post_handshake<S>(
stream: S,
session_id: SessionId,
push_table: SessionTxTable,
last_matches: LastMatches,
config: ServerConfig,
) -> std::io::Result<()>
where
S: AsyncRead + AsyncWrite + Unpin + Send + 'static,
{
// Canal por donde el server inyecta frames push (MatchEvent, etc.). // Canal por donde el server inyecta frames push (MatchEvent, etc.).
let (tx, mut rx) = mpsc::channel::<Frame>(PUSH_CHANNEL_CAPACITY); let (tx, mut rx) = mpsc::channel::<Frame>(PUSH_CHANNEL_CAPACITY);
self.push_table.lock().await.insert(session_id, tx); push_table.lock().await.insert(session_id, tx);
// Tras registrar el canal, recomputar matches y emitir diffs a // Tras registrar el canal, recomputar matches y emitir diffs.
// todas las sesiones afectadas (incluida ésta, si tiene inputs). broadcast_match_diffs(&push_table, &last_matches, &config).await;
self.broadcast_match_diffs().await;
loop { // Split: reader en el hilo principal, writer compartido bajo Mutex
tokio::select! { // entre la writer task (push channel) y el handler de inbound
// Frame entrante del cliente. // (que escribe Pong/Error). Mutex serializa writes; es OK porque
res = read_frame(&mut self.stream) => { // la frecuencia de writes por sesión es baja.
match res { let (mut reader, writer) = split(stream);
Ok(frame) => { let writer = Arc::new(Mutex::new(writer));
if !self.handle_inbound_frame(session_id, frame).await? {
return Ok(()); // Writer task: drena el push channel.
let writer_for_push = writer.clone();
let writer_task = tokio::spawn(async move {
while let Some(frame) = rx.recv().await {
let mut w = writer_for_push.lock().await;
if write_frame(&mut *w, &frame).await.is_err() {
break;
} }
} }
});
// Reader loop principal.
let result: std::io::Result<()> = loop {
match read_frame(&mut reader).await {
Ok(frame) => match handle_inbound_frame(session_id, frame, &writer).await {
Ok(true) => continue,
Ok(false) => break Ok(()),
Err(e) => break Err(e),
},
Err(e) if e.kind() == std::io::ErrorKind::UnexpectedEof => { Err(e) if e.kind() == std::io::ErrorKind::UnexpectedEof => {
debug!(session = %session_id, "cliente cerró sin Farewell"); debug!(session = %session_id, "cliente cerró sin Farewell");
return Ok(()); break Ok(());
}
Err(e) => return Err(e),
}
}
// Frame push desde el server (MatchEvent).
Some(frame) = rx.recv() => {
write_frame(&mut self.stream, &frame).await?;
}
}
} }
Err(e) => break Err(e),
} }
};
/// Maneja un frame entrante. Devuelve `Ok(false)` si la sesión debe // Cerrar writer: drop nuestro Arc y abortar la task. La task
/// cerrarse limpiamente (Farewell con session-id correcto). // saldrá igual cuando rx se cierre por drop del último Sender,
async fn handle_inbound_frame( // pero abortarla es más rápido que esperar a que próximo recv()
&mut self, // observe el cierre.
drop(writer);
writer_task.abort();
let _ = writer_task.await;
result
}
async fn handle_inbound_frame<S>(
session_id: SessionId, session_id: SessionId,
frame: Frame, frame: Frame,
) -> std::io::Result<bool> { writer: &Arc<Mutex<WriteHalf<S>>>,
) -> std::io::Result<bool>
where
S: AsyncRead + AsyncWrite + Unpin + Send + 'static,
{
match frame { match frame {
Frame::Ping(Ping { session }) if session == session_id => { Frame::Ping(Ping { session }) if session == session_id => {
let pong = Pong { let pong = Pong {
timestamp_ms: now_ms(), timestamp_ms: now_ms(),
}; };
write_frame(&mut self.stream, &Frame::Pong(pong)).await?; let mut w = writer.lock().await;
write_frame(&mut *w, &Frame::Pong(pong)).await?;
Ok(true) Ok(true)
} }
Frame::Ping(_) => { Frame::Ping(_) => {
let mut w = writer.lock().await;
write_frame( write_frame(
&mut self.stream, &mut *w,
&Frame::Error(HandshakeError::Unauthorized( &Frame::Error(HandshakeError::Unauthorized(
"session-id no coincide".into(), "session-id no coincide".into(),
)), )),
@@ -215,8 +312,9 @@ impl Session {
} }
Frame::Farewell(Farewell { session }) if session == session_id => Ok(false), Frame::Farewell(Farewell { session }) if session == session_id => Ok(false),
Frame::Farewell(_) => { Frame::Farewell(_) => {
let mut w = writer.lock().await;
write_frame( write_frame(
&mut self.stream, &mut *w,
&Frame::Error(HandshakeError::Unauthorized( &Frame::Error(HandshakeError::Unauthorized(
"session-id no coincide".into(), "session-id no coincide".into(),
)), )),
@@ -225,8 +323,9 @@ impl Session {
Ok(true) Ok(true)
} }
_ => { _ => {
let mut w = writer.lock().await;
write_frame( write_frame(
&mut self.stream, &mut *w,
&Frame::Error(HandshakeError::Rejected( &Frame::Error(HandshakeError::Rejected(
"frame inesperado tras handshake".into(), "frame inesperado tras handshake".into(),
)), )),
@@ -235,39 +334,44 @@ impl Session {
Ok(true) Ok(true)
} }
} }
} }
/// Limpieza atómica de TRES vistas: registro de sesiones + broker + /// Limpieza atómica de TRES vistas: registro de sesiones + broker +
/// canal push. Se ejecuta tanto si la sesión cierra por Farewell, EOF, /// canal push. Se ejecuta tanto si la sesión cierra por Farewell, EOF,
/// o error. Tras desregistrar, emite diffs a las sesiones que perdieron /// o error. Tras desregistrar, emite diffs a las sesiones que perdieron
/// el match contra ésta. /// el match contra ésta.
async fn cleanup(&self, session_id: SessionId) { async fn cleanup(
self.sessions.lock().await.remove(&session_id); session_id: SessionId,
self.push_table.lock().await.remove(&session_id); sessions: &SessionRegistry,
self.last_matches.lock().await.remove(&session_id); push_table: &SessionTxTable,
if let Some(broker) = &self.config.broker { last_matches: &LastMatches,
config: &ServerConfig,
) {
sessions.lock().await.remove(&session_id);
push_table.lock().await.remove(&session_id);
last_matches.lock().await.remove(&session_id);
if let Some(broker) = &config.broker {
broker.lock().await.unregister(session_id); broker.lock().await.unregister(session_id);
} }
self.broadcast_match_diffs().await; broadcast_match_diffs(push_table, last_matches, config).await;
} }
/// Recomputa los matches para todas las sesiones registradas y empuja /// Recomputa los matches para todas las sesiones registradas y empuja
/// `MatchEvent::Available` / `MatchEvent::Lost` por las que cambiaron /// `MatchEvent::Available` / `MatchEvent::Lost` por las que cambiaron
/// respecto al último estado conocido. /// respecto al último estado conocido.
/// async fn broadcast_match_diffs(
/// Se llama tras cada `register_session` y `cleanup`. Las inserciones push_table: &SessionTxTable,
/// al canal usan `try_send` (non-blocking); si el cliente está lento last_matches: &LastMatches,
/// y se llena el buffer, los eventos extra se pierden — es ephemeral config: &ServerConfig,
/// y el cliente puede re-consultar el estado vía `brahman-status`. ) {
async fn broadcast_match_diffs(&self) { let broker = match &config.broker {
let broker = match &self.config.broker {
Some(b) => b, Some(b) => b,
None => return, None => return,
}; };
let b = broker.lock().await; let b = broker.lock().await;
let push_table = self.push_table.lock().await; let push_table = push_table.lock().await;
let mut last = self.last_matches.lock().await; let mut last = last_matches.lock().await;
debug!( debug!(
target: "brahman_handshake::broadcast", target: "brahman_handshake::broadcast",
@@ -276,14 +380,13 @@ impl Session {
"broadcast_match_diffs" "broadcast_match_diffs"
); );
// Snapshot de cards para no tener que sostener el lock del broker.
let cards: Vec<_> = b.cards().cloned().collect(); let cards: Vec<_> = b.cards().cloned().collect();
for cons in &cards { for cons in &cards {
let cons_session = cons.session; let cons_session = cons.session;
let tx = match push_table.get(&cons_session) { let tx = match push_table.get(&cons_session) {
Some(tx) => tx, Some(tx) => tx,
None => continue, // todavía no tiene canal push None => continue,
}; };
let cons_last = last.entry(cons_session).or_default(); let cons_last = last.entry(cons_session).or_default();
@@ -297,9 +400,6 @@ impl Session {
} }
if let Some(m) = &new_match { if let Some(m) = &new_match {
// Resolvemos el service_socket del productor desde
// la BrokeredCard; pasarlo en el evento permite al
// consumer conectar directo sin discovery extra.
let producer_service_socket = b let producer_service_socket = b
.cards() .cards()
.find(|c| c.session == m.producer.session) .find(|c| c.session == m.producer.session)
@@ -328,7 +428,6 @@ impl Session {
"Available pushed" "Available pushed"
); );
} else { } else {
// Tenía match, ahora no.
let event = MatchEvent { let event = MatchEvent {
kind: MatchEventKind::Lost, kind: MatchEventKind::Lost,
consumer_flow: input.name.clone(), consumer_flow: input.name.clone(),
@@ -350,17 +449,23 @@ impl Session {
} }
} }
} }
} }
/// Lee el Hello, valida, registra la sesión y emite HelloAck. /// Lee el Hello, valida, registra la sesión y emite HelloAck.
/// Devuelve `Some(session_id)` si el handshake fue exitoso. async fn do_handshake<S>(
async fn do_handshake(&mut self) -> std::io::Result<Option<SessionId>> { stream: &mut S,
let frame = read_frame(&mut self.stream).await?; config: &ServerConfig,
sessions: &SessionRegistry,
) -> std::io::Result<Option<SessionId>>
where
S: AsyncRead + AsyncWrite + Unpin,
{
let frame = read_frame(stream).await?;
let hello = match frame { let hello = match frame {
Frame::Hello(h) => h, Frame::Hello(h) => h,
_ => { _ => {
write_frame( write_frame(
&mut self.stream, stream,
&Frame::Error(HandshakeError::Rejected( &Frame::Error(HandshakeError::Rejected(
"primer frame debe ser Hello".into(), "primer frame debe ser Hello".into(),
)), )),
@@ -370,36 +475,34 @@ impl Session {
} }
}; };
if let Some(err) = self.validate_hello(&hello) { if let Some(err) = validate_hello(&hello) {
write_frame(&mut self.stream, &Frame::Error(err)).await?; write_frame(stream, &Frame::Error(err)).await?;
return Ok(None); return Ok(None);
} }
let session_id = Ulid::new(); let session_id = Ulid::new();
// WireCard → Card: extensiones quedan vacías post-wire (es el contrato).
let card: Card = hello.card.into(); let card: Card = hello.card.into();
self.register_session(session_id, card, hello.wit).await; register_session(session_id, card, hello.wit, config, sessions).await;
let ack = HelloAck { let ack = HelloAck {
server_version: crate::HANDSHAKE_VERSION.to_string(), server_version: crate::HANDSHAKE_VERSION.to_string(),
protocol_version: brahman_card::PROTOCOL_VERSION.to_string(), protocol_version: brahman_card::PROTOCOL_VERSION.to_string(),
session: session_id, session: session_id,
init_attached: self.config.init_attached, init_attached: config.init_attached,
}; };
write_frame(&mut self.stream, &Frame::HelloAck(ack)).await?; write_frame(stream, &Frame::HelloAck(ack)).await?;
debug!(session = %session_id, "handshake completado"); debug!(session = %session_id, "handshake completado");
Ok(Some(session_id)) Ok(Some(session_id))
} }
/// Indexa la sesión: ResolvedCard en sessions + Card en broker (si hay). async fn register_session(
/// Si `wit` está presente, el módulo se registra como "consciente".
async fn register_session(
&self,
session_id: SessionId, session_id: SessionId,
card: Card, card: Card,
wit: Option<WitInterface>, wit: Option<WitInterface>,
) { config: &ServerConfig,
if let Some(broker) = &self.config.broker { sessions: &SessionRegistry,
) {
if let Some(broker) = &config.broker {
broker broker
.lock() .lock()
.await .await
@@ -409,11 +512,10 @@ impl Session {
Some(w) => ResolvedCard::from_conscious(card, w), Some(w) => ResolvedCard::from_conscious(card, w),
None => ResolvedCard::from_agnostic(card), None => ResolvedCard::from_agnostic(card),
}; };
self.sessions.lock().await.insert(session_id, resolved); sessions.lock().await.insert(session_id, resolved);
} }
/// Validaciones que el servidor aplica al Hello del cliente. fn validate_hello(hello: &Hello) -> Option<HandshakeError> {
fn validate_hello(&self, hello: &Hello) -> Option<HandshakeError> {
if hello.schema_version != CARD_SCHEMA_VERSION { if hello.schema_version != CARD_SCHEMA_VERSION {
return Some(HandshakeError::SchemaMismatch { return Some(HandshakeError::SchemaMismatch {
client: hello.schema_version, client: hello.schema_version,
@@ -427,14 +529,11 @@ impl Session {
brahman_card::PROTOCOL_VERSION brahman_card::PROTOCOL_VERSION
))); )));
} }
// Validamos contra Card (la rica) — convertir es barato y centraliza
// la lógica de validación en un solo lugar.
let as_card: Card = Card::from(hello.card.clone()); let as_card: Card = Card::from(hello.card.clone());
if let Err(e) = as_card.validate() { if let Err(e) = as_card.validate() {
return Some(HandshakeError::InvalidCard(e.to_string())); return Some(HandshakeError::InvalidCard(e.to_string()));
} }
None None
}
} }
fn now_ms() -> u64 { fn now_ms() -> u64 {
@@ -0,0 +1,117 @@
//! Test E2E: handshake brahman remoto sobre libp2p stream.
//!
//! Pipeline:
//! 1. Server: bind Unix socket (necesario aunque no lo use el cliente);
//! crear `BrahmanNet` y escuchar en `/ip4/127.0.0.1/tcp/0`;
//! montar `run_libp2p_accept_loop`.
//! 2. Client: crear su propio `BrahmanNet`; dial al multiaddr del
//! server; `connect_libp2p` con su Card; `ping`; `farewell`.
//! 3. Verificar: el server registró la sesión; sessions.len() == 1
//! durante la sesión, == 0 después del farewell.
use std::collections::BTreeSet;
use std::sync::Arc;
use std::time::Duration;
use brahman_broker::{Broker, BrokerConfig};
use brahman_card::{
ulid::Ulid, Card, CardKind, Lifecycle, Payload, Priority, Supervision,
CARD_SCHEMA_VERSION,
};
use brahman_handshake::network::{connect_libp2p, run_libp2p_accept_loop};
use brahman_handshake::server::{Server, ServerConfig};
use brahman_net::{BrahmanNet, Multiaddr, PeerId, Protocol};
use tempfile::TempDir;
use tokio::sync::Mutex;
fn sample_card(label: &str) -> Card {
Card {
schema_version: CARD_SCHEMA_VERSION,
id: Ulid::new(),
label: label.into(),
provides: BTreeSet::new(),
requires: BTreeSet::new(),
permissions: Default::default(),
soma: Default::default(),
payload: Payload::Virtual,
supervision: Supervision::OneShot,
lifecycle: Lifecycle::default(),
priority: Priority::default(),
kind: CardKind::Ente,
..Default::default()
}
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn libp2p_handshake_roundtrip() {
// ---- Server side ----
let tmp = TempDir::new().unwrap();
let unix_socket = tmp.path().join("brahman-init.sock");
let broker = Arc::new(Mutex::new(Broker::new(BrokerConfig::default())));
let server = Arc::new(
Server::bind(
&unix_socket,
ServerConfig {
init_attached: true,
broker: Some(broker.clone()),
},
)
.unwrap(),
);
let sessions = server.sessions();
let server_net = Arc::new(BrahmanNet::new().unwrap());
let server_peer_id = server_net.peer_id;
// Listen on a random TCP port.
let listen_addr: Multiaddr = "/ip4/127.0.0.1/tcp/0".parse().unwrap();
let actual_addr = server_net.listen(listen_addr).await;
// Inject the libp2p PeerId into the multiaddr so the client knows
// who to dial.
let mut full_addr = actual_addr.clone();
full_addr.push(Protocol::P2p(server_peer_id));
// Spawn the libp2p accept loop.
tokio::spawn(run_libp2p_accept_loop(server.clone(), server_net.clone()));
// ---- Client side ----
let client_net = BrahmanNet::new().unwrap();
client_net.dial(full_addr.clone());
// Pequeña espera para que el dial conecte. En un entorno real el
// caller usaría un mecanismo de barrier, pero para tests un sleep
// corto es suficiente y deterministic en localhost.
tokio::time::sleep(Duration::from_millis(200)).await;
let card = sample_card("test.remote_ente");
let mut client = connect_libp2p(&client_net, server_peer_id, card, None)
.await
.expect("handshake remoto debería completar");
// Verificación: el server vio la sesión.
{
let s = sessions.lock().await;
assert_eq!(s.len(), 1, "una sesión registrada");
let resolved = s.values().next().unwrap();
assert_eq!(resolved.card.label, "test.remote_ente");
}
// Ping roundtrip.
let ts = client.ping().await.expect("ping debería responder");
assert!(ts > 0, "timestamp del Pong > 0");
// Farewell limpio.
client.farewell().await.expect("farewell debería completar");
// Tras el farewell, el cleanup remueve la sesión.
// Damos un tick para que el handler procese el frame.
tokio::time::sleep(Duration::from_millis(100)).await;
{
let s = sessions.lock().await;
assert_eq!(s.len(), 0, "sesión removida tras farewell");
}
// peer_id no usado aquí, pero validamos que la API existe.
let _ = PeerId::random();
}
+3 -2
View File
@@ -48,12 +48,13 @@ use futures::StreamExt;
use libp2p::{ use libp2p::{
identify, identity, kad, noise, identify, identity, kad, noise,
swarm::{NetworkBehaviour, SwarmEvent}, swarm::{NetworkBehaviour, SwarmEvent},
tcp, yamux, Multiaddr, PeerId, Swarm, SwarmBuilder, tcp, yamux, Swarm, SwarmBuilder,
}; };
use libp2p_stream as stream; use libp2p_stream as stream;
use tokio::sync::{mpsc, oneshot, Mutex}; use tokio::sync::{mpsc, oneshot, Mutex};
pub use libp2p::{Stream, StreamProtocol}; pub use libp2p::{multiaddr::Protocol, Multiaddr, PeerId, Stream, StreamProtocol};
pub use libp2p_stream::OpenStreamError;
const IDENTIFY_PROTOCOL: &str = "/brahman-net/0.1.0"; const IDENTIFY_PROTOCOL: &str = "/brahman-net/0.1.0";
const IDLE_CONNECTION_TIMEOUT: Duration = Duration::from_secs(60); const IDLE_CONNECTION_TIMEOUT: Duration = Duration::from_secs(60);