Compare commits

...
Author SHA1 Message Date
Evan 7ed3eaa617 json state proxy 2026-05-07 15:36:03 +01:00
Evan e113d42ba1 uncap 2026-05-07 13:28:51 +01:00
Evan c5245dd87e libp2p -> zenoh 2026-05-07 11:21:59 +01:00
Alex Cheema edef8004f8 Store custom model cards in State (#2024)
## Why

Workers currently update their custom model-card cache by reacting to
`CustomModelCardAdded` / `CustomModelCardDeleted` events directly. That
is another snapshot footgun: a worker restored from State may never see
the historical add/delete event, so the durable State must include the
desired custom-card set.

## How

- Add `State.custom_model_cards`, keyed by `ModelId`.
- Reduce `CustomModelCardAdded` into State.
- Reduce `CustomModelCardDeleted` into State.
- Add focused reducer tests for add and delete.

This PR only makes custom cards durable in State. A follow-up PR will
make workers reconcile their on-disk custom-card cache from this state
instead of relying on those events directly.

## Tests

- `uv run pytest
src/exo/shared/tests/test_apply/test_apply_custom_model_cards.py
src/exo/shared/tests/test_state_serialization.py`
- `uv run pytest`
- `uv run ruff check src/exo/shared/types/state.py
src/exo/shared/apply.py
src/exo/shared/tests/test_apply/test_apply_custom_model_cards.py`
- `uv run basedpyright`
- `nix fmt`
2026-05-07 09:06:39 +01:00
Alex CheemaandClaude Opus 4.7 a0c00f9dfd fix(placement): gate RDMA on nodeRdmaCtl.enabled at both endpoints (#2014)
## Summary

- Fixes a bug where `POST /place_instance` (and the dashboard UI) would
accept an MlxJaccl/RDMA instance spanning nodes whose
`nodeRdmaCtl.enabled` was `false`, because topology + placement
consulted Thunderbolt-derived RDMA edges without checking the per-node
`rdma_ctl` status.
- Three-layer fix: topology only emits `RDMAConnection` edges when both
endpoints have `nodeRdmaCtl.enabled = true`; flipping a node to disabled
immediately purges every RDMA edge touching it; `place_instance`
additionally rejects RDMA cycles containing any disabled or unobserved
node as a defense-in-depth check on the API/master path.

## Details

- `src/exo/shared/apply.py`
- `MacThunderboltConnections` case now filters out RDMA connections
whose source or sink lacks observed-and-enabled `rdma_ctl` status
(missing entry → treated as disabled).
- `RdmaCtlStatus` case now calls
`topology.remove_all_rdma_connections_touching(node_id)` when the node
reports disabled, so consumers don't have to wait for the next TB poll.
- `src/exo/shared/topology.py`
- New `Topology.remove_all_rdma_connections_touching(node_id)` removes
every RDMA edge incident to the node (incoming and outgoing) while
leaving socket edges intact.
- `src/exo/master/placement.py`
- `place_instance` accepts `node_rdma_ctl: Mapping[NodeId,
NodeRdmaCtlStatus] | None`. The `is_rdma_cycle` filter now also requires
`nodeRdmaCtl.enabled` for every node in the cycle. MlxJaccl placement
raises the existing "no RDMA-connected cycles available" error if no
qualifying cycle remains.
- `src/exo/api/main.py`, `src/exo/master/main.py`
  - Both placement entrypoints now pass `state.node_rdma_ctl` through.

## Tests

- `src/exo/shared/tests/test_apply/test_apply_rdma_gating.py` (new): six
unit tests covering enabled/disabled/missing combinations on apply, the
immediate-purge transition, and that purging RDMA edges leaves socket
edges untouched.
- `src/exo/master/tests/test_placement.py`: existing
`test_tensor_rdma_backend_connectivity_matrix` updated to pass
`node_rdma_ctl`. Two new tests assert MlxJaccl placement is rejected
when any cycle node is `enabled=false` or has no `rdma_ctl` entry.

## Test plan

- [x] `uv run basedpyright` — 0 errors
- [x] `uv run ruff check` — clean
- [x] `nix fmt`
- [x] `uv run pytest` — 429 passed, 1 skipped
- [ ] On a real mixed cluster (s15/s16 disabled, s17/s18 enabled),
confirm:
- [ ] `POST /place_instance` for an RDMA instance including s15 or s16
returns an error
  - [ ] An RDMA instance can still be placed across {s17, s18}
- [ ] `GET /state` shows no `sourceRdmaIface`/`sinkRdmaIface` on s15↔s16
connections
- [ ] Dashboard previews don't surface RDMA-spanning options that
include s15/s16

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 07:00:15 +00:00
Drifter4242 89d20c1888 fix(inference): prevent TP collective deadlock via agree_on_tasks order (#2048)
If you have two machines and make two requests at the same time, it can
crash. This is because the tasks can sometimes end up in different
orders on different machines. We need to sort the tasks and
mx_all_gather_tasks already sorts the tasks but the code ignores that
ordering. The fix is to make sure the sort order is preserved.

The rest is written by Sonnet (reviewed by me):

Tensor-parallel inference requires that every rank enqueues tasks in the
same order before running agree_on_tasks collectives. The old
implementation filtered from _maybe_queue:

self._queue.extend(task for task in self._maybe_queue if task in agreed)
self._maybe_queue = [task for task in self._maybe_queue if task in
different]

Because _maybe_queue is independently ordered per-rank (tasks arrive via
gRPC in whatever order the API server sends them), two concurrent
requests could produce different _maybe_queue orderings on rank 0 vs
rank 1. The filter then preserved those different orders into _queue, so
each rank started processing tasks in a different sequence. The next mlx
collective (all_reduce, all_gather, etc.) on rank 0 corresponded to a
different task than on rank 1 → permanent deadlock.

Fix: extend from agreed directly. mx_all_gather_tasks returns agreed as
a list sorted by task_id on all ranks, so every rank appends the same
sequence regardless of local arrival order.

Applies to both SequentialGenerator and BatchGenerator.

## Motivation

`agree_on_tasks` is called on every rank after accumulating new requests
in
`_maybe_queue`. Its job is to run an `all_gather` collective so all
ranks agree
on which tasks to promote to `_queue` before the next inference step.

The old implementation re-imposed **local arrival order** when extending
`_queue`:

```python
self._queue.extend(task for task in self._maybe_queue if task in agreed)
```

`mx_all_gather_tasks` already returns `agreed` sorted by `task_id` — the
same
deterministic order on every rank. But iterating `self._maybe_queue`
instead of
`agreed` discarded that sort and substituted the local gRPC arrival
order, which
differs per rank under concurrent load. Two concurrent requests arriving
in
`[A, B]` order on rank 0 and `[B, A]` on rank 1 caused the first MLX
collective
in the next step to hang permanently: each rank was executing a
different task's
collective and would never match.

## Changes

`SequentialGenerator.agree_on_tasks` and
`BatchGenerator.agree_on_tasks`:

```python
# Before
self._queue.extend(task for task in self._maybe_queue if task in agreed)
self._maybe_queue = [task for task in self._maybe_queue if task in different]

# After
self._queue.extend(agreed)          # preserves mx_all_gather_tasks sort order
self._maybe_queue = list(different) # already in local order; filter was redundant
```

## Why It Works

`mx_all_gather_tasks` (in `utils_mlx.py`) computes the agreed set then
sorts by
`task_id`:

```python
agreed = [local_tasks[tid] for tid in sorted(agreed_ids)]
```

Because `task_id` is a UUID and the sort is lexicographic, every rank
produces
the same `agreed` list regardless of local arrival order. Using `agreed`
directly
preserves this guarantee. The `different` list (tasks not yet seen on
all ranks)
is built by iterating `tasks` in local order, which is already correct.

## Test Plan

### Manual Testing

**Hardware:** 2× Mac Studio M3 Ultra 512 GB, Thunderbolt 5 direct
bridge,
`MlxJaccl` RDMA tensor-parallel (`moonshotai/Kimi-K2.6`, 595 GB INT4, 61
layers).

- Sent concurrent streaming requests; confirmed all complete without
deadlock.
- This hardware configuration (sub-millisecond inter-node latency) is
the most
likely to trigger the race, as requests from separate HTTP connections
can
reach rank 0 and rank 1 in opposite order before `agree_on_tasks` runs.

### Automated Testing

All existing tests pass: `pytest src -m "not slow"
--import-mode=importlib`
— 422/422 passed. The existing `test_event_ordering.py` covers the
`agree_on_tasks` call path with a mock that returns tasks in consistent
order;
the race requires real distributed hardware to reproduce
deterministically.
2026-05-06 12:24:58 +00:00
Evan Quineyandciaranbor dbcceaa50c Initialise _cancelled_tasks in ImageEngine (#2051)
we yielded nonsense chunks from engines; we didn't initialize the image
engine correctly. mostly rewrite of #2049

---------

Co-authored-by: ciaranbor <ciaranborourke-dev@proton.me>
2026-05-05 17:27:57 +01:00
49 changed files with 3373 additions and 3679 deletions

No files matched your search

Generated
+2258 -2192
View File
File diff suppressed because it is too large. Load diff
+4 -15
View File
@@ -1,6 +1,6 @@
[workspace]
resolver = "3"
members = ["rust/networking", "rust/exo_pyo3_bindings", "rust/util"]
members = ["rust/exo_pyo3_bindings", "rust/networking"]
[workspace.package]
version = "0.0.1"
@@ -20,30 +20,19 @@ opt-level = 3
[workspace.dependencies]
## Crate members as common dependencies
networking = { path = "rust/networking" }
util = { path = "rust/util" }
# Macro dependecies
extend = "1.2"
delegate = "0.13"
# Utility dependencies
keccak-const = "0.2"
# Async dependencies
async-stream = "0.3"
tokio = "1.46"
futures-lite = "2.6.1"
futures-timer = "3.0"
# Data structures
either = "1.15"
# Tracing/logging
log = "0.4"
env_logger = "0.11.10"
# networking
libp2p = "0.56"
libp2p-tcp = "0.44"
zenoh = "=1.9.0"
zerompk = "0.4.2"
[workspace.lints.rust]
static_mut_refs = "warn" # Or use "warn" instead of deny
Generated
+6 -6
View File
@@ -47,11 +47,11 @@
"rust-analyzer-src": "rust-analyzer-src"
},
"locked": {
"lastModified": 1775807984,
"narHash": "sha256-Redoe3D9zGN5I9QPHWL9vfMVQBehY1fKsMiRXQ83X3w=",
"lastModified": 1777708550,
"narHash": "sha256-Qif3UXT0l5OQq8H9pRWt4/ia4gF48MWK2oHKL8uVx8U=",
"owner": "nix-community",
"repo": "fenix",
"rev": "fcf90c0c4d368b2ca917a7afa6d08e98a397e5fd",
"rev": "74c1591efaff494756b8d35ebe357c6c2bbdca96",
"type": "github"
},
"original": {
@@ -218,11 +218,11 @@
"rust-analyzer-src": {
"flake": false,
"locked": {
"lastModified": 1775745684,
"narHash": "sha256-8MbfLwd60FNa8dRFkjE+G3TT/x21G3Rsplm1bMBQUtU=",
"lastModified": 1777639980,
"narHash": "sha256-6d7Hdurvbjc5uwJuc0YiK7rZBGj6Gs3uzfBFcTs+xCc=",
"owner": "rust-lang",
"repo": "rust-analyzer",
"rev": "64ddb549bc9a70d011328746fa46a8883f937b6b",
"rev": "64cdaeb06f69b6b769a492edd88b022ae88e8ca2",
"type": "github"
},
"original": {
+1 -1
View File
@@ -146,7 +146,7 @@
config.treefmt.build.wrapper
# PYTHON
self'.packages.editableVenv
#self'.packages.editableVenv
uv
# RUST
+9 -12
View File
@@ -22,7 +22,8 @@ doc = false
workspace = true
[dependencies]
networking = { workspace = true }
networking.workspace = true
extend.workspace = true
# interop
pyo3 = { version = "0.27.2", features = [
@@ -46,21 +47,17 @@ pyo3-async-runtimes = { version = "0.27.0", features = [
] }
pyo3-log = "0.13.2"
# macro dependencies
extend = { workspace = true }
delegate = { workspace = true }
# async runtime
tokio = { workspace = true, features = ["full", "tracing"] }
futures-lite = { workspace = true }
# utility dependencies
util = { workspace = true }
pin-project = "1.1.10"
# Tracing
log = { workspace = true }
env_logger = "0.11"
log.workspace = true
env_logger.workspace = true
# Networking
libp2p = { workspace = true, features = ["full"] }
pin-project = "1.1.10"
zenoh.workspace = true
zerompk.workspace = true
rand = "0.10.1"
serde_json = "1.0.149"
+3 -86
View File
@@ -1,94 +1,11 @@
# This file is automatically generated by pyo3_stub_gen
# ruff: noqa: E501, F401
import builtins
import typing
@typing.final
class AllQueuesFullError(builtins.Exception):
def __new__(cls, *args: typing.Any) -> AllQueuesFullError: ...
def __repr__(self) -> builtins.str: ...
def __str__(self) -> builtins.str: ...
@typing.final
class Keypair:
r"""
Identity keypair of a node.
"""
class StateProxy:
@staticmethod
def generate() -> Keypair:
r"""
Generate a new Ed25519 keypair.
"""
@staticmethod
def from_bytes(bytes: bytes) -> Keypair:
r"""
Construct an Ed25519 keypair from secret key bytes
"""
def to_bytes(self) -> bytes:
r"""
Get the secret key bytes underlying the keypair
"""
def to_node_id(self) -> builtins.str:
r"""
Convert the `Keypair` into the corresponding `PeerId` string, which we use as our `NodeId`.
"""
@typing.final
class MessageTooLargeError(builtins.Exception):
def __new__(cls, *args: typing.Any) -> MessageTooLargeError: ...
def __repr__(self) -> builtins.str: ...
def __str__(self) -> builtins.str: ...
@typing.final
class NetworkingHandle:
def __new__(cls, identity: Keypair, bootstrap_peers: typing.Sequence[builtins.str], listen_port: builtins.int) -> NetworkingHandle: ...
async def gossipsub_subscribe(self, topic: builtins.str) -> builtins.bool:
r"""
Subscribe to a `GossipSub` topic.
Returns `True` if the subscription worked. Returns `False` if we were already subscribed.
"""
async def gossipsub_unsubscribe(self, topic: builtins.str) -> builtins.bool:
r"""
Unsubscribes from a `GossipSub` topic.
Returns `True` if we were subscribed to this topic. Returns `False` if we were not subscribed.
"""
async def gossipsub_publish(self, topic: builtins.str, data: bytes) -> None:
r"""
Publishes a message with multiple topics to the `GossipSub` network.
If no peers are found that subscribe to this topic, throws `NoPeersSubscribedToTopicError` exception.
"""
async def recv(self) -> PyFromSwarm: ...
@typing.final
class NoPeersSubscribedToTopicError(builtins.Exception):
def __new__(cls, *args: typing.Any) -> NoPeersSubscribedToTopicError: ...
def __repr__(self) -> builtins.str: ...
def __str__(self) -> builtins.str: ...
class PyFromSwarm:
@typing.final
class Connection(PyFromSwarm):
__match_args__ = ("peer_id", "connected",)
@property
def peer_id(self) -> builtins.str: ...
@property
def connected(self) -> builtins.bool: ...
def __new__(cls, peer_id: builtins.str, connected: builtins.bool) -> PyFromSwarm.Connection: ...
@typing.final
class Message(PyFromSwarm):
__match_args__ = ("origin", "topic", "data",)
@property
def origin(self) -> builtins.str: ...
@property
def topic(self) -> builtins.str: ...
@property
def data(self) -> bytes: ...
def __new__(cls, origin: builtins.str, topic: builtins.str, data: bytes) -> PyFromSwarm.Message: ...
...
async def init() -> StateProxy: ...
async def snapshot(self) -> str: ...
+11 -14
View File
@@ -1,5 +1,4 @@
use crate::ext::ResultExt as _;
use libp2p::identity::Keypair;
use pyo3::types::{PyBytes, PyBytesMethods as _};
use pyo3::{Bound, PyResult, Python, pyclass, pymethods};
use pyo3_stub_gen::derive::{gen_stub_pyclass, gen_stub_pymethods};
@@ -8,7 +7,7 @@ use pyo3_stub_gen::derive::{gen_stub_pyclass, gen_stub_pymethods};
#[gen_stub_pyclass]
#[pyclass(name = "Keypair", frozen)]
#[repr(transparent)]
pub struct PyKeypair(pub Keypair);
pub struct PyKeypair(pub u128);
#[gen_stub_pymethods]
#[pymethods]
@@ -17,31 +16,29 @@ impl PyKeypair {
/// Generate a new Ed25519 keypair.
#[staticmethod]
fn generate() -> Self {
Self(Keypair::generate_ed25519())
Self(rand::random())
}
/// Construct an Ed25519 keypair from secret key bytes
#[staticmethod]
fn from_bytes(bytes: Bound<'_, PyBytes>) -> PyResult<Self> {
let mut bytes = Vec::from(bytes.as_bytes());
Ok(Self(Keypair::ed25519_from_bytes(&mut bytes).pyerr()?))
let bytes = Vec::from(bytes.as_bytes());
Ok(Self(u128::from_le_bytes(
bytes
.try_into()
.map_err(|_| "passed too many bytes to from_bytes")
.pyerr()?,
)))
}
/// Get the secret key bytes underlying the keypair
fn to_bytes<'py>(&self, py: Python<'py>) -> PyResult<Bound<'py, PyBytes>> {
let bytes = self
.0
.clone()
.try_into_ed25519()
.pyerr()?
.secret()
.as_ref()
.to_vec();
let bytes = self.0.to_le_bytes();
Ok(PyBytes::new(py, &bytes))
}
/// Convert the `Keypair` into the corresponding `PeerId` string, which we use as our `NodeId`.
fn to_node_id(&self) -> String {
self.0.public().to_peer_id().to_base58()
format!("{:x}", self.0)
}
}
+8 -13
View File
@@ -5,21 +5,15 @@
//!
mod allow_threading;
mod ident;
mod networking;
// mod ident;
// mod networking;
mod state;
use crate::ident::PyKeypair;
use crate::networking::networking_submodule;
use crate::state::snapshot_module;
use pyo3::prelude::PyModule;
use pyo3::types::PyModuleMethods;
use pyo3::{Bound, PyResult, pyclass, pymodule};
use pyo3::{Bound, PyResult, pymodule};
use pyo3_stub_gen::define_stub_info_gatherer;
/// Namespace for all the constants used by this crate.
pub(crate) mod r#const {
pub const MPSC_CHANNEL_SIZE: usize = 1024;
}
/// Namespace for crate-wide extension traits/methods
pub(crate) mod ext {
use crate::allow_threading::AllowThreads;
@@ -162,8 +156,9 @@ fn main_module(m: &Bound<'_, PyModule>) -> PyResult<()> {
// TODO: for now this is all NOT a submodule, but figure out how to make the submodule system
// work with maturin, where the types generate correctly, in the right folder, without
// too many importing issues...
m.add_class::<PyKeypair>()?;
networking_submodule(m)?;
// m.add_class::<PyKeypair>()?;
// networking_submodule(m)?;
snapshot_module(m)?;
// top-level constructs
// TODO: ...
+8 -145
View File
@@ -5,12 +5,8 @@ use crate::r#const::MPSC_CHANNEL_SIZE;
use crate::ext::{ByteArrayExt as _, FutureExt, PyErrExt as _};
use crate::ext::{ResultExt as _, TokioMpscSenderExt as _};
use crate::ident::PyKeypair;
use crate::networking::exception::{
PyAllQueuesFullError, PyMessageTooLargeError, PyNoPeersSubscribedToTopicError,
};
use crate::pyclass;
use futures_lite::{Stream, StreamExt as _};
use libp2p::gossipsub::PublishError;
use networking::swarm::{FromSwarm, ToSwarm, create_swarm};
use pyo3::exceptions::PyRuntimeError;
use pyo3::prelude::{PyModule, PyModuleMethods as _};
@@ -21,114 +17,6 @@ use pyo3_stub_gen::derive::{
};
use tokio::sync::{Mutex, mpsc, oneshot};
mod exception {
use pyo3::types::PyTuple;
use pyo3::{exceptions::PyException, prelude::*};
use pyo3_stub_gen::derive::*;
#[gen_stub_pyclass]
#[pyclass(frozen, extends=PyException, name="NoPeersSubscribedToTopicError")]
pub struct PyNoPeersSubscribedToTopicError {}
impl PyNoPeersSubscribedToTopicError {
const MSG: &'static str = "\
No peers are currently subscribed to receive messages on this topic. \
Wait for peers to subscribe or check your network connectivity.";
/// Creates a new [ `PyErr` ] of this type.
///
/// [`PyErr`] : https://docs.rs/pyo3/latest/pyo3/struct.PyErr.html "PyErr in pyo3"
pub(crate) fn new_err() -> PyErr {
PyErr::new::<Self, _>(()) // TODO: check if this needs to be replaced???
}
}
#[gen_stub_pymethods]
#[pymethods]
impl PyNoPeersSubscribedToTopicError {
#[new]
#[pyo3(signature = (*args))]
#[allow(unused_variables)]
pub(crate) fn new(args: &Bound<'_, PyTuple>) -> Self {
Self {}
}
fn __repr__(&self) -> String {
format!("PeerId(\"{}\")", Self::MSG)
}
fn __str__(&self) -> String {
Self::MSG.to_string()
}
}
#[gen_stub_pyclass]
#[pyclass(frozen, extends=PyException, name="AllQueuesFullError")]
pub struct PyAllQueuesFullError {}
impl PyAllQueuesFullError {
const MSG: &'static str =
"All libp2p peers are unresponsive, resend the message or reconnect.";
/// Creates a new [ `PyErr` ] of this type.
///
/// [`PyErr`] : https://docs.rs/pyo3/latest/pyo3/struct.PyErr.html "PyErr in pyo3"
pub(crate) fn new_err() -> PyErr {
PyErr::new::<Self, _>(()) // TODO: check if this needs to be replaced???
}
}
#[gen_stub_pymethods]
#[pymethods]
impl PyAllQueuesFullError {
#[new]
#[pyo3(signature = (*args))]
#[allow(unused_variables)]
pub(crate) fn new(args: &Bound<'_, PyTuple>) -> Self {
Self {}
}
fn __repr__(&self) -> String {
format!("PeerId(\"{}\")", Self::MSG)
}
fn __str__(&self) -> String {
Self::MSG.to_string()
}
}
#[gen_stub_pyclass]
#[pyclass(frozen, extends=PyException, name="MessageTooLargeError")]
pub struct PyMessageTooLargeError {}
impl PyMessageTooLargeError {
const MSG: &'static str = "Gossipsub message exceeds max_transmit_size. Reduce prompt length or increase the limit.";
pub(crate) fn new_err() -> PyErr {
PyErr::new::<Self, _>(())
}
}
#[gen_stub_pymethods]
#[pymethods]
impl PyMessageTooLargeError {
#[new]
#[pyo3(signature = (*args))]
#[allow(unused_variables)]
pub(crate) fn new(args: &Bound<'_, PyTuple>) -> Self {
Self {}
}
fn __repr__(&self) -> String {
format!("MessageTooLargeError(\"{}\")", Self::MSG)
}
fn __str__(&self) -> String {
Self::MSG.to_string()
}
}
}
#[gen_stub_pyclass]
#[pyclass(name = "NetworkingHandle")]
struct PyNetworkingHandle {
@@ -140,29 +28,15 @@ struct PyNetworkingHandle {
#[gen_stub_pyclass_complex_enum]
#[pyclass]
enum PyFromSwarm {
Connection {
peer_id: String,
connected: bool,
},
Message {
origin: String,
topic: String,
data: Py<PyBytes>,
},
Connection { connected: bool },
Message { topic: String, data: Py<PyBytes> },
}
impl From<FromSwarm> for PyFromSwarm {
fn from(value: FromSwarm) -> Self {
match value {
FromSwarm::Discovered { peer_id } => Self::Connection {
peer_id: peer_id.to_base58(),
connected: true,
},
FromSwarm::Expired { peer_id } => Self::Connection {
peer_id: peer_id.to_base58(),
connected: false,
},
FromSwarm::Message { from, topic, data } => Self::Message {
origin: from.to_base58(),
FromSwarm::Discovered {} => Self::Connection { connected: true },
FromSwarm::Expired {} => Self::Connection { connected: false },
FromSwarm::Message { topic, data } => Self::Message {
topic: topic,
data: data.pybytes(),
},
@@ -195,8 +69,8 @@ impl PyNetworkingHandle {
// create networking swarm (within tokio context!! or it crashes)
let _guard = pyo3_async_runtimes::tokio::get_runtime().enter();
let swarm = create_swarm(identity, from_client, bootstrap_peers, listen_port)
.pyerr()?
.into_stream();
.map(|it| it.into_stream())
.pyerr()?;
Ok(Self {
swarm: Arc::new(Mutex::new(swarm)),
@@ -285,14 +159,7 @@ impl PyNetworkingHandle {
.allow_threads_py() // allow-threads-aware async call
.await
.map_err(|_| PyErr::receiver_channel_closed())?
.map_err(|e| match e {
PublishError::AllQueuesFull(_) => PyAllQueuesFullError::new_err(),
PublishError::MessageTooLarge => PyMessageTooLargeError::new_err(),
PublishError::NoPeersSubscribedToTopic => {
PyNoPeersSubscribedToTopicError::new_err()
}
e => PyRuntimeError::new_err(e.to_string()),
})?;
.map_err(|e| PyRuntimeError::new_err(e.to_string()))?;
Ok(())
}
}
@@ -307,10 +174,6 @@ pyo3_stub_gen::inventory::submit! {
}
pub fn networking_submodule(m: &Bound<'_, PyModule>) -> PyResult<()> {
m.add_class::<exception::PyNoPeersSubscribedToTopicError>()?;
m.add_class::<exception::PyAllQueuesFullError>()?;
m.add_class::<exception::PyMessageTooLargeError>()?;
m.add_class::<PyNetworkingHandle>()?;
m.add_class::<PyFromSwarm>()?;
+110
View File
@@ -0,0 +1,110 @@
use networking::Session;
use pyo3::{
exceptions::{PyRuntimeError, PyValueError},
prelude::*,
};
use pyo3_stub_gen::derive::{gen_methods_from_python, gen_stub_pyclass, gen_stub_pymethods};
use serde_json::{Map, Value};
use zenoh::{Result, Session as ZSession, query::QueryTarget, sample::SampleFields};
pyo3_stub_gen::inventory::submit! {
gen_methods_from_python! {
r#"
class StateProxy:
@staticmethod
async def init() -> StateProxy: ...
async def snapshot(self) -> str: ...
"#
}
}
pub fn snapshot_module(m: &Bound<'_, PyModule>) -> PyResult<()> {
m.add_class::<StateProxy>()?;
Ok(())
}
#[gen_stub_pyclass]
#[pyclass]
pub struct StateProxy {
session: Session,
}
#[gen_stub_pymethods]
#[pymethods]
impl StateProxy {
#[staticmethod]
#[gen_stub(skip)]
pub fn init<'py>(py: Python<'py>) -> PyResult<Bound<'py, PyAny>> {
pyo3_async_runtimes::tokio::future_into_py(py, async move {
Ok(Self {
session: networking::open(
networking::cfg(rand::random(), 0).expect("default cfg is valid"),
)
.await
.map_err(|e| PyRuntimeError::new_err(e.to_string()))?,
})
})
}
#[gen_stub(skip)]
pub fn snapshot<'py>(&self, py: Python<'py>) -> PyResult<Bound<'py, PyAny>> {
pyo3_async_runtimes::tokio::future_into_py(py, {
let session = self.session.clone();
async move {
Self::_snapshot(session)
.await
.map_err(|e| PyValueError::new_err(e.to_string()))
.map(|v| v.to_string())
}
})
}
}
impl StateProxy {
async fn _snapshot(session: ZSession) -> Result<Value> {
let q = session.get("storage/mem1/**").await?;
let mut v = Value::Object(Map::default());
while let Ok(sample) = q.recv_async().await {
let mut cur_v = &mut v;
let Ok(sample) = sample.into_result() else {
continue;
};
// skip storage/mem1
let SampleFields {
payload, key_expr, ..
} = sample.into();
let mut iter = key_expr.split('/').skip(2).peekable();
loop {
let Some(p) = iter.next() else {
break;
};
if iter.peek().is_none() {
// terminal; write value into json
let existing = cur_v
.as_object_mut()
.expect("path terminated unexpectedly - value stored at some/path and some/path/two")
.insert(p.to_owned(), Value::String(payload.try_to_string()?.to_string()));
if let Some(value) = existing {
assert!(value.is_string())
// could log, but string overwrites are fine
}
} else {
// non-terminal; ensure key exists in v, then replace cur with that object
cur_v = cur_v
.as_object_mut()
.expect("path terminated unexpectedly - value stored at some/path and some/path/two")
.entry(p)
.or_insert(Value::Object(Map::default()));
assert!(
cur_v.is_object(),
"path terminated unexpectedly - value stored at some/path and some/path/two"
)
}
}
}
Ok(v)
}
}
-54
View File
@@ -1,54 +0,0 @@
#[cfg(test)]
mod tests {
use core::mem::drop;
use core::option::Option::Some;
use core::time::Duration;
use tokio;
use tokio::sync::mpsc;
#[tokio::test]
async fn test_drop_channel() {
struct Ping;
let (tx, mut rx) = mpsc::channel::<Ping>(10);
let _ = tokio::spawn(async move {
println!("TASK: entered");
loop {
tokio::select! {
result = rx.recv() => {
match result {
Some(_) => {
println!("TASK: pinged");
}
None => {
println!("TASK: closing channel");
break;
}
}
}
_ = tokio::time::sleep(Duration::from_secs_f32(0.1)) => {
println!("TASK: heartbeat");
}
}
}
println!("TASK: exited");
});
let tx2 = tx.clone();
tokio::time::sleep(Duration::from_secs_f32(0.11)).await;
tx.send(Ping).await.expect("Should not fail");
drop(tx);
tokio::time::sleep(Duration::from_secs_f32(0.11)).await;
tx2.send(Ping).await.expect("Should not fail");
drop(tx2);
tokio::time::sleep(Duration::from_secs_f32(0.11)).await;
}
}
+6 -6
View File
@@ -4,7 +4,6 @@ import pytest
from exo_pyo3_bindings import (
Keypair,
NetworkingHandle,
NoPeersSubscribedToTopicError,
PyFromSwarm,
)
@@ -13,17 +12,15 @@ from exo_pyo3_bindings import (
async def test_sleep_on_multiple_items() -> None:
print("PYTHON: starting handle")
h = NetworkingHandle(Keypair.generate(), [], 0)
print("PYTHON: handle started")
rt = asyncio.create_task(_await_recv(h))
# sleep for 4 ticks
for i in range(4):
for i in range(10):
await asyncio.sleep(1)
try:
await h.gossipsub_publish("topic", b"somehting or other")
except NoPeersSubscribedToTopicError as e:
print("caught it", e)
await h.gossipsub_publish("topic", b"somehting or other")
async def _await_recv(h: NetworkingHandle):
@@ -34,3 +31,6 @@ async def _await_recv(h: NetworkingHandle):
print(f"PYTHON: connection update: {c}")
case PyFromSwarm.Message() as m:
print(f"PYTHON: message: {m}")
if __name__ == "__main__":
asyncio.run(test_sleep_on_multiple_items())
+15 -35
View File
@@ -1,42 +1,22 @@
[package]
name = "networking"
version = { workspace = true }
edition = { workspace = true }
publish = false
version.workspace = true
edition.workspace = true
[lib]
doctest = false
name = "networking"
path = "src/lib.rs"
[dependencies]
async-stream = "0.3.6"
futures-lite.workspace = true
netwatcher = { version = "0.6.0", features = ["tokio"] }
parking_lot = "0.12.5"
tokio = { workspace = true, features = ["full"] }
zenoh = { version = "=1.9.0", features = ["internal", "plugins", "unstable"] }
zenoh-plugin-storage-manager = { version = "=1.9.0", default-features = false }
zenoh-plugin-trait = "=1.9.0"
zerompk = { version = "0.4.2", features = ["derive"] }
rand = "0.10.1"
tracing = "0.1.44"
log.workspace = true
[lints]
workspace = true
[dependencies]
# datastructures
either = { workspace = true }
# macro dependencies
extend = { workspace = true }
delegate = { workspace = true }
# async
async-stream = { workspace = true }
futures-lite = { workspace = true }
futures-timer = { workspace = true }
tokio = { workspace = true, features = ["full"] }
# utility dependencies
util = { workspace = true }
tracing-subscriber = { version = "0.3.19", features = [
"default",
"env-filter",
] }
keccak-const = { workspace = true }
# tracing/logging
log = { workspace = true }
# networking
libp2p = { workspace = true, features = ["full"] }
pin-project = "1.1.10"
-86
View File
@@ -1,86 +0,0 @@
use futures_lite::StreamExt;
use libp2p::identity;
use networking::swarm;
use networking::swarm::{FromSwarm, ToSwarm};
use tokio::sync::{mpsc, oneshot};
use tokio::{io, io::AsyncBufReadExt as _};
use tracing_subscriber::EnvFilter;
use tracing_subscriber::filter::LevelFilter;
#[tokio::main]
async fn main() {
let _ = tracing_subscriber::fmt()
.with_env_filter(EnvFilter::from_default_env().add_directive(LevelFilter::INFO.into()))
.try_init();
let (to_swarm, from_client) = mpsc::channel(20);
// Configure swarm
let mut swarm = swarm::create_swarm(
identity::Keypair::generate_ed25519(),
from_client,
vec![],
0,
)
.expect("Swarm creation failed")
.into_stream();
// Create a Gossipsub topic & subscribe
let (tx, rx) = oneshot::channel();
_ = to_swarm
.send(ToSwarm::Subscribe {
topic: "test-net".to_string(),
result_sender: tx,
})
.await
.expect("should send");
// Read full lines from stdin
let mut stdin = io::BufReader::new(io::stdin()).lines();
println!("Enter messages via STDIN and they will be sent to connected peers using Gossipsub");
tokio::task::spawn(async move {
rx.await
.expect("tx not dropped")
.expect("subscribe shouldn't fail");
loop {
if let Ok(Some(line)) = stdin.next_line().await {
let (tx, rx) = oneshot::channel();
if let Err(e) = to_swarm
.send(swarm::ToSwarm::Publish {
topic: "test-net".to_string(),
data: line.as_bytes().to_vec(),
result_sender: tx,
})
.await
{
println!("Send error: {e:?}");
return;
};
match rx.await {
Ok(Err(e)) => println!("Publish error: {e:?}"),
Err(e) => println!("Publish error: {e:?}"),
Ok(_) => {}
}
}
}
});
// Kick it off
loop {
// on gossipsub outgoing
match swarm.next().await {
// on gossipsub incoming
Some(FromSwarm::Discovered { peer_id }) => {
println!("\n\nconnected to {peer_id}\n\n")
}
Some(FromSwarm::Expired { peer_id }) => {
println!("\n\ndisconnected from {peer_id}\n\n")
}
Some(FromSwarm::Message { from, topic, data }) => {
println!("{topic}/{from}:\n{}", String::from_utf8_lossy(&data))
}
None => {}
}
}
}
+22
View File
@@ -0,0 +1,22 @@
use networking;
use tracing::info;
use zenoh::Result;
#[tokio::main]
async fn main() -> Result<()> {
zenoh::init_log_from_env_or("info");
info!("Opening session...");
let cfg = networking::cfg(rand::random(), 0)?;
let session = networking::open(cfg).await?;
let _tok = session
.liveliness()
.declare_token(format!("nodes/{}/live", session.zid()))
.await?;
let key_expr = "storage/mem1/name";
let payload = "me";
info!("Putting Data ('{key_expr}': '{payload}')...");
session.put(key_expr, payload).await?;
tokio::signal::ctrl_c().await?;
Ok(())
}
+23
View File
@@ -0,0 +1,23 @@
use networking;
use tracing::info;
use zenoh::Result;
#[tokio::main]
async fn main() -> Result<()> {
zenoh::init_log_from_env_or("info");
info!("Opening session...");
let cfg = networking::cfg(rand::random(), 0)?;
let session = networking::open(cfg).await?;
let _tok = session
.liveliness()
.declare_token(format!("nodes/{}/live", session.zid()))
.await?;
let _sub = session
.liveliness()
.declare_subscriber("nodes/*/live")
.history(true)
.callback(|tok| println!("{tok:?}"))
.await?;
tokio::signal::ctrl_c().await?;
Ok(())
}
-44
View File
@@ -1,44 +0,0 @@
https://github.com/ml-explore/mlx/commit/3fe98bacc7640d857acf3539f1d21b47a32e5609
^raw sockets distributed -> `<net/ndrv.h>` -> https://newosxbook.com/code/xnu-3247.1.106/bsd/net/ndrv.h.auto.html
--> header file for a networking component found in the macOS kernel (XNU) that defines structures for network device driver registration, specifically the ndrv_demux_desc and ndrv_protocol_desc structures used for demultiplexing protocol data at the network interface level. It specifies how to describe protocol data, such as an Ethernet type or a SNAP header, and how to associate these descriptions with a specific protocol family to receive matching packets.
--> Used to bind an NDRV socket so that packets that match given protocol demux descriptions can be received.
--> An NDRV socket is a special kind of socket in the Darwin/macOS operating system's XNU kernel, used for low-level network packet manipulation and binding to specific protocols for packet processing. It allows user-space applications or drivers to directly write Layer 2 (L2) network packets or interact with the network stack at a lower level, often by binding to protocol descriptors like the ndrv_protocol_desc. This type of socket is used for functions such as capturing and injecting packets, especially in network infrastructure software like routers or for kernel-level network monitoring and security tools.
--> also called PF_NDRV sockets --> https://newosxbook.com/bonus/vol1ch16.html
----> they are conceptually similar to https://scapy.disruptivelabs.in/networking/socket-interface PF_RAW or PF_PACKET
https://stackoverflow.com/questions/17169298/af-packet-on-osx
^AF_PACKET duplicates the packets as soon as it receives them from the physical layer (for incoming packets) or just before sending them out to the physical layer (for outgoing packets). -> this is on Linux only
^it doesn't exist on OS X so you can use /dev/bpfX (Berkeley Packet Filter) for sniffing
https://www.unix.com/man_page/mojave/4/ip/
^OS X manpages for IP
https://developer.apple.com/documentation/kernel/implementing_drivers_system_extensions_and_kexts
^driver kit, system extensions & kexts for macOS
----
To set up a Linux system to use a Thunderbolt connection as a network device, connect the two computers with a Thunderbolt cable, load the thunderbolt-net kernel module (usually automatic but modprobe is an option for manual loading), and then the operating system will create virtual Ethernet interfaces (e.g., thunderbolt0) for networking. You can then use standard tools like ifconfig or your desktop environment's network manager to configure these new interfaces for a link-local network.
--> https://gist.github.com/geosp/80fbd39e617b7d1d9421683df4ea224a
----> here is a guide on how to set up thunderbolt-ethernet on linux
----> I may be able to steal the thunderbolt-net code ideas to implement a kernel module for MacOS
https://chatgpt.com/s/t_68af8e41a8548191993281a014f846a7
^GPT discussion about making socket interface
https://chatgpt.com/s/t_68afb798a85c8191973c02a0fa7a48a3 --> link-local address,,??
https://chatgpt.com/s/t_68afb02987e08191b2b0044d3667ece2
^GPT discussion about accessing TB on MacOS low level interactions
--------------------------------
https://www.intel.com/content/www/us/en/support/articles/000098893/software.html
^Thunderbolt Share & Thunderbolt Networking Mode => intel's equivalent of thunderbolt bridge
---------------------------------
https://www.zerotier.com/blog/how-zerotier-eliminated-kernel-extensions-on-macos/
-->fake ethernet devices on MacOS -> omg??? we can detect thunderbolt bridge, then bind to it, then re-expose it as fake ethernet??
-->ps: https://chatgpt.com/s/t_68afb2b25fb881919526763fb5d7359c, AF/PF_NDRV are one and the same!!!
-->https://github.com/zerotier/ZeroTierOne/blob/dev/osdep/MacEthernetTapAgent.c
-390
View File
@@ -1,390 +0,0 @@
use crate::ext::MultiaddrExt;
use delegate::delegate;
use either::Either;
use futures_lite::FutureExt;
use futures_timer::Delay;
use libp2p::core::transport::PortUse;
use libp2p::core::{ConnectedPoint, Endpoint};
use libp2p::swarm::behaviour::ConnectionEstablished;
use libp2p::swarm::dial_opts::DialOpts;
use libp2p::swarm::{
CloseConnection, ConnectionClosed, ConnectionDenied, ConnectionHandler,
ConnectionHandlerSelect, ConnectionId, FromSwarm, NetworkBehaviour, THandler, THandlerInEvent,
THandlerOutEvent, ToSwarm, dummy,
};
use libp2p::{Multiaddr, PeerId, identity, mdns};
use std::collections::{BTreeSet, HashMap};
use std::convert::Infallible;
use std::io;
use std::net::IpAddr;
use std::task::{Context, Poll};
use std::time::Duration;
use util::wakerdeque::WakerDeque;
const RETRY_CONNECT_INTERVAL: Duration = Duration::from_secs(5);
mod managed {
use libp2p::swarm::NetworkBehaviour;
use libp2p::{identity, mdns, ping};
use std::io;
use std::time::Duration;
const MDNS_RECORD_TTL: Duration = Duration::from_secs(2_500);
const MDNS_QUERY_INTERVAL: Duration = Duration::from_secs(1_500);
const PING_TIMEOUT: Duration = Duration::from_millis(2_500);
const PING_INTERVAL: Duration = Duration::from_millis(2_500);
#[derive(NetworkBehaviour)]
pub struct Behaviour {
mdns: mdns::tokio::Behaviour,
ping: ping::Behaviour,
}
impl Behaviour {
pub fn new(keypair: &identity::Keypair) -> io::Result<Self> {
Ok(Self {
mdns: mdns_behaviour(keypair)?,
ping: ping_behaviour(),
})
}
}
fn mdns_behaviour(keypair: &identity::Keypair) -> io::Result<mdns::tokio::Behaviour> {
use mdns::{Config, tokio};
// mDNS config => enable IPv6
let mdns_config = Config {
ttl: MDNS_RECORD_TTL,
query_interval: MDNS_QUERY_INTERVAL,
// enable_ipv6: true, // TODO: for some reason, TCP+mDNS don't work well with ipv6?? figure out how to make work
..Default::default()
};
let mdns_behaviour = tokio::Behaviour::new(mdns_config, keypair.public().to_peer_id());
Ok(mdns_behaviour?)
}
fn ping_behaviour() -> ping::Behaviour {
ping::Behaviour::new(
ping::Config::new()
.with_timeout(PING_TIMEOUT)
.with_interval(PING_INTERVAL),
)
}
}
/// Events for when a listening connection is truly established and truly closed.
#[derive(Debug, Clone)]
pub enum Event {
ConnectionEstablished {
peer_id: PeerId,
connection_id: ConnectionId,
remote_ip: IpAddr,
remote_tcp_port: u16,
},
ConnectionClosed {
peer_id: PeerId,
connection_id: ConnectionId,
remote_ip: IpAddr,
remote_tcp_port: u16,
},
}
/// Discovery behavior that wraps mDNS to produce truly discovered durable peer-connections.
///
/// The behaviour operates as such:
/// 1) All true (listening) connections/disconnections are tracked, emitting corresponding events
/// to the swarm.
/// 1) mDNS discovered/expired peers are tracked; discovered but not connected peers are dialed
/// immediately, and expired but connected peers are disconnected from immediately.
/// 2) Every fixed interval: discovered but not connected peers are dialed, and expired but
/// connected peers are disconnected from.
pub struct Behaviour {
// state-tracking for managed behaviors & mDNS-discovered peers
managed: managed::Behaviour,
mdns_discovered: HashMap<PeerId, BTreeSet<Multiaddr>>,
bootstrap_peers: Vec<Multiaddr>,
retry_delay: Delay, // retry interval
// pending events to emmit => waker-backed Deque to control polling
pending_events: WakerDeque<ToSwarm<Event, Infallible>>,
}
impl Behaviour {
pub fn new(keypair: &identity::Keypair, bootstrap_peers: Vec<Multiaddr>) -> io::Result<Self> {
Ok(Self {
managed: managed::Behaviour::new(keypair)?,
mdns_discovered: HashMap::new(),
bootstrap_peers,
retry_delay: Delay::new(RETRY_CONNECT_INTERVAL),
pending_events: WakerDeque::new(),
})
}
fn dial(&mut self, peer_id: PeerId, addr: Multiaddr) {
self.pending_events.push_back(ToSwarm::Dial {
opts: DialOpts::peer_id(peer_id).addresses(vec![addr]).build(),
})
}
fn close_connection(&mut self, peer_id: PeerId, connection: ConnectionId) {
// push front to make this IMMEDIATE
self.pending_events.push_front(ToSwarm::CloseConnection {
peer_id,
connection: CloseConnection::One(connection),
})
}
fn handle_mdns_discovered(&mut self, peers: Vec<(PeerId, Multiaddr)>) {
for (p, ma) in peers {
self.dial(p, ma.clone()); // always connect
// get peer's multi-addresses or insert if missing
let Some(mas) = self.mdns_discovered.get_mut(&p) else {
self.mdns_discovered.insert(p, BTreeSet::from([ma]));
continue;
};
// multiaddress should never already be present - else something has gone wrong
let is_new_addr = mas.insert(ma);
assert!(is_new_addr, "cannot discover a discovered peer");
}
}
fn handle_mdns_expired(&mut self, peers: Vec<(PeerId, Multiaddr)>) {
for (p, ma) in peers {
// at this point, we *must* have the peer
let mas = self
.mdns_discovered
.get_mut(&p)
.expect("nonexistent peer cannot expire");
// at this point, we *must* have the multiaddress
let was_present = mas.remove(&ma);
assert!(was_present, "nonexistent multiaddress cannot expire");
// if empty, remove the peer-id entirely
if mas.is_empty() {
self.mdns_discovered.remove(&p);
}
}
}
fn on_connection_established(
&mut self,
peer_id: PeerId,
connection_id: ConnectionId,
remote_ip: IpAddr,
remote_tcp_port: u16,
) {
// send out connected event
self.pending_events
.push_back(ToSwarm::GenerateEvent(Event::ConnectionEstablished {
peer_id,
connection_id,
remote_ip,
remote_tcp_port,
}));
}
fn on_connection_closed(
&mut self,
peer_id: PeerId,
connection_id: ConnectionId,
remote_ip: IpAddr,
remote_tcp_port: u16,
) {
// send out disconnected event
self.pending_events
.push_back(ToSwarm::GenerateEvent(Event::ConnectionClosed {
peer_id,
connection_id,
remote_ip,
remote_tcp_port,
}));
}
}
impl NetworkBehaviour for Behaviour {
type ConnectionHandler =
ConnectionHandlerSelect<dummy::ConnectionHandler, THandler<managed::Behaviour>>;
type ToSwarm = Event;
// simply delegate to underlying mDNS behaviour
delegate! {
to self.managed {
fn handle_pending_inbound_connection(&mut self, connection_id: ConnectionId, local_addr: &Multiaddr, remote_addr: &Multiaddr) -> Result<(), ConnectionDenied>;
fn handle_pending_outbound_connection(&mut self, connection_id: ConnectionId, maybe_peer: Option<PeerId>, addresses: &[Multiaddr], effective_role: Endpoint) -> Result<Vec<Multiaddr>, ConnectionDenied>;
}
}
fn handle_established_inbound_connection(
&mut self,
connection_id: ConnectionId,
peer: PeerId,
local_addr: &Multiaddr,
remote_addr: &Multiaddr,
) -> Result<THandler<Self>, ConnectionDenied> {
Ok(ConnectionHandler::select(
dummy::ConnectionHandler,
self.managed.handle_established_inbound_connection(
connection_id,
peer,
local_addr,
remote_addr,
)?,
))
}
#[allow(clippy::needless_question_mark)]
fn handle_established_outbound_connection(
&mut self,
connection_id: ConnectionId,
peer: PeerId,
addr: &Multiaddr,
role_override: Endpoint,
port_use: PortUse,
) -> Result<THandler<Self>, ConnectionDenied> {
Ok(ConnectionHandler::select(
dummy::ConnectionHandler,
self.managed.handle_established_outbound_connection(
connection_id,
peer,
addr,
role_override,
port_use,
)?,
))
}
fn on_connection_handler_event(
&mut self,
peer_id: PeerId,
connection_id: ConnectionId,
event: THandlerOutEvent<Self>,
) {
match event {
Either::Left(ev) => libp2p::core::util::unreachable(ev),
Either::Right(ev) => {
self.managed
.on_connection_handler_event(peer_id, connection_id, ev)
}
}
}
// hook into these methods to drive behavior
fn on_swarm_event(&mut self, event: FromSwarm) {
self.managed.on_swarm_event(event); // let mDNS handle swarm events
// handle swarm events to update internal state:
match event {
FromSwarm::ConnectionEstablished(ConnectionEstablished {
peer_id,
connection_id,
endpoint,
..
}) => {
let remote_address = match endpoint {
ConnectedPoint::Dialer { address, .. } => address,
ConnectedPoint::Listener { send_back_addr, .. } => send_back_addr,
};
if let Some((ip, port)) = remote_address.try_to_tcp_addr() {
// handle connection established event which is filtered correctly
self.on_connection_established(peer_id, connection_id, ip, port)
}
}
FromSwarm::ConnectionClosed(ConnectionClosed {
peer_id,
connection_id,
endpoint,
..
}) => {
let remote_address = match endpoint {
ConnectedPoint::Dialer { address, .. } => address,
ConnectedPoint::Listener { send_back_addr, .. } => send_back_addr,
};
if let Some((ip, port)) = remote_address.try_to_tcp_addr() {
// handle connection closed event which is filtered correctly
self.on_connection_closed(peer_id, connection_id, ip, port)
}
}
// since we are running TCP/IP transport layer, we are assuming that
// no address changes can occur, hence encountering one is a fatal error
FromSwarm::AddressChange(a) => {
unreachable!("unhandlable: address change encountered: {:?}", a)
}
_ => {}
}
}
fn poll(&mut self, cx: &mut Context) -> Poll<ToSwarm<Self::ToSwarm, THandlerInEvent<Self>>> {
// delegate to managed behaviors for any behaviors they need to perform
match self.managed.poll(cx) {
Poll::Ready(ToSwarm::GenerateEvent(e)) => {
match e {
// handle discovered and expired events from mDNS
managed::BehaviourEvent::Mdns(e) => match e.clone() {
mdns::Event::Discovered(peers) => {
self.handle_mdns_discovered(peers);
}
mdns::Event::Expired(peers) => {
self.handle_mdns_expired(peers);
}
},
// handle ping events => if error then disconnect
managed::BehaviourEvent::Ping(e) => {
if let Err(_) = e.result {
self.close_connection(e.peer, e.connection.clone())
}
}
}
// since we just consumed an event, we should immediately wake just in case
// there are more events to come where that came from
cx.waker().wake_by_ref();
}
// forward any other mDNS event to the swarm or its connection handler(s)
Poll::Ready(e) => {
return Poll::Ready(
e.map_out(|_| unreachable!("events returning to swarm already handled"))
.map_in(Either::Right),
);
}
Poll::Pending => {}
}
// retry connecting to all mDNS peers periodically (fails safely if already connected)
if self.retry_delay.poll(cx).is_ready() {
for (p, mas) in self.mdns_discovered.clone() {
for ma in mas {
self.dial(p, ma)
}
}
// dial bootstrap peers (for environments where mDNS is unavailable)
for addr in &self.bootstrap_peers {
self.pending_events.push_back(ToSwarm::Dial {
opts: DialOpts::unknown_peer_id().address(addr.clone()).build(),
})
}
self.retry_delay.reset(RETRY_CONNECT_INTERVAL) // reset timeout
}
// send out any pending events from our own service
if let Some(e) = self.pending_events.pop_front(cx) {
return Poll::Ready(e.map_in(Either::Left));
}
// wait for pending events
Poll::Pending
}
}
+110 -36
View File
@@ -1,44 +1,118 @@
//! TODO: crate documentation
//!
//! this is here as a placeholder documentation
//!
//!
pub mod discovery;
use std::{
env,
ops::{Deref, DerefMut},
panic,
sync::Arc,
};
use netwatcher::WatchHandle;
use parking_lot::Mutex;
use tokio::{sync::mpsc, task::JoinHandle};
use zenoh::{Result, Session as ZSession, config::WhatAmI, internal::runtime::Runtime};
use zenoh_plugin_storage_manager::StoragesPlugin;
use zenoh_plugin_trait::PluginsManager;
pub use zenoh::{Config, config::ZenohId};
pub mod swarm;
/// Namespace for all the type/trait aliases used by this crate.
pub(crate) mod alias {
use std::error::Error;
pub type AnyError = Box<dyn Error + Send + Sync + 'static>;
pub type AnyResult<T> = Result<T, AnyError>;
pub fn cfg(identity: u128, listen_port: u16) -> Result<zenoh::Config> {
let namespace = env::var("EXO_ZENOH_NAMESPACE").unwrap_or_else(|_| "exo".to_string());
let mut cfg = zenoh::Config::default();
// todo: cleanup
cfg.insert_json5("id", &format!("\"{identity:x}\""))?;
cfg.insert_json5("mode", "\"peer\"")?;
cfg.insert_json5("listen/endpoints", &format!("[\"tcp/[::]:{listen_port}\"]"))?;
cfg.insert_json5("scouting/multicast/enabled", "true")?;
cfg.insert_json5("scouting/multicast/autoconnect", "[]")?;
cfg.insert_json5("scouting/gossip/multihop", "true")?;
cfg.insert_json5("namespace", &format!("{namespace:?}"))?;
cfg.insert_json5("transport/link/tx/batch_size", "9216")?;
cfg.insert_json5("timestamping/enabled", "true")?;
cfg.insert_json5("plugins/storage_manager/__required__", "true")?;
cfg.insert_json5(
"plugins/storage_manager/storages/mem1",
r#"{
key_expr: "storage/mem1/**",
strip_prefix: "storage/mem1",
volume: "memory",
replication: {
interval: 2,
}
}"#,
)?;
Ok(cfg)
}
/// Namespace for crate-wide extension traits/methods
pub(crate) mod ext {
use extend::ext;
use libp2p::Multiaddr;
use libp2p::multiaddr::Protocol;
use std::net::IpAddr;
#[ext(pub, name = MultiaddrExt)]
impl Multiaddr {
/// If the multiaddress corresponds to a TCP address, extracts it
fn try_to_tcp_addr(&self) -> Option<(IpAddr, u16)> {
let mut ps = self.into_iter();
let ip = if let Some(p) = ps.next() {
match p {
Protocol::Ip4(ip) => IpAddr::V4(ip),
Protocol::Ip6(ip) => IpAddr::V6(ip),
_ => return None,
pub async fn open(cfg: zenoh::Config) -> Result<Session> {
let mut plugins = PluginsManager::static_plugins_only();
plugins.declare_static_plugin::<StoragesPlugin, _>("storage_manager", true);
let mut runtime = zenoh::internal::runtime::RuntimeBuilder::new(cfg)
.plugins_manager(plugins)
.build()
.await?;
let session = zenoh::session::init(runtime.clone().into()).await?;
runtime.start().await?;
let _watch_all_handle = Arc::new(Mutex::new(watch_all(runtime).await?));
Ok(Session {
session,
_watch_all_handle,
})
}
async fn watch_all(runtime: Runtime) -> Result<WatchAllHandle> {
log::info!("spawning scout");
let mut cfg = Config::default();
cfg.insert_json5("scouting/multicast/ttl", "3")?;
cfg.insert_json5("scouting/multicast/interface", "\"auto\"")?;
let mut scout = zenoh::scout(WhatAmI::Peer, cfg.clone()).await?;
let (send, mut recv) = mpsc::unbounded_channel();
let _sync = netwatcher::watch_interfaces_with_callback(move |u| _ = send.send(u))?;
let _async = tokio::task::spawn(async move {
loop {
tokio::select! {
u = recv.recv() => {
if u.is_none() {
return Ok(());
}
log::info!("reloading scout");
scout = zenoh::scout(WhatAmI::Peer, cfg.clone()).await?;
}
} else {
return None;
};
let Some(Protocol::Tcp(port)) = ps.next() else {
return None;
};
Some((ip, port))
hello = scout.recv_async() => {
if let Ok(hello) = hello {
// todo: auth
runtime
.connect_peer(&hello.zid().into(), hello.locators())
.await;
}
}
}
}
});
Ok(WatchAllHandle { _sync, _async })
}
pub struct Session {
pub session: ZSession,
_watch_all_handle: Arc<Mutex<WatchAllHandle>>,
}
impl Deref for Session {
type Target = ZSession;
fn deref(&self) -> &Self::Target {
&self.session
}
}
impl DerefMut for Session {
fn deref_mut(&mut self) -> &mut Self::Target {
&mut self.session
}
}
impl Drop for WatchAllHandle {
fn drop(&mut self) {
self._async.abort();
}
}
pub struct WatchAllHandle {
_sync: WatchHandle,
_async: JoinHandle<Result<()>>,
}
+129 -229
View File
@@ -1,24 +1,22 @@
//! Compat shim for the old libp2p code
use std::collections::HashMap;
use std::pin::Pin;
use crate::swarm::transport::tcp_transport;
use crate::{alias, discovery};
pub use behaviour::{Behaviour, BehaviourEvent};
use futures_lite::{Stream, StreamExt};
use libp2p::{PeerId, SwarmBuilder, gossipsub, identity, swarm::SwarmEvent};
use tokio::sync::{mpsc, oneshot};
use futures_lite::Stream;
use tokio::sync::mpsc;
use tokio::sync::oneshot;
use tracing::info;
use zenoh::Result;
use zenoh::Session;
use zenoh::handlers::FifoChannelHandler;
use zenoh::liveliness::LivelinessToken;
use zenoh::pubsub::Subscriber;
use zenoh::sample::Sample;
use zenoh::sample::SampleKind;
use zerompk::{FromMessagePack, ToMessagePack};
/// The current version of the network: this prevents devices running different versions of the
/// software from interacting with each other.
///
/// TODO: right now this is a hardcoded constant; figure out what the versioning semantics should
/// even be, and how to inject the right version into this config/initialization. E.g. should
/// this be passed in as a parameter? What about rapidly changing versions in debug builds?
/// this is all VERY very hard to figure out and needs to be mulled over as a team.
pub const NETWORK_VERSION: &[u8] = b"v0.0.1";
pub const OVERRIDE_VERSION_ENV_VAR: &str = "EXO_LIBP2P_NAMESPACE";
// Uses oneshot senders to emulate function calling apis while avoiding requiring unique ownership
// of the Swarm.
#[derive(Debug)]
pub enum ToSwarm {
Unsubscribe {
topic: String,
@@ -26,52 +24,66 @@ pub enum ToSwarm {
},
Subscribe {
topic: String,
result_sender: oneshot::Sender<Result<bool, gossipsub::SubscriptionError>>,
result_sender: oneshot::Sender<Result<bool>>,
},
Publish {
topic: String,
data: Vec<u8>,
result_sender: oneshot::Sender<Result<gossipsub::MessageId, gossipsub::PublishError>>,
result_sender: oneshot::Sender<Result<()>>,
},
}
#[derive(Debug, ToMessagePack, FromMessagePack)]
pub enum FromSwarm {
Message {
from: PeerId,
topic: String,
data: Vec<u8>,
},
Discovered {
peer_id: PeerId,
},
Expired {
peer_id: PeerId,
},
Message { topic: String, data: Vec<u8> },
Discovered {},
Expired {},
}
pub type Topics = HashMap<String, Subscriber<()>>;
pub struct Swarm {
swarm: libp2p::Swarm<Behaviour>,
cfg: zenoh::Config,
from_client: mpsc::Receiver<ToSwarm>,
}
impl Swarm {
pub fn into_stream(self) -> Pin<Box<dyn Stream<Item = FromSwarm> + Send>> {
let Swarm {
mut swarm,
cfg,
mut from_client,
} = self;
let stream = async_stream::stream! {
let (mut to_topics, mut from_topics) = mpsc::channel(1024);
let mut topics = Topics::new();
let Ok(mut session) = crate::open(cfg).await else { return; };
let Ok((_token, discovery)) = register_liveness(&mut session).await else { return; };
loop {
tokio::select! {
msg = from_client.recv() => {
let Some(msg) = msg else { break };
on_message(&mut swarm, msg);
on_message(&mut session, &mut topics, &mut to_topics, msg).await;
}
event = swarm.next() => {
let Some(event) = event else { break };
if let Some(item) = filter_swarm_event(event) {
yield item;
event = from_topics.recv() => {
if let Some(event) = event {
yield event
}
}
token = discovery.recv_async() => {
if let Ok(token) = token {
let key_expr = token.key_expr().as_str().to_owned();
let nid = key_expr.strip_prefix("nodes/").and_then(|s| s.strip_suffix("/live"));
yield match token.kind() {
SampleKind::Put => {
info!("discovered: {nid:?}");
FromSwarm::Discovered {}
}
SampleKind::Delete => {
info!("expired: {nid:?}");
FromSwarm::Expired {}
}
}
}
}
}
}
};
@@ -79,208 +91,96 @@ impl Swarm {
}
}
fn on_message(swarm: &mut libp2p::Swarm<Behaviour>, message: ToSwarm) {
match message {
ToSwarm::Subscribe {
topic,
result_sender,
} => {
let result = swarm
.behaviour_mut()
.gossipsub
.subscribe(&gossipsub::IdentTopic::new(topic));
_ = result_sender.send(result);
}
ToSwarm::Unsubscribe {
topic,
result_sender,
} => {
let result = swarm
.behaviour_mut()
.gossipsub
.unsubscribe(&gossipsub::IdentTopic::new(topic));
_ = result_sender.send(result);
}
async fn register_liveness(
session: &mut Session,
) -> Result<(LivelinessToken, Subscriber<FifoChannelHandler<Sample>>)> {
let token = session
.liveliness()
.declare_token(format!("nodes/{}/live", session.zid()))
.await?;
let sub = session
.liveliness()
.declare_subscriber("nodes/*/live")
.history(true)
.await?;
Ok((token, sub))
}
async fn on_message(
session: &mut Session,
topics: &mut Topics,
to_topics: &mut mpsc::Sender<FromSwarm>,
msg: ToSwarm,
) {
match msg {
ToSwarm::Publish {
topic,
data,
result_sender,
} => {
let result = swarm
.behaviour_mut()
.gossipsub
.publish(gossipsub::IdentTopic::new(topic), data);
_ = result_sender.send(result);
let res = session.put(format!("topics/{topic}"), data).await;
_ = result_sender.send(res);
}
ToSwarm::Unsubscribe {
topic,
result_sender,
} => {
let Some((_, subscriber)) = topics.remove_entry(&topic) else {
_ = result_sender.send(false);
return;
};
_ = subscriber.undeclare().await;
_ = result_sender.send(true);
}
ToSwarm::Subscribe {
topic,
result_sender,
} => {
assert!(topic.is_ascii());
if topics.contains_key(&topic) {
_ = result_sender.send(Ok(false));
return;
}
let subscriber = match session
.declare_subscriber(format!("topics/{topic}"))
.allowed_origin(zenoh::sample::Locality::Remote)
.callback({
let sender = to_topics.clone();
let topic = topic.clone();
move |sample| {
if sample.kind() != SampleKind::Put {
return;
}
_ = sender.try_send(FromSwarm::Message {
topic: topic.clone(),
data: sample.payload().to_bytes().to_vec(),
});
}
})
.await
{
Ok(p) => p,
Err(e) => {
_ = result_sender.send(Err(e));
return;
}
};
assert!(topics.insert(topic, subscriber).is_none());
_ = result_sender.send(Ok(true));
}
}
}
fn filter_swarm_event(event: SwarmEvent<BehaviourEvent>) -> Option<FromSwarm> {
match event {
SwarmEvent::Behaviour(BehaviourEvent::Gossipsub(gossipsub::Event::Message {
message:
gossipsub::Message {
source: Some(peer_id),
topic,
data,
..
},
..
})) => Some(FromSwarm::Message {
from: peer_id,
topic: topic.into_string(),
data,
}),
SwarmEvent::Behaviour(BehaviourEvent::Discovery(
discovery::Event::ConnectionEstablished { peer_id, .. },
)) => Some(FromSwarm::Discovered { peer_id }),
SwarmEvent::Behaviour(BehaviourEvent::Discovery(discovery::Event::ConnectionClosed {
peer_id,
..
})) => Some(FromSwarm::Expired { peer_id }),
_ => None,
}
}
/// Create and configure a swarm.
///
/// - `listen_port`: TCP port to listen on. `0` lets the OS assign one.
/// - `bootstrap_peers`: multiaddrs to dial for environments without mDNS.
pub fn create_swarm(
keypair: identity::Keypair,
identity: u128,
from_client: mpsc::Receiver<ToSwarm>,
bootstrap_peers: Vec<String>,
listen_port: u16,
) -> alias::AnyResult<Swarm> {
let parsed_bootstrap_peers: Vec<libp2p::Multiaddr> = bootstrap_peers
.iter()
.filter(|s| !s.is_empty())
.filter_map(|s| s.parse().ok())
.collect();
let mut swarm = SwarmBuilder::with_existing_identity(keypair)
.with_tokio()
.with_other_transport(tcp_transport)?
.with_behaviour(|keypair| Behaviour::new(keypair, parsed_bootstrap_peers))?
.build();
swarm.listen_on(format!("/ip4/0.0.0.0/tcp/{listen_port}").parse()?)?;
Ok(Swarm { swarm, from_client })
}
mod transport {
use crate::alias;
use crate::swarm::{NETWORK_VERSION, OVERRIDE_VERSION_ENV_VAR};
use futures_lite::{AsyncRead, AsyncWrite};
use keccak_const::Sha3_256;
use libp2p::core::muxing;
use libp2p::core::transport::Boxed;
use libp2p::pnet::{PnetError, PnetOutput};
use libp2p::{PeerId, Transport, identity, noise, pnet, yamux};
use std::{env, sync::LazyLock};
/// Key used for networking's private network; parametrized on the [`NETWORK_VERSION`].
/// See [`pnet_upgrade`] for more.
static PNET_PRESHARED_KEY: LazyLock<[u8; 32]> = LazyLock::new(|| {
let builder = Sha3_256::new().update(b"exo_discovery_network");
if let Ok(var) = env::var(OVERRIDE_VERSION_ENV_VAR) {
let bytes = var.into_bytes();
builder.update(&bytes)
} else {
builder.update(NETWORK_VERSION)
}
.finalize()
});
/// Make the Swarm run on a private network, as to not clash with public libp2p nodes and
/// also different-versioned instances of this same network.
/// This is implemented as an additional "upgrade" ontop of existing [`libp2p::Transport`] layers.
async fn pnet_upgrade<TSocket>(
socket: TSocket,
_: impl Sized,
) -> Result<PnetOutput<TSocket>, PnetError>
where
TSocket: AsyncRead + AsyncWrite + Send + Unpin + 'static,
{
use pnet::{PnetConfig, PreSharedKey};
PnetConfig::new(PreSharedKey::new(*PNET_PRESHARED_KEY))
.handshake(socket)
.await
}
/// TCP/IP transport layer configuration.
pub fn tcp_transport(
keypair: &identity::Keypair,
) -> alias::AnyResult<Boxed<(PeerId, muxing::StreamMuxerBox)>> {
use libp2p::{
core::upgrade::Version,
tcp::{Config, tokio},
};
// `TCP_NODELAY` enabled => avoid latency
let tcp_config = Config::default().nodelay(true);
// V1 + lazy flushing => 0-RTT negotiation
let upgrade_version = Version::V1Lazy;
// Noise is faster than TLS + we don't care much for security
let noise_config = noise::Config::new(keypair)?;
// Use default Yamux config for multiplexing
let yamux_config = yamux::Config::default();
// Create new Tokio-driven TCP/IP transport layer
let base_transport = tokio::Transport::new(tcp_config)
.and_then(pnet_upgrade)
.upgrade(upgrade_version)
.authenticate(noise_config)
.multiplex(yamux_config);
// Return boxed transport (to flatten complex type)
Ok(base_transport.boxed())
}
}
mod behaviour {
use crate::{alias, discovery};
use libp2p::swarm::NetworkBehaviour;
use libp2p::{gossipsub, identity};
/// Behavior of the Swarm which composes all desired behaviors:
/// Right now its just [`discovery::Behaviour`] and [`gossipsub::Behaviour`].
#[derive(NetworkBehaviour)]
pub struct Behaviour {
pub discovery: discovery::Behaviour,
pub gossipsub: gossipsub::Behaviour,
}
impl Behaviour {
pub fn new(
keypair: &identity::Keypair,
bootstrap_peers: Vec<libp2p::Multiaddr>,
) -> alias::AnyResult<Self> {
Ok(Self {
discovery: discovery::Behaviour::new(keypair, bootstrap_peers)?,
gossipsub: gossipsub_behaviour(keypair),
})
}
}
fn gossipsub_behaviour(keypair: &identity::Keypair) -> gossipsub::Behaviour {
use gossipsub::{ConfigBuilder, MessageAuthenticity, ValidationMode};
// build a gossipsub network behaviour
// => signed message authenticity + strict validation mode means the message-ID is
// automatically provided by gossipsub w/out needing to provide custom message-ID function
gossipsub::Behaviour::new(
MessageAuthenticity::Signed(keypair.clone()),
ConfigBuilder::default()
.max_transmit_size(8 * 1024 * 1024)
.validation_mode(ValidationMode::Strict)
.build()
.expect("the configuration should always be valid"),
)
.expect("creating gossipsub behavior should always work")
}
) -> Result<Swarm> {
// todo: bootstrap
if !bootstrap_peers.is_empty() || listen_port != 0 {
todo!();
}
let cfg = crate::cfg(identity, listen_port)?;
Ok(Swarm { cfg, from_client })
}
-107
View File
@@ -1,107 +0,0 @@
use futures_lite::StreamExt;
use networking::swarm::{FromSwarm, create_swarm};
use std::time::Duration;
use tokio::sync::mpsc;
use tokio::time::timeout;
/// Helper: find a free TCP port.
fn free_port() -> u16 {
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
listener.local_addr().unwrap().port()
}
/// Two nodes connect via bootstrap peers — no mDNS needed.
///
/// Node A listens on a fixed port. Node B bootstraps to A's address.
/// We verify that B emits `FromSwarm::Discovered` for A's peer ID.
#[tokio::test]
async fn two_nodes_connect_via_bootstrap_peers() {
let port_a = free_port();
// Node A: listens on a known port, no bootstrap peers
let keypair_a = libp2p::identity::Keypair::generate_ed25519();
let peer_id_a = keypair_a.public().to_peer_id();
let (_tx_a, rx_a) = mpsc::channel(16);
let swarm_a = create_swarm(keypair_a, rx_a, vec![], port_a).expect("create swarm A");
let mut stream_a = swarm_a.into_stream();
// Node B: bootstraps to A's address
let keypair_b = libp2p::identity::Keypair::generate_ed25519();
let (_tx_b, rx_b) = mpsc::channel(16);
let swarm_b = create_swarm(
keypair_b,
rx_b,
vec![format!("/ip4/127.0.0.1/tcp/{port_a}")],
0,
)
.expect("create swarm B");
let mut stream_b = swarm_b.into_stream();
// Wait for B to discover A (connection established)
let connected = timeout(Duration::from_secs(10), async {
loop {
tokio::select! {
Some(event) = stream_a.next() => {
// A will also see B connect, but we check from B's perspective
let _ = event;
}
Some(event) = stream_b.next() => {
if let FromSwarm::Discovered { peer_id } = event {
if peer_id == peer_id_a {
return true;
}
}
}
}
}
})
.await;
assert!(
connected.is_ok() && connected.unwrap(),
"Node B should discover Node A via bootstrap peer"
);
}
/// Empty bootstrap peers should work (backward compatible).
#[tokio::test]
async fn create_swarm_with_empty_bootstrap_peers() {
let keypair = libp2p::identity::Keypair::generate_ed25519();
let (_tx, rx) = mpsc::channel(16);
let swarm = create_swarm(keypair, rx, vec![], 0);
assert!(
swarm.is_ok(),
"create_swarm with no bootstrap peers should succeed"
);
}
/// Invalid multiaddr strings are silently filtered out.
#[tokio::test]
async fn create_swarm_ignores_invalid_bootstrap_addrs() {
let keypair = libp2p::identity::Keypair::generate_ed25519();
let (_tx, rx) = mpsc::channel(16);
let swarm = create_swarm(
keypair,
rx,
vec![
"not-a-valid-multiaddr".to_string(),
"".to_string(),
"/ip4/10.0.0.1/tcp/30000".to_string(), // valid
],
0,
);
assert!(
swarm.is_ok(),
"create_swarm should succeed even with invalid bootstrap addrs"
);
}
/// Fixed listen port works correctly.
#[tokio::test]
async fn create_swarm_with_fixed_port() {
let port = free_port();
let keypair = libp2p::identity::Keypair::generate_ed25519();
let (_tx, rx) = mpsc::channel(16);
let swarm = create_swarm(keypair, rx, vec![], port);
assert!(swarm.is_ok(), "create_swarm with fixed port should succeed");
}
-7
View File
@@ -1,7 +0,0 @@
// maybe this will hold test in the future...??
#[cfg(test)]
mod tests {
#[test]
fn does_nothing() {}
}
-15
View File
@@ -1,15 +0,0 @@
[package]
name = "util"
version = { workspace = true }
edition = { workspace = true }
publish = false
[lib]
doctest = false
name = "util"
path = "src/lib.rs"
[lints]
workspace = true
[dependencies]
-1
View File
@@ -1 +0,0 @@
pub mod wakerdeque;
-55
View File
@@ -1,55 +0,0 @@
use std::collections::VecDeque;
use std::fmt::{Debug, Formatter};
use std::task::{Context, Waker};
/// A wrapper around [`VecDeque`] which wakes (if it can) on any `push_*` methods,
/// and updates the internally stored waker by consuming [`Context`] on any `pop_*` methods.
pub struct WakerDeque<T> {
waker: Option<Waker>,
deque: VecDeque<T>,
}
impl<T: Debug> Debug for WakerDeque<T> {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
self.deque.fmt(f)
}
}
impl<T> WakerDeque<T> {
pub fn new() -> Self {
Self {
waker: None,
deque: VecDeque::new(),
}
}
fn update(&mut self, cx: &mut Context<'_>) {
self.waker = Some(cx.waker().clone());
}
fn wake(&mut self) {
let Some(ref mut w) = self.waker else { return };
w.wake_by_ref();
self.waker = None;
}
pub fn pop_front(&mut self, cx: &mut Context<'_>) -> Option<T> {
self.update(cx);
self.deque.pop_front()
}
pub fn pop_back(&mut self, cx: &mut Context<'_>) -> Option<T> {
self.update(cx);
self.deque.pop_back()
}
pub fn push_front(&mut self, value: T) {
self.wake();
self.deque.push_front(value);
}
pub fn push_back(&mut self, value: T) {
self.wake();
self.deque.push_back(value);
}
}
+12 -13
View File
@@ -133,12 +133,10 @@ from exo.shared.constants import (
)
from exo.shared.election import ElectionMessage
from exo.shared.logging import InterceptLogger
from exo.shared.models import model_cards
from exo.shared.models.model_cards import (
ModelCard,
ModelId,
add_to_card_cache,
get_card,
get_model_cards,
)
from exo.shared.tracing import TraceEvent, compute_stats, export_trace, load_trace_file
from exo.shared.types.chunks import (
@@ -481,6 +479,7 @@ class API:
topology=self.state.topology,
current_instances=self.state.instances,
download_status=self.state.downloads,
node_rdma_ctl=self.state.node_rdma_ctl,
)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
@@ -544,6 +543,7 @@ class API:
current_instances=self.state.instances,
required_nodes=required_nodes,
download_status=self.state.downloads,
node_rdma_ctl=self.state.node_rdma_ctl,
)
except ValueError as exc:
if (model_card.model_id, sharding, instance_meta, 0) not in seen:
@@ -1633,17 +1633,16 @@ class API:
async def ollama_tags(self) -> OllamaTagsResponse:
"""Returns list of models in Ollama tags format. We return the downloaded ones only."""
def none_if_empty(value: str) -> str | None:
return value or None
downloaded_model_ids: set[str] = set()
downloaded_model_ids: set[ModelId] = set()
for node_downloads in self.state.downloads.values():
for dl in node_downloads:
if isinstance(dl, DownloadCompleted):
downloaded_model_ids.add(dl.shard_metadata.model_card.model_id)
cards = [
c for c in await get_model_cards() if c.model_id in downloaded_model_ids
c
for c in await model_cards.card_cache.list_all()
if c.model_id in downloaded_model_ids
]
now = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
@@ -1656,8 +1655,8 @@ class API:
size=card.storage_size.in_bytes,
digest="sha256:000000000000",
details=OllamaModelDetails(
family=none_if_empty(card.family),
quantization_level=none_if_empty(card.quantization),
family=card.family or None,
quantization_level=card.quantization or None,
),
)
for card in cards
@@ -1720,7 +1719,7 @@ class API:
async def get_models(self, status: str | None = Query(default=None)) -> ModelList:
"""Returns list of available models, optionally filtered by being downloaded."""
cards = await get_model_cards()
cards = await model_cards.card_cache.list_all()
if status == "downloaded":
downloaded_model_ids: set[str] = set()
@@ -1771,7 +1770,7 @@ class API:
# Immediately update the local cache so the subsequent GET /models
# returns the new model without waiting for the event round-trip.
add_to_card_cache(card)
model_cards.card_cache.cc[card.model_id] = card
return ModelListModel(
id=card.model_id,
@@ -1787,7 +1786,7 @@ class API:
async def delete_custom_model(self, model_id: ModelId) -> JSONResponse:
"""Delete a user-added custom model card and sync deletion across the cluster."""
card = get_card(model_id)
card = model_cards.card_cache.get(model_id)
if card is None or not card.is_custom:
raise HTTPException(status_code=404, detail="Custom model card not found")
+3 -2
View File
@@ -16,7 +16,8 @@ from exo.download.download_utils import (
)
from exo.download.shard_downloader import ShardDownloader
from exo.shared.constants import EXO_DEFAULT_MODELS_DIR, EXO_MODELS_READ_ONLY_DIRS
from exo.shared.models.model_cards import ModelId, get_model_cards
from exo.shared.models import model_cards
from exo.shared.models.model_cards import ModelId
from exo.shared.types.commands import (
CancelDownload,
DeleteDownload,
@@ -422,7 +423,7 @@ class DownloadCoordinator:
)
# Scan read-only directories for pre-downloaded models
if EXO_MODELS_READ_ONLY_DIRS:
for card in await get_model_cards():
for card in await model_cards.card_cache.list_all():
mid = card.model_id
if mid in self.active_downloads:
continue
+2 -2
View File
@@ -11,11 +11,11 @@ from exo.download.download_utils import (
download_shard,
)
from exo.download.shard_downloader import ShardDownloader
from exo.shared.models import model_cards
from exo.shared.models.model_cards import (
ModelCard,
ModelId,
ModelTask,
get_model_cards,
)
from exo.shared.types.memory import Memory
from exo.shared.types.worker.shards import (
@@ -258,7 +258,7 @@ class ResumableShardDownloader(ShardDownloader):
tasks = [
create_task(download_with_semaphore(model_card))
for model_card in await get_model_cards()
for model_card in await model_cards.card_cache.list_all()
]
for task in asyncio.as_completed(tasks):
+2 -3
View File
@@ -365,6 +365,7 @@ class Master:
self.state.node_memory,
self.state.node_network,
download_status=self.state.downloads,
node_rdma_ctl=self.state.node_rdma_ctl,
)
transition_events = get_transition_events(
self.state.instances, placement, self.state.tasks
@@ -439,9 +440,7 @@ class Master:
InstanceLinkDeleted(link_id=command.link_id)
)
case RequestEventLog():
# We should just be able to send everything, since other buffers will ignore old messages
# rate limit to 1000 at a time
end = min(command.since_idx + 1000, len(self._event_log))
end = len(self._event_log)
for i, event in enumerate(
self._event_log.read_range(command.since_idx, end),
start=command.since_idx,
+13 -2
View File
@@ -28,7 +28,7 @@ from exo.shared.types.events import (
TaskStatusUpdated,
)
from exo.shared.types.memory import Memory
from exo.shared.types.profiling import MemoryUsage, NodeNetworkInfo
from exo.shared.types.profiling import MemoryUsage, NodeNetworkInfo, NodeRdmaCtlStatus
from exo.shared.types.tasks import Task, TaskId, TaskStatus
from exo.shared.types.worker.downloads import (
DownloadCompleted,
@@ -105,6 +105,7 @@ def place_instance(
node_network: Mapping[NodeId, NodeNetworkInfo],
required_nodes: set[NodeId] | None = None,
download_status: Mapping[NodeId, Sequence[DownloadProgress]] | None = None,
node_rdma_ctl: Mapping[NodeId, NodeRdmaCtlStatus] | None = None,
) -> dict[InstanceId, Instance]:
cycles = topology.get_cycles()
candidate_cycles = list(filter(lambda it: len(it) >= command.min_nodes, cycles))
@@ -166,8 +167,18 @@ def place_instance(
smallest_cycles = get_smallest_cycles(cycles_with_sufficient_memory)
rdma_ctl_status = node_rdma_ctl or {}
def _all_rdma_ctl_enabled(cycle: Cycle) -> bool:
return all(
((status := rdma_ctl_status.get(node_id)) is not None and status.enabled)
for node_id in cycle
)
smallest_rdma_cycles = [
cycle for cycle in smallest_cycles if topology.is_rdma_cycle(cycle)
cycle
for cycle in smallest_cycles
if topology.is_rdma_cycle(cycle) and _all_rdma_ctl_enabled(cycle)
]
if command.instance_meta == InstanceMeta.MlxJaccl:
+144 -2
View File
@@ -21,7 +21,11 @@ from exo.shared.types.events import (
)
from exo.shared.types.memory import Memory
from exo.shared.types.multiaddr import Multiaddr
from exo.shared.types.profiling import NetworkInterfaceInfo, NodeNetworkInfo
from exo.shared.types.profiling import (
NetworkInterfaceInfo,
NodeNetworkInfo,
NodeRdmaCtlStatus,
)
from exo.shared.types.tasks import TaskId, TaskStatus, TextGeneration
from exo.shared.types.text_generation import (
InputMessage,
@@ -439,8 +443,21 @@ def test_tensor_rdma_backend_connectivity_matrix(
min_nodes=1,
)
node_rdma_ctl = {
node_a: NodeRdmaCtlStatus(enabled=True),
node_b: NodeRdmaCtlStatus(enabled=True),
node_c: NodeRdmaCtlStatus(enabled=True),
}
# act
placements = place_instance(cic, topology, {}, node_memory, node_network)
placements = place_instance(
cic,
topology,
{},
node_memory,
node_network,
node_rdma_ctl=node_rdma_ctl,
)
# assert
assert len(placements) == 1
@@ -482,6 +499,131 @@ def test_tensor_rdma_backend_connectivity_matrix(
assert len(ip_part.split(".")) == 4
def _build_three_node_rdma_topology() -> tuple[
Topology, NodeId, NodeId, NodeId, dict[NodeId, NodeNetworkInfo]
]:
topology = Topology()
node_a = NodeId()
node_b = NodeId()
node_c = NodeId()
ethernet_interface = NetworkInterfaceInfo(name="en0", ip_address="10.0.0.1")
ethernet_conn = SocketConnection(
sink_multiaddr=Multiaddr(address="/ip4/10.0.0.1/tcp/8000")
)
node_network = {
node_a: NodeNetworkInfo(interfaces=[ethernet_interface]),
node_b: NodeNetworkInfo(interfaces=[ethernet_interface]),
node_c: NodeNetworkInfo(interfaces=[ethernet_interface]),
}
for n in (node_a, node_b, node_c):
topology.add_node(n)
rdma_pairs = [
(node_a, node_b, 3),
(node_b, node_a, 3),
(node_b, node_c, 4),
(node_c, node_b, 4),
(node_a, node_c, 5),
(node_c, node_a, 5),
]
for src, sink, iface in rdma_pairs:
topology.add_connection(
Connection(source=src, sink=sink, edge=create_rdma_connection(iface))
)
socket_pairs = [
(node_a, node_b),
(node_b, node_c),
(node_c, node_a),
(node_a, node_c),
(node_b, node_a),
(node_c, node_b),
]
for src, sink in socket_pairs:
topology.add_connection(Connection(source=src, sink=sink, edge=ethernet_conn))
return topology, node_a, node_b, node_c, node_network
def test_place_mlx_jaccl_rejects_when_a_node_has_rdma_ctl_disabled(
model_card: ModelCard,
):
# arrange
model_card = model_card.model_copy(
update={"n_layers": 12, "storage_size": Memory.from_bytes(1500)}
)
topology, node_a, node_b, node_c, node_network = _build_three_node_rdma_topology()
node_memory = {
node_a: create_node_memory(500),
node_b: create_node_memory(500),
node_c: create_node_memory(500),
}
node_rdma_ctl = {
node_a: NodeRdmaCtlStatus(enabled=True),
node_b: NodeRdmaCtlStatus(enabled=True),
node_c: NodeRdmaCtlStatus(enabled=False),
}
cic = PlaceInstance(
sharding=Sharding.Tensor,
instance_meta=InstanceMeta.MlxJaccl,
command_id=CommandId(),
model_card=model_card,
min_nodes=3,
)
# act / assert
with pytest.raises(
ValueError, match="Requested RDMA \\(MlxJaccl\\) but no RDMA-connected cycles"
):
place_instance(
cic,
topology,
{},
node_memory,
node_network,
node_rdma_ctl=node_rdma_ctl,
)
def test_place_mlx_jaccl_rejects_when_node_rdma_ctl_missing(model_card: ModelCard):
"""A node with no observed rdma_ctl status must not participate in RDMA placement."""
# arrange
model_card = model_card.model_copy(
update={"n_layers": 12, "storage_size": Memory.from_bytes(1500)}
)
topology, node_a, node_b, node_c, node_network = _build_three_node_rdma_topology()
node_memory = {
node_a: create_node_memory(500),
node_b: create_node_memory(500),
node_c: create_node_memory(500),
}
# node_c has no rdma_ctl entry at all
node_rdma_ctl = {
node_a: NodeRdmaCtlStatus(enabled=True),
node_b: NodeRdmaCtlStatus(enabled=True),
}
cic = PlaceInstance(
sharding=Sharding.Tensor,
instance_meta=InstanceMeta.MlxJaccl,
command_id=CommandId(),
model_card=model_card,
min_nodes=3,
)
# act / assert
with pytest.raises(ValueError):
place_instance(
cic,
topology,
{},
node_memory,
node_network,
node_rdma_ctl=node_rdma_ctl,
)
def _make_task(
instance_id: InstanceId,
status: TaskStatus = TaskStatus.Running,
+1 -3
View File
@@ -1,15 +1,13 @@
from exo_pyo3_bindings import PyFromSwarm
from exo.shared.types.common import NodeId
from exo.utils.pydantic_ext import FrozenModel
"""Serialisable types for Connection Updates/Messages"""
class ConnectionMessage(FrozenModel):
node_id: NodeId
connected: bool
@classmethod
def from_update(cls, update: PyFromSwarm.Connection) -> "ConnectionMessage":
return cls(node_id=NodeId(update.peer_id), connected=update.connected)
return cls(connected=update.connected)
+6 -18
View File
@@ -13,11 +13,8 @@ from anyio import (
sleep_forever,
)
from exo_pyo3_bindings import (
AllQueuesFullError,
Keypair,
MessageTooLargeError,
NetworkingHandle,
NoPeersSubscribedToTopicError,
PyFromSwarm,
)
from filelock import FileLock
@@ -191,9 +188,9 @@ class Router:
from_swarm = await self._net.recv()
logger.debug(from_swarm)
match from_swarm:
case PyFromSwarm.Message(origin, topic, data):
case PyFromSwarm.Message(topic, data):
logger.trace(
f"Received message on {topic} from {origin} with payload {data}"
f"Received message on {topic} with payload {data}"
)
if topic not in self.topic_routers:
logger.warning(
@@ -225,21 +222,12 @@ class Router:
async def _networking_publish(self):
with self.networking_receiver as networked_items:
async for topic, data in networked_items:
try:
logger.trace(f"Sending message on {topic} with payload {data}")
if len(data) > 1024 * 1024:
logger.warning(
"Sending overlarge payload, network performance may be temporarily degraded"
)
await self._net.gossipsub_publish(topic, data)
except NoPeersSubscribedToTopicError:
pass
except AllQueuesFullError:
logger.warning(f"All peer queues full, dropping message on {topic}")
except MessageTooLargeError:
logger.trace(f"Sending message on {topic} with payload {data}")
if len(data) > 1024 * 1024:
logger.warning(
f"Message too large for gossipsub on {topic} ({len(data)} bytes), dropping"
"Sending overlarge payload, network performance may be temporarily degraded"
)
await self._net.gossipsub_publish(topic, data)
def get_node_id_keypair(
+50 -3
View File
@@ -4,7 +4,8 @@ from datetime import datetime
from loguru import logger
from exo.shared.types.common import NodeId
from exo.shared.models.model_cards import ModelCard
from exo.shared.types.common import ModelId, NodeId
from exo.shared.types.events import (
ChunkGenerated,
CustomModelCardAdded,
@@ -65,6 +66,18 @@ from exo.utils.info_gatherer.info_gatherer import (
)
def _is_rdma_ctl_enabled(
node_id: NodeId, node_rdma_ctl: Mapping[NodeId, NodeRdmaCtlStatus]
) -> bool:
"""A node is RDMA-capable only if rdma_ctl status has been observed as enabled.
Missing entries default to ``False`` — if we have not yet observed (or the node
cannot run) ``rdma_ctl``, it must not participate in an RDMA-backed instance.
"""
status = node_rdma_ctl.get(node_id)
return status is not None and status.enabled
def event_apply(event: Event, state: State) -> State:
"""Apply an event to state."""
match event:
@@ -75,10 +88,12 @@ def event_apply(event: Event, state: State) -> State:
| InputChunkReceived()
| TracesCollected()
| TracesMerged()
| CustomModelCardAdded()
| CustomModelCardDeleted()
): # Pass-through events that don't modify state
return state
case CustomModelCardAdded():
return apply_custom_model_card_added(event, state)
case CustomModelCardDeleted():
return apply_custom_model_card_deleted(event, state)
case InstanceCreated():
return apply_instance_created(event, state)
case InstanceDeleted():
@@ -397,6 +412,9 @@ def apply_node_gathered_info(event: NodeGatheredInfo, state: State) -> State:
for nid in state.node_thunderbolt
for tb_ident in state.node_thunderbolt[nid].interfaces
}
source_is_rdma_enabled = _is_rdma_ctl_enabled(
event.node_id, state.node_rdma_ctl
)
as_rdma_conns = [
Connection(
source=event.node_id,
@@ -409,6 +427,10 @@ def apply_node_gathered_info(event: NodeGatheredInfo, state: State) -> State:
for tb_conn in info.conns
if tb_conn.source_uuid in conn_map
if tb_conn.sink_uuid in conn_map
if source_is_rdma_enabled
and _is_rdma_ctl_enabled(
conn_map[tb_conn.sink_uuid][0], state.node_rdma_ctl
)
]
topology.replace_all_out_rdma_connections(event.node_id, as_rdma_conns)
case ThunderboltBridgeInfo():
@@ -432,6 +454,12 @@ def apply_node_gathered_info(event: NodeGatheredInfo, state: State) -> State:
**state.node_rdma_ctl,
event.node_id: NodeRdmaCtlStatus(enabled=info.enabled),
}
# If RDMA just got disabled on this node, drop any RDMA edges touching it
# so placement / topology consumers cannot pick a disabled node for an
# RDMA-backed instance. (Edges will repopulate on the next
# MacThunderboltConnections poll once both endpoints are enabled again.)
if not info.enabled:
topology.remove_all_rdma_connections_touching(event.node_id)
return state.model_copy(update=update)
@@ -447,3 +475,22 @@ def apply_topology_edge_deleted(event: TopologyEdgeDeleted, state: State) -> Sta
topology.remove_connection(event.conn)
# TODO: Clean up removing the reverse connection
return state.model_copy(update={"topology": topology})
def apply_custom_model_card_added(event: CustomModelCardAdded, state: State) -> State:
new_cards: Mapping[ModelId, ModelCard] = {
**state.custom_model_cards,
event.model_card.model_id: event.model_card,
}
return state.model_copy(update={"custom_model_cards": new_cards})
def apply_custom_model_card_deleted(
event: CustomModelCardDeleted, state: State
) -> State:
new_cards: Mapping[ModelId, ModelCard] = {
model_id: card
for model_id, card in state.custom_model_cards.items()
if model_id != event.model_id
}
return state.model_copy(update={"custom_model_cards": new_cards})
+2 -1
View File
@@ -46,7 +46,8 @@ class _InterceptHandler(logging.Handler):
def logger_setup(log_file: Path | None, verbosity: int = 0):
"""Set up logging for this process - formatting, file handles, verbosity and output"""
logging.getLogger("exo_pyo3_bindings").setLevel(logging.WARNING)
logging.getLogger("exo_net").setLevel(logging.INFO)
logging.getLogger("networking").setLevel(logging.INFO)
logging.getLogger("httpx").setLevel(logging.WARNING)
logging.getLogger("httpcore").setLevel(logging.WARNING)
+54 -52
View File
@@ -39,7 +39,57 @@ _BUILTIN_CARD_DIRS = [
Path(RESOURCES_DIR) / "image_model_cards",
]
_card_cache: dict[ModelId, "ModelCard"] = {}
class _CardCache:
def __init__(self):
self.cc: dict[ModelId, "ModelCard"] = {}
def get(self, model_id: ModelId) -> "ModelCard | None":
return self.cc.get(model_id)
async def save(self, card: "ModelCard"):
self.cc[card.model_id] = card
try:
await card.save_to_custom_dir()
except OSError as e:
logger.warning(f"failed to save custom model card ({e.strerror})")
async def pop(self, model_id: ModelId) -> "ModelCard | None":
"""Delete a user-added custom model card. Returns True if deleted."""
card_path = _custom_cards_dir / (ModelId(model_id).normalize() + ".toml")
try:
if await card_path.exists():
await card_path.unlink()
return self.cc.pop(model_id, None)
except OSError as e:
logger.warning(f"failed to delete custom model card ({e.strerror})")
async def list_all(self) -> list["ModelCard"]:
if len(self.cc) == 0:
await self.refresh()
if EXO_ENABLE_IMAGE_MODELS:
return list(self.cc.values())
return [c for c in self.cc.values() if not _is_image_card(c)]
async def _load_cards_from_dir(self, directory: Path, *, is_custom: bool) -> None:
"""Load all TOML model cards from a directory into the cache."""
async for toml_file in directory.rglob("*.toml"):
try:
card = await ModelCard.load_from_path(toml_file)
if is_custom:
card = card.model_copy(update={"is_custom": True})
if self.get(card.model_id) is None:
self.cc[card.model_id] = card
except (ValidationError, TOMLKitError):
pass
async def refresh(self) -> None:
for path in _BUILTIN_CARD_DIRS:
await self._load_cards_from_dir(path, is_custom=False)
await self._load_cards_from_dir(_custom_cards_dir, is_custom=True)
card_cache = _CardCache()
def detect_vision_from_config(model_id: ModelId) -> "VisionCardConfig | None":
@@ -59,42 +109,10 @@ def detect_vision_from_config(model_id: ModelId) -> "VisionCardConfig | None":
return None
async def _load_cards_from_dir(directory: Path, *, is_custom: bool) -> None:
"""Load all TOML model cards from a directory into the cache."""
async for toml_file in directory.rglob("*.toml"):
try:
card = await ModelCard.load_from_path(toml_file)
if is_custom:
card = card.model_copy(update={"is_custom": True})
if card.model_id not in _card_cache:
_card_cache[card.model_id] = card
except (ValidationError, TOMLKitError):
pass
async def _refresh_card_cache() -> None:
for path in _BUILTIN_CARD_DIRS:
await _load_cards_from_dir(path, is_custom=False)
await _load_cards_from_dir(_custom_cards_dir, is_custom=True)
def _is_image_card(card: "ModelCard") -> bool:
return any(t in (ModelTask.TextToImage, ModelTask.ImageToImage) for t in card.tasks)
def get_card(model_id: ModelId) -> "ModelCard | None":
"""Look up a single model card from the cache by ID."""
return _card_cache.get(model_id)
async def get_model_cards() -> list["ModelCard"]:
if len(_card_cache) == 0:
await _refresh_card_cache()
if EXO_ENABLE_IMAGE_MODELS:
return list(_card_cache.values())
return [c for c in _card_cache.values() if not _is_image_card(c)]
class ModelTask(str, Enum):
TextGeneration = "TextGeneration"
TextToImage = "TextToImage"
@@ -196,14 +214,13 @@ class ModelCard(FrozenModel):
# Is it okay that model card.load defaults to network access if the card doesn't exist? do we want to be more explicit here?
@staticmethod
async def load(model_id: ModelId) -> "ModelCard":
if model_id not in _card_cache:
await _refresh_card_cache()
if (mc := _card_cache.get(model_id)) is not None:
if card_cache.get(model_id) is None:
await card_cache.refresh()
if (mc := card_cache.get(model_id)) is not None:
return mc
mc = await ModelCard.fetch_from_hf(model_id)
await mc.save_to_custom_dir()
_card_cache[model_id] = mc
return mc
@staticmethod
@@ -233,21 +250,6 @@ class ModelCard(FrozenModel):
)
def add_to_card_cache(card: "ModelCard") -> None:
"""Add or update a model card in the in-memory cache."""
_card_cache[card.model_id] = card
async def delete_custom_card(model_id: ModelId) -> bool:
"""Delete a user-added custom model card. Returns True if deleted."""
card_path = _custom_cards_dir / (ModelId(model_id).normalize() + ".toml")
if await card_path.exists():
await card_path.unlink()
_card_cache.pop(model_id, None)
return True
return False
class ConfigData(BaseModel):
model_config = {"extra": "ignore"} # Allow unknown fields
@@ -0,0 +1,44 @@
from exo.shared.apply import apply
from exo.shared.models.model_cards import ModelCard, ModelTask
from exo.shared.types.common import ModelId
from exo.shared.types.events import (
CustomModelCardAdded,
CustomModelCardDeleted,
IndexedEvent,
)
from exo.shared.types.memory import Memory
from exo.shared.types.state import State
def _model_card(model_id: ModelId) -> ModelCard:
return ModelCard(
model_id=model_id,
n_layers=1,
storage_size=Memory.from_bytes(1),
hidden_size=1,
supports_tensor=True,
tasks=[ModelTask.TextGeneration],
)
def test_custom_model_card_added_is_reduced_into_state() -> None:
card = _model_card(ModelId("custom/model"))
state = apply(
State(),
IndexedEvent(idx=0, event=CustomModelCardAdded(model_card=card)),
)
assert state.custom_model_cards == {card.model_id: card}
def test_custom_model_card_deleted_removes_card_from_state() -> None:
card = _model_card(ModelId("custom/model"))
state = State(custom_model_cards={card.model_id: card}, last_event_applied_idx=0)
state = apply(
state,
IndexedEvent(idx=1, event=CustomModelCardDeleted(model_id=card.model_id)),
)
assert state.custom_model_cards == {}
@@ -0,0 +1,231 @@
from datetime import datetime, timezone
from exo.shared.apply import apply_node_gathered_info
from exo.shared.topology import Topology
from exo.shared.types.common import NodeId
from exo.shared.types.events import NodeGatheredInfo
from exo.shared.types.profiling import (
NodeRdmaCtlStatus,
NodeThunderboltInfo,
)
from exo.shared.types.state import State
from exo.shared.types.thunderbolt import ThunderboltConnection, ThunderboltIdentifier
from exo.shared.types.topology import RDMAConnection
from exo.utils.info_gatherer.info_gatherer import (
MacThunderboltConnections,
RdmaCtlStatus,
)
def _now() -> str:
return datetime.now(timezone.utc).isoformat()
def _make_state_with_thunderbolt_idents(
*node_ids_and_uuids: tuple[NodeId, str, str],
rdma_ctl: dict[NodeId, NodeRdmaCtlStatus] | None = None,
) -> State:
"""Build a State with Thunderbolt identifiers per node so the apply MacThunderboltConnections
case can resolve uuid -> (node, iface)."""
node_thunderbolt = {
nid: NodeThunderboltInfo(
interfaces=[ThunderboltIdentifier(rdma_interface=iface, domain_uuid=uuid)]
)
for nid, uuid, iface in node_ids_and_uuids
}
return State(
node_thunderbolt=node_thunderbolt,
node_rdma_ctl=rdma_ctl or {},
)
def _has_rdma_edge(topology: Topology, source: NodeId, sink: NodeId) -> bool:
return any(
isinstance(edge, RDMAConnection)
for edge in topology.get_all_connections_between(source, sink)
)
def test_mac_thunderbolt_connections_emits_rdma_when_both_endpoints_enabled():
node_a = NodeId()
node_b = NodeId()
state = _make_state_with_thunderbolt_idents(
(node_a, "uuid-a", "rdma_en1"),
(node_b, "uuid-b", "rdma_en1"),
rdma_ctl={
node_a: NodeRdmaCtlStatus(enabled=True),
node_b: NodeRdmaCtlStatus(enabled=True),
},
)
event = NodeGatheredInfo(
node_id=node_a,
when=_now(),
info=MacThunderboltConnections(
conns=[ThunderboltConnection(source_uuid="uuid-a", sink_uuid="uuid-b")]
),
)
new_state = apply_node_gathered_info(event, state)
assert _has_rdma_edge(new_state.topology, node_a, node_b)
def test_mac_thunderbolt_connections_skips_rdma_when_source_rdma_ctl_disabled():
node_a = NodeId()
node_b = NodeId()
state = _make_state_with_thunderbolt_idents(
(node_a, "uuid-a", "rdma_en1"),
(node_b, "uuid-b", "rdma_en1"),
rdma_ctl={
node_a: NodeRdmaCtlStatus(enabled=False),
node_b: NodeRdmaCtlStatus(enabled=True),
},
)
event = NodeGatheredInfo(
node_id=node_a,
when=_now(),
info=MacThunderboltConnections(
conns=[ThunderboltConnection(source_uuid="uuid-a", sink_uuid="uuid-b")]
),
)
new_state = apply_node_gathered_info(event, state)
assert not _has_rdma_edge(new_state.topology, node_a, node_b)
def test_mac_thunderbolt_connections_skips_rdma_when_sink_rdma_ctl_disabled():
node_a = NodeId()
node_b = NodeId()
state = _make_state_with_thunderbolt_idents(
(node_a, "uuid-a", "rdma_en1"),
(node_b, "uuid-b", "rdma_en1"),
rdma_ctl={
node_a: NodeRdmaCtlStatus(enabled=True),
node_b: NodeRdmaCtlStatus(enabled=False),
},
)
event = NodeGatheredInfo(
node_id=node_a,
when=_now(),
info=MacThunderboltConnections(
conns=[ThunderboltConnection(source_uuid="uuid-a", sink_uuid="uuid-b")]
),
)
new_state = apply_node_gathered_info(event, state)
assert not _has_rdma_edge(new_state.topology, node_a, node_b)
def test_mac_thunderbolt_connections_skips_rdma_when_rdma_ctl_status_missing():
"""Missing rdma_ctl status defaults to not-enabled — node is RDMA-incapable."""
node_a = NodeId()
node_b = NodeId()
state = _make_state_with_thunderbolt_idents(
(node_a, "uuid-a", "rdma_en1"),
(node_b, "uuid-b", "rdma_en1"),
rdma_ctl={
node_a: NodeRdmaCtlStatus(enabled=True),
# node_b intentionally absent
},
)
event = NodeGatheredInfo(
node_id=node_a,
when=_now(),
info=MacThunderboltConnections(
conns=[ThunderboltConnection(source_uuid="uuid-a", sink_uuid="uuid-b")]
),
)
new_state = apply_node_gathered_info(event, state)
assert not _has_rdma_edge(new_state.topology, node_a, node_b)
def test_rdma_ctl_status_disabled_purges_existing_rdma_edges():
"""When a node reports rdma_ctl disabled, all RDMA edges touching it must be removed."""
node_a = NodeId()
node_b = NodeId()
# Start with both nodes RDMA-enabled and existing RDMA edges in the topology.
state = _make_state_with_thunderbolt_idents(
(node_a, "uuid-a", "rdma_en1"),
(node_b, "uuid-b", "rdma_en1"),
rdma_ctl={
node_a: NodeRdmaCtlStatus(enabled=True),
node_b: NodeRdmaCtlStatus(enabled=True),
},
)
state = apply_node_gathered_info(
NodeGatheredInfo(
node_id=node_a,
when=_now(),
info=MacThunderboltConnections(
conns=[ThunderboltConnection(source_uuid="uuid-a", sink_uuid="uuid-b")]
),
),
state,
)
state = apply_node_gathered_info(
NodeGatheredInfo(
node_id=node_b,
when=_now(),
info=MacThunderboltConnections(
conns=[ThunderboltConnection(source_uuid="uuid-b", sink_uuid="uuid-a")]
),
),
state,
)
assert _has_rdma_edge(state.topology, node_a, node_b)
assert _has_rdma_edge(state.topology, node_b, node_a)
# Now node_a flips to rdma_ctl disabled — both directions of RDMA edge must drop.
state = apply_node_gathered_info(
NodeGatheredInfo(
node_id=node_a, when=_now(), info=RdmaCtlStatus(enabled=False)
),
state,
)
assert not _has_rdma_edge(state.topology, node_a, node_b)
assert not _has_rdma_edge(state.topology, node_b, node_a)
assert state.node_rdma_ctl[node_a].enabled is False
def test_topology_remove_all_rdma_connections_touching_keeps_socket_edges():
"""Purging RDMA edges for a disabled node must not affect non-RDMA edges."""
from exo.shared.types.multiaddr import Multiaddr
from exo.shared.types.topology import Connection, SocketConnection
topology = Topology()
node_a = NodeId()
node_b = NodeId()
topology.add_node(node_a)
topology.add_node(node_b)
topology.add_connection(
Connection(
source=node_a,
sink=node_b,
edge=RDMAConnection(
source_rdma_iface="rdma_en1", sink_rdma_iface="rdma_en1"
),
)
)
socket_edge = SocketConnection(
sink_multiaddr=Multiaddr(address="/ip4/10.0.0.1/tcp/8000")
)
topology.add_connection(Connection(source=node_a, sink=node_b, edge=socket_edge))
topology.remove_all_rdma_connections_touching(node_a)
assert not _has_rdma_edge(topology, node_a, node_b)
# Socket edge survives.
assert any(
isinstance(edge, SocketConnection)
for edge in topology.get_all_connections_between(node_a, node_b)
)
+1 -1
View File
@@ -327,7 +327,7 @@ async def test_connection_message_triggers_new_round_broadcast() -> None:
tg.start_soon(election.run)
# Send any connection message object; we close quickly to cancel before result creation
await cm_tx.send(ConnectionMessage(node_id=NodeId(), connected=True))
await cm_tx.send(ConnectionMessage(connected=True))
# Expect a broadcast for the new round at clock=1
while True:
+16
View File
@@ -169,6 +169,22 @@ class Topology:
for conn in new_connections:
self.add_connection(conn)
def remove_all_rdma_connections_touching(self, node_id: NodeId) -> None:
"""Remove every RDMA edge incident to ``node_id`` (incoming or outgoing)."""
if node_id not in self._vertex_indices:
return
rx_idx = self._vertex_indices[node_id]
rdma_edge_idxs = [
edge_idx
for edge_idx in (
*self._graph.out_edge_indices(rx_idx),
*self._graph.in_edge_indices(rx_idx),
)
if isinstance(self._graph.get_edge_data_by_index(edge_idx), RDMAConnection)
]
for edge_idx in rdma_edge_idxs:
self._graph.remove_edge_from_index(edge_idx)
def remove_connection(self, conn: Connection) -> None:
if (
conn.source not in self._vertex_indices
+5 -1
View File
@@ -5,8 +5,9 @@ from typing import Any, cast
from pydantic import ConfigDict, Field, field_serializer, field_validator
from pydantic.alias_generators import to_camel
from exo.shared.models.model_cards import ModelCard
from exo.shared.topology import Topology, TopologySnapshot
from exo.shared.types.common import NodeId
from exo.shared.types.common import ModelId, NodeId
from exo.shared.types.instance_link import InstanceLink, InstanceLinkId
from exo.shared.types.profiling import (
DiskUsage,
@@ -65,6 +66,9 @@ class State(FrozenModel):
instance_links: Mapping[InstanceLinkId, InstanceLink] = {}
prefill_server_ports: Mapping[RunnerId, int] = {}
# User-added model cards. Workers can reconcile their on-disk custom card cache
custom_model_cards: Mapping[ModelId, ModelCard] = {}
@field_serializer("topology", mode="plain")
def _encode_topology(self, value: Topology) -> TopologySnapshot:
return value.to_snapshot()
+2 -2
View File
@@ -135,9 +135,9 @@ class TextGenerationTaskParams(BaseModel, frozen=True):
prefill_endpoint: str | None = None
def with_card_sampling_defaults(self) -> "TextGenerationTaskParams":
from exo.shared.models.model_cards import get_card
from exo.shared.models import model_cards
card = get_card(self.model)
card = model_cards.card_cache.get(self.model)
if card is None:
return self
+1 -1
View File
@@ -38,7 +38,7 @@ def print_startup_banner(port: int) -> None:
╔═══════════════════════════════════════════════════════════════════════╗
║ ║
🌐 Dashboard & API Ready ║
║ Dashboard & API Ready
║ ║
{dashboard_url}{" " * (69 - len(dashboard_url))}
║ ║
+6 -1
View File
@@ -143,6 +143,7 @@ class ImageEngine(Engine):
Generator[tuple[TaskId, Chunk | FinishedResponse | CancelledResponse]] | None
) = field(init=False, default=None)
queue: deque[ImageTask] = field(init=False, default_factory=deque)
_cancelled_tasks: set[TaskId] = field(init=False, default_factory=set)
def warmup(self) -> None:
image = warmup_image_generator(model=self.image_model)
@@ -168,7 +169,11 @@ class ImageEngine(Engine):
task = self.queue.popleft()
self.current_gen = self._run_image_task(task.task_id, task.task_params)
resp = next(self.current_gen, None)
return (resp,) if resp is not None else ()
return (
(resp,)
if resp is not None and _is_primary_output_node(self.shard_metadata)
else ()
)
def close(self) -> None:
with contextlib.suppress(NameError, AttributeError):
+14 -9
View File
@@ -10,7 +10,7 @@ from exo.api.types import ImageEditsTaskParams
from exo.download.download_utils import is_read_only_model_dir, resolve_existing_model
from exo.shared.apply import apply
from exo.shared.constants import EXO_MAX_INSTANCE_RETRIES
from exo.shared.models.model_cards import ModelId, add_to_card_cache, delete_custom_card
from exo.shared.models.model_cards import ModelId, card_cache
from exo.shared.types.chunks import InputImageChunk
from exo.shared.types.commands import (
DeleteInstance,
@@ -20,8 +20,6 @@ from exo.shared.types.commands import (
)
from exo.shared.types.common import CommandId, NodeId, SystemId
from exo.shared.types.events import (
CustomModelCardAdded,
CustomModelCardDeleted,
Event,
IndexedEvent,
InputChunkReceived,
@@ -110,6 +108,8 @@ class Worker:
tg.start_soon(self.plan_step)
tg.start_soon(self._event_applier)
tg.start_soon(self._poll_connection_updates)
tg.start_soon(self._reconcile_custom_cards)
finally:
# Actual shutdown code - waits for all tasks to complete before executing.
logger.info("Stopping Worker")
@@ -151,7 +151,6 @@ class Worker:
self.input_chunk_buffer[cmd_id][event.chunk.chunk_index] = (
event.chunk
)
if (
len(self.input_chunk_buffer[cmd_id])
== self.input_chunk_counts[cmd_id]
@@ -172,12 +171,18 @@ class Worker:
)
] = img
if isinstance(event, CustomModelCardAdded):
await event.model_card.save_to_custom_dir()
add_to_card_cache(event.model_card)
async def _reconcile_custom_cards(self) -> None:
while True:
await anyio.sleep(1)
target = dict(self.state.custom_model_cards)
for model_id, card in target.items():
if card_cache.get(model_id) == card:
continue
await card_cache.save(card)
if isinstance(event, CustomModelCardDeleted):
await delete_custom_card(event.model_id)
for card in await card_cache.list_all():
if card.model_id not in target:
await card_cache.pop(card.model_id)
async def plan_step(self):
while True:
@@ -138,8 +138,10 @@ class SequentialGenerator(Engine):
def agree_on_tasks(self) -> None:
"""Agree between all ranks about the task ordering (some may have received in different order or not at all)."""
agreed, different = mx_all_gather_tasks(self._maybe_queue, self.group)
self._queue.extend(task for task in self._maybe_queue if task in agreed)
self._maybe_queue = [task for task in self._maybe_queue if task in different]
# Extend from `agreed` (sorted by task_id on all ranks) to guarantee every
# rank enqueues tasks in the same order, preventing TP collective deadlocks.
self._queue.extend(agreed)
self._maybe_queue = list(different)
def agree_on_cancellations(self) -> None:
"""Agree between all ranks about which tasks to cancel."""
@@ -197,9 +199,14 @@ class SequentialGenerator(Engine):
self._active = None
raise
return itertools.chain(
output,
map(lambda task: (task, CancelledResponse()), self._cancelled_tasks),
return filter(
lambda chunk: (
not isinstance(chunk[1], GenerationChunk) or self.device_rank == 0
),
itertools.chain(
output,
map(lambda task: (task, CancelledResponse()), self._cancelled_tasks),
),
)
def _start_next(self) -> None:
@@ -368,8 +375,10 @@ class BatchGenerator(Engine):
def agree_on_tasks(self) -> None:
"""Agree between all ranks about the task ordering (some may have received in different order or not at all)."""
agreed, different = mx_all_gather_tasks(self._maybe_queue, self.group)
self._queue.extend(task for task in self._maybe_queue if task in agreed)
self._maybe_queue = [task for task in self._maybe_queue if task in different]
# Extend from `agreed` (sorted by task_id on all ranks) to guarantee every
# rank enqueues tasks in the same order, preventing TP collective deadlocks.
self._queue.extend(agreed)
self._maybe_queue = list(different)
def agree_on_cancellations(self) -> None:
"""Agree between all ranks about which tasks to cancel."""
@@ -449,7 +458,12 @@ class BatchGenerator(Engine):
output.append((task.task_id, FinishedResponse()))
del self._active_tasks[uid]
return itertools.chain(output, self._apply_cancellations())
return filter(
lambda chunk: (
not isinstance(chunk[1], GenerationChunk) or self.device_rank == 0
),
itertools.chain(output, self._apply_cancellations()),
)
def _apply_cancellations(
self,
+2 -2
View File
@@ -390,5 +390,5 @@ class Runner:
chunk: Chunk,
command_id: CommandId,
):
if self.device_rank == 0:
self.event_sender.send(ChunkGenerated(command_id=command_id, chunk=chunk))
assert isinstance(self.generator, Engine)
self.event_sender.send(ChunkGenerated(command_id=command_id, chunk=chunk))
@@ -16,7 +16,7 @@ from exo.download.download_utils import (
fetch_file_list_with_cache,
resolve_model_dir,
)
from exo.shared.models.model_cards import ModelCard, ModelId, get_model_cards
from exo.shared.models.model_cards import ModelCard, ModelId, card_cache
from exo.worker.engines.mlx.utils_mlx import (
get_eos_token_ids_for_model,
load_tokenizer_for_model_id,
@@ -76,7 +76,7 @@ def get_test_models() -> list[ModelCard]:
"""Get a representative sample of models to test."""
# Pick one model from each family to test
families: dict[str, ModelCard] = {}
for card in asyncio.run(get_model_cards()):
for card in asyncio.run(card_cache.list_all()):
# Extract family name (e.g., "llama-3.1" from "llama-3.1-8b")
parts = card.model_id.short().split("-")
family = "-".join(parts[:2]) if len(parts) >= 2 else parts[0]
@@ -298,7 +298,7 @@ async def test_tokenizer_special_tokens(model_card: ModelCard) -> None:
async def test_kimi_tokenizer_specifically():
"""Test Kimi tokenizer with its specific patches and quirks."""
kimi_models = [
card for card in await get_model_cards() if "kimi" in card.model_id.lower()
card for card in await card_cache.list_all() if "kimi" in card.model_id.lower()
]
if not kimi_models:
@@ -350,7 +350,7 @@ async def test_glm_tokenizer_specifically():
glm_model_cards = [
card
for card in await get_model_cards()
for card in await card_cache.list_all()
if contains(card, "glm")
and not contains(card, "-5")
and not contains(card, "4.7")
+13
View File
@@ -0,0 +1,13 @@
from exo_pyo3_bindings import StateProxy
import anyio
async def main():
sp = await StateProxy.init()
while True:
data = await sp.snapshot()
if data != "{}":
print(data)
await anyio.sleep(1)
if __name__ == "__main__":
anyio.run(main)