Compare commits

...
11 Commits
Author SHA1 Message Date
Evan a79f8ec7b1 small refactor 2026-06-03 17:11:54 +01:00
Evan 72897dd9da custom model cards + instance links 2026-06-03 17:09:18 +01:00
Evan 150fa21b2b move metrics to zenoh Last Value semantics 2026-06-03 17:07:58 +01:00
Evan QuineyandAndrei Cravtov 09f9ea313f libp2p -> zenoh (#2132)
supercedes #2076 and #2073

---------

Co-authored-by: Andrei Cravtov <the.andrei.cravtov@gmail.com>
2026-06-03 16:31:56 +01:00
Sakutaro 81d7cb0fcd docs: add Homebrew cask install instructions (#2140)
## Motivation

exo is now available as a Homebrew cask, so the README should show the
simplest macOS installation path alongside the existing DMG download.

Fixes https://github.com/exo-explore/exo/issues/2105
https://github.com/exo-explore/exo/issues/176

## Changes

- Added `brew install --cask exo` to the macOS App section of
`README.md`
- Kept the existing DMG download link as the first installation option

## Why It Works

Adding the Homebrew cask command gives macOS users a
package-manager-managed installation path while preserving the existing
DMG download option.

## Test Plan

### Manual Testing

- Reviewed the rendered Markdown structure in `README.md`

### Automated Testing

- Not run. Documentation-only change.

## Related

- https://github.com/Homebrew/homebrew-cask/pull/265956
2026-06-02 15:35:18 +00:00
Andrei Cravtov 629c55d6ba Rename exo_pyo3_bindings to exo_rs (#2131)
## Motivation

(I think it) Makes Evan's massive PR easier to merge later on

## Changes

- Renamed exo_pyo3_bindings to exo_rs
- Upgraded versions of pyo3-based dependencies
- Renamed PyFromSwarm to just FromSwarm, and PyNetworkingHandle to just
NetworkingHandle
2026-05-31 19:23:41 +01:00
Andrei Cravtov f9f8cbb3c3 fix: make app builds work again (#2127)
## Motivation

They didn't

## Changes

They now do

## Why It Works

I changed an env flag, and added a keyword
2026-05-29 18:37:47 +01:00
ciaranbor 051a64e3b4 Capture energy in prefill and ageneration separately (#2124)
## Motivation

Energy was reported as a single aggregate. Split into prefill vs.
generation so each phase can be analysed independently.

## Changes

- `PowerSampler`: `mark_prefill_done()` + `trapezoidal_energy_range()`
helper; `result()` now emits per-phase splits.
- `PowerUsage` / `NodePowerStats`: optional `prefill_*` / `generation_*`
fields (back-compat: `None` if unmarked).
- API marks the boundary on the first non-`PrefillProgressChunk`.
- `bench/exo_bench.py` surfaces the split in the log line and persists
`power_usage` to JSON.
- METHODOLOGY: one sentence + one bullet.

## Why It Works

First non-prefill chunk *is* the boundary. Anchoring a sample there and
interpolating power at the boundary makes phase energies sum exactly to
the unsplit total.

## Test Plan

### Manual Testing

`eco`-reserved nodes:
- M3 Ultra, Qwen3-VL-4B, pp=8192/tg=1024: server 1940 J vs client 1931 J
(+0.5 %)
- M4 Pro, Qwen3.6-27B, pp=16384/tg=2048: server 20,292 J vs client
20,221 J (+0.35 %)

### Automated Testing

5 new tests in `test_power_sampler.py` (range integrator,
splits-sum-to-total, `None`-when-unmarked, idempotency). 14/14 pass.
2026-05-28 14:42:36 -07:00
Andrei Cravtov a8602ea6d5 fix(bug): no longer repeated _trigger_notify_user_to_download_model (#2114)
## Motivation

Partially fixes [this](https://github.com/exo-explore/exo/issues/2098)
issue. Removed erroneous logic for telling user to download when they
already downloaded.

Could not figure out about the "spontaneous crashes" in that issue,
author should consolidate more logs and open a new issue dedicated to
that. I believe
[this](https://github.com/exo-explore/exo/commit/74e9fe15e62fe189dc7e019db86e75c83eca2721)
commit solved some EventRouter-related crashes, which was mentioned in
[this](https://github.com/exo-explore/exo/issues/2098) issue, so it may
have already been solved. If not, should be re-submitted as a new issue.

## Changes

- Consolidated _resolve_and_validate_text_model and
_validate_image_model into one function: _validate_model_has_instance;
- + They already had virtually identical logic, it being different seems
to be an artifact of history
- + Added logic to ensure that _trigger_notify_user_to_download_model is
only called when no such model is downloaded, not just if there is no
instance of it
- Added a new `/instance/await` SSE streaming endpoint to wait for when
a model has an instance available. Complements instance-placement API,
so we can wait till that is done without client-side polling.
- Updated docs and a /tmp script to reflect some of the changes
- Updated dashboard `getModelForRequest` to only return model ID if an
instance exists for it, and updated bits to use `handleChatSend` instead
of `sendMessage` because that checks for if a model instance exists
first.

## Why It Works

The problem was that there was erroneous logging for model not
downloaded. I fixed that logic. The rest is extra.
2026-05-26 14:42:39 +01:00
Andrei Cravtov a1a22b5f38 feat: added background/daemon support (#2106)
## Motivation

Addresses [this](https://github.com/exo-explore/exo/issues/1931) issue.

## Changes

You can now launch Exo as a legacy SysV-style daemin (in the background)
with `--legacy-daemon` flag.
NOTE: don't use it if you're managing Exo with systemd or launchd

SIDE FIX: the macmon process not found trace is no longer displayed on
process shutdown via ctrl+c, that error is supressed.

## Why It Works

Because I used a daemonization library and tweaked it not to break
multiprocessing.

## Test Plan

I ran it in daemon mode, non daemon mode, etc., and pid locking +
inference + everything else works just fine.

Also ran it `ssh user@host -t 'cd exo && nohup nix run .#exo --
--legacy-daemon'` on a 4-node TB mac-mini cluster and the mDNS didn't
die
2026-05-25 20:42:47 +01:00
Andrei Cravtov 74e9fe15e6 fix(bug): EventRouter lifetime-handling fixed, no more process crashes (#2102)
## Motivation

Trying to (partially) fix
[this](https://github.com/exo-explore/exo/issues/2101) issue.

## Changes

Changed channels (in channels.py) to support exception overriding.

Made EventRouter channels throw a subclass of the resource closed/broken
errors.

The current lifetime logic of EventRouter in event loop no longer blows
up because components that use channels from EventRouter now catch the
subclass exceptions in the run method: Worker, Master,
DownloadCoordinator, RunnerSupervisor.

Added logic to throw when API server exits without being asked to shut
down - this kill the sleep-forever in the task-group.
2026-05-22 14:20:04 +01:00
123 changed files with 6787 additions and 5904 deletions

No files matched your search

-7
View File
@@ -1,8 +1 @@
use flake
# creates .venv if doesn't exist and loads its environment
export VIRTUAL_ENV=".venv"
if ! [ -d "./$VIRTUAL_ENV" ]; then
uv venv
fi
layout python
+2
View File
@@ -38,6 +38,8 @@ bench/**/*.json
# tmp
tmp/models
/build/exo
/.agents
/.claude/skills
/.claude
/.codex
skills-lock.json
+5 -5
View File
@@ -4,7 +4,7 @@ This file provides guidance to AI coding agents when working with code in this r
## Project Overview
exo is a distributed AI inference system that connects multiple devices into a cluster. It enables running large language models across multiple machines using MLX as the inference backend and libp2p for peer-to-peer networking.
exo is a distributed AI inference system that connects multiple devices into a cluster. It enables running large language models across multiple machines using MLX as the inference backend and zenoh for peer-to-peer networking.
## Build & Run Commands
@@ -69,7 +69,7 @@ If `nix fmt` changes any files, stage them before committing. The CI runs `nix f
### Node Composition
A single exo `Node` (src/exo/main.py) runs multiple components:
- **Router**: libp2p-based pub/sub messaging via Rust bindings (exo_pyo3_bindings)
- **Router**: zenoh-based pub/sub messaging via Rust bindings (exo_rs)
- **Worker**: Handles inference tasks, downloads models, manages runner processes
- **Master**: Coordinates cluster state, places model instances across nodes
- **Election**: Bully algorithm for master election
@@ -81,7 +81,7 @@ Components communicate via typed pub/sub topics (src/exo/routing/topics.py):
- `LOCAL_EVENTS`: Workers send events to master for indexing
- `COMMANDS`: Workers/API send commands to master
- `ELECTION_MESSAGES`: Election protocol messages
- `CONNECTION_MESSAGES`: libp2p connection updates
- `CONNECTION_MESSAGES`: zenoh connection updates
### Event Sourcing
The system uses event sourcing for state management:
@@ -98,8 +98,8 @@ The system uses event sourcing for state management:
### Rust Components
Rust code in `rust/` provides:
- `networking`: libp2p networking (gossipsub, peer discovery)
- `exo_pyo3_bindings`: PyO3 bindings exposing Rust to Python
- `networking`: zenoh networking (gossipsub, peer discovery)
- `exo_rs`: PyO3 bindings exposing Rust to Python
- `system_custodian`: System-level operations
### Dashboard
Generated
+2721 -2399
View File
File diff suppressed because it is too large. Load diff
+57 -11
View File
@@ -1,6 +1,6 @@
[workspace]
resolver = "3"
members = ["rust/networking", "rust/exo_pyo3_bindings", "rust/util"]
members = ["rust/exo_rs", "rust/networking"]
[workspace.package]
version = "0.0.1"
@@ -20,30 +20,76 @@ opt-level = 3
[workspace.dependencies]
## Crate members as common dependencies
networking = { path = "rust/networking" }
util = { path = "rust/util" }
# Macro dependecies
# pyo3
pyo3 = "0.28.3"
pyo3-async-runtimes = "0.28.0"
pyo3-log = "0.13.3"
pyo3-stub-gen = "0.22.3"
# util
extend = "1.2"
delegate = "0.13"
# Utility dependencies
keccak-const = "0.2"
nix = "0.31"
# Async dependencies
async-stream = "0.3"
tokio = "1.46"
futures-lite = "2.6.1"
futures-timer = "3.0"
# Data structures
either = "1.15"
async-stream = "0.3.6"
pin-project = "1.1.10"
serde_json = "1.0.149"
rand = "0.10.1"
parking_lot = "0.12.5"
# Tracing/logging
log = "0.4"
env_logger = "0.11.10"
# networking
libp2p = "0.56"
libp2p-tcp = "0.44"
zenoh = "=1.9.0"
zenoh-ext = "=1.9.0"
zenoh-plugin-storage-manager = { version = "=1.9.0", default-features = false }
zenoh-plugin-trait = "=1.9.0"
netwatcher = "0.6.0"
bytemuck = "1.25.0"
blake3 = "1.8.5"
smol = "2.0.2"
socket2 = "0.6.4"
tracing = "0.1.44"
pidfile-rs = { git = "https://github.com/AndreiCravtov/pidfile-rs" }
[patch.crates-io]
zenoh = { git = "https://github.com/evanev7/zenoh.git", branch = "exo" }
zenoh-ext = { git = "https://github.com/evanev7/zenoh.git", branch = "exo" }
zenoh-buffers = { git = "https://github.com/evanev7/zenoh.git", branch = "exo" }
zenoh-codec = { git = "https://github.com/evanev7/zenoh.git", branch = "exo" }
zenoh-collections = { git = "https://github.com/evanev7/zenoh.git", branch = "exo" }
zenoh-config = { git = "https://github.com/evanev7/zenoh.git", branch = "exo" }
zenoh-core = { git = "https://github.com/evanev7/zenoh.git", branch = "exo" }
zenoh-crypto = { git = "https://github.com/evanev7/zenoh.git", branch = "exo" }
zenoh-keyexpr = { git = "https://github.com/evanev7/zenoh.git", branch = "exo" }
zenoh-link = { git = "https://github.com/evanev7/zenoh.git", branch = "exo" }
zenoh-link-commons = { git = "https://github.com/evanev7/zenoh.git", branch = "exo" }
zenoh-link-quic = { git = "https://github.com/evanev7/zenoh.git", branch = "exo" }
zenoh-link-quic_datagram = { git = "https://github.com/evanev7/zenoh.git", branch = "exo" }
zenoh-link-tcp = { git = "https://github.com/evanev7/zenoh.git", branch = "exo" }
zenoh-link-tls = { git = "https://github.com/evanev7/zenoh.git", branch = "exo" }
zenoh-link-udp = { git = "https://github.com/evanev7/zenoh.git", branch = "exo" }
zenoh-link-unixsock_stream = { git = "https://github.com/evanev7/zenoh.git", branch = "exo" }
zenoh-link-ws = { git = "https://github.com/evanev7/zenoh.git", branch = "exo" }
zenoh-macros = { git = "https://github.com/evanev7/zenoh.git", branch = "exo" }
zenoh-plugin-trait = { git = "https://github.com/evanev7/zenoh.git", branch = "exo" }
zenoh-protocol = { git = "https://github.com/evanev7/zenoh.git", branch = "exo" }
zenoh-result = { git = "https://github.com/evanev7/zenoh.git", branch = "exo" }
zenoh-runtime = { git = "https://github.com/evanev7/zenoh.git", branch = "exo" }
zenoh-sync = { git = "https://github.com/evanev7/zenoh.git", branch = "exo" }
zenoh-task = { git = "https://github.com/evanev7/zenoh.git", branch = "exo" }
zenoh-transport = { git = "https://github.com/evanev7/zenoh.git", branch = "exo" }
zenoh-util = { git = "https://github.com/evanev7/zenoh.git", branch = "exo" }
zenoh-plugin-storage-manager = { git = "https://github.com/evanev7/zenoh.git", branch = "exo" }
zenoh_backend_traits = { git = "https://github.com/evanev7/zenoh.git", branch = "exo" }
[workspace.lints.rust]
static_mut_refs = "warn" # Or use "warn" instead of deny
-41
View File
@@ -1,41 +0,0 @@
# Missed things
[X] Log EXO_LIBP2P_NAMESPACE on start in exo/main.py
[X] Ordering of warmup was changed, which is wrong. It was changed to rank < n-1, then rank=n-1. It should be rank!=0 then rank=0 (this matches the auto_parallel implementation. NOTE: we use a different convention to mlx-lm, our terminal rank is rank=n-1 whereas mlx-lm is rank=0 hence i can see why this was changed wrongly).
[X] Downloads keying by model_id not shard_metadata (worker/plan.py, worker/main.py).
[X] Fetching download status of all models on start
[X] Deduplication of tasks in plan_step.
[X] resolve_allow_patterns should just be wildcard now.
[X] no mx_barrier in genreate.py mlx_generate at the end.
[] cache assertion not needed in auto_parallel.py PipelineLastLayer.
[X] GPTOSS support dropped in auto_parallel.py.
[X] sharding changed "all-to-sharded" became _all_to_sharded in auto_parallel.py.
[X] same as above with "sharded-to-all" became _sharded_to_all in auto_parallel.py.
[X] Dropped support for Ministral3Model, DeepseekV32Model, Glm4MoeModel, Qwen3NextModel, GptOssMode in auto_parallel.py.
[] Dropped prefill/decode code in auto_parallel.py and utils_mlx.py.
[X] KV_CACHE_BITS should be None to disable quantized KV cache.
[X] Dropped _set_nofile_limit in utils_mlx.py.
[X] We have group optional in load_mlx_items in utils_mlx.py.
[X] Dropped add_missing_chat_templates for GptOss in load_mlx_items in utils_mlx.py.
[X] Dropped model.make_cache in make_kv_cache in utils_mlx.py.
[X] We put cache limit back in utils_mlx.py.
[X] topology.py remove_node removes the connections after checking if node is is in self._node_id_to_rx_id_map. on beta_1 it checks after, so would remove stale connections I guess?
[X] Missing Glm 4.7 model cards (this isn't ready yet but should be picked up, probably create an issue... the blocker is transforemrs version doesn't support the tokenizer for Glm 4.7. rc-1 does but we can't upgrade as it breaks other things.)
[] try-except in _command_processor only excepts ValueError. This was silently failing leading to un-debuggable errors (we had a KeyError that was happening ). Changed this to catch Exception instead of ValueError. See exo-v2 89ae38405e0052e3c22405daf094b065878aa873 and fb99fea69b5a39017efc90c5dad0072e677455f0.
[X] In placement.py, place_instance no longer looks at model_meta.supports_tensor and check if this tensor parallel number of nodes is supported by the model's tensor dimensions.
[X] In placement.py, place_instanec, we no longer have the special case to exclude DeepSeek v3.1 pipeline parallel (it doesn't work).
[] logger.warning("You have likely selected ibv for a single node instance; falling back to MlxRing") was changed to debug. That will spam this warning since it happens every time we query instance previews.
[X] In placement_utils.py, get_mlx_jaccl_coordinators, We no longer prioritise Jaccl Coordinator IP. Now it picks the first one, which is unstable (Jaccl coordinator over TB5 is unstable).
[X] Downloads keying by model_id not shard_metadata (worker/plan.py, worker/main.py).
[X] Fetching download status of all models on start
[X] Deduplication of tasks in plan_step.
[X] resolve_allow_patterns should just be wildcard now.
[X] KV_CACHE_BITS should be None to disable quantized KV cache.
[X] We put cache limit back in utils_mlx.py.
[X] In placement.py, place_instance no longer looks at model_meta.supports_tensor and check if this tensor parallel number of nodes is supported by the model's tensor dimensions.
[X] In placement.py, place_instanec, we no longer have the special case to exclude DeepSeek v3.1 pipeline parallel (it doesn't work).
[X] In placement_utils.py, get_mlx_jaccl_coordinators, We no longer prioritise Jaccl Coordinator IP. Now it picks the first one, which is unstable (Jaccl coordinator over TB5 is unstable).
+24
View File
@@ -201,6 +201,12 @@ This starts the exo dashboard and API at http://localhost:52415/
uv run exo --no-worker
```
- `--legacy-daemon`: Run exo as a legacy SysV-style background daemon using double-fork daemonization. This is intended for legacy init scripts; systemd and launchd should run exo in the foreground without this flag.
```bash
uv run exo --legacy-daemon
```
**File Locations (Linux):**
exo follows the [XDG Base Directory Specification](https://specifications.freedesktop.org/basedir-spec/basedir-spec-latest.html) on Linux:
@@ -223,6 +229,12 @@ The macOS app requires macOS Tahoe 26.2 or later.
Download the latest build here: [EXO-latest.dmg](https://assets.exolabs.net/EXO-latest.dmg).
You can also install the latest build with Homebrew:
```bash
brew install --cask exo
```
The app will ask for permission to modify system settings and install a new Network profile. Improvements to this are being worked on.
**Custom Namespace for Cluster Isolation:**
@@ -395,6 +407,18 @@ Sample response:
}
```
This command is asynchronous. Before sending inference requests, wait until the
API sees the new instance for this model:
```bash
curl -N "http://localhost:52415/instance/await?model_id=mlx-community/Llama-3.2-1B-Instruct-4bit"
```
The endpoint returns an SSE stream. A successful wait emits a message with
`"type": "ready"` and the matching instance; a timeout emits `"type": "timeout"`.
By default it waits indefinitely. Set `timeout_seconds` to a positive value to
bound the wait.
---
**3. Send a chat completion**
+2 -3
View File
@@ -1,14 +1,13 @@
3. Task cancellation. When API http request gets cancelled, it should cancel corresponding task.
1. EXO_BOOTSTRAP_PEERS is currently broken
4. I'd like to see profiled network latency / bandwidth.
5. I'd like to see how much bandwidth each link is using.
7. Solve the problem of in continuous batching when a new prompt comes in, it will block decode of the current batch until the prefill is complete.
8. We want people to be able to copy models over to a new device without ever connecting EXO to the internet. Right now EXO require internet connection once to cache some files to check if a download is complete. Instead, we should simply check if there is a non-empty model folder locally with no .partial files. This indicates it's a fully downloaded model that can be loaded.
13. Memory pressure instead of memory used.
14. Show the type of each connection (TB5, Ethernet, etc.) in the UI. Refer to old exo: https://github.com/exo-explore/exo/blob/56f783b38dc6b08ce606b07a5386dc40dae00330/exo/helpers.py#L251
15. Prioritise certain connection types (or by latency). TB5 > Ethernet > WiFi. Refer to old exo: https://github.com/exo-explore/exo/blob/56f783b38dc6b08ce606b07a5386dc40dae00330/exo/helpers.py#L251
16. Dynamically switch to higher priority connection when it becomes available. Probably bring back InstanceReplacedAtomically.
17. Faster model loads by streaming model from other devices in cluster.
18. Add support for specifying the type of network connection to use in a test. Depends on 15/16.
25. Rethink retry logic
27. Log cleanup - per-module log filters and default to DEBUG log levels
28. Validate RDMA connections with ibv_devinfo in the info gatherer
+1 -1
View File
@@ -352,7 +352,7 @@ final class ExoProcessController: ObservableObject {
private func makeEnvironment(for runtimeURL: URL) -> [String: String] {
var environment = ProcessInfo.processInfo.environment
environment["EXO_RUNTIME_DIR"] = runtimeURL.path
environment["EXO_LIBP2P_NAMESPACE"] = computeNamespace()
environment["EXO_ZENOH_NAMESPACE"] = computeNamespace()
if !hfToken.isEmpty {
environment["HF_TOKEN"] = hfToken
}
@@ -37,7 +37,7 @@ final class ClusterStateService: ObservableObject {
/// gain nothing from being cached on disk. Use an ephemeral session
/// with `urlCache = nil` so neither response bodies nor metadata
/// touch disk.
private static func makeNonCachingSession() -> URLSession {
nonisolated private static func makeNonCachingSession() -> URLSession {
let config = URLSessionConfiguration.ephemeral
config.urlCache = nil
config.requestCachePolicy = .reloadIgnoringLocalCacheData
+2 -1
View File
@@ -125,7 +125,7 @@ A background thread polls each node at 1 Hz, collecting:
- System power draw (W)
- CPU cluster usage (performance and efficiency cores)
**Energy** is computed via trapezoidal integration of the power samples over each inference window (the wall-clock span of each benchmark request or concurrent batch). Average power is `total_joules / total_inference_seconds`.
**Energy** is computed via trapezoidal integration of the power samples over each inference window (the wall-clock span of each benchmark request or concurrent batch). Average power is `total_joules / total_inference_seconds`. The server additionally returns a `power_usage` block in each non-stream `/bench/chat/completions` response that splits energy into prefill and generation phases, with the boundary anchored to the first non-`PrefillProgressChunk` from the runner.
---
@@ -136,6 +136,7 @@ Results are written as JSON with three top-level keys:
- **`runs`**: Array of per-request result objects, each containing:
- `elapsed_s`, `output_text_preview` (first 200 chars)
- `stats`: `{ prompt_tps, generation_tps, prompt_tokens, generation_tokens, peak_memory_usage }`
- `power_usage`: server-side total + prefill/generation split, per-node breakdown (non-stream requests only)
- Placement metadata: `model_id`, `placement_sharding`, `placement_instance_meta`, `placement_nodes`
- Run metadata: `pp_tokens`, `tg`, `repeat_index`, `concurrency`, `concurrent_index`
- `download_duration_s` (if model was freshly downloaded)
+26
View File
@@ -295,6 +295,7 @@ def run_one_completion(
elapsed = time.perf_counter() - t0
stats = out.get("generation_stats")
power_usage = out.get("power_usage")
choices = out.get("choices") or [{}]
message = choices[0].get("message", {}) if choices else {}
content = message.get("content") or ""
@@ -330,6 +331,7 @@ def run_one_completion(
elapsed = time.perf_counter() - t0
preview = "".join(text_parts)[:200]
power_usage = None
if not stats:
ttft = (first_token_time - t0) if first_token_time else elapsed
@@ -348,6 +350,7 @@ def run_one_completion(
"elapsed_s": elapsed,
"output_text_preview": preview,
"stats": stats,
"power_usage": power_usage,
}, pp_tokens
@@ -764,6 +767,7 @@ def main() -> int:
out = c.post_bench_chat_completions(_payload)
elapsed = time.perf_counter() - t0
stats = out.get("generation_stats")
power_usage = out.get("power_usage")
choices = out.get("choices") or [{}]
message = (
choices[0].get("message", {}) if choices else {}
@@ -773,6 +777,7 @@ def main() -> int:
"elapsed_s": elapsed,
"output_text_preview": text[:200],
"stats": stats,
"power_usage": power_usage,
}, _actual_pp
inf_t0 = time.monotonic()
@@ -868,6 +873,27 @@ def main() -> int:
inf_seconds = sum(t1 - t0 for t0, t1 in inference_windows)
avg_watts = joules / inf_seconds if inf_seconds > 0 else 0
summary += f" energy={joules:.1f}J ({avg_watts:.1f}W avg over {inf_seconds:.1f}s inference)"
# mean() not sum() across concurrent runs: each
# request's PowerSampler observes the same shared
# cluster state, so they all report the same figure.
prefill_energies = [
(x.get("power_usage") or {}).get("prefill_energy_joules")
for x in runs
]
gen_energies = [
(x.get("power_usage") or {}).get("generation_energy_joules")
for x in runs
]
prefill_vals = [e for e in prefill_energies if e is not None]
gen_vals = [e for e in gen_energies if e is not None]
if prefill_vals and gen_vals:
avg_pref = mean(prefill_vals)
avg_gen = mean(gen_vals)
summary += (
f" prefill_energy={avg_pref:.1f}J "
f"gen_energy={avg_gen:.1f}J"
)
logger.info(f"{summary}\n")
time.sleep(2)
finally:
@@ -12,6 +12,8 @@
toggleDebugMode,
topologyOnlyMode,
toggleTopologyOnlyMode,
getInstanceFirstShard,
type Instance,
} from "$lib/stores/app.svelte";
interface Props {
@@ -186,7 +188,7 @@
function extractInstanceModelId(instanceWrapped: unknown): string | null {
const [, instance] = getTaggedValue(instanceWrapped);
if (!instance || typeof instance !== "object") return null;
const inst = instance as { shardAssignments?: { modelId?: string } };
const inst = instance as Instance;
return inst.shardAssignments?.modelId ?? null;
}
@@ -204,11 +206,7 @@
else if (instanceTag === "MlxJacclInstance") instanceType = "MLX RDMA";
let sharding: string | null = null;
const inst = instance as {
shardAssignments?: { runnerToShard?: Record<string, unknown> };
};
const runnerToShard = inst.shardAssignments?.runnerToShard || {};
const firstShardWrapped = Object.values(runnerToShard)[0];
const firstShardWrapped = getInstanceFirstShard(instance as Instance);
if (firstShardWrapped) {
const [shardTag] = getTaggedValue(firstShardWrapped);
if (shardTag === "PipelineShardMetadata") sharding = "Pipeline";
@@ -9,6 +9,7 @@
createInstanceLink,
updateInstanceLink,
deleteInstanceLink,
getInstanceNodeIds,
type Instance,
} from "$lib/stores/app.svelte";
import { deriveBaseModel, deriveFamily } from "$lib/utils/model_family";
@@ -16,7 +17,6 @@
type InstanceWrapper = {
MlxRingInstance?: Instance;
MlxJacclInstance?: Instance;
VllmInstance?: Instance;
};
let interval: ReturnType<typeof setInterval> | null = null;
@@ -43,13 +43,9 @@
const ids = nodeIdentities();
for (const [id, raw] of Object.entries(instances())) {
const wrapper = raw as InstanceWrapper;
const inst =
wrapper.MlxRingInstance ??
wrapper.MlxJacclInstance ??
wrapper.VllmInstance;
const inst = wrapper.MlxRingInstance ?? wrapper.MlxJacclInstance;
const modelId = inst?.shardAssignments?.modelId ?? "";
const nodeToRunner = inst?.shardAssignments?.nodeToRunner ?? {};
const nodeIds = Object.keys(nodeToRunner);
const nodeIds = getInstanceNodeIds(inst);
const nodeNames = nodeIds
.map((nodeId) => ids[nodeId]?.friendlyName ?? nodeId.slice(0, 6))
.filter((name) => !!name);
+48 -17
View File
@@ -66,12 +66,40 @@ export interface TopologyData {
edges: TopologyEdge[];
}
export type InstanceShard = [nodeId: string, runnerId: string, shard: unknown];
export interface ShardAssignments {
modelId: string;
shards: InstanceShard[];
primaryOutputNode: number;
}
export interface Instance {
shardAssignments?: {
modelId?: string;
runnerToShard?: Record<string, unknown>;
nodeToRunner?: Record<string, string>;
};
shardAssignments: ShardAssignments;
}
export function getInstanceShards(
instance: Instance | null | undefined,
): InstanceShard[] {
return instance?.shardAssignments.shards ?? [];
}
export function getInstanceRunnerIds(
instance: Instance | null | undefined,
): string[] {
return getInstanceShards(instance).map(([, runnerId]) => runnerId);
}
export function getInstanceNodeIds(
instance: Instance | null | undefined,
): string[] {
return [...new Set(getInstanceShards(instance).map(([nodeId]) => nodeId))];
}
export function getInstanceFirstShard(
instance: Instance | null | undefined,
): unknown {
return getInstanceShards(instance)[0]?.[2];
}
export interface RawInstanceLink {
@@ -918,7 +946,7 @@ class AppStore {
private extractInstanceModelId(instanceWrapped: unknown): string | null {
const [, instance] = this.getTaggedValue(instanceWrapped);
if (!instance || typeof instance !== "object") return null;
const inst = instance as { shardAssignments?: { modelId?: string } };
const inst = instance as Instance;
return inst.shardAssignments?.modelId ?? null;
}
@@ -936,11 +964,8 @@ class AppStore {
else if (instanceTag === "MlxJacclInstance") instanceType = "MLX RDMA";
let sharding: string | null = null;
const inst = instance as {
shardAssignments?: { runnerToShard?: Record<string, unknown> };
};
const runnerToShard = inst.shardAssignments?.runnerToShard || {};
const firstShardWrapped = Object.values(runnerToShard)[0];
const inst = instance as Instance;
const firstShardWrapped = getInstanceFirstShard(inst);
if (firstShardWrapped) {
const [shardTag] = this.getTaggedValue(firstShardWrapped);
if (shardTag === "PipelineShardMetadata") sharding = "Pipeline";
@@ -2253,19 +2278,25 @@ class AppStore {
* @returns The model ID to use, or null if none available
*/
private getModelForRequest(modelId?: string): string | null {
if (modelId) return modelId;
if (this.selectedChatModel) return this.selectedChatModel;
const requestedModelId = modelId || this.selectedChatModel;
// Try to get model from first running instance
// Only models with a placed instance can receive requests; disk downloads alone are not enough.
for (const [, instanceWrapper] of Object.entries(this.instances)) {
if (instanceWrapper && typeof instanceWrapper === "object") {
const keys = Object.keys(instanceWrapper as Record<string, unknown>);
if (keys.length === 1) {
const instance = (instanceWrapper as Record<string, unknown>)[
keys[0]
] as { shardAssignments?: { modelId?: string } };
if (instance?.shardAssignments?.modelId) {
return instance.shardAssignments.modelId;
] as Instance;
const instanceModelId = instance?.shardAssignments?.modelId;
// ensure to only return requestedModelId that matches an instance
// or fall back to first instance
if (
instanceModelId &&
(!requestedModelId || requestedModelId === instanceModelId)
) {
return instanceModelId;
}
}
}
+66 -93
View File
@@ -65,6 +65,11 @@
nodeThunderboltBridge,
nodeIdentities,
isConnected,
getInstanceFirstShard,
getInstanceNodeIds,
getInstanceRunnerIds,
getInstanceShards,
type Instance,
type DownloadProgress,
type PlacementPreview,
} from "$lib/stores/app.svelte";
@@ -998,11 +1003,7 @@
if (keys.length !== 1) return new Set();
const instance = (instanceWrapped as Record<string, unknown>)[keys[0]];
if (!instance || typeof instance !== "object") return new Set();
const inst = instance as {
shardAssignments?: { nodeToRunner?: Record<string, string> };
};
if (!inst.shardAssignments?.nodeToRunner) return new Set();
return new Set(Object.keys(inst.shardAssignments.nodeToRunner));
return new Set(getInstanceNodeIds(instance as Instance));
}
function toggleInstanceDownloadDetails(nodeId: string): void {
@@ -1461,6 +1462,9 @@
addToast({ type: "info", message: `Launching model...` });
// Always auto-select the newly launched model so the user chats to what they just launched
setSelectedChatModel(modelId);
userForcedIdle = false;
pendingChatModelId = modelId;
chatLaunchState = "launching";
// Record the launch in recent models history
recordRecentLaunch(modelId);
@@ -1781,13 +1785,7 @@
};
}
const inst = instance as {
shardAssignments?: {
nodeToRunner?: Record<string, string>;
runnerToShard?: Record<string, unknown>;
modelId?: string;
};
};
const inst = instance as Instance;
const instanceModelId = inst.shardAssignments?.modelId;
if (!instanceModelId) {
@@ -1802,16 +1800,7 @@
};
}
// Get node IDs assigned to this instance
const nodeToRunner = inst.shardAssignments?.nodeToRunner || {};
const runnerToShard = inst.shardAssignments?.runnerToShard || {};
const runnerToNode: Record<string, string> = {};
for (const [nodeId, runnerId] of Object.entries(nodeToRunner)) {
runnerToNode[runnerId] = nodeId;
}
const instanceNodeIds = Object.keys(runnerToShard)
.map((runnerId) => runnerToNode[runnerId])
.filter(Boolean);
const instanceNodeIds = getInstanceNodeIds(inst);
const result = collectDownloadStatus(instanceModelId, instanceNodeIds);
@@ -1855,6 +1844,7 @@
case "FAILED":
return "text-red-400";
case "SHUTDOWN":
case "SHUTTING DOWN":
return "text-gray-400";
case "DOWNLOADING":
return "text-blue-400";
@@ -1862,6 +1852,7 @@
case "WARMING UP":
case "WAITING":
case "INITIALIZING":
case "CONNECTING":
return "text-yellow-400";
case "RUNNING":
return "text-teal-400";
@@ -1884,10 +1875,7 @@
return { statusText: "PREPARING", statusClass: "inactive" };
}
const inst = instance as {
shardAssignments?: { runnerToShard?: Record<string, unknown> };
};
const runnerIds = Object.keys(inst.shardAssignments?.runnerToShard || {});
const runnerIds = getInstanceRunnerIds(instance as Instance);
const statuses = runnerIds
.map((rid) => {
@@ -1895,14 +1883,15 @@
if (!r) return null;
const [kind] = getTagged(r);
const statusMap: Record<string, string> = {
RunnerWaitingForInitialization: "WaitingForInitialization",
RunnerInitializingBackend: "InitializingBackend",
RunnerWaitingForModel: "WaitingForModel",
RunnerIdle: "Idle",
RunnerConnecting: "Connecting",
RunnerConnected: "Connected",
RunnerLoading: "Loading",
RunnerLoaded: "Loaded",
RunnerWarmingUp: "WarmingUp",
RunnerReady: "Ready",
RunnerRunning: "Running",
RunnerShuttingDown: "ShuttingDown",
RunnerShutdown: "Shutdown",
RunnerFailed: "Failed",
};
@@ -1956,14 +1945,15 @@
return { statusText: "RUNNING", statusClass: "running" };
if (has("Ready")) return { statusText: "READY", statusClass: "loaded" };
if (has("Loaded")) return { statusText: "LOADED", statusClass: "loaded" };
if (has("WaitingForModel"))
return { statusText: "WAITING", statusClass: "starting" };
if (has("InitializingBackend"))
return { statusText: "INITIALIZING", statusClass: "starting" };
if (has("WaitingForInitialization"))
if (has("Connected"))
return { statusText: "INITIALIZING", statusClass: "starting" };
if (has("Connecting"))
return { statusText: "CONNECTING", statusClass: "starting" };
if (has("Idle")) return { statusText: "WAITING", statusClass: "starting" };
if (has("ShuttingDown"))
return { statusText: "SHUTTING DOWN", statusClass: "inactive" };
return { statusText: "RUNNING", statusClass: "active" };
return { statusText: "PREPARING", statusClass: "inactive" };
}
function getBytes(value: unknown): number {
@@ -2036,7 +2026,7 @@
function getInstanceModelId(instanceWrapped: unknown): string {
const [, instance] = getTagged(instanceWrapped);
if (!instance || typeof instance !== "object") return "Unknown";
const inst = instance as { shardAssignments?: { modelId?: string } };
const inst = instance as Instance;
return inst.shardAssignments?.modelId || "Unknown Model";
}
@@ -2064,17 +2054,11 @@
if (instanceTag === "MlxRingInstance") instanceType = "MLX Ring";
else if (instanceTag === "MlxJacclInstance") instanceType = "MLX RDMA";
const inst = instance as {
shardAssignments?: {
nodeToRunner?: Record<string, string>;
runnerToShard?: Record<string, unknown>;
};
};
const inst = instance as Instance;
// Sharding strategy from first shard
let sharding = "Unknown";
const runnerToShard = inst.shardAssignments?.runnerToShard || {};
const firstShardWrapped = Object.values(runnerToShard)[0];
const firstShardWrapped = getInstanceFirstShard(inst);
if (firstShardWrapped) {
const [shardTag] = getTagged(firstShardWrapped);
if (shardTag === "PipelineShardMetadata") sharding = "Pipeline";
@@ -2084,8 +2068,7 @@
}
// Node names from topology
const nodeToRunner = inst.shardAssignments?.nodeToRunner || {};
const nodeIds = Object.keys(nodeToRunner);
const nodeIds = getInstanceNodeIds(inst);
const nodeNames = nodeIds.map((nodeId) => {
const node = data?.nodes?.[nodeId];
return node?.friendly_name || nodeId.slice(0, 8);
@@ -2189,35 +2172,19 @@
}
function getOrderedRunnerNodes(
instance: Record<string, unknown>,
instance: Instance,
shardType: "Pipeline" | "Tensor",
) {
const runnerToShard =
(
instance.shardAssignments as
| { runnerToShard?: Record<string, unknown> }
| undefined
)?.runnerToShard || {};
const nodeToRunner =
(
instance.shardAssignments as
| { nodeToRunner?: Record<string, string> }
| undefined
)?.nodeToRunner || {};
const runnerEntries = Object.entries(runnerToShard).map(
([runnerId, shardWrapped]) => {
const runnerEntries = getInstanceShards(instance).map(
([nodeId, runnerId, shardWrapped]) => {
const [tag, shard] = getTagged(shardWrapped);
const meta = shard as
| {
modelMeta?: {
worldSize?: number;
nLayers?: number;
deviceRank?: number;
};
deviceRank?: number;
}
| undefined;
const deviceRank = meta?.modelMeta?.deviceRank ?? 0;
return { runnerId, tag, deviceRank };
const deviceRank = meta?.deviceRank ?? 0;
return { nodeId, runnerId, tag, deviceRank };
},
);
@@ -2228,13 +2195,11 @@
: r.tag === "TensorShardMetadata",
)
.sort((a, b) => a.deviceRank - b.deviceRank)
.map((r, idx) => {
const nodeId = Object.entries(nodeToRunner).find(
([, rid]) => rid === r.runnerId,
)?.[0];
return { nodeId, runnerId: r.runnerId, order: idx };
})
.filter((item) => item.nodeId);
.map((r, idx) => ({
nodeId: r.nodeId,
runnerId: r.runnerId,
order: idx,
}));
return ordered as Array<{
nodeId: string;
@@ -2278,10 +2243,7 @@
// Jaccl (RDMA) show RDMA interfaces from ibvDevices
if (instanceTag === "MlxJacclInstance") {
const ordered = getOrderedRunnerNodes(
instance as Record<string, unknown>,
"Tensor",
);
const ordered = getOrderedRunnerNodes(instance as Instance, "Tensor");
const ibvDevices =
(instance as { ibvDevices?: Array<Array<string | null>> }).ibvDevices ||
[];
@@ -2313,10 +2275,7 @@
// Ring derive ring order from pipeline shard ranks and pick host IPs from hostsByNode
if (instanceTag === "MlxRingInstance") {
const ordered = getOrderedRunnerNodes(
instance as Record<string, unknown>,
"Pipeline",
);
const ordered = getOrderedRunnerNodes(instance as Instance, "Pipeline");
const hostsByNode =
(
instance as {
@@ -2547,12 +2506,10 @@
];
// ── Seamless chat: launch models from chat view ──
type ChatLaunchState =
| "idle"
| "launching"
| "downloading"
| "loading"
| "ready";
type InFlightChatLaunchState = "launching" | "downloading" | "loading";
type ReadyLikeChatLaunchState = "idle" | "ready";
type ChatLaunchState = InFlightChatLaunchState | ReadyLikeChatLaunchState;
let chatLaunchState = $state<ChatLaunchState>("idle");
let pendingChatModelId = $state<string | null>(null);
let selectedChatCategory = $state<string | null>(null);
@@ -2605,6 +2562,7 @@
status.statusText === "WARMING UP" ||
status.statusText === "WAITING" ||
status.statusText === "INITIALIZING" ||
status.statusText === "CONNECTING" ||
status.statusText === "PREPARING"
) {
chatLaunchState = "launching";
@@ -3129,6 +3087,15 @@
if (model) {
pendingAutoMessage = { content, files };
userForcedIdle = false;
// The selected model is already being placed or loaded; keep the queued
// message and let the existing launch state effects send it once ready.
if (
pendingChatModelId === model &&
chatLaunchState !== "idle" &&
chatLaunchState !== "ready"
) {
return;
}
launchModelForChat(model, "picker", messages().length > 0);
return;
}
@@ -4603,7 +4570,7 @@
type="button"
onclick={() => {
completeOnboarding();
sendMessage(chip, undefined, thinkingEnabled());
handleChatSend(chip);
}}
class="px-4 py-2 rounded-full border border-white/10 bg-white/5 text-sm text-white/60 hover:bg-white/10 hover:text-white/80 hover:border-white/20 transition-all duration-200 cursor-pointer"
>
@@ -5098,7 +5065,10 @@
{@const isFailed = statusText === "FAILED"}
{@const isLoading = statusText === "LOADING"}
{@const isWarmingUp =
statusText === "WARMING UP" || statusText === "WAITING"}
statusText === "WARMING UP" ||
statusText === "WAITING" ||
statusText === "INITIALIZING" ||
statusText === "CONNECTING"}
{@const isReady =
statusText === "READY" || statusText === "LOADED"}
{@const isRunning = statusText === "RUNNING"}
@@ -6100,7 +6070,7 @@
onclick={() => {
chatLaunchState = "idle";
selectedChatCategory = null;
sendMessage(prompt, undefined, thinkingEnabled());
handleChatSend(prompt);
}}
class="text-left px-3 py-2.5 text-xs text-exo-light-gray hover:text-white font-mono rounded-lg border border-exo-medium-gray/30 hover:border-exo-yellow/30 bg-exo-dark-gray/30 hover:bg-exo-dark-gray/60 transition-all duration-200 cursor-pointer"
>
@@ -6234,7 +6204,10 @@
{@const isFailed = statusText === "FAILED"}
{@const isLoading = statusText === "LOADING"}
{@const isWarmingUp =
statusText === "WARMING UP" || statusText === "WAITING"}
statusText === "WARMING UP" ||
statusText === "WAITING" ||
statusText === "INITIALIZING" ||
statusText === "CONNECTING"}
{@const isReady =
statusText === "READY" || statusText === "LOADED"}
{@const isRunning = statusText === "RUNNING"}
@@ -3,7 +3,11 @@
import { fade } from "svelte/transition";
import HeaderNav from "$lib/components/HeaderNav.svelte";
import IntegrationCard from "$lib/components/IntegrationCard.svelte";
import { instances, refreshState } from "$lib/stores/app.svelte";
import {
instances,
refreshState,
type Instance,
} from "$lib/stores/app.svelte";
import { onMount } from "svelte";
const apiUrl = browser
@@ -24,9 +28,7 @@
if (values.length > 0) {
const instance = values[0];
if (instance && typeof instance === "object") {
const inst = instance as {
shardAssignments?: { modelId?: string };
};
const inst = instance as Instance;
const modelId = inst.shardAssignments?.modelId;
if (modelId && !models.includes(modelId)) {
models.push(modelId);
+35 -6
View File
@@ -66,7 +66,9 @@ Creates a new model instance in the cluster.
```
**Response:**
JSON description of the created instance.
Command acknowledgement. Instance creation is asynchronous; clients should wait
for the model to appear through `/instance/await` before sending inference
requests for that model.
### Delete Instance
@@ -94,6 +96,31 @@ Returns details of a specific instance.
**Response:**
JSON description of the instance.
### Await Instance
**GET** `/instance/await?model_id=...&timeout_seconds=0`
Waits until API state contains an instance for the requested model. The response
is an SSE stream so clients receive keep-alive comments while waiting.
**Query parameters:**
* `model_id`: string, required
* `timeout_seconds`: float, optional, default `0`. `0` waits indefinitely;
positive values time out after that many seconds. Maximum positive value:
`300`.
**Stream messages:**
```text
data: {"type": "ready", "instance": {...}}
data: {"type": "timeout", "message": "No instance found for model ..."}
```
The HTTP status is `200` for both messages because the stream starts before the
final result is known. The `type` field disambiguates the terminal message.
### Preview Placements
**GET** `/instance/previews?model_id=...`
@@ -123,17 +150,18 @@ Computes a placement for a potential instance without creating it.
**Response:**
JSON object describing the proposed placement / instance configuration.
### Place Instance (Dry Operation)
### Place Instance
**POST** `/place_instance`
Performs a placement operation for an instance (planning step), without necessarily creating it.
Places an instance for a model using the server's placement logic.
**Request body:**
JSON describing the instance to be placed.
**Response:**
Placement result.
Command acknowledgement. The instance may not be ready immediately; wait for it
to appear through `/instance/await` before sending inference requests.
## 3. Models
@@ -639,10 +667,11 @@ GET /events
# Instance Management
POST /instance
GET /instance/{instance_id}
DELETE /instance/{instance_id}
GET /instance/await
GET /instance/previews
GET /instance/placement
GET /instance/{instance_id}
DELETE /instance/{instance_id}
POST /place_instance
# Models
-84
View File
@@ -1,84 +0,0 @@
# EXO Architecture overview
EXO uses an _Event Sourcing_ architecture, and Erlang-style _message passing_. To facilitate this, we've written a channel library extending anyio channels with inspiration from tokio::sync::mpsc.
Each logical module - designed to be functional independently of the others - communicates with the rest of the system by sending messages on topics.
## Systems
There are currently 5 major systems:
- Master
Executes placement and orders events through a single writer
- Worker
Schedules work on a node, gathers system information, etc.#
- Runner
Executes inference jobs (for now) in an isolated process from the worker for fault-tolerance.
- API
Runs a python webserver for exposing state and commands to client applications
- Election
Implements a distributed algorithm for master election in unstable networking conditions
## API Layer
The API system uses multiple adapters to support multiple API formats, converting them to a single request / response type.
### Adapter Pattern
Adapters convert between external API formats and EXO's internal types:
```
Chat Completions → [adapter] → TextGenerationTaskParams → Application
Claude Messages → [adapter] → TextGenerationTaskParams → Application
Responses API → [adapter] → TextGenerationTaskParams → Application
Ollama API → [adapter] → TextGenerationTaskParams → Application
```
Each adapter implements two key functions:
1. **Request conversion**: Converts API-specific requests to `TextGenerationTaskParams`
2. **Response generation**: Converts internal `TokenChunk` streams back to API-specific formats (streaming and non-streaming)
## Topics
There are currently 5 topics:
- Commands
The API and Worker instruct the master when the event log isn't sufficient. Namely placement and catchup requests go through Commands atm.
- Local Events
All nodes write events here, the master reads those events and orders them
- Global Events
The master writes events here, all nodes read from this topic and fold the produced events into their `State`
- Election Messages
Before establishing a cluster, nodes communicate here to negotiate a master node.
- Connection Messages
The networking system write mdns-discovered hardware connections here.
## Event Sourcing
Lots has been written about event sourcing, but it lets us centralize faulty connections and message ACKing with the following model.
Whenever a device produces side effects, it captures those side effects in an `Event`. `Event`s are then "applied" to their model of `State`, which is globally distributed across the cluster. Whenever a command is received, it is combined with state to produce side effects, captured in yet more events. The rule of thumb is "`Event`s are past tense, `Command`s are imperative". Telling a node to perform some action like "place this model" or "Give me a copy of the event log" is represented by a command (The worker's `Task`s are also commands), while "this node is using 300GB of ram" is an event. Notably, `Event`s SHOULD never cause side effects on their own. There are a few exceptions to this, we're working out the specifics of generalizing the distributed event sourcing model to make it better suit our needs
## Purity
A significant goal of the current design is to make data flow explicit. Classes should either represent simple data (`FrozenModel`s typically, and `TaggedModel`s for unions) or active `System`s (Erlang `Actor`s), with all transformations of that data being "referentially transparent" - destructure and construct new data, don't mutate in place. We have had varying degrees of success with this, and are still exploring where purity makes sense.
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
@@ -110,7 +110,7 @@
nixpkgs-fmt.enable = true;
ruff-format = {
enable = true;
excludes = [ "rust/exo_pyo3_bindings/exo_pyo3_bindings.pyi" ];
excludes = [ "rust/exo_rs/exo_rs.pyi" ];
};
rustfmt = {
enable = true;
+2 -2
View File
@@ -23,7 +23,7 @@ sync-clean:
rust-rebuild:
PYO3_PYTHON="$(uv run python -c 'import sys; print(sys.executable)')" cargo run --bin stub_gen
uv sync --reinstall-package exo_pyo3_bindings
uv sync --reinstall-package exo_rs
build-dashboard:
#!/usr/bin/env bash
@@ -37,7 +37,7 @@ package: build-dashboard
rm -rf build
build-app: rust-rebuild sync-clean package
xcodebuild build -project app/EXO/EXO.xcodeproj -scheme EXO -configuration Debug -derivedDataPath app/EXO/build
env -u LD xcodebuild build -project app/EXO/EXO.xcodeproj -scheme EXO -configuration Debug -derivedDataPath app/EXO/build
@echo "\nBuild complete. Run with:\n open {{justfile_directory()}}/app/EXO/build/Build/Products/Debug/EXO.app"
clean:
+12 -12
View File
@@ -15,7 +15,7 @@ dependencies = [
"huggingface-hub>=1.8.0",
"psutil>=7.0.0",
"loguru>=0.7.3",
"exo-pyo3-bindings", # rust bindings
"exo-rs", # rust bindings
"anyio==4.11.0",
"tiktoken>=0.12.0", # required for kimi k2 tokenizer
"hypercorn>=0.18.0",
@@ -26,6 +26,7 @@ dependencies = [
"msgspec>=0.19.0",
"zstandard>=0.23.0",
"transformers>=5.6.2",
"python-daemon>=3.1.2",
]
[project.scripts]
@@ -75,22 +76,14 @@ mlx-cuda13 = [
###
[tool.uv.workspace]
members = ["rust/exo_pyo3_bindings", "bench", "tools"]
members = ["rust/exo_rs", "bench", "tools"]
[tool.uv.sources]
exo-pyo3-bindings = { workspace = true }
exo-rs = { workspace = true }
mlx = [
{ git = "https://github.com/rltakashige/mlx-jaccl-fix-small-recv.git", branch = "address-rdma-gpu-locks", marker = "sys_platform == 'darwin'" },
{ url = "https://github.com/rltakashige/mlx-jaccl-fix-small-recv/releases/download/mlx_cuda/mlx-0.32.0-cp313-cp313-manylinux_2_35_aarch64.whl", marker = "sys_platform == 'linux' and platform_machine == 'aarch64'" },
{ url = "https://github.com/rltakashige/mlx-jaccl-fix-small-recv/releases/download/mlx_cuda/mlx-0.32.0-cp313-cp313-manylinux_2_35_x86_64.whl", marker = "sys_platform == 'linux' and platform_machine != 'aarch64'" },
]
mlx-lm = { git = "https://github.com/rltakashige/mlx-lm", branch = "leo/deepseek-v4" }
mflux = { git = "https://github.com/evanev7/mflux", branch = "exo2" }
torch = [
{ index = "pytorch-cpu", marker = "sys_platform == 'linux' and extra == 'mlx-cpu' and extra != 'mlx-cuda13' and extra != 'mlx-cuda12'" },
{ index = "pytorch-cu128", marker = "sys_platform == 'linux' and extra == 'mlx-cuda12' and extra != 'mlx-cuda13' " },
{ index = "pytorch-cu130", marker = "sys_platform == 'linux' and extra == 'mlx-cuda13'" },
]
mlx-cuda-12 = [
{ url = "https://github.com/rltakashige/mlx-jaccl-fix-small-recv/releases/download/mlx_cuda/mlx_cuda_12-0.32.0-py3-none-manylinux_2_35_aarch64.whl", marker = "sys_platform == 'linux' and platform_machine == 'aarch64'" },
@@ -100,6 +93,13 @@ mlx-cuda-13 = [
{ url = "https://github.com/rltakashige/mlx-jaccl-fix-small-recv/releases/download/mlx_cuda/mlx_cuda_13-0.32.0-py3-none-manylinux_2_35_aarch64.whl", marker = "sys_platform == 'linux' and platform_machine == 'aarch64'" },
{ url = "https://github.com/rltakashige/mlx-jaccl-fix-small-recv/releases/download/mlx_cuda/mlx_cuda_13-0.32.0-py3-none-manylinux_2_35_x86_64.whl", marker = "sys_platform == 'linux' and platform_machine != 'aarch64'" },
]
mlx-lm = { git = "https://github.com/rltakashige/mlx-lm", branch = "leo/deepseek-v4" }
mflux = { git = "https://github.com/evanev7/mflux", branch = "exo2" }
torch = [
{ index = "pytorch-cpu", marker = "sys_platform == 'linux' and extra == 'mlx-cpu' and extra != 'mlx-cuda13' and extra != 'mlx-cuda12'" },
{ index = "pytorch-cu128", marker = "sys_platform == 'linux' and extra == 'mlx-cuda12' and extra != 'mlx-cuda13' " },
{ index = "pytorch-cu130", marker = "sys_platform == 'linux' and extra == 'mlx-cuda13'" },
]
torchvision = [
{ index = "pytorch-cpu", marker = "sys_platform == 'linux' and extra == 'mlx-cpu' and extra != 'mlx-cuda13' and extra != 'mlx-cuda12'" },
{ index = "pytorch-cu128", marker = "sys_platform == 'linux' and extra == 'mlx-cuda12' and extra != 'mlx-cuda13'" },
@@ -240,7 +240,7 @@ torchaudio = ["torch"]
###
[tool.ruff]
extend-exclude = [".typings/**", "rust/exo_pyo3_bindings/**", "bench/vendor/**"]
extend-exclude = [".typings/**", "rust/exo_rs/**", "bench/vendor/**"]
[tool.ruff.lint]
extend-select = ["I", "N", "B", "A", "PIE", "SIM"]
+9 -8
View File
@@ -44,20 +44,21 @@ let
paths = builtins.concatMap (p: [ (lib.getBin p) (lib.getLib p) (lib.getDev p) ]) (cudaLibs ++ [ cudaPackages.cuda_nvcc cuda_cccl_compat ]);
};
exoOverlay = final: prev: {
# Replace workspace exo_pyo3_bindings with Nix-built wheel.
# Replace workspace exo_rs with Nix-built wheel.
# Preserve passthru so mkVirtualEnv can resolve dependency groups.
# Copy .pyi stub + py.typed marker so basedpyright can find the types.
exo-pyo3-bindings = pkgs.stdenv.mkDerivation {
pname = "exo-pyo3-bindings";
exo-rs = pkgs.stdenv.mkDerivation {
pname = "exo-rs";
version = "0.1.0";
src = self'.packages.exo_pyo3_bindings;
src = self'.packages.exo-rs;
# Install from pre-built wheel
nativeBuildInputs = [ final.pyprojectWheelHook ];
dontStrip = true;
passthru = prev.exo-pyo3-bindings.passthru or { };
passthru = prev.exo-rs.passthru or { };
postInstall = ''
local siteDir=$out/${final.python.sitePackages}/exo_pyo3_bindings
cp ${inputs.self}/rust/exo_pyo3_bindings/exo_pyo3_bindings.pyi $siteDir/
local siteDir=$out/${final.python.sitePackages}/exo_rs
cp ${inputs.self}/rust/exo_rs/exo_rs.pyi $siteDir/
touch $siteDir/py.typed
'';
};
@@ -223,7 +224,7 @@ let
++ lib.optionals isDarwin [ pkgs.macmon ];
passthru = {
venv = venv name;
evenv = ((pythonSet.overrideScope editableOverlay).mkVirtualEnv "${name}-evenv" (members // { exo = (members.exo or [ ]) ++ [ "dev" ]; })).overrideAttrs (_: {
evenv = ((pythonSet.overrideScope editableOverlay).mkVirtualEnv "${name}-evenv" (members // { exo = (members.exo or [ ]) ++ [ "dev" ]; exo-rs = [ ]; })).overrideAttrs (_: {
venvSkip = [ "lib/python${python.pythonVersion}/site-packages/mlx/share/cmake/*" "lib/python${python.pythonVersion}/site-packages/build_backend.py" ];
});
} // lib.optionalAttrs cudaSupport {
-69
View File
@@ -1,69 +0,0 @@
[package]
name = "exo_pyo3_bindings"
version = { workspace = true }
edition = { workspace = true }
publish = false
[lib]
doctest = false
path = "src/lib.rs"
name = "exo_pyo3_bindings"
# "cdylib" needed to produce shared library for Python to import
# "rlib" needed for stub-gen to run
crate-type = ["cdylib", "rlib"]
[[bin]]
path = "src/bin/stub_gen.rs"
name = "stub_gen"
doc = false
[lints]
workspace = true
[dependencies]
networking = { workspace = true }
# interop
pyo3 = { version = "0.27.2", features = [
# "abi3-py313", # tells pyo3 (and maturin) to build using the stable ABI with minimum Python version 3.13
# "nightly", # enables better-supported GIL integration
"experimental-async", # async support in #[pyfunction] & #[pymethods]
#"experimental-inspect", # inspection of generated binary => easier to automate type-hint generation
#"py-clone", # adding Clone-ing of `Py<T>` without GIL (may cause panics - remove if panics happen)
# "multiple-pymethods", # allows multiple #[pymethods] sections per class
# integrations with other libraries
# "arc_lock", "bigdecimal", "either", "hashbrown", "indexmap", "num-bigint", "num-complex", "num-rational",
# "ordered-float", "rust_decimal", "smallvec",
# "anyhow", "chrono", "chrono-local", "chrono-tz", "eyre", "jiff-02", "lock_api", "parking-lot", "time", "serde",
] }
pyo3-stub-gen = { version = "0.17.2" }
pyo3-async-runtimes = { version = "0.27.0", features = [
"attributes",
"tokio-runtime",
"testing",
] }
pyo3-log = "0.13.2"
pidfile-rs = "0.3"
# macro dependencies
extend = { workspace = true }
delegate = { workspace = true }
thiserror = "2.0"
# async runtime
tokio = { workspace = true, features = ["full", "tracing"] }
futures-lite = { workspace = true }
# utility dependencies
util = { workspace = true }
# Tracing
log = { workspace = true }
env_logger = "0.11"
# Networking
libp2p = { workspace = true, features = ["full"] }
pin-project = "1.1.10"
-47
View File
@@ -1,47 +0,0 @@
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};
/// Identity keypair of a node.
#[gen_stub_pyclass]
#[pyclass(name = "Keypair", frozen)]
#[repr(transparent)]
pub struct PyKeypair(pub Keypair);
#[gen_stub_pymethods]
#[pymethods]
#[allow(clippy::needless_pass_by_value)]
impl PyKeypair {
/// Generate a new Ed25519 keypair.
#[staticmethod]
fn generate() -> Self {
Self(Keypair::generate_ed25519())
}
/// 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()?))
}
/// 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();
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()
}
}
-318
View File
@@ -1,318 +0,0 @@
use std::pin::Pin;
use std::sync::Arc;
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 _};
use pyo3::types::PyBytes;
use pyo3::{Bound, Py, PyAny, PyErr, PyResult, Python, pymethods};
use pyo3_stub_gen::derive::{
gen_methods_from_python, gen_stub_pyclass, gen_stub_pyclass_complex_enum, gen_stub_pymethods,
};
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 {
// channels
pub to_swarm: mpsc::Sender<ToSwarm>,
pub swarm: Arc<Mutex<Pin<Box<dyn Stream<Item = FromSwarm> + Send>>>>,
}
#[gen_stub_pyclass_complex_enum]
#[pyclass]
enum PyFromSwarm {
Connection {
peer_id: String,
connected: bool,
},
Message {
origin: String,
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(),
topic: topic,
data: data.pybytes(),
},
}
}
}
#[gen_stub_pymethods]
#[pymethods]
impl PyNetworkingHandle {
// NOTE: `async fn`s here that use `.await` will wrap the future in `.allow_threads_py()`
// immediately beforehand to release the interpreter.
// SEE: https://pyo3.rs/v0.26.0/async-await.html#detaching-from-the-interpreter-across-await
// ---- Lifecycle management methods ----
#[new]
#[pyo3(signature = (identity, bootstrap_peers, listen_port))]
fn py_new(
identity: Bound<'_, PyKeypair>,
bootstrap_peers: Vec<String>,
listen_port: u16,
) -> PyResult<Self> {
// create communication channels
let (to_swarm, from_client) = mpsc::channel(MPSC_CHANNEL_SIZE);
// get identity
let identity = identity.borrow().0.clone();
// 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();
Ok(Self {
swarm: Arc::new(Mutex::new(swarm)),
to_swarm,
})
}
#[gen_stub(skip)]
fn recv<'py>(&'py self, py: Python<'py>) -> PyResult<Bound<'py, PyAny>> {
let swarm = Arc::clone(&self.swarm);
pyo3_async_runtimes::tokio::future_into_py(py, async move {
swarm
.try_lock()
.map_err(|_| PyRuntimeError::new_err("called recv twice concurrently"))?
.next()
.await
.ok_or(PyErr::receiver_channel_closed())
.map(PyFromSwarm::from)
})
}
// ---- Gossipsub management methods ----
/// Subscribe to a `GossipSub` topic.
///
/// Returns `True` if the subscription worked. Returns `False` if we were already subscribed.
async fn gossipsub_subscribe(&self, topic: String) -> PyResult<bool> {
let (tx, rx) = oneshot::channel();
// send off request to subscribe
self.to_swarm
.send_py(ToSwarm::Subscribe {
topic,
result_sender: tx,
})
.allow_threads_py() // allow-threads-aware async call
.await?;
// wait for response & return any errors
rx.allow_threads_py() // allow-threads-aware async call
.await
.map_err(|_| PyErr::receiver_channel_closed())?
.pyerr()
}
/// Unsubscribes from a `GossipSub` topic.
///
/// Returns `True` if we were subscribed to this topic. Returns `False` if we were not subscribed.
async fn gossipsub_unsubscribe(&self, topic: String) -> PyResult<bool> {
let (tx, rx) = oneshot::channel();
// send off request to unsubscribe
self.to_swarm
.send_py(ToSwarm::Unsubscribe {
topic,
result_sender: tx,
})
.allow_threads_py() // allow-threads-aware async call
.await?;
// wait for response & convert any errors
rx.allow_threads_py() // allow-threads-aware async call
.await
.map_err(|_| PyErr::receiver_channel_closed())
}
/// Publishes a message with multiple topics to the `GossipSub` network.
///
/// If no peers are found that subscribe to this topic, throws `NoPeersSubscribedToTopicError` exception.
async fn gossipsub_publish(&self, topic: String, data: Py<PyBytes>) -> PyResult<()> {
let (tx, rx) = oneshot::channel();
// send off request to subscribe
let data = Python::attach(|py| Vec::from(data.as_bytes(py)));
self.to_swarm
.send_py(ToSwarm::Publish {
topic,
data,
result_sender: tx,
})
.allow_threads_py() // allow-threads-aware async call
.await?;
// wait for response & return any errors => ignore messageID for now!!!
let _ = rx
.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()),
})?;
Ok(())
}
}
pyo3_stub_gen::inventory::submit! {
gen_methods_from_python! {
r#"
class PyNetworkingHandle:
async def recv() -> PyFromSwarm: ...
"#
}
}
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>()?;
Ok(())
}
-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;
}
}
+54
View File
@@ -0,0 +1,54 @@
[package]
name = "exo_rs"
version = { workspace = true }
edition = { workspace = true }
publish = false
[lib]
doctest = false
path = "src/lib.rs"
name = "exo_rs"
# "cdylib" needed to produce shared library for Python to import
# "rlib" needed for stub-gen to run
crate-type = ["cdylib", "rlib"]
[[bin]]
path = "src/bin/stub_gen.rs"
name = "stub_gen"
doc = false
[lints]
workspace = true
[dependencies]
networking.workspace = true
extend.workspace = true
# interop
pyo3 = { workspace = true, features = ["experimental-async"] }
pyo3-stub-gen.workspace = true
pyo3-async-runtimes = { workspace = true, features = [
"attributes",
"tokio-runtime",
"testing",
] }
pyo3-log.workspace = true
pidfile-rs = { workspace = true }
# async runtime
tokio = { workspace = true, features = ["full"] }
futures-lite.workspace = true
pin-project.workspace = true
# Tracing
log.workspace = true
env_logger.workspace = true
# Networking
zenoh.workspace = true
zenoh-ext = { workspace = true, features = ["unstable"] }
rand.workspace = true
serde_json.workspace = true
parking_lot.workspace = true
File renamed without changes.
@@ -1,50 +1,60 @@
# This file is automatically generated by pyo3_stub_gen
# ruff: noqa: E501, F401
# ruff: noqa: E501, F401, F403, F405
import builtins
import collections.abc
import os
import pathlib
import typing
__all__ = [
"FromSwarm",
"LVAggregator",
"LVPublisher",
"LVSubscriber",
"NetworkingHandle",
"Pidfile",
"PidfileError",
"SessionHandle",
"Storage",
"StorageGetter",
]
class FromSwarm:
@typing.final
class Connection(FromSwarm):
__match_args__ = ("connected",)
@property
def connected(self) -> builtins.bool: ...
def __new__(cls, connected: builtins.bool) -> FromSwarm.Connection: ...
@typing.final
class Message(FromSwarm):
__match_args__ = ("topic", "data",)
@property
def topic(self) -> builtins.str: ...
@property
def data(self) -> bytes: ...
def __new__(cls, topic: builtins.str, data: bytes) -> FromSwarm.Message: ...
...
@typing.final
class AllQueuesFullError(builtins.Exception):
def __new__(cls, *args: typing.Any) -> AllQueuesFullError: ...
def __repr__(self) -> builtins.str: ...
def __str__(self) -> builtins.str: ...
class LVAggregator:
def dump(self) -> builtins.dict[builtins.str, builtins.str]: ...
@typing.final
class Keypair:
r"""
Identity keypair of a node.
"""
@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`.
"""
class LVPublisher:
def put(self, data: builtins.str) -> collections.abc.Awaitable[None]: ...
@typing.final
class MessageTooLargeError(builtins.Exception):
def __new__(cls, *args: typing.Any) -> MessageTooLargeError: ...
def __repr__(self) -> builtins.str: ...
def __str__(self) -> builtins.str: ...
class LVSubscriber:
def recv(self) -> collections.abc.Awaitable[tuple[str, str] | None]: ...
@typing.final
class NetworkingHandle:
def __new__(cls, identity: Keypair, bootstrap_peers: typing.Sequence[builtins.str], listen_port: builtins.int) -> NetworkingHandle: ...
@staticmethod
def new(identity: builtins.str, namespace: builtins.str, listen_port: builtins.int, discovery_service_port: builtins.int) -> NetworkingHandle: ...
def recv(self) -> typing.Awaitable[FromSwarm]: ...
async def gossipsub_subscribe(self, topic: builtins.str) -> builtins.bool:
r"""
Subscribe to a `GossipSub` topic.
@@ -63,13 +73,6 @@ class NetworkingHandle:
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: ...
@typing.final
class Pidfile:
@@ -77,7 +80,7 @@ class Pidfile:
A PID file protected with a lock.
An instance of `Pidfile` can be used to manage a PID file: create it,
lock it, detect already running daemons. It is backed by [`pidfile`][]
lock it, detect already running daemons. It is backed by [`pidfile`]
functions of `libbsd`/`libutil` which use `flopen` to lock the PID
file.
@@ -107,32 +110,44 @@ class Pidfile:
The file is truncated before writing.
"""
def as_raw_fd(self) -> builtins.int:
r"""
Extracts the raw file descriptor.
This function is typically used to **borrow** an owned file descriptor.
When used in this way, this method does **not** pass ownership of the
raw file descriptor to the caller, and the file descriptor is only
guaranteed to be valid while the original object has not yet been
destroyed.
"""
def close(self) -> None:
r"""
Closes the PID file and releases associated resources.
"""
@typing.final
class PidfileError(builtins.Exception):
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: ...
...
@typing.final
class SessionHandle:
@staticmethod
def new(identity: builtins.str, namespace: builtins.str, listen_port: builtins.int, discovery_service_port: builtins.int) -> tuple[SessionHandle, NetworkingHandle]: ...
def last_value_aggregator(self, prefix: builtins.str) -> LVAggregator: ...
def last_value_subscriber(self, kexpr: builtins.str) -> LVSubscriber: ...
def last_value_publisher(self, kexpr: builtins.str) -> LVPublisher: ...
def storage_interface(self) -> Storage: ...
@typing.final
class Storage:
def get(self, key: builtins.str) -> collections.abc.Awaitable[str | None]: ...
def get_many(self, key: builtins.str) -> StorageGetter: ...
def put(self, key: builtins.str, data: builtins.str) -> collections.abc.Awaitable[None]: ...
def delete(self, key: builtins.str) -> collections.abc.Awaitable[None]: ...
def dump(self, prefix: builtins.str) -> collections.abc.Awaitable[dict[str, str]]: ...
@typing.final
class StorageGetter:
def recv(self) -> collections.abc.Awaitable[tuple[str, str] | None]: ...
@@ -3,27 +3,31 @@ requires = ["maturin>=1.0,<2.0"]
build-backend = "maturin"
[project]
name = "exo_pyo3_bindings"
version = "0.2.2"
name = "exo_rs"
version = "0.3.0"
description = "Add your description here"
readme = "README.md"
authors = [
{ name = "Andrei Cravtov", email = "the.andrei.cravtov@gmail.com" },
{ name = "Evan Quiney", email = "evanev7@gmail.com" },
{ name = "Andrei Cravtov", email = "the.andrei.cravtov@gmail.com" },
]
requires-python = ">=3.13"
dependencies = []
[dependency-groups]
dev = ["exo_pyo3_bindings", "pytest>=8.4.0", "pytest-asyncio>=1.0.0"]
dev = ["exo_rs", "pytest>=8.4.0", "pytest-asyncio>=1.0.0"]
[tool.maturin]
#purelib = true
#python-source = "python"
module-name = "exo_pyo3_bindings"
module-name = "exo_rs"
features = ["pyo3/extension-module", "pyo3/experimental-async"]
[tool.pyo3-stub-gen]
generate-init-py = true
[tool.pytest.ini_options]
log_cli = true
log_cli_level = "INFO"
asyncio_mode = "auto"
[tool.uv]
cache-keys = [{ file = "src/**/*.rs" }]
@@ -2,7 +2,7 @@ use pyo3_stub_gen::Result;
fn main() -> Result<()> {
env_logger::Builder::from_env(env_logger::Env::default().filter_or("RUST_LOG", "info")).init();
let stub = exo_pyo3_bindings::stub_info()?;
let stub = exo_rs::stub_info()?;
stub.generate()?;
Ok(())
}
+181
View File
@@ -0,0 +1,181 @@
use networking::{Session, liveliness_aggregator::LivelinessAggregator};
use std::{
collections::{HashMap, HashSet},
sync::Arc,
};
use zenoh::{Result as ZResult, Wait};
use parking_lot::Mutex;
use pyo3::{
exceptions::{PyConnectionError, PyRuntimeError},
prelude::*,
};
use pyo3_stub_gen::derive::{gen_stub_pyclass, gen_stub_pymethods};
use zenoh::{
handlers::FifoChannelHandler,
sample::{Sample, SampleKind},
};
use zenoh_ext::{
AdvancedPublisher, AdvancedSubscriber, AdvancedSubscriberBuilderExt, HistoryConfig,
};
#[gen_stub_pyclass]
#[pyclass]
pub struct LVAggregator {
pub prefix: Arc<str>,
pub store: Arc<Mutex<HashMap<String, String>>>,
pub current_live: LivelinessAggregator,
}
pub fn spawn_lv_aggregator_onto(session: &Session, prefix: Arc<str>) -> ZResult<LVAggregator> {
// nota bene: config must be kept in line with SessionHandle::last_value_receiver
let store = Arc::new(Mutex::new(HashMap::default()));
session
.z
//assuming all LV aggregators are prefix/node_id/atomic_json
.declare_subscriber(format!("{prefix}/*/*"))
.advanced()
.history(
HistoryConfig::default()
.max_samples(1)
.detect_late_publishers(),
)
.callback({
let store = Arc::clone(&store);
let prefix = Arc::clone(&prefix);
move |sample| {
if let Some(s) = sample
.key_expr()
.to_string()
.strip_prefix(&*prefix)
.and_then(|it| it.strip_prefix('/'))
{
let s = s.to_string();
match sample.kind() {
SampleKind::Put => {
store.lock().insert(
s,
sample
.payload()
.try_to_string()
.expect("we only use utf8 encoded strings. someone messed up")
.to_string(),
);
}
SampleKind::Delete => {
store.lock().remove(&s);
}
}
};
}
})
.background()
.wait()?;
Ok(LVAggregator {
prefix,
store,
current_live: session.liveliness_aggregator.clone(),
})
}
#[gen_stub_pymethods]
#[pymethods]
impl LVAggregator {
pub fn dump(&self) -> HashMap<String, String> {
let mut store = self.store.lock();
let currently_alive: HashSet<String> = self.current_live.dump();
// remove any keys that are no longer live
store.retain(|key, _| {
currently_alive.iter().any(|node_id| {
key.strip_prefix(node_id)
.is_some_and(|rest| rest.starts_with("/"))
})
});
store.clone()
}
}
#[gen_stub_pyclass]
#[pyclass]
pub struct LVSubscriber {
pub subscriber: AdvancedSubscriber<FifoChannelHandler<Sample>>,
}
#[gen_stub_pymethods]
#[pymethods]
impl LVSubscriber {
#[gen_stub(override_return_type(
type_repr="collections.abc.Awaitable[tuple[str, str] | None]",
imports=("collections.abc")
))]
pub fn recv<'py>(&'py self, py: Python<'py>) -> PyResult<Bound<'py, PyAny>> {
pyo3_async_runtimes::tokio::future_into_py(py, {
if self.subscriber.receiver_count() != 1 {
return Err(PyRuntimeError::new_err(
"tried to receive twice on the same receiver",
));
}
let subscriber = self.subscriber.clone();
async move {
loop {
match subscriber.recv_async().await {
Ok(sample) if sample.kind() == SampleKind::Delete => continue,
Err(_) => {
return Ok(None);
}
Ok(sample) => {
return Ok(Some((
sample.key_expr().to_string(),
sample
.payload()
.try_to_string()
.expect("we only use utf8 encoded strings. someone messed up")
.to_string(),
)));
}
}
}
}
})
}
}
#[gen_stub_pyclass]
#[pyclass]
pub struct LVPublisher {
pub state: Arc<AdvancedPublisher<'static>>,
}
impl LVPublisher {
pub fn new(publisher: AdvancedPublisher<'static>) -> Self {
Self {
state: Arc::new(publisher),
}
}
}
#[gen_stub_pymethods]
#[pymethods]
impl LVPublisher {
#[gen_stub(override_return_type(
type_repr="collections.abc.Awaitable[None]",
imports=("collections.abc")
))]
pub fn put<'py>(&'py self, py: Python<'py>, data: String) -> PyResult<Bound<'py, PyAny>> {
let state = Arc::clone(&self.state);
pyo3_async_runtimes::tokio::future_into_py(py, {
// clone the data so py can have it back
async move {
state
.put(data)
.await
.map_err(|e| PyConnectionError::new_err(e.to_string()))?;
Ok(())
}
})
}
}
pub fn lv_submodule(m: &Bound<'_, PyModule>) -> PyResult<()> {
m.add_class::<LVPublisher>()?;
m.add_class::<LVSubscriber>()?;
m.add_class::<LVAggregator>()?;
Ok(())
}
@@ -4,24 +4,22 @@
//!
//!
mod allow_threading;
mod ident;
mod networking;
mod pidfile;
pub mod allow_threading;
pub mod last_value;
pub mod networking;
pub mod pidfile;
pub mod session;
mod storage;
use crate::ident::PyKeypair;
use crate::last_value::lv_submodule;
use crate::networking::networking_submodule;
use crate::pidfile::pidfile_submodule;
use crate::session::session_submodule;
use crate::storage::storage_submodule;
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;
@@ -153,7 +151,7 @@ pub(crate) mod ext {
/// A Python module implemented in Rust. The name of this function must match
/// the `lib.name` setting in the `Cargo.toml`, else Python will not be able to
/// import the module.
#[pymodule(name = "exo_pyo3_bindings")]
#[pymodule(name = "exo_rs", gil_used = true)]
fn main_module(m: &Bound<'_, PyModule>) -> PyResult<()> {
// install logger
pyo3_log::init();
@@ -161,15 +159,12 @@ fn main_module(m: &Bound<'_, PyModule>) -> PyResult<()> {
builder.enable_all();
pyo3_async_runtimes::tokio::init(builder);
// 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)?;
// TODO: for now this is all NOT a submodule. KISS
pidfile_submodule(m)?;
// top-level constructs
// TODO: ...
networking_submodule(m)?;
lv_submodule(m)?;
session_submodule(m)?;
storage_submodule(m)?;
Ok(())
}
+197
View File
@@ -0,0 +1,197 @@
use std::pin::Pin;
use std::sync::Arc;
use crate::ext::{ByteArrayExt as _, FutureExt, PyErrExt as _};
use crate::ext::{ResultExt as _, TokioMpscSenderExt as _};
use futures_lite::{Stream, StreamExt as _};
use networking::swarm::{FromSwarm, Swarm, ToSwarm, create_swarm};
use networking::{Session, is_valid_zid};
use pyo3::exceptions::{PyRuntimeError, PyValueError};
use pyo3::prelude::*;
use pyo3::types::PyBytes;
use pyo3::{Bound, Py, PyAny, PyErr, PyResult, Python, pymethods};
use pyo3_stub_gen::derive::{gen_stub_pyclass, gen_stub_pyclass_complex_enum, gen_stub_pymethods};
use tokio::sync::{Mutex, mpsc, oneshot};
#[gen_stub_pyclass]
#[pyclass(name = "NetworkingHandle")]
pub struct PyNetworkingHandle {
// channels
pub to_swarm: mpsc::Sender<ToSwarm>,
pub swarm: Arc<Mutex<Pin<Box<dyn Stream<Item = FromSwarm> + Send>>>>,
}
#[gen_stub_pyclass_complex_enum]
#[pyclass(name = "FromSwarm")]
pub enum PyFromSwarm {
Connection { connected: bool },
Message { topic: String, data: Py<PyBytes> },
}
impl From<FromSwarm> for PyFromSwarm {
fn from(value: FromSwarm) -> Self {
match value {
FromSwarm::Discovered {} => Self::Connection { connected: true },
FromSwarm::Expired {} => Self::Connection { connected: false },
FromSwarm::Message { topic, data } => Self::Message {
topic: topic,
data: data.pybytes(),
},
}
}
}
impl PyNetworkingHandle {
pub fn from_session(session: Session) -> Self {
let (to_swarm, from_client) = mpsc::channel(1024);
let swarm = Swarm {
from_client,
session,
};
PyNetworkingHandle {
swarm: Arc::new(Mutex::new(swarm.into_stream())),
to_swarm,
}
}
}
#[gen_stub_pymethods]
#[pymethods]
impl PyNetworkingHandle {
// NOTE: `async fn`s here that use `.await` will wrap the future in `.allow_threads_py()`
// immediately beforehand to release the interpreter.
// SEE: https://pyo3.rs/v0.26.0/async-await.html#detaching-from-the-interpreter-across-await
// ---- Lifecycle management methods ----
#[staticmethod]
pub fn new(
identity: &str,
namespace: &str,
listen_port: u16,
discovery_service_port: u16,
) -> PyResult<PyNetworkingHandle> {
// todo: zenoh self assigned peers
if listen_port == 0 {
todo!("cannot listen on port 0 yet");
}
// create communication channels
let (to_swarm, from_client) = mpsc::channel(1024);
// get identity
if !is_valid_zid(identity) {
return Err(PyValueError::new_err(format!(
"{identity} is not a valid zenoh identity"
)));
}
// create networking swarm (within tokio context!! or it crashes)
let swarm = pyo3_async_runtimes::tokio::get_runtime()
.block_on(create_swarm(
identity,
namespace,
from_client,
listen_port,
discovery_service_port,
))
.pyerr()?;
Ok(PyNetworkingHandle {
swarm: Arc::new(Mutex::new(swarm.into_stream())),
to_swarm,
})
}
#[gen_stub(override_return_type(
type_repr="typing.Awaitable[FromSwarm]", imports=("typing")
))]
pub fn recv<'py>(&'py self, py: Python<'py>) -> PyResult<Bound<'py, PyAny>> {
let swarm = Arc::clone(&self.swarm);
pyo3_async_runtimes::tokio::future_into_py(py, async move {
swarm
.try_lock()
.map_err(|_| PyRuntimeError::new_err("called recv twice concurrently"))?
.next()
.await
.ok_or(PyErr::receiver_channel_closed())
.map(PyFromSwarm::from)
})
}
// ---- Gossipsub management methods ----
/// Subscribe to a `GossipSub` topic.
///
/// Returns `True` if the subscription worked. Returns `False` if we were already subscribed.
pub async fn gossipsub_subscribe(&self, topic: String) -> PyResult<bool> {
let (tx, rx) = oneshot::channel();
// send off request to subscribe
self.to_swarm
.send_py(ToSwarm::Subscribe {
topic,
result_sender: tx,
})
.allow_threads_py() // allow-threads-aware async call
.await?;
// wait for response & return any errors
rx.allow_threads_py() // allow-threads-aware async call
.await
.map_err(|_| PyErr::receiver_channel_closed())?
.pyerr()
}
/// Unsubscribes from a `GossipSub` topic.
///
/// Returns `True` if we were subscribed to this topic. Returns `False` if we were not subscribed.
pub async fn gossipsub_unsubscribe(&self, topic: String) -> PyResult<bool> {
let (tx, rx) = oneshot::channel();
// send off request to unsubscribe
self.to_swarm
.send_py(ToSwarm::Unsubscribe {
topic,
result_sender: tx,
})
.allow_threads_py() // allow-threads-aware async call
.await?;
// wait for response & convert any errors
rx.allow_threads_py() // allow-threads-aware async call
.await
.map_err(|_| PyErr::receiver_channel_closed())
}
/// Publishes a message with multiple topics to the `GossipSub` network.
///
/// If no peers are found that subscribe to this topic, throws `NoPeersSubscribedToTopicError` exception.
pub async fn gossipsub_publish(&self, topic: String, data: Py<PyBytes>) -> PyResult<()> {
let (tx, rx) = oneshot::channel();
// send off request to subscribe
let data = Python::attach(|py| Vec::from(data.as_bytes(py)));
self.to_swarm
.send_py(ToSwarm::Publish {
topic,
data,
result_sender: tx,
})
.allow_threads_py() // allow-threads-aware async call
.await?;
// wait for response & return any errors => ignore messageID for now!!!
let _ = rx
.allow_threads_py() // allow-threads-aware async call
.await
.map_err(|_| PyErr::receiver_channel_closed())?
.map_err(|e| PyRuntimeError::new_err(e.to_string()))?;
Ok(())
}
}
pub fn networking_submodule(m: &Bound<'_, PyModule>) -> PyResult<()> {
m.add_class::<PyNetworkingHandle>()?;
m.add_class::<PyFromSwarm>()?;
Ok(())
}
@@ -3,7 +3,9 @@ use pyo3::exceptions::PyException;
use pyo3::prelude::{PyModule, PyModuleMethods};
use pyo3::{Bound, PyErr, PyResult, Python, pyclass, pymethods};
use pyo3_stub_gen::derive::{gen_stub_pyclass, gen_stub_pymethods};
use std::fs;
use std::fs::Permissions;
use std::os::fd::{AsRawFd, RawFd};
use std::os::unix::prelude::PermissionsExt;
use std::path::PathBuf;
@@ -36,7 +38,7 @@ impl PyPidfileError {
/// A PID file protected with a lock.
///
/// An instance of `Pidfile` can be used to manage a PID file: create it,
/// lock it, detect already running daemons. It is backed by [`pidfile`][]
/// lock it, detect already running daemons. It is backed by [`pidfile`]
/// functions of `libbsd`/`libutil` which use `flopen` to lock the PID
/// file.
///
@@ -53,7 +55,23 @@ impl PyPidfileError {
/// [`daemon`(3)]: https://linux.die.net/man/3/daemon
#[gen_stub_pyclass]
#[pyclass(name = "Pidfile")]
pub struct PyPidfile(Pidfile);
pub struct PyPidfile(Option<Pidfile>);
impl PyPidfile {
#[inline(always)]
fn get(&self) -> &Pidfile {
self.0
.as_ref()
.expect("cannot use resource after exiting context")
}
#[inline(always)]
fn get_mut(&mut self) -> &mut Pidfile {
self.0
.as_mut()
.expect("cannot use resource after exiting context")
}
}
#[gen_stub_pymethods]
#[pymethods]
@@ -65,17 +83,40 @@ impl PyPidfile {
/// the PID file yet.
#[new]
fn py_new(py: Python, path: PathBuf, mode: u32) -> PyResult<Self> {
Ok(Self(
Pidfile::new(&path, Permissions::from_mode(mode))
.map_err(|e| PyPidfileError(e).into_pyerr(py))?,
))
// create all parent directories if don't exist
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)
.map_err(|e| PyPidfileError(PidfileError::Io(e)).into_pyerr(py))?;
}
let pidfile = Pidfile::new(&path, Permissions::from_mode(mode))
.map_err(|e| PyPidfileError(e).into_pyerr(py))?;
Ok(Self(Some(pidfile)))
}
/// Writes the current process ID to the PID file.
///
/// The file is truncated before writing.
fn write<'py>(&mut self, py: Python<'py>) -> PyResult<()> {
self.0.write().map_err(|e| PyPidfileError(e).into_pyerr(py))
self.get_mut()
.write()
.map_err(|e| PyPidfileError(e).into_pyerr(py))
}
/// Extracts the raw file descriptor.
///
/// This function is typically used to **borrow** an owned file descriptor.
/// When used in this way, this method does **not** pass ownership of the
/// raw file descriptor to the caller, and the file descriptor is only
/// guaranteed to be valid while the original object has not yet been
/// destroyed.
fn as_raw_fd(&self) -> RawFd {
self.get().as_raw_fd()
}
/// Closes the PID file and releases associated resources.
fn close(&mut self) {
self.0 = None;
}
}
+111
View File
@@ -0,0 +1,111 @@
use networking::Session;
use pyo3::{
exceptions::{PyConnectionError, PyRuntimeError, PyValueError},
prelude::*,
};
use pyo3_stub_gen::derive::{gen_stub_pyclass, gen_stub_pymethods};
use zenoh::Wait;
use zenoh_ext::{
AdvancedPublisherBuilderExt, AdvancedSubscriberBuilderExt, CacheConfig, HistoryConfig,
MissDetectionConfig,
};
use crate::{
last_value::{LVAggregator, LVPublisher, LVSubscriber, spawn_lv_aggregator_onto},
networking::PyNetworkingHandle,
storage::Storage,
};
#[gen_stub_pyclass]
#[pyclass]
pub struct SessionHandle {
pub session: Session,
}
#[gen_stub_pymethods]
#[pymethods]
impl SessionHandle {
#[staticmethod]
pub fn new<'py>(
identity: &str,
namespace: &str,
listen_port: u16,
discovery_service_port: u16,
) -> PyResult<(SessionHandle, PyNetworkingHandle)> {
// get identity
if !identity
.chars()
.all(|c| ('0'..='9').contains(&c) || ('a'..='f').contains(&c))
|| identity.len() > 32
{
return Err(PyValueError::new_err(format!(
"{identity} is not a valid zenoh identity"
)));
}
let cfg = networking::cfg(identity, listen_port).map_err(|e| {
PyValueError::new_err(format!("failed to write config: {}", e.to_string()))
})?;
let session = pyo3_async_runtimes::tokio::get_runtime()
.block_on(networking::open(
cfg,
namespace,
listen_port,
discovery_service_port,
))
.map_err(|e| {
PyRuntimeError::new_err(format!(
"failed to spawn networking on tokio runtime: {}",
e.to_string()
))
})?;
let legacy = PyNetworkingHandle::from_session(session.clone());
Ok((Self { session }, legacy))
}
pub fn last_value_aggregator(&self, prefix: String) -> PyResult<LVAggregator> {
spawn_lv_aggregator_onto(&self.session, prefix.into()).map_err(|e| {
PyConnectionError::new_err(format!("failed to spawn liveliness aggregator: {e}"))
})
}
pub fn last_value_subscriber(&self, kexpr: &str) -> PyResult<LVSubscriber> {
// nota bene: config must be kept in track with the LVAggregator
self.session
.z
.declare_subscriber(kexpr)
.advanced()
.history(
HistoryConfig::default()
.max_samples(1)
.detect_late_publishers(),
)
.wait()
.map_err(|e| PyConnectionError::new_err(format!("failed to declare subscriber: {e}")))
.map(|subscriber| LVSubscriber { subscriber })
}
pub fn last_value_publisher(&self, kexpr: String) -> PyResult<LVPublisher> {
self.session
.z
.declare_publisher(kexpr)
.advanced()
.publisher_detection()
.sample_miss_detection(MissDetectionConfig::default())
.cache(CacheConfig::default().max_samples(1))
.wait()
.map_err(|e| PyConnectionError::new_err(format!("failed to declare publisher: {e}")))
.map(LVPublisher::new)
}
pub fn storage_interface(&self) -> Storage {
Storage {
session: self.session.z.clone(),
}
}
}
pub fn session_submodule(m: &Bound<'_, PyModule>) -> PyResult<()> {
m.add_class::<SessionHandle>()?;
Ok(())
}
+199
View File
@@ -0,0 +1,199 @@
use std::{collections::HashMap, time::Duration};
use networking::STORAGE_PREFIX;
use pyo3::{
exceptions::{PyConnectionError, PyRuntimeError, PyValueError},
prelude::*,
};
use pyo3_stub_gen::derive::{gen_stub_pyclass, gen_stub_pymethods};
use zenoh::{
Session as ZSession, Wait, handlers::FifoChannelHandler, query::Reply, sample::SampleKind,
};
#[gen_stub_pyclass]
#[pyclass]
pub struct Storage {
pub session: ZSession,
}
#[gen_stub_pymethods]
#[pymethods]
impl Storage {
#[gen_stub(override_return_type(
type_repr="collections.abc.Awaitable[str | None]",
imports=("collections.abc")
))]
pub fn get<'py>(&'py self, py: Python<'py>, key: String) -> PyResult<Bound<'py, PyAny>> {
if key.contains('*') {
return Err(PyValueError::new_err(format!(
"{key} is invalid -- Storage.get only supports fixed keys"
)));
}
let session = self.session.clone();
pyo3_async_runtimes::tokio::future_into_py(py, async move {
let subscriber = session
.get(format!("{STORAGE_PREFIX}/{key}"))
//.allowed_destination(Locality::SessionLocal)
.await
.map_err(|e| PyConnectionError::new_err(format!("failed to query storage: {e}")))?;
tokio::select! {
_ = tokio::time::sleep(Duration::from_secs(1)) => {
Ok(None)
}
reply = subscriber.recv_async() => {
Ok(reply.ok()
.and_then(|reply| reply.into_result().ok())
.and_then(|sample| {
if sample.kind() == SampleKind::Put {
Some(sample
.payload()
.try_to_string()
.expect("we only use utf8 encoded strings. someone messed up").to_string()
)
} else { None }
})
)
}
}
})
}
pub fn get_many(&self, key: String) -> PyResult<StorageGetter> {
self.session
.get(key)
.wait()
.map_err(|e| PyConnectionError::new_err(format!("failed to query storage: {e}")))
.map(StorageGetter)
}
#[gen_stub(override_return_type(
type_repr="collections.abc.Awaitable[None]",
imports=("collections.abc")
))]
pub fn put<'py>(
&'py self,
py: Python<'py>,
key: String,
data: String,
) -> PyResult<Bound<'py, PyAny>> {
if key.contains('*') {
return Err(PyValueError::new_err(format!(
"{key} is invalid -- Storage.put only supports fixed keys"
)));
}
let session = self.session.clone();
pyo3_async_runtimes::tokio::future_into_py(py, async move {
session
.put(format!("{STORAGE_PREFIX}/{key}"), data)
.await
.map_err(|e| PyConnectionError::new_err(format!("failed to query storage: {e}")))
})
}
#[gen_stub(override_return_type(
type_repr="collections.abc.Awaitable[None]",
imports=("collections.abc")
))]
pub fn delete<'py>(&'py self, py: Python<'py>, key: String) -> PyResult<Bound<'py, PyAny>> {
if key.contains('*') {
return Err(PyValueError::new_err(format!(
"{key} is invalid -- Storage.delete only supports fixed keys"
)));
}
let session = self.session.clone();
pyo3_async_runtimes::tokio::future_into_py(py, async move {
session
.delete(format!("{STORAGE_PREFIX}/{key}"))
.await
.map_err(|e| PyConnectionError::new_err(format!("failed to query storage: {e}")))
})
}
#[gen_stub(override_return_type(
type_repr="collections.abc.Awaitable[dict[str, str]]",
imports=("collections.abc")
))]
pub fn dump<'py>(&'py self, py: Python<'py>, prefix: String) -> PyResult<Bound<'py, PyAny>> {
pyo3_async_runtimes::tokio::future_into_py(py, async move {
Ok(networking::read_raw_memory_storage()
.await
.into_iter()
.filter_map(|(key, value)| {
Some((
key?.as_str().strip_prefix(prefix.as_str())?.to_string(),
value
.payload
.try_to_string()
.expect("we only use utf8 encoded strings. someone messed up")
.to_string(),
))
})
.collect::<HashMap<String, String>>())
})
}
}
#[gen_stub_pyclass]
#[pyclass]
pub struct StorageGetter(FifoChannelHandler<Reply>);
#[gen_stub_pymethods]
#[pymethods]
impl StorageGetter {
#[gen_stub(override_return_type(
type_repr="collections.abc.Awaitable[tuple[str, str] | None]",
imports=("collections.abc")
))]
fn recv<'py>(&'py self, py: Python<'py>) -> PyResult<Bound<'py, PyAny>> {
if self.0.receiver_count() != 1 {
return Err(PyRuntimeError::new_err(
"Tried to call StorageGetter.recv twice concurrently",
));
}
let dupe = self.0.clone();
pyo3_async_runtimes::tokio::future_into_py(py, async move {
let sample = loop {
match dupe.recv_async().await {
Err(_) => return Ok(None),
Ok(reply) => match reply.into_result() {
Err(e) => {
log::warn!("Ignoring reply error: {e}");
continue;
}
Ok(sample) => match sample.kind() {
SampleKind::Put => break sample,
SampleKind::Delete => {
log::warn!(
"Received unexpected DELETE from queryable: {}",
sample.key_expr()
);
continue;
}
},
},
};
};
let key = sample
.key_expr()
.to_string()
.strip_prefix(format!("{STORAGE_PREFIX}/").as_str())
.expect("invalid storage format encountered")
.to_string();
Ok(Some((
key,
sample
.payload()
.try_to_string()
.expect("we only use utf8 encoded strings. someone messed up")
.to_string(),
)))
})
}
}
pub fn storage_submodule(m: &Bound<'_, PyModule>) -> PyResult<()> {
m.add_class::<Storage>()?;
m.add_class::<StorageGetter>()?;
Ok(())
}
+168
View File
@@ -0,0 +1,168 @@
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use zenoh_ext::{
AdvancedPublisherBuilderExt, AdvancedSubscriber, AdvancedSubscriberBuilderExt, CacheConfig,
HistoryConfig, MissDetectionConfig,
};
use zenoh::{handlers::FifoChannelHandler, sample::Sample};
// Adjust these imports to your crate/module paths.
use exo_rs::{
last_value::{LVPublisher, LVSubscriber},
session::SessionHandle,
};
async fn expect_two_values(
sub: &AdvancedSubscriber<FifoChannelHandler<Sample>>,
key_a: &str,
val_a: &str,
key_b: &str,
val_b: &str,
) {
use std::collections::HashMap;
use tokio::time::{Duration, Instant, timeout};
use zenoh::sample::SampleKind;
let deadline = Instant::now() + Duration::from_secs(10);
let mut seen: HashMap<String, String> = HashMap::new();
loop {
if seen.get(key_a).map(String::as_str) == Some(val_a)
&& seen.get(key_b).map(String::as_str) == Some(val_b)
{
return;
}
let remaining = deadline.saturating_duration_since(Instant::now());
assert!(
!remaining.is_zero(),
"timed out waiting for both historical samples; expected {key_a}={val_a}, {key_b}={val_b}; seen = {seen:?}"
);
match timeout(remaining.min(Duration::from_millis(750)), sub.recv_async()).await {
Ok(Ok(sample)) => {
if sample.kind() == SampleKind::Delete {
continue;
}
let key = sample.key_expr().to_string();
let value = sample
.payload()
.try_to_string()
.expect("payload should be UTF-8")
.to_string();
if key == key_a || key == key_b {
eprintln!("received relevant {key} = {value}");
seen.insert(key, value);
} else {
eprintln!("received unrelated {key} = {value}");
}
}
Ok(Err(e)) => panic!("subscriber receive failed: {e}"),
Err(_) => {}
}
}
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn lv_subscriber_receives_last_value_from_multiple_publishers() {
let cfg =
networking::cfg(&format!("{:x}", rand::random::<u128>()), 52414).expect("create config");
let n_session = networking::open(cfg, "exo", 52414, 52413)
.await
.expect("open session");
let session = SessionHandle { session: n_session };
let run_id = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_nanos();
let base = format!("zenoh_advanced_history_test/{run_id}");
let key_a = format!("{base}/a");
let key_b = format!("{base}/b");
let sub_key = format!("{base}/*");
let pub1: LVPublisher = session
.last_value_publisher(key_a.clone())
.expect("declare LV publisher a");
pub1.state.put("aa").await.expect("publish aa");
let pub2: LVPublisher = session
.last_value_publisher(key_b.clone())
.expect("declare LV publisher b");
pub2.state.put("bb").await.expect("publish bb");
// Let publisher detection / cache metadata settle before the late subscriber joins.
tokio::time::sleep(Duration::from_millis(250)).await;
let sub: LVSubscriber = session
.last_value_subscriber(&*sub_key)
.expect("declare LV subscriber");
expect_two_values(&sub.subscriber, &*key_a, "aa", &*key_b, "bb").await
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn wildcard_advanced_subscriber_receives_history_from_both_publishers() {
let cfg =
networking::cfg(&format!("{:x}", rand::random::<u128>()), 52412).expect("create config");
let n_session = networking::open(cfg, "exo", 52412, 52411)
.await
.expect("open session");
let session = n_session.z.clone();
// Unique prefix so the wildcard subscriber cannot accidentally see unrelated traffic.
let run_id = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_nanos();
let base = format!("zenoh_advanced_history_test/{run_id}");
let key_a = format!("{base}/a");
let key_b = format!("{base}/b");
let sub_key = format!("{base}/*");
let pub1 = session
.declare_publisher(key_a.clone())
.advanced()
.publisher_detection()
.sample_miss_detection(MissDetectionConfig::default())
.cache(CacheConfig::default().max_samples(1))
.await
.expect("declare advanced publisher a");
pub1.put("aa").await.expect("publish aa");
let pub2 = session
.declare_publisher(key_b.clone())
.advanced()
.sample_miss_detection(MissDetectionConfig::default())
.publisher_detection()
.cache(CacheConfig::default().max_samples(1))
.await
.expect("declare advanced publisher b");
pub2.put("bb").await.expect("publish bb");
// Give liveliness/cache declarations a brief chance to settle before declaring
// the late-joining advanced subscriber.
tokio::time::sleep(Duration::from_millis(250)).await;
let sub = session
.declare_subscriber(sub_key)
.advanced()
.history(
HistoryConfig::default()
.max_samples(1)
.detect_late_publishers(),
)
.await
.expect("declare advanced subscriber");
expect_two_values(&sub, &*key_a, "aa", &*key_b, "bb").await
}
+51
View File
@@ -0,0 +1,51 @@
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;
}
@@ -1,31 +1,28 @@
import asyncio
import os
import pytest
from _pytest.capture import CaptureFixture
from exo_pyo3_bindings import (
Keypair,
from exo_rs import (
NetworkingHandle,
NoPeersSubscribedToTopicError,
Pidfile,
PyFromSwarm,
FromSwarm,
)
@pytest.mark.asyncio
async def test_sleep_on_multiple_items() -> None:
print("PYTHON: starting handle")
h = NetworkingHandle(Keypair.generate(), [], 0)
h = NetworkingHandle.new(os.urandom(16).hex().lstrip("0"), 52414, 52413)
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")
def test_pidfile(capsys: CaptureFixture[str]):
@@ -39,11 +36,15 @@ async def _await_recv(h: NetworkingHandle):
while True:
event = await h.recv()
match event:
case PyFromSwarm.Connection() as c:
case FromSwarm.Connection() as c:
print(f"PYTHON: connection update: {c}")
case PyFromSwarm.Message() as m:
case FromSwarm.Message() as m:
print(f"PYTHON: message: {m}")
def scoped_lock_file():
a = Pidfile("/tmp/lock.pid", 0o0600)
if __name__ == "__main__":
asyncio.run(test_sleep_on_multiple_items())
+69
View File
@@ -0,0 +1,69 @@
import os
import uuid
import pytest
from exo_rs import SessionHandle
ZENOH_PORT = 52414
DISCOVERY_PORT = 52413
@pytest.fixture(scope="module")
def storage():
node_id = os.urandom(16).hex().rstrip("0")
session_handle, _nh = SessionHandle.new(
node_id,
ZENOH_PORT,
DISCOVERY_PORT,
)
return session_handle.storage_interface()
@pytest.mark.asyncio
async def test_storage_get_missing_key_returns_none(storage):
key = f"tests/storage/{uuid.uuid4().hex}/missing"
value = await storage.get(key)
assert value is None
@pytest.mark.asyncio
async def test_storage_put_then_get_returns_value(storage):
key = f"tests/storage/{uuid.uuid4().hex}/value"
expected = "hello storage"
await storage.put(key, expected)
assert await storage.get(key) == expected
@pytest.mark.asyncio
async def test_storage_put_overwrites_value(storage):
key = f"tests/storage/{uuid.uuid4().hex}/overwrite"
await storage.put(key, "old")
await storage.put(key, "new")
assert await storage.get(key) == "new"
@pytest.mark.asyncio
async def test_storage_put_overwrites_value(storage):
key = f"tests/storage/{uuid.uuid4().hex}/overwrite"
await storage.put(key, "old")
await storage.delete(key)
assert await storage.get(key) == None
@pytest.mark.asyncio
async def test_storage_get_rejects_wildcard_key(storage):
with pytest.raises(ValueError, match="only supports fixed keys"):
await storage.get("tests/storage/*")
@pytest.mark.asyncio
async def test_storage_put_rejects_wildcard_key(storage):
with pytest.raises(ValueError, match="only supports fixed keys"):
await storage.put("tests/storage/*", "value")
+20 -35
View File
@@ -1,42 +1,27 @@
[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.workspace = true
futures-lite.workspace = true
netwatcher = { workspace = true, features = ["tokio"] }
parking_lot.workspace = true
tokio = { workspace = true, features = ["full"] }
zenoh = { workspace = true, features = ["internal", "plugins", "unstable"] }
zenoh-plugin-storage-manager.workspace = true
zenoh-plugin-trait.workspace = true
rand.workspace = true
log.workspace = true
bytemuck = { workspace = true, features = ["derive"] }
socket2.workspace = true
blake3.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"
[dev-dependencies]
env_logger.workspace = true
smol.workspace = true
tracing.workspace = true
-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 => {}
}
}
}
+34
View File
@@ -0,0 +1,34 @@
use networking;
use tracing::{info, warn};
use zenoh::{Result, Wait};
#[tokio::main]
async fn main() -> Result<()> {
zenoh::init_log_from_env_or("info");
info!("Opening session...");
let cfg = networking::cfg(&format!("{:x}", rand::random::<u128>()), 52414)?;
let session = networking::open(cfg, "exo", 52414, 52413).await?;
let _tok = session
.z
.liveliness()
.declare_token(format!("nodes/{}/live", session.z.zid()))
.wait()?;
let subs = session
.z
.liveliness()
.declare_subscriber("**")
.history(true)
.wait()?;
loop {
tokio::select! {
_ = tokio::signal::ctrl_c() => break,
s = subs.recv_async() => {
match s {
Err(e) => warn!("{e}"),
Ok(s) => info!("{}: {}", s.kind(), s.key_expr().to_string().split("/").nth(1).unwrap()),
}
}
}
}
Ok(())
}
+32
View File
@@ -0,0 +1,32 @@
use env_logger::Env;
use log::info;
use networking;
use zenoh::{Result, Wait};
#[tokio::main]
async fn main() -> Result<()> {
env_logger::try_init_from_env(Env::new().default_filter_or("info")).expect("logger failed");
info!("Opening session...");
let cfg = networking::cfg(&format!("{:x}", rand::random::<u128>()), 52414)?;
let session = networking::open(cfg, "exo", 52414, 52413).await?;
let _tok = session
.z
.liveliness()
.declare_token(format!("nodes/{}/live", session.z.zid()))
.wait()?;
session
.z
.liveliness()
.declare_subscriber("**")
.history(true)
.callback(|tok| info!("{}: {}", tok.kind(), tok.key_expr().to_string()))
.background()
.wait()?;
loop {
tokio::select! {
_ = tokio::signal::ctrl_c() => break,
_ = session.z.put("hello", "world") => {},
}
}
Ok(())
}
+48
View File
@@ -0,0 +1,48 @@
use std::{env, time::Duration};
use env_logger::Env;
use log::info;
use networking;
use zenoh::Result;
#[tokio::main]
async fn main() -> Result<()> {
env_logger::try_init_from_env(Env::new().default_filter_or("info")).expect("logger failed");
let n_bytes = env::args()
.nth(1)
.and_then(|it| it.parse::<usize>().ok())
.expect("USAGE: put_string <n> -- pub a string of n bytes into stream/data");
info!("Opening session...");
let cfg = networking::cfg(&format!("{:x}", rand::random::<u128>()), 52414)?;
let session = networking::open(cfg, "exo", 52414, 52413).await?;
let _tok = session
.z
.liveliness()
.declare_token(format!("nodes/{}/live", session.z.zid()))
.await?;
let key_expr = "stream/data";
let payload = "n".repeat(n_bytes);
let pubs = session
.z
.declare_publisher(key_expr)
.congestion_control(zenoh::qos::CongestionControl::Block)
.await?;
let pubs_l = pubs.matching_listener().await?;
if !pubs.matching_status().await?.matching() {
while !pubs_l.recv_async().await?.matching() {}
}
tokio::time::sleep(Duration::from_secs(1)).await;
info!("Putting Data ('{key_expr}': '{}')...", payload.len());
for _ in 0..10 {
let t = tokio::time::Instant::now();
for _ in 0..5000 {
pubs.put(payload.clone()).await?;
}
info!("{:?}", t.elapsed());
tokio::time::sleep(Duration::from_secs(1)).await;
}
tokio::signal::ctrl_c().await?;
Ok(())
}
+74
View File
@@ -0,0 +1,74 @@
use std::time::Duration;
use env_logger::Env;
use log::info;
use networking;
use zenoh::Result;
#[tokio::main]
async fn main() -> Result<()> {
env_logger::try_init_from_env(Env::new().default_filter_or("info")).expect("logger failed");
info!("Opening session...");
let cfg = networking::cfg(&format!("{:x}", rand::random::<u128>()), 52414)?;
let session = networking::open(cfg, "exo", 52414, 52413).await?;
let _tok = session
.z
.liveliness()
.declare_token(format!("nodes/{}/live", session.z.zid()))
.await?;
let _sub = session
.z
.liveliness()
.declare_subscriber("nodes/*/live")
.history(true)
.callback(|tok| {
info!(
"{}: {}",
tok.kind(),
tok.key_expr()
.to_string()
.strip_prefix("nodes/")
.and_then(|it| it.strip_suffix("/live"))
.unwrap()
)
})
.await?;
let watch = async {
for _ in 0..1000 {
tokio::time::sleep(Duration::from_secs(1)).await;
session
.z
.get("**")
.callback(|reply| {
let sample = reply.into_result().expect("no errs");
info!(
"got {} bytes on {}",
sample.payload().len(),
sample.key_expr()
)
})
.await?;
}
Result::<()>::Ok(())
};
let subs = session.z.declare_subscriber("**").await?;
let mut i = 0;
let _a = async {
while let Ok(sample) = subs.recv_async().await {
i += 1;
info!(
"[{i}] received {} bytes on {}",
sample.payload().len(),
sample.key_expr()
)
}
};
tokio::select! {
_ = watch => {},
_ = _a => {},
_ = tokio::signal::ctrl_c() => {},
}
Ok(())
}
+41
View File
@@ -0,0 +1,41 @@
use std::{borrow::Cow, env};
use env_logger::Env;
use log::{info, warn};
use networking;
use zenoh::{Result, Wait};
#[tokio::main]
async fn main() -> Result<()> {
env_logger::try_init_from_env(Env::new().default_filter_or("info")).expect("logger failed");
info!("Opening session...");
let cfg = networking::cfg(&format!("{:x}", rand::random::<u128>()), 52414)?;
let session = networking::open(cfg, "exo", 52414, 52413).await?;
let other_live = session
.z
.liveliness()
.declare_subscriber("**")
.history(true)
.wait()?;
_ = other_live.recv_async().await?;
let other_live = session.z.liveliness().get("**").wait()?;
while let Ok(s) = other_live.recv_async().await {
info!("{s:?}");
}
let query = env::args().nth(1).expect("USAGE: z_get [query]");
info!("Querying {query}");
let subs = session.z.liveliness().get(query).await?;
while let Ok(r) = subs.recv_async().await {
match r.into_result() {
Ok(s) => info!(
"{}: {}",
s.key_expr(),
s.payload()
.try_to_string()
.unwrap_or_else(|_| Cow::Borrowed("-bytes-"))
),
Err(e) => warn!("{e}"),
}
}
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
+316 -367
View File
@@ -1,390 +1,339 @@
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 std::{
io,
net::{Ipv6Addr, SocketAddr, SocketAddrV6},
sync::Arc,
time::Duration,
};
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);
use bytemuck::{Pod, Zeroable};
use log::{debug, trace, warn};
use netwatcher::WatchHandle;
use parking_lot::Mutex;
use tokio::{
net::UdpSocket,
time::{Interval, interval},
};
use zenoh::config::ZenohId;
mod managed {
use libp2p::swarm::NetworkBehaviour;
use libp2p::{identity, mdns, ping};
use std::io;
use std::time::Duration;
const GROUP: Ipv6Addr = Ipv6Addr::new(0xff12, 0, 0, 0, 0, 0, 0xe0a1, 0xde89);
const MAGIC: [u8; 3] = *b"EXO";
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),
)
}
pub struct Discovery {
sock: Arc<UdpSocket>,
ifaces: Arc<Mutex<Vec<SocketAddrV6>>>,
namespace: [u8; 8],
last_nonce: Mutex<[u8; 8]>,
/// the port of the service we are doing discovery for - transmitted to peers
listen_port: u16,
zid: ZenohId,
tick: Interval,
_sync: Mutex<WatchHandle>,
}
/// 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,
},
#[derive(Debug, Clone, Copy)]
pub struct Discovered {
pub zid: ZenohId,
pub addr: SocketAddrV6,
}
/// 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);
impl Discovery {
pub async fn new(
zid: ZenohId,
namespace: [u8; 8],
listen_port: u16,
discovery_port: u16,
) -> io::Result<Self> {
let sock = socket2::Socket::new(
socket2::Domain::IPV6,
socket2::Type::DGRAM,
Some(socket2::Protocol::UDP),
)?;
sock.set_reuse_address(true)?;
#[cfg(unix)]
sock.set_reuse_port(true)?;
sock.bind(&SocketAddrV6::new(Ipv6Addr::UNSPECIFIED, discovery_port, 0, 0).into())?;
sock.set_nonblocking(true)?;
sock.set_multicast_loop_v6(true)?;
let sock = Arc::new(UdpSocket::from_std(sock.into())?);
let ifaces: Arc<Mutex<Vec<SocketAddrV6>>> = Default::default();
let _sync = Mutex::new(
netwatcher::watch_interfaces_with_callback({
let sock = sock.clone();
let ifaces = ifaces.clone();
move |update| {
for (iface_idx, iface) in update.interfaces.iter() {
if iface
.ipv6_ips()
.all(|addr| addr.is_loopback() || addr.is_unspecified())
{
continue;
}
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())
match sock.join_multicast_v6(&GROUP, *iface_idx) {
Ok(()) => ifaces.lock().push(SocketAddrV6::new(
GROUP,
discovery_port,
0,
*iface_idx,
)),
Err(e) if e.kind() != io::ErrorKind::AddrInUse => {
// skip AddrInUse - just means we've already joined the mv6
if let Some(iface) = update.interfaces.get(&iface_idx) {
warn!(
"failed to join multicast v6 for interface {}: {e}",
iface.name
)
}
}
_ => {}
}
}
for iface_idx in update.diff.removed {
ifaces.lock().retain(|addr| addr.scope_id() != iface_idx);
if let Err(e) = sock.leave_multicast_v6(&GROUP, iface_idx) {
if let Some(iface) = update.interfaces.get(&iface_idx) {
warn!(
"failed to leave multicast v6 for interface {}: {e}",
iface.name
)
}
}
}
}
})
// todo: better error handling here
.expect("failed to bind discovery watcher"),
);
Ok(Self {
sock,
namespace,
ifaces,
last_nonce: Mutex::new(rand::random()),
listen_port,
zid,
tick: interval(Duration::from_secs(1)),
_sync,
})
}
// 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)
pub async fn next(&mut self) -> io::Result<Discovered> {
let mut buf = [0u8; Hello::buf_size() + WhatsUp::buf_size() + 1];
loop {
tokio::select! {
_ = self.tick.tick() => {
self.announce().await?;
}
res = self.sock.recv_from(&mut buf) => {
let Ok((bytes_read, addr)) = res else { continue; };
if let Some(discovered) = self.respond(bytes_read, addr, &buf).await? {
return Ok(discovered)
}
}
}
// 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(),
})
}
}
async fn respond(
&self,
bytes_read: usize,
addr: SocketAddr,
buf: &[u8],
) -> io::Result<Option<Discovered>> {
trace!(
"raw recv: {bytes_read} bytes from {addr}: {:02x?}",
&buf[..bytes_read]
);
if bytes_read < size_of::<Header>() {
trace!("dropped: early EOF");
return Ok(None);
}
let header: &Header = bytemuck::from_bytes(&buf[0..size_of::<Header>()]);
if header.magic != MAGIC {
trace!("dropped: wrong magic");
return Ok(None);
}
let Ok(kind) = header.kind.try_into() else {
trace!("dropped: unknown message kind {}", header.kind);
return Ok(None);
};
match kind {
Kind::Hello => {
let total = Hello::buf_size();
if bytes_read != total {
trace!("dropped: hello wrong size");
return Ok(None);
}
let hello: &Hello = bytemuck::from_bytes(&buf[size_of::<Header>()..total]);
if hello.nonce == *self.last_nonce.lock() {
trace!("dropped: local hello nonce");
return Ok(None);
}
if hello.namespace != self.namespace {
trace!("dropped: different namespace");
return Ok(None);
}
// reply
trace!("replying to Hello({:?})", hello.nonce);
let reply = WhatsUp {
nonce: hello.nonce,
zid: self.zid.to_le_bytes(),
port_le: self.listen_port.to_le_bytes(),
}
.alloc();
for i in 1..6 {
if self
.sock
.send_to(&reply, addr)
.await
.inspect_err(|e| debug!("send to {addr} failed: {e}"))
.is_ok_and(|sent| sent == WhatsUp::buf_size())
{
trace!(
"sent {} bytes to {addr} after {} attempt(s)",
WhatsUp::buf_size(),
i
);
break;
}
tokio::time::sleep(Duration::from_millis(300)).await;
}
Ok(None)
}
Kind::WhatsUp => {
let total = WhatsUp::buf_size();
if bytes_read != total {
trace!("dropped: whatsup wrong size");
return Ok(None);
}
let whats_up: &WhatsUp = bytemuck::from_bytes(&buf[size_of::<Header>()..total]);
if whats_up.nonce != *self.last_nonce.lock() {
trace!("dropped: stale nonce");
return Ok(None);
}
let SocketAddr::V6(v6) = addr else {
trace!("dropped: v4 addr used");
return Ok(None);
};
let Ok(zid) = ZenohId::try_from(&whats_up.zid[..]) else {
trace!("dropped: zenoh conversion failed");
return Ok(None);
};
if zid == self.zid {
trace!("dropped: self zenoh id");
return Ok(None);
}
// discovery success!
// the incoming port is our listen port;
// overwrite it with the whats_up port corresponding to the remote zenoh service
let addr = {
let mut x = v6;
x.set_port(u16::from_le_bytes(whats_up.port_le));
x
};
Ok(Some(Discovered { addr, zid }))
}
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));
async fn announce(&self) -> io::Result<()> {
let nonce = rand::random();
*self.last_nonce.lock() = nonce;
let buf = Hello {
nonce,
namespace: self.namespace,
}
.alloc();
// wait for pending events
Poll::Pending
let addrs = self.ifaces.lock().clone();
debug!("announcing Hello({nonce:?}) to {addrs:?}");
// rev so .remove() doesn't break things
for (i, addr) in addrs.into_iter().enumerate().rev() {
match self.sock.send_to(&buf, addr).await {
Ok(bytes) => trace!("sent {bytes} to {addr}"),
Err(e) if e.kind() == io::ErrorKind::HostUnreachable => {
debug!("disabling discovery address {addr}: {e}");
_ = self.ifaces.lock().swap_remove(i);
}
Err(e) => debug!("failed to reach {addr}: {e}"),
}
}
Ok(())
}
}
#[repr(u8)]
#[derive(Debug, Clone, Copy)]
// packet & version
pub enum Kind {
Hello = 0,
WhatsUp = 1,
}
pub struct UnknownKind;
impl TryFrom<u8> for Kind {
type Error = UnknownKind;
fn try_from(value: u8) -> Result<Self, Self::Error> {
match value {
0 => Ok(Self::Hello),
1 => Ok(Self::WhatsUp),
_ => Err(UnknownKind),
}
}
}
pub trait Message: Pod {
const KIND: Kind;
}
// should be part of the Message trait, but const in traits isnt stabilized. this lets alloc :: Self -> [u8; Self::buf_size()]
macro_rules! impl_alloc {
($a:ident) => {
impl $a {
const fn buf_size() -> usize {
size_of::<Header>() + size_of::<Self>()
}
pub fn alloc(self) -> [u8; Self::buf_size()] {
let mut buf = [0u8; Self::buf_size()];
buf[0..size_of::<Header>()].copy_from_slice(bytemuck::bytes_of(&Header {
magic: MAGIC,
kind: Self::KIND as u8,
}));
buf[size_of::<Header>()..Self::buf_size()]
.copy_from_slice(bytemuck::bytes_of(&self));
buf
}
}
};
}
#[repr(C)]
#[derive(Debug, Clone, Copy, Pod, Zeroable)]
pub struct Header {
magic: [u8; 3],
kind: u8,
}
#[repr(C)]
#[derive(Debug, Clone, Copy, Pod, Zeroable)]
pub struct Hello {
pub nonce: [u8; 8],
pub namespace: [u8; 8],
}
impl Message for Hello {
const KIND: Kind = Kind::Hello;
}
impl_alloc!(Hello);
#[repr(C)]
#[derive(Debug, Clone, Copy, Pod, Zeroable)]
pub struct WhatsUp {
pub nonce: [u8; 8],
pub zid: [u8; 16],
pub port_le: [u8; 2],
}
impl Message for WhatsUp {
const KIND: Kind = Kind::WhatsUp;
}
impl_alloc!(WhatsUp);
+120 -33
View File
@@ -1,44 +1,131 @@
//! TODO: crate documentation
//!
//! this is here as a placeholder documentation
//!
//!
use std::sync::Arc;
use tokio::task::JoinHandle;
use zenoh::{Result, Session as ZSession, config::Locator};
use zenoh_plugin_storage_manager::StoragesPlugin;
use zenoh_plugin_trait::PluginsManager;
pub use zenoh::{Config, config::ZenohId};
use crate::{
discovery::Discovery,
liveliness_aggregator::{LivelinessAggregator, spawn_liveliness_aggregator},
};
pub use zenoh_plugin_storage_manager::read_raw_memory_storage;
pub const STORAGE_PREFIX: &str = "storage/mem1";
pub mod discovery;
pub mod liveliness_aggregator;
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 is_valid_zid(identity: &str) -> bool {
let mut iter = identity.chars();
iter.next()
.is_some_and(|c| ('1'..='9').contains(&c) || ('a'..='f').contains(&c))
&& iter.all(|c| ('0'..='9').contains(&c) || ('a'..='f').contains(&c))
&& identity.len() <= 32
}
/// 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;
pub fn cfg(identity: &str, listen_port: u16) -> Result<zenoh::Config> {
assert!(is_valid_zid(identity));
assert!(identity.len() <= 32);
assert!(listen_port != 0, "must used defined listen port");
let mut cfg = zenoh::Config::default();
// todo: cleanup
cfg.insert_json5("id", &format!("\"{identity}\""))?;
cfg.insert_json5("mode", "\"router\"")?;
cfg.insert_json5("listen/endpoints", &format!("[\"tcp/[::]:{listen_port}\"]"))?;
cfg.insert_json5("scouting/multicast/enabled", "false")?;
cfg.insert_json5("scouting/multicast/autoconnect", "[]")?;
cfg.insert_json5("scouting/gossip/multihop", "true")?;
cfg.insert_json5("adminspace/enabled", "true")?;
cfg.insert_json5("transport/link/tx/batch_size", "9216")?;
cfg.insert_json5("transport/link/rx/buffer_size", "16777216")?;
cfg.insert_json5("timestamping/enabled", "true")?;
cfg.insert_json5("plugins/storage_manager/__required__", "true")?;
cfg.insert_json5(
"plugins/storage_manager/storages/mem1/key_expr",
&format!("\"{STORAGE_PREFIX}/**\""),
)?;
cfg.insert_json5(
"plugins/storage_manager/storages/mem1/strip_prefix",
&format!("\"{STORAGE_PREFIX}\""),
)?;
cfg.insert_json5("plugins/storage_manager/storages/mem1/volume", "\"memory\"")?;
cfg.insert_json5(
"plugins/storage_manager/storages/mem1/replication/interval",
"2",
)?;
Ok(cfg)
}
#[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,
}
} else {
return None;
pub async fn open(
cfg: zenoh::Config,
namespace: &str,
listen_port: u16,
discovery_service_port: u16,
) -> Result<Session> {
assert!(listen_port != 0, "must used defined listen port");
let namespace: [u8; 8] = {
blake3::hash(namespace.as_bytes()).as_bytes()[..8]
.try_into()
.expect("8 is equal to 8")
};
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 z = zenoh::session::init(runtime.clone().into()).await?;
runtime.start().await?;
let mut discovery =
Discovery::new(z.zid(), namespace, listen_port, discovery_service_port).await?;
let _jh = Arc::new(AbortOnDrop(tokio::task::spawn(async move {
loop {
let Ok(discovered) = discovery.next().await.inspect_err(|e| {
log::warn!("discovery error {e}");
}) else {
continue;
};
let Some(Protocol::Tcp(port)) = ps.next() else {
return None;
if discovered.zid > runtime.zid() {
log::debug!("not connecting to peer with greater zid");
continue;
}
let Ok(locator) =
Locator::new("tcp", discovered.addr.to_string(), "").inspect_err(|e| {
log::warn!("failed to parse locator from addr: {e}");
})
else {
continue;
};
Some((ip, port))
runtime
.connect_peer(&discovered.zid.into(), &[locator])
.await;
}
})));
let liveliness_aggregator = spawn_liveliness_aggregator(&z)?;
Ok(Session {
z,
liveliness_aggregator,
_jh,
})
}
pub struct AbortOnDrop(pub JoinHandle<()>);
impl Drop for AbortOnDrop {
fn drop(&mut self) {
self.0.abort();
}
}
#[derive(Clone)]
pub struct Session {
pub z: ZSession,
pub liveliness_aggregator: LivelinessAggregator,
_jh: Arc<AbortOnDrop>,
}
@@ -0,0 +1,44 @@
use std::{collections::HashSet, sync::Arc};
use parking_lot::Mutex;
use zenoh::{Result, Session, Wait, sample::SampleKind};
pub fn spawn_liveliness_aggregator(session: &Session) -> Result<LivelinessAggregator> {
let store = Arc::new(Mutex::new(HashSet::default()));
session
.liveliness()
.declare_subscriber("live/*")
.history(true)
.callback({
let store = Arc::clone(&store);
move |sample| {
let Some(nid) = sample
.key_expr()
.to_string()
.strip_prefix("live/")
.map(str::to_owned)
else {
return;
};
let mut mg = store.lock();
match sample.kind() {
SampleKind::Put => mg.insert(nid),
SampleKind::Delete => mg.remove(&nid),
};
}
})
.background()
.wait()?;
Ok(LivelinessAggregator { store })
}
#[derive(Clone)]
pub struct LivelinessAggregator {
// need two arcs as the sub owns an arc to the store.
store: Arc<Mutex<HashSet<String>>>,
}
impl LivelinessAggregator {
pub fn dump(&self) -> HashSet<String> {
self.store.lock().clone()
}
}
+154 -232
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 zenoh::Result;
use zenoh::Session;
use zenoh::handlers::FifoChannelHandler;
use zenoh::liveliness::LivelinessToken;
use zenoh::pubsub::Publisher;
use zenoh::pubsub::Subscriber;
use zenoh::qos::CongestionControl;
use zenoh::sample::Sample;
use zenoh::sample::SampleKind;
/// 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)]
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<()>, Publisher<'static>)>;
pub struct Swarm {
swarm: libp2p::Swarm<Behaviour>,
from_client: mpsc::Receiver<ToSwarm>,
pub session: crate::Session,
pub from_client: mpsc::Receiver<ToSwarm>,
}
impl Swarm {
pub fn into_stream(self) -> Pin<Box<dyn Stream<Item = FromSwarm> + Send>> {
let Swarm {
mut swarm,
session,
mut from_client,
} = self;
let stream = async_stream::stream! {
let mut session = session;
let (mut to_topics, mut from_topics) = mpsc::channel(1024);
let mut topics = Topics::new();
let Ok((_token, discovery)) = register_liveness(&mut session.z).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.z, &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 zid = key_expr.strip_prefix("live/");
yield match token.kind() {
SampleKind::Put => {
log::info!("discovered: {zid:?}");
FromSwarm::Discovered {}
}
SampleKind::Delete => {
log::info!("expired: {zid:?}");
FromSwarm::Expired {}
}
}
}
}
}
}
};
@@ -79,208 +91,118 @@ 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!("live/{}", session.zid()))
.await?;
let sub = session
.liveliness()
.declare_subscriber("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 = match topics.get(&topic) {
Some(topic) => topic.1.put(data).await,
None => {
// TODO: this should be an error but the python FromSwarm is somewhat nondeterministic
Ok(()) //Err("not subscribed to topic!".into()),
}
};
_ = result_sender.send(res);
}
ToSwarm::Unsubscribe {
topic,
result_sender,
} => {
let Some((_, (subscriber, publisher))) = topics.remove_entry(&topic) else {
_ = result_sender.send(false);
return;
};
_ = publisher.undeclare().await;
_ = 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 publisher_res = session
.declare_publisher(format!("topics/{topic}"))
.congestion_control(CongestionControl::Block)
.await;
let publisher = match publisher_res {
Ok(p) => p,
Err(e) => {
_ = result_sender.send(Err(e));
return;
}
};
let subscriber_res = 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;
let subscriber = match subscriber_res {
Ok(s) => s,
Err(e) => {
_ = result_sender.send(Err(e));
return;
}
};
assert!(topics.insert(topic, (subscriber, publisher)).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,
pub async fn create_swarm(
identity: &str,
namespace: &str,
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")
}
discovery_service_port: u16,
) -> Result<Swarm> {
let cfg = crate::cfg(identity, listen_port)?;
let session = crate::open(cfg, namespace, listen_port, discovery_service_port).await?;
Ok(Swarm {
session,
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() {}
}
+99
View File
@@ -0,0 +1,99 @@
use std::time::Duration;
use zenoh::Wait;
fn unique_key(name: &str) -> String {
format!("test/zenoh-runtime-polling/{}/{}", std::process::id(), name)
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn get_and_recv_work_on_tokio_baseline() {
let session = zenoh::open(zenoh::Config::default())
.await
.expect("open session");
let key = unique_key("tokio-baseline");
let reply_key = key.clone();
let _queryable = session
.declare_queryable(key.clone())
.callback(move |query| {
query
.reply(reply_key.clone(), "hello-from-queryable")
.wait()
.expect("reply from queryable");
})
.await
.expect("declare queryable");
let replies = session.get(key).await.expect("get");
let reply = tokio::time::timeout(Duration::from_secs(5), replies.recv_async())
.await
.expect("timed out waiting for reply")
.expect("reply channel closed");
let sample = reply.result().expect("reply result was error");
let payload = sample
.payload()
.try_to_string()
.expect("payload should be utf8");
assert_eq!(payload.as_ref(), "hello-from-queryable");
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn get_and_recv_work_when_polled_by_smol_without_tokio_context() {
let session = zenoh::open(zenoh::Config::default())
.await
.expect("open session under tokio");
let key = unique_key("smol-no-tokio-context");
let reply_key = key.clone();
let _queryable = session
.declare_queryable(key.clone())
.callback(move |query| {
query
.reply(reply_key.clone(), "hello-from-queryable")
.wait()
.expect("reply from queryable");
})
.await
.expect("declare queryable under tokio");
let session_for_smol = session.clone();
let (tx, rx) = std::sync::mpsc::channel();
std::thread::spawn(move || {
// This thread was not entered by Tokio.
// If Zenoh's get/recv path requires an ambient Tokio Handle in the polling thread,
// this is where it should panic, hang, or error.
let result = {
smol::block_on(async move {
let replies = session_for_smol.get(key).await.expect("get under smol");
let reply = replies.recv_async().await.expect("reply channel closed");
let sample = reply.result().expect("reply result was error");
let payload = sample
.payload()
.try_to_string()
.expect("payload should be utf8");
payload.to_string()
})
};
tx.send(result).expect("send test result");
});
let result = rx
.recv_timeout(Duration::from_secs(5))
.expect("smol thread timed out; likely hung waiting for get/reply");
let payload = result;
assert_eq!(payload, "hello-from-queryable");
}
+4 -3
View File
@@ -55,6 +55,7 @@
];
OPENSSL_NO_VENDOR = "1";
MATURIN_NO_INSTALL_RUST = "1";
# Required for pyo3 tests to find libpython
LD_LIBRARY_PATH = lib.makeLibraryPath [ pkgs.python313 ];
@@ -81,11 +82,11 @@
config = {
packages = {
# Python bindings wheel via maturin
exo_pyo3_bindings = craneLib.buildPackage (
exo-rs = craneLib.buildPackage (
commonArgs
// {
inherit cargoArtifacts;
pname = "exo_pyo3_bindings";
pname = "exo-rs";
nativeBuildInputs = commonArgs.nativeBuildInputs ++ [
pkgs.maturin
@@ -95,7 +96,7 @@
maturin build \
--release \
--manylinux off \
--manifest-path rust/exo_pyo3_bindings/Cargo.toml \
--manifest-path rust/exo_rs/Cargo.toml \
--features "pyo3/extension-module,pyo3/experimental-async" \
--interpreter ${pkgs.python313}/bin/python \
--out dist
-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);
}
}
+3
View File
@@ -0,0 +1,3 @@
from importlib.metadata import version
__version__ = version("exo")
+155 -108
View File
@@ -13,6 +13,7 @@ from uuid import uuid4
import anyio
from anyio import BrokenResourceError, ClosedResourceError
from exo_rs import SessionHandle
from fastapi import FastAPI, File, Form, HTTPException, Query, Request, UploadFile
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import FileResponse, JSONResponse, StreamingResponse
@@ -20,8 +21,9 @@ from fastapi.staticfiles import StaticFiles
from hypercorn.asyncio import serve # pyright: ignore[reportUnknownVariableType]
from hypercorn.config import Config
from hypercorn.typing import ASGIFramework
from hypercorn.utils import LifespanTimeoutError
from hypercorn.utils import LifespanTimeoutError, ShutdownError
from loguru import logger
from pydantic import ValidationError
from exo.api.adapters.chat_completions import (
chat_request_to_text_generation,
@@ -50,6 +52,8 @@ from exo.api.keepalive import with_sse_keepalive
from exo.api.types import (
AddCustomModelParams,
AdvancedImageParams,
AwaitInstanceReadyMessage,
AwaitInstanceTimeoutMessage,
BenchChatCompletionRequest,
BenchChatCompletionResponse,
BenchImageGenerationResponse,
@@ -151,14 +155,11 @@ from exo.shared.types.chunks import (
ToolCallChunk,
)
from exo.shared.types.commands import (
AddCustomModelCard,
CancelDownload,
Command,
CreateInstance,
DeleteCustomModelCard,
DeleteDownload,
DeleteInstance,
DeleteInstanceLink,
DownloadCommand,
ForwarderCommand,
ForwarderDownloadCommand,
@@ -166,7 +167,6 @@ from exo.shared.types.commands import (
ImageGeneration,
PlaceInstance,
SendInputChunk,
SetInstanceLink,
StartDownload,
TaskCancelled,
TaskFinished,
@@ -244,6 +244,7 @@ class API:
download_command_sender: Sender[ForwarderDownloadCommand],
# This lets us pause the API if an election is running
election_receiver: Receiver[ElectionMessage],
session_handle: SessionHandle,
) -> None:
self.state = State()
self._event_log = DiskEventLog(_API_EVENT_LOG_DIR)
@@ -256,6 +257,8 @@ class API:
self.last_completed_election: int = 0
self.port = port
self._sent_image_hashes: set[str] = set()
self.aggregator = session_handle.last_value_aggregator("metrics")
self.storage = session_handle.storage_interface()
self.paused: bool = False
self.paused_ev: anyio.Event = anyio.Event()
@@ -344,6 +347,7 @@ class API:
self.app.post("/place_instance")(self.place_instance)
self.app.get("/instance/placement")(self.get_placement)
self.app.get("/instance/previews")(self.get_placement_previews)
self.app.get("/instance/await", response_model=None)(self.await_instance)
self.app.get("/instance/{instance_id}")(self.get_instance)
self.app.delete("/instance/{instance_id}")(self.delete_instance)
self.app.get("/v1/instance-links")(self.list_instance_links)
@@ -406,10 +410,12 @@ class API:
self.app.post("/onboarding")(self.complete_onboarding)
def get_state(self, path: str = ""):
state = self.state.with_aggregator(self.aggregator)
if path == "":
return self.state
return state
try:
x = self.state.model_dump(by_alias=True)
x: Any = state.model_dump(by_alias=True)
for attr in path.split("/"):
if attr != "":
if isinstance(x, dict):
@@ -473,6 +479,7 @@ class API:
model_card = await ModelCard.load(model_id)
try:
state = self.state.with_aggregator(self.aggregator)
placements = get_instance_placements(
PlaceInstance(
model_card=model_card,
@@ -480,13 +487,13 @@ class API:
instance_meta=instance_meta,
min_nodes=min_nodes,
),
node_memory=self.state.node_memory,
node_network=self.state.node_network,
node_backends=self.state.node_backends,
topology=self.state.topology,
current_instances=self.state.instances,
download_status=self.state.downloads,
node_rdma_ctl=self.state.node_rdma_ctl,
node_memory=state.node_memory,
node_network=state.node_network,
node_backends=state.node_backends,
topology=state.topology,
current_instances=state.instances,
download_status=state.downloads,
node_rdma_ctl=state.node_rdma_ctl,
)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
@@ -511,8 +518,9 @@ class API:
seen: set[tuple[ModelId, Sharding, InstanceMeta, int]] = set()
previews: list[PlacementPreview] = []
required_nodes = set(node_ids) if node_ids else None
state = self.state.with_aggregator(self.aggregator)
if len(list(self.state.topology.list_nodes())) == 0:
if len(list(state.topology.list_nodes())) == 0:
return PlacementPreviewResponse(previews=[])
try:
@@ -527,9 +535,7 @@ class API:
instance_combinations.extend(
[
(sharding, instance_meta, i)
for i in range(
1, len(list(self.state.topology.list_nodes())) + 1
)
for i in range(1, len(list(state.topology.list_nodes())) + 1)
]
)
# TODO: PDD
@@ -544,14 +550,14 @@ class API:
instance_meta=instance_meta,
min_nodes=min_nodes,
),
node_memory=self.state.node_memory,
node_network=self.state.node_network,
node_backends=self.state.node_backends,
topology=self.state.topology,
current_instances=self.state.instances,
node_memory=state.node_memory,
node_network=state.node_network,
node_backends=state.node_backends,
topology=state.topology,
current_instances=state.instances,
required_nodes=required_nodes,
download_status=self.state.downloads,
node_rdma_ctl=self.state.node_rdma_ctl,
download_status=state.downloads,
node_rdma_ctl=state.node_rdma_ctl,
)
except ValueError as exc:
if (model_card.model_id, sharding, instance_meta, 0) not in seen:
@@ -590,7 +596,7 @@ class API:
instance = new_instances[0]
shard_assignments = instance.shard_assignments
placement_node_ids = list(shard_assignments.node_to_runner.keys())
placement_node_ids = list(s.node_id for s in shard_assignments.shards)
memory_delta_by_node: dict[str, int] = {}
if placement_node_ids:
@@ -633,6 +639,48 @@ class API:
raise HTTPException(status_code=404, detail="Instance not found")
return self.state.instances[instance_id]
async def await_instance(
self,
model_id: ModelId,
timeout_seconds: float = Query(default=0.0, ge=0.0, le=300.0),
) -> StreamingResponse:
_sleep = 0.1
async def _stream() -> AsyncGenerator[str, None]:
deadline = (
None if timeout_seconds == 0 else anyio.current_time() + timeout_seconds
)
while True:
for instance in self.state.instances.values():
if instance.shard_assignments.model_id == model_id:
payload = AwaitInstanceReadyMessage(instance=instance)
yield f"data: {payload.model_dump_json()}\n\n"
return
if deadline is None:
await anyio.sleep(_sleep)
else:
remaining = deadline - anyio.current_time()
if remaining <= 0:
payload = AwaitInstanceTimeoutMessage(
message=f"No instance found for model {model_id}"
)
yield f"data: {payload.model_dump_json()}\n\n"
return
await anyio.sleep(min(_sleep, remaining))
return StreamingResponse(
with_sse_keepalive(_stream()),
media_type="text/event-stream",
headers={
"Cache-Control": "no-cache",
"Connection": "close",
"X-Accel-Buffering": "no",
},
)
async def delete_instance(self, instance_id: InstanceId) -> DeleteInstanceResponse:
if instance_id not in self.state.instances:
raise HTTPException(status_code=404, detail="Instance not found")
@@ -651,9 +699,16 @@ class API:
return {"disaggregation": ENABLE_DISAGGREGATION}
async def list_instance_links(self) -> list[InstanceLink]:
links: list[InstanceLink] = []
if not ENABLE_DISAGGREGATION:
return []
return list(self.state.instance_links.values())
return links
for _, value in (await self.storage.dump("custom_model_cards/")).items():
try:
link = InstanceLink.model_validate_json(value)
except ValidationError:
continue
links.append(link)
return links
async def create_instance_link(
self, body: InstanceLinkBody
@@ -670,25 +725,22 @@ class API:
async def _set_instance_link(
self, link_id: InstanceLinkId, body: InstanceLinkBody
) -> InstanceLinkResponse:
command = SetInstanceLink(
link_id=link_id,
prefill_instances=list(body.prefill_instances),
decode_instances=list(body.decode_instances),
)
await self._send(command)
return InstanceLinkResponse(
message="Command received.", command_id=command.command_id
await self.storage.put(
f"instance_links/{link_id}",
InstanceLink(
link_id=link_id,
prefill_instances=body.prefill_instances,
decode_instances=body.decode_instances,
).model_dump_json(),
)
return InstanceLinkResponse(message="Command received.")
async def delete_instance_link(
self, link_id: InstanceLinkId
) -> InstanceLinkResponse:
_require_disaggregation_enabled()
command = DeleteInstanceLink(link_id=link_id)
await self._send(command)
return InstanceLinkResponse(
message="Command received.", command_id=command.command_id
)
await self.storage.delete(f"instance_links/{link_id}")
return InstanceLinkResponse(message="Command received.")
async def cancel_command(self, command_id: CommandId) -> CancelCommandResponse:
"""Cancel an active command by closing its stream and notifying workers."""
@@ -746,7 +798,11 @@ class API:
async def _collect_text_generation_with_stats(
self, command_id: CommandId
) -> BenchChatCompletionResponse:
sampler = PowerSampler(get_node_system=lambda: self.state.node_system)
sampler = PowerSampler(
get_node_system=lambda: self.state.with_aggregator(
self.aggregator
).node_system
)
text_parts: list[str] = []
tool_calls: list[ToolCall] = []
model: ModelId | None = None
@@ -761,6 +817,8 @@ class API:
if isinstance(chunk, PrefillProgressChunk):
continue
sampler.mark_prefill_done()
if chunk.finish_reason == "error":
raise HTTPException(
status_code=500,
@@ -871,10 +929,8 @@ class API:
) -> ChatCompletionResponse | StreamingResponse:
"""OpenAI Chat Completions API - adapter."""
task_params = await chat_request_to_text_generation(payload)
resolved_model = await self._resolve_and_validate_text_model(
ModelId(task_params.model)
)
task_params = task_params.model_copy(update={"model": resolved_model})
validated_model = await self._validate_model_has_instance(task_params.model)
task_params = task_params.model_copy(update={"model": validated_model})
command = await self._send_text_generation_with_images(task_params)
@@ -906,10 +962,10 @@ class API:
self, payload: BenchChatCompletionRequest
) -> BenchChatCompletionResponse | StreamingResponse:
task_params = await chat_request_to_text_generation(payload)
resolved_model = await self._resolve_and_validate_text_model(
validated_model = await self._validate_model_has_instance(
ModelId(task_params.model)
)
task_params = task_params.model_copy(update={"model": resolved_model})
task_params = task_params.model_copy(update={"model": validated_model})
task_params = task_params.model_copy(
update={
@@ -939,8 +995,10 @@ class API:
return await self._collect_text_generation_with_stats(command.command_id)
async def _resolve_and_validate_text_model(self, model_id: ModelId) -> ModelId:
"""Validate a text model exists and return the resolved model ID.
async def _validate_model_has_instance(self, model_id: ModelId) -> ModelId:
"""Validate a model has an active instance.
If the model isn't even downloaded, triggers notification to user to download model.
Raises HTTPException 404 if no instance is found for the model.
"""
@@ -948,30 +1006,21 @@ class API:
instance.shard_assignments.model_id == model_id
for instance in self.state.instances.values()
):
await self._trigger_notify_user_to_download_model(model_id)
# Check if model is actually downloaded
model_is_downloaded = any(
isinstance(download, DownloadCompleted)
and download.shard_metadata.model_card.model_id == model_id
for node_downloads in self.state.downloads.values()
for download in node_downloads
)
if not model_is_downloaded:
await self._trigger_notify_user_to_download_model(model_id)
raise HTTPException(
status_code=404,
detail=f"No instance found for model {model_id}",
status_code=404, detail=f"No instance found for model {model_id}"
)
return model_id
async def _validate_image_model(self, model: ModelId) -> ModelId:
"""Validate model exists and return resolved model ID.
Raises HTTPException 404 if no instance is found for the model.
"""
model_card = await ModelCard.load(model)
resolved_model = model_card.model_id
if not any(
instance.shard_assignments.model_id == resolved_model
for instance in self.state.instances.values()
):
await self._trigger_notify_user_to_download_model(resolved_model)
raise HTTPException(
status_code=404, detail=f"No instance found for model {resolved_model}"
)
return resolved_model
def stream_events(self) -> StreamingResponse:
def _generate_json_array(events: Iterable[Event]) -> Iterable[str]:
yield "["
@@ -1024,7 +1073,9 @@ class API:
"""
payload = payload.model_copy(
update={
"model": await self._validate_image_model(ModelId(payload.model)),
"model": await self._validate_model_has_instance(
ModelId(payload.model)
),
"advanced_params": _ensure_seed(payload.advanced_params),
}
)
@@ -1274,7 +1325,11 @@ class API:
num_images: int,
response_format: str,
) -> BenchImageGenerationResponse:
sampler = PowerSampler(get_node_system=lambda: self.state.node_system)
sampler = PowerSampler(
get_node_system=lambda: self.state.with_aggregator(
self.aggregator
).node_system
)
images: list[ImageData] = []
stats: ImageGenerationStats | None = None
async with anyio.create_task_group() as tg:
@@ -1292,7 +1347,9 @@ class API:
) -> BenchImageGenerationResponse:
payload = payload.model_copy(
update={
"model": await self._validate_image_model(ModelId(payload.model)),
"model": await self._validate_model_has_instance(
ModelId(payload.model)
),
"stream": False,
"partial_images": 0,
"advanced_params": _ensure_seed(payload.advanced_params),
@@ -1328,7 +1385,7 @@ class API:
advanced_params: AdvancedImageParams | None,
) -> ImageEdits:
"""Prepare and send an image edits command with chunked image upload."""
resolved_model = await self._validate_image_model(model)
validated_model = await self._validate_model_has_instance(model)
advanced_params = _ensure_seed(advanced_params)
image_content = await image.read()
@@ -1347,7 +1404,7 @@ class API:
image_data="",
total_input_chunks=total_chunks,
prompt=prompt,
model=resolved_model,
model=validated_model,
n=n,
size=size,
response_format=response_format,
@@ -1368,7 +1425,7 @@ class API:
await self._send(
SendInputChunk(
chunk=InputImageChunk(
model=resolved_model,
model=validated_model,
command_id=command.command_id,
data=chunk_data,
chunk_index=chunk_index,
@@ -1492,10 +1549,10 @@ class API:
) -> ClaudeMessagesResponse | StreamingResponse:
"""Claude Messages API - adapter."""
task_params = await claude_request_to_text_generation(payload)
resolved_model = await self._resolve_and_validate_text_model(
validated_model = await self._validate_model_has_instance(
ModelId(task_params.model)
)
task_params = task_params.model_copy(update={"model": resolved_model})
task_params = task_params.model_copy(update={"model": validated_model})
command = await self._send_text_generation_with_images(task_params)
@@ -1530,8 +1587,8 @@ class API:
) -> ResponsesResponse | StreamingResponse:
"""OpenAI Responses API."""
task_params = await responses_request_to_text_generation(payload)
resolved_model = await self._resolve_and_validate_text_model(task_params.model)
task_params = task_params.model_copy(update={"model": resolved_model})
validated_model = await self._validate_model_has_instance(task_params.model)
task_params = task_params.model_copy(update={"model": validated_model})
command = await self._send_text_generation_with_images(task_params)
@@ -1567,20 +1624,18 @@ class API:
return JSONResponse(content="Ollama is running")
async def ollama_chat(
self, request: Request
self, request: OllamaChatRequest
) -> OllamaChatResponse | StreamingResponse:
"""Ollama Chat API — accepts JSON regardless of Content-Type."""
body = await request.body()
payload = OllamaChatRequest.model_validate_json(body)
task_params = ollama_request_to_text_generation(payload)
resolved_model = await self._resolve_and_validate_text_model(
task_params = ollama_request_to_text_generation(request)
resolved_model = await self._validate_model_has_instance(
ModelId(task_params.model)
)
task_params = task_params.model_copy(update={"model": resolved_model})
command = await self._send_text_generation_with_images(task_params)
if payload.stream:
if request.stream:
return StreamingResponse(
generate_ollama_chat_stream(
command.command_id,
@@ -1603,20 +1658,18 @@ class API:
)
async def ollama_generate(
self, request: Request
self, request: OllamaGenerateRequest
) -> OllamaGenerateResponse | StreamingResponse:
"""Ollama Generate API — accepts JSON regardless of Content-Type."""
body = await request.body()
payload = OllamaGenerateRequest.model_validate_json(body)
task_params = ollama_generate_request_to_text_generation(payload)
resolved_model = await self._resolve_and_validate_text_model(
task_params = ollama_generate_request_to_text_generation(request)
resolved_model = await self._validate_model_has_instance(
ModelId(task_params.model)
)
task_params = task_params.model_copy(update={"model": resolved_model})
command = await self._send_text_generation_with_images(task_params)
if payload.stream:
if request.stream:
return StreamingResponse(
generate_ollama_generate_stream(
command.command_id,
@@ -1671,11 +1724,9 @@ class API:
]
)
async def ollama_show(self, request: Request) -> OllamaShowResponse:
async def ollama_show(self, request: OllamaShowRequest) -> OllamaShowResponse:
"""Returns model information in Ollama show format."""
body = await request.body()
payload = OllamaShowRequest.model_validate_json(body)
model_name = payload.name or payload.model
model_name = request.name or request.model
if not model_name:
raise HTTPException(status_code=400, detail="name or model is required")
try:
@@ -1736,7 +1787,7 @@ class API:
"""Calculate total available memory across all nodes in bytes."""
total_available = Memory()
for memory in self.state.node_memory.values():
for memory in self.state.with_aggregator(self.aggregator).node_memory.values():
total_available += memory.ram_available
return total_available
@@ -1785,11 +1836,8 @@ class API:
status_code=400, detail=f"Failed to fetch model: {exc}"
) from exc
await self.command_sender.send(
ForwarderCommand(
origin=self._system_id,
command=AddCustomModelCard(model_card=card),
)
await self.storage.put(
f"custom_model_cards/{card.model_id.normalize()}", card.model_dump_json()
)
# Immediately update the local cache so the subsequent GET /models
@@ -1814,12 +1862,7 @@ class API:
if card is None or not card.is_custom:
raise HTTPException(status_code=404, detail="Custom model card not found")
await self.command_sender.send(
ForwarderCommand(
origin=self._system_id,
command=DeleteCustomModelCard(model_id=model_id),
)
)
await self.storage.delete(f"custom_model_cards/{card.model_id.normalize()}")
return JSONResponse(
{"message": "Model card deleted", "model_id": str(model_id)}
@@ -1914,6 +1957,10 @@ class API:
cfg,
shutdown_trigger=ev.wait,
)
if not ev.is_set():
raise ShutdownError(
"Server exited without shutdown trigger - exiting abnormally"
)
except LifespanTimeoutError as e:
logger.warning(
"Graceful server shutdown timed out, some connections forcebly closed"
+2
View File
@@ -1,5 +1,7 @@
from .api import AddCustomModelParams as AddCustomModelParams
from .api import AdvancedImageParams as AdvancedImageParams
from .api import AwaitInstanceReadyMessage as AwaitInstanceReadyMessage
from .api import AwaitInstanceTimeoutMessage as AwaitInstanceTimeoutMessage
from .api import BenchChatCompletionRequest as BenchChatCompletionRequest
from .api import BenchChatCompletionResponse as BenchChatCompletionResponse
from .api import BenchImageGenerationResponse as BenchImageGenerationResponse
+26 -1
View File
@@ -186,6 +186,12 @@ class NodePowerStats(BaseModel, frozen=True):
node_id: NodeId
samples: int
avg_sys_power: float
# Per-phase breakdown. Populated only when the caller marks a phase
# boundary (e.g. prefill -> generation); None otherwise.
prefill_avg_sys_power: float | None = None
generation_avg_sys_power: float | None = None
prefill_energy_joules: float | None = None
generation_energy_joules: float | None = None
class PowerUsage(BaseModel, frozen=True):
@@ -193,6 +199,16 @@ class PowerUsage(BaseModel, frozen=True):
nodes: list[NodePowerStats]
total_avg_sys_power_watts: float
total_energy_joules: float
# Split between the prefill (prompt-processing) phase and the
# generation/decode phase. Populated only when the caller marks a phase
# boundary; None otherwise. The two phase energies should sum to
# approximately `total_energy_joules` (modulo interpolation rounding).
prefill_seconds: float | None = None
generation_seconds: float | None = None
prefill_energy_joules: float | None = None
generation_energy_joules: float | None = None
prefill_avg_sys_power_watts: float | None = None
generation_avg_sys_power_watts: float | None = None
class BenchChatCompletionResponse(ChatCompletionResponse):
@@ -291,6 +307,16 @@ class DeleteInstanceResponse(BaseModel):
instance_id: InstanceId
class AwaitInstanceReadyMessage(BaseModel):
type: Literal["ready"] = "ready"
instance: Instance
class AwaitInstanceTimeoutMessage(BaseModel):
type: Literal["timeout"] = "timeout"
message: str
class CancelCommandResponse(BaseModel):
message: str
command_id: CommandId
@@ -303,7 +329,6 @@ class InstanceLinkBody(BaseModel):
class InstanceLinkResponse(BaseModel):
message: str
command_id: CommandId
ImageSize = Literal[
+11
View File
@@ -15,6 +15,10 @@ from exo.download.download_utils import (
resolve_existing_model,
)
from exo.download.shard_downloader import ShardDownloader
from exo.routing.event_router import (
EventRouterBrokenResourceError,
EventRouterClosedResourceError,
)
from exo.shared.constants import EXO_DEFAULT_MODELS_DIR, EXO_MODELS_READ_ONLY_DIRS
from exo.shared.models import model_cards
from exo.shared.models.model_cards import ModelId
@@ -139,7 +143,14 @@ class DownloadCoordinator:
async with self._tg as tg:
tg.start_soon(self._command_processor)
tg.start_soon(self._emit_existing_download_progress)
except* (EventRouterBrokenResourceError, EventRouterClosedResourceError):
# Event router has been closed (try-star syntax handles error groups)
pass
finally:
# don't forget to clean up resources
self.download_command_receiver.close()
self.event_sender.close()
self._stopped.set()
async def shutdown(self) -> None:
+119 -43
View File
@@ -8,23 +8,26 @@ from dataclasses import dataclass, field
from typing import Self
import anyio
from anyio.lowlevel import checkpoint as anyio_checkpoint
from daemon import DaemonContext # pyright: ignore[reportMissingTypeStubs]
from exo_rs import Pidfile, PidfileError, SessionHandle
from loguru import logger
from pydantic import PositiveInt
import exo.routing.topics as topics
from exo import __version__
from exo.api.main import API
from exo.download.coordinator import DownloadCoordinator
from exo.download.impl_shard_downloader import exo_shard_downloader
from exo.master.main import Master
from exo.routing.event_router import EventRouter
from exo.routing.router import Router, get_node_id_keypair
from exo.shared.constants import EXO_DEFAULT_MODELS_DIR, EXO_LOG
from exo.routing.router import Router, get_node_zid
from exo.shared.constants import EXO_DEFAULT_MODELS_DIR, EXO_LOG, EXO_PID_FILE
from exo.shared.election import Election, ElectionResult
from exo.shared.logging import logger_cleanup, logger_setup
from exo.shared.types.common import NodeId, SessionId
from exo.utils import STDIO_FDS
from exo.utils.channels import Receiver, channel
from exo.utils.daemon import detach_stdio_to_devnull
from exo.utils.pidfile import PidfileLockError, acquire_exo_pidfile
from exo.utils.pydantic_ext import FrozenModel
from exo.utils.task_group import TaskGroup
from exo.worker.main import Worker
@@ -44,18 +47,21 @@ class Node:
node_id: NodeId
offline: bool
_api_port: int
_sh: SessionHandle
_tg: TaskGroup = field(init=False, default_factory=TaskGroup)
@classmethod
async def create(cls, args: "Args") -> Self:
keypair = get_node_id_keypair()
node_id = NodeId(keypair.to_node_id())
node_id = get_node_zid()
session_id = SessionId(master_node_id=node_id, election_clock=0)
router = Router.create(
keypair,
bootstrap_peers=args.bootstrap_peers,
listen_port=args.libp2p_port,
session_handle, _nh = SessionHandle.new(
node_id,
namespace=args.namespace,
listen_port=args.zenoh_port,
discovery_service_port=args.discovery_port,
)
router = Router(_nh)
await router.register_topic(topics.GLOBAL_EVENTS)
await router.register_topic(topics.LOCAL_EVENTS)
await router.register_topic(topics.COMMANDS)
@@ -94,6 +100,7 @@ class Node:
command_sender=router.sender(topics.COMMANDS),
download_command_sender=router.sender(topics.DOWNLOAD_COMMANDS),
election_receiver=router.receiver(topics.ELECTION_MESSAGES),
session_handle=session_handle,
)
else:
api = None
@@ -105,6 +112,7 @@ class Node:
event_sender=event_router.sender(),
command_sender=router.sender(topics.COMMANDS),
download_command_sender=router.sender(topics.DOWNLOAD_COMMANDS),
session_handle=session_handle,
api_port=args.api_port,
)
else:
@@ -119,6 +127,8 @@ class Node:
local_event_receiver=router.receiver(topics.LOCAL_EVENTS),
command_receiver=router.receiver(topics.COMMANDS),
download_command_sender=router.sender(topics.DOWNLOAD_COMMANDS),
aggregator=session_handle.last_value_aggregator("metrics"),
storage=session_handle.storage_interface(),
)
er_send, er_recv = channel[ElectionResult]()
@@ -147,6 +157,7 @@ class Node:
node_id,
args.offline,
args.api_port,
session_handle,
)
async def run(self):
@@ -190,7 +201,7 @@ class Node:
# - Shut down and re-create the API
if result.is_new_master:
await anyio.sleep(0)
await anyio_checkpoint()
self.event_router.shutdown()
self.event_router = EventRouter(
result.session_id,
@@ -203,7 +214,10 @@ class Node:
result.session_id.master_node_id == self.node_id
and self.master is not None
):
logger.info("Node elected Master")
assert not result.is_new_master, (
"cannot be new master if we remain master"
)
logger.info("Node elected Master - maintaining self")
elif (
result.session_id.master_node_id == self.node_id
and self.master is None
@@ -219,6 +233,8 @@ class Node:
download_command_sender=self.router.sender(
topics.DOWNLOAD_COMMANDS
),
aggregator=self._sh.last_value_aggregator("metrics"),
storage=self._sh.storage_interface(),
)
self._tg.start_soon(self.master.run)
elif (
@@ -258,6 +274,7 @@ class Node:
download_command_sender=self.router.sender(
topics.DOWNLOAD_COMMANDS
),
session_handle=self._sh,
api_port=self._api_port,
)
self._tg.start_soon(self.worker.run)
@@ -270,14 +287,60 @@ class Node:
def main():
# Exit early if no PID file (not compatible with double-for daemonization yet)
try:
pidfile = acquire_exo_pidfile()
except PidfileLockError as exception:
print(exception, file=sys.stderr)
raise SystemExit(1) from exception
# Parse args first => --help or bad args don't require PID-locking
args = Args.parse()
# Exit early if cannot acquire PID file
try:
pidfile = Pidfile(EXO_PID_FILE, 0o0600)
except PidfileError as e:
print(e, file=sys.stderr)
raise SystemExit(1) from e
try:
if args.legacy_daemon:
# keep stdio backed by explicit /dev/null streams. multiprocessing spawn expects
# valid stdio FDs; letting DaemonContext close/reopen them can break runner startup.
for stream in (sys.stdout, sys.stderr, sys.__stdout__, sys.__stderr__):
if stream is not None:
stream.flush()
stdin = open(os.devnull, "r") # noqa: SIM115
stdout = open(os.devnull, "w") # noqa: SIM115
stderr = open(os.devnull, "w") # noqa: SIM115
with DaemonContext(
detach_process=True,
files_preserve=[pidfile.as_raw_fd()],
stdin=stdin,
stdout=stdout,
stderr=stderr,
):
# cleanup loose file descriptors (as long as they aren't stdio)
for f in (
f for f in (stdin, stdout, stderr) if f.fileno() not in STDIO_FDS
):
f.close()
# 1) if daemonizing => fork then write PID
try:
pidfile.write()
except PidfileError as e:
print(e, file=sys.stderr)
raise SystemExit(1) from e
main_inner(args)
else:
# 2) otherwise => just write PID
try:
pidfile.write()
except PidfileError as e:
print(e, file=sys.stderr)
raise SystemExit(1) from e
main_inner(args)
finally:
pidfile.close()
def main_inner(args: "Args"):
soft, hard = resource.getrlimit(resource.RLIMIT_NOFILE)
target = min(max(soft, 65535), hard)
resource.setrlimit(resource.RLIMIT_NOFILE, (target, hard))
@@ -286,20 +349,19 @@ def main():
# TODO: Refactor the current verbosity system
logger_setup(EXO_LOG, args.verbosity)
if args.no_stdio:
detach_stdio_to_devnull()
logger.info("Detached stdio to /dev/null")
logger.info(f"{'=' * 40}")
logger.info(f"Starting EXO | pid={os.getpid()}")
logger.info(f"{'=' * 40}")
logger.info(f"EXO_LIBP2P_NAMESPACE: {os.getenv('EXO_LIBP2P_NAMESPACE')}")
logger.info(f"pid = {os.getpid()}")
if os.getenv("EXO_LIBP2P_NAMESPACE"):
raise ValueError(
"EXO_LIBP2P_NAMESPACE has been removed - use EXO_ZENOH_NAMESPACE instead"
)
logger.info(f"EXO_ZENOH_NAMESPACE: {os.getenv('EXO_ZENOH_NAMESPACE')}")
if args.offline:
logger.info("Running in OFFLINE mode — no internet checks, local models only")
if args.bootstrap_peers:
logger.info(f"Bootstrap peers: {args.bootstrap_peers}")
raise ValueError("Bootstrap peers has been temporarily removed")
if args.no_batch:
os.environ["EXO_NO_BATCH"] = "1"
@@ -324,23 +386,23 @@ def main():
finally:
logger.info("EXO Shutdown complete")
logger_cleanup()
del pidfile
class Args(FrozenModel):
verbosity: int = 0
force_master: bool = False
spawn_api: bool = False
api_port: PositiveInt = 52415
tb_only: bool = False
verbosity: int
force_master: bool
spawn_api: bool
api_port: PositiveInt
no_worker: bool = False
no_downloads: bool = False
offline: bool = os.getenv("EXO_OFFLINE", "false").lower() == "true"
no_batch: bool = False
offline: bool
no_batch: bool
fast_synch: bool | None = None # None = auto, True = force on, False = force off
no_stdio: bool = False
legacy_daemon: bool
bootstrap_peers: list[str] = []
libp2p_port: int
namespace: str
zenoh_port: int
discovery_port: int
@classmethod
def parse(cls) -> Self:
@@ -399,9 +461,9 @@ class Args(FrozenModel):
help="Disable continuous batching, use sequential generation",
)
parser.add_argument(
"--no-stdio",
"--legacy-daemon",
action="store_true",
help="Detach stdin/stdout/stderr to /dev/null after logging is configured",
help="Run as a legacy SysV-style background daemon using double-fork daemonization",
)
parser.add_argument(
"--bootstrap-peers",
@@ -413,11 +475,25 @@ class Args(FrozenModel):
help="Comma-separated libp2p multiaddrs to dial on startup (env: EXO_BOOTSTRAP_PEERS)",
)
parser.add_argument(
"--libp2p-port",
"--namespace",
type=str,
default=__version__,
dest="namespace",
help="Discovery namespace, nodes with different namespaces will not connect.",
)
parser.add_argument(
"--zenoh-port",
type=int,
default=0,
dest="libp2p_port",
help="Fixed TCP port for libp2p to listen on (0 = OS-assigned).",
default=52414,
dest="zenoh_port",
help="Fixed port for zenoh to listen on.",
)
parser.add_argument(
"--discovery-port",
type=int,
default=52413,
dest="discovery_port",
help="Fixed UDP port for the discovery service.",
)
fast_synch_group = parser.add_mutually_exclusive_group()
fast_synch_group.add_argument(
+71 -68
View File
@@ -1,7 +1,9 @@
from datetime import datetime, timedelta, timezone
import anyio
from exo_rs import LVAggregator, Storage
from loguru import logger
from pydantic import ValidationError
from exo.master.placement import (
add_instance_to_placements,
@@ -11,14 +13,15 @@ from exo.master.placement import (
place_instance,
)
from exo.master.placement_utils import find_ip_prioritised
from exo.routing.event_router import (
EventRouterBrokenResourceError,
EventRouterClosedResourceError,
)
from exo.shared.apply import apply
from exo.shared.constants import EXO_EVENT_LOG_DIR, EXO_TRACING_ENABLED
from exo.shared.types.commands import (
AddCustomModelCard,
CreateInstance,
DeleteCustomModelCard,
DeleteInstance,
DeleteInstanceLink,
ForwarderCommand,
ForwarderDownloadCommand,
ImageEdits,
@@ -26,7 +29,6 @@ from exo.shared.types.commands import (
PlaceInstance,
RequestEventLog,
SendInputChunk,
SetInstanceLink,
TaskCancelled,
TaskFinished,
TestCommand,
@@ -34,15 +36,11 @@ from exo.shared.types.commands import (
)
from exo.shared.types.common import CommandId, NodeId, SessionId, SystemId
from exo.shared.types.events import (
CustomModelCardAdded,
CustomModelCardDeleted,
Event,
GlobalForwarderEvent,
IndexedEvent,
InputChunkReceived,
InstanceDeleted,
InstanceLinkCreated,
InstanceLinkDeleted,
LocalForwarderEvent,
NodeGatheredInfo,
NodeTimedOut,
@@ -75,16 +73,18 @@ from exo.utils.event_buffer import MultiSourceBuffer
from exo.utils.task_group import TaskGroup
def _prefill_endpoint_for(state: State, decode_instance_id: InstanceId) -> str | None:
def _prefill_endpoint_for(
state: State, instance_links: list[InstanceLink], decode_instance_id: InstanceId
) -> str | None:
decode = state.instances.get(decode_instance_id)
if decode is None:
return None
decode_node = next(iter(decode.shard_assignments.node_to_runner.keys()), None)
if decode_node is None:
return None
decode_node = decode.shard_assignments.shards[
decode.shard_assignments.primary_output_node
].node_id
sources: set[InstanceId] = set()
for link in state.instance_links.values():
for link in instance_links:
if decode_instance_id in link.decode_instances:
sources.update(link.prefill_instances)
sources.discard(decode_instance_id)
@@ -102,7 +102,7 @@ def _prefill_endpoint_for(state: State, decode_instance_id: InstanceId) -> str |
instance = state.instances.get(src_id)
if instance is None:
continue
for node_id, runner_id in instance.shard_assignments.node_to_runner.items():
for node_id, runner_id, _ in instance.shard_assignments.shards:
port = state.prefill_server_ports.get(runner_id)
if port is None:
continue
@@ -126,6 +126,8 @@ class Master:
local_event_receiver: Receiver[LocalForwarderEvent],
global_event_sender: Sender[GlobalForwarderEvent],
download_command_sender: Sender[ForwarderDownloadCommand],
aggregator: LVAggregator,
storage: Storage,
):
self.node_id = node_id
self.session_id = session_id
@@ -141,7 +143,9 @@ class Master:
self._multi_buffer = MultiSourceBuffer[SystemId, Event]()
self._event_log = DiskEventLog(EXO_EVENT_LOG_DIR / "master")
self._pending_traces: dict[TaskId, dict[int, list[TraceEventData]]] = {}
self._expected_ranks: dict[TaskId, set[int]] = {}
self._world_sizes: dict[TaskId, int] = {}
self.aggregator: LVAggregator = aggregator
self.storage: Storage = storage
async def run(self):
logger.info("Starting Master")
@@ -151,6 +155,9 @@ class Master:
tg.start_soon(self._event_processor)
tg.start_soon(self._command_processor)
tg.start_soon(self._plan)
except* (EventRouterBrokenResourceError, EventRouterClosedResourceError):
# Event router has been closed (try-star syntax handles error groups)
pass
finally:
self._event_log.close()
self.global_event_sender.close()
@@ -174,18 +181,32 @@ class Master:
case TestCommand():
pass
case TextGeneration():
# set-difference => prefill-only nodes
instance_links: list[InstanceLink] = []
prefill_only: set[InstanceId] = set()
for link in self.state.instance_links.values():
for _, link in (
await self.storage.dump("instance_links/")
).items():
try:
instance_links.append(
InstanceLink.model_validate_json(link)
)
except ValidationError:
continue
for link in instance_links:
prefill_only.update(link.prefill_instances)
for link in self.state.instance_links.values():
for link in instance_links:
prefill_only.difference_update(link.decode_instances)
for instance in self.state.instances.values():
# NON-prefill-only instances matching the model ID
if (
instance.shard_assignments.model_id
== command.task_params.model
and instance.instance_id not in prefill_only
):
# count in-flight tasks of that instance
in_flight = {TaskStatus.Pending, TaskStatus.Running}
task_count = sum(
1
@@ -197,6 +218,7 @@ class Master:
task_count
)
# there are no NON-prefill-only instances matching this model ID
if not instance_task_counts:
raise ValueError(
f"No instance found for model {command.task_params.model}"
@@ -214,7 +236,9 @@ class Master:
params = command.task_params.model_copy(
update={
"prefill_endpoint": _prefill_endpoint_for(
self.state, decode_instance_id
self.state.with_aggregator(self.aggregator),
instance_links,
decode_instance_id,
),
}
)
@@ -282,11 +306,9 @@ class Master:
selected_instance_id
)
if selected_instance:
ranks = set(
shard.device_rank
for shard in selected_instance.shard_assignments.runner_to_shard.values()
self._world_sizes[task_id] = len(
selected_instance.shard_assignments.shards
)
self._expected_ranks[task_id] = ranks
case ImageEdits():
for instance in self.state.instances.values():
if (
@@ -338,11 +360,9 @@ class Master:
selected_instance_id
)
if selected_instance:
ranks = set(
shard.device_rank
for shard in selected_instance.shard_assignments.runner_to_shard.values()
self._world_sizes[task_id] = len(
selected_instance.shard_assignments.shards
)
self._expected_ranks[task_id] = ranks
case DeleteInstance():
placement = delete_instance(command, self.state.instances)
transition_events = get_transition_events(
@@ -358,15 +378,16 @@ class Master:
)
generated_events.extend(transition_events)
case PlaceInstance():
state = self.state.with_aggregator(self.aggregator)
placement = place_instance(
command,
self.state.topology,
self.state.instances,
self.state.node_memory,
self.state.node_network,
self.state.node_backends,
download_status=self.state.downloads,
node_rdma_ctl=self.state.node_rdma_ctl,
state.topology,
state.instances,
state.node_memory,
state.node_network,
state.node_backends,
download_status=state.downloads,
node_rdma_ctl=state.node_rdma_ctl,
)
transition_events = get_transition_events(
self.state.instances, placement, self.state.tasks
@@ -417,29 +438,6 @@ class Master:
f"Finished command {command.finished_command_id} finished"
)
case AddCustomModelCard():
generated_events.append(
CustomModelCardAdded(model_card=command.model_card)
)
case DeleteCustomModelCard():
generated_events.append(
CustomModelCardDeleted(model_id=command.model_id)
)
case SetInstanceLink():
link = InstanceLink(
link_id=command.link_id,
prefill_instances=list(
dict.fromkeys(command.prefill_instances)
),
decode_instances=list(
dict.fromkeys(command.decode_instances)
),
)
generated_events.append(InstanceLinkCreated(link=link))
case DeleteInstanceLink():
generated_events.append(
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
@@ -448,19 +446,23 @@ class Master:
self._event_log.read_range(command.since_idx, end),
start=command.since_idx,
):
await self._send_event(IndexedEvent(idx=i, event=event))
await self._send_indexed_event(
IndexedEvent(idx=i, event=event)
)
for event in generated_events:
await self.event_sender.send(event)
except ValueError as e:
except Exception as e:
logger.opt(exception=e).warning("Error in command processor")
# These plan loops are the cracks showing in our event sourcing architecture - more things could be commands
async def _plan(self) -> None:
while True:
# kill broken instances
connected_node_ids = set(self.state.topology.list_nodes())
connected_node_ids = set(
self.state.with_aggregator(self.aggregator).topology.list_nodes()
)
for instance_id, instance in self.state.instances.items():
for node_id in instance.shard_assignments.node_to_runner:
for node_id, _, _ in instance.shard_assignments.shards:
if node_id not in connected_node_ids:
await self.event_sender.send(
InstanceDeleted(instance_id=instance_id)
@@ -468,7 +470,9 @@ class Master:
break
# time out dead nodes
for node_id, time in self.state.last_seen.items():
for node_id, time in self.state.with_aggregator(
self.aggregator
).last_seen.items():
now = datetime.now(tz=timezone.utc)
if now - time > timedelta(seconds=30):
logger.info(f"Manually removing node {node_id} due to inactivity")
@@ -506,10 +510,10 @@ class Master:
self.state = apply(self.state, indexed)
self._event_log.append(event)
await self._send_event(indexed)
await self._send_indexed_event(indexed)
# This function is re-entrant, take care!
async def _send_event(self, event: IndexedEvent):
async def _send_indexed_event(self, event: IndexedEvent):
# Convenience method since this line is ugly
await self.global_event_sender.send(
GlobalForwarderEvent(
@@ -527,9 +531,8 @@ class Master:
self._pending_traces[task_id][event.rank] = event.traces
if (
task_id in self._expected_ranks
and set(self._pending_traces[task_id].keys())
>= self._expected_ranks[task_id]
task_id in self._world_sizes
and len(self._pending_traces[task_id]) >= self._world_sizes[task_id]
):
await self._merge_and_save_traces(task_id)
@@ -543,5 +546,5 @@ class Master:
)
del self._pending_traces[task_id]
if task_id in self._expected_ranks:
del self._expected_ranks[task_id]
if task_id in self._world_sizes:
del self._world_sizes[task_id]
+3 -16
View File
@@ -262,20 +262,7 @@ def place_instance(
match command.instance_meta:
case InstanceMeta.MlxJaccl:
# TODO(evan): shard assignments should contain information about ranks, this is ugly
def get_device_rank(node_id: NodeId) -> int:
runner_id = shard_assignments.node_to_runner[node_id]
shard_metadata = shard_assignments.runner_to_shard.get(runner_id)
assert shard_metadata is not None
return shard_metadata.device_rank
zero_node_ids = [
node_id
for node_id in selected_cycle.node_ids
if get_device_rank(node_id) == 0
]
assert len(zero_node_ids) == 1
coordinator_node_id = zero_node_ids[0]
coordinator_node_id = shard_assignments.shards[0].node_id
mlx_jaccl_devices = get_mlx_jaccl_devices_matrix(
[node_id for node_id in selected_cycle],
@@ -376,10 +363,10 @@ def cancel_unnecessary_downloads(
active_models = set(
(
node_id,
instance.shard_assignments.runner_to_shard[runner_id].model_card.model_id,
instance.shard_assignments.model_id,
)
for instance in instances.values()
for node_id, runner_id in instance.shard_assignments.node_to_runner.items()
for node_id, _, _ in instance.shard_assignments.shards
)
for pair in currently_downloading:
if pair not in active_models:
+20 -34
View File
@@ -8,12 +8,11 @@ from exo.shared.types.common import Host, NodeId
from exo.shared.types.memory import Memory
from exo.shared.types.profiling import MemoryUsage, NodeNetworkInfo
from exo.shared.types.topology import Cycle, RDMAConnection, SocketConnection
from exo.shared.types.worker.runners import RunnerId, ShardAssignments
from exo.shared.types.worker.runners import RunnerId, ShardAssignments, ShardWithId
from exo.shared.types.worker.shards import (
CfgShardMetadata,
PipelineShardMetadata,
Sharding,
ShardMetadata,
TensorShardMetadata,
)
@@ -152,27 +151,27 @@ def _get_shard_assignments_for_cfg_parallel(
_validate_cycle(cycle)
world_size = len(cycle)
cfg_world_size = 2
pipeline_world_size = world_size // cfg_world_size
pipeline_world_size = world_size // 2
# Allocate layers for one pipeline group (both groups run the same layers)
pipeline_node_ids = cycle.node_ids[:pipeline_world_size]
pipeline_memory = _compute_total_memory(pipeline_node_ids, node_memory)
# nb: only validates the forward path...
layer_allocations = _allocate_and_validate_layers(
pipeline_node_ids, node_memory, pipeline_memory, model_card
)
# Ring topology: group 0 ascending [0,1,2,...], group 1 descending [...,2,1,0]
# This places both last stages as neighbors for CFG exchange.
position_to_cfg_pipeline = [(0, r) for r in range(pipeline_world_size)] + [
(1, r) for r in reversed(range(pipeline_world_size))
]
position_to_cfg_pipeline = list(range(pipeline_world_size)) + list(
reversed(range(pipeline_world_size))
)
runner_to_shard: dict[RunnerId, ShardMetadata] = {}
node_to_runner: dict[NodeId, RunnerId] = {}
shards: list[ShardWithId] = []
for device_rank, node_id in enumerate(cycle.node_ids):
cfg_rank, pipeline_rank = position_to_cfg_pipeline[device_rank]
pipeline_rank = position_to_cfg_pipeline[device_rank]
layers_before = sum(layer_allocations[:pipeline_rank])
node_layers = layer_allocations[pipeline_rank]
@@ -183,20 +182,15 @@ def _get_shard_assignments_for_cfg_parallel(
start_layer=layers_before,
end_layer=layers_before + node_layers,
n_layers=model_card.n_layers,
cfg_rank=cfg_rank,
cfg_world_size=cfg_world_size,
pipeline_rank=pipeline_rank,
pipeline_world_size=pipeline_world_size,
)
runner_id = RunnerId()
runner_to_shard[runner_id] = shard
node_to_runner[node_id] = runner_id
shards.append(ShardWithId(node_id, runner_id, shard))
return ShardAssignments(
model_id=model_card.model_id,
runner_to_shard=runner_to_shard,
node_to_runner=node_to_runner,
shards=shards,
primary_output_node=pipeline_world_size - 1,
)
@@ -208,13 +202,13 @@ def _get_shard_assignments_for_pure_pipeline(
"""Create shard assignments for pure pipeline execution."""
_validate_cycle(cycle)
total_memory = _compute_total_memory(cycle.node_ids, node_memory)
world_size = len(cycle)
layer_allocations = _allocate_and_validate_layers(
cycle.node_ids, node_memory, total_memory, model_card
)
runner_to_shard: dict[RunnerId, ShardMetadata] = {}
node_to_runner: dict[NodeId, RunnerId] = {}
shards: list[ShardWithId] = []
for pipeline_rank, node_id in enumerate(cycle.node_ids):
layers_before = sum(layer_allocations[:pipeline_rank])
@@ -223,20 +217,17 @@ def _get_shard_assignments_for_pure_pipeline(
shard = PipelineShardMetadata(
model_card=model_card,
device_rank=pipeline_rank,
world_size=len(cycle),
world_size=world_size,
start_layer=layers_before,
end_layer=layers_before + node_layers,
n_layers=model_card.n_layers,
)
runner_id = RunnerId()
runner_to_shard[runner_id] = shard
node_to_runner[node_id] = runner_id
shards.append(ShardWithId(node_id, runner_id, shard))
return ShardAssignments(
model_id=model_card.model_id,
runner_to_shard=runner_to_shard,
node_to_runner=node_to_runner,
model_id=model_card.model_id, shards=shards, primary_output_node=world_size - 1
)
@@ -246,8 +237,7 @@ def get_shard_assignments_for_tensor_parallel(
):
total_layers = model_card.n_layers
world_size = len(cycle)
runner_to_shard: dict[RunnerId, ShardMetadata] = {}
node_to_runner: dict[NodeId, RunnerId] = {}
shards: list[ShardWithId] = []
for i, node_id in enumerate(cycle):
shard = TensorShardMetadata(
@@ -260,14 +250,10 @@ def get_shard_assignments_for_tensor_parallel(
)
runner_id = RunnerId()
runner_to_shard[runner_id] = shard
node_to_runner[node_id] = runner_id
shards.append(ShardWithId(node_id, runner_id, shard))
shard_assignments = ShardAssignments(
model_id=model_card.model_id,
runner_to_shard=runner_to_shard,
node_to_runner=node_to_runner,
model_id=model_card.model_id, shards=shards, primary_output_node=world_size - 1
)
return shard_assignments
+44 -20
View File
@@ -6,7 +6,6 @@ import pytest
from loguru import logger
from exo.master.main import Master
from exo.routing.router import get_node_id_keypair
from exo.shared.models.model_cards import ModelCard, ModelTask
from exo.shared.types.backends import Backend
from exo.shared.types.commands import (
@@ -42,15 +41,34 @@ from exo.shared.types.worker.instances import (
MlxRingInstance,
ShardAssignments,
)
from exo.shared.types.worker.runners import ShardWithId
from exo.shared.types.worker.shards import PipelineShardMetadata, Sharding
from exo.utils.channels import channel
from exo.utils.info_gatherer.info_gatherer import NodeBackends
class MockAggregator:
def dump(self) -> dict[str, str]:
return {}
class MockStorage:
async def get(self, _: str) -> None:
return None
async def put(self, _1: str, _2: str) -> None:
return None
async def delete(self, _: str) -> None:
return None
async def dump(self, _: str) -> dict[str, str]:
return {}
@pytest.mark.asyncio
async def test_master():
keypair = get_node_id_keypair()
node_id = NodeId(keypair.to_node_id())
node_id = NodeId("yoooo")
session_id = SessionId(master_node_id=node_id, election_clock=0)
ge_sender, global_event_receiver = channel[GlobalForwarderEvent]()
@@ -95,6 +113,8 @@ async def test_master():
local_event_receiver=le_receiver,
command_receiver=co_receiver,
download_command_sender=fcds,
aggregator=MockAggregator(), # pyright: ignore[reportArgumentType]
storage=MockStorage(), # pyright: ignore[reportArgumentType]
)
logger.info("run the master")
async with anyio.create_task_group() as tg:
@@ -206,29 +226,33 @@ async def test_master():
assert isinstance(events[2].event, InstanceCreated)
created_instance = events[2].event.instance
assert isinstance(created_instance, MlxRingInstance)
runner_id = list(created_instance.shard_assignments.runner_to_shard.keys())[0]
runner_id = created_instance.shard_assignments.shards[0].runner_id
# Validate the shard assignments
expected_shard_assignments = ShardAssignments(
model_id=ModelId("llama-3.2-1b"),
runner_to_shard={
(runner_id): PipelineShardMetadata(
start_layer=0,
end_layer=16,
n_layers=16,
model_card=ModelCard(
model_id=ModelId("llama-3.2-1b"),
shards=[
ShardWithId(
node_id,
runner_id,
PipelineShardMetadata(
start_layer=0,
end_layer=16,
n_layers=16,
storage_size=Memory.from_bytes(678948),
hidden_size=7168,
supports_tensor=True,
tasks=[ModelTask.TextGeneration],
backends=[Backend.MlxMetal],
model_card=ModelCard(
model_id=ModelId("llama-3.2-1b"),
n_layers=16,
storage_size=Memory.from_bytes(678948),
hidden_size=7168,
supports_tensor=True,
tasks=[ModelTask.TextGeneration],
backends=[Backend.MlxMetal],
),
device_rank=0,
world_size=1,
),
device_rank=0,
world_size=1,
)
},
node_to_runner={node_id: runner_id},
],
primary_output_node=0,
)
assert created_instance.shard_assignments == expected_shard_assignments
# For single-node, hosts_by_node should have one entry with self-binding
+41 -31
View File
@@ -49,16 +49,36 @@ from exo.shared.types.worker.instances import (
MlxJacclInstance,
MlxRingInstance,
)
from exo.shared.types.worker.runners import ShardAssignments
from exo.shared.types.worker.runners import RunnerId, ShardAssignments, ShardWithId
from exo.shared.types.worker.shards import PipelineShardMetadata, Sharding
class MockShard:
def is_primary_output(self) -> bool:
return True
@pytest.fixture
def instance() -> Instance:
def instance(model_card: ModelCard) -> Instance:
return MlxRingInstance(
instance_id=InstanceId(),
shard_assignments=ShardAssignments(
model_id=ModelId("test-model"), runner_to_shard={}, node_to_runner={}
model_id=ModelId("test-model"),
shards=[
ShardWithId(
NodeId(),
RunnerId(),
PipelineShardMetadata(
model_card=model_card,
device_rank=0,
world_size=1,
start_layer=0,
end_layer=model_card.n_layers,
n_layers=model_card.n_layers,
),
)
],
primary_output_node=0,
),
hosts_by_node={},
ephemeral_port=50000,
@@ -123,6 +143,11 @@ def test_get_instance_placements_create_instance(
node_id_a = NodeId()
node_id_b = NodeId()
node_id_c = NodeId()
node_to_layers = {
node_id_a: expected_layers[0],
node_id_b: expected_layers[1],
node_id_c: expected_layers[2],
}
# fully connected (directed) between the 3 nodes
conn_a_b = Connection(
@@ -175,22 +200,11 @@ def test_get_instance_placements_create_instance(
instance = placements[instance_id]
assert instance.shard_assignments.model_id == model_card.model_id
runner_id_a = instance.shard_assignments.node_to_runner[node_id_a]
runner_id_b = instance.shard_assignments.node_to_runner[node_id_b]
runner_id_c = instance.shard_assignments.node_to_runner[node_id_c]
for nid, _, shard in (shards := instance.shard_assignments.shards):
assert shard.end_layer - shard.start_layer == node_to_layers[nid]
shard_a = instance.shard_assignments.runner_to_shard[runner_id_a]
shard_b = instance.shard_assignments.runner_to_shard[runner_id_b]
shard_c = instance.shard_assignments.runner_to_shard[runner_id_c]
assert shard_a.end_layer - shard_a.start_layer == expected_layers[0]
assert shard_b.end_layer - shard_b.start_layer == expected_layers[1]
assert shard_c.end_layer - shard_c.start_layer == expected_layers[2]
shards = [shard_a, shard_b, shard_c]
shards_sorted = sorted(shards, key=lambda s: s.start_layer)
assert shards_sorted[0].start_layer == 0
assert shards_sorted[-1].end_layer == total_layers
assert shards[0].shard.start_layer == 0
assert shards[-1].shard.end_layer == total_layers
def test_get_instance_placements_one_node_exact_fit() -> None:
@@ -218,9 +232,7 @@ def test_get_instance_placements_one_node_exact_fit() -> None:
instance_id = list(placements.keys())[0]
instance = placements[instance_id]
assert instance.shard_assignments.model_id == "test-model"
assert len(instance.shard_assignments.node_to_runner) == 1
assert len(instance.shard_assignments.runner_to_shard) == 1
assert len(instance.shard_assignments.runner_to_shard) == 1
assert len(instance.shard_assignments.shards) == 1
def test_get_instance_placements_one_node_fits_with_extra_memory() -> None:
@@ -248,9 +260,7 @@ def test_get_instance_placements_one_node_fits_with_extra_memory() -> None:
instance_id = list(placements.keys())[0]
instance = placements[instance_id]
assert instance.shard_assignments.model_id == "test-model"
assert len(instance.shard_assignments.node_to_runner) == 1
assert len(instance.shard_assignments.runner_to_shard) == 1
assert len(instance.shard_assignments.runner_to_shard) == 1
assert len(instance.shard_assignments.shards) == 1
def test_get_instance_placements_one_node_not_fit() -> None:
@@ -381,7 +391,7 @@ def test_placement_selects_leaf_nodes(
assert len(placements) == 1
instance = list(placements.values())[0]
assigned_nodes = set(instance.shard_assignments.node_to_runner.keys())
assigned_nodes = set(map(lambda it: it.node_id, instance.shard_assignments.shards))
assert assigned_nodes == set((node_id_a, node_id_b)) or assigned_nodes == set(
(
node_id_c,
@@ -498,8 +508,8 @@ def test_tensor_rdma_backend_connectivity_matrix(
for i in range(3):
assert matrix[i][i] is None
assigned_nodes = list(instance.shard_assignments.node_to_runner.keys())
node_to_idx = {node_id: idx for idx, node_id in enumerate(assigned_nodes)}
assigned_nodes = list(instance.shard_assignments.shards)
node_to_idx = {node_id: idx for idx, (node_id, _, _) in enumerate(assigned_nodes)}
idx_a = node_to_idx[node_a]
idx_b = node_to_idx[node_b]
@@ -511,7 +521,7 @@ def test_tensor_rdma_backend_connectivity_matrix(
# Verify coordinators are set for all nodes
assert len(instance.jaccl_coordinators) == 3
for node_id in assigned_nodes:
for node_id, _, _ in assigned_nodes:
assert node_id in instance.jaccl_coordinators
coordinator = instance.jaccl_coordinators[node_id]
assert ":" in coordinator
@@ -825,7 +835,7 @@ def test_placement_prefers_cycle_with_downloaded_model(
assert len(placements) == 1
instance = list(placements.values())[0]
assigned_nodes = set(instance.shard_assignments.node_to_runner.keys())
assigned_nodes = set(map(lambda it: it.node_id, instance.shard_assignments.shards))
assert assigned_nodes == {node_b}
@@ -903,7 +913,7 @@ def test_placement_prefers_cycle_with_higher_download_progress(
assert len(placements) == 1
instance = list(placements.values())[0]
assigned_nodes = set(instance.shard_assignments.node_to_runner.keys())
assigned_nodes = set(map(lambda it: it.node_id, instance.shard_assignments.shards))
assert assigned_nodes == {node_b}
@@ -957,7 +967,7 @@ def test_placement_does_not_prefer_cycle_with_failed_download(
assert len(placements) == 1
instance = list(placements.values())[0]
assigned_nodes = set(instance.shard_assignments.node_to_runner.keys())
assigned_nodes = set(map(lambda it: it.node_id, instance.shard_assignments.shards))
# node_a should win on RAM tiebreaker since failed download scores 0.0
assert assigned_nodes == {node_a}
+24 -32
View File
@@ -204,6 +204,11 @@ def test_get_shard_assignments(
node_a_id = NodeId()
node_b_id = NodeId()
node_c_id = NodeId()
layers_by_node = {
node_a_id: expected_layers[0],
node_b_id: expected_layers[1],
node_c_id: expected_layers[2],
}
# create connections (A -> B -> C -> A forms a 3-cycle, plus B -> A also exists)
connection1 = Connection(
@@ -258,25 +263,8 @@ def test_get_shard_assignments(
)
# assert
runner_id_a = shard_assignments.node_to_runner[node_a_id]
runner_id_b = shard_assignments.node_to_runner[node_b_id]
runner_id_c = shard_assignments.node_to_runner[node_c_id]
assert (
shard_assignments.runner_to_shard[runner_id_a].end_layer
- shard_assignments.runner_to_shard[runner_id_a].start_layer
== expected_layers[0]
)
assert (
shard_assignments.runner_to_shard[runner_id_b].end_layer
- shard_assignments.runner_to_shard[runner_id_b].start_layer
== expected_layers[1]
)
assert (
shard_assignments.runner_to_shard[runner_id_c].end_layer
- shard_assignments.runner_to_shard[runner_id_c].start_layer
== expected_layers[2]
)
for nid, _, shard in shard_assignments.shards:
assert shard.end_layer - shard.start_layer == layers_by_node[nid]
def test_get_mlx_jaccl_coordinators():
@@ -543,11 +531,11 @@ class TestCfgParallelPlacement:
model_card, cycle, node_memory
)
shards = list(assignments.runner_to_shard.values())
shards = list(assignments.shards)
assert len(shards) == 2
# CFG models should get CfgShardMetadata
for shard in shards:
for _, _, shard in shards:
assert isinstance(shard, CfgShardMetadata)
# Both nodes should have all layers (no pipeline split)
assert shard.start_layer == 0
@@ -558,7 +546,7 @@ class TestCfgParallelPlacement:
assert shard.pipeline_rank == 0
cfg_ranks = sorted(
s.cfg_rank for s in shards if isinstance(s, CfgShardMetadata)
s.shard.cfg_rank for s in shards if isinstance(s.shard, CfgShardMetadata)
)
assert cfg_ranks == [0, 1]
@@ -587,11 +575,11 @@ class TestCfgParallelPlacement:
model_card, cycle, node_memory
)
shards = list(assignments.runner_to_shard.values())
shards = assignments.shards
assert len(shards) == 4
# CFG models should get CfgShardMetadata
for shard in shards:
for _, _, shard in shards:
assert isinstance(shard, CfgShardMetadata)
assert shard.cfg_world_size == 2
assert shard.pipeline_world_size == 2
@@ -599,10 +587,14 @@ class TestCfgParallelPlacement:
# Check we have 2 nodes in each CFG group
cfg_0_shards = [
s for s in shards if isinstance(s, CfgShardMetadata) and s.cfg_rank == 0
s.shard
for s in shards
if isinstance(s.shard, CfgShardMetadata) and s.shard.cfg_rank == 0
]
cfg_1_shards = [
s for s in shards if isinstance(s, CfgShardMetadata) and s.cfg_rank == 1
s.shard
for s in shards
if isinstance(s.shard, CfgShardMetadata) and s.shard.cfg_rank == 1
]
assert len(cfg_0_shards) == 2
assert len(cfg_1_shards) == 2
@@ -637,11 +629,11 @@ class TestCfgParallelPlacement:
model_card, cycle, node_memory
)
shards = list(assignments.runner_to_shard.values())
shards = list(assignments.shards)
assert len(shards) == 3
# Odd node count with CFG model falls back to PipelineShardMetadata (sequential CFG)
for shard in shards:
for _, _, shard in shards:
assert isinstance(shard, PipelineShardMetadata)
def test_two_nodes_non_cfg_model_uses_pipeline(self):
@@ -673,18 +665,18 @@ class TestCfgParallelPlacement:
model_card, cycle, node_memory
)
shards = list(assignments.runner_to_shard.values())
shards = list(assignments.shards)
assert len(shards) == 2
# Non-CFG models should get PipelineShardMetadata
for shard in shards:
for _, _, shard in shards:
assert isinstance(shard, PipelineShardMetadata)
# Should have actual layer sharding (pipeline)
layer_ranges = sorted(
(s.start_layer, s.end_layer)
(s.shard.start_layer, s.shard.end_layer)
for s in shards
if isinstance(s, PipelineShardMetadata)
if isinstance(s.shard, PipelineShardMetadata)
)
# First shard starts at 0, last shard ends at 57
assert layer_ranges[0][0] == 0
+3 -5
View File
@@ -1,15 +1,13 @@
from exo_pyo3_bindings import PyFromSwarm
from exo_rs import FromSwarm
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)
def from_update(cls, update: FromSwarm.Connection) -> "ConnectionMessage":
return cls(connected=update.connected)
+21 -2
View File
@@ -15,11 +15,30 @@ from exo.shared.types.events import (
IndexedEvent,
LocalForwarderEvent,
)
from exo.utils import channels
from exo.utils.channels import Receiver, Sender, channel
from exo.utils.event_buffer import OrderedBuffer
from exo.utils.task_group import TaskGroup
class EventRouterClosedResourceError(ClosedResourceError):
pass
class EventRouterBrokenResourceError(BrokenResourceError):
pass
# Event Router is created and destroyed before consumers of its channels are,
# hence its nice to have tagged errors for event-router channels being closed
#
# so consumers can catch specifically these errors, rather than the generic ones
_ERROR_CFG = channels.ErrorOverride(
closed_resource_error=EventRouterClosedResourceError,
broken_resource_error=EventRouterBrokenResourceError,
)
@dataclass
class EventRouter:
session_id: SessionId
@@ -64,7 +83,7 @@ class EventRouter:
await self.external_outbound.send(event)
def sender(self) -> Sender[Event]:
send, recv = channel[Event]()
send, recv = channel[Event](error_override_config=_ERROR_CFG)
if self._tg.is_running():
self._tg.start_soon(self._ingest, SystemId(), recv)
else:
@@ -73,7 +92,7 @@ class EventRouter:
def receiver(self) -> Receiver[IndexedEvent]:
assert not self._tg.is_running()
send, recv = channel[IndexedEvent]()
send, recv = channel[IndexedEvent](error_override_config=_ERROR_CFG)
self.internal_outbound.append(send)
return recv
+25 -36
View File
@@ -1,8 +1,7 @@
from collections.abc import Sequence
import os
from copy import copy
from itertools import count
from math import inf
from os import PathLike
from pathlib import Path
from typing import cast
@@ -12,18 +11,14 @@ from anyio import (
move_on_after,
sleep_forever,
)
from exo_pyo3_bindings import (
AllQueuesFullError,
Keypair,
MessageTooLargeError,
from exo_rs import (
FromSwarm,
NetworkingHandle,
NoPeersSubscribedToTopicError,
PyFromSwarm,
)
from filelock import FileLock
from loguru import logger
from exo.shared.constants import EXO_NODE_ID_KEYPAIR
from exo.shared.constants import EXO_NODE_ZID
from exo.shared.types.common import NodeId
from exo.utils.channels import Receiver, Sender, channel
from exo.utils.pydantic_ext import FrozenModel
from exo.utils.task_group import TaskGroup
@@ -105,12 +100,15 @@ class Router:
@classmethod
def create(
cls,
identity: Keypair,
bootstrap_peers: Sequence[str] = (),
listen_port: int = 0,
identity: str,
namespace: str,
listen_port: int,
discovery_service_port: int,
) -> "Router":
return cls(
handle=NetworkingHandle(identity, list(bootstrap_peers), listen_port)
handle=NetworkingHandle.new(
identity, namespace, listen_port, discovery_service_port
)
)
def __init__(self, handle: NetworkingHandle):
@@ -191,10 +189,8 @@ class Router:
from_swarm = await self._net.recv()
logger.debug(from_swarm)
match from_swarm:
case PyFromSwarm.Message(origin, topic, data):
logger.trace(
f"Received message on {topic} from {origin} with payload {data}"
)
case FromSwarm.Message(topic, data):
logger.trace(f"Received message on {topic} with payload {data}")
if topic not in self.topic_routers:
logger.warning(
f"Received message on unknown or inactive topic {topic}"
@@ -202,7 +198,7 @@ class Router:
continue
router = self.topic_routers[topic]
await router.publish_bytes(data)
case PyFromSwarm.Connection():
case FromSwarm.Connection():
message = ConnectionMessage.from_update(from_swarm)
logger.trace(
f"Received message on connection_messages with payload {message}"
@@ -225,33 +221,25 @@ 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(
path: str | bytes | PathLike[str] | PathLike[bytes] = EXO_NODE_ID_KEYPAIR,
) -> Keypair:
def get_node_zid(
path: Path = EXO_NODE_ZID,
) -> NodeId:
"""
Obtains the :class:`Keypair` associated with this node-ID.
Obtain the :class:`PeerId` by from it.
"""
# TODO(evan): bring back node id persistence once we figure out how to deal with duplicates
return Keypair.generate()
return NodeId(os.urandom(16).hex().lstrip("0"))
"""
def lock_path(path: str | bytes | PathLike[str] | PathLike[bytes]) -> Path:
return Path(str(path) + ".lock")
@@ -273,3 +261,4 @@ def get_node_id_keypair(
keypair = Keypair.generate()
f.write(keypair.to_bytes())
return keypair
"""
+46 -124
View File
@@ -4,19 +4,14 @@ from datetime import datetime
from loguru import logger
from exo.shared.models.model_cards import ModelCard
from exo.shared.types.common import ModelId, NodeId
from exo.shared.types.common import NodeId
from exo.shared.types.events import (
ChunkGenerated,
CustomModelCardAdded,
CustomModelCardDeleted,
Event,
IndexedEvent,
InputChunkReceived,
InstanceCreated,
InstanceDeleted,
InstanceLinkCreated,
InstanceLinkDeleted,
NodeDownloadProgress,
NodeGatheredInfo,
NodeTimedOut,
@@ -32,7 +27,6 @@ from exo.shared.types.events import (
TracesCollected,
TracesMerged,
)
from exo.shared.types.instance_link import InstanceLink, InstanceLinkId
from exo.shared.types.profiling import (
NodeIdentity,
NodeNetworkInfo,
@@ -42,7 +36,6 @@ from exo.shared.types.profiling import (
)
from exo.shared.types.state import State
from exo.shared.types.tasks import Task, TaskId, TaskStatus
from exo.shared.types.topology import Connection, RDMAConnection
from exo.shared.types.worker.downloads import DownloadProgress
from exo.shared.types.worker.instances import Instance, InstanceId
from exo.shared.types.worker.runners import (
@@ -67,18 +60,6 @@ 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:
@@ -91,10 +72,6 @@ def event_apply(event: Event, state: State) -> State:
| TracesMerged()
): # 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():
@@ -119,10 +96,6 @@ def event_apply(event: Event, state: State) -> State:
return apply_topology_edge_created(event, state)
case TopologyEdgeDeleted():
return apply_topology_edge_deleted(event, state)
case InstanceLinkCreated():
return apply_instance_link_created(event, state)
case InstanceLinkDeleted():
return apply_instance_link_deleted(event, state)
def apply(state: State, event: IndexedEvent) -> State:
@@ -222,38 +195,7 @@ def apply_instance_deleted(event: InstanceDeleted, state: State) -> State:
new_instances: Mapping[InstanceId, Instance] = {
iid: inst for iid, inst in state.instances.items() if iid != event.instance_id
}
new_links: dict[InstanceLinkId, InstanceLink] = {}
for link_id, link in state.instance_links.items():
prefill = [i for i in link.prefill_instances if i != event.instance_id]
decode = [i for i in link.decode_instances if i != event.instance_id]
if not prefill or not decode:
continue
if prefill == list(link.prefill_instances) and decode == list(
link.decode_instances
):
new_links[link_id] = link
else:
new_links[link_id] = link.model_copy(
update={"prefill_instances": prefill, "decode_instances": decode}
)
return state.model_copy(
update={"instances": new_instances, "instance_links": new_links}
)
def apply_instance_link_created(event: InstanceLinkCreated, state: State) -> State:
new_links: Mapping[InstanceLinkId, InstanceLink] = {
**state.instance_links,
event.link.link_id: event.link,
}
return state.model_copy(update={"instance_links": new_links})
def apply_instance_link_deleted(event: InstanceLinkDeleted, state: State) -> State:
new_links: Mapping[InstanceLinkId, InstanceLink] = {
lid: link for lid, link in state.instance_links.items() if lid != event.link_id
}
return state.model_copy(update={"instance_links": new_links})
return state.model_copy(update={"instances": new_instances})
def apply_runner_status_updated(event: RunnerStatusUpdated, state: State) -> State:
@@ -408,59 +350,26 @@ def apply_node_gathered_info(event: NodeGatheredInfo, state: State) -> State:
event.node_id: NodeThunderboltInfo(interfaces=info.idents),
}
case MacThunderboltConnections():
conn_map = {
tb_ident.domain_uuid: (nid, tb_ident.rdma_interface)
for nid in state.node_thunderbolt
for tb_ident in state.node_thunderbolt[nid].interfaces
update["node_thunderbolt_connections"] = {
**state.node_thunderbolt_connections,
event.node_id: info,
}
source_is_rdma_enabled = _is_rdma_ctl_enabled(
event.node_id, state.node_rdma_ctl
)
as_rdma_conns = [
Connection(
source=event.node_id,
sink=conn_map[tb_conn.sink_uuid][0],
edge=RDMAConnection(
source_rdma_iface=conn_map[tb_conn.source_uuid][1],
sink_rdma_iface=conn_map[tb_conn.sink_uuid][1],
),
)
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():
new_tb_bridge: dict[NodeId, ThunderboltBridgeStatus] = {
**state.node_thunderbolt_bridge,
event.node_id: info.status,
}
update["node_thunderbolt_bridge"] = new_tb_bridge
# Only recompute cycles if the enabled status changed
old_status = state.node_thunderbolt_bridge.get(event.node_id)
old_enabled = old_status.enabled if old_status else False
new_enabled = info.status.enabled
if old_enabled != new_enabled:
update["thunderbolt_bridge_cycles"] = (
topology.get_thunderbolt_bridge_cycles(
new_tb_bridge, state.node_network
)
update["thunderbolt_bridge_cycles"] = (
topology.get_thunderbolt_bridge_cycles(
new_tb_bridge, state.node_network
)
)
case RdmaCtlStatus():
update["node_rdma_ctl"] = {
**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)
case NodeBackends():
update["node_backends"] = {
**state.node_backends,
@@ -471,32 +380,45 @@ def apply_node_gathered_info(event: NodeGatheredInfo, state: State) -> State:
def apply_topology_edge_created(event: TopologyEdgeCreated, state: State) -> State:
topology = copy.deepcopy(state.topology)
topology.add_connection(event.conn)
return state.model_copy(update={"topology": topology})
source_connections = state.node_socket_connections.get(event.conn.source, {})
sink_connections = source_connections.get(event.conn.sink, [])
update = {
"node_socket_connections": {
**state.node_socket_connections,
event.conn.source: {
**source_connections,
event.conn.sink: sink_connections
if event.conn.edge in sink_connections
else [*sink_connections, event.conn.edge],
},
}
}
return state.model_copy(update=update)
def apply_topology_edge_deleted(event: TopologyEdgeDeleted, state: State) -> State:
topology = copy.deepcopy(state.topology)
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,
inner_update = {
sink: final_edges
for sink, edges in state.node_socket_connections.get(
event.conn.source, {}
).items()
if (
final_edges := [
edge
for edge in edges
if (edge != event.conn.edge or sink != event.conn.sink)
]
)
}
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
update = {
"node_socket_connections": {
source: maps
for source, maps in {
**state.node_socket_connections,
event.conn.source: inner_update,
}.items()
if maps
}
}
return state.model_copy(update={"custom_model_cards": new_cards})
return state.model_copy(update=update)
+1 -1
View File
@@ -76,7 +76,7 @@ EXO_TEST_LOG = EXO_CACHE_HOME / "exo_test.log"
EXO_PID_FILE = EXO_CACHE_HOME / "exo.pid"
# Identity (config)
EXO_NODE_ID_KEYPAIR = EXO_CONFIG_HOME / "node_id.keypair"
EXO_NODE_ZID = EXO_CACHE_HOME / "node_zid"
EXO_CONFIG_FILE = EXO_CONFIG_HOME / "config.toml"
# libp2p topics for event forwarding
+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_rs").setLevel(logging.INFO)
logging.getLogger("networking").setLevel(logging.INFO)
logging.getLogger("httpx").setLevel(logging.WARNING)
logging.getLogger("httpcore").setLevel(logging.WARNING)
+1 -1
View File
@@ -55,7 +55,7 @@ class _CardCache:
except OSError as e:
logger.warning(f"failed to save custom model card ({e.strerror})")
async def pop(self, model_id: ModelId) -> "ModelCard | None":
async def delete(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:
@@ -1,46 +0,0 @@
from exo.shared.apply import apply
from exo.shared.models.model_cards import ModelCard, ModelTask
from exo.shared.types.backends import Backend
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],
backends=[Backend.MlxMetal],
)
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 == {}
@@ -1,72 +0,0 @@
from exo.shared.apply import (
apply_instance_deleted,
apply_instance_link_created,
apply_instance_link_deleted,
)
from exo.shared.types.events import (
InstanceDeleted,
InstanceLinkCreated,
InstanceLinkDeleted,
)
from exo.shared.types.instance_link import InstanceLink, InstanceLinkId
from exo.shared.types.state import State
from exo.shared.types.worker.instances import InstanceId
def _link(
prefill: list[InstanceId],
decode: list[InstanceId],
link_id: InstanceLinkId | None = None,
) -> InstanceLink:
return InstanceLink(
link_id=link_id or InstanceLinkId(),
prefill_instances=prefill,
decode_instances=decode,
)
def test_create_link() -> None:
state = State()
link = _link([InstanceId("a")], [InstanceId("b")])
new_state = apply_instance_link_created(InstanceLinkCreated(link=link), state)
assert new_state.instance_links == {link.link_id: link}
def test_update_replaces_existing_link() -> None:
a, b, c = InstanceId("a"), InstanceId("b"), InstanceId("c")
link = _link([a], [b])
state = State(instance_links={link.link_id: link})
updated = link.model_copy(update={"decode_instances": [b, c]})
new_state = apply_instance_link_created(InstanceLinkCreated(link=updated), state)
assert set(new_state.instance_links[link.link_id].decode_instances) == {b, c}
def test_delete_link() -> None:
link = _link([InstanceId("a")], [InstanceId("b")])
state = State(instance_links={link.link_id: link})
new_state = apply_instance_link_deleted(
InstanceLinkDeleted(link_id=link.link_id), state
)
assert new_state.instance_links == {}
def test_instance_deleted_strips_from_links() -> None:
a, b, c = InstanceId("a"), InstanceId("b"), InstanceId("c")
link = _link([a, c], [b])
state = State(instance_links={link.link_id: link})
new_state = apply_instance_deleted(InstanceDeleted(instance_id=a), state)
remaining = new_state.instance_links[link.link_id]
assert remaining.prefill_instances == [c]
assert remaining.decode_instances == [b]
def test_instance_deleted_drops_link_when_role_empties() -> None:
a, b = InstanceId("a"), InstanceId("b")
link = _link([a], [b])
state = State(instance_links={link.link_id: link})
new_state = apply_instance_deleted(InstanceDeleted(instance_id=a), state)
assert link.link_id not in new_state.instance_links
@@ -217,7 +217,7 @@ def test_topology_remove_all_rdma_connections_touching_keeps_socket_edges():
)
)
socket_edge = SocketConnection(
sink_multiaddr=Multiaddr(address="/ip4/10.0.0.1/tcp/8000")
sink_multiaddr=Multiaddr(address="/ip4/10.0.0.1/tcp/8000"),
)
topology.add_connection(Connection(source=node_a, sink=node_b, edge=socket_edge))
+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:
@@ -10,8 +10,8 @@ from multiprocessing.synchronize import Semaphore as SemaphoreT
from loguru import logger
from pytest import LogCaptureFixture, mark
from exo.routing.router import get_node_id_keypair
from exo.shared.constants import EXO_NODE_ID_KEYPAIR
from exo.routing.router import get_node_zid
from exo.shared.constants import EXO_NODE_ZID
NUM_CONCURRENT_PROCS = 10
@@ -23,7 +23,7 @@ def _get_keypair_concurrent_subprocess_task(
sem.release()
# wait to be told to begin simultaneous read
ev.wait()
queue.put(get_node_id_keypair().to_bytes())
queue.put(get_node_zid().encode())
def _get_keypair_concurrent(num_procs: int) -> bytes:
@@ -79,7 +79,7 @@ def test_node_id_fetching(caplog: LogCaptureFixture):
reps = 10
# delete current file and write a new one
_delete_if_exists(EXO_NODE_ID_KEYPAIR)
_delete_if_exists(EXO_NODE_ZID)
kp = _get_keypair_concurrent(NUM_CONCURRENT_PROCS)
with caplog.at_level(101): # supress logs
@@ -88,6 +88,6 @@ def test_node_id_fetching(caplog: LogCaptureFixture):
assert kp == _get_keypair_concurrent(NUM_CONCURRENT_PROCS)
# make sure that after deleting, we are not fetching the same value
_delete_if_exists(EXO_NODE_ID_KEYPAIR)
_delete_if_exists(EXO_NODE_ZID)
for _ in range(reps):
assert kp != _get_keypair_concurrent(NUM_CONCURRENT_PROCS)
@@ -1,35 +0,0 @@
from exo.shared.types.common import NodeId
from exo.shared.types.multiaddr import Multiaddr
from exo.shared.types.state import State
from exo.shared.types.topology import Connection, SocketConnection
def test_state_serialization_roundtrip() -> None:
"""Verify that State → JSON → State round-trip preserves topology."""
# --- build a simple state ------------------------------------------------
node_a = NodeId("node-a")
node_b = NodeId("node-b")
connection = Connection(
source=node_a,
sink=node_b,
edge=SocketConnection(
sink_multiaddr=Multiaddr(address="/ip4/127.0.0.1/tcp/10001"),
),
)
state = State()
state.topology.add_connection(connection)
json_repr = state.model_dump_json()
restored_state = State.model_validate_json(json_repr)
assert (
state.topology.to_snapshot().nodes
== restored_state.topology.to_snapshot().nodes
)
assert set(state.topology.to_snapshot().connections) == set(
restored_state.topology.to_snapshot().connections
)
assert restored_state.model_dump_json() == json_repr
-7
View File
@@ -97,13 +97,6 @@ def test_macos_uses_traditional_paths():
assert home / ".exo" == constants.EXO_CACHE_HOME
def test_node_id_in_config_dir():
"""Test that node ID keypair is in the config directory."""
import exo.shared.constants as constants
assert constants.EXO_NODE_ID_KEYPAIR.parent == constants.EXO_CONFIG_HOME
def test_models_in_data_dir():
"""Test that default models directory is in the data directory."""
# Clear EXO_MODELS_DIRS to test default behavior
-33
View File
@@ -1,10 +1,8 @@
import contextlib
from collections.abc import Mapping, Sequence
from dataclasses import dataclass, field
from typing import Iterable
import rustworkx as rx
from pydantic import BaseModel, ConfigDict
from exo.shared.types.common import NodeId
from exo.shared.types.profiling import (
@@ -20,15 +18,6 @@ from exo.shared.types.topology import (
)
class TopologySnapshot(BaseModel):
nodes: Sequence[NodeId]
connections: Mapping[
NodeId, Mapping[NodeId, Sequence[SocketConnection | RDMAConnection]]
]
model_config = ConfigDict(frozen=True, extra="forbid")
@dataclass
class Topology:
_graph: rx.PyDiGraph[NodeId, SocketConnection | RDMAConnection] = field(
@@ -36,28 +25,6 @@ class Topology:
)
_vertex_indices: dict[NodeId, int] = field(init=False, default_factory=dict)
def to_snapshot(self) -> TopologySnapshot:
return TopologySnapshot(
nodes=list(self.list_nodes()), connections=self.map_connections()
)
@classmethod
def from_snapshot(cls, snapshot: TopologySnapshot) -> "Topology":
topology = cls()
for node_id in snapshot.nodes:
with contextlib.suppress(ValueError):
topology.add_node(node_id)
for source in snapshot.connections:
for sink in snapshot.connections[source]:
for edge in snapshot.connections[source][sink]:
topology.add_connection(
Connection(source=source, sink=sink, edge=edge)
)
return topology
def add_node(self, node_id: NodeId) -> None:
if node_id in self._vertex_indices:
return
-23
View File
@@ -7,7 +7,6 @@ from exo.api.types import (
from exo.shared.models.model_cards import ModelCard, ModelId
from exo.shared.types.chunks import InputImageChunk
from exo.shared.types.common import CommandId, NodeId, SystemId
from exo.shared.types.instance_link import InstanceLinkId
from exo.shared.types.text_generation import TextGenerationTaskParams
from exo.shared.types.worker.instances import Instance, InstanceId, InstanceMeta
from exo.shared.types.worker.shards import Sharding, ShardMetadata
@@ -82,24 +81,6 @@ class CancelDownload(BaseCommand):
model_id: ModelId
class AddCustomModelCard(BaseCommand):
model_card: ModelCard
class DeleteCustomModelCard(BaseCommand):
model_id: ModelId
class SetInstanceLink(BaseCommand):
link_id: InstanceLinkId
prefill_instances: list[InstanceId]
decode_instances: list[InstanceId]
class DeleteInstanceLink(BaseCommand):
link_id: InstanceLinkId
DownloadCommand = StartDownload | DeleteDownload | CancelDownload
@@ -115,10 +96,6 @@ Command = (
| TaskCancelled
| TaskFinished
| SendInputChunk
| AddCustomModelCard
| DeleteCustomModelCard
| SetInstanceLink
| DeleteInstanceLink
)
+1 -23
View File
@@ -3,11 +3,9 @@ from typing import final
from pydantic import Field
from exo.shared.models.model_cards import ModelCard
from exo.shared.topology import Connection
from exo.shared.types.chunks import Chunk, InputImageChunk
from exo.shared.types.common import CommandId, Id, ModelId, NodeId, SessionId, SystemId
from exo.shared.types.instance_link import InstanceLink, InstanceLinkId
from exo.shared.types.common import CommandId, Id, NodeId, SessionId, SystemId
from exo.shared.types.tasks import Task, TaskId, TaskStatus
from exo.shared.types.worker.downloads import DownloadProgress
from exo.shared.types.worker.instances import Instance, InstanceId
@@ -108,14 +106,6 @@ class TopologyEdgeDeleted(BaseEvent):
conn: Connection
class CustomModelCardAdded(BaseEvent):
model_card: ModelCard
class CustomModelCardDeleted(BaseEvent):
model_id: ModelId
@final
class TraceEventData(FrozenModel):
name: str
@@ -138,14 +128,6 @@ class TracesMerged(BaseEvent):
traces: list[TraceEventData]
class InstanceLinkCreated(BaseEvent):
link: InstanceLink
class InstanceLinkDeleted(BaseEvent):
link_id: InstanceLinkId
Event = (
TestEvent
| TaskCreated
@@ -165,10 +147,6 @@ Event = (
| TopologyEdgeDeleted
| TracesCollected
| TracesMerged
| CustomModelCardAdded
| CustomModelCardDeleted
| InstanceLinkCreated
| InstanceLinkDeleted
)
+4
View File
@@ -16,6 +16,10 @@ class MemoryUsage(FrozenModel):
swap_total: Memory
swap_available: Memory
@classmethod
def tag(cls) -> str:
return cls.__name__
@classmethod
def from_bytes(
cls, *, ram_total: int, ram_available: int, swap_total: int, swap_available: int
+91 -27
View File
@@ -1,15 +1,15 @@
from collections.abc import Mapping, Sequence
from datetime import datetime
from typing import Any, cast
from typing import Any
from pydantic import ConfigDict, Field, field_serializer, field_validator
from exo_rs import LVAggregator
from pydantic import ConfigDict, Field, model_serializer
from pydantic.alias_generators import to_camel
from pydantic_core.core_schema import SerializerFunctionWrapHandler
from exo.shared.models.model_cards import ModelCard
from exo.shared.topology import Topology, TopologySnapshot
from exo.shared.topology import Topology
from exo.shared.types.backends import Backend
from exo.shared.types.common import ModelId, NodeId
from exo.shared.types.instance_link import InstanceLink, InstanceLinkId
from exo.shared.types.common import NodeId
from exo.shared.types.profiling import (
DiskUsage,
MemoryUsage,
@@ -21,9 +21,15 @@ from exo.shared.types.profiling import (
ThunderboltBridgeStatus,
)
from exo.shared.types.tasks import Task, TaskId
from exo.shared.types.topology import (
Connection,
RDMAConnection,
SocketConnection,
)
from exo.shared.types.worker.downloads import DownloadProgress
from exo.shared.types.worker.instances import Instance, InstanceId
from exo.shared.types.worker.runners import RunnerId, RunnerStatus
from exo.utils.info_gatherer.info_gatherer import MacThunderboltConnections
from exo.utils.pydantic_ext import FrozenModel
@@ -39,7 +45,6 @@ class State(FrozenModel):
alias_generator=to_camel,
validate_by_name=True,
extra="forbid",
# I want to reenable this ASAP, but it's causing an issue with TaskStatus
strict=True,
arbitrary_types_allowed=True,
)
@@ -48,7 +53,6 @@ class State(FrozenModel):
downloads: Mapping[NodeId, Sequence[DownloadProgress]] = {}
tasks: Mapping[TaskId, Task] = {}
last_seen: Mapping[NodeId, datetime] = {}
topology: Topology = Field(default_factory=Topology)
last_event_applied_idx: int = Field(default=-1, ge=-1)
# Granular node state mappings (update independently at different frequencies)
@@ -61,34 +65,94 @@ class State(FrozenModel):
node_thunderbolt_bridge: Mapping[NodeId, ThunderboltBridgeStatus] = {}
node_rdma_ctl: Mapping[NodeId, NodeRdmaCtlStatus] = {}
node_backends: Mapping[NodeId, list[Backend]] = {}
node_socket_connections: Mapping[
NodeId, Mapping[NodeId, Sequence[SocketConnection]]
] = {}
node_thunderbolt_connections: Mapping[NodeId, MacThunderboltConnections] = {}
# Detected cycles where all nodes have Thunderbolt bridge enabled (>2 nodes)
thunderbolt_bridge_cycles: Sequence[Sequence[NodeId]] = []
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] = {}
@model_serializer(mode="wrap")
def _serialize(self, handler: SerializerFunctionWrapHandler) -> dict[str, Any]:
data = handler(self) # pyright: ignore[reportAny]
data["topology"] = {
"nodes": list(self.node_identities.keys()),
"connections": self.topology.map_connections(),
}
return data # pyright: ignore[reportAny]
@field_serializer("topology", mode="plain")
def _encode_topology(self, value: Topology) -> TopologySnapshot:
return value.to_snapshot()
@property
def topology(self) -> Topology:
topology = Topology()
thunderbolt_by_uuid = {
ident.domain_uuid: (node_id, ident.rdma_interface)
for node_id, info in self.node_thunderbolt.items()
for ident in info.interfaces
}
for node_id in self.node_identities:
topology.add_node(node_id)
@field_validator("topology", mode="before")
@classmethod
def _deserialize_topology(cls, value: object) -> Topology: # noqa: D401 Pydantic validator signature
"""Convert an incoming *value* into a :class:`Topology` instance.
for source, data in self.node_socket_connections.items():
for sink, conns in data.items():
for conn in conns:
topology.add_connection(
Connection(source=source, sink=sink, edge=conn)
)
Accepts either an already constructed :class:`Topology` or a mapping
representing :class:`~shared.topology.TopologySnapshot`.
"""
for source, connections in self.node_thunderbolt_connections.items():
if not self.node_rdma_ctl.get(
source, NodeRdmaCtlStatus(enabled=False)
).enabled:
continue
for connection in connections.conns:
if (
source_iface := thunderbolt_by_uuid.get(connection.source_uuid)
) is None or (
sink_iface := thunderbolt_by_uuid.get(connection.sink_uuid)
) is None:
continue
if not self.node_rdma_ctl.get(
sink_iface[0], NodeRdmaCtlStatus(enabled=False)
).enabled:
continue
assert source_iface[0] == source, "registered invalid source uuid"
topology.add_connection(
Connection(
source=source_iface[0],
sink=sink_iface[0],
edge=RDMAConnection(
source_rdma_iface=source_iface[1],
sink_rdma_iface=sink_iface[1],
),
)
)
if isinstance(value, Topology):
return value
return topology
if isinstance(value, Mapping): # likely a snapshot-dict coming from JSON
snapshot = TopologySnapshot(**cast(dict[str, Any], value)) # type: ignore[arg-type]
return Topology.from_snapshot(snapshot)
def with_aggregator(self, aggregator: LVAggregator) -> "State":
from datetime import datetime, timezone
raise TypeError("Invalid representation for Topology field in State")
from pydantic import TypeAdapter
from exo.shared.apply import event_apply
from exo.shared.types.events import NodeGatheredInfo
from exo.utils.info_gatherer.info_gatherer import GatheredInfo
state = self.model_copy()
for key, value in aggregator.dump().items():
try:
data = TypeAdapter[GatheredInfo](GatheredInfo).validate_json(value)
node_id = NodeId(key.split("/")[0])
event = NodeGatheredInfo(
node_id=node_id, when=str(datetime.now(tz=timezone.utc)), info=data
)
state = event_apply(event, state)
except Exception as e:
print(
f"\n{'=' * 10}key: {key} with exception {str(e)}\nvalue: {value}{'=' * 10}\n"
)
return state
+12 -3
View File
@@ -1,3 +1,4 @@
from collections.abc import Iterable
from enum import Enum
from pydantic import model_validator
@@ -22,7 +23,14 @@ class BaseInstance(TaggedModel):
shard_assignments: ShardAssignments
def shard(self, runner_id: RunnerId) -> ShardMetadata | None:
return self.shard_assignments.runner_to_shard.get(runner_id, None)
for _, rid, shard in self.shard_assignments.shards:
if rid == runner_id:
return shard
def runners_for(self, node_id: NodeId) -> Iterable[RunnerId]:
for nid, rid, _ in self.shard_assignments.shards:
if nid == node_id:
yield rid
class MlxRingInstance(BaseInstance):
@@ -59,8 +67,9 @@ class BoundInstance(FrozenModel):
@model_validator(mode="after")
def validate_shard_exists(self) -> "BoundInstance":
assert (
self.bound_runner_id in self.instance.shard_assignments.runner_to_shard
assert any(
rid == self.bound_runner_id
for (_, rid, _) in self.instance.shard_assignments.shards
), (
"Bound Instance must be constructed with a runner_id that is in the instances assigned shards"
)
+19 -8
View File
@@ -1,4 +1,5 @@
from collections.abc import Mapping
from collections.abc import Sequence
from typing import NamedTuple
from pydantic import model_validator
@@ -83,16 +84,26 @@ RunnerStatus = (
)
class ShardWithId(NamedTuple):
node_id: NodeId
runner_id: RunnerId
shard: ShardMetadata
class ShardAssignments(FrozenModel):
model_id: ModelId
runner_to_shard: Mapping[RunnerId, ShardMetadata]
node_to_runner: Mapping[NodeId, RunnerId]
shards: Sequence[ShardWithId]
# this node needs to be connected to the API node for the stream to be considered ready
# (this is a device rank)
primary_output_node: int
@model_validator(mode="after")
def validate_runners_exist(self) -> "ShardAssignments":
for runner_id in self.node_to_runner.values():
if runner_id not in self.runner_to_shard:
raise ValueError(
f"Runner {runner_id} in node_to_runner does not exist in runner_to_shard"
)
for position, shard in enumerate(self.shards):
if shard.shard.device_rank != position:
raise ValueError("shard position does not correspond to device rank")
if not self.shards[self.primary_output_node].shard.is_primary_output():
raise ValueError("primary output node does not correspond to primary shard")
return self
+43 -18
View File
@@ -15,18 +15,14 @@ class Sharding(str, Enum):
class BaseShardMetadata(TaggedModel):
"""
Defines a specific shard of the model that is ready to be run on a device.
Replaces previous `Shard` object.
Layers are represented as a half-open interval [start_layer, end_layer),
where start_layer is inclusive and end_layer is exclusive.
"""
model_card: ModelCard
device_rank: int
world_size: int
# Error handling; equivalent to monkey-patch, but we can't monkey-patch runner.py
# This is kinda annoying because it allocates memory in the ShardMetadata object. Can be rethought after Shanghai.
immediate_exception: bool = False
should_timeout: float | None = None
start_layer: int = Field(ge=0)
end_layer: int = Field(ge=0)
n_layers: int = Field(ge=0)
@@ -51,27 +47,56 @@ class BaseShardMetadata(TaggedModel):
)
)
def is_primary_output(self) -> bool:
return self.device_rank == self.world_size - 1
@final
class PipelineShardMetadata(BaseShardMetadata):
"""
Pipeline parallelism shard meta.
Layers are represented as a half-open interval [start_layer, end_layer),
where start_layer is inclusive and end_layer is exclusive.
"""
pass
@final
class CfgShardMetadata(BaseShardMetadata):
"""Shard metadata for CFG-parallel image generation models."""
# example
# world_size 6
# rank prank crank
# 0 0 0
# 1 1 0
# 2 2 0
# 3 2 1
# 4 1 1
# 5 0 1
cfg_rank: int # 0 = positive branch, 1 = negative branch
cfg_world_size: int = 2
@property
def cfg_rank(self) -> int:
# 0 = positive branch, 1 = negative branch
return 0 if self.device_rank < self.world_size // 2 else 1
# Pipeline-relative coordinates (computed at placement time)
pipeline_rank: int # rank within the pipeline group (0, 1, 2, ...)
pipeline_world_size: int # number of nodes per pipeline group
@property
def cfg_world_size(self) -> int:
return 2
@property
def pipeline_rank(self) -> int:
return (
self.device_rank
if self.cfg_rank == 0
else (self.world_size - self.device_rank - 1)
)
@property
def pipeline_world_size(self) -> int:
return self.world_size // 2
def is_primary_output(self) -> bool:
"""
For CFG models: the last pipeline stage in CFG group 0 (positive prompt).
For non-CFG models: the last pipeline stage.
"""
assert self.pipeline_world_size == self.world_size // 2
assert self.world_size % 2 == 0
return self.device_rank == (self.world_size // 2) - 1
@final
+14 -1
View File
@@ -1,7 +1,12 @@
from typing import Any, Type
from typing import Any, Callable, Iterable, Iterator, Type, TypeGuard
from .phantom import PhantomData
STDIN_FD = 0
STDOUT_FD = 1
STDERR_FD = 2
STDIO_FDS = (STDIN_FD, STDOUT_FD, STDERR_FD)
def ensure_type[T](obj: Any, expected_type: Type[T]) -> T: # type: ignore
if not isinstance(obj, expected_type):
@@ -14,3 +19,11 @@ def todo[T](
_phantom: PhantomData[T] = None,
) -> T:
raise NotImplementedError(msg)
def not_none[T](t: T | None) -> TypeGuard[T]:
return t is not None
def fmap[T, U](f: Callable[[T], U | None], s: Iterable[T]) -> Iterator[U]:
return filter(not_none, map(f, s))
+4 -5
View File
@@ -25,10 +25,9 @@ from anyio import (
from anyio.abc import TaskStatus
from loguru import logger
from exo.utils import STDERR_FD, STDIO_FDS, STDOUT_FD
from exo.utils.channels import Receiver, Sender, channel
_STDOUT_FD = 1
_STDERR_FD = 2
_READ_CHUNK_SIZE = 64 * 1024
_JOIN_GRACE_SECONDS = 3.0
_TERMINATE_GRACE_SECONDS = 5.0
@@ -256,11 +255,11 @@ def _run_with_captured_stdio(
stderr_fd = stderr.detach()
try:
os.dup2(stdout_fd, _STDOUT_FD)
os.dup2(stderr_fd, _STDERR_FD)
os.dup2(stdout_fd, STDOUT_FD)
os.dup2(stderr_fd, STDERR_FD)
finally:
for fd in (stdout_fd, stderr_fd):
if fd not in (_STDOUT_FD, _STDERR_FD):
if fd not in STDIO_FDS:
_close_fd(fd)
faulthandler.enable(file=sys.stderr, all_threads=True)
+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))}
Loaded 100 of 123 files, more files were not shown because too many files have changed in this diff. Show more