Compare commits

..
Author SHA1 Message Date
ciaranbor 91a9d0e10e Use sliding window 2026-06-02 12:09:28 -07:00
ciaranbor a2dfc57d50 Always take the most recent snapshot 2026-06-01 11:08:05 -07: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
Evan Quiney 90f24bef30 fix model cards not validating properly after #2071 (#2096) 2026-05-15 15:17:35 +00:00
Andrei Cravtov 5097b2665d Tweaked workspace settings (#2095)
workspace settings
2026-05-15 13:04:50 +00:00
rltakashigeandEvan bc6661e6aa Add node backends to model cards (#2071)
Co-authored-by: Evan <evanev7@gmail.com>
2026-05-15 12:52:12 +00:00
Andrei CravtovandEvan Quiney 14aab35688 Runner error handling (#2093)
# Runner error handling

## Motivation

Runner failures were mostly surfaced as plain shutdown messages, which
made root cause hard to spot from API errors or runner status.

This adds a MVP path for preserving runner crash context and attaching
known stderr diagnostics to failure reports.

## Changes

- Added `RunnerTerminationError` for Python exceptions raised inside
runner bootstrap
- Changed runner bootstrap to send `Event | RunnerTerminationError` over
the private runner channel
- Moved public `RunnerFailed` emission back into supervisor
- Added stderr-only `RunnerDiagnosticCollector`
- + Added known diagnostics for Metal GPU timeout, ring socket receive
errno, and ring transport abort
- Added diagnostics to `RunnerFailed` and `ErrorChunk`
- Tweaked async process termination to join briefly before
terminate/kill
- Updated tests/fixtures for new failure payload shape
- Added Ruff VS Code formatter settings

## Why It Works

Runner child now reports raw-ish failure context to supervisor instead
of publishing failed status directly.

Supervisor still owns process lifecycle, exit code/signal handling,
in-flight task error chunks, and final runner status. Stderr diagnostics
stay best effort and only known root-cause variants are surfaced.

## Test Plan

### Manual Testing

Hardware: remote runner logs from e16/e11/e4/e2

What you did:
- inspected live runner stderr logs
- used observed Metal GPU timeout and ring socket errors as initial
diagnostic targets

### Automated Testing

- `nix flake check`
- supervisor test covers error chunk + failed status emission
- plan lifecycle test updated for failed runner diagnostics
- type/lint checks cover new runner channel union

---------

Co-authored-by: Evan Quiney <evanev7@gmail.com>
2026-05-15 12:40:59 +00:00
Heidar 88d46d46fd fix: omit null delta fields in streaming chat completions (issue #2082) (#2092)
## Motivation

Streaming /v1/chat/completions responses emitted null for tool_calls,
function_call, name, and tool_call_id in every delta chunk. The OpenAI
streaming spec marks these fields as non-nullable — they must either
carry a
  real value or be absent entirely. Spec-correct clients doing
delta.get("tool_calls", []) receive None and crash with 'NoneType'
object is
  not iterable.

Root cause: the streaming serialisation path called model_dump_json()
without
exclude_none=True, while the request-parsing path already used it
correctly.
Three call sites in chat_completions.py and two in responses.py were
affected.

## Testing

Before — every delta carries explicit nulls:

  $ curl -sN -X POST http://localhost:52415/v1/chat/completions \
    -H 'Content-Type: application/json' \
-d
'{"model":"mlx-community/Qwen3.5-2B-MLX-8bit","messages":[{"role":"user","
  content":"hi"}],"max_tokens":3,"stream":true}' \
    | grep "^data: "
data:
{"id":"7c4dae10-...","choices":[{"index":0,"delta":{"role":"assistant","c

ontent":null,"reasoning_content":"Okay","name":null,"tool_calls":null,"tool_cal

l_id":null,"function_call":null},"logprobs":null,"finish_reason":null,"usage":n
  ull}],"usage":null,"service_tier":null}
data:
{"id":"7c4dae10-...","choices":[{"index":0,"delta":{"role":"assistant","c

ontent":null,"reasoning_content":",","name":null,"tool_calls":null,"tool_call_i

d":null,"function_call":null},"logprobs":null,"finish_reason":null,"usage":null
  }],"usage":null,"service_tier":null}
data:
{"id":"7c4dae10-...","choices":[{"index":0,"delta":{"role":"assistant","c
ontent":"
the","reasoning_content":null,"name":null,"tool_calls":null,"tool_cal

l_id":null,"function_call":null},"logprobs":null,"finish_reason":"length","usag
  e":{"prompt_tokens":11,...}}],"usage":null,"service_tier":null}
  data: [DONE]

  After — only populated fields are emitted:
data:
{"id":"demo","object":"chat.completion","created":...,"model":"mlx-commun

ity/Qwen3.5-2B-MLX-8bit","choices":[{"index":0,"delta":{"role":"assistant","rea
  soning_content":"Okay"}}]}
data:
{"id":"demo","object":"chat.completion","created":...,"model":"mlx-commun

ity/Qwen3.5-2B-MLX-8bit","choices":[{"index":0,"delta":{"role":"assistant","rea
  soning_content":","}}]}
data:
{"id":"demo","object":"chat.completion","created":...,"model":"mlx-commun

ity/Qwen3.5-2B-MLX-8bit","choices":[{"index":0,"delta":{"role":"assistant","con
tent":"
the"},"finish_reason":"length"}],"usage":{"prompt_tokens":11,"completio
  n_tokens":3,"total_tokens":14,...}}
  data: [DONE]
2026-05-14 16:32:54 +00:00
HeidarandClaude Opus 4.7 e8ec8d5010 fix ollama API compatibility for VS Code Copilot (#2091)
Ollama adapter fixes for VS Code Copilot (#2042):

  - /api/version: bare semver "1.0.0" - Copilot parseInts each segment.
- /api/show: populate model_info + capabilities - Copilot crashes on
null model_info and filters by `tools`.
- Add POST /ollama/v1/chat/completions - ollama serves the OpenAI-compat
route here, BYOK clients 405 without it.


Before:
<img width="1380" height="144" alt="image"
src="https://github.com/user-attachments/assets/99d5464f-187d-4432-9a31-8229c55aa209"
/>

After:
<img width="1362" height="181" alt="image"
src="https://github.com/user-attachments/assets/361dc006-d8df-435f-8d8b-4fa4f44a8c23"
/>
<img width="279" height="909" alt="image"
src="https://github.com/user-attachments/assets/4621aba7-bd57-4762-8568-34a3383a6025"
/>

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-14 16:12:58 +00:00
Heidar 1fd15d59fc create directory on startup (#2089)
## Motivation

<!-- Why is this change needed? What problem does it solve? -->
<!-- If it fixes an open issue, please link to the issue here -->

When you first run `uv run exo` you get an error like :

`FileNotFoundError: [Errno 2] No such file or directory:
'/Users/heidar/.exo/models'`

Manually tested on Macbook Pro M1 32GB

Fixes issue - https://github.com/exo-explore/exo/issues/2090
2026-05-14 16:03:51 +00:00
Evan Quiney 4466cd5323 use custom mlx sources for linux (#2087)
switch to hosting mlx sources on github & cachix instead of using a
broken version of mlx. closes #2043.
2026-05-13 10:45:11 +01:00
Andrei Cravtov ed2d10bdc6 Redirect runner stdout/stderr to file logs (#2084)
## Motivation

We want to use log mining tools like
[Drain3](https://github.com/logpai/Drain3) to get standardized error
formats, but for that we should record runner stdout/stderr in a massive
append-only log to gather training data for such tools. Also useful for
future opt-in telemetry.

## Changes

The stdout/stderr from runner now splits into 3 tasks: 
1) raw write to dedicated runner logs 
2) sanitized line-by-line logging with log-guru 
3) stub for further error-processing (i.e. turning lines into errors)

### Manual Testing
Works on 4x mac mini clusted connected as TB4 ring.
2026-05-12 11:48:08 +01:00
Andrei Cravtov 87c72fc1fd Fixes issue #2068 (#2083)
## Motivation

To fix https://github.com/exo-explore/exo/issues/2068

## Changes

Adds queue shutdown logic & hard-timeouts for closing server.

## Why It Works

Prevents API from hanging more than 5 seconds.
2026-05-11 12:15:22 +00:00
Evan Quiney b76bc30107 bump rust versions (#2081) 2026-05-10 17:11:46 +00:00
08ffa5f637 Map GLM 4.7 stop tokens to GLM 4 IDs (#2061)
## Motivation

GLM 4.7 reuses the GLM 4 chat-template tokenizer, but the model card and
EOS-detection path didn't have an explicit mapping for it, so
OpenAI-compatible clients didn't see a clean stop and the runner emitted
follow-on role turns (e.g. \`<|user|>\` continuations after
\`<|assistant|>\`'s output).

## Changes

\`src/exo/worker/engines/mlx/utils_mlx.py\` — add the GLM 4 stop-token
IDs as the EOS set when the loaded model's tokenizer matches GLM 4 / 4.7
chat templates.

## Why It Works

The GLM 4 tokenizer's \`<|user|>\`, \`<|observation|>\`, and
\`<|endoftext|>\` IDs are stable across the GLM 4 / 4.7 line; treating
any of them as EOS lets the runner stop at the assistant turn boundary
the same way it stops at \`</s>\` for Llama-style models. No
prompt-template changes — only the stop set widens.

## Test Plan

### Automated Testing

New unit test
\`src/exo/worker/tests/unittests/test_mlx/test_eos_token_ids.py\`
covering: GLM 4 / 4.7 path returns the expected stop ID set; non-GLM
path returns the standard EOS only.

\`\`\`
src/exo/worker/tests/unittests/test_mlx/test_eos_token_ids.py ..
=== 2 passed in 0.01s ===
\`\`\`

\`uv run basedpyright\` and \`uv run ruff check\` both clean.

### Manual Testing

Hardware: 4-node Apple Silicon cluster, M5 Max master.

- Loaded \`mlx-community/GLM-4.7-Air-mlx-4bit\`, ran chat completion via
\`/v1/chat/completions\`. Before this fix the assistant turn ran on into
a synthetic \`<|user|>\` continuation; after the fix the response stops
cleanly at the assistant boundary.

---------

Co-authored-by: jw-wcv <101585096+jw-wcv@users.noreply.github.com>
Co-authored-by: Evan Quiney <evanev7@gmail.com>
2026-05-10 17:02:22 +00:00
Andrei Cravtov 45df74ba98 Andrei/mp capture stdio (#2056)
## Motivation

Process-isolated runner crashes and C-extension failures can write
directly to fd-level stdout/stderr, bypassing Python/loguru. We need to
capture that output per runner process without polluting the main
process or other workers, and without breaking operation when the parent
stdio is detached.

## Changes

- Added `AsyncProcess`, a spawn-only multiprocessing wrapper that
redirects child stdout/stderr to pipes and exposes them as in-memory
`Receiver[bytes]`s
- Replaced runner-supervisor's raw `multiprocessing.Process` usage with
`AsyncProcess`
- Added `--no-stdio`, redirecting stdin/stdout/stderr to `/dev/null`
after logging is configured
- Disabled verbose MLX
- Added tests covering stdio capture, child crashes, repeated bad
children, SIGTERM/SIGKILL shutdown escalation, stdio detachment, and
spawning captured children from a stdio-detached parent

## Why It Works

The parent can redirect its own stdio fds to `/dev/null`, while
`AsyncProcess` installs fresh pipe fds over fd 1 and 2 inside each
spawned child. That keeps stdio-detached parents quiet while preserving
per-runner stdout/stderr capture. Runner shutdown is still bounded:
SIGTERM grace first, then SIGKILL escalation if needed.

Next direction: the runner supervisor currently drains captured output
and logs it as stdout/debug and stderr/warning. This should be split
into more useful process-isolated error reporting instead of just log
forwarding (regex match on errors to obtain "reason" string, best
effort).

## Test Plan

### Manual Testing

Ran on 4 Mac Minis in a Thunderbolt 4 ring, can see that runner's
stdout/stderr contents are being captured.

### Automated Testing

- Added async-process tests for fd-level stdout/stderr capture, Python
traceback capture, bounded-buffer output, child `exit`/abort, parent
stdio preservation, fd leak checks, spawn-context mp channels, and
SIGTERM/SIGKILL shutdown behavior
- Added stdio-detach tests proving stdio detaches to `/dev/null`, a
stdio-detached parent can still spawn and capture a child, and the same
stdio-detached parent can spawn/capture multiple children sequentially
- Updated runner-supervisor tests for the new `AsyncProcess.exitcode`
path
2026-05-09 22:45:14 +01:00
Kerollos Magdy ce37bdceb6 fix: Create directory for PID file if it doesn't exist (#2075)
Ensure the directory for the PID file exists before creating it.

## Motivation

Fixes https://github.com/exo-explore/exo/issues/2074

## Changes

<!-- Describe what you changed in detail -->

## Why It Works

<!-- Explain why your approach solves the problem -->

## Test Plan

### Manual Testing
<!-- Hardware: (e.g., MacBook Pro M1 Max 32GB, Mac Mini M2 16GB,
connected via Thunderbolt 4) -->
<!-- What you did: -->
<!-- - -->

### Automated Testing
<!-- Describe changes to automated tests, or how existing tests cover
this change -->
<!-- - -->
2026-05-09 12:10:22 +00:00
Andrei Cravtov e5a1e5dadb Create PID file locking for EXO (#2072)
## Motivation

EXO should be PID file locked, to prevent duplicate processes from
clobbering the log, right now this isn't the case.

## Changes

I added a wrapper around a Rust PID file lock library, and used it to
implement PID locking for EXO, with the PID file being in exo cache
directory.

## Test Plan

### Manual Testing
Tested on e11, trying to spawn duplicate EXO processes prevented.
2026-05-08 18:50:18 +01:00
ciaranbor fa57131374 Integration tests infra (#1995)
## Motivation

No automated integration tests exist for exo. Manual testing against
real hardware clusters is slow and error-prone. We need a pytest
framework that deploys clusters via `eco`, runs inference scenarios, and
tears down cleanly.

## Changes

- **`tools/src/exo_tools/`** — New workspace member shared by bench,
eval, and tests:
- `client.py` — `ExoClient` HTTP client (extracted from
`bench/harness.py`)
- `harness.py` — instance lifecycle helpers (placement, wait-for-ready,
etc.)
- `cluster.py` — `EcoSession` for eco cluster lifecycle
(deploy/stop/start/release/logs/exec) with unique `USER=<prefix>-<uuid>`
per session and atexit/signal cleanup
- **`tests/integration/`** — 17 pytest tests across 5 files:
- `test_1node.py` — place, chat, multi-turn, delete, state/models
endpoints, cluster snapshot, download-from-scratch
- `test_2node.py` — parametrized tensor/jaccl + pipeline/ring inference
and multi-turn
- `test_4node.py` — parametrized 4-node pipeline/ring inference, cluster
state
- `test_resilience.py` — full disconnect/reconnect cycle (2-node →
disconnect → 1-node → reconnect → 2-node)
- `test_dashboard.py` — Playwright: dashboard loads, shows node info,
chat flow
- `helpers.py` — placement/inference helpers, re-exports from
`exo_tools`
- `conftest.py` — session-scoped cluster fixtures with constraint-based
eco reservations; `--hosts` override; `EXO_REF` env var for CI
deployments from a GitHub branch
- **`bench/`** — Updated imports from `exo_tools.client` /
`exo_tools.harness`
- **`pyproject.toml`** — Added `tools` workspace member, `playwright`
dev dep, `--ignore=tests/integration`

## Why It Works

Tests use `eco` for cluster lifecycle and `ExoClient` for API
interactions — same tools humans use. Session-scoped fixtures deploy
once per file. Unique eco users prevent test runs from interfering with
each other or manual usage.

## Test Plan

### Automated Testing

- `uv run pytest tests/integration/ -v -s` — full suite (~4-5 min, 17/17
passing)
- `uv run pytest tests/integration/ -v -s --hosts s4,s9,s10,s22` — pin
specific hosts
- `EXO_REF=main uv run pytest tests/integration/ -v` — deploy from a
GitHub branch (CI)
- `uv run pytest` — confirms integration tests are excluded from default
runs
2026-05-08 17:15:08 +01:00
Alex Cheema 414132ae9c Use time-weighted power sampling (#2038)
## Why

The power sampler currently averages sampled wattage values
arithmetically. That can be materially wrong when sample intervals are
uneven: a short high-power spike gets the same weight as a long steady
interval. Energy should be computed by integrating power over time, and
average power should be derived from energy / elapsed time.

## How

- Store each power sample with its relative timestamp.
- Anchor the first sample at `t=0` and take a final sample at `elapsed`
when producing results.
- Integrate per-node power using the trapezoidal rule.
- Sum node energy for total cluster energy, then derive total average
system power from total energy / elapsed.
- Add focused unit tests for uneven sample intervals and the
single-sample fallback.

## Tests

- `uv run pytest src/exo/utils/tests/test_power_sampler.py`
- `uv run basedpyright`
- `uv run ruff check src/exo/utils/power_sampler.py
src/exo/utils/tests/test_power_sampler.py`
- `nix fmt`
2026-05-07 10:42:14 +00:00
Alex Cheema edef8004f8 Store custom model cards in State (#2024)
## Why

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

## How

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

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

## Tests

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

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

## Details

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

## Tests

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

## Test plan

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

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

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

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

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

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

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

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

Applies to both SequentialGenerator and BatchGenerator.

## Motivation

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

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

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

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

## Changes

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

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

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

## Why It Works

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

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

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

## Test Plan

### Manual Testing

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

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

### Automated Testing

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

---------

Co-authored-by: ciaranbor <ciaranborourke-dev@proton.me>
2026-05-05 17:27:57 +01:00
Sam BradburyandSam Bradbury 9c6ff4ce95 feat: update rdma_ctl instructions (#1977)
## Motivation

The RDMA setup instructions were missing a step: after booting to
Recovery mode, users need to open Terminal from the Utilities menu
before they can run the `rdma_ctl` command. Without this step, users
following the instructions wouldn't know how to access a terminal in
Recovery mode. This step was already in the README just not in the UI
notifications.

## Changes

Added a missing instruction step — "Open Terminal from the Utilities
menu" — to three instances of the RDMA setup flow in
`dashboard/src/routes/+page.svelte`.

## Why It Works

N/A copy change only. 

## Test Plan

### Manual Testing
Hardware: MacBook Pro M4 Max 48GB

### Automated Testing
No automated tests affected; this is a UI copy change only.

Co-authored-by: Sam Bradbury <sam@consultbradbury.com>
2026-05-01 11:18:57 +00:00
ecohash-coandJordan Miller b26268dfaf fix(macos-app): disable URL response caching for cluster-state polling (#2005)
Fixes #2004.

`ClusterStateService` polls `/state` at 2 Hz via `URLSession.shared`,
which keeps an on-disk `URLCache` attached by default. Every polled
response body gets persisted under `~/Library/Caches/exolabs.EXO/`,
sustaining ~500–620 KB/sec of file-backed memory dirtied — far above
macOS's ~25 KB/sec per-process daily-average baseline. Six
microstackshot reports observed on a single Mac Studio M3 Ultra over
eight days, with one 15-hour run accumulating 34.36 GB of cache writes.

Heaviest stack on every diagnostic report (96–98% of samples):

```
_dispatch_workloop_worker_thread → _dispatch_block_async_invoke2 →
  __CFURLCache::CreateAndStoreCacheNode → write
```

Full diagnostic data and analysis in #2004.

## What changed

`ClusterStateService` now defaults to an ephemeral, non-caching
`URLSession` instead of `URLSession.shared`. Cluster-state responses are
time-sensitive and small; nothing benefits from being cached on disk.

```swift
private static func makeNonCachingSession() -> URLSession {
    let config = URLSessionConfiguration.ephemeral
    config.urlCache = nil
    config.requestCachePolicy = .reloadIgnoringLocalCacheData
    return URLSession(configuration: config)
}
```

The existing per-request `request.cachePolicy =
.reloadIgnoringLocalCacheData` calls are kept as defense in depth — they
only affect read behavior, but harmless to leave alongside the
session-level config.

## Scope

- **Behavioral**: none. Polled requests still go out at the same
cadence; responses still parse the same; no semantic change to any API
surface.
- **Test injection**: the `session:` parameter remains in `init`, so
tests can still inject a custom mock session unchanged.
- **`BugReportService` and other `URLSession.shared` callers**:
untouched. If maintainers prefer an app-wide URLCache disable instead,
happy to switch the approach (issue body has the alternative spelled
out).

## Verification

Verified locally that compiling EXO with this change produces a working
menubar app and `ClusterStateService` continues to fetch state
correctly. After ~30 min of idle polling, no new entries in
`/Library/Logs/DiagnosticReports/EXO_*.diag` and no growth in
`~/Library/Caches/exolabs.EXO/`.

## Test plan
- [ ] Build EXO from this branch on macOS 26.4
- [ ] Launch, let cluster state polling run for 30+ min
- [ ] Confirm no new microstackshot diagnostic reports
- [ ] Confirm `~/Library/Caches/exolabs.EXO/Cache.db*` does not grow

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

Co-authored-by: Jordan Miller <jordan.d.miller@gmail.com>
2026-05-01 10:41:10 +00:00
ciaranbor 8dae3ecb9a A few targeted tweaks to address HF rate limits (#2009)
## Motivation

- exo bursts ~200 HF Hub-API requests on every cold start, blowing past
the anonymous 500-req/5-min budget.
- The existing retry loop catches 429 generically and gives up in ~3s —
well before HF's reset window.
- `file_meta` and `_download_file` had no 429 handling at all (became
`AssertionError`).
- Disk file-list cache was bypassed on every process restart.

## Changes

All in `src/exo/download/download_utils.py` + tests.

- Parse `t=` from HF's `RateLimit` header on 429; sleep `min(t, 300s) +
jitter`.
- Handle 429 at all three call sites (`_fetch_file_list`, `file_meta`,
`_download_file`).
- `n_attempts`: 3 → 5.
- Disk cache now primary across restarts (24h mtime TTL).
- `?recursive=true` instead of N+1 subdir walks.

## Why It Works

`t=<seconds>` is HF's "wait this long and you'll be unblocked" —
sleeping that long lets the window reset. Disk-cache-as-primary plus
recursive listing cuts cold-start Hub-API traffic by ~10×.

## Test Plan

### Manual Testing

MacBook Pro M1 Max. Tripped the real HF 429. Pre-fix: failed in 3.4s.
Post-fix: slept (HF returned `t=158`) and recovered.

### Automated Testing

- New `test_rate_limit_handling.py` (19 tests) — header parsing,
retry-loop behaviour, plus HTTP-level coverage that mocks aiohttp to
return a 429 and asserts each call site raises
`HuggingFaceRateLimitError(retry_after=52.0)`.
- New `TestFileListCacheTTL` in `test_offline_mode.py` — fresh cache
hits, stale cache refetches.
- 421 tests pass; basedpyright / ruff / nix fmt clean.
2026-04-30 18:06:15 +00:00
Alex CheemaandClaude Opus 4.7 fb12b403ea fix(app): tighten Share Bug Report prompt layout (#2008)
## Summary

Follow-ups to #2003 based on feedback that the Share Bug Report window
felt visually weighty: too much padding above and below, and a
description editor that invited an essay rather than a one-liner.

## Changes (one file)

`app/EXO/EXO/Views/BugReportWindowController.swift`:

- **Auto-size the window to its content.** Switched from `NSHostingView`
+ fixed `contentRect: 480x380` + SwiftUI `frame(minHeight: 320)` to
`NSHostingController` with `sizingOptions = [.preferredContentSize,
.minSize]`. The fixed-min combo was centering the form in dead vertical
space.
- **Smaller, lower-pressure editor.** Field is now labeled `Description
(optional)` with a placeholder hint (`What were you doing when it
broke?`) inside the editor. Editor height fixed at 72pt (was 120pt min).
Replaced the long lead-in paragraph and headline with a single one-line
caption between field and buttons: `Diagnostic logs will be uploaded
with your report.`
- **Tighter spacing.** Outer padding 20 -> 16, root spacing 16 -> 12,
prompting-section spacing 12 -> 8.
- **Remove em dash from copy.**

`BugReportService` and the menu wiring are unchanged.

## Test plan

- [ ] Click `Share Bug Report...` from the menu bar.
- [ ] The window opens centered and sized to its content (no big empty
bands top/bottom).
- [ ] Description editor is visibly compact, with the placeholder hint
showing when empty.
- [ ] The optional-ness is conveyed by the field label (no separate help
paragraph).
- [ ] Caption `Diagnostic logs will be uploaded with your report.`
appears in `.caption` style under the editor, above the buttons.
- [ ] Resize the window: persists across re-opens (frame autosave still
works).
- [ ] Send/Cancel/Try Again/Done flows behave the same as before.

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

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-30 15:10:26 +01:00
Alex CheemaandClaude Opus 4.7 1606e63816 feat(app): open Share Bug Report in a dedicated window (#2003)
## Summary

- Adds a top-level **Share Bug Report…** menu item to the macOS popover
(between *Check for Updates* and *Quit*) with SF Symbol `ladybug`.
- Clicking it opens a dedicated resizable `NSWindow` ("Send a Bug
Report") that hosts the prompting / sending / success / failure flow.
- Removes the description-less duplicate from Settings → Debug Info, and
the dead `debugSection` it nominally lived behind.

## Why

PR #1959 added a user-description prompt to the bug-report flow, but its
trigger lived inside `ContentView.debugSection` — a view that's defined
but never rendered in the body. The path users actually hit was
`SettingsView.sendBugReportButton`, which called
`BugReportService.sendReport(isManual: true)` without ever passing
`userDescription`. So the description prompt was unreachable in the
built app.

## Approach

Per Apple HIG, an action that requires further input before completing
should open a dialog, not transform the menu inline. So:

- Add a top-level menu entry that ends in `…` (HIG: ellipsis indicates
"further input required").
- Move the prompting/sending/success/failure state machine into a
standalone `BugReportWindowController` modeled after the existing
`SettingsWindowController`.
- Single-instance window with frame-autosave name, sensible
`contentMinSize`, resizable, native button layout (`.cancelAction` /
`.defaultAction` keyboard shortcuts), light/dark-mode-correct
`.textBackgroundColor` and `.separatorColor`.
- Auto-focus the description field on open. `Try Again` from failure,
`Open GitHub Issue` + `Done` from success.

## Files

- `app/EXO/EXO/Views/BugReportWindowController.swift` (new) — controller
+ view.
- `app/EXO/EXO/EXOApp.swift` — wire `BugReportWindowController` as a
`@StateObject` and inject as environment object.
- `app/EXO/EXO/ContentView.swift` — replace inline state machine with
menu item that calls `bugReportWindowController.open()`. Remove
now-unused state, helpers, and dead `debugSection`.
- `app/EXO/EXO/Views/SettingsView.swift` — remove duplicate
`sendBugReportButton`, `sendBugReport()`, and related `@State`. Section
"Debug Info" keeps Thunderbolt / interface / RDMA info.

`BugReportService` is unchanged.

## Test plan

- [ ] Open the menu-bar popover → confirm **Share Bug Report…** appears
between *Check for Updates* and *Quit*, with a ladybug icon.
- [ ] Click it → a window titled "Send a Bug Report" appears, centered,
with the description editor focused.
- [ ] Resize the window → size persists across re-opens (frame
autosave).
- [ ] Type a description, press Return → upload succeeds, success card
with **Open GitHub Issue** + **Done** appears.
- [ ] Click **Open GitHub Issue** → browser opens with the description
pre-filled into the issue template.
- [ ] Send with empty description → upload still succeeds.
- [ ] Press Esc from the prompting state → window closes.
- [ ] On failure (e.g., offline) → error card with **Try Again** +
**Close** appears; Try Again returns to the editor with the description
preserved.
- [ ] Open the Settings window → Debug Info section is unchanged except
the Send Bug Report button is gone.

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

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-30 13:05:07 +01:00
667a3bb0e5 feat: keep-models option when uninstalling EXO (#1997)
## Summary

- Adds a **Keep downloaded models (~/.exo/models)** checkbox to the
macOS uninstall confirmation dialog (Settings → Advanced → Danger Zone).
The full `~/.exo` directory is now removed on uninstall by default; if
the checkbox is checked, `~/.exo/models` is preserved.
- The standalone `app/EXO/uninstall-exo.sh` gains a matching
`--keep-models` flag and the same `~/.exo` cleanup so GUI and CLI flows
stay in sync. Resolves the user home via `$SUDO_USER` since the script
runs under `sudo`.

Previously, "Uninstall EXO" only cleaned up system-level components
(LaunchDaemon, network location, logs, app bundle) and left the entire
`~/.exo` directory behind. Now uninstalling actually removes EXO's user
data, with a one-click opt-out for the (potentially many GB) of
downloaded models.

![Uninstall dialog with new
checkbox](https://raw.githubusercontent.com/exo-explore/exo/703b7fbbf13441217ad2903bb199f07e92af4490/uninstall-dialog.png)

> Note: the rendered icon in the screenshot above is the generic system
folder icon because it was captured from a small standalone Swift binary
(no app bundle / icon resource). When triggered from the actual EXO.app,
the EXO app icon is shown.

## Test plan

- [ ] Build EXO.app locally; open Settings → Advanced → Danger Zone →
Uninstall EXO; confirm the new "Keep downloaded models (~/.exo/models)"
checkbox is present and unchecked by default.
- [ ] Uninstall with the checkbox **checked** → `~/.exo/models/`
survives, all other entries under `~/.exo` are gone, system components
removed, app moved to Trash.
- [ ] Uninstall with the checkbox **unchecked** → `~/.exo` is fully
removed.
- [ ] `sudo app/EXO/uninstall-exo.sh --keep-models` → `~/.exo/models/`
is preserved, the rest of `~/.exo` is removed.
- [ ] `sudo app/EXO/uninstall-exo.sh` (no flag) → `~/.exo` is fully
removed.
- [ ] `app/EXO/uninstall-exo.sh --help` prints usage and exits 0;
unknown args exit 2 with a usage hint.

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

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: Evan <evanev7@gmail.com>
2026-04-28 01:06:02 +00:00
Evan Quiney c80b10c013 implement engine abstraction for mlx and mflux (#2000)
refactor for future versions.
2026-04-28 00:58:17 +00:00
Alex CheemaandClaude Opus 4.7 18ffe1df23 fix: uninstall-exo.sh removes both current and legacy bridge scripts (#1998)
## Summary

The standalone `app/EXO/uninstall-exo.sh` only knew about the legacy
filename `disable_bridge_enable_dhcp.sh`. On machines installed with
newer EXO versions, the current `/Library/Application
Support/EXO/disable_bridge.sh` was left behind, and the script then
reported `EXO support directory not empty, leaving in place`.

This PR makes the script try both filenames, removing whichever ones
exist. Tolerates **either**, **both**, or **neither** being present
without erroring.

The Swift `NetworkSetupHelper.makeUninstallScript()` already handles
both paths correctly, so the GUI uninstall flow is unaffected — this is
a script-only fix.

Caught while running an end-to-end uninstall on a real machine for
#1997.

## Test plan

Verified the new block in isolation against all four states:

- [x] both `disable_bridge.sh` and `disable_bridge_enable_dhcp.sh`
present → both removed
- [x] only `disable_bridge.sh` present → removed cleanly
- [x] only `disable_bridge_enable_dhcp.sh` present → removed cleanly
(legacy install)
- [x] neither present → prints the existing "already removed?" warning,
exits 0

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

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 00:28:12 +00:00
rltakashige f0d1371d89 MLX P/D (#1993)
## Motivation

MLX only prefill server for Apple Silicon
2026-04-28 00:12:42 +00:00
5d10188d3a fix: route by in-flight tasks only — completed tasks were skewing load balance (#1989)
The load balancer counted ALL tasks (Complete, Cancelled, TimedOut,
Failed) instead of only Pending/Running ones. With 138 accumulated tasks
and only 7 active, routing decisions were based on historical
distribution, causing one node to appear permanently 'busier' and
starving the other of work.

Co-authored-by: Adam Durham <adam@example.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-27 16:03:12 +00:00
ciaranbor f2a0db4e23 Extend bench/eval tooling (#1905)
## Motivation

Extend bench/eval tooling with robustness features, streaming support,
and align model configs with vllm eval for reproducible comparisons.

## Changes

- **exo_eval**: Checkpoint/resume (JSONL), instance health monitoring +
early abort, `top_k`/`min_p`/`enable_thinking` params, LCB
`--release-version`/`--offset`
- **exo_bench**: Streaming SSE (`--stream`), Kimi tokenizer fix for
transformers 5.x
- **Both tools**: Auto-detect running instances instead of requiring
`--skip-instance-setup`; `--fresh-instance` to override
- **harness**: SSE streaming client, `find_existing_instance()` shared
helper, removed download timeout, settle-timeout default 0→7200s
- **models.toml**: Added `enable_thinking`, aligned `max_tokens`/temps
with vllm, added new models
- **API**: Streaming SSE for `/bench/chat/completions`

## Why It Works

- Checkpoint/resume uses append-only JSONL + skip-on-load so interrupted
evals resume without re-running completed questions
- Health monitoring races an `asyncio.Event` against API calls for fast
abort when the instance dies
- Auto-detection queries `/state` for existing instances matching the
model ID before attempting placement
- Streaming reuses the existing `generate_chat_stream` infrastructure
from the regular chat endpoint
2026-04-27 16:53:43 +01:00
rltakashigeandEvan 37f6f4f6c2 Add DeepSeek V4 Flash/Pro (#1978)
Wait for upstream merge.

---------

Co-authored-by: Evan <evanev7@gmail.com>
2026-04-27 15:20:50 +01:00
Adam DurhamandAdam Durham 48a922fd5c fix: map presence_penalty and frequency_penalty from ChatCompletionRequest (#1991)
Upstream PR #1947 added `presence_penalty` and `frequency_penalty` to
`TextGenerationTaskParams` and the mlx-lm generator call sites, but
missed wiring them up in the API adapter so they were silently dropped
from incoming requests. This fixes the API mapping.

Co-authored-by: Adam Durham <adam@example.com>
2026-04-27 08:58:59 +00:00
rltakashige fd707de30b Add more model cards (#1970) 2026-04-23 15:28:40 +01:00
Alex CheemaandClaude Opus 4.7 45248c5c85 chore(app): hardcode bug report presigned-URL endpoint (#1971)
## Motivation

The bug-report presigned-URL endpoint
(`https://reports.exolabs.net/presigned-urls`) was injected at build
time from the `EXO_BUG_REPORT_PRESIGNED_URL_ENDPOINT` GitHub Actions
secret into `Info.plist`, then read at runtime by `BugReportService`. It
isn't actually a secret — the POST body is just `{"keys":[...]}` with no
credential (see `app/EXO/EXO/Services/BugReportService.swift:136-142`),
abuse prevention lives server-side on the lambda, and the URL is already
visible in every publicly-distributed DMG's `Info.plist`. Treating it as
a repo secret added plumbing with no security benefit and broke local
dev builds — hitting **Send Bug Report** on an uncustomised `just
build-app` raised "Bug report endpoint is invalid".

## Changes

- `app/EXO/EXO/Info.plist`: replace
`$(EXO_BUG_REPORT_PRESIGNED_URL_ENDPOINT)` with the literal URL.
- `.github/workflows/build-app.yml`: drop the
`EXO_BUG_REPORT_PRESIGNED_URL_ENDPOINT` job-level env var and the
xcodebuild build-setting passthrough. No other workflow changes.

Swift code is unchanged — `BugReportService` still reads from
`Info.plist`, which leaves an escape hatch if anyone ever needs to
override via `xcodebuild EXOBugReportPresignedUrlEndpoint=...` without
recompiling.

Follow-up: the `EXO_BUG_REPORT_PRESIGNED_URL_ENDPOINT` repo secret can
now be deleted in the GitHub Actions settings UI.

## Why It Works

`Info.plist` variable substitution turns `$(FOO)` into whatever build
setting `FOO` resolves to. CI was setting `FOO` via xcodebuild; local
dev wasn't, so the key resolved to an empty string, which
`BugReportService.fetchPresignedUploadUrls` rejects via the
`!trimmedEndpointString.isEmpty` guard at `BugReportService.swift:131`.
Hardcoding the literal string removes the substitution entirely, so
every build — local or CI — gets the right value.

## Test Plan

### Manual Testing
<!-- Hardware: MacBook Pro (macOS app build via Xcode) -->
- `just build-app` with no extra env vars (reproduces the failure path
on `main`).
- `/usr/libexec/PlistBuddy -c "Print :EXOBugReportPresignedUrlEndpoint"
app/EXO/build/Build/Products/Debug/EXO.app/Contents/Info.plist` →
returns `https://reports.exolabs.net/presigned-urls` (was empty before
this change).
- `open app/EXO/build/Build/Products/Debug/EXO.app` → menubar → **Debug
Info** → **Send Bug Report** → type a description → **Send** → upload
succeeds and the **Create GitHub Issue** button appears (was failing
with "Bug report endpoint is invalid" before).
- Cross-check on the Slack side that the uploaded `report.json` lands
under `reports/YYYY/MM/DD/<ts>/` as before.

### Automated Testing
<!-- Describe changes to automated tests, or how existing tests cover
this change -->
- No new tests. This is a single-string change to `Info.plist` plus a
workflow cleanup. `nix flake check` in CI verifies formatting/lint for
the rest of the tree.

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

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 14:08:16 +00:00
rltakashige 290e3fd927 Keep image cache fresh (temporary fix) (#1961)
## Motivation

When a new node joins, it might not have the cache.



Caveat: 
This is potentially fallible if a new node joins and updates real
topology, but the API topology hasn't caught up with this fact and the
user queues up a new text generation. In practice, there is only a split
second where this is the case, and this is only for users of the
dashboard interface. We should fix this properly after the release.
2026-04-23 11:39:36 +01:00
rltakashige 3894cf134e Fix Gemma 4 E2B TP + DeepSeek V32 thinking parsing (#1967) 2026-04-23 01:50:39 +00:00
Alex CheemaandClaude Opus 4.7 8993ccaf09 feat(app): add friendly context message to bug report prompt (#1959)
## Motivation

When a user clicks **Send Bug Report** in the macOS app, we already give
them the option to add more context via an optional text field. But the
current prompt is just a terse label — `"What's the issue? (optional)"`
— which doesn't tell the user why bothering to fill it in matters. A
friendly one-line explanation increases the chance they'll describe what
went wrong, which is the single most useful signal when we triage the
resulting diagnostic bundle.

## Changes

- `app/EXO/EXO/ContentView.swift`: In the `.prompting` phase of
`sendBugReportButton`, replace the single label with a two-line
hierarchy:
  - Primary: `Tell us what went wrong (optional)`
- Helper: `A quick description of what you were doing and what happened
helps us track down the bug for you.`
- The helper uses `.caption2` + `.secondary` + `.opacity(0.8)` +
`.fixedSize(horizontal: false, vertical: true)` so it stays visually
subordinate and wraps cleanly inside the 340pt popover.

No changes to `BugReportService`, the `user_description` payload, or any
other flow.

## Why It Works

The optional description is already plumbed end-to-end (text editor →
`bugReportUserDescription` state → `BugReportService.sendReport(...,
userDescription:)` → `report.json`'s `user_description` field → GitHub
issue pre-fill). The only gap was user-facing motivation, so this is
purely a copy/layout tweak inside the existing `.prompting` case — no
new state, bindings, or service changes.

## Test Plan

### Manual Testing
<!-- Hardware: MacBook Pro (macOS app build via Xcode) -->
- Build the macOS app in Xcode (`app/EXO/EXO.xcodeproj`) and launch it.
- Open the menubar popover → expand **Debug Info** → click **Send Bug
Report**.
- Verify the new primary label and helper sentence both appear above the
text editor and wrap cleanly within the popover width.
- Leave the field empty → click **Send** → upload should succeed (no
`user_description` in payload, same as before).
- Fill in a description → click **Send** → upload succeeds and the
success card with **Create GitHub Issue** appears; clicking it opens
GitHub with the description pre-filled.
- Click **Cancel** from the prompting state → returns to idle.

### Automated Testing
<!-- Describe changes to automated tests, or how existing tests cover
this change -->
- No new automated tests. This is a SwiftUI copy/layout change; existing
`EXOTests` are smoke-level and don't cover `ContentView` view bodies,
and UI snapshot tests aren't worth adding for a two-line copy tweak.
- `nix fmt` reports 0 files changed after the edit; `nix flake check` in
CI will verify formatting/lint for the rest of the tree.

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

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 17:39:36 +00:00
Nadeem Hilal Wani 4939fbe995 feat(dashboard): add Pi integration tab (#1925)
## Summary
Adds a new **Pi** tab to the Integrations page (`/#/integrations`)
alongside the existing Claude Code, OpenCode, Codex, OpenClaw, Open
WebUI, n8n, and Firefox tabs.

[pi](https://pi.dev) (`@mariozechner/pi-coding-agent`) is a terminal
coding agent that supports custom OpenAI-compatible providers via
`~/.pi/agent/models.json`.
This tab gives users a copy-pasteable config to wire pi up to their exo
cluster.

## What's in the tab
- **Model selector** (shown when multiple models are running) — picks
the default model for the generated shell command.
- **Models Config card** — generates `~/.pi/agent/models.json`
registering `exo` as a custom provider:
     - `baseUrl` → `<apiUrl>/v1`
     - `api` → `openai-completions`
     - `apiKey` → `"exo"` (placeholder; exo ignores it)
- `compat.supportsDeveloperRole: false` and
`compat.supportsReasoningEffort: false`, per pi docs recommendation for
local OpenAI-compatible servers
- Auto-populates every running model with `id`, `contextWindow` (from
`/v1/models`), and `input: ["text", "image"]` for vision-capable models
- **Shell Command card** — `pi --provider exo --model <model>` for quick
launch.

The tab gracefully falls back to `your-model-id` when no models are
running, matching the behavior of the other tabs.

   ## Usage

   1. `npm install -g @mariozechner/pi-coding-agent`
   2. Paste the generated config into `~/.pi/agent/models.json`
3. Run `pi` and pick an exo model via `/model` — or run the shell
command directly

   ## Changes

- `dashboard/src/routes/integrations/+page.svelte` — adds `"Pi"` to the
`tabs` tuple, `piModel` state, `piModelsJson` + `piShellCommand`
derivations, and the tab content block.

   Single-file, scoped change — no backend or type changes.

   ## Testing

   - `cd dashboard && npm run build` —  builds cleanly
   - `svelte-check` on the edited file — no new errors
- Manually verified the tab renders, the model selector updates the
generated JSON, and the config reflects `/v1/models` capabilities
(vision → `input: ["text","image"]`,
 `context_length` → `contextWindow`).

   ## Screenshots

<img width="1545" height="1236" alt="pi-tab"
src="https://github.com/user-attachments/assets/38aa179f-4ed9-4a1e-9783-d3baa7738263"
/>
2026-04-22 17:29:47 +00:00
rltakashige 73782ecc65 Fix event mutation causing indexed vs event mismatch (#1964)
Fixes small issue with #1957
2026-04-22 16:12:24 +00:00
rltakashige f6e418ed23 Cleanup on #1952 (#1960) 2026-04-22 17:05:49 +01:00
rltakashigeandEvan 7a312a177b Misc fixes: upstream JACCL all_sum, API, etc. + Add Kimi K2.6 (#1952)
## Motivation

This fixes a bunch of observed model quality issues introduced upstream
in JACCL, as well as API issues and prefix cache calculation.


## Test Plan

### Manual Testing
Tested a bunch

### Automated Testing
Added a test, automated eval tool calls on Kimi K2.6, Minimax M2.7, GPT
OSS and Qwen3.6 models.

---------

Co-authored-by: Evan <evanev7@gmail.com>
2026-04-22 15:43:27 +00:00
Evan Quiney 0a549f8846 remove layer loading callback (#1890)
first part of modularising the backend is simplifying some of the
control flow. more tbd.
2026-04-22 14:03:31 +01:00
Evan Quiney df332035ef swap camelcasemodels for frozenmodels globally (#1957) 2026-04-22 11:49:25 +00:00
ciaranbor af673845d3 Ignore HF remote repo changes (temporary fix) (#1958)
## Motivation

Fixes #1918. Downloaded model status reverts from "completed" to
"pending" during each download scan. Reproduced with `zai-org/GLM-5.1`.

## Changes

- `coordinator.py`: In the periodic rescan, don't downgrade
already-completed models; fall back to `resolve_existing_model()`
(safetensors weight check) when per-file size check reports incomplete
- New `test_download_status_not_lost.py`: 3 regression tests

## Why It Works

The rescan compares local file sizes against HF's `main` revision. When
HF updates text files (README, jinja, etc.), remote sizes change but
local files still match the old revision — causing a false "incomplete".
The fix uses the safetensors weight check as ground truth instead.

Long-term: pin the downloaded revision SHA rather than always checking
against `main`.

## Test Plan

### Manual Testing

- Mac Studio M3 Ultra with GLM-5.1 downloaded (natural reproduction of
the issue)
- Confirmed GLM-5.1 stays `DownloadCompleted` through multiple rescan
cycles

### Automated Testing

- 3 new tests: completed-not-downgraded, fallback-to-resolve,
genuinely-incomplete-stays-pending
2026-04-22 11:11:01 +01:00
ciaranbor 49670c8624 Handle missing total_size in safetensors index files (#1956)
## Motivation

Image models fail to load after a mid-download instance deletion and
recreation. The system skips the download and crashes with
`FileNotFoundError: No safetensors files found in .../vae`.

## Changes

- Make `ModelSafetensorsIndexMetadata.total_size` optional (`PositiveInt
| None = None`)
- Add null guard in `fetch_safetensors_size`
- Add regression test

## Why It Works

Exolabs quantized image models have safetensors index files with mflux
metadata (`quantization_level`, `mflux_version`) but no `total_size`.
The required `PositiveInt` field caused Pydantic validation to fail,
which was silently swallowed by `except Exception: continue` in
`_scan_model_directory`. This skipped all weight map checks, making
incomplete models appear complete.

## Test Plan

### Manual Testing

- Hardware: Mac Studio
- Before: `CreateRunner → LoadModel` (crash). After: `CreateRunner →
DownloadModel` (correct).

### Automated Testing

- `test_safetensors_index.py`: 3 cases covering missing, valid, and null
metadata
2026-04-21 16:39:14 +01:00
rltakashige fcc3718efb Add sampling defaults (#1947)
## Motivation

Model quality issues

### Manual Testing
TODO
2026-04-21 06:45:33 +00:00
rltakashige 8ccfd7fcb6 Fix some misc build issues (#1948)
## Motivation

<!-- Why is this change needed? What problem does it solve? -->
<!-- If it fixes an open issue, please link to the issue here -->

## Changes

<!-- Describe what you changed in detail -->

## Why It Works

<!-- Explain why your approach solves the problem -->

## Test Plan

### Manual Testing
<!-- Hardware: (e.g., MacBook Pro M1 Max 32GB, Mac Mini M2 16GB,
connected via Thunderbolt 4) -->
<!-- What you did: -->
<!-- - -->

### Automated Testing
<!-- Describe changes to automated tests, or how existing tests cover
this change -->
<!-- - -->
2026-04-21 07:41:07 +01:00
Evan Quiney 7b416155de bump uv lock for linux builds (#1942)
followup from a rebase issue in #1874
2026-04-20 11:41:40 +00:00
Evan Quiney 93a24748e6 mlx cuda 13 (dgx spark) support (#1874) 2026-04-20 11:49:12 +01:00
Evan Quiney e32829e51d chore: bump versions in line with release (#1941) 2026-04-20 09:39:28 +00:00
rltakashige 09e894dd52 Fix vision models on M5 Pro/Max MacBooks (#1927)
## Motivation

Vision models don't understand images on M5 series MacBooks. The
upstream NAX addmm fix (https://github.com/ml-explore/mlx/pull/3422)
fixes this.

## Why It Works
Same conclusion I came to when I was debugging the issue on an M5 Max.
It works after this fix.

## Test Plan

### Manual Testing
Works for Qwen3.5 27B
2026-04-19 12:55:20 +01:00
rltakashige bf8aacfd41 Improve build CI (#1920)
## Motivation

<!-- Why is this change needed? What problem does it solve? -->
<!-- If it fixes an open issue, please link to the issue here -->

## Changes

<!-- Describe what you changed in detail -->

## Why It Works

<!-- Explain why your approach solves the problem -->

## Test Plan

### Manual Testing
<!-- Hardware: (e.g., MacBook Pro M1 Max 32GB, Mac Mini M2 16GB,
connected via Thunderbolt 4) -->
<!-- What you did: -->
<!-- - -->

### Automated Testing
<!-- Describe changes to automated tests, or how existing tests cover
this change -->
<!-- - -->
2026-04-17 20:55:15 +00:00
af9e847edb fix: force gc + clear_cache after KV prefix cache eviction (#1832)
## Summary
- After `KVPrefixCache` evicts LRU entries, the MLX Metal buffers stay
allocated until Python's GC runs
- This leaks ~3-4 GB between long-context requests, reducing the
effective context ceiling for back-to-back requests
- Adding `gc.collect()` + `mx.clear_cache()` after eviction frees Metal
buffers promptly

## Test plan
- [x] Measured on 2-node PP cluster with Qwen3.5-397B-A17B-4bit at 63K
context
- [x] Before: 108.88 GB retained after eviction (3.78 GB above baseline)
- [x] After: 105.48 GB retained after eviction (0.38 GB above baseline —
draft model KV + minor overhead)
- [x] `gc.collect()` adds ~2-3ms latency, runs once per eviction cycle
(not per token)
- [ ] Verify with `uv run pytest`

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

---------

Co-authored-by: Adam Durham <adam@example.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-authored-by: rltakashige <rl.takashige@gmail.com>
2026-04-17 17:57:32 +00:00
mlpy0 01598960bd Add model card for Qwen3.6-35B-A3B-8bit (#1917)
Adds the 8bit variant missing from #1907 — the safetensors index is now
live on HF.

- `mlx-community/Qwen3.6-35B-A3B-8bit` (~35 GB)

Architectural fields match the existing 4bit/5bit/bf16 cards.
`storage_size.in_bytes` is taken from `metadata.total_size` of the
upstream `model.safetensors.index.json`.
2026-04-17 10:06:23 +00:00
63b8e64715 Add model cards for Qwen3.6-35B-A3B variants (#1907)
## Motivation

`mlx-community` has just published the new **Qwen3.6-35B-A3B**
multimodal MoE family on HuggingFace. Without static model cards exo
doesn't surface these models in the dashboard picker or match its
placement / prefill logic, so users can't one-click launch them. This PR
adds cards for the three quants whose safetensors indexes are already
live on HF (4bit / 5bit / bf16).

## Changes

Three new TOML files in `resources/inference_model_cards/`:

- `mlx-community--Qwen3.6-35B-A3B-4bit.toml` (~19 GB)
- `mlx-community--Qwen3.6-35B-A3B-5bit.toml` (~23 GB)
- `mlx-community--Qwen3.6-35B-A3B-bf16.toml` (~65 GB)

All three share the same architectural fields (`n_layers = 40`,
`hidden_size = 2048`, `num_key_value_heads = 2`, `context_length =
262144`, capabilities `text, thinking, thinking_toggle, vision`,
`base_model = "Qwen3.6 35B A3B"`) — only `model_id`, `quantization`, and
`storage_size.in_bytes` differ between variants.

## Why It Works

- Qwen3.6-35B-A3B reuses the `qwen3_5_moe` architecture
(`Qwen3_5MoeForConditionalGeneration`) — the same one already wired into
exo's MLX runner at `src/exo/worker/engines/mlx/auto_parallel.py:47` via
`Qwen3_5MoeModel`. The architectural fields are taken verbatim from the
HF `config.json.text_config` and match the existing `Qwen3.5-35B-A3B-*`
cards.
- Storage sizes are the exact `metadata.total_size` read from each
variant's `model.safetensors.index.json` on HF, so download progress and
cluster-memory-fit checks are accurate.
- Vision support is flagged in `capabilities`; the `[vision]` block is
auto-detected by `ModelCard._autodetect_vision` from the upstream
`config.json`, so no hand-written vision config is required.
- The card loader (`_refresh_card_cache` in
`src/exo/shared/models/model_cards.py`) globs every `.toml` in
`resources/inference_model_cards/` on startup, so nothing else needs to
change — the `/models` endpoint and the dashboard picker pick them up
automatically.

The `mxfp4` / `mxfp8` / `nvfp4` variants are still uploading upstream
(index JSONs currently 404) and can be added in a follow-up PR once HF
completes.

## Test Plan

### Manual Testing

Hardware: MacBook Pro M4 Max, 48 GB unified memory.

- Built the dashboard, ran `uv run exo`, waited for the API to come up
on `http://localhost:52415`.
- `curl -s http://localhost:52415/models` returns the three new model
ids (`mlx-community/Qwen3.6-35B-A3B-{4bit,5bit,bf16}`) alongside
existing models.
- Opened the dashboard, clicked SELECT MODEL, typed "Qwen3.6" into the
search box. A single **"Qwen3.6 35B A3B"** group appears showing `3
variants (19GB-65GB)`. Expanding it lists the `4bit` / `5bit` / `bf16`
quants with sizes `19GB` / `23GB` / `65GB`, exactly as expected:

![Qwen3.6 35B A3B in model
picker](https://gist.githubusercontent.com/AlexCheema/68c2c02da9450b44968e6b0e0b1d255e/raw/127119f70382353c65a847188e5a2c9013db68d2/qwen36-picker.png)

- Programmatically loaded each TOML via `ModelCard.load_from_path(...)`
and confirmed the parsed fields (layers / hidden / KV heads / context /
quant / base_model / caps / bytes) match what's written in the files.

### Automated Testing

No code paths were touched — these are pure TOML data files that plug
into the existing model-card loader. The existing pytest suite covers
TOML parsing and card serving; adding new TOMLs doesn't require new test
scaffolding. `uv run ruff check` and `nix fmt` are clean.

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-authored-by: Ryuichi Leo Takashige <rl.takashige@gmail.com>
2026-04-16 23:25:26 +01:00
rltakashige 28c797846a Update mlx and mlx lm to latest (#1906)
Just bumping to the very latest upstream versions.
2026-04-16 10:59:33 +00:00
rltakashige 058bb08261 Allow copying on dashboard even on HTTP (#1902)
## Motivation

<!-- Why is this change needed? What problem does it solve? -->
<!-- If it fixes an open issue, please link to the issue here -->

## Changes

<!-- Describe what you changed in detail -->

## Why It Works

<!-- Explain why your approach solves the problem -->

## Test Plan

### Manual Testing
<!-- Hardware: (e.g., MacBook Pro M1 Max 32GB, Mac Mini M2 16GB,
connected via Thunderbolt 4) -->
<!-- What you did: -->
<!-- - -->

### Automated Testing
<!-- Describe changes to automated tests, or how existing tests cover
this change -->
<!-- - -->
2026-04-15 23:30:12 +01:00
rltakashige 3eead80238 Better environment variables in MacOS app (#1901)
## Motivation

Closes #1858 

## Changes

<!-- Describe what you changed in detail -->

## Why It Works

<!-- Explain why your approach solves the problem -->

## Test Plan

### Manual Testing
<!-- Hardware: (e.g., MacBook Pro M1 Max 32GB, Mac Mini M2 16GB,
connected via Thunderbolt 4) -->
<!-- What you did: -->
<!-- - -->

### Automated Testing
<!-- Describe changes to automated tests, or how existing tests cover
this change -->
<!-- - -->
2026-04-15 20:14:52 +00:00
rltakashige 87329c80ef Add usage stats to tool calls and handle multiple tool calls correctly (#1899)
## Motivation

Tool calls are usually not end tokens, so they didn't have usage stats.
2026-04-15 19:40:00 +01:00
rltakashige 8cdc833892 Drain tokens silently skipped in thinking parsing (#1898)
## Motivation
Closes #1882
2026-04-15 14:23:07 +00:00
rltakashige 2cd66ae4cf Fix out of order event idx causing fatal crashes (#1894)
## Motivation

<img width="828" height="373" alt="Screenshot 2026-04-14 at 22 56 52"
src="https://github.com/user-attachments/assets/f8f48c1d-68c5-4acc-a6de-9d180672da9d"
/>

if is_new_master=True, _elect_loop creates a new EventRouter before the
worker has receivers. Then, event router runs _run_ext_in and
buf.drain_indexed() will pick off events, even though
self.internal_outbound is not populated fully.

Finally, when the worker does try requesting events, the next event it
receives is not the first event, meaning the worker crashes.

## Changes

Start the event router after all the receivers are registered

## Why It Works

self.internal_outbound is populated before the loop begins.

## Test Plan

### Manual Testing
No more crashes observed in testing (it's actually quite easy to
reproduce the issue if you have one node with this fix but the other
node on main).

I'm convinced this is a fix, at least.
2026-04-15 08:46:02 +00:00
rltakashige 2ecefa0cfe Fix Qwen3-VL and autodetect vision config (#1893)
## Motivation

Qwen3 VL TP doesn't work atm, and vision is not behaving.

## Test Plan

### Manual Testing
Works now.
2026-04-14 23:05:55 +01:00
rltakashige b8eaf707a8 Add gemma 4 tensor parallelism (#1891) 2026-04-14 20:31:59 +01:00
rltakashige 8d81811b89 Try harder to clean up processes nicely (#1889)
## Motivation

Model loading is actually quite reliable now. No need to kill if you
have a slow SSD or it's a massive model; the user can shut the instance
down if necessary.

This was a major cause of signal=9 issues although not the only one (can
happen during inference too?).
The reason signal=9 is so bad is that RDMA will no longer work until
restart if this ever happens.

## Changes

- no more model load timeout
- no more crazy sigkills
- try harder to clean up processes on model shutdown

## Test Plan

### Manual Testing
Tested with some RDMA instances
2026-04-14 16:37:49 +01:00
rltakashige f2709dcde6 Add prefix cache flag to exo bench (#1888)
## Motivation
For using Exo-Bench extensively, there are many cases that we could use
prefix caching to speed up the benchmarks, especially when the focus is
on the token generation.

At the same time, it's very clear that prefix caching decode tokens is
not very useful in most current scenarios. Surprisingly, even for
non-thinking models, the chat template means that a continued
conversation will be formatted such that the existing cache is not
effective.

We already (slightly accidentally) do this for the batch generator - we
should do it for the sequential generator too.

## Changes

We can now speed up exo bench by having a use prefix caching flag. Of
course, for most accurate pp results, it is better to not have it, but
this speeds up tg and large benchmarking significantly.
Updated methodology to match

## Test Plan

### Manual Testing
Tested on many configurations that the difference in results is
negligible, even with multiple --pp options.
2026-04-14 11:12:58 +01:00
ciaranbor 77ffe039b3 Complete responses api usage response field (#1885)
## Motivation

The Responses API usage response was missing `input_tokens_details` and
`output_tokens_details`. The chat completions API already reports these.

## Changes

- Added `InputTokensDetails` (`cached_tokens`) and `OutputTokensDetails`
(`reasoning_tokens`) to `ResponseUsage`
- Extracted shared `_build_response_usage()` helper for both streaming
and non-streaming paths

## Test Plan

### Manual Testing

4-node cluster, `Qwen3-30B-A3B-4bit` — verified both detail objects
present with correct values in streaming and non-streaming responses.

### Automated Testing

13 tests in `test_openai_responses_api.py`.
2026-04-13 17:38:33 +00:00
rltakashige 3f0df404a5 Reduce memory consumption by adding Flash Attention to Qwen3.5 and Gemma 4, and fix RotatingKVCache prefix cache memory leak (#1886)
## Motivation

Part 1 of many memory improvements.

## Changes
As written in the title

## Test Plan

### Manual Testing
Gemma 4 26B cache reduced from 54GB -> 10GB per 100k tokens, Qwen3.5 35B
A3B cache reduced from 21GB every 100000 tokens to 7GB.
2026-04-13 18:32:17 +01:00
95 changed files with 2202 additions and 11589 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
Generated
+156 -1072
View File
File diff suppressed because it is too large. Load diff
+2 -6
View File
@@ -1,11 +1,6 @@
[workspace]
resolver = "3"
members = [
"rust/networking",
"rust/exo_pyo3_bindings",
"rust/util",
"rust/babblerd",
]
members = ["rust/networking", "rust/exo_rs", "rust/util"]
[workspace.package]
version = "0.0.1"
@@ -33,6 +28,7 @@ delegate = "0.13"
# Utility dependencies
keccak-const = "0.2"
nix = "0.31"
# Async dependencies
async-stream = "0.3"
+18
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:
@@ -395,6 +401,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**
@@ -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:
+11 -5
View File
@@ -2253,10 +2253,9 @@ 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>);
@@ -2264,8 +2263,15 @@ class AppStore {
const instance = (instanceWrapper as Record<string, unknown>)[
keys[0]
] as { shardAssignments?: { modelId?: string } };
if (instance?.shardAssignments?.modelId) {
return instance.shardAssignments.modelId;
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;
}
}
}
+18 -8
View File
@@ -1461,6 +1461,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);
@@ -2547,12 +2550,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);
@@ -3129,6 +3130,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 +4613,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"
>
@@ -6100,7 +6110,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"
>
+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
Generated
+24 -24
View File
@@ -2,11 +2,11 @@
"nodes": {
"crane": {
"locked": {
"lastModified": 1779130139,
"narHash": "sha256-BLrtr42azquO7MdGFU5a7KiMl3YpFlTeIXqy1fT5GlQ=",
"lastModified": 1775790182,
"narHash": "sha256-pG2RWVQY0Pe+rmmXJx+Jpyi+JcgjWzS18m7fcD1B64Q=",
"owner": "ipetkov",
"repo": "crane",
"rev": "edb38893982a3338972bb4a2ec7ce7c29ba10fd9",
"rev": "534982f1c41834b101e381b07b1121a4f065a374",
"type": "github"
},
"original": {
@@ -47,11 +47,11 @@
"rust-analyzer-src": "rust-analyzer-src"
},
"locked": {
"lastModified": 1779185128,
"narHash": "sha256-Kl2bkmwZJD3n2KWDxuIlturZ7emqRK+anpD1LmDwpmY=",
"lastModified": 1775807984,
"narHash": "sha256-Redoe3D9zGN5I9QPHWL9vfMVQBehY1fKsMiRXQ83X3w=",
"owner": "nix-community",
"repo": "fenix",
"rev": "b7bd9323fe26a3b4f4bddbb2c2a1dacabced2f88",
"rev": "fcf90c0c4d368b2ca917a7afa6d08e98a397e5fd",
"type": "github"
},
"original": {
@@ -83,11 +83,11 @@
]
},
"locked": {
"lastModified": 1778716662,
"narHash": "sha256-m1Yf0wZ8j1OHjTc2UwHwyQRSnNeSgLJOd7q5Y45hzi4=",
"lastModified": 1775087534,
"narHash": "sha256-91qqW8lhL7TLwgQWijoGBbiD4t7/q75KTi8NxjVmSmA=",
"owner": "hercules-ci",
"repo": "flake-parts",
"rev": "f7c1a2d347e4c52d5fb8d10cb4d94b5884e546fb",
"rev": "3107b77cd68437b9a76194f0f7f9c55f2329ca5b",
"type": "github"
},
"original": {
@@ -118,11 +118,11 @@
},
"nixpkgs": {
"locked": {
"lastModified": 1779102034,
"narHash": "sha256-vZJZjLo513IeI8hjzHFc6TDezUd4uCE2Eq4SNO3DNNg=",
"lastModified": 1775595990,
"narHash": "sha256-OEf7YqhF9IjJFYZJyuhAypgU+VsRB5lD4DuiMws5Ltc=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "687f05a9184cad4eaf905c48b63649e3a86f5433",
"rev": "4e92bbcdb030f3b4782be4751dc08e6b6cb6ccf2",
"type": "github"
},
"original": {
@@ -168,11 +168,11 @@
]
},
"locked": {
"lastModified": 1776659114,
"narHash": "sha256-qapCOQmR++yZSY43dzrp3wCrkOTLpod+ONtJWBk6iKU=",
"lastModified": 1773870109,
"narHash": "sha256-ZoTdqZP03DcdoyxvpFHCAek4bkPUTUPUF3oCCgc3dP4=",
"owner": "pyproject-nix",
"repo": "build-system-pkgs",
"rev": "ffaa2161dd5d63e0e94591f86b54fc239660fb2e",
"rev": "b6e74f433b02fa4b8a7965ee24680f4867e2926f",
"type": "github"
},
"original": {
@@ -188,11 +188,11 @@
]
},
"locked": {
"lastModified": 1778901413,
"narHash": "sha256-GSKXTAnFqRAMlZkJrIPcQMYf+lpMr66K3i60mB9STvc=",
"lastModified": 1775439158,
"narHash": "sha256-NHY9SJNU019n+8NCabBDtmuzRFeE2gZlYKHowp9bV24=",
"owner": "pyproject-nix",
"repo": "pyproject.nix",
"rev": "a228447c3e179d477c1b6246ef3efa8cfe3c469a",
"rev": "fb6b728260f3f32761367e9fd1e1a25b4245bcd0",
"type": "github"
},
"original": {
@@ -218,11 +218,11 @@
"rust-analyzer-src": {
"flake": false,
"locked": {
"lastModified": 1779074864,
"narHash": "sha256-0M3WqsWmtXmv9Ev/vnFfCHosWvISDwiuuhQ104UO3CI=",
"lastModified": 1775745684,
"narHash": "sha256-8MbfLwd60FNa8dRFkjE+G3TT/x21G3Rsplm1bMBQUtU=",
"owner": "rust-lang",
"repo": "rust-analyzer",
"rev": "cdfe408d4b436e806ff525cb3e67588a6a009ed1",
"rev": "64ddb549bc9a70d011328746fa46a8883f937b6b",
"type": "github"
},
"original": {
@@ -284,11 +284,11 @@
]
},
"locked": {
"lastModified": 1778664018,
"narHash": "sha256-ogNyNANNLo0SMFevIeUpbTMOL9uUDu/hXvp7JlOYbwQ=",
"lastModified": 1775706324,
"narHash": "sha256-BTb4sydzX2B5/oNbvCdQFeSbk97xEnbb8bk84CiKCOs=",
"owner": "pyproject-nix",
"repo": "uv2nix",
"rev": "b48abe99ef639cd100c224898529370e5d935294",
"rev": "5707df99097375896a3dda811d492a2fabe63500",
"type": "github"
},
"original": {
+1 -2
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;
@@ -132,7 +132,6 @@
packages = {
default = self'.packages.exo;
babeld = pkgs.callPackage ./nix/babeld.nix { };
} //
lib.optionalAttrs pkgs.stdenv.hostPlatform.isDarwin {
metal-toolchain = pkgs.callPackage ./nix/metal-toolchain.nix { };
+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:
-26
View File
@@ -1,26 +0,0 @@
{ stdenv
, lib
, fetchgit
}:
stdenv.mkDerivation {
pname = "babeld";
version = "1.13.2-rc";
# TODO: pin to specific version/revision, or better yet, use a patch file
src = fetchgit {
url = "https://github.com/AndreiCravtov/babeld.git";
fetchSubmodules = true;
sha256 = "sha256-/qsoMSRhtwa/2hvACtFwbl+563o+TKxWMS684D+g8mk=";
};
outputs = [
"out"
"man"
];
makeFlags = [
"PREFIX=${placeholder "out"}"
"ETCDIR=${placeholder "out"}/etc"
];
}
+6
View File
@@ -0,0 +1,6 @@
{
"name": "exo",
"lockfileVersion": 3,
"requires": true,
"packages": {}
}
+5 -5
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,15 +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" }
@@ -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 {
-1
View File
@@ -1 +0,0 @@
/nix/store/41rd4g2p69a2106qscl3xvkzmh6j7nry-babblerd
-1
View File
@@ -1 +0,0 @@
/pbprobe/*
-39
View File
@@ -1,39 +0,0 @@
[package]
name = "babblerd"
version.workspace = true
edition.workspace = true
[dependencies]
color-eyre = "0.6.5"
clap = { version = "4.5.53", features = ["derive"] }
futures-lite.workspace = true
ipnet = "2.12.0"
nix = { version = "0.31", features = ["fs", "signal", "process", "user", "net", "uio"] }
netdev = "0.42"
ahash = "0.8.12"
arrayvec = "0.7.6"
crossbeam-channel = "0.5.15"
hashbrown = "0.16.0"
iroh-quinn-udp = { version = "0.8.0", default-features = false, features = ["fast-apple-datapath"] }
libc = "0.2"
mio = { version = "1.1.0", features = ["net", "os-ext", "os-poll"] }
n0-watcher = "0.6"
netwatch = "0.16"
rand = "0.10"
route_manager = "0.2.11"
slab = "0.4.11"
socket2 = "0.6.1"
tokio = { workspace = true, features = ["full"] }
tracing = "0.1.44"
tracing-subscriber = { version = "0.3.22", features = ["env-filter"] }
tun-rs = "2.8.1" # if you update, it causes transitive dependeny clashes that need patch.crates-io fixes or whatnot, too long to do now :)
# parsing
memchr = "2.8"
winnow = "1.0"
thiserror = "2.0"
macaddr = "1.0"
zerocopy = { version = "0.8.31", features = ["derive"] }
[lints]
workspace = true
-4
View File
@@ -1,4 +0,0 @@
fn main() -> color_eyre::Result<()> {
color_eyre::install()?;
babblerd::profiling::standalone::run_from_env()
}
-4
View File
@@ -1,4 +0,0 @@
fn main() -> color_eyre::Result<()> {
color_eyre::install()?;
babblerd::profiling::pbprobe::standalone::run_from_env()
}
@@ -1,391 +0,0 @@
# `babblerd` Future Architectural Directions
This file is not a debt list.
Use [shortcuts.md](./shortcuts.md) for concrete shortcuts, footguns, and
implementation compromises that should be cleaned up later.
This file is for directional reasoning:
- what the current architecture is trying to become,
- which major steps are worth doing next,
- and why those steps are ordered the way they are.
It should evolve as the architecture evolves.
## Current Position
`babblerd` is no longer just a thin wrapper around `babeld`.
It now has the beginnings of a real daemon architecture:
- a resident daemon process,
- a resident TUN interface,
- a keepalive-driven daemon core,
- a heavy routing stack that can turn on and off,
- a typed Babel control/runtime layer,
- a derived FIB layer,
- a dedicated dataplane thread wired into the routing stack,
- a persisted node identity,
- and a central config module.
That is enough structure to stop treating the whole project as “just Babel
plumbing”.
For bring-up, the current tree also contains a temporary internal self-client
that connects to the public socket and periodically sends keepalive commands.
That is only a testing scaffold so the routing stack stays on without a real
frontend process yet. It should be removed once a real controller exists.
It is also enough structure to begin building the actual dataplane without
needing to perfect every IPC and control-plane detail first.
Stable one-hop forwarding is now proven on the four-Mac Thunderbolt lab for an
adjacent pair after switching dataplane TUN I/O on macOS to `tun-rs`
`SyncDevice::recv`/`send` instead of raw fd `read`/`write`.
Steady-state ICMPv6 reachability is also now green across the full four-node
lab ring, and small generic TCP application payloads now work too once the
mesh has converged. The current remaining gap is no longer basic correctness of
non-ICMP transport traffic, but sustained throughput under load: current
`iperf3` testing transfers an initial burst and then collapses into heavy
retransmits and near-zero receive-side throughput.
So the current architecture is good enough for continued correctness and
reliability bring-up, but serious performance work should wait until that
throughput-collapse behavior is understood.
## The Most Important Architectural Decision
The current codebase is already good enough to serve as the shell around a
first real dataplane.
That means the next major effort should **not** automatically be:
- replacing the line protocol with `zbus`,
- perfecting lease ownership,
- or fully polishing lifecycle semantics.
Those are still desirable, but they are not the blocking step for getting to a
working end-to-end system.
The next big milestone should be:
- a real UDP dataplane,
- driven by the current daemon core and current Babel-derived state,
- with a basic but coherent forwarding model.
In other words: move from “control plane with architecture” to “working router
with acceptable architecture”.
## Near-Term Goal
The near-term target is:
> a basic end-to-end MVC where:
>
> - the daemon has a stable node identity,
> - the daemon can be kept alive by the frontend,
> - the daemon maintains Babel-derived routing state,
> - the daemon can forward packets through a UDP overlay between nodes,
> - and the frontend can inspect enough daemon state to be useful.
This does **not** require the final IPC architecture first.
## Recommended Next Phase
### 1. Validate and harden the first UDP dataplane
This should be the next major feature.
The basic pieces are now in place:
- `fib.rs` derives immutable forwarding snapshots from `BabelState`,
- those snapshots now carry the admitted interface set as well as routes,
- `dataplane.rs` provides a dedicated-thread hot-path module using `mio`,
`socket2`, `crossbeam-channel`, `hashbrown`, `ahash`, `slab`, and
`arrayvec`.
- `routing_stack.rs` now starts the dataplane and publishes coalesced
`FibSnapshot` updates into it.
- dataplane socket ownership is now driven by interfaces that currently have
live Babel neighbours rather than only by currently selected routes, and
retained sockets are refreshed when an `ifname` resolves to a new ifindex.
- socket reconcile is now best-effort under interface churn: transient
resolution/open failures are logged and retried without killing the
dataplane during reconcile.
- unchanged FIB snapshots are now deduplicated in the control plane, so the
dataplane also carries its own lightweight timer-driven reconcile retry for
admitted interfaces that still do not have usable sockets.
- the stable-link packet path is now working on the lab ring for adjacent
one-hop traffic.
So the next step is no longer “invent or wire the modules”.
It is:
- extend live validation from adjacent one-hop traffic to multi-hop and churn,
- harden the remaining dataplane failure/reporting behavior under interface
churn,
- confirm the interface-bound UDP socket model behaves correctly on the target
machines,
- and then fill the first obvious protocol gaps such as ICMPv6 error handling.
One caveat that is now proven on the lab Macs: the current macOS receive path
cannot treat "which UDP socket got the packet" as trustworthy interface
attribution. In live tests, packets sent directly over one Thunderbolt
interface are still being received by a different reuseport socket while the
peer scope-id reflects the real physical ingress interface. That means the
current multi-socket receive model is acceptable for basic forwarding bring-up,
but it is not yet a reliable source of receive-side interface truth on macOS.
The likely long-term fix is to move receive-side interface attribution onto
ancillary packet metadata (`IPV6_PKTINFO` / receive-interface data) rather than
inferring it from which socket woke up.
For the current four-Mac Thunderbolt lab, the broad macOS `en*` watcher
heuristic has proven too permissive in practice. The dataplane now corrects
that somewhat by only owning sockets on interfaces that Babel has actually
formed neighbour adjacencies on, but the watcher/bootstrap side is still broad
and may still need a per-host allowlist during bring-up while the long-term
admission policy is refined.
The current broad-admission behavior is acceptable for v1 as long as point to
point and multihop forwarding remain reliable, but it does mean that multiple
wired interfaces can become equally admissible at once.
The desired longer-term policy is:
- admit any interface that Babel can actually form a live neighbour adjacency
on, regardless of naming convention,
- keep that broad admissibility for reachability,
- but rank competing links by measured quality rather than treating all wired
links as equivalent.
That future link-scoring direction likely requires:
- computing local link metrics such as latency, loss, and possibly sustainable
throughput without generating excessive probe traffic,
- sharing or projecting those metrics into the distributed routing view in a
way Babel can actually consume,
- and then teaching the Babel path-selection logic to prefer the better direct
link when multiple usable adjacencies exist.
That is explicitly post-v1 work. For the first version, correctness and
reliability of the multihop mesh matter more than optimal link preference.
The current sustained-throughput investigation is therefore focused on two
nearer-term issues before any serious performance tuning:
- understanding whether load-induced loss is primarily backpressure on UDP
socket send / TUN reinjection,
- and separating that from the now-proven macOS receive-side interface
attribution oddities.
The live restart-sensitive failures are now narrowed more precisely than that.
On the four-Mac lab, a restarted node can receive an encapsulated packet, push
it through local TUN delivery successfully, and still blackhole the exchange
because the generated return packet is resolved onto a worse broad-admission
path (for example `en1`) instead of the direct Thunderbolt neighbour that just
delivered the request.
So the current main blocker for reliable restart/churn behavior is not "the
dataplane cannot receive or decapsulate packets". It is "the control plane can
still choose an asymmetric installed route that is valid enough for Babel to
advertise, but poor enough to break or destabilize the actual return path".
That strengthens the case for the planned future link-quality policy:
- broad admissibility is still the right v1 reachability rule,
- but equally admissible wired links need a better ranking signal than today's
flat wired costs,
- otherwise restart-time route selection can still land on a functionally worse
path even when a direct high-quality neighbour exists.
The current tree now owns the kernel route that steers overlay traffic into the
resident TUN interface:
- the local node `/128` address is installed on the TUN device,
- `EXO_ULA_PREFIX -> tunX` is added when the routing stack turns on,
- that prefix route is removed when the routing stack turns off,
- and `babeld` kernel installs remain disabled.
That means local application traffic can now be steered into the overlay once
the UDP dataplane is active.
The first version can stay simple:
- one UDP datagram carries exactly one inner IPv6 packet,
- no custom framing,
- no batching,
- no crypto,
- no relays,
- no multiplexed control/data protocol.
The dataplane should:
- read packets from TUN,
- classify local-delivery vs forwarding,
- look up next-hop information from a derived forwarding view,
- send encapsulated packets to direct neighbors over UDP,
- receive UDP packets from neighbors,
- decapsulate them,
- either inject them locally into TUN or forward them onward.
This gives the project a real “V” and “M” to go with the current daemon/control
shell.
The current tree now hardcodes:
- physical link MTU assumption: `1500`
- outer overhead assumption: `40 bytes IPv6 + 8 bytes UDP`
- derived TUN MTU: `1452`
That is acceptable for bring-up, but it is still only a temporary model.
The future direction should be:
- route-aware MTU derivation,
- PMTUD-aware behavior,
- and better support for environments where hop-to-hop links can use jumbo
frames without exposing that complexity to user traffic.
### 2. Keep the derived forwarding table separate from `BabelState`
`BabelState` should remain a mirror of what `babeld` says.
The current code now reflects that direction:
- `BabelState` is still the protocol mirror,
- `FibSnapshot` is the dataplane view.
The next layer should be a derived forwarding table/FIB that:
- is keyed by destination prefix or node address,
- only keeps the routes the dataplane should actually use,
- captures next hop / outgoing interface / any other forwarding metadata,
- and is cheap for the dataplane to consult.
This avoids mixing:
- “what Babel currently knows”
- with
- “what the UDP router should do with packets”.
### 3. Add a stronger public state/readiness model
The current `ServiceState` is useful, but it is only lifecycle state:
- `Off`
- `Starting`
- `On`
- `Stopping`
That is not the same thing as routing readiness.
Once the dataplane exists, a separate readiness/status view should exist too.
For example, the frontend may want to distinguish:
- daemon is idle,
- daemon is starting,
- Babel is running but no eligible interfaces exist,
- interfaces exist but no neighbors are usable,
- forwarding is nominal,
- forwarding is degraded.
That should be modeled separately from `ServiceState`, not by making
`ServiceState::On` carry too much meaning.
## What Can Wait Until After the Dataplane Exists
These are still desirable, but they do not need to block the first end-to-end
router:
### `zbus` / D-Bus-style IPC
This is still the likely long-term direction.
But the current line protocol is good enough for:
- `keepalive <ttl_ms>`
- `get-state`
while the dataplane is being built.
So `zbus` should remain a planned improvement, not the immediate blocker.
### Per-client leases
The daemon should eventually track leases per client/connection rather than via
a single global keepalive deadline.
That is a real architectural improvement, but it is control-plane polish rather
than dataplane unblocker.
It can happen after the first router path works.
### Structured diagnostics/debug output
Right now diagnostics are tracing-only.
That is acceptable for development while the dataplane is first being brought
up.
A configurable debug stream or structured diagnostics feed should be added
later, preferably once the public IPC shape is stabilized.
## The First Dataplane Should Stay Intentionally Small
The first version should avoid solving every future overlay concern.
It should **not** attempt to solve:
- encryption,
- authentication,
- path quality metrics beyond what Babel already provides,
- batching,
- relay protocols,
- or multi-transport negotiation.
The first version should prove the simplest useful thing:
- stable node addresses,
- UDP transport between neighbors,
- Babel-driven next-hop selection,
- TUN injection/extraction,
- packet forwarding that actually works end-to-end.
If that works, the rest can be improved incrementally.
## Architectural Path After the First Dataplane Works
Once the basic dataplane exists and works, the likely next path is:
1. Improve the public state/readiness model.
2. Replace the ad-hoc control socket with `zbus`.
3. Replace the single keepalive deadline with per-client leases.
4. Tighten interface admission beyond the current broad heuristic.
5. Pin and explicitly invoke the exact forked `babeld`.
6. Harden node-id file mode checks and other local security edges.
7. Revisit diagnostics streaming.
8. Revisit platform abstractions around TUN / transport / forwarding.
That ordering is intentional:
- prove the router first,
- then harden and refine the daemon architecture around it.
## Guiding Principle
The project should prefer:
- a coherent working router with a few acknowledged shortcuts
over:
- a beautifully abstract control plane that still does not move packets.
That does **not** mean ignoring architecture.
It means using the current architecture as a platform for the next real
capability, rather than repeatedly polishing the control shell before the
dataplane exists.
-352
View File
@@ -1,352 +0,0 @@
# babblerd Handoff
This is the current handoff for a new session picking up `babblerd` work.
## Repo / Branch / State
- Repo: `/home/royalguard/Desktop/exo-all/exo`
- Branch: `babbler`
- Current HEAD: `b0f508ac` (`fix babbler ula prefix`)
- Recent relevant commits:
- `b0f508ac` fix babbler ula prefix
- `2e17eb03` retry skipped dataplane sockets
- `4efc883f` dedup unchanged fib snapshots
- `79a5a84b` instrument dataplane backpressure
- `f0140a81` trim dataplane logs for live testing
- `df2760b6` admit dataplane interfaces from babel neighbours
- `e1e4643e` use tun-rs packet io in dataplane
- Current working tree state when this was written:
- modified: `rust/babblerd/shortcuts.md`
- modified: `rust/babblerd/future_architectural_directions.md`
- untracked: `.codex`
- Local checks pass:
- `cargo fmt -p babblerd`
- `cargo check -p babblerd`
- `cargo test -p babblerd`
## Core Conclusion
The project is past the “is the overlay architecture wrong?” phase.
The current architecture is the right one:
- `babeld` is control plane only
- Babel kernel installs are disabled
- local mesh traffic is steered into a resident TUN
- userspace dataplane forwards one inner IPv6 packet per UDP datagram hop-by-hop over neighbour link-locals
So the main remaining work is now:
- route-selection debugging
- restart/convergence robustness
- load/backpressure/throughput behavior
- eventually link scoring across multiple admissible wired links
## Why The Old Approach Failed
The original macOS idea was effectively:
- let `babeld` install routes
- try to make kernel source selection behave
That did not work cleanly for this use case:
- no usable IPv6 pref-src route install path on macOS/BSD for this design
- no native source-specific IPv6 routing model that solves the app behavior wanted here
- putting ULAs on `lo0` or `utun` did not reliably fix source selection
- app-aware binding alone was not enough in practice
That is why the design pivoted to the userspace overlay dataplane.
## Files To Read First
- `src/daemon.rs`
- `src/routing_stack.rs`
- `src/dataplane.rs`
- `src/fib.rs`
- `src/tun.rs`
- `src/babel/runtime.rs`
- `src/route_ctl.rs`
- `lab_topology_reference.md`
- `shortcuts.md`
- `future_architectural_directions.md`
## Current Intended Architecture
Model:
- one stable node `/128` on TUN
- `EXO_ULA_PREFIX -> tunX` installed by `babblerd`
- `babeld` kernel installs are disabled / ignored
- `BabelState` mirrors `babeld`
- `FibSnapshot` is a reduced immutable dataplane view
- control plane stays on Tokio
- dataplane is a dedicated thread
- one UDP datagram carries exactly one inner IPv6 packet
- no custom framing yet
- outer IPv6 destination is neighbour link-local
- outer UDP port is `router_udp_port`
This is the v1 forwarding model:
- exact-match `/128` host routes only
- interface identity in FIB is `ifname`
- dataplane owns sockets from admitted interface set
- admitted dataplane interfaces come from live Babel neighbours
## Important Design Decisions Already Landed
- typed Babel parsing/state model, not raw string handling
- monitor-driven Babel runtime, not periodic dump polling
- persistent node identity across restarts
- explicit daemon lifecycle: `Off | Starting | On | Stopping`
- resident TUN lifetime, separate heavy routing stack
- dataplane thread + immutable FIB snapshot swaps
- socket ownership from admitted interface set, not only current route set
- same-name/new-ifindex socket refresh handled
- timer-driven socket reconcile retry in dataplane, so deduped unchanged FIB snapshots do not suppress retries forever
- dataplane exit supervision back into routing stack / daemon
- macOS dataplane uses `tun-rs` packet I/O (`SyncDevice::recv/send`), not raw fd reads/writes
## Very Important Fix After Earlier Handovers
The previously-deployed node addresses were wrong.
There was a real bug in `EXO_ULA_PREFIX` construction:
- intended prefix: `fde0:20c6:1fa7:ffff::/64`
- broken runtime prefix had become: `20c6:1fa7:ffff:0::/64`
Cause:
- `config.rs` used a `u128` left-shift construction that dropped the high `fde0` bits
Fix:
- commit `b0f508ac` changed the prefix constant to explicit hextets and added a regression test
Live verification after redeploy:
- `e4 utun5`: `fde0:20c6:1fa7:ffff:cc78:aec2:d64e:f125/128`
- `e2 utun5`: `fde0:20c6:1fa7:ffff:aeb:e53a:cb17:aa42/128`
- `e11 utun5`: `fde0:20c6:1fa7:ffff:34a:26dd:46ff:1a3f/128`
- `e16 utun5`: `fde0:20c6:1fa7:ffff:7c5d:5e2d:54df:e665/128`
So any older notes mentioning the truncated non-ULA prefix are stale.
## Current Dataplane Behavior
In `src/dataplane.rs`:
- TUN ingress:
- read inner IPv6 packet
- parse destination
- drop self-directed
- FIB lookup
- send raw inner packet as UDP payload to neighbour
- UDP ingress:
- receive UDP payload
- payload is raw inner IPv6 packet
- if destination local, inject into TUN
- else decrement inner hop limit and forward
Fast-path traits:
- dedicated OS thread
- `mio` polling
- `socket2` UDP sockets
- immutable FIB snapshot swaps over `crossbeam-channel`
- no lock on packet lookup path
## What Works
These things are now real:
- one-hop two-node `ping6`
- adjacent dataplane path
- small low-rate UDP matrix
- encapsulation / decapsulation itself
- basic generic TCP correctness after convergence
A very important live proof point:
On a failing restart-sensitive `e11 -> e16` case, the dataplane itself still did the right work:
- `e11` emitted the encapsulated packet on `en3`
- `e16` received that UDP packet on `en18`
- `e16` dataplane delivered the inner packet into TUN
- the local stack generated a reply packet
So the dataplane is not fundamentally broken anymore.
## What Is Still Broken
The main remaining live problem is restart/convergence behavior and path selection quality.
Observed failure shape:
- after restart, a node can receive and decapsulate correctly
- but the return packet gets resolved onto a worse path, often `en1`, instead of the direct Thunderbolt link
- this creates blackholes or severe instability
So the current blocker is:
- control-plane / derived-FIB route choice under broad multi-link admission
- not “UDP overlay cannot carry packets”
## Very Important macOS Receive-Side Finding
On macOS, receive-side socket attribution is not trustworthy in the current one-socket-per-interface model.
Observed live behavior:
- traffic sent directly over one Thunderbolt link can be delivered to a different UDP socket than expected
- the peer scope-id still reflects the real ingress interface
Implication:
- do not trust “which socket woke up” as authoritative ingress truth on macOS
- if receive-side interface attribution matters, use peer scope-id and likely ancillary packet metadata later
This is a real quirk, but it is not the primary blocker for the current restart blackhole.
## Key Local FIB Caveat
Do not assume route-choice issues are only Babels fault.
In `src/fib.rs`, `FibBuilder` collapses multiple installed host routes by choosing the lowest:
- `metric`
- then `refmetric`
- then `handle`
So if restart churn leaves multiple `installed=yes` candidates, babblerds derived `FibSnapshot` can still be part of why traffic goes via `en1`.
That means the next debug pass must compare all three:
1. raw Babel route events / dump
2. current `BabelState`
3. derived `FibSnapshot`
Not Babel in isolation.
## Lab Topology / Operations
Source of truth file:
- `lab_topology_reference.md`
Key facts:
- four Mac minis
- hostnames:
- `e4@e4`
- `e2@e2`
- `e11@e11`
- `e16@e16`
- ring topology:
- `e4 -> e2 -> e11 -> e16 -> e4`
- remote repo path:
- `~/babeld-exo`
- each remote must `git pull` before running
- current start command:
- `cd ~/babeld-exo && git pull && RUST_LOG=info sudo -E nix run .#babblerd --impure`
- temporary internal keepalive client exists, so no external `nc -U ...` client is needed just to keep daemon alive
- remote `iperf3` exists at:
- `/opt/homebrew/bin/iperf3`
## Current Docs Are Mostly Accurate
Read:
- `shortcuts.md`
- `future_architectural_directions.md`
They correctly capture:
- broad admissibility is acceptable for v1 reachability
- flat wired costs are not enough for good best-path choice
- throughput under load is still bad
- restart-sensitive failures are now dominated by route selection, not dataplane decode
- there is still debt around interface identity, macOS receive attribution, IPC/authz, and incomplete ICMP/PMTUD behavior
## Important Remaining Technical Debt
Still unresolved:
- public IPC socket is too open
- `ServiceState::On` is not the same as “fully ready/routable”
- broad interface admission is still heuristic
- route ownership of `EXO_ULA_PREFIX` is aggressive
- no ICMPv6 Time Exceeded
- no Packet Too Big handling
- no real backpressure/queueing; `WouldBlock` is still drop-on-backpressure
- throughput collapses under sustained load
- macOS receive-side interface attribution needs a better long-term path
- multi-link path selection is still too naive
## What Not To Revisit Right Now
These are settled enough for now:
- overlay architecture itself
- TUN + userspace UDP forwarding model
- one-packet-per-datagram framing
- control plane on Tokio, dataplane on dedicated thread
- exact-match `/128` FIB for v1
- `tun-rs` packet I/O on macOS instead of raw fd reads/writes
- disabling Babel kernel installs and owning `EXO_ULA_PREFIX -> tunX` locally
## Best Next Debugging Step
For the restart-sensitive `en1` misroute, inspect all three together for a single problematic `/128` pair:
1. raw Babel route events over time
2. current `BabelState`
3. derived `FibSnapshot`
Goal:
- determine whether the bad path is already in Babels installed route set
- or introduced when `FibBuilder` collapses multiple `installed=yes` routes
If equal-cost multi-link route choice is the problem, add only one temporary v1 policy knob:
- either Babel interface-cost bias
- or local FIB depreference
Do not do both at once.
## Best Next Live Tests
1. full directed `ping6` matrix on node `/128`s
2. small directed UDP matrix
3. short soak tests on adjacent and two-hop pairs
4. restart/convergence tests
5. physical churn tests
6. for failures, always capture:
- symptom
- raw Babel route state / dump
- current `BabelState`
- derived FIB state if relevant
- dataplane logs
- relevant `ifconfig`
## Short Version
The project is now in the:
- route-selection debugging
- restart convergence
- throughput / backpressure robustness
phase.
The dataplane is basically real.
The current main question is not “can the overlay forward packets at all?”
It is:
- why do restart-time and multi-link route choices still select worse return paths
- and whether that bad choice originates in Babels installed set or in local FIB collapse
-52
View File
@@ -1,52 +0,0 @@
# Lab Topology Reference
This file records the current, still-relevant lab topology and bring-up
context for `babblerd`.
## Hosts
- The lab consists of four Mac minis.
- SSH targets:
- `e4@e4`
- `e2@e2`
- `e11@e11`
- `e16@e16`
- You can SSH into these machines directly to inspect or run commands.
## Physical Topology
- The machines are connected in a Thunderbolt ring:
- `e4 -> e2 -> e11 -> e16 -> e4`
- The Thunderbolt-facing interface names are not fixed to `en2` and `en3`.
macOS can expose additional Thunderbolt links as other `en*` interfaces such
as `en5`, `en6`, or host-specific names after reconfiguration.
- Treat `en2,en3` as an old bring-up heuristic only. For normal lab testing,
run without `BABBLER_INTERFACE_ALLOWLIST` and let `babblerd`/Babel discover
the live interfaces.
## Repository Location On The Macs
- Each machine has a checkout of the Exo repository at:
- `~/babeld-exo`
- That checkout is expected to already be on the correct branch for this work.
## Running `babblerd`
From `~/babeld-exo`, pull first so the machine is not testing stale commits,
then start `babblerd`:
```sh
cd ~/babeld-exo
git pull
RUST_LOG=info sudo -E nix run .#babblerd --impure
```
If broad interface discovery causes unrelated links to interfere with a
specific debug run, `BABBLER_INTERFACE_ALLOWLIST` is still available as a
temporary escape hatch. Do not use it as the default lab topology description.
## Important Current Note
- With the current codebase, `babblerd` has an internal dummy keepalive client.
- That means you do **not** need to connect an external client socket just to
make the daemon stay active during testing.
@@ -1,48 +0,0 @@
# PBProbe Implementation Plan
This file tracks the staged implementation and validation of a paper-faithful
PBProbe profiler for link-local lab links.
## Stage 1: Local Implementation
- Add `src/profiling/pbprobe/` as a separate module from the simple packet
train profiler.
- Implement the paper protocol:
- START initiates one direction.
- RTS requests each sample.
- the sender replies with a packet bulk of length `k`, meaning `k + 1`
packets.
- the receiver measures first and last packet arrival time, delay sum, and
dispersion.
- END reports the selected sample and estimate.
- Implement Algorithm 1:
- start with `k = 1`.
- if measured minimum dispersion is below `D_thresh`, multiply `k` by 10 and
restart.
- otherwise pace samples with `G = 2D / U`.
- stop after fixed `n` accepted samples.
- Keep the C implementation as a reference, but use the paper's units for `G`.
## Stage 2: Local Verification
- Unit-test packet encoding/decoding.
- Unit-test estimator selection by minimum delay sum.
- Unit-test bulk-length adaptation and pacing calculations.
- Compile the standalone example.
## Stage 3: Lab Validation
- Discover the current link-local addresses and interface names on the Mac mini
ring via SSH.
- Build or run the PBProbe example on the relevant remotes.
- Run `iperf3` over the same link-local scoped addresses as the baseline.
- Compare PBProbe estimates against `iperf3` with a reasonable tolerance.
- If estimates are outside tolerance, adjust only algorithm parameters or
implementation bugs, not the scoring target.
## Current Notes
- The repo license is Apache-2.0. The dropped PBProbe source has a permissive
MIT-like license header with notice retention and academic citation language.
- The C code appears to implement the core estimator, but its `G` sleep units
look inconsistent with the paper. This implementation should follow the paper.
-334
View File
@@ -1,334 +0,0 @@
# `babblerd` Shortcuts
This file tracks architectural and implementation shortcuts that were taken
deliberately during the refactors. They are acceptable for now, but they are
not meant to be the final design.
This is not a dump of every `TODO` comment in the crate. It is the curated list
of shortcuts that should be revisited later.
## Architecture / IPC
- The public control socket still uses an ad-hoc line protocol instead of the
intended `zbus`/D-Bus-style IPC surface.
Files:
- `src/daemon.rs`
- `src/main.rs`
Follow-up:
- Replace `keepalive <ttl_ms>` / `get-state` string commands with a typed IPC
API.
- The daemon core currently tracks a single global keepalive deadline, not
per-client leases.
Files:
- `src/daemon.rs`
- `src/main.rs`
Why this is a shortcut:
- It does not model multiple clients independently.
- It cannot distinguish which client is keeping the service alive.
- The current tree also includes a temporary internal self-client in
`main.rs` that periodically issues keepalive commands just to keep the
daemon/routing stack alive during bring-up.
Follow-up:
- Introduce real lease ownership/tracking in the daemon core.
- Remove the temporary internal keepalive client once a real frontend or test
harness is driving the daemon.
- Raw Babel debug output currently only goes to tracing logs.
Files:
- `src/babel/runtime.rs`
- `src/daemon.rs`
Why this is a shortcut:
- There is no configurable or structured diagnostics stream anymore.
- That is fine for now, but eventually debugging should not require tailing
daemon logs.
Follow-up:
- Add configurable debug output or a separate structured diagnostics stream
once the real IPC surface exists.
- The daemon core exposes state only through `get-state` polling and inline
command responses.
Files:
- `src/daemon.rs`
Follow-up:
- Add real state publication/signals once the IPC surface is upgraded.
## Service Lifecycle
- The daemon now has explicit `Off/Starting/On/Stopping`, but the control model
is still minimal.
Files:
- `src/daemon.rs`
Why this is a shortcut:
- There is no richer lifecycle API yet.
- There is no explicit enable/disable policy beyond keepalive-driven on/off.
Follow-up:
- Revisit the final lifecycle API once IPC is made real.
- `ServiceState::On` currently means “the routing tasks were started”, not a
stronger readiness guarantee such as “babeld is healthy, has admitted
interfaces, and is actually usable for mesh forwarding”.
Files:
- `src/daemon.rs`
- `src/routing_stack.rs`
- `src/babel/runtime.rs`
Why this is a shortcut:
- The frontend may eventually want to distinguish process/task liveness from
actual routing readiness.
Follow-up:
- Add a separate readiness field or richer public state model instead of
overloading `ServiceState::On`.
- The resident TUN vs heavy routing-stack split is now in place, but the
naming and abstractions are still transitional.
Files:
- `src/daemon.rs`
- `src/routing_stack.rs`
- `src/tun.rs`
Follow-up:
- Revisit names and boundaries after the daemon core / IPC architecture settles.
- `RoutingStack::stop` still uses abort-driven shutdown for the interface
watcher and logger task.
Files:
- `src/routing_stack.rs`
Why this is a shortcut:
- It is pragmatic, but not a carefully coordinated shutdown protocol.
Follow-up:
- Replace task abortion with explicit shutdown signaling where it matters.
## Babel Integration
- `babeld` runtime startup config is still assembled partly as raw strings.
Files:
- `src/babel/runtime.rs`
- `src/babel/command.rs`
Why this is a shortcut:
- The local-socket command side is typed, but spawn-time `-C` config is not.
Follow-up:
- Add a typed Babel config/config-statement layer.
- The runtime still depends on fork-specific `babeld` behavior
(`kernel-install false`) while spawning `"babeld"` from `PATH`.
Files:
- `src/babel/runtime.rs`
- `../nix/babeld.nix`
Why this is a shortcut:
- It assumes the right binary is on `PATH`.
- The Nix packaging is still not pinned to a specific revision.
Follow-up:
- Pin the fork revision and make the runtime use that exact binary.
## Networking / Interface Admission
- Interface admission is still heuristic and too broad on macOS.
Files:
- `src/lib.rs` (`if_watcher`)
- `src/config.rs`
- `src/fib.rs`
Why this is a shortcut:
- Any `en*` interface with link-local IPv6 and `is_up()` can still get pulled
into Babel during bootstrap.
- This can include unrelated WiFi, built-in Ethernet, USB Ethernet, etc.
- The dataplane now narrows that broad bootstrap set back down to interfaces
that actually have live Babel neighbours, which is much closer to the real
transport set.
- But the watcher/bootstrap side is still using the coarse `en*` heuristic,
and the env allowlist is still just a bring-up escape hatch rather than the
long-term admission policy.
- When multiple admissible wired links exist, Babel's current wired scoring
still treats them essentially flatly, so path selection is based on Babel's
existing costs rather than measured latency/throughput differences between
those direct links.
Follow-up:
- Replace the watcher-side bootstrap heuristic with a stronger admission
policy (neighbor proof, richer metadata, or both), so Babel does not need
broad speculative interface admission just to discover the right links.
- Add a future link-quality scoring path so broadly admissible direct links
can still be ranked by actual observed quality rather than flat wired cost.
- The dataplane now derives immutable FIB snapshots and runs on a dedicated
thread, but it still assumes interface names are the stable long-lived
identity for socket ownership.
Files:
- `src/fib.rs`
- `src/dataplane.rs`
Why this is a shortcut:
- The dataplane now owns sockets from the admitted interface set rather than
inferring them only from current routes, and it refreshes retained sockets
when a name resolves to a new ifindex.
- The dataplane now also has a lightweight timer-driven reconcile retry for
admitted interfaces whose socket setup was skipped or failed, so unchanged
FIB snapshots no longer suppress retries completely.
- That fixes the earlier route-derived and stale-ifindex bugs, but the design
still assumes interface names are stable enough to be the long-lived
control-plane identity.
Follow-up:
- Revisit whether the long-term identity should be richer than `ifname`,
especially if interface renames/hotplug churn become common during runtime.
- On macOS in particular, the current "one socket per interface" receive
model is not trustworthy enough to identify the real ingress interface:
live testing shows packets sent directly over one Thunderbolt link can be
received on a different `MioUdpSocket` while the peer scope-id still
reflects the actual physical ingress interface.
- Revisit receive-side interface attribution on macOS, likely using ancillary
packet-info / receive-interface metadata instead of assuming the receiving
socket tells the truth.
- The current dataplane is intentionally minimal and still drops several packet
classes silently.
Files:
- `src/dataplane.rs`
- `src/fib.rs`
- `src/routing_stack.rs`
Why this is a shortcut:
- The current lab state has reliable ICMPv6 reachability and now also basic
generic TCP correctness after convergence: a direct `nc` TCP send across
the overlay succeeds and the server receives the payload.
- But sustained throughput is still not healthy. In live `iperf3` testing, a
TCP flow transfers an initial burst and then stalls with heavy retransmits
and near-zero receive-side throughput.
- The current code also still treats `WouldBlock` on UDP send and TUN
reinjection as drop-on-backpressure behavior. That is now visible in logs,
but it is not yet a proper queued/backpressured forwarding model.
- So the remaining blocker is no longer "can non-ICMP traffic work at all",
but "why does sustained transport performance collapse under load".
- Restart-sensitive failures are now narrowed further than "the dataplane is
broken". In live `e11 -> e16` debugging, `e11` emitted the encapsulated
packet on the expected direct link, `e16` received it, delivered it into
the TUN, and the local stack generated a reply. The failure happened on the
return path because `e16`'s current FIB resolved the reply toward `en1`
instead of the direct link back to `e11`.
- That means the current post-restart blackholes are now dominated by
control-plane / FIB route selection under broad admissibility, not by the
dataplane failing to decapsulate or reinject packets.
- There is no ICMPv6 Time Exceeded generation yet.
- There is no Packet Too Big handling yet.
- No-route and invalid-packet cases are mostly tracing-and-drop behavior.
Follow-up:
- Characterize the throughput collapse under load, starting with route churn,
multi-interface path selection, and loss/retransmit behavior on the direct
lab edge.
- Investigate why the current Babel-selected installed routes can prefer
higher-cost `en1` return paths over direct Thunderbolt neighbours after
restart, and how that should interact with the eventual measured link
scoring policy.
- Add proper ICMPv6 error generation and tighter packet-validation behavior
once the first end-to-end forwarding path is validated.
- `TunDevice` is still a thin platform-specific wrapper with some rough edges.
Files:
- `src/tun.rs`
- `src/dataplane.rs`
Why this is a shortcut:
- It still stores the address as `Ipv6Net` even though usage is `/128`-only.
- On macOS, the actual kernel interface name is still `utunN`; the daemon's
cross-platform naming has been cleaned up, but the OS-level interface name
is not under our control there.
- It still has hard-coded MTU and other tun-rs builder assumptions.
- The dataplane still relies on `mio::unix::SourceFd` and `AsRawFd` to poll
the TUN fd on Unix.
- On macOS, packet I/O must still go through `tun-rs`'s `SyncDevice::recv`
and `SyncDevice::send`; bypassing those with raw fd `read`/`write` breaks
utun packet-information handling even if `mio` polling itself is correct.
- That low-level fd borrowing is smaller and safer than the old
`unsafe`/owned-fd handoff, but it still keeps raw-fd details in the
dataplane hot path.
Follow-up:
- Tighten the type and revisit the platform-specific tuning once the dataplane
is implemented.
- Revisit whether a future dataplane/eventing design can remove the direct
`mio`/raw-fd dependency entirely.
- The current MTU model is still intentionally crude:
- assume physical links must support 1500-byte packets,
- derive TUN MTU as `1500 - 40 (IPv6) - 8 (UDP) = 1452`,
- reject candidate physical interfaces below 1500 MTU.
Files:
- `src/config.rs`
- `src/lib.rs`
- `src/tun.rs`
Why this is a shortcut:
- It does not handle PMTUD, VLAN overhead, per-route MTU variation, or
jumbo-frame opportunities.
Follow-up:
- Replace the current fixed MTU model with route-aware MTU derivation once the
UDP dataplane exists.
- The overlay route controller currently claims the whole overlay prefix
aggressively.
Files:
- `src/route_ctl.rs`
Why this is a shortcut:
- It removes any existing route matching `EXO_ULA_PREFIX` before adding the
daemon's own interface route, and removes all matching routes again on
shutdown.
- That is acceptable only if babblerd is the sole owner of the overlay
prefix.
Follow-up:
- Narrow route deletion so it only removes routes that this daemon installed,
or otherwise encode route ownership more precisely.
## Identity / Security / Filesystem
- The node-id file is created with `0600`, but existing files are only
owner-checked, not mode-checked.
Files:
- `src/identity.rs`
Why this is a shortcut:
- A root-owned but group/world-writable file would still be accepted.
Follow-up:
- Enforce safe permissions on reload, not just on initial creation.
- The public IPC socket is intentionally world-accessible for now.
Files:
- `src/main.rs`
Why this is a shortcut:
- Any local user can connect, issue keepalives, and drive the daemon's public
control surface.
Follow-up:
- Revisit permissions/authz once the IPC surface is finalized.
## Error Modeling
- Several orchestration-layer errors are flattened to `String`/`Arc<str>` too
early.
Files:
- `src/daemon.rs`
- `src/babel/runtime.rs`
- `src/lib.rs` (`BabbleError::Other(String)`)
Why this is a shortcut:
- It loses structure and source-chain information.
Follow-up:
- Prefer typed errors or `eyre::Report` internally, and stringify only at the
IPC/UI boundary.
## Constants / Magic Values
- A few important constants are still effectively magic values:
- EXO ULA prefix details
- default router UDP port
- various timeout/sleep durations in the Babel runtime
- tun MTU
Files:
- `src/config.rs`
- `src/babel/runtime.rs`
- `src/tun.rs`
Follow-up:
- Either justify them clearly as real protocol/runtime constants or move them
into better configuration/abstraction layers.
## Testing
- The typed Babel parser/state layers are tested, but the newer daemon-core and
routing-stack lifecycle behavior is still lightly tested.
Files:
- `src/daemon.rs`
- `src/routing_stack.rs`
- `src/main.rs`
Follow-up:
- Add focused tests for:
- keepalive-driven transitions,
- stack start/stop behavior,
- public socket command behavior,
- failure propagation from the routing stack.
-71
View File
@@ -1,71 +0,0 @@
//! Typed representation of commands sent to `babeld`'s local socket.
//!
//! This is the outbound counterpart to [`crate::babel::line`]:
//!
//! - [`crate::babel::line`] models what `babeld` emits
//! - this module models the runtime control lines that `babblerd` sends
//!
//! The scope here is intentionally narrow: this module only models the local-socket
//! commands that `babblerd` currently issues at runtime.
//!
//! NOTE: spawn-time `-C` configuration strings are still assembled in the runtime layer for now.
//! If you want to push the protocol model further, the next obvious extraction is a typed
//! configuration/config-statement layer rather than more runtime socket commands.
use std::fmt;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum BabelCommand {
Dump,
Monitor,
Unmonitor,
Quit,
Interface(Box<str>),
}
impl BabelCommand {
/// Encode this command for the local `babeld` socket, including line framing.
#[must_use]
pub fn encode(&self) -> String {
format!("{self}\n")
}
}
impl fmt::Display for BabelCommand {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Dump => f.write_str("dump"),
Self::Monitor => f.write_str("monitor"),
Self::Unmonitor => f.write_str("unmonitor"),
Self::Quit => f.write_str("quit"),
Self::Interface(ifname) => write!(f, "interface {ifname}"),
}
}
}
#[cfg(test)]
mod tests {
use super::BabelCommand;
#[test]
fn renders_commands() {
assert_eq!(BabelCommand::Dump.to_string(), "dump");
assert_eq!(BabelCommand::Monitor.to_string(), "monitor");
assert_eq!(BabelCommand::Unmonitor.to_string(), "unmonitor");
assert_eq!(BabelCommand::Quit.to_string(), "quit");
assert_eq!(
BabelCommand::Interface("en2".into()).to_string(),
"interface en2"
);
}
#[test]
fn encodes_commands() {
assert_eq!(BabelCommand::Dump.encode(), "dump\n");
assert_eq!(BabelCommand::Monitor.encode(), "monitor\n");
assert_eq!(
BabelCommand::Interface("en2".into()).encode(),
"interface en2\n"
);
}
}
-686
View File
@@ -1,686 +0,0 @@
//! Typed representation of lines emitted by `babeld`'s local socket.
//!
//! This module models the inbound side of the Babel local control protocol:
//!
//! - [`BabelLine`] is one parsed wire line.
//! - [`HeaderLine`] covers the connection prelude.
//! - [`Status`] covers command completion lines such as `ok`, `bad`, and `no ...`.
//! - [`Event`] and its associated structs cover the asynchronous routing/interface updates
//! emitted by `dump` and `monitor`.
//!
//! The sibling parser lives in [`parse`]. Its job is to turn raw socket lines into these domain
//! types. Higher layers such as the Babel runtime/state code should depend on this module's
//! types, and keep raw strings only at the actual socket boundary.
//!
//! More concretely:
//!
//! - use [`parse::parse_line`] when reading from `babeld`
//! - reduce [`Event`] values into [`crate::babel::state::BabelState`]
//! - treat [`Status`] as command acknowledgements
//! - keep outbound socket/config commands in a separate module rather than mixing them into
//! this inbound line model
use crate::babel::Eui64;
use ipnet::IpNet;
use std::net::{IpAddr, Ipv4Addr};
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum BabelLine {
Header(HeaderLine),
Status(Status),
Event(Event),
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum HeaderLine {
Banner { major: u8, minor: u8 },
Version(Box<str>),
Host(Box<str>),
MyId(Eui64),
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Status {
Ok,
Bad,
No(Option<Box<str>>),
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Event {
Interface(InterfaceEvent),
Neighbour(NeighbourEvent),
XRoute(XRouteEvent),
Route(RouteEvent),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum EventKind {
Add,
Change,
Flush,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct InterfaceEvent {
pub kind: EventKind,
pub ifname: Box<str>,
pub up: bool,
pub ipv6: Option<IpAddr>,
pub ipv4: Option<Ipv4Addr>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct NeighbourEvent {
pub kind: EventKind,
pub handle: u64,
pub address: IpAddr,
pub ifname: Box<str>,
pub reach: u16,
pub ureach: u16,
pub rxcost: u32,
pub txcost: u32,
pub rtt_millis: Option<u32>,
pub rttcost: Option<u32>,
pub cost: u32,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct XRouteEvent {
pub kind: EventKind,
pub prefix: IpNet,
pub from: IpNet,
pub metric: u32,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RouteEvent {
pub kind: EventKind,
pub handle: u64,
pub prefix: IpNet,
pub from: IpNet,
pub installed: bool,
pub id: Eui64,
pub metric: u32,
pub refmetric: u32,
pub via: IpAddr,
pub ifname: Box<str>,
}
/// Parser for `babeld`'s local socket output.
///
/// This submodule is the wire-format counterpart to the parent [`crate::babel::line`] domain
/// types. It turns raw socket text into [`BabelLine`] values.
///
/// The local socket protocol implemented in `networking-related/babeld/local.c` is line-oriented
/// ASCII. The parser is split into two layers:
///
/// - [`RawLines`] does zero-copy line framing over buffered bytes with [`memchr`].
/// - [`parse_line`] parses one complete line with [`winnow`].
/// - [`ParsedLines`] is a convenience adapter for buffered transcripts such as `dump` output.
///
/// `monitor` mode uses the exact same line grammar as `dump`; it simply keeps emitting event lines
/// after the initial snapshot.
///
/// The accepted grammar is:
///
/// ```text
/// stream ::= (line "\n")* line?
/// line ::= header | status | event
///
/// header ::= banner | version | host | my-id
/// banner ::= "BABEL " uint "." uint
/// version ::= "version " text
/// host ::= "host " text
/// my-id ::= "my-id " eui64
///
/// status ::= "ok" | "bad" | ("no" (" " text)?)
///
/// event ::= kind " " (interface | neighbour | xroute | route)
/// kind ::= "add" | "change" | "flush"
///
/// interface ::= "interface " ifname " up " bool
/// (" ipv6 " ip)?
/// (" ipv4 " ipv4)?
///
/// neighbour ::= "neighbour " hex " address " ip " if " ifname
/// " reach " hex " ureach " hex
/// " rxcost " uint " txcost " uint
/// (" rtt " millis " rttcost " uint)?
/// " cost " uint
///
/// xroute ::= "xroute " prefix "-" prefix
/// " prefix " prefix " from " prefix " metric " uint
///
/// route ::= "route " hex
/// " prefix " prefix " from " prefix
/// " installed " yesno
/// " id " eui64
/// " metric " uint " refmetric " uint
/// " via " ip " if " ifname
/// ```
///
/// The accepted grammar is written in a regex/BNF-ish notation:
///
/// - `e1 e2` means concatenation
/// - `e1 | e2` means choice
/// - `e*` means zero or more
/// - `e+` means one or more
/// - `e?` means optional
/// - `(e)` groups expressions
///
/// # Notes
///
/// - The `xroute` summary `prefix-from` token is parsed only to consume the wire format;
/// the later `prefix` and `from` fields are treated as the authoritative values.
/// - The parser is intentionally strict about the documented token set. Internal defensive
/// fallbacks in `babeld` such as `???` are not treated as part of the formal grammar.
pub mod parse {
use crate::babel::Eui64;
use crate::babel::line::{
BabelLine, Event, EventKind, HeaderLine, InterfaceEvent, NeighbourEvent, RouteEvent,
Status, XRouteEvent,
};
use ipnet::IpNet;
use memchr::memchr;
use std::{
net::{IpAddr, Ipv4Addr},
str::FromStr,
};
use thiserror::Error;
use winnow::{
ascii::{dec_uint, hex_uint, space1},
combinator::{alt, eof, opt, preceded, terminated},
error::ContextError,
prelude::*,
token::{rest, take_till},
};
#[derive(Error, Debug)]
pub enum ParseError {
#[error("invalid utf8 in babeld output: {0}")]
InvalidUtf8(#[from] std::str::Utf8Error),
#[error("failed to parse babeld line {line:?}: {error}")]
Syntax { line: String, error: String },
}
/// Zero-copy line framing for already-buffered socket output.
///
/// This is the `stream = { line }` part of the grammar: framing happens first,
/// then each line is parsed independently by `parse_line`.
#[derive(Debug, Clone)]
pub struct RawLines<'a> {
remaining: &'a [u8],
}
impl<'a> RawLines<'a> {
pub fn new(bytes: &'a [u8]) -> Self {
Self { remaining: bytes }
}
}
impl<'a> Iterator for RawLines<'a> {
type Item = Result<&'a str, ParseError>;
fn next(&mut self) -> Option<Self::Item> {
if self.remaining.is_empty() {
return None;
}
let split = memchr(b'\n', self.remaining);
let (line, rest_bytes) = match split {
Some(idx) => (&self.remaining[..idx], &self.remaining[idx + 1..]),
None => (self.remaining, &[][..]),
};
self.remaining = rest_bytes;
let line = if let Some(stripped) = line.strip_suffix(b"\r") {
stripped
} else {
line
};
Some(std::str::from_utf8(line).map_err(ParseError::InvalidUtf8))
}
}
/// Convenience adapter for parsing a fully buffered transcript, e.g. a dump.
#[derive(Debug, Clone)]
pub struct ParsedLines<'a> {
raw: RawLines<'a>,
}
impl<'a> ParsedLines<'a> {
pub fn new(bytes: &'a [u8]) -> Self {
Self {
raw: RawLines::new(bytes),
}
}
}
impl<'a> Iterator for ParsedLines<'a> {
type Item = Result<BabelLine, ParseError>;
fn next(&mut self) -> Option<Self::Item> {
self.raw.next().map(|line| line.and_then(parse_line))
}
}
pub fn parse_line(line: &str) -> Result<BabelLine, ParseError> {
terminated(parse_babel_line, eof)
.parse(line)
.map_err(|err| ParseError::Syntax {
line: line.to_owned(),
error: err.to_string(),
})
}
fn parse_babel_line(input: &mut &str) -> ModalResult<BabelLine> {
alt((
parse_banner,
parse_version,
parse_host,
parse_my_id,
parse_ok,
parse_bad,
parse_no,
parse_event,
))
.parse_next(input)
}
fn parse_banner(input: &mut &str) -> ModalResult<BabelLine> {
let _ = "BABEL ".parse_next(input)?;
let major = dec_uint::<_, u8, _>.parse_next(input)?;
let _ = '.'.parse_next(input)?;
let minor = dec_uint::<_, u8, _>.parse_next(input)?;
Ok(BabelLine::Header(HeaderLine::Banner { major, minor }))
}
fn parse_version(input: &mut &str) -> ModalResult<BabelLine> {
let _ = "version ".parse_next(input)?;
let version = Box::<str>::from(rest.parse_next(input)?);
Ok(BabelLine::Header(HeaderLine::Version(version)))
}
fn parse_host(input: &mut &str) -> ModalResult<BabelLine> {
let _ = "host ".parse_next(input)?;
let host = Box::<str>::from(rest.parse_next(input)?);
Ok(BabelLine::Header(HeaderLine::Host(host)))
}
fn parse_my_id(input: &mut &str) -> ModalResult<BabelLine> {
let _ = "my-id ".parse_next(input)?;
let id = parse_eui64.parse_next(input)?;
Ok(BabelLine::Header(HeaderLine::MyId(id)))
}
fn parse_ok(input: &mut &str) -> ModalResult<BabelLine> {
let _ = "ok".parse_next(input)?;
Ok(BabelLine::Status(Status::Ok))
}
fn parse_bad(input: &mut &str) -> ModalResult<BabelLine> {
let _ = "bad".parse_next(input)?;
Ok(BabelLine::Status(Status::Bad))
}
fn parse_no(input: &mut &str) -> ModalResult<BabelLine> {
let _ = "no".parse_next(input)?;
let message = opt(preceded(space1, rest)).parse_next(input)?;
let message = message.filter(|msg| !msg.is_empty()).map(Into::into);
Ok(BabelLine::Status(Status::No(message)))
}
fn parse_event(input: &mut &str) -> ModalResult<BabelLine> {
let kind = parse_kind.parse_next(input)?;
let _ = ' '.parse_next(input)?;
let entity = parse_word.parse_next(input)?;
match entity {
"interface" => parse_interface_event(kind, input).map(Event::Interface),
"neighbour" => parse_neighbour_event(kind, input).map(Event::Neighbour),
"xroute" => parse_xroute_event(kind, input).map(Event::XRoute),
"route" => parse_route_event(kind, input).map(Event::Route),
_ => Err(winnow::error::ErrMode::Backtrack(ContextError::new())),
}
.map(BabelLine::Event)
}
fn parse_interface_event(kind: EventKind, input: &mut &str) -> ModalResult<InterfaceEvent> {
let _ = ' '.parse_next(input)?;
let ifname = parse_word.parse_next(input)?;
let _ = " up ".parse_next(input)?;
let up = parse_bool.parse_next(input)?;
let ipv6 = opt(preceded(" ipv6 ", parse_ip_addr)).parse_next(input)?;
let ipv4 = opt(preceded(" ipv4 ", parse_ipv4_addr)).parse_next(input)?;
Ok(InterfaceEvent {
kind,
ifname: ifname.into(),
up,
ipv6,
ipv4,
})
}
fn parse_neighbour_event(kind: EventKind, input: &mut &str) -> ModalResult<NeighbourEvent> {
let _ = ' '.parse_next(input)?;
let handle = parse_hex_u64.parse_next(input)?;
let _ = " address ".parse_next(input)?;
let address = parse_ip_addr.parse_next(input)?;
let _ = " if ".parse_next(input)?;
let ifname = parse_word.parse_next(input)?;
let _ = " reach ".parse_next(input)?;
let reach = parse_hex_u16.parse_next(input)?;
let _ = " ureach ".parse_next(input)?;
let ureach = parse_hex_u16.parse_next(input)?;
let _ = " rxcost ".parse_next(input)?;
let rxcost = dec_uint::<_, u32, _>.parse_next(input)?;
let _ = " txcost ".parse_next(input)?;
let txcost = dec_uint::<_, u32, _>.parse_next(input)?;
let rtt = opt(parse_rtt_clause).parse_next(input)?;
let _ = " cost ".parse_next(input)?;
let cost = dec_uint::<_, u32, _>.parse_next(input)?;
Ok(NeighbourEvent {
kind,
handle,
address,
ifname: ifname.into(),
reach,
ureach,
rxcost,
txcost,
rtt_millis: rtt.map(|(millis, _)| millis),
rttcost: rtt.map(|(_, cost)| cost),
cost,
})
}
fn parse_xroute_event(kind: EventKind, input: &mut &str) -> ModalResult<XRouteEvent> {
let _ = ' '.parse_next(input)?;
let _summary_prefix = parse_prefix_until('-').parse_next(input)?;
let _ = '-'.parse_next(input)?;
let _summary_from = parse_prefix.parse_next(input)?;
let _ = " prefix ".parse_next(input)?;
let prefix = parse_prefix.parse_next(input)?;
let _ = " from ".parse_next(input)?;
let from = parse_prefix.parse_next(input)?;
let _ = " metric ".parse_next(input)?;
let metric = dec_uint::<_, u32, _>.parse_next(input)?;
Ok(XRouteEvent {
kind,
prefix,
from,
metric,
})
}
fn parse_route_event<'a>(kind: EventKind, input: &mut &'a str) -> ModalResult<RouteEvent> {
let _ = ' '.parse_next(input)?;
let handle = parse_hex_u64.parse_next(input)?;
let _ = " prefix ".parse_next(input)?;
let prefix = parse_prefix.parse_next(input)?;
let _ = " from ".parse_next(input)?;
let from = parse_prefix.parse_next(input)?;
let _ = " installed ".parse_next(input)?;
let installed = parse_yes_no.parse_next(input)?;
let _ = " id ".parse_next(input)?;
let id = parse_eui64.parse_next(input)?;
let _ = " metric ".parse_next(input)?;
let metric = dec_uint::<_, u32, _>.parse_next(input)?;
let _ = " refmetric ".parse_next(input)?;
let refmetric = dec_uint::<_, u32, _>.parse_next(input)?;
let _ = " via ".parse_next(input)?;
let via = parse_ip_addr.parse_next(input)?;
let _ = " if ".parse_next(input)?;
let ifname = parse_word.parse_next(input)?;
Ok(RouteEvent {
kind,
handle,
prefix,
from,
installed,
id,
metric,
refmetric,
via,
ifname: ifname.into(),
})
}
fn parse_rtt_clause(input: &mut &str) -> ModalResult<(u32, u32)> {
let _ = " rtt ".parse_next(input)?;
let millis = parse_millis.parse_next(input)?;
let _ = " rttcost ".parse_next(input)?;
let rttcost = dec_uint::<_, u32, _>.parse_next(input)?;
Ok((millis, rttcost))
}
fn parse_kind(input: &mut &str) -> ModalResult<EventKind> {
alt((
"add".value(EventKind::Add),
"change".value(EventKind::Change),
"flush".value(EventKind::Flush),
))
.parse_next(input)
}
fn parse_bool(input: &mut &str) -> ModalResult<bool> {
alt(("true".value(true), "false".value(false))).parse_next(input)
}
fn parse_yes_no(input: &mut &str) -> ModalResult<bool> {
alt(("yes".value(true), "no".value(false))).parse_next(input)
}
fn parse_ip_addr(input: &mut &str) -> ModalResult<IpAddr> {
parse_word.try_map(IpAddr::from_str).parse_next(input)
}
fn parse_ipv4_addr(input: &mut &str) -> ModalResult<Ipv4Addr> {
parse_word.try_map(Ipv4Addr::from_str).parse_next(input)
}
fn parse_prefix(input: &mut &str) -> ModalResult<IpNet> {
parse_word.try_map(IpNet::from_str).parse_next(input)
}
fn parse_prefix_until(separator: char) -> impl FnMut(&mut &str) -> ModalResult<IpNet> {
move |input: &mut &str| {
let token = take_till(1.., |c: char| c == separator).parse_next(input)?;
IpNet::from_str(token)
.map_err(|_| winnow::error::ErrMode::Backtrack(ContextError::new()))
}
}
fn parse_eui64(input: &mut &str) -> ModalResult<Eui64> {
parse_word.try_map(Eui64::from_str).parse_next(input)
}
fn parse_hex_u64(input: &mut &str) -> ModalResult<u64> {
hex_uint.parse_next(input)
}
fn parse_hex_u16(input: &mut &str) -> ModalResult<u16> {
hex_uint.parse_next(input)
}
fn parse_millis(input: &mut &str) -> ModalResult<u32> {
let word = parse_word.parse_next(input)?;
parse_millis_str(word).map_err(|_| winnow::error::ErrMode::Backtrack(ContextError::new()))
}
fn parse_word<'a>(input: &mut &'a str) -> ModalResult<&'a str> {
take_till(1.., |c: char| c == ' ').parse_next(input)
}
fn parse_millis_str(value: &str) -> Result<u32, &'static str> {
let (secs, millis) = value
.split_once('.')
.ok_or("missing milliseconds separator")?;
if millis.len() != 3 || !millis.bytes().all(|b| b.is_ascii_digit()) {
return Err("expected 3-digit millisecond suffix");
}
let secs = secs
.parse::<u32>()
.map_err(|_| "invalid seconds field in rtt value")?;
let millis = millis
.parse::<u32>()
.map_err(|_| "invalid milliseconds field in rtt value")?;
secs.checked_mul(1000)
.and_then(|s| s.checked_add(millis))
.ok_or("rtt value overflowed u32")
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::babel::line::parse::{ParsedLines, parse_line};
use std::str::FromStr;
#[test]
fn parse_header_banner() {
assert_eq!(
parse_line("BABEL 1.0").unwrap(),
BabelLine::Header(HeaderLine::Banner { major: 1, minor: 0 })
);
}
#[test]
fn parse_header_metadata() {
assert_eq!(
parse_line("version babeld-1.13.1").unwrap(),
BabelLine::Header(HeaderLine::Version("babeld-1.13.1".into()))
);
assert_eq!(
parse_line("host e2").unwrap(),
BabelLine::Header(HeaderLine::Host("e2".into()))
);
assert_eq!(
parse_line("my-id 02:00:00:00:00:00:00:01").unwrap(),
BabelLine::Header(HeaderLine::MyId(Eui64::new(2, 0, 0, 0, 0, 0, 0, 1)))
);
}
#[test]
fn parse_status_lines() {
assert_eq!(parse_line("ok").unwrap(), BabelLine::Status(Status::Ok));
assert_eq!(parse_line("bad").unwrap(), BabelLine::Status(Status::Bad));
assert_eq!(
parse_line("no No such interface").unwrap(),
BabelLine::Status(Status::No(Some("No such interface".into())))
);
}
#[test]
fn parse_interface_event() {
assert_eq!(
parse_line("add interface en2 up true ipv6 fe80::1 ipv4 169.254.1.2").unwrap(),
BabelLine::Event(Event::Interface(InterfaceEvent {
kind: EventKind::Add,
ifname: "en2".into(),
up: true,
ipv6: Some(IpAddr::from_str("fe80::1").unwrap()),
ipv4: Some(Ipv4Addr::new(169, 254, 1, 2)),
}))
);
assert_eq!(
parse_line("change interface en3 up false").unwrap(),
BabelLine::Event(Event::Interface(InterfaceEvent {
kind: EventKind::Change,
ifname: "en3".into(),
up: false,
ipv6: None,
ipv4: None,
}))
);
}
#[test]
fn parse_neighbour_event() {
assert_eq!(
parse_line(
"add neighbour 7ffdeadbeef address fe80::1 if en2 reach 00ff ureach 000f rxcost 256 txcost 96 rtt 0.123 rttcost 32 cost 128"
)
.unwrap(),
BabelLine::Event(Event::Neighbour(NeighbourEvent {
kind: EventKind::Add,
handle: 0x7ffdeadbeef,
address: IpAddr::from_str("fe80::1").unwrap(),
ifname: "en2".into(),
reach: 0x00ff,
ureach: 0x000f,
rxcost: 256,
txcost: 96,
rtt_millis: Some(123),
rttcost: Some(32),
cost: 128,
}))
);
}
#[test]
fn parse_xroute_event() {
assert_eq!(
parse_line(
"add xroute fd00::1/128-fd00::/64 prefix fd00::1/128 from fd00::/64 metric 0"
)
.unwrap(),
BabelLine::Event(Event::XRoute(XRouteEvent {
kind: EventKind::Add,
prefix: IpNet::from_str("fd00::1/128").unwrap(),
from: IpNet::from_str("fd00::/64").unwrap(),
metric: 0,
}))
);
}
#[test]
fn parse_route_event() {
assert_eq!(
parse_line(
"change route 7ffdeadbeef prefix fd00::1/128 from fd00::/64 installed yes id 02:00:00:00:00:00:00:01 metric 96 refmetric 0 via fe80::2 if en2"
)
.unwrap(),
BabelLine::Event(Event::Route(RouteEvent {
kind: EventKind::Change,
handle: 0x7ffdeadbeef,
prefix: IpNet::from_str("fd00::1/128").unwrap(),
from: IpNet::from_str("fd00::/64").unwrap(),
installed: true,
id: Eui64::new(2, 0, 0, 0, 0, 0, 0, 1),
metric: 96,
refmetric: 0,
via: IpAddr::from_str("fe80::2").unwrap(),
ifname: "en2".into(),
}))
);
}
#[test]
fn raw_lines_uses_memchr_framing() {
let bytes = b"BABEL 1.0\nok\nadd interface en2 up false\n";
let parsed = ParsedLines::new(bytes)
.collect::<Result<Vec<_>, _>>()
.unwrap();
assert_eq!(
parsed,
vec![
BabelLine::Header(HeaderLine::Banner { major: 1, minor: 0 }),
BabelLine::Status(Status::Ok),
BabelLine::Event(Event::Interface(InterfaceEvent {
kind: EventKind::Add,
ifname: "en2".into(),
up: false,
ipv6: None,
ipv4: None,
})),
]
);
}
}
-38
View File
@@ -1,38 +0,0 @@
use ipnet::Ipv6Net;
use std::sync::Arc;
use tokio::sync::{mpsc, watch};
use crate::Result;
pub mod command;
pub mod line;
pub mod runtime;
pub mod state;
use runtime::BabelRuntime;
/// An EUI-64 type aliased to [`macaddr::MacAddr8`].
pub type Eui64 = macaddr::MacAddr8;
pub use state::BabelState;
#[derive(Debug)]
pub enum Babble {
AddIface(Box<str>),
}
#[tracing::instrument(skip(state_send, recv))]
pub async fn babel(
advertised: Ipv6Net,
mut recv: mpsc::Receiver<Babble>,
state_send: watch::Sender<Arc<BabelState>>,
) -> Result<()> {
// Cannot spawn babeld without at least one interface to monitor.
let Some(Babble::AddIface(iface)) = recv.recv().await else {
return Ok(());
};
let mut runtime = BabelRuntime::spawn(advertised, &iface, state_send).await?;
let res1 = runtime.run(recv).await;
let res2 = runtime.shutdown().await;
res1.and(res2)
}
-436
View File
@@ -1,436 +0,0 @@
//! Managed `babeld` runtime for `babblerd`.
//!
//! This module owns the full lifecycle of the private `babeld` instance:
//!
//! - spawn-time configuration of the child process
//! - the private Unix socket path used for the local control connection
//! - connecting to that socket and speaking the local Babel protocol
//! - running the monitor-driven control loop
//! - shutdown and cleanup of the child process and socket
//!
//! Unlike the old `process` / `session` split, this is intended to model the real runtime unit:
//! a single managed `babeld` process together with its single local control session.
use std::fs::Permissions;
use std::io;
use std::os::unix::fs::PermissionsExt;
use std::sync::Arc;
use ipnet::Ipv6Net;
use nix::errno::Errno;
use nix::sys::signal::{Signal, kill};
use nix::unistd::Pid;
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader, Lines};
use tokio::net::UnixStream;
use tokio::net::unix::{OwnedReadHalf, OwnedWriteHalf};
use tokio::process::{Child, Command};
use tokio::sync::{mpsc, watch};
use tokio::time::{Duration, MissedTickBehavior, timeout};
use crate::babel::Babble;
use crate::babel::command::BabelCommand;
use crate::babel::line::parse::ParseError;
use crate::babel::line::{self, BabelLine, HeaderLine, Status};
use crate::babel::state::BabelState;
use crate::{BabbleError, Result};
#[cfg(target_os = "macos")]
const PRIVATE_SOCK_PATH: &str = "/var/run/babbler/private/babeld.sock";
#[cfg(target_os = "linux")]
const PRIVATE_SOCK_PATH: &str = "/run/babbler/private/babeld.sock";
#[cfg(target_os = "macos")]
const PRIVATE_DIR: &str = "/var/run/babbler/private";
#[cfg(target_os = "linux")]
const PRIVATE_DIR: &str = "/run/babbler/private";
const STARTUP_SOCKET_TIMEOUT: Duration = Duration::from_secs(10);
const STARTUP_SOCKET_POLL_INTERVAL: Duration = Duration::from_millis(50);
const SHUTDOWN_TIMEOUT: Duration = Duration::from_secs(5);
pub(crate) struct BabelRuntime {
proc: Child,
read: Lines<BufReader<OwnedReadHalf>>,
write: OwnedWriteHalf,
state_send: watch::Sender<Arc<BabelState>>,
state: BabelState,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum StartupStage {
Banner,
Version,
Host,
MyId,
Ready,
}
impl StartupStage {
fn advance(self, line: BabelLine) -> Result<Option<Self>> {
match (self, line) {
(Self::Banner, BabelLine::Header(HeaderLine::Banner { major: 1, minor: 0 })) => {
Ok(Some(Self::Version))
}
(Self::Version, BabelLine::Header(HeaderLine::Version(_))) => Ok(Some(Self::Host)),
(Self::Host, BabelLine::Header(HeaderLine::Host(_))) => Ok(Some(Self::MyId)),
(Self::MyId, BabelLine::Header(HeaderLine::MyId(_))) => Ok(Some(Self::Ready)),
(Self::Ready, BabelLine::Status(Status::Ok)) => Ok(None),
(stage, other) => Err(BabbleError::Other(format!(
"unexpected babeld startup line while waiting for {stage:?}: {other:?}"
))),
}
}
}
impl Drop for BabelRuntime {
#[inline]
fn drop(&mut self) {
// Emergency SIGKILL to avoid leaking an unmanaged babeld subprocess.
match self.proc.try_wait() {
Ok(None) => {}
Ok(Some(sc)) => {
if !sc.success() {
_ = self.proc.start_kill();
}
}
_ => {
_ = self.proc.start_kill();
}
}
}
}
impl BabelRuntime {
#[tracing::instrument(skip(state_send))]
pub(crate) async fn spawn(
advertised: Ipv6Net,
iface: &str,
state_send: watch::Sender<Arc<BabelState>>,
) -> Result<Self> {
tokio::fs::create_dir_all(PRIVATE_DIR).await?;
// TODO: remove this magic constant (and magic constants in general)
tokio::fs::set_permissions(PRIVATE_DIR, Permissions::from_mode(0o0700)).await?;
tracing::info!("spawning babeld socket in {PRIVATE_SOCK_PATH}");
let mut proc = match Command::new("babeld")
.arg("-G")
.arg(PRIVATE_SOCK_PATH)
.arg("-I")
.arg(format!("{PRIVATE_DIR}/babeld.pid"))
.arg("-C")
.arg("kernel-install false")
.arg("-C")
.arg(format!("redistribute local ip {advertised}"))
.arg("-C")
.arg("redistribute local deny")
.arg(iface)
.spawn()
{
Ok(proc) => proc,
Err(e) => {
tracing::warn!(error=%e, "failed to spawn babeld");
return Err(e.into());
}
};
if let Err(err) = Self::wait_for_socket(&mut proc).await {
Self::abort_child(&mut proc).await;
return Err(err);
}
// TODO: magic undocumented number
if let Err(err) =
std::fs::set_permissions(PRIVATE_SOCK_PATH, Permissions::from_mode(0o0600))
{
Self::abort_child(&mut proc).await;
return Err(err.into());
}
let (reader, write) = match UnixStream::connect(PRIVATE_SOCK_PATH).await {
Ok(stream) => stream.into_split(),
Err(err) => {
Self::abort_child(&mut proc).await;
return Err(err.into());
}
};
let mut runtime = Self {
proc,
read: BufReader::new(reader).lines(),
write,
state_send,
state: BabelState::new(),
};
if let Err(err) = runtime.await_ready().await {
let _ = runtime.shutdown().await;
return Err(err);
}
Ok(runtime)
}
async fn wait_for_socket(proc: &mut Child) -> Result<()> {
timeout(STARTUP_SOCKET_TIMEOUT, async {
let mut poll = tokio::time::interval(STARTUP_SOCKET_POLL_INTERVAL);
poll.set_missed_tick_behavior(MissedTickBehavior::Delay);
loop {
poll.tick().await;
match tokio::fs::try_exists(PRIVATE_SOCK_PATH).await {
Ok(true) => return Ok(()),
Ok(false) => {}
Err(err) => return Err(err.into()),
}
if let Some(status) = proc.try_wait()? {
return Err(BabbleError::BabeldCrashed(status.code()));
}
}
})
.await
.unwrap_or_else(|_| {
Err(BabbleError::Other(format!(
"timed out after {}s waiting for babeld socket {PRIVATE_SOCK_PATH}",
STARTUP_SOCKET_TIMEOUT.as_secs()
)))
})
}
async fn abort_child(proc: &mut Child) {
let _ = proc.kill().await;
}
#[tracing::instrument(skip_all)]
async fn await_ready(&mut self) -> Result<()> {
let mut stage = StartupStage::Banner;
while let Some(line) = self.read.next_line().await? {
match self.observe_line(line)? {
Ok(parsed) => match stage.advance(parsed)? {
Some(next) => stage = next,
None => {
tracing::info!("babeld ok");
return Ok(());
}
},
Err(err) => {
return Err(BabbleError::Other(format!(
"failed to parse babeld startup prelude: {err}"
)));
}
}
}
Err(BabbleError::Other(
"babeld closed before completing startup prelude".into(),
))
}
#[tracing::instrument(skip(self))]
async fn query(&mut self, cmd: &BabelCommand) -> io::Result<Option<Status>> {
self.write.write_all(cmd.encode().as_bytes()).await?;
loop {
let Some(line) = self.read.next_line().await? else {
tracing::warn!("babeld closed unexpectedly");
return Ok(None);
};
match self.observe_line(line)? {
Ok(parsed) => {
let status = self.reduce_live_line(parsed)?;
let Some(status) = status else {
continue;
};
match &status {
Status::Ok => {}
Status::Bad => tracing::warn!("malformed message sent to babeld"),
Status::No(rest) => tracing::warn!("message rejected: {rest:?}"),
}
return Ok(Some(status));
}
Err(err) => {
return Err(io::Error::new(
io::ErrorKind::InvalidData,
format!("failed to parse babeld command output: {err}"),
));
}
}
}
}
#[tracing::instrument(skip(self))]
fn observe_line(&self, line: String) -> io::Result<std::result::Result<BabelLine, ParseError>> {
tracing::info!("[babel] {:?}", line);
let observed = match line::parse::parse_line(&line) {
Ok(parsed) => {
tracing::info!("[parsed] {:?}", parsed);
Ok(parsed)
}
Err(err) => {
tracing::error!(error=%err, "failed to parse babeld line");
Err(err)
}
};
Ok(observed)
}
#[tracing::instrument(skip(self))]
async fn start_monitoring(&mut self) -> io::Result<Option<Status>> {
let mut snapshot = BabelState::new();
self.write
.write_all(BabelCommand::Monitor.encode().as_bytes())
.await?;
loop {
let Some(line) = self.read.next_line().await? else {
tracing::warn!("babeld closed unexpectedly");
return Ok(None);
};
match self.observe_line(line)? {
Ok(BabelLine::Event(event)) => {
snapshot.apply(event);
}
Ok(BabelLine::Status(status)) => {
match &status {
Status::Ok => {
self.state = snapshot;
self.publish_state();
}
Status::Bad => tracing::warn!("malformed message sent to babeld"),
Status::No(rest) => tracing::warn!("message rejected: {rest:?}"),
}
return Ok(Some(status));
}
Ok(BabelLine::Header(header)) => {
return Err(io::Error::new(
io::ErrorKind::InvalidData,
format!("unexpected header line during monitor bootstrap: {header:?}"),
));
}
Err(err) => {
return Err(io::Error::new(
io::ErrorKind::InvalidData,
format!("failed to parse babeld monitor bootstrap output: {err}"),
));
}
}
}
}
fn publish_state(&self) {
self.state_send.send_replace(Arc::new(self.state.clone()));
}
fn reduce_live_line(&mut self, line: BabelLine) -> io::Result<Option<Status>> {
match line {
BabelLine::Event(event) => {
self.state.apply(event);
self.publish_state();
Ok(None)
}
BabelLine::Status(status) => Ok(Some(status)),
BabelLine::Header(header) => Err(io::Error::new(
io::ErrorKind::InvalidData,
format!("unexpected header line after startup: {header:?}"),
)),
}
}
#[tracing::instrument(skip_all)]
pub(crate) async fn run(&mut self, mut recv: mpsc::Receiver<Babble>) -> Result<()> {
match self.start_monitoring().await? {
Some(Status::Ok) => {}
Some(Status::Bad) => {
return Err(BabbleError::Other(
"babeld rejected monitor command as malformed".into(),
));
}
Some(Status::No(reason)) => {
return Err(BabbleError::Other(format!(
"babeld rejected monitor command: {reason:?}"
)));
}
None => {
return Err(BabbleError::Other(
"babeld control socket closed during monitor bootstrap".into(),
));
}
}
loop {
tokio::select! {
babble = recv.recv() => {
tracing::debug!("[babble] {:?}", babble);
let Some(babble) = babble else {
break;
};
match babble {
Babble::AddIface(iface) => {
let cmd = BabelCommand::Interface(iface);
self.query(&cmd).await?;
}
}
},
line = self.read.next_line() => {
let line = match line {
Ok(Some(line)) => line,
Ok(None) => {
return Err(BabbleError::Other(
"babeld control socket closed during live monitoring".into(),
));
}
Err(err) => {
return Err(BabbleError::Other(format!(
"failed to read babeld control socket during live monitoring: {err}"
)));
}
};
match self.observe_line(line)? {
Ok(parsed) => {
if let Some(status) = self.reduce_live_line(parsed)? {
tracing::debug!(?status, "ignoring unsolicited status line from babeld");
}
}
Err(err) => {
return Err(io::Error::new(
io::ErrorKind::InvalidData,
format!("failed to parse babeld monitor output: {err}"),
)
.into());
}
}
},
}
}
Ok(())
}
pub(crate) async fn shutdown(mut self) -> Result<()> {
let kill_res = if let Some(pid) = self.proc.id() {
let pid: i32 = pid.try_into().expect("pid overflow");
let rc_err = match kill(Pid::from_raw(pid), Signal::SIGINT) {
Ok(()) | Err(Errno::ESRCH) => Ok(()),
Err(err) => Err(io::Error::from_raw_os_error(err as i32).into()),
};
match timeout(SHUTDOWN_TIMEOUT, self.proc.wait()).await {
Ok(Ok(code)) => {
if code.success() {
rc_err
} else {
rc_err.and_then(|()| Err(BabbleError::BabeldCrashed(code.code())))
}
}
Ok(Err(e)) => Err(e.into()),
Err(_) => {
self.proc.kill().await?;
rc_err.and(Err(BabbleError::BabeldCrashed(None)))
}
}
} else {
Ok(())
};
let rem_res = match std::fs::remove_file(PRIVATE_SOCK_PATH) {
Ok(()) => Ok(()),
Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(()),
Err(e) => Err(e.into()),
};
kill_res.and(rem_res)
}
}
-398
View File
@@ -1,398 +0,0 @@
//! Reduced in-memory state derived from `babeld` event lines.
//!
//! This module is the consumer-side counterpart to [`crate::babel::line`]:
//!
//! - [`Event`] is the wire/domain event stream emitted by `babeld`
//! - [`BabelState`] is the current snapshot obtained by reducing those events
//!
//! The reducer model is intentionally simple:
//!
//! - `add` inserts the entity into the relevant table
//! - `change` upserts the entity into the relevant table
//! - `flush` removes the entity from the relevant table
//!
//! The stored state types do **not** retain [`EventKind`], because the event
//! kind is transport/update metadata rather than persistent object state.
use std::collections::HashMap;
use std::net::{IpAddr, Ipv4Addr};
use crate::babel::Eui64;
use crate::babel::line::{
Event, EventKind, InterfaceEvent, NeighbourEvent, RouteEvent, XRouteEvent,
};
use ipnet::IpNet;
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct BabelState {
pub interfaces: HashMap<Box<str>, InterfaceState>,
pub neighbours: HashMap<u64, NeighbourState>,
pub xroutes: HashMap<XRouteKey, XRouteState>,
pub routes: HashMap<u64, RouteState>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct InterfaceState {
pub ifname: Box<str>,
pub up: bool,
pub ipv6: Option<IpAddr>,
pub ipv4: Option<Ipv4Addr>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct NeighbourState {
pub handle: u64,
pub address: IpAddr,
pub ifname: Box<str>,
pub reach: u16,
pub ureach: u16,
pub rxcost: u32,
pub txcost: u32,
pub rtt_millis: Option<u32>,
pub rttcost: Option<u32>,
pub cost: u32,
}
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct XRouteKey {
pub prefix: IpNet,
pub from: IpNet,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct XRouteState {
pub prefix: IpNet,
pub from: IpNet,
pub metric: u32,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RouteState {
pub handle: u64,
pub prefix: IpNet,
pub from: IpNet,
pub installed: bool,
pub id: Eui64,
pub metric: u32,
pub refmetric: u32,
pub via: IpAddr,
pub ifname: Box<str>,
}
impl BabelState {
#[must_use]
pub fn new() -> Self {
Self::default()
}
pub fn apply(&mut self, event: Event) {
match event {
Event::Interface(event) => self.apply_interface(event),
Event::Neighbour(event) => self.apply_neighbour(event),
Event::XRoute(event) => self.apply_xroute(event),
Event::Route(event) => self.apply_route(event),
}
}
pub fn extend<I>(&mut self, events: I)
where
I: IntoIterator<Item = Event>,
{
for event in events {
self.apply(event);
}
}
fn apply_interface(&mut self, event: InterfaceEvent) {
let key = event.ifname.clone();
match event.kind {
EventKind::Add | EventKind::Change => {
self.interfaces.insert(key, event.into());
}
EventKind::Flush => {
self.interfaces.remove(&key);
}
}
}
fn apply_neighbour(&mut self, event: NeighbourEvent) {
let key = event.handle;
match event.kind {
EventKind::Add | EventKind::Change => {
self.neighbours.insert(key, event.into());
}
EventKind::Flush => {
self.neighbours.remove(&key);
}
}
}
fn apply_xroute(&mut self, event: XRouteEvent) {
let key = XRouteKey {
prefix: event.prefix,
from: event.from,
};
match event.kind {
EventKind::Add | EventKind::Change => {
self.xroutes.insert(key, event.into());
}
EventKind::Flush => {
self.xroutes.remove(&key);
}
}
}
fn apply_route(&mut self, event: RouteEvent) {
let key = event.handle;
match event.kind {
EventKind::Add | EventKind::Change => {
self.routes.insert(key, event.into());
}
EventKind::Flush => {
self.routes.remove(&key);
}
}
}
}
impl From<InterfaceEvent> for InterfaceState {
fn from(event: InterfaceEvent) -> Self {
Self {
ifname: event.ifname,
up: event.up,
ipv6: event.ipv6,
ipv4: event.ipv4,
}
}
}
impl From<NeighbourEvent> for NeighbourState {
fn from(event: NeighbourEvent) -> Self {
Self {
handle: event.handle,
address: event.address,
ifname: event.ifname,
reach: event.reach,
ureach: event.ureach,
rxcost: event.rxcost,
txcost: event.txcost,
rtt_millis: event.rtt_millis,
rttcost: event.rttcost,
cost: event.cost,
}
}
}
impl From<XRouteEvent> for XRouteState {
fn from(event: XRouteEvent) -> Self {
Self {
prefix: event.prefix,
from: event.from,
metric: event.metric,
}
}
}
impl From<RouteEvent> for RouteState {
fn from(event: RouteEvent) -> Self {
Self {
handle: event.handle,
prefix: event.prefix,
from: event.from,
installed: event.installed,
id: event.id,
metric: event.metric,
refmetric: event.refmetric,
via: event.via,
ifname: event.ifname,
}
}
}
#[cfg(test)]
mod tests {
use std::net::{IpAddr, Ipv4Addr, Ipv6Addr};
use super::{BabelState, InterfaceState, XRouteKey};
use crate::babel::Eui64;
use crate::babel::line::{
Event, EventKind, InterfaceEvent, NeighbourEvent, RouteEvent, XRouteEvent,
};
use ipnet::IpNet;
fn net(s: &str) -> IpNet {
s.parse().unwrap()
}
#[test]
fn interface_add_change_flush() {
let mut state = BabelState::new();
state.apply(Event::Interface(InterfaceEvent {
kind: EventKind::Add,
ifname: "en2".into(),
up: true,
ipv6: Some(IpAddr::V6(Ipv6Addr::LOCALHOST)),
ipv4: Some(Ipv4Addr::new(169, 254, 1, 2)),
}));
assert_eq!(
state.interfaces.get("en2"),
Some(&InterfaceState {
ifname: "en2".into(),
up: true,
ipv6: Some(IpAddr::V6(Ipv6Addr::LOCALHOST)),
ipv4: Some(Ipv4Addr::new(169, 254, 1, 2)),
})
);
state.apply(Event::Interface(InterfaceEvent {
kind: EventKind::Change,
ifname: "en2".into(),
up: false,
ipv6: None,
ipv4: None,
}));
assert_eq!(
state.interfaces.get("en2"),
Some(&InterfaceState {
ifname: "en2".into(),
up: false,
ipv6: None,
ipv4: None,
})
);
state.apply(Event::Interface(InterfaceEvent {
kind: EventKind::Flush,
ifname: "en2".into(),
up: false,
ipv6: None,
ipv4: None,
}));
assert!(!state.interfaces.contains_key("en2"));
}
#[test]
fn neighbour_add_and_flush() {
let mut state = BabelState::new();
state.apply(Event::Neighbour(NeighbourEvent {
kind: EventKind::Add,
handle: 0xabc,
address: IpAddr::V6("fe80::1".parse().unwrap()),
ifname: "en3".into(),
reach: 0x00ff,
ureach: 0x000f,
rxcost: 96,
txcost: 128,
rtt_millis: Some(42),
rttcost: Some(10),
cost: 224,
}));
assert_eq!(state.neighbours.len(), 1);
assert_eq!(state.neighbours.get(&0xabc).unwrap().ifname.as_ref(), "en3");
state.apply(Event::Neighbour(NeighbourEvent {
kind: EventKind::Flush,
handle: 0xabc,
address: IpAddr::V6("fe80::1".parse().unwrap()),
ifname: "en3".into(),
reach: 0,
ureach: 0,
rxcost: 0,
txcost: 0,
rtt_millis: None,
rttcost: None,
cost: 0,
}));
assert!(state.neighbours.is_empty());
}
#[test]
fn xroute_change_upserts_by_prefix_pair() {
let mut state = BabelState::new();
state.apply(Event::XRoute(XRouteEvent {
kind: EventKind::Add,
prefix: net("fde0:20c6:1fa7:ffff::/128"),
from: net("::/0"),
metric: 256,
}));
state.apply(Event::XRoute(XRouteEvent {
kind: EventKind::Change,
prefix: net("fde0:20c6:1fa7:ffff::/128"),
from: net("::/0"),
metric: 42,
}));
assert_eq!(state.xroutes.len(), 1);
assert_eq!(
state
.xroutes
.get(&XRouteKey {
prefix: net("fde0:20c6:1fa7:ffff::/128"),
from: net("::/0"),
})
.unwrap()
.metric,
42
);
}
#[test]
fn route_add_and_flush_by_handle() {
let mut state = BabelState::new();
state.apply(Event::Route(RouteEvent {
kind: EventKind::Add,
handle: 0xdeadbeef,
prefix: net("fde0:20c6:1fa7:ffff::/128"),
from: net("::/0"),
installed: true,
id: Eui64::new(0, 1, 2, 3, 4, 5, 6, 7),
metric: 96,
refmetric: 96,
via: IpAddr::V6("fe80::1234".parse().unwrap()),
ifname: "en2".into(),
}));
assert_eq!(state.routes.len(), 1);
assert!(state.routes.get(&0xdeadbeef).unwrap().installed);
state.apply(Event::Route(RouteEvent {
kind: EventKind::Flush,
handle: 0xdeadbeef,
prefix: net("fde0:20c6:1fa7:ffff::/128"),
from: net("::/0"),
installed: false,
id: Eui64::new(0, 1, 2, 3, 4, 5, 6, 7),
metric: 0,
refmetric: 0,
via: IpAddr::V6("fe80::1234".parse().unwrap()),
ifname: "en2".into(),
}));
assert!(state.routes.is_empty());
}
#[test]
fn extend_applies_multiple_events() {
let mut state = BabelState::new();
state.extend([
Event::Interface(InterfaceEvent {
kind: EventKind::Add,
ifname: "en2".into(),
up: true,
ipv6: None,
ipv4: None,
}),
Event::XRoute(XRouteEvent {
kind: EventKind::Add,
prefix: net("fde0:20c6:1fa7:ffff::/128"),
from: net("::/0"),
metric: 123,
}),
]);
assert_eq!(state.interfaces.len(), 1);
assert_eq!(state.xroutes.len(), 1);
}
}
-141
View File
@@ -1,141 +0,0 @@
//! Process configuration and shared constants for `babblerd`.
//!
//! This module centralizes:
//!
//! - default runtime paths
//! - environment variable overrides
//! - protocol/application constants such as the mesh prefix
//! - coarse daemon defaults such as the router UDP port
use color_eyre::eyre::{self, eyre};
use ipnet::Ipv6Net;
use std::collections::HashSet;
use std::env;
use std::net::Ipv6Addr;
use std::path::{Path, PathBuf};
pub const PUBLIC_SOCKET_PATH_ENV: &str = "BABBLER_SOCKET_PATH";
pub const NODE_ID_FILE_ENV: &str = "BABBLER_NODE_ID_FILE";
pub const ROUTER_UDP_PORT_ENV: &str = "BABBLER_ROUTER_UDP_PORT";
pub const INTERFACE_ALLOWLIST_ENV: &str = "BABBLER_INTERFACE_ALLOWLIST";
pub const DEFAULT_PUBLIC_SOCKET_PATH: &str = {
#[cfg(target_os = "macos")]
{
"/var/run/babbler/babblerd.sock"
}
#[cfg(target_os = "linux")]
{
"/run/babbler/babblerd.sock"
}
};
pub const DEFAULT_NODE_ID_FILE: &str = {
#[cfg(target_os = "macos")]
{
"/var/db/babbler/node-id"
}
#[cfg(target_os = "linux")]
{
"/var/lib/babbler/node-id"
}
};
// TODO: just picked a random one that didn't seem occupied, there is probably a better way
// to do this in the future :)
pub const DEFAULT_ROUTER_UDP_PORT: u16 = 41897;
pub const PHYSICAL_LINK_MTU: u16 = 1500;
pub const OUTER_IPV6_HEADER_BYTES: u16 = 40;
pub const OUTER_UDP_HEADER_BYTES: u16 = 8;
pub const TUN_MTU: u16 = PHYSICAL_LINK_MTU - OUTER_IPV6_HEADER_BYTES - OUTER_UDP_HEADER_BYTES;
pub const EXO_ULA_PREFIX: Ipv6Net = Ipv6Net::new_assert(
// TODO: break out into "fd" for ULA
// e0_20c61fa7 for EXO address-space
// ffff for anything else we want, like maybe versioning and so on (but for now its not used)
//
// NOTE: spell the hextets explicitly here. A previous `u128` bit-shift
// construction accidentally truncated the leading `fde0` and produced
// `20c6:1fa7:ffff::/64`, which is not ULA.
Ipv6Addr::new(0xfde0, 0x20c6, 0x1fa7, 0xffff, 0, 0, 0, 0),
64,
);
#[derive(Debug, Clone)]
pub struct Config {
pub public_socket_path: PathBuf,
pub public_dir: PathBuf,
pub node_id_file: PathBuf,
pub router_udp_port: u16,
pub exo_ula_prefix: Ipv6Net,
}
impl Config {
pub fn from_env() -> eyre::Result<Self> {
let public_socket_path = env::var_os(PUBLIC_SOCKET_PATH_ENV)
.map(PathBuf::from)
.unwrap_or_else(|| PathBuf::from(DEFAULT_PUBLIC_SOCKET_PATH));
let Some(public_dir) = public_socket_path.parent().map(Path::to_path_buf) else {
return Err(eyre!(
"public socket path has no parent directory: {}",
public_socket_path.display()
));
};
let node_id_file = env::var_os(NODE_ID_FILE_ENV)
.map(PathBuf::from)
.unwrap_or_else(|| PathBuf::from(DEFAULT_NODE_ID_FILE));
let router_udp_port = match env::var(ROUTER_UDP_PORT_ENV) {
Ok(raw) => raw
.parse::<u16>()
.map_err(|e| eyre!("invalid {ROUTER_UDP_PORT_ENV} value {raw:?}: {e}"))?,
Err(_) => DEFAULT_ROUTER_UDP_PORT,
};
Ok(Self {
public_socket_path,
public_dir,
node_id_file,
router_udp_port,
exo_ula_prefix: EXO_ULA_PREFIX,
})
}
}
pub fn interface_allowlist_from_env() -> eyre::Result<Option<HashSet<Box<str>>>> {
let Ok(raw) = env::var(INTERFACE_ALLOWLIST_ENV) else {
return Ok(None);
};
let allowlist = raw
.split(',')
.map(str::trim)
.filter(|name| !name.is_empty())
.map(|name| name.into())
.collect::<HashSet<Box<str>>>();
if allowlist.is_empty() {
return Err(eyre!(
"{INTERFACE_ALLOWLIST_ENV} was set but contained no interface names"
));
}
Ok(Some(allowlist))
}
#[cfg(test)]
mod tests {
use super::EXO_ULA_PREFIX;
use std::net::Ipv6Addr;
#[test]
fn exo_ula_prefix_keeps_fde0_high_bits() {
assert_eq!(
EXO_ULA_PREFIX.addr(),
Ipv6Addr::new(0xfde0, 0x20c6, 0x1fa7, 0xffff, 0, 0, 0, 0)
);
assert_eq!(EXO_ULA_PREFIX.prefix_len(), 64);
}
}
-381
View File
@@ -1,381 +0,0 @@
use color_eyre::eyre::{Result, WrapErr, eyre};
use ipnet::Ipv6Net;
use std::fmt::{Display, Formatter};
use std::{future::pending, sync::Arc};
use tokio::{
io::{AsyncBufReadExt, AsyncWriteExt, BufReader},
net::UnixStream,
sync::{mpsc, oneshot, watch},
task::JoinHandle,
time::{Duration, Instant},
};
use crate::route_ctl;
use crate::routing_stack::RoutingStack;
use crate::{babel::BabelState, tun::TunDevice};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ServiceState {
Off,
Starting,
On,
Stopping,
}
impl Display for ServiceState {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
match self {
Self::Off => write!(f, "off"),
Self::Starting => write!(f, "starting"),
Self::On => write!(f, "on"),
Self::Stopping => write!(f, "stopping"),
}
}
}
#[derive(Debug, Clone)]
pub struct DaemonStatus {
pub service_state: ServiceState,
pub node_id: u64,
pub node_addr: Ipv6Net,
pub tun_ifname: Arc<str>,
// realistically should always have one?? right??
pub keepalive_deadline: Option<Instant>,
pub last_error: Option<Arc<str>>,
}
impl DaemonStatus {
fn new(node_id: u64, node_addr: Ipv6Net, tun_ifname: Arc<str>) -> Self {
Self {
service_state: ServiceState::Off,
node_id,
node_addr,
tun_ifname,
keepalive_deadline: None,
last_error: None,
}
}
pub fn render(&self) -> String {
let keepalive_remaining_ms = self
.keepalive_deadline
.and_then(|deadline| deadline.checked_duration_since(Instant::now()))
.map(|remaining| remaining.as_millis().to_string())
.unwrap_or_else(|| "none".to_owned());
let mut line = format!(
"state {} node_id={:#018x} node_addr={} tun={} keepalive_remaining_ms={}",
self.service_state,
self.node_id,
self.node_addr,
self.tun_ifname,
keepalive_remaining_ms
);
if let Some(err) = &self.last_error {
line.push_str(&format!(" last_error={err:?}"));
}
line
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum StackTaskKind {
Babel,
Watcher,
FibPublisher,
Dataplane,
}
#[derive(Debug)]
pub enum RoutingStackEvent {
Exited {
kind: StackTaskKind,
error: Option<String>,
},
}
#[derive(Debug)]
enum DaemonCommand {
KeepAlive {
ttl: Duration,
reply: oneshot::Sender<Result<DaemonStatus>>,
},
GetState {
reply: oneshot::Sender<DaemonStatus>,
},
}
#[derive(Clone)]
pub struct DaemonHandle {
send: mpsc::Sender<DaemonCommand>,
}
impl DaemonHandle {
pub async fn keep_alive(&self, ttl: Duration) -> Result<DaemonStatus> {
let (reply_send, reply_recv) = oneshot::channel();
self.send
.send(DaemonCommand::KeepAlive {
ttl,
reply: reply_send,
})
.await
.map_err(|_| eyre!("daemon core stopped"))?;
reply_recv.await.map_err(|_| eyre!("daemon core stopped"))?
}
pub async fn get_state(&self) -> Result<DaemonStatus> {
let (reply_send, reply_recv) = oneshot::channel();
self.send
.send(DaemonCommand::GetState { reply: reply_send })
.await
.map_err(|_| eyre!("daemon core stopped"))?;
reply_recv.await.map_err(|_| eyre!("daemon core stopped"))
}
}
pub struct DaemonCore {
status: DaemonStatus,
overlay_prefix: Ipv6Net,
router_udp_port: u16,
_tun: TunDevice,
routing_stack: Option<RoutingStack>,
babel_state_send: watch::Sender<Arc<BabelState>>,
command_recv: mpsc::Receiver<DaemonCommand>,
event_send: mpsc::Sender<RoutingStackEvent>,
event_recv: mpsc::Receiver<RoutingStackEvent>,
}
impl DaemonCore {
pub fn spawn(
node_id: u64,
overlay_prefix: Ipv6Net,
router_udp_port: u16,
node_addr: Ipv6Net,
tun: TunDevice,
babel_state_send: watch::Sender<Arc<BabelState>>,
) -> (DaemonHandle, JoinHandle<Result<()>>) {
let (command_send, command_recv) = mpsc::channel(32);
let (event_send, event_recv) = mpsc::channel(8);
let status = DaemonStatus::new(node_id, node_addr, Arc::from(tun.ifname().to_owned()));
let core = Self {
status,
overlay_prefix,
router_udp_port,
_tun: tun,
routing_stack: None,
babel_state_send,
command_recv,
event_send,
event_recv,
};
let handle = DaemonHandle { send: command_send };
let task = tokio::spawn(core.run());
(handle, task)
}
async fn run(mut self) -> Result<()> {
loop {
tokio::select! {
command = self.command_recv.recv() => {
let Some(command) = command else {
break;
};
self.handle_command(command).await?;
}
event = self.event_recv.recv() => {
let Some(event) = event else {
break;
};
self.handle_stack_event(event).await?;
}
_ = lease_timer(self.status.keepalive_deadline) => {
self.handle_lease_expiry().await?;
}
}
}
self.stop_stack().await?;
Ok(())
}
async fn handle_command(&mut self, command: DaemonCommand) -> Result<()> {
match command {
DaemonCommand::KeepAlive { ttl, reply } => {
self.status.keepalive_deadline = Some(Instant::now() + ttl);
if self.routing_stack.is_none() {
let result = self.start_stack().await.map(|()| self.status.clone());
let _ = reply.send(result);
return Ok(());
}
let _ = reply.send(Ok(self.status.clone()));
Ok(())
}
DaemonCommand::GetState { reply } => {
let _ = reply.send(self.status.clone());
Ok(())
}
}
}
async fn handle_stack_event(&mut self, event: RoutingStackEvent) -> Result<()> {
let RoutingStackEvent::Exited { kind, error } = event;
if self.routing_stack.is_none() {
return Ok(());
}
tracing::warn!(?kind, ?error, "routing stack task exited");
self.status.last_error =
Some(Arc::from(error.unwrap_or_else(|| {
format!("{kind:?} task exited unexpectedly")
})));
if let Err(err) = self.stop_stack().await {
self.status.last_error = Some(Arc::from(err.to_string()));
}
Ok(())
}
async fn handle_lease_expiry(&mut self) -> Result<()> {
let expired = self
.status
.keepalive_deadline
.is_some_and(|deadline| deadline <= Instant::now());
if expired {
tracing::info!("keepalive expired, transitioning routing stack off");
self.status.keepalive_deadline = None;
if let Err(err) = self.stop_stack().await {
self.status.last_error = Some(Arc::from(err.to_string()));
}
}
Ok(())
}
async fn start_stack(&mut self) -> Result<()> {
if self.routing_stack.is_some() {
return Ok(());
}
self.status.service_state = ServiceState::Starting;
self.status.last_error = None;
match RoutingStack::start(
self.status.node_addr,
&self._tun,
self.router_udp_port,
self.babel_state_send.clone(),
self.event_send.clone(),
) {
Ok(stack) => {
self.routing_stack = Some(stack);
if let Err(err) = route_ctl::ensure_overlay_route(
self.overlay_prefix,
self.status.tun_ifname.as_ref(),
) {
let _ = self.stop_stack().await;
self.status.service_state = ServiceState::Off;
self.status.last_error = Some(Arc::from(err.to_string()));
return Err(err.into());
}
self.status.service_state = ServiceState::On;
Ok(())
}
Err(err) => {
self.status.service_state = ServiceState::Off;
self.status.last_error = Some(Arc::from(err.to_string()));
Err(err)
}
}
}
async fn stop_stack(&mut self) -> Result<()> {
if let Err(err) = route_ctl::remove_overlay_route(self.overlay_prefix) {
tracing::warn!(error=%err, "failed to remove overlay route");
}
let Some(stack) = self.routing_stack.take() else {
self.status.service_state = ServiceState::Off;
self.babel_state_send
.send_replace(Arc::new(BabelState::new()));
return Ok(());
};
self.status.service_state = ServiceState::Stopping;
let stop_result = stack.stop().await;
self.babel_state_send
.send_replace(Arc::new(BabelState::new()));
self.status.service_state = ServiceState::Off;
if let Err(err) = stop_result {
self.status.last_error = Some(Arc::from(err.to_string()));
return Err(err);
}
Ok(())
}
}
async fn lease_timer(deadline: Option<Instant>) {
if let Some(deadline) = deadline {
tokio::time::sleep_until(deadline).await;
} else {
pending::<()>().await;
}
}
pub async fn handle_client(sock: UnixStream, daemon: DaemonHandle) {
tracing::info!("new socket conn");
let (reader, mut write) = sock.into_split();
let mut reader = BufReader::new(reader).lines();
if let Ok(state) = daemon.get_state().await {
let _ = write
.write_all(format!("{}\n", state.render()).as_bytes())
.await;
}
loop {
let Ok(Some(line)) = reader.next_line().await else {
break;
};
let trimmed = line.trim();
if trimmed.is_empty() {
continue;
}
let response = match handle_command_line(trimmed, &daemon).await {
Ok(response) => response,
Err(err) => format!("error {err}"),
};
if let Err(err) = write.write_all(format!("{response}\n").as_bytes()).await {
tracing::warn!(error=%err, "failed to write command response");
break;
}
}
tracing::info!("closing socket conn");
let _ = write.shutdown().await;
}
async fn handle_command_line(line: &str, daemon: &DaemonHandle) -> Result<String> {
let mut parts = line.split_whitespace();
let Some(command) = parts.next() else {
return Ok("error empty-command".to_owned());
};
match command {
"get-state" => Ok(daemon.get_state().await?.render()),
"keepalive" => {
let Some(ttl_ms) = parts.next() else {
return Err(eyre!("keepalive requires ttl_ms"));
};
let ttl_ms = ttl_ms
.parse::<u64>()
.wrap_err_with(|| format!("invalid ttl_ms: {ttl_ms:?}"))?;
Ok(daemon
.keep_alive(Duration::from_millis(ttl_ms))
.await?
.render())
}
"help" => Ok("commands: get-state | keepalive <ttl_ms>".to_owned()),
other => Err(eyre!("unknown command: {other}")),
}
}
File diff suppressed because it is too large. Load diff
-390
View File
@@ -1,390 +0,0 @@
//! Immutable forwarding snapshots derived from [`crate::babel::BabelState`].
//!
//! `BabelState` mirrors the control-plane view emitted by `babeld`.
//! `FibSnapshot` is the reduced dataplane view:
//!
//! - exact-match IPv6 host routes only for now,
//! - admitted interface ownership alongside those routes,
//! - one immutable snapshot swapped wholesale into the dataplane,
//! - keyed for fast lookup rather than protocol fidelity.
//!
//! The v1 forwarding model is intentionally narrow:
//!
//! - local addresses are explicit inputs, not inferred from every xroute,
//! - only interfaces with a live Babel neighbour are exposed to the dataplane,
//! - only installed IPv6 `/128` routes are considered,
//! - only destination-based forwarding is modeled,
//! - routes with non-link-local next hops are ignored.
use std::net::Ipv6Addr;
use ahash::RandomState;
use hashbrown::{HashMap, HashSet, hash_map::Entry};
use ipnet::{IpNet, Ipv6Net};
use crate::babel::BabelState;
use crate::babel::state::RouteState;
pub type HostKey = u128;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct FibEntry {
pub next_hop_ll: Ipv6Addr,
pub ifname: Box<str>,
pub mtu: u16,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct FibSnapshot {
pub locals: HashSet<HostKey, RandomState>,
pub admitted_interfaces: HashSet<Box<str>, RandomState>,
pub routes: HashMap<HostKey, FibEntry, RandomState>,
}
#[derive(Debug, Clone)]
pub struct FibBuilder {
local_addrs: Vec<Ipv6Addr>,
route_mtu: u16,
}
impl FibBuilder {
pub fn new<I>(local_addrs: I, route_mtu: u16) -> Self
where
I: IntoIterator<Item = Ipv6Addr>,
{
Self {
local_addrs: local_addrs.into_iter().collect(),
route_mtu,
}
}
pub fn derive(&self, state: &BabelState) -> FibSnapshot {
let mut locals =
HashSet::with_capacity_and_hasher(self.local_addrs.len(), RandomState::new());
for addr in &self.local_addrs {
locals.insert(host_key(*addr));
}
let up_interfaces: HashSet<&str, RandomState> = state
.interfaces
.values()
.filter(|interface| interface.up)
.map(|interface| interface.ifname.as_ref())
.collect::<HashSet<_, _>>();
let mut admitted_interfaces = HashSet::with_hasher(RandomState::new());
for neighbour in state.neighbours.values() {
if up_interfaces.contains(neighbour.ifname.as_ref()) {
admitted_interfaces.insert(neighbour.ifname.clone());
}
}
let mut routes = HashMap::with_hasher(RandomState::new());
let mut route_scores = HashMap::with_hasher(RandomState::new());
let mut candidates: Vec<&RouteState> = state.routes.values().collect();
candidates.sort_by(|left, right| {
left.ifname
.cmp(&right.ifname)
.then_with(|| left.prefix.to_string().cmp(&right.prefix.to_string()))
.then_with(|| left.metric.cmp(&right.metric))
.then_with(|| left.refmetric.cmp(&right.refmetric))
.then_with(|| left.handle.cmp(&right.handle))
});
for route in candidates {
let Some((dst, next_hop_ll)) = route_to_host(route) else {
continue;
};
if !admitted_interfaces.contains(route.ifname.as_ref()) {
continue;
}
if locals.contains(&dst) {
continue;
}
let candidate = FibEntry {
next_hop_ll,
ifname: route.ifname.clone(),
mtu: self.route_mtu,
};
let candidate_score = (route.metric, route.refmetric, route.handle);
match routes.entry(dst) {
Entry::Vacant(slot) => {
slot.insert(candidate);
route_scores.insert(dst, candidate_score);
}
Entry::Occupied(mut slot) => {
let Some(existing_score) = route_scores.get(&dst).copied() else {
slot.insert(candidate);
route_scores.insert(dst, candidate_score);
continue;
};
if candidate_score < existing_score {
slot.insert(candidate);
route_scores.insert(dst, candidate_score);
}
}
}
}
FibSnapshot {
locals,
admitted_interfaces,
routes,
}
}
}
impl FibSnapshot {
pub fn empty() -> Self {
Self {
locals: HashSet::with_hasher(RandomState::new()),
admitted_interfaces: HashSet::with_hasher(RandomState::new()),
routes: HashMap::with_hasher(RandomState::new()),
}
}
pub fn from_node_addr(node_addr: Ipv6Net, state: &BabelState, route_mtu: u16) -> Self {
FibBuilder::new([node_addr.addr()], route_mtu).derive(state)
}
pub fn is_local(&self, addr: Ipv6Addr) -> bool {
self.locals.contains(&host_key(addr))
}
pub fn lookup(&self, addr: Ipv6Addr) -> Option<&FibEntry> {
self.routes.get(&host_key(addr))
}
}
pub fn host_key(addr: Ipv6Addr) -> HostKey {
u128::from(addr)
}
fn route_to_host(route: &RouteState) -> Option<(HostKey, Ipv6Addr)> {
if !route.installed {
return None;
}
let IpNet::V6(prefix) = route.prefix else {
return None;
};
if prefix.prefix_len() != 128 {
return None;
}
let IpNet::V6(from) = route.from else {
return None;
};
if from.prefix_len() != 0 || from.addr() != Ipv6Addr::UNSPECIFIED {
return None;
}
let std::net::IpAddr::V6(via) = route.via else {
return None;
};
if !via.is_unicast_link_local() {
return None;
}
Some((host_key(prefix.addr()), via))
}
#[cfg(test)]
mod tests {
use std::net::{IpAddr, Ipv6Addr};
use crate::babel::Eui64;
use crate::babel::line::{Event, EventKind, InterfaceEvent, NeighbourEvent, RouteEvent};
use crate::babel::state::BabelState;
use super::FibBuilder;
fn route(
handle: u64,
prefix: &str,
from: &str,
installed: bool,
via: Ipv6Addr,
ifname: &str,
metric: u32,
refmetric: u32,
) -> Event {
Event::Route(RouteEvent {
kind: EventKind::Add,
handle,
prefix: prefix.parse().unwrap(),
from: from.parse().unwrap(),
installed,
id: Eui64::new(0, 1, 2, 3, 4, 5, 6, 7),
metric,
refmetric,
via: IpAddr::V6(via),
ifname: ifname.into(),
})
}
fn interface(ifname: &str, up: bool) -> Event {
Event::Interface(InterfaceEvent {
kind: EventKind::Add,
ifname: ifname.into(),
up,
ipv6: None,
ipv4: None,
})
}
fn neighbour(handle: u64, ifname: &str, address: &str) -> Event {
Event::Neighbour(NeighbourEvent {
kind: EventKind::Add,
handle,
address: IpAddr::V6(address.parse().unwrap()),
ifname: ifname.into(),
reach: 0xffff,
ureach: 0xffff,
rxcost: 96,
txcost: 96,
rtt_millis: Some(1),
rttcost: Some(0),
cost: 96,
})
}
#[test]
fn derives_local_and_host_routes() {
let mut state = BabelState::new();
state.apply(interface("en2", true));
state.apply(neighbour(1, "en2", "fe80::1"));
state.apply(route(
1,
"fde0::1234/128",
"::/0",
true,
"fe80::1".parse().unwrap(),
"en2",
96,
32,
));
let fib = FibBuilder::new(["fde0::1".parse().unwrap()], 1452).derive(&state);
assert!(fib.is_local("fde0::1".parse().unwrap()));
assert!(fib.admitted_interfaces.contains("en2"));
let entry = fib.lookup("fde0::1234".parse().unwrap()).unwrap();
assert_eq!(entry.next_hop_ll, "fe80::1".parse::<Ipv6Addr>().unwrap());
assert_eq!(entry.ifname.as_ref(), "en2");
assert_eq!(entry.mtu, 1452);
}
#[test]
fn skips_non_installed_or_non_host_routes() {
let mut state = BabelState::new();
state.apply(interface("en2", true));
state.apply(interface("en3", true));
state.apply(neighbour(1, "en2", "fe80::1"));
state.apply(neighbour(2, "en3", "fe80::2"));
state.apply(route(
1,
"fde0::abcd/128",
"::/0",
false,
"fe80::1".parse().unwrap(),
"en2",
96,
32,
));
state.apply(route(
2,
"fde0::/64",
"::/0",
true,
"fe80::2".parse().unwrap(),
"en3",
96,
32,
));
let fib = FibBuilder::new(["fde0::1".parse().unwrap()], 1452).derive(&state);
assert!(fib.routes.is_empty());
}
#[test]
fn prefers_lower_metric_when_multiple_installed_routes_exist() {
let mut state = BabelState::new();
state.apply(interface("en2", true));
state.apply(interface("en3", true));
state.apply(neighbour(1, "en2", "fe80::1"));
state.apply(neighbour(2, "en3", "fe80::2"));
state.apply(route(
1,
"fde0::beef/128",
"::/0",
true,
"fe80::1".parse().unwrap(),
"en2",
200,
20,
));
state.apply(route(
2,
"fde0::beef/128",
"::/0",
true,
"fe80::2".parse().unwrap(),
"en3",
100,
10,
));
let fib = FibBuilder::new(["fde0::1".parse().unwrap()], 1452).derive(&state);
let entry = fib.lookup("fde0::beef".parse().unwrap()).unwrap();
assert_eq!(entry.next_hop_ll, "fe80::2".parse::<Ipv6Addr>().unwrap());
assert_eq!(entry.ifname.as_ref(), "en3");
}
#[test]
fn skips_routes_for_interfaces_without_live_neighbours() {
let mut state = BabelState::new();
state.apply(interface("en2", true));
state.apply(route(
1,
"fde0::cafe/128",
"::/0",
true,
"fe80::1".parse().unwrap(),
"en2",
96,
32,
));
let fib = FibBuilder::new(["fde0::1".parse().unwrap()], 1452).derive(&state);
assert!(fib.admitted_interfaces.is_empty());
assert!(fib.lookup("fde0::cafe".parse().unwrap()).is_none());
}
#[test]
fn skips_neighbour_interfaces_that_are_not_up() {
let mut state = BabelState::new();
state.apply(interface("en2", false));
state.apply(neighbour(1, "en2", "fe80::1"));
state.apply(route(
1,
"fde0::cafe/128",
"::/0",
true,
"fe80::1".parse().unwrap(),
"en2",
96,
32,
));
let fib = FibBuilder::new(["fde0::1".parse().unwrap()], 1452).derive(&state);
assert!(fib.admitted_interfaces.is_empty());
assert!(fib.lookup("fde0::cafe".parse().unwrap()).is_none());
}
}
-154
View File
@@ -1,154 +0,0 @@
//! Persistent node identity for `babblerd`.
//!
//! The node ID occupies the full low 64 bits of the EXO ULA space.
use std::fs::{self, OpenOptions};
use std::io::Write;
use std::os::unix::fs::{MetadataExt, PermissionsExt};
use std::path::Path;
use color_eyre::eyre::{self, WrapErr, eyre};
use ipnet::Ipv6Net;
use nix::unistd::geteuid;
use std::net::Ipv6Addr;
pub fn load_or_create_node_id(path: &Path) -> eyre::Result<u64> {
match read_node_id(path) {
Ok(node_id) => Ok(node_id),
Err(err) if is_not_found(&err) => create_node_id(path),
Err(err) => Err(err),
}
}
pub fn node_addr(prefix: Ipv6Net, node_id: u64) -> eyre::Result<Ipv6Net> {
if prefix.prefix_len() != 64 {
return Err(eyre!(
"expected EXO ULA prefix to be /64, got {prefix} with /{}",
prefix.prefix_len()
));
}
Ok(Ipv6Net::new_assert(
Ipv6Addr::from_bits(prefix.trunc().addr().to_bits() | u128::from(node_id)),
128,
))
}
fn create_node_id(path: &Path) -> eyre::Result<u64> {
let Some(parent) = path.parent() else {
return Err(eyre!(
"node id file has no parent directory: {}",
path.display()
));
};
fs::create_dir_all(parent)
.wrap_err_with(|| format!("creating node id directory {}", parent.display()))?;
let node_id = generate_node_id();
let mut file = match OpenOptions::new().write(true).create_new(true).open(path) {
Ok(file) => file,
Err(err) if err.kind() == std::io::ErrorKind::AlreadyExists => {
return read_node_id(path);
}
Err(err) => {
return Err(err).wrap_err_with(|| format!("creating node id file {}", path.display()));
}
};
file.set_permissions(fs::Permissions::from_mode(0o600))
.wrap_err_with(|| format!("setting permissions on {}", path.display()))?;
writeln!(file, "{node_id:016x}")
.wrap_err_with(|| format!("writing node id file {}", path.display()))?;
file.sync_all()
.wrap_err_with(|| format!("syncing node id file {}", path.display()))?;
drop(file);
read_node_id(path)
}
fn read_node_id(path: &Path) -> eyre::Result<u64> {
let metadata =
fs::metadata(path).wrap_err_with(|| format!("reading metadata for {}", path.display()))?;
ensure_owner(path, &metadata)?;
let raw = fs::read_to_string(path)
.wrap_err_with(|| format!("reading node id file {}", path.display()))?;
let trimmed = raw.trim();
if trimmed.is_empty() {
return Err(eyre!("node id file is empty: {}", path.display()));
}
let node_id = u64::from_str_radix(trimmed.trim_start_matches("0x"), 16)
.wrap_err_with(|| format!("invalid node id in {}: {:?}", path.display(), trimmed))?;
Ok(node_id)
}
fn ensure_owner(path: &Path, metadata: &fs::Metadata) -> eyre::Result<()> {
let expected_uid = geteuid().as_raw();
let actual_uid = metadata.uid();
if actual_uid != expected_uid {
return Err(eyre!(
"node id file {} is owned by uid {}, expected {}",
path.display(),
actual_uid,
expected_uid
));
}
Ok(())
}
fn generate_node_id() -> u64 {
rand::random::<u64>()
}
fn is_not_found(err: &eyre::Report) -> bool {
err.downcast_ref::<std::io::Error>()
.is_some_and(|e| e.kind() == std::io::ErrorKind::NotFound)
}
#[cfg(test)]
mod tests {
use super::*;
fn temp_path(name: &str) -> std::path::PathBuf {
let nonce = rand::random::<u64>();
std::env::temp_dir().join(format!(
"babblerd-identity-{name}-{}-{nonce}",
std::process::id()
))
}
#[test]
fn creates_and_reloads_same_node_id() {
let dir = temp_path("create");
let path = dir.join("node-id");
let first = load_or_create_node_id(&path).expect("create node id");
let second = load_or_create_node_id(&path).expect("reload node id");
assert_eq!(first, second);
let _ = fs::remove_file(&path);
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn node_addr_uses_full_low_64_bits() {
let addr = node_addr(
Ipv6Net::new_assert(
Ipv6Addr::new(0xfde0, 0x20c6, 0x1fa7, 0xffff, 0, 0, 0, 0),
64,
),
0x1234_5678_9abc_def0,
)
.expect("node address should be constructed");
assert_eq!(addr.prefix_len(), 128);
assert_eq!(
addr.addr(),
Ipv6Addr::new(
0xfde0, 0x20c6, 0x1fa7, 0xffff, 0x1234, 0x5678, 0x9abc, 0xdef0,
)
);
}
}
-335
View File
@@ -1,335 +0,0 @@
#[cfg(not(any(target_os = "macos", target_os = "linux")))]
compile_error!("babblerd is mac/linux-only");
pub mod babel;
pub mod config;
pub mod daemon;
pub mod dataplane;
pub mod fib;
pub mod identity;
pub mod profiling;
pub(crate) mod route_ctl;
pub mod routing_stack;
pub mod tun;
pub use babel::babel;
pub use config::EXO_ULA_PREFIX as PREFIX;
pub use error::{BabbleError, Result};
pub use if_watcher::watch;
pub mod error {
use std::io;
use thiserror::Error;
pub type Result<T> = core::result::Result<T, BabbleError>;
#[derive(Error, Debug)]
pub enum BabbleError {
#[error("An IO error occurred: {0}")]
Io(#[from] io::Error),
#[error("Unspecified error")]
Unspecified,
#[error("Babeld crashed unexpectedly with code: {0:?}")]
BabeldCrashed(Option<i32>),
#[error("Failed to set IP address")]
FailedToSetIp,
#[error("Other error: {0}")]
Other(String),
}
}
pub mod if_watcher {
#[cfg(target_os = "linux")]
use std::path::PathBuf;
use std::{collections::HashSet, net::IpAddr};
use futures_lite::StreamExt;
use n0_watcher::Watcher;
use netwatch::interfaces::{Interface, IpNet};
use tokio::sync::mpsc;
use crate::config::{EXO_ULA_PREFIX, PHYSICAL_LINK_MTU, interface_allowlist_from_env};
use crate::ip_manager::remove_ip;
use crate::{BabbleError, Result, babel::Babble};
pub const LOCALHOST_INTERFACE_NAMES: [&'static str; 2] = ["lo", "lo0"];
trait IfaceExt {
fn has_link_local_v6(&self) -> bool;
fn has_required_mtu(&self) -> bool;
fn is_real_interface(&self) -> bool;
fn will_babel(&self) -> bool;
}
impl IfaceExt for Interface {
fn will_babel(&self) -> bool {
self.has_link_local_v6()
&& self.has_required_mtu()
&& self.is_real_interface()
&& self.is_up()
}
fn has_link_local_v6(&self) -> bool {
let mut has = false;
for addr in self.addrs() {
let IpAddr::V6(a) = addr.addr() else {
continue;
};
if a.is_unicast_link_local() {
has = true;
break;
}
}
has
}
fn has_required_mtu(&self) -> bool {
let Some(mtu) = interface_mtu(self.name()) else {
tracing::debug!(
"skipping interface {} because MTU could not be determined",
self.name()
);
return false;
};
if mtu < u32::from(PHYSICAL_LINK_MTU) {
tracing::debug!(
"skipping interface {} because mtu {} is below required {}",
self.name(),
mtu,
PHYSICAL_LINK_MTU
);
return false;
}
true
}
fn is_real_interface(&self) -> bool {
// macos is weird. en0 & en1 are ethernet & wifi (varies which is which by device). en3+ is thunderbolt, but at some point becomes usb ethernet.
if self.name().strip_prefix("en").is_none()
//.and_then(|s| s.parse::<u8>().ok())
//.is_none_or(|_n| false)
{
return false;
}
#[cfg(target_os = "linux")]
{
if !PathBuf::from(format!("/sys/class/net/{}/device", self.name())).exists() {
tracing::debug!(
"skipping interface {} as it doesn't correspond to a physical link",
self.name()
);
return false;
}
let dev_type_path = PathBuf::from(format!("/sys/class/net/{}/type", self.name()));
if !dev_type_path.exists() {
tracing::debug!(
"skipping interface {} with no type file at {:?}",
self.name(),
dev_type_path.to_str()
);
return false;
}
let Ok(dev_type) = std::fs::read_to_string(dev_type_path) else {
return false;
};
if dev_type.trim() != "1" {
tracing::debug!(
"skipping interface {} with type {:?}",
self.name(),
dev_type
);
return false;
}
}
true
}
}
fn interface_mtu(name: &str) -> Option<u32> {
netdev::get_interfaces()
.into_iter()
.find(|iface| iface.name == name)
.and_then(|iface| iface.mtu)
}
#[tracing::instrument(skip(send))]
pub async fn watch(send: mpsc::Sender<Babble>) -> Result<()> {
let mut ready_ifaces = HashSet::new();
let interface_allowlist = interface_allowlist_from_env()
.map_err(|e| BabbleError::Other(format!("invalid interface allowlist: {e}")))?;
if let Some(allowlist) = &interface_allowlist {
tracing::info!(?allowlist, "interface allowlist active");
}
tracing::info!("starting interface monitor");
let mon = netwatch::netmon::Monitor::new()
.await
.map_err(|_| BabbleError::Unspecified)?;
// TODD: this should never really be a thing thats the case, BUT I like the idea of having
// "heuristic" scripts that can help resolve issues but not necessarily gurantee success;
// I like the idea of generalising this concept into a framework where we have "heuristic tasks"
// that run to aid in tyring to fix some system ale-ment or whatever
//
// one-shot cleanup:
// - remove any stale app-prefix addresses from lo0
// - remove any app-prefix addresses that accidentally landed on physical links
{
let state = mon.interface_state();
for iface in state.peek().interfaces.values() {
let cleanup_target =
LOCALHOST_INTERFACE_NAMES.contains(&iface.name()) || iface.is_real_interface();
if !cleanup_target {
continue;
}
for addr in iface.addrs() {
if let IpNet::V6 { net: v6, .. } = addr
&& EXO_ULA_PREFIX.contains(&v6.addr())
{
tracing::info!("removing stale app ip {v6} from {}", iface.name());
if let Err(e) = remove_ip(v6, iface).await {
tracing::warn!(%e, "failed to remove stale app ip");
}
}
}
}
}
// stream updates
let mut mon_stream = mon.interface_state().stream();
while let Some(s) = mon_stream.next().await {
for iface in s.interfaces.values() {
if let Some(allowlist) = &interface_allowlist
&& !allowlist.contains(iface.name())
{
tracing::debug!(
"skipping interface {} because it is not in {}",
iface.name(),
crate::config::INTERFACE_ALLOWLIST_ENV
);
continue;
}
if !iface.is_real_interface() {
continue;
}
// physical links should not carry babbler application-space addresses
for addr in iface.addrs() {
if let IpNet::V6 { net: v6, .. } = addr
&& EXO_ULA_PREFIX.contains(&v6.addr())
{
tracing::info!("removing app ip {v6} from {}", iface.name());
if let Err(e) = remove_ip(v6, iface).await {
tracing::warn!(%e, "failed to remove ip");
}
}
}
if !iface.will_babel() {
continue;
}
if ready_ifaces.insert(iface.name().to_owned()) {
tracing::info!("telling babeld to watch {}", iface.name());
let Ok(()) = send.send(Babble::AddIface(iface.name().into())).await else {
return Ok(());
};
}
}
}
tracing::info!("stopping interface monitor");
Ok(())
}
}
pub(crate) mod ip_manager {
pub use sys::add_ip;
pub use sys::remove_ip;
#[cfg(target_os = "linux")]
mod sys {
use ipnet::Ipv6Net;
use netwatch::interfaces::Interface;
use crate::{BabbleError, Result};
use tokio::process::Command;
#[tracing::instrument]
pub async fn add_ip(subnet: Ipv6Net, iface: &Interface) -> Result<()> {
let out = Command::new("ip")
.arg("addr")
.arg("add")
.arg(format!("{subnet}"))
.arg("dev")
.arg(iface.name())
.output()
.await?;
if out.status.success() {
Ok(())
} else {
Err(BabbleError::FailedToSetIp)
}
}
#[tracing::instrument]
pub async fn remove_ip(v6: Ipv6Net, iface: &Interface) -> Result<()> {
let out = Command::new("ip")
.arg("addr")
.arg("del")
.arg(format!("{v6}"))
.arg("dev")
.arg(iface.name())
.output()
.await?;
if out.status.success() {
Ok(())
} else {
let std_err = String::from_utf8_lossy(&out.stdout);
tracing::debug!(%std_err);
Err(BabbleError::FailedToSetIp)
}
}
}
#[cfg(target_os = "macos")]
mod sys {
use ipnet::Ipv6Net;
use netwatch::interfaces::Interface;
use crate::BabbleError;
use crate::Result;
use tokio::process::Command;
#[tracing::instrument]
pub async fn add_ip(subnet: Ipv6Net, iface: &Interface) -> Result<()> {
let out = Command::new("ifconfig")
.arg(iface.name())
.arg("inet6")
.arg(format!("{subnet}"))
.arg("add")
.output()
.await?;
if out.status.success() {
Ok(())
} else {
Err(BabbleError::FailedToSetIp)
}
}
#[tracing::instrument]
pub async fn remove_ip(v6: Ipv6Net, iface: &Interface) -> Result<()> {
let out = Command::new("ifconfig")
.arg(iface.name())
.arg("inet6")
.arg(format!("{v6}"))
.arg("delete")
.output()
.await?;
if out.status.success() {
Ok(())
} else {
let std_err = String::from_utf8_lossy(&out.stdout);
tracing::debug!(%std_err);
Err(BabbleError::FailedToSetIp)
}
}
}
}
-195
View File
@@ -1,195 +0,0 @@
// Major TODO: at some point don't call it "babbler" because that is a silly name that makes no sense
// but this is at the very bottom of my concerns right now :)
#[cfg(not(any(target_os = "macos", target_os = "linux")))]
compile_error!("babblerd is mac/linux-only");
use std::{fs::Permissions, io, os::unix::fs::PermissionsExt, sync::Arc};
use babblerd::{babel::BabelState, config::Config, daemon, identity, tun::TunDevice};
use color_eyre::eyre::{self, WrapErr, eyre};
use tokio::{
io::{AsyncBufReadExt, AsyncWriteExt, BufReader},
net::UnixListener,
net::UnixStream,
signal,
sync::watch,
task::JoinSet,
time::{Duration, sleep},
};
const INTERNAL_KEEPALIVE_TTL_MS: u64 = 30_000;
const INTERNAL_KEEPALIVE_INTERVAL_MS: u64 = 10_000;
#[tokio::main]
async fn main() -> eyre::Result<()> {
color_eyre::install()?;
tracing_subscriber::fmt()
.with_env_filter(tracing_subscriber::EnvFilter::from_default_env())
.init();
let config = Config::from_env()?;
// cleanup old public socket path
match std::fs::remove_file(&config.public_socket_path) {
Err(e) if e.kind() != io::ErrorKind::NotFound => return Err(e.into()),
Ok(()) => {
tracing::info!(
"cleaned up old file at {}",
config.public_socket_path.display()
);
}
_ => {}
}
// create new public directory
std::fs::create_dir_all(&config.public_dir)?;
if let Err(e) = std::fs::set_permissions(&config.public_dir, Permissions::from_mode(0o0755)) {
if e.kind() == io::ErrorKind::PermissionDenied {
return Err(eyre!(
"Insufficient permissions to run daemon -- did you forget sudo?"
));
}
return Err(e.into());
}
let res = inner_main(&config).await;
let _ = std::fs::remove_file(&config.public_socket_path);
res
}
async fn inner_main(config: &Config) -> eyre::Result<()> {
let node_id = identity::load_or_create_node_id(&config.node_id_file)?;
let node_addr = identity::node_addr(config.exo_ula_prefix, node_id)?;
let tun = TunDevice::create(node_addr.addr()).wrap_err("creating tun for node address")?;
tracing::info!("creating socket at {}", config.public_socket_path.display());
tracing::info!(
"router defaults: udp_port={} node_id_file={} app_prefix={} node_id={:#018x} node_addr={} tun={}",
config.router_udp_port,
config.node_id_file.display(),
config.exo_ula_prefix,
node_id,
node_addr,
tun.ifname(),
);
let public_socket = UnixListener::bind(&config.public_socket_path)?;
// make our socket world accessible
std::fs::set_permissions(&config.public_socket_path, Permissions::from_mode(0o0666))?;
let (babel_state_send, _) = watch::channel(Arc::new(BabelState::new()));
let (daemon, mut core_task) = daemon::DaemonCore::spawn(
node_id,
config.exo_ula_prefix,
config.router_udp_port,
node_addr,
tun,
babel_state_send,
);
// TEMP: keep the daemon alive without an external client until the real
// frontend/test harness exists. This should be removed later.
let mut internal_keepalive =
tokio::spawn(internal_keepalive_client(config.public_socket_path.clone()));
let mut listeners = JoinSet::new();
loop {
tokio::select! {
sig = signal::ctrl_c() => {
sig?;
internal_keepalive.abort();
let _ = (&mut internal_keepalive).await;
listeners.abort_all();
while let Some(res) = listeners.join_next().await {
res.wrap_err("while ctrl-c")?;
}
drop(daemon);
core_task.await??;
break;
}
sock = public_socket.accept() => {
let sock = sock?.0;
listeners.spawn(daemon::handle_client(sock, daemon.clone()));
}
res = &mut core_task => {
res??;
internal_keepalive.abort();
let _ = (&mut internal_keepalive).await;
listeners.abort_all();
while let Some(res2) = listeners.join_next().await {
res2.wrap_err("while closing daemon core")?;
}
break;
}
res = &mut internal_keepalive => {
return Err(eyre!("internal keepalive client exited unexpectedly: {res:?}"));
}
next_join_result = listeners.join_next(), if !listeners.is_empty() => {
next_join_result.expect("checked")?;
tracing::info!("dropped a listener");
}
}
}
Ok(())
}
async fn internal_keepalive_client(socket_path: std::path::PathBuf) {
loop {
match UnixStream::connect(&socket_path).await {
Ok(stream) => {
tracing::info!(
socket=%socket_path.display(),
"internal keepalive client connected"
);
let (reader, mut writer) = stream.into_split();
let mut reader = BufReader::new(reader).lines();
match reader.next_line().await {
Ok(Some(line)) => {
tracing::debug!(?line, "internal keepalive initial state");
}
Ok(None) => {
tracing::warn!("internal keepalive connection closed before initial state");
sleep(Duration::from_secs(1)).await;
continue;
}
Err(err) => {
tracing::warn!(error=%err, "internal keepalive failed to read initial state");
sleep(Duration::from_secs(1)).await;
continue;
}
}
loop {
let command = format!("keepalive {INTERNAL_KEEPALIVE_TTL_MS}\n");
if let Err(err) = writer.write_all(command.as_bytes()).await {
tracing::warn!(error=%err, "internal keepalive failed to send keepalive");
break;
}
match reader.next_line().await {
Ok(Some(line)) => {
tracing::debug!(?line, "internal keepalive response");
}
Ok(None) => {
tracing::warn!("internal keepalive connection closed");
break;
}
Err(err) => {
tracing::warn!(error=%err, "internal keepalive failed to read response");
break;
}
}
sleep(Duration::from_millis(INTERNAL_KEEPALIVE_INTERVAL_MS)).await;
}
}
Err(err) => {
tracing::warn!(error=%err, socket=%socket_path.display(), "internal keepalive failed to connect");
}
}
sleep(Duration::from_secs(1)).await;
}
}
-124
View File
@@ -1,124 +0,0 @@
use std::time::Duration;
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct LatencyStats {
pub sent: u32,
pub received: u32,
pub loss_ratio: f64,
pub min: Duration,
pub avg: Duration,
pub max: Duration,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct CapacitySample {
pub sent_packets: u32,
pub received_packets: u32,
pub received_bytes: u64,
pub span: Duration,
}
impl CapacitySample {
pub fn loss_ratio(self) -> f64 {
if self.sent_packets == 0 {
return 0.0;
}
let lost = self.sent_packets.saturating_sub(self.received_packets);
f64::from(lost) / f64::from(self.sent_packets)
}
pub fn mbps(self) -> Option<f64> {
capacity_mbps(self.received_bytes, self.span)
}
}
pub fn latency_stats(sent: u32, samples: &[Duration]) -> Option<LatencyStats> {
let received = u32::try_from(samples.len()).ok()?;
if sent == 0 || samples.is_empty() {
return None;
}
let mut min = samples.first().copied()?;
let mut max = min;
let mut total_nanos = 0_u128;
for sample in samples {
min = min.min(*sample);
max = max.max(*sample);
total_nanos = total_nanos.saturating_add(sample.as_nanos());
}
let avg_nanos = total_nanos / u128::from(received);
let avg = Duration::from_nanos(u64_saturating_from_u128(avg_nanos));
let lost = sent.saturating_sub(received);
Some(LatencyStats {
sent,
received,
loss_ratio: f64::from(lost) / f64::from(sent),
min,
avg,
max,
})
}
pub fn capacity_mbps(received_bytes: u64, span: Duration) -> Option<f64> {
let nanos = span.as_nanos();
if received_bytes == 0 || nanos == 0 {
return None;
}
let bits = received_bytes.saturating_mul(8);
Some((bits as f64) * 1_000.0 / (nanos as f64))
}
pub fn duration_nanos_u64(duration: Duration) -> u64 {
u64_saturating_from_u128(duration.as_nanos())
}
fn u64_saturating_from_u128(value: u128) -> u64 {
u64::try_from(value).unwrap_or(u64::MAX)
}
#[cfg(test)]
mod tests {
use std::time::Duration;
use super::{CapacitySample, capacity_mbps, latency_stats};
#[test]
fn latency_summary_reports_loss_and_bounds() {
let samples = [
Duration::from_millis(3),
Duration::from_millis(1),
Duration::from_millis(2),
];
let Some(stats) = latency_stats(4, &samples) else {
panic!("expected latency stats");
};
assert_eq!(stats.sent, 4);
assert_eq!(stats.received, 3);
assert_eq!(stats.loss_ratio, 0.25);
assert_eq!(stats.min, Duration::from_millis(1));
assert_eq!(stats.avg, Duration::from_millis(2));
assert_eq!(stats.max, Duration::from_millis(3));
}
#[test]
fn capacity_summary_reports_mbps() {
let sample = CapacitySample {
sent_packets: 10,
received_packets: 10,
received_bytes: 125_000,
span: Duration::from_millis(1),
};
assert_eq!(sample.loss_ratio(), 0.0);
assert_eq!(
capacity_mbps(sample.received_bytes, sample.span),
Some(1000.0)
);
}
}
-16
View File
@@ -1,16 +0,0 @@
//! Link-local profiling support.
//!
//! This module is intentionally independent of the Babel control plane. The
//! standalone example uses it to measure one physical link directly; the daemon
//! can later consume the same types and estimators when route scoring is wired
//! in.
pub mod estimator;
pub mod pbprobe;
pub mod protocol;
pub mod socket;
pub mod standalone;
pub mod types;
pub use estimator::{CapacitySample, LatencyStats, capacity_mbps, latency_stats};
pub use types::{DEFAULT_PROFILE_PORT, LinkKey, ProbeConfig};
@@ -1,195 +0,0 @@
use std::time::Duration;
use crate::config::{OUTER_IPV6_HEADER_BYTES, OUTER_UDP_HEADER_BYTES, PHYSICAL_LINK_MTU};
pub const DEFAULT_PBPROBE_PORT: u16 = 41_902;
pub const DEFAULT_SAMPLE_COUNT: u32 = 200;
pub const DEFAULT_UTILIZATION: f64 = 0.01;
pub const DEFAULT_DISPERSION_THRESHOLD_MS: u64 = 1;
pub const DEFAULT_DISPERSION_THRESHOLD: Duration =
Duration::from_millis(DEFAULT_DISPERSION_THRESHOLD_MS);
pub const DEFAULT_MAX_BULK_LEN: u32 = 10_000;
pub const DEFAULT_RTS_TIMEOUT_MS: u64 = 750;
pub const DEFAULT_RTS_TIMEOUT: Duration = Duration::from_millis(DEFAULT_RTS_TIMEOUT_MS);
pub const DEFAULT_START_TIMEOUT_MS: u64 = 750;
pub const DEFAULT_START_TIMEOUT: Duration = Duration::from_millis(DEFAULT_START_TIMEOUT_MS);
pub const DEFAULT_CONTROL_RETRIES: u32 = 5;
#[derive(Debug, Clone)]
pub struct PbProbeConfig {
pub port: u16,
pub sample_count: u32,
pub utilization: f64,
pub dispersion_threshold: Duration,
pub initial_bulk_len: u32,
pub max_bulk_len: u32,
pub ip_packet_bytes: usize,
pub start_timeout: Duration,
pub rts_timeout: Duration,
pub control_retries: u32,
}
impl Default for PbProbeConfig {
fn default() -> Self {
Self {
port: DEFAULT_PBPROBE_PORT,
sample_count: DEFAULT_SAMPLE_COUNT,
utilization: DEFAULT_UTILIZATION,
dispersion_threshold: DEFAULT_DISPERSION_THRESHOLD,
initial_bulk_len: 1,
max_bulk_len: DEFAULT_MAX_BULK_LEN,
ip_packet_bytes: usize::from(PHYSICAL_LINK_MTU),
start_timeout: DEFAULT_START_TIMEOUT,
rts_timeout: DEFAULT_RTS_TIMEOUT,
control_retries: DEFAULT_CONTROL_RETRIES,
}
}
}
impl PbProbeConfig {
pub fn udp_payload_bytes(&self) -> usize {
let overhead = usize::from(OUTER_IPV6_HEADER_BYTES + OUTER_UDP_HEADER_BYTES);
self.ip_packet_bytes.saturating_sub(overhead)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct AcceptedSample {
pub sample_id: u32,
pub bulk_len: u32,
pub delay_first: Duration,
pub delay_last: Duration,
pub dispersion: Duration,
pub server_issue_duration: Option<Duration>,
}
impl AcceptedSample {
pub fn delay_sum(self) -> Duration {
self.delay_first.saturating_add(self.delay_last)
}
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct SelectedSample {
pub sample: AcceptedSample,
pub capacity_mbps: f64,
}
#[derive(Debug, Clone, PartialEq)]
pub struct Estimate {
pub bulk_len: u32,
pub sample_count: u32,
pub attempts: u32,
pub lost_samples: u32,
pub ip_packet_bytes: usize,
pub selected: SelectedSample,
pub min_dispersion: Duration,
pub server_issue_samples: u32,
pub min_server_issue_duration: Option<Duration>,
}
#[derive(Debug, Clone, PartialEq)]
pub enum EstimateOutcome {
Complete(Estimate),
IncreaseBulk {
previous_bulk_len: u32,
next_bulk_len: u32,
observed_dispersion: Duration,
},
}
pub fn select_capacity_sample(
samples: &[AcceptedSample],
ip_packet_bytes: usize,
) -> Option<SelectedSample> {
let sample = samples
.iter()
.copied()
.min_by_key(|sample| sample.delay_sum())?;
let capacity_mbps = capacity_mbps(sample.bulk_len, ip_packet_bytes, sample.dispersion)?;
Some(SelectedSample {
sample,
capacity_mbps,
})
}
pub fn capacity_mbps(bulk_len: u32, ip_packet_bytes: usize, dispersion: Duration) -> Option<f64> {
let nanos = dispersion.as_nanos();
if bulk_len == 0 || ip_packet_bytes == 0 || nanos == 0 {
return None;
}
let bits = f64::from(bulk_len) * (ip_packet_bytes as f64) * 8.0;
Some(bits * 1_000.0 / (nanos as f64))
}
pub fn next_bulk_len(current: u32, max: u32) -> Option<u32> {
let next = current.checked_mul(10)?;
if next > max || next == current {
return None;
}
Some(next)
}
pub fn pacing_interval(dispersion: Duration, utilization: f64) -> Option<Duration> {
if dispersion.is_zero() || !utilization.is_finite() || utilization <= 0.0 {
return None;
}
Some(Duration::from_secs_f64(
(2.0 * dispersion.as_secs_f64()) / utilization,
))
}
#[cfg(test)]
mod tests {
use std::time::Duration;
use super::{
AcceptedSample, capacity_mbps, next_bulk_len, pacing_interval, select_capacity_sample,
};
#[test]
fn capacity_uses_bulk_length_not_packet_count() {
let estimate = capacity_mbps(100, 1500, Duration::from_micros(1200));
assert_eq!(estimate, Some(1000.0));
}
#[test]
fn selector_uses_minimum_delay_sum() {
let samples = [
AcceptedSample {
sample_id: 1,
bulk_len: 10,
delay_first: Duration::from_millis(3),
delay_last: Duration::from_millis(4),
dispersion: Duration::from_micros(900),
server_issue_duration: None,
},
AcceptedSample {
sample_id: 2,
bulk_len: 10,
delay_first: Duration::from_millis(1),
delay_last: Duration::from_millis(2),
dispersion: Duration::from_micros(1200),
server_issue_duration: None,
},
];
let selected = select_capacity_sample(&samples, 1500).expect("sample should be selected");
assert_eq!(selected.sample.sample_id, 2);
assert_eq!(selected.capacity_mbps, 100.0);
}
#[test]
fn bulk_growth_is_tenfold_and_capped() {
assert_eq!(next_bulk_len(1, 1000), Some(10));
assert_eq!(next_bulk_len(1000, 1000), None);
}
#[test]
fn pacing_follows_paper_formula() {
let interval = pacing_interval(Duration::from_millis(1), 0.01);
assert_eq!(interval, Some(Duration::from_millis(200)));
}
}
@@ -1,14 +0,0 @@
//! Paper-faithful PBProbe implementation.
//!
//! PBProbe is a CapProbe-derived capacity estimator that uses packet bulks
//! instead of a single packet pair. This module follows the paper algorithm
//! rather than the old C implementation's process/control structure.
pub mod estimator;
pub mod protocol;
pub mod standalone;
pub use estimator::{
AcceptedSample, Estimate, EstimateOutcome, PbProbeConfig, SelectedSample, next_bulk_len,
pacing_interval, select_capacity_sample,
};
@@ -1,317 +0,0 @@
use std::mem::size_of;
use std::time::Duration;
use thiserror::Error;
use zerocopy::byteorder::{NetworkEndian, U16, U32, U64};
use zerocopy::{FromBytes, Immutable, IntoBytes, KnownLayout};
type U16Be = U16<NetworkEndian>;
type U32Be = U32<NetworkEndian>;
type U64Be = U64<NetworkEndian>;
pub const HEADER_LEN: usize = size_of::<WireHeader>();
pub const RESULT_BODY_LEN: usize = size_of::<WireResultBody>();
pub const RESULT_PACKET_LEN: usize = HEADER_LEN + RESULT_BODY_LEN;
const MAGIC: &[u8; 4] = b"BBPB";
const VERSION: u8 = 1;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[repr(u8)]
pub enum PacketKind {
Start = 1,
StartAck = 2,
Rts = 3,
Bulk = 4,
Result = 5,
End = 6,
ErrorMessage = 7,
}
impl TryFrom<u8> for PacketKind {
type Error = ProtocolError;
fn try_from(value: u8) -> Result<Self, Self::Error> {
match value {
1 => Ok(Self::Start),
2 => Ok(Self::StartAck),
3 => Ok(Self::Rts),
4 => Ok(Self::Bulk),
5 => Ok(Self::Result),
6 => Ok(Self::End),
7 => Ok(Self::ErrorMessage),
other => Err(ProtocolError::UnknownKind(other)),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Header {
pub kind: PacketKind,
pub run_id: u64,
pub sample_id: u32,
pub seq: u32,
pub bulk_len: u32,
pub sample_count: u32,
pub ip_packet_bytes: u32,
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct ResultBody {
pub attempts: u32,
pub lost_samples: u32,
pub selected_sample_id: u32,
pub accepted_samples: u32,
pub delay_sum: Duration,
pub dispersion: Duration,
pub min_dispersion: Duration,
pub capacity_mbps: f64,
}
#[repr(C)]
#[derive(Debug, Clone, Copy, FromBytes, Immutable, IntoBytes, KnownLayout)]
struct WireHeader {
magic: [u8; 4],
version: u8,
kind: u8,
flags: U16Be,
run_id: U64Be,
sample_id: U32Be,
seq: U32Be,
bulk_len: U32Be,
sample_count: U32Be,
ip_packet_bytes: U32Be,
reserved: U32Be,
}
#[repr(C)]
#[derive(Debug, Clone, Copy, FromBytes, Immutable, IntoBytes, KnownLayout)]
struct WireResultBody {
attempts: U32Be,
lost_samples: U32Be,
selected_sample_id: U32Be,
accepted_samples: U32Be,
delay_sum_nanos: U64Be,
dispersion_nanos: U64Be,
min_dispersion_nanos: U64Be,
capacity_mbps_bits: U64Be,
}
#[derive(Debug, Error, Clone, PartialEq, Eq)]
pub enum ProtocolError {
#[error("packet is too short")]
TooShort,
#[error("packet buffer is too small")]
BufferTooSmall,
#[error("bad PBProbe packet magic")]
BadMagic,
#[error("unsupported PBProbe protocol version {0}")]
BadVersion(u8),
#[error("unknown PBProbe packet kind {0}")]
UnknownKind(u8),
}
pub fn encode_header(dst: &mut [u8], header: Header) -> Result<usize, ProtocolError> {
encode_header_with_aux(dst, header, 0)
}
pub fn encode_header_with_aux(
dst: &mut [u8],
header: Header,
aux: u32,
) -> Result<usize, ProtocolError> {
write_bytes(dst, WireHeader::from_header(header, aux).as_bytes())
}
pub fn decode_header(src: &[u8]) -> Result<Header, ProtocolError> {
decode_header_with_aux(src).map(|(header, _aux)| header)
}
pub fn decode_header_with_aux(src: &[u8]) -> Result<(Header, u32), ProtocolError> {
let (wire, _) = WireHeader::read_from_prefix(src).map_err(|_| ProtocolError::TooShort)?;
wire.decode()
}
pub fn encode_result(
dst: &mut [u8],
header: Header,
body: ResultBody,
) -> Result<usize, ProtocolError> {
if dst.len() < RESULT_PACKET_LEN {
return Err(ProtocolError::BufferTooSmall);
}
let cursor = encode_header(dst, header)?;
write_bytes(&mut dst[cursor..], WireResultBody::from(body).as_bytes())?;
Ok(RESULT_PACKET_LEN)
}
pub fn decode_result_body(src: &[u8]) -> Result<ResultBody, ProtocolError> {
let body_src = src.get(HEADER_LEN..).ok_or(ProtocolError::TooShort)?;
let (wire, _) =
WireResultBody::read_from_prefix(body_src).map_err(|_| ProtocolError::TooShort)?;
Ok(ResultBody::from(wire))
}
pub fn duration_nanos(duration: Duration) -> u64 {
u64::try_from(duration.as_nanos()).unwrap_or(u64::MAX)
}
impl WireHeader {
fn from_header(header: Header, aux: u32) -> Self {
Self {
magic: *MAGIC,
version: VERSION,
kind: header.kind as u8,
flags: U16Be::ZERO,
run_id: U64Be::new(header.run_id),
sample_id: U32Be::new(header.sample_id),
seq: U32Be::new(header.seq),
bulk_len: U32Be::new(header.bulk_len),
sample_count: U32Be::new(header.sample_count),
ip_packet_bytes: U32Be::new(header.ip_packet_bytes),
reserved: U32Be::new(aux),
}
}
fn decode(self) -> Result<(Header, u32), ProtocolError> {
if self.magic != *MAGIC {
return Err(ProtocolError::BadMagic);
}
if self.version != VERSION {
return Err(ProtocolError::BadVersion(self.version));
}
Ok((
Header {
kind: PacketKind::try_from(self.kind)?,
run_id: self.run_id.get(),
sample_id: self.sample_id.get(),
seq: self.seq.get(),
bulk_len: self.bulk_len.get(),
sample_count: self.sample_count.get(),
ip_packet_bytes: self.ip_packet_bytes.get(),
},
self.reserved.get(),
))
}
}
impl From<ResultBody> for WireResultBody {
fn from(body: ResultBody) -> Self {
Self {
attempts: U32Be::new(body.attempts),
lost_samples: U32Be::new(body.lost_samples),
selected_sample_id: U32Be::new(body.selected_sample_id),
accepted_samples: U32Be::new(body.accepted_samples),
delay_sum_nanos: U64Be::new(duration_nanos(body.delay_sum)),
dispersion_nanos: U64Be::new(duration_nanos(body.dispersion)),
min_dispersion_nanos: U64Be::new(duration_nanos(body.min_dispersion)),
capacity_mbps_bits: U64Be::new(body.capacity_mbps.to_bits()),
}
}
}
impl From<WireResultBody> for ResultBody {
fn from(wire: WireResultBody) -> Self {
Self {
attempts: wire.attempts.get(),
lost_samples: wire.lost_samples.get(),
selected_sample_id: wire.selected_sample_id.get(),
accepted_samples: wire.accepted_samples.get(),
delay_sum: Duration::from_nanos(wire.delay_sum_nanos.get()),
dispersion: Duration::from_nanos(wire.dispersion_nanos.get()),
min_dispersion: Duration::from_nanos(wire.min_dispersion_nanos.get()),
capacity_mbps: f64::from_bits(wire.capacity_mbps_bits.get()),
}
}
}
fn write_bytes(dst: &mut [u8], src: &[u8]) -> Result<usize, ProtocolError> {
let Some(slot) = dst.get_mut(..src.len()) else {
return Err(ProtocolError::BufferTooSmall);
};
slot.copy_from_slice(src);
Ok(src.len())
}
#[cfg(test)]
mod tests {
use std::time::Duration;
use super::{
HEADER_LEN, Header, PacketKind, RESULT_PACKET_LEN, ResultBody, decode_header,
decode_header_with_aux, decode_result_body, encode_header, encode_header_with_aux,
encode_result,
};
#[test]
fn header_layout_is_stable() {
assert_eq!(HEADER_LEN, 40);
}
#[test]
fn header_round_trips() {
let header = Header {
kind: PacketKind::Bulk,
run_id: 7,
sample_id: 11,
seq: 3,
bulk_len: 100,
sample_count: 200,
ip_packet_bytes: 1500,
};
let mut buf = [0_u8; HEADER_LEN];
assert_eq!(encode_header(&mut buf, header), Ok(HEADER_LEN));
assert_eq!(decode_header(&buf), Ok(header));
}
#[test]
fn header_aux_round_trips() {
let header = Header {
kind: PacketKind::Bulk,
run_id: 7,
sample_id: 11,
seq: 100,
bulk_len: 100,
sample_count: 200,
ip_packet_bytes: 1500,
};
let mut buf = [0_u8; HEADER_LEN];
assert_eq!(
encode_header_with_aux(&mut buf, header, 12_345),
Ok(HEADER_LEN)
);
assert_eq!(decode_header_with_aux(&buf), Ok((header, 12_345)));
}
#[test]
fn result_round_trips() {
let header = Header {
kind: PacketKind::Result,
run_id: 9,
sample_id: 0,
seq: 0,
bulk_len: 100,
sample_count: 200,
ip_packet_bytes: 1500,
};
let body = ResultBody {
attempts: 210,
lost_samples: 10,
selected_sample_id: 42,
accepted_samples: 200,
delay_sum: Duration::from_micros(123),
dispersion: Duration::from_micros(1200),
min_dispersion: Duration::from_micros(1100),
capacity_mbps: 1000.25,
};
let mut buf = [0_u8; RESULT_PACKET_LEN];
assert_eq!(encode_result(&mut buf, header, body), Ok(RESULT_PACKET_LEN));
assert_eq!(decode_header(&buf), Ok(header));
assert_eq!(decode_result_body(&buf), Ok(body));
}
}
File diff suppressed because it is too large. Load diff
-228
View File
@@ -1,228 +0,0 @@
use std::mem::size_of;
use thiserror::Error;
use zerocopy::byteorder::{NetworkEndian, U16, U32, U64};
use zerocopy::{FromBytes, Immutable, IntoBytes, KnownLayout};
type U16Be = U16<NetworkEndian>;
type U32Be = U32<NetworkEndian>;
type U64Be = U64<NetworkEndian>;
pub const HEADER_LEN: usize = size_of::<WireHeader>();
pub const SUMMARY_BODY_LEN: usize = size_of::<WireSummaryBody>();
pub const SUMMARY_PACKET_LEN: usize = HEADER_LEN + SUMMARY_BODY_LEN;
const MAGIC: &[u8; 4] = b"BBLP";
const VERSION: u8 = 1;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[repr(u8)]
pub enum PacketKind {
EchoRequest = 1,
EchoReply = 2,
Train = 3,
SummaryRequest = 4,
SummaryReply = 5,
}
impl TryFrom<u8> for PacketKind {
type Error = ProtocolError;
fn try_from(value: u8) -> Result<Self, Self::Error> {
match value {
1 => Ok(Self::EchoRequest),
2 => Ok(Self::EchoReply),
3 => Ok(Self::Train),
4 => Ok(Self::SummaryRequest),
5 => Ok(Self::SummaryReply),
other => Err(ProtocolError::UnknownKind(other)),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Header {
pub kind: PacketKind,
pub run_id: u64,
pub seq: u32,
pub count: u32,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct SummaryBody {
pub received_packets: u32,
pub received_bytes: u64,
pub span_nanos: u64,
}
#[repr(C)]
#[derive(Debug, Clone, Copy, FromBytes, Immutable, IntoBytes, KnownLayout)]
struct WireHeader {
magic: [u8; 4],
version: u8,
kind: u8,
flags: U16Be,
run_id: U64Be,
seq: U32Be,
count: U32Be,
}
#[repr(C)]
#[derive(Debug, Clone, Copy, FromBytes, Immutable, IntoBytes, KnownLayout)]
struct WireSummaryBody {
received_packets: U32Be,
received_bytes: U64Be,
span_nanos: U64Be,
}
#[derive(Debug, Error, Clone, PartialEq, Eq)]
pub enum ProtocolError {
#[error("packet is too short")]
TooShort,
#[error("packet buffer is too small")]
BufferTooSmall,
#[error("bad profiling packet magic")]
BadMagic,
#[error("unsupported profiling protocol version {0}")]
BadVersion(u8),
#[error("unknown profiling packet kind {0}")]
UnknownKind(u8),
}
pub fn encode_header(dst: &mut [u8], header: Header) -> Result<usize, ProtocolError> {
write_bytes(dst, WireHeader::from_header(header).as_bytes())
}
pub fn decode_header(src: &[u8]) -> Result<Header, ProtocolError> {
let (wire, _) = WireHeader::read_from_prefix(src).map_err(|_| ProtocolError::TooShort)?;
wire.decode()
}
pub fn encode_summary(
dst: &mut [u8],
header: Header,
body: SummaryBody,
) -> Result<usize, ProtocolError> {
if dst.len() < SUMMARY_PACKET_LEN {
return Err(ProtocolError::BufferTooSmall);
}
let cursor = encode_header(dst, header)?;
write_bytes(&mut dst[cursor..], WireSummaryBody::from(body).as_bytes())?;
Ok(SUMMARY_PACKET_LEN)
}
pub fn decode_summary_body(src: &[u8]) -> Result<SummaryBody, ProtocolError> {
let body_src = src.get(HEADER_LEN..).ok_or(ProtocolError::TooShort)?;
let (wire, _) =
WireSummaryBody::read_from_prefix(body_src).map_err(|_| ProtocolError::TooShort)?;
Ok(SummaryBody::from(wire))
}
impl WireHeader {
fn from_header(header: Header) -> Self {
Self {
magic: *MAGIC,
version: VERSION,
kind: header.kind as u8,
flags: U16Be::ZERO,
run_id: U64Be::new(header.run_id),
seq: U32Be::new(header.seq),
count: U32Be::new(header.count),
}
}
fn decode(self) -> Result<Header, ProtocolError> {
if self.magic != *MAGIC {
return Err(ProtocolError::BadMagic);
}
if self.version != VERSION {
return Err(ProtocolError::BadVersion(self.version));
}
Ok(Header {
kind: PacketKind::try_from(self.kind)?,
run_id: self.run_id.get(),
seq: self.seq.get(),
count: self.count.get(),
})
}
}
impl From<SummaryBody> for WireSummaryBody {
fn from(body: SummaryBody) -> Self {
Self {
received_packets: U32Be::new(body.received_packets),
received_bytes: U64Be::new(body.received_bytes),
span_nanos: U64Be::new(body.span_nanos),
}
}
}
impl From<WireSummaryBody> for SummaryBody {
fn from(wire: WireSummaryBody) -> Self {
Self {
received_packets: wire.received_packets.get(),
received_bytes: wire.received_bytes.get(),
span_nanos: wire.span_nanos.get(),
}
}
}
fn write_bytes(dst: &mut [u8], src: &[u8]) -> Result<usize, ProtocolError> {
let Some(slot) = dst.get_mut(..src.len()) else {
return Err(ProtocolError::BufferTooSmall);
};
slot.copy_from_slice(src);
Ok(src.len())
}
#[cfg(test)]
mod tests {
use super::{
HEADER_LEN, Header, PacketKind, SUMMARY_PACKET_LEN, SummaryBody, decode_header,
decode_summary_body, encode_header, encode_summary,
};
#[test]
fn layouts_are_stable() {
assert_eq!(HEADER_LEN, 24);
assert_eq!(SUMMARY_PACKET_LEN, 44);
}
#[test]
fn header_round_trips() {
let header = Header {
kind: PacketKind::Train,
run_id: 42,
seq: 7,
count: 64,
};
let mut buf = [0_u8; HEADER_LEN];
let encoded = encode_header(&mut buf, header);
assert_eq!(encoded, Ok(HEADER_LEN));
assert_eq!(decode_header(&buf), Ok(header));
}
#[test]
fn summary_round_trips() {
let header = Header {
kind: PacketKind::SummaryReply,
run_id: 99,
seq: 0,
count: 64,
};
let body = SummaryBody {
received_packets: 63,
received_bytes: 91_476,
span_nanos: 725_000,
};
let mut buf = [0_u8; SUMMARY_PACKET_LEN];
let encoded = encode_summary(&mut buf, header, body);
assert_eq!(encoded, Ok(SUMMARY_PACKET_LEN));
assert_eq!(decode_header(&buf), Ok(header));
assert_eq!(decode_summary_body(&buf), Ok(body));
}
}
-63
View File
@@ -1,63 +0,0 @@
use std::io;
use std::net::{Ipv6Addr, SocketAddr, SocketAddrV6, UdpSocket};
use std::num::NonZeroU32;
use std::time::Duration;
use nix::net::if_::if_nametoindex;
use socket2::{Domain, Protocol, Socket, Type};
const PROFILE_SOCKET_BUFFER_BYTES: usize = 4 * 1024 * 1024;
pub fn open_link_local_udp(
ifname: &str,
port: u16,
read_timeout: Option<Duration>,
) -> io::Result<(UdpSocket, u32)> {
let ifindex = if_nametoindex(ifname).map_err(io::Error::from)?;
let Some(nonzero_ifindex) = NonZeroU32::new(ifindex) else {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
format!("invalid ifindex for {ifname}"),
));
};
let socket = Socket::new(Domain::IPV6, Type::DGRAM, Some(Protocol::UDP))?;
socket.set_reuse_address(true)?;
socket.set_reuse_port(true)?;
socket.set_only_v6(true)?;
socket.set_recv_buffer_size(PROFILE_SOCKET_BUFFER_BYTES)?;
socket.set_send_buffer_size(PROFILE_SOCKET_BUFFER_BYTES)?;
#[cfg(target_os = "linux")]
socket.bind_device(Some(ifname.as_bytes()))?;
socket.bind_device_by_index_v6(Some(nonzero_ifindex))?;
socket.bind(&SocketAddrV6::new(Ipv6Addr::UNSPECIFIED, port, 0, 0).into())?;
let udp: UdpSocket = socket.into();
udp.set_read_timeout(read_timeout)?;
udp.set_write_timeout(read_timeout)?;
Ok((udp, ifindex))
}
pub fn scoped_peer_addr(peer: Ipv6Addr, port: u16, ifindex: u32) -> SocketAddr {
SocketAddr::V6(SocketAddrV6::new(peer, port, 0, ifindex))
}
pub fn with_default_scope(addr: SocketAddr, ifindex: u32) -> SocketAddr {
match addr {
SocketAddr::V6(v6) if v6.ip().is_unicast_link_local() && v6.scope_id() == 0 => {
SocketAddr::V6(SocketAddrV6::new(
*v6.ip(),
v6.port(),
v6.flowinfo(),
ifindex,
))
}
other => other,
}
}
pub fn parse_link_local_addr(raw: &str) -> Result<Ipv6Addr, std::net::AddrParseError> {
let addr = raw.split_once('%').map_or(raw, |(addr, _scope)| addr);
addr.parse()
}
-722
View File
@@ -1,722 +0,0 @@
use std::collections::HashMap;
use std::ffi::OsString;
use std::io::{self, ErrorKind};
use std::net::{Ipv6Addr, SocketAddr, UdpSocket};
use std::thread;
use std::time::{Duration, Instant};
use clap::{Args, Parser, Subcommand};
use color_eyre::eyre::{Result, WrapErr, eyre};
use super::estimator::{CapacitySample, duration_nanos_u64, latency_stats};
use super::protocol::{
HEADER_LEN, Header, PacketKind, SUMMARY_PACKET_LEN, SummaryBody, decode_header,
decode_summary_body, encode_header, encode_summary,
};
use super::socket::{
open_link_local_udp, parse_link_local_addr, scoped_peer_addr, with_default_scope,
};
use super::types::{
DEFAULT_CAPACITY_ROUNDS, DEFAULT_ECHO_COUNT, DEFAULT_ECHO_INTERVAL_MS, DEFAULT_ECHO_TIMEOUT_MS,
DEFAULT_PROFILE_PORT, DEFAULT_TRAIN_INTERVAL_MS, DEFAULT_TRAIN_PACKETS,
DEFAULT_TRAIN_SETTLE_MS, ProbeConfig,
};
const MAX_UDP_PACKET_BYTES: usize = 65_535;
const REFLECT_RECV_TIMEOUT: Duration = Duration::from_secs(1);
const STALE_TRAIN_AFTER: Duration = Duration::from_secs(60);
const SUMMARY_REQUEST_ATTEMPTS: u32 = 3;
pub fn run_from_env() -> Result<()> {
run_cli(Cli::parse())
}
pub fn run<I, S>(args: I) -> Result<()>
where
I: IntoIterator<Item = S>,
S: Into<OsString> + Clone,
{
run_cli(Cli::try_parse_from(args)?)
}
fn run_cli(cli: Cli) -> Result<()> {
match cli.command {
Command::Probe(args) => run_probe(args.into_probe_options()),
Command::Reflect(options) => run_reflect(options),
}
}
#[derive(Debug, Parser)]
#[command(name = "link_profile")]
#[command(about = "Run standalone link-local latency and packet-train probes")]
#[command(arg_required_else_help = true)]
struct Cli {
#[command(subcommand)]
command: Command,
}
#[derive(Debug, Subcommand)]
enum Command {
Probe(ProbeArgs),
Reflect(ReflectOptions),
}
#[derive(Debug, Clone)]
struct ProbeOptions {
ifname: String,
peer: Ipv6Addr,
config: ProbeConfig,
}
#[derive(Debug, Args, Clone)]
struct ReflectOptions {
#[arg(long)]
ifname: String,
#[arg(long, default_value_t = DEFAULT_PROFILE_PORT)]
port: u16,
}
#[derive(Debug, Args, Clone)]
struct ProbeArgs {
#[arg(long)]
ifname: String,
#[arg(long, value_parser = parse_link_local_addr_arg)]
peer: Ipv6Addr,
#[arg(long, default_value_t = DEFAULT_PROFILE_PORT)]
port: u16,
#[arg(long = "echo-count", default_value_t = DEFAULT_ECHO_COUNT)]
echo_count: u32,
#[arg(long = "echo-interval-ms", default_value_t = DEFAULT_ECHO_INTERVAL_MS)]
echo_interval_ms: u64,
#[arg(long = "timeout-ms", default_value_t = DEFAULT_ECHO_TIMEOUT_MS)]
timeout_ms: u64,
#[arg(long = "capacity-rounds", default_value_t = DEFAULT_CAPACITY_ROUNDS)]
capacity_rounds: u32,
#[arg(long = "train-packets", default_value_t = DEFAULT_TRAIN_PACKETS)]
train_packets: u32,
#[arg(long = "payload-bytes", default_value_t = usize::from(crate::config::TUN_MTU))]
payload_bytes: usize,
#[arg(long = "train-interval-ms", default_value_t = DEFAULT_TRAIN_INTERVAL_MS)]
train_interval_ms: u64,
#[arg(long = "settle-ms", default_value_t = DEFAULT_TRAIN_SETTLE_MS)]
settle_ms: u64,
}
impl ProbeArgs {
fn into_probe_options(self) -> ProbeOptions {
ProbeOptions {
ifname: self.ifname,
peer: self.peer,
config: ProbeConfig {
port: self.port,
echo_count: self.echo_count,
echo_interval: Duration::from_millis(self.echo_interval_ms),
echo_timeout: Duration::from_millis(self.timeout_ms),
capacity_rounds: self.capacity_rounds,
train_packets: self.train_packets,
train_payload_bytes: self.payload_bytes,
train_interval: Duration::from_millis(self.train_interval_ms),
train_settle: Duration::from_millis(self.settle_ms),
},
}
}
}
#[derive(Debug)]
struct TrainAccumulator {
received_packets: u32,
received_bytes: u64,
first_rx: Option<Instant>,
last_rx: Option<Instant>,
last_update: Instant,
seen: Vec<bool>,
}
impl TrainAccumulator {
fn new(expected_packets: u32, now: Instant) -> Self {
Self {
received_packets: 0,
received_bytes: 0,
first_rx: None,
last_rx: None,
last_update: now,
seen: vec![false; usize::try_from(expected_packets).unwrap_or(0)],
}
}
fn record(&mut self, seq: u32, packet_len: usize, now: Instant) {
self.last_update = now;
if !mark_seen(&mut self.seen, seq) {
return;
}
self.received_packets = self.received_packets.saturating_add(1);
self.received_bytes = self
.received_bytes
.saturating_add(u64::try_from(packet_len).unwrap_or(u64::MAX));
if self.first_rx.is_none() {
self.first_rx = Some(now);
}
self.last_rx = Some(now);
}
fn summary(&self) -> SummaryBody {
let span_nanos = match (self.first_rx, self.last_rx) {
(Some(first), Some(last)) => duration_nanos_u64(last.saturating_duration_since(first)),
_ => 0,
};
SummaryBody {
received_packets: self.received_packets,
received_bytes: self.received_bytes,
span_nanos,
}
}
}
fn mark_seen(seen: &mut [bool], seq: u32) -> bool {
let Ok(index) = usize::try_from(seq) else {
return false;
};
let Some(slot) = seen.get_mut(index) else {
return false;
};
if *slot {
return false;
}
*slot = true;
true
}
fn run_reflect(options: ReflectOptions) -> Result<()> {
let (socket, ifindex) =
open_link_local_udp(&options.ifname, options.port, Some(REFLECT_RECV_TIMEOUT))
.wrap_err_with(|| format!("opening profiling reflector on {}", options.ifname))?;
let local_addr = socket
.local_addr()
.wrap_err("reading reflector local address")?;
println!(
"reflecting profiling probes on {} ifindex={} local={}",
options.ifname, ifindex, local_addr
);
let mut buf = vec![0_u8; MAX_UDP_PACKET_BYTES];
let mut trains = HashMap::<u64, TrainAccumulator>::new();
let mut last_cleanup = Instant::now();
loop {
cleanup_stale_trains(&mut trains, &mut last_cleanup);
let (packet_len, from) = match socket.recv_from(&mut buf) {
Ok(received) => received,
Err(err) if is_timeout(&err) || err.kind() == ErrorKind::Interrupted => continue,
Err(err) => return Err(err).wrap_err("receiving profiling packet"),
};
let Some(packet) = buf.get(..packet_len) else {
continue;
};
let Ok(header) = decode_header(packet) else {
continue;
};
match header.kind {
PacketKind::EchoRequest => {
send_echo_reply(&socket, ifindex, from, header).wrap_err("sending echo reply")?;
}
PacketKind::Train => {
let now = Instant::now();
trains
.entry(header.run_id)
.or_insert_with(|| TrainAccumulator::new(header.count, now))
.record(header.seq, packet_len, now);
}
PacketKind::SummaryRequest => {
send_summary_reply(&socket, ifindex, from, header, &mut trains)
.wrap_err("sending train summary")?;
}
PacketKind::EchoReply | PacketKind::SummaryReply => {}
}
}
}
fn run_probe(options: ProbeOptions) -> Result<()> {
if options.config.train_payload_bytes < HEADER_LEN {
return Err(eyre!(
"train payload must be at least {HEADER_LEN} bytes, got {}",
options.config.train_payload_bytes
));
}
if options.config.train_packets == 0 {
return Err(eyre!("train packet count must be non-zero"));
}
let (socket, ifindex) =
open_link_local_udp(&options.ifname, 0, Some(options.config.echo_timeout))
.wrap_err_with(|| format!("opening profiling probe socket on {}", options.ifname))?;
let peer = scoped_peer_addr(options.peer, options.config.port, ifindex);
let local_addr = socket
.local_addr()
.wrap_err("reading probe local address")?;
let base_run_id = make_base_run_id();
println!(
"probing {} via {} ifindex={} local={} peer_port={}",
options.peer, options.ifname, ifindex, local_addr, options.config.port
);
println!(
"capacity probe: rounds={} train_packets={} payload_bytes={} interval_ms={}",
options.config.capacity_rounds,
options.config.train_packets,
options.config.train_payload_bytes,
options.config.train_interval.as_millis()
);
let latency_samples = run_echo_probes(&socket, peer, base_run_id, &options.config)
.wrap_err("running latency probes")?;
print_latency_summary(options.config.echo_count, &latency_samples);
let capacity_samples = run_capacity_probes(&socket, peer, base_run_id, &options.config)
.wrap_err("running capacity probes")?;
print_capacity_summary(&capacity_samples);
Ok(())
}
fn send_echo_reply(
socket: &UdpSocket,
ifindex: u32,
from: SocketAddr,
request: Header,
) -> Result<()> {
let reply = Header {
kind: PacketKind::EchoReply,
run_id: request.run_id,
seq: request.seq,
count: request.count,
};
let mut out = [0_u8; HEADER_LEN];
encode_header(&mut out, reply)?;
send_datagram(socket, &out, with_default_scope(from, ifindex))
}
fn send_summary_reply(
socket: &UdpSocket,
ifindex: u32,
from: SocketAddr,
request: Header,
trains: &mut HashMap<u64, TrainAccumulator>,
) -> Result<()> {
let body = trains
.get(&request.run_id)
.map_or_else(empty_summary, TrainAccumulator::summary);
let reply = Header {
kind: PacketKind::SummaryReply,
run_id: request.run_id,
seq: 0,
count: request.count,
};
let mut out = [0_u8; SUMMARY_PACKET_LEN];
let len = encode_summary(&mut out, reply, body)?;
send_datagram(
socket,
out.get(..len).unwrap_or(&out),
with_default_scope(from, ifindex),
)?;
trains.remove(&request.run_id);
Ok(())
}
fn run_echo_probes(
socket: &UdpSocket,
peer: SocketAddr,
base_run_id: u64,
config: &ProbeConfig,
) -> Result<Vec<Duration>> {
let mut samples = Vec::new();
let run_id = base_run_id ^ 0xe0c0_u64;
for seq in 0..config.echo_count {
let header = Header {
kind: PacketKind::EchoRequest,
run_id,
seq,
count: config.echo_count,
};
let mut out = [0_u8; HEADER_LEN];
encode_header(&mut out, header)?;
let start = Instant::now();
send_datagram(socket, &out, peer)?;
match receive_echo_reply(socket, run_id, seq, start, config.echo_timeout)? {
Some(sample) => {
println!("echo {:>3}: {}", seq + 1, format_duration(sample));
samples.push(sample);
}
None => {
println!("echo {:>3}: timeout", seq + 1);
}
}
thread::sleep(config.echo_interval);
}
Ok(samples)
}
fn run_capacity_probes(
socket: &UdpSocket,
peer: SocketAddr,
base_run_id: u64,
config: &ProbeConfig,
) -> Result<Vec<CapacitySample>> {
let mut samples = Vec::new();
let mut train = vec![0_u8; config.train_payload_bytes];
for round in 0..config.capacity_rounds {
let run_id = base_run_id ^ (0xc0_ffee_u64.wrapping_add(u64::from(round)));
let sender_start = Instant::now();
for seq in 0..config.train_packets {
let header = Header {
kind: PacketKind::Train,
run_id,
seq,
count: config.train_packets,
};
encode_header(&mut train, header)?;
send_datagram(socket, &train, peer)?;
}
let sender_span = sender_start.elapsed();
thread::sleep(config.train_settle);
let summary = request_summary(socket, peer, run_id, config)?;
match summary {
Some(body) => {
let sample = CapacitySample {
sent_packets: config.train_packets,
received_packets: body.received_packets,
received_bytes: body.received_bytes,
span: Duration::from_nanos(body.span_nanos),
};
print_capacity_round(round + 1, sample, sender_span);
samples.push(sample);
}
None => {
println!(
"capacity {:>3}: summary timeout after sender_burst={}",
round + 1,
format_duration(sender_span)
);
}
}
thread::sleep(config.train_interval);
}
Ok(samples)
}
fn request_summary(
socket: &UdpSocket,
peer: SocketAddr,
run_id: u64,
config: &ProbeConfig,
) -> Result<Option<SummaryBody>> {
let request = Header {
kind: PacketKind::SummaryRequest,
run_id,
seq: 0,
count: config.train_packets,
};
let mut out = [0_u8; HEADER_LEN];
encode_header(&mut out, request)?;
let attempt_timeout = div_duration(config.echo_timeout, SUMMARY_REQUEST_ATTEMPTS);
for _attempt in 0..SUMMARY_REQUEST_ATTEMPTS {
send_datagram(socket, &out, peer)?;
let deadline = Instant::now() + attempt_timeout;
if let Some(summary) = receive_summary_reply(socket, run_id, deadline)? {
return Ok(Some(summary));
}
}
Ok(None)
}
fn receive_echo_reply(
socket: &UdpSocket,
run_id: u64,
seq: u32,
start: Instant,
timeout: Duration,
) -> Result<Option<Duration>> {
let deadline = start + timeout;
let mut buf = vec![0_u8; MAX_UDP_PACKET_BYTES];
loop {
if !set_timeout_until(socket, deadline)? {
return Ok(None);
}
let (packet_len, _from) = match socket.recv_from(&mut buf) {
Ok(received) => received,
Err(err) if is_timeout(&err) => return Ok(None),
Err(err) if err.kind() == ErrorKind::Interrupted => continue,
Err(err) => return Err(err).wrap_err("receiving echo reply"),
};
let Some(packet) = buf.get(..packet_len) else {
continue;
};
let Ok(header) = decode_header(packet) else {
continue;
};
if header.kind == PacketKind::EchoReply && header.run_id == run_id && header.seq == seq {
return Ok(Some(start.elapsed()));
}
}
}
fn receive_summary_reply(
socket: &UdpSocket,
run_id: u64,
deadline: Instant,
) -> Result<Option<SummaryBody>> {
let mut buf = vec![0_u8; MAX_UDP_PACKET_BYTES];
loop {
if !set_timeout_until(socket, deadline)? {
return Ok(None);
}
let (packet_len, _from) = match socket.recv_from(&mut buf) {
Ok(received) => received,
Err(err) if is_timeout(&err) => return Ok(None),
Err(err) if err.kind() == ErrorKind::Interrupted => continue,
Err(err) => return Err(err).wrap_err("receiving train summary"),
};
let Some(packet) = buf.get(..packet_len) else {
continue;
};
let Ok(header) = decode_header(packet) else {
continue;
};
if header.kind == PacketKind::SummaryReply && header.run_id == run_id {
return Ok(Some(decode_summary_body(packet)?));
}
}
}
fn send_datagram(socket: &UdpSocket, buf: &[u8], target: SocketAddr) -> Result<()> {
let sent = socket
.send_to(buf, target)
.wrap_err_with(|| format!("sending profiling packet to {target}"))?;
if sent != buf.len() {
return Err(eyre!(
"short UDP send to {target}: sent {sent} of {} bytes",
buf.len()
));
}
Ok(())
}
fn set_timeout_until(socket: &UdpSocket, deadline: Instant) -> Result<bool> {
let now = Instant::now();
if now >= deadline {
return Ok(false);
}
socket
.set_read_timeout(Some(deadline.saturating_duration_since(now)))
.wrap_err("setting profiling socket read timeout")?;
Ok(true)
}
fn cleanup_stale_trains(trains: &mut HashMap<u64, TrainAccumulator>, last_cleanup: &mut Instant) {
if last_cleanup.elapsed() < Duration::from_secs(5) {
return;
}
trains.retain(|_run_id, train| train.last_update.elapsed() < STALE_TRAIN_AFTER);
*last_cleanup = Instant::now();
}
fn empty_summary() -> SummaryBody {
SummaryBody {
received_packets: 0,
received_bytes: 0,
span_nanos: 0,
}
}
fn print_latency_summary(sent: u32, samples: &[Duration]) {
match latency_stats(sent, samples) {
Some(stats) => println!(
"latency summary: sent={} received={} loss={:.1}% min={} avg={} max={}",
stats.sent,
stats.received,
stats.loss_ratio * 100.0,
format_duration(stats.min),
format_duration(stats.avg),
format_duration(stats.max)
),
None => println!("latency summary: no replies"),
}
}
fn print_capacity_round(round: u32, sample: CapacitySample, sender_span: Duration) {
let mbps = sample
.mbps()
.map_or_else(|| "n/a".to_owned(), |value| format!("{value:.1} Mbps"));
println!(
"capacity {:>3}: rx={}/{} loss={:.1}% span={} estimate={} sender_burst={}",
round,
sample.received_packets,
sample.sent_packets,
sample.loss_ratio() * 100.0,
format_duration(sample.span),
mbps,
format_duration(sender_span)
);
}
fn print_capacity_summary(samples: &[CapacitySample]) {
let mut estimates = samples
.iter()
.filter_map(|sample| sample.mbps())
.collect::<Vec<f64>>();
if estimates.is_empty() {
println!("capacity summary: no usable samples");
return;
}
estimates.sort_by(f64::total_cmp);
let median_index = estimates.len() / 2;
let median = estimates.get(median_index).copied().unwrap_or(0.0);
let best = estimates.last().copied().unwrap_or(median);
let received = samples
.iter()
.map(|sample| sample.received_packets)
.sum::<u32>();
let sent = samples
.iter()
.map(|sample| sample.sent_packets)
.sum::<u32>();
let loss = if sent == 0 {
0.0
} else {
f64::from(sent.saturating_sub(received)) / f64::from(sent)
};
println!(
"capacity summary: samples={} median={median:.1} Mbps best={best:.1} Mbps aggregate_loss={:.1}%",
estimates.len(),
loss * 100.0
);
}
fn parse_link_local_addr_arg(raw: &str) -> std::result::Result<Ipv6Addr, String> {
parse_link_local_addr(raw).map_err(|err| err.to_string())
}
fn is_timeout(err: &io::Error) -> bool {
matches!(err.kind(), ErrorKind::WouldBlock | ErrorKind::TimedOut)
}
fn div_duration(duration: Duration, divisor: u32) -> Duration {
if divisor == 0 {
return duration;
}
Duration::from_nanos(duration_nanos_u64(duration) / u64::from(divisor))
}
fn format_duration(duration: Duration) -> String {
if duration < Duration::from_millis(1) {
return format!("{:.3} us", duration.as_secs_f64() * 1_000_000.0);
}
format!("{:.3} ms", duration.as_secs_f64() * 1_000.0)
}
fn make_base_run_id() -> u64 {
rand::random()
}
#[cfg(test)]
mod tests {
use std::net::Ipv6Addr;
use clap::Parser;
use super::{Cli, Command, mark_seen};
#[test]
fn parses_probe_options() {
let cli = Cli::try_parse_from([
"link_profile",
"probe",
"--ifname",
"en3",
"--peer",
"fe80::1%en3",
"--train-packets",
"32",
])
.expect("probe options should parse");
match cli.command {
Command::Probe(args) => {
let options = args.into_probe_options();
assert_eq!(options.ifname, "en3");
assert_eq!(
options.peer,
"fe80::1".parse::<Ipv6Addr>().expect("valid IPv6")
);
assert_eq!(options.config.train_packets, 32);
}
Command::Reflect(_) => panic!("expected probe command"),
}
}
#[test]
fn parses_reflect_options() {
let cli = Cli::try_parse_from([
"link_profile",
"reflect",
"--ifname",
"en2",
"--port",
"42000",
])
.expect("reflect options should parse");
match cli.command {
Command::Reflect(options) => {
assert_eq!(options.ifname, "en2");
assert_eq!(options.port, 42_000);
}
Command::Probe(_) => panic!("expected reflect command"),
}
}
#[test]
fn mark_seen_accepts_each_sequence_once() {
let mut seen = vec![false; 2];
assert!(mark_seen(&mut seen, 0));
assert!(!mark_seen(&mut seen, 0));
assert!(mark_seen(&mut seen, 1));
assert!(!mark_seen(&mut seen, 2));
}
}
-49
View File
@@ -1,49 +0,0 @@
use std::net::Ipv6Addr;
use std::time::Duration;
use crate::config::TUN_MTU;
pub const DEFAULT_PROFILE_PORT: u16 = 41_901;
pub const DEFAULT_ECHO_COUNT: u32 = 10;
pub const DEFAULT_ECHO_INTERVAL_MS: u64 = 250;
pub const DEFAULT_ECHO_TIMEOUT_MS: u64 = 500;
pub const DEFAULT_CAPACITY_ROUNDS: u32 = 5;
pub const DEFAULT_TRAIN_PACKETS: u32 = 64;
pub const DEFAULT_TRAIN_INTERVAL_MS: u64 = 1_000;
pub const DEFAULT_TRAIN_SETTLE_MS: u64 = 25;
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct LinkKey {
pub ifname: Box<str>,
pub ifindex: u32,
pub peer_link_local: Ipv6Addr,
}
#[derive(Debug, Clone)]
pub struct ProbeConfig {
pub port: u16,
pub echo_count: u32,
pub echo_interval: Duration,
pub echo_timeout: Duration,
pub capacity_rounds: u32,
pub train_packets: u32,
pub train_payload_bytes: usize,
pub train_interval: Duration,
pub train_settle: Duration,
}
impl Default for ProbeConfig {
fn default() -> Self {
Self {
port: DEFAULT_PROFILE_PORT,
echo_count: DEFAULT_ECHO_COUNT,
echo_interval: Duration::from_millis(DEFAULT_ECHO_INTERVAL_MS),
echo_timeout: Duration::from_millis(DEFAULT_ECHO_TIMEOUT_MS),
capacity_rounds: DEFAULT_CAPACITY_ROUNDS,
train_packets: DEFAULT_TRAIN_PACKETS,
train_payload_bytes: usize::from(TUN_MTU),
train_interval: Duration::from_millis(DEFAULT_TRAIN_INTERVAL_MS),
train_settle: Duration::from_millis(DEFAULT_TRAIN_SETTLE_MS),
}
}
}
-59
View File
@@ -1,59 +0,0 @@
use std::io;
use std::net::IpAddr;
use ipnet::Ipv6Net;
use nix::net::if_::if_nametoindex;
use route_manager::{Route, RouteManager};
use crate::Result;
fn route_destination(prefix: Ipv6Net) -> IpAddr {
IpAddr::V6(prefix.trunc().addr())
}
fn is_overlay_route(route: &Route, prefix: Ipv6Net) -> bool {
route.destination() == route_destination(prefix) && route.prefix() == prefix.prefix_len()
}
fn tun_if_index(tun_ifname: &str) -> io::Result<u32> {
if_nametoindex(tun_ifname).map_err(io::Error::from)
}
pub fn ensure_overlay_route(prefix: Ipv6Net, tun_ifname: &str) -> Result<()> {
let tun_ifindex = tun_if_index(tun_ifname)?;
let desired =
Route::new(route_destination(prefix), prefix.prefix_len()).with_if_index(tun_ifindex);
let mut manager = RouteManager::new()?;
let existing: Vec<Route> = manager
.list()?
.into_iter()
.filter(|route| is_overlay_route(route, prefix))
.collect();
let already_present = existing
.iter()
.any(|route| route.if_index() == Some(tun_ifindex) && route.gateway().is_none());
if already_present {
return Ok(());
}
for route in existing {
manager.delete(&route)?;
}
manager.add(&desired)?;
Ok(())
}
pub fn remove_overlay_route(prefix: Ipv6Net) -> Result<()> {
let mut manager = RouteManager::new()?;
let existing: Vec<Route> = manager
.list()?
.into_iter()
.filter(|route| is_overlay_route(route, prefix))
.collect();
for route in existing {
manager.delete(&route)?;
}
Ok(())
}
-205
View File
@@ -1,205 +0,0 @@
use std::sync::Arc;
use color_eyre::eyre::{Result, WrapErr};
use ipnet::Ipv6Net;
use tokio::{
sync::{mpsc, watch},
task::JoinHandle,
time::{Duration, MissedTickBehavior},
};
use crate::babel::BabelState;
use crate::config::TUN_MTU;
use crate::daemon::{RoutingStackEvent, StackTaskKind};
use crate::dataplane::{Dataplane, DataplaneConfig, DataplanePublisher, PublishSnapshotError};
use crate::fib::FibBuilder;
use crate::tun::TunDevice;
pub struct RoutingStack {
babel: JoinHandle<crate::Result<()>>,
watcher: JoinHandle<crate::Result<()>>,
state_logger: JoinHandle<()>,
fib_publisher: JoinHandle<crate::Result<()>>,
dataplane_monitor: JoinHandle<()>,
dataplane: Dataplane,
}
impl RoutingStack {
pub fn start(
node_addr: Ipv6Net,
tun: &TunDevice,
udp_port: u16,
state_send: watch::Sender<Arc<BabelState>>,
event_send: mpsc::Sender<RoutingStackEvent>,
) -> Result<Self> {
let (iface_send, iface_recv) = mpsc::channel(32);
let mut state_recv = state_send.subscribe();
let fib_state_recv = state_send.subscribe();
let initial_state = state_send.borrow().clone();
let mut dataplane = Dataplane::spawn(DataplaneConfig {
tun_device: tun.shared_device(),
udp_port,
initial_fib: Arc::new(
FibBuilder::new([node_addr.addr()], TUN_MTU).derive(initial_state.as_ref()),
),
})?;
let dataplane_exit = dataplane
.take_exit_receiver()
.ok_or_else(|| color_eyre::eyre::eyre!("dataplane exit receiver missing"))?;
let state_logger = tokio::spawn(async move {
while state_recv.changed().await.is_ok() {
let snapshot = state_recv.borrow_and_update();
tracing::info!(state = ?*snapshot, "babel state snapshot updated");
}
tracing::info!("babel state stream closed");
});
let babel_events = event_send.clone();
let babel = tokio::spawn(async move {
let res = crate::babel(node_addr, iface_recv, state_send).await;
let _ = babel_events
.send(RoutingStackEvent::Exited {
kind: StackTaskKind::Babel,
error: res.as_ref().err().map(ToString::to_string),
})
.await;
res
});
let watcher_events = event_send.clone();
let watcher = tokio::spawn(async move {
let res = crate::watch(iface_send).await;
let _ = watcher_events
.send(RoutingStackEvent::Exited {
kind: StackTaskKind::Watcher,
error: res.as_ref().err().map(ToString::to_string),
})
.await;
res
});
let fib_events = event_send.clone();
let dataplane_publisher = dataplane.publisher();
let fib_publisher = tokio::spawn(async move {
let res = publish_fib_updates(node_addr, fib_state_recv, dataplane_publisher).await;
let _ = fib_events
.send(RoutingStackEvent::Exited {
kind: StackTaskKind::FibPublisher,
error: res.as_ref().err().map(ToString::to_string),
})
.await;
res
});
let dataplane_events = event_send;
let dataplane_monitor = tokio::spawn(async move {
let exit = dataplane_exit.await;
let (kind, error) = match exit {
Ok(Ok(())) => (StackTaskKind::Dataplane, None),
Ok(Err(err)) => (StackTaskKind::Dataplane, Some(err)),
Err(err) => (
StackTaskKind::Dataplane,
Some(format!("dataplane exit receiver dropped: {err}")),
),
};
let _ = dataplane_events
.send(RoutingStackEvent::Exited { kind, error })
.await;
});
Ok(Self {
babel,
watcher,
state_logger,
fib_publisher,
dataplane_monitor,
dataplane,
})
}
pub async fn stop(self) -> Result<()> {
let Self {
babel,
watcher,
state_logger,
fib_publisher,
dataplane_monitor,
dataplane,
} = self;
watcher.abort();
if let Ok(res) = watcher.await {
res.wrap_err("stopping interface watcher")?;
}
state_logger.abort();
let _ = state_logger.await;
fib_publisher.abort();
let _ = fib_publisher.await;
dataplane_monitor.abort();
let _ = dataplane_monitor.await;
dataplane.stop().wrap_err("stopping dataplane thread")?;
babel.await?.wrap_err("stopping babeld runtime")?;
Ok(())
}
}
async fn publish_fib_updates(
node_addr: Ipv6Net,
mut state_recv: watch::Receiver<Arc<BabelState>>,
publisher: DataplanePublisher,
) -> crate::Result<()> {
let builder = FibBuilder::new([node_addr.addr()], TUN_MTU);
let mut pending = Some(Arc::new(builder.derive(state_recv.borrow().as_ref())));
let mut published: Option<Arc<crate::fib::FibSnapshot>> = None;
let mut retry_tick = tokio::time::interval(Duration::from_millis(10));
retry_tick.set_missed_tick_behavior(MissedTickBehavior::Delay);
loop {
if let Some(snapshot) = pending.take() {
let published_snapshot = Arc::clone(&snapshot);
match publisher.try_publish(snapshot) {
Ok(()) => {
published = Some(published_snapshot);
}
Err(PublishSnapshotError::Full(snapshot)) => {
pending = Some(snapshot);
}
Err(PublishSnapshotError::Stopped) => {
return Err(crate::BabbleError::Other(
"dataplane thread stopped".to_owned(),
));
}
}
}
tokio::select! {
changed = state_recv.changed() => {
match changed {
Ok(()) => {
let snapshot = {
let state = state_recv.borrow_and_update();
Arc::new(builder.derive(state.as_ref()))
};
let matches_published = published
.as_ref()
.is_some_and(|current| current.as_ref() == snapshot.as_ref());
let matches_pending = pending
.as_ref()
.is_some_and(|current| current.as_ref() == snapshot.as_ref());
if !matches_published && !matches_pending {
pending = Some(snapshot);
}
}
Err(_) => return Ok(()),
}
}
_ = retry_tick.tick(), if pending.is_some() => {}
}
}
}
-63
View File
@@ -1,63 +0,0 @@
use ipnet::Ipv6Net;
use std::net::Ipv6Addr;
use std::sync::Arc;
use tun_rs::{DeviceBuilder, SyncDevice};
use crate::config::TUN_MTU;
#[cfg(target_os = "linux")]
const DESIRED_TUN_NAME: &str = "exonet";
/// Holds the TUN device open for the lifetime of the daemon.
/// The interface disappears when this is dropped.
pub struct TunDevice {
dev: Arc<SyncDevice>,
ifname: String,
node_addr: Ipv6Net, // TODO: we are only ever gonna install /128 subnets, maybe change to Ipv6Addr in future??
}
impl TunDevice {
pub fn create(node_addr: Ipv6Addr) -> crate::Result<Self> {
let builder = DeviceBuilder::new().ipv6(node_addr, 128u8).mtu(TUN_MTU);
#[cfg(target_os = "linux")]
let builder = builder.name(DESIRED_TUN_NAME);
let dev = builder
.with(|builder| {
builder.packet_information(false);
#[cfg(target_os = "macos")]
{
// Route ownership stays in userspace; do not let tun-rs auto-add routes.
// The IPv6 /128 address itself is still applied by tun-rs.
builder.associate_route(false);
}
})
.build_sync()?;
dev.set_nonblocking(true)?;
let ifname = dev.name()?;
Ok(Self {
dev: Arc::new(dev),
ifname,
node_addr: Ipv6Net::new_assert(node_addr, 128), // TODO: i dont't like the magic numbers, I also don't like wrapping and unwrapping
})
}
pub fn ifname(&self) -> &str {
&self.ifname
}
pub fn node_addr(&self) -> Ipv6Net {
self.node_addr
}
pub fn device(&self) -> &SyncDevice {
self.dev.as_ref()
}
pub fn shared_device(&self) -> Arc<SyncDevice> {
Arc::clone(&self.dev)
}
}
@@ -1,5 +1,5 @@
[package]
name = "exo_pyo3_bindings"
name = "exo_rs"
version = { workspace = true }
edition = { workspace = true }
publish = false
@@ -7,7 +7,7 @@ publish = false
[lib]
doctest = false
path = "src/lib.rs"
name = "exo_pyo3_bindings"
name = "exo_rs"
# "cdylib" needed to produce shared library for Python to import
# "rlib" needed for stub-gen to run
@@ -25,7 +25,7 @@ workspace = true
networking = { workspace = true }
# interop
pyo3 = { version = "0.27.2", features = [
pyo3 = { version = "0.28.3", 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]
@@ -38,15 +38,15 @@ pyo3 = { version = "0.27.2", features = [
# "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 = [
pyo3-stub-gen = { version = "0.22.3" }
pyo3-async-runtimes = { version = "0.28.0", features = [
"attributes",
"tokio-runtime",
"testing",
] }
pyo3-log = "0.13.2"
pyo3-log = "0.13.3"
pidfile-rs = "0.3"
pidfile-rs = { git = "https://github.com/AndreiCravtov/pidfile-rs" }
# macro dependencies
extend = { workspace = true }
File renamed without changes.
@@ -1,10 +1,20 @@
# This file is automatically generated by pyo3_stub_gen
# ruff: noqa: E501, F401
# ruff: noqa: E501, F401, F403, F405
import builtins
import os
import pathlib
import typing
__all__ = [
"AllQueuesFullError",
"FromSwarm",
"Keypair",
"MessageTooLargeError",
"NetworkingHandle",
"NoPeersSubscribedToTopicError",
"Pidfile",
"PidfileError",
]
@typing.final
class AllQueuesFullError(builtins.Exception):
@@ -12,6 +22,29 @@ class AllQueuesFullError(builtins.Exception):
def __repr__(self) -> builtins.str: ...
def __str__(self) -> builtins.str: ...
class FromSwarm:
@typing.final
class Connection(FromSwarm):
__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) -> FromSwarm.Connection: ...
@typing.final
class Message(FromSwarm):
__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) -> FromSwarm.Message: ...
...
@typing.final
class Keypair:
r"""
@@ -45,6 +78,7 @@ class MessageTooLargeError(builtins.Exception):
@typing.final
class NetworkingHandle:
def __new__(cls, identity: Keypair, bootstrap_peers: typing.Sequence[builtins.str], listen_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,7 +97,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):
@@ -77,7 +110,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 +140,23 @@ 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: ...
...
@@ -3,8 +3,8 @@ requires = ["maturin>=1.0,<2.0"]
build-backend = "maturin"
[project]
name = "exo_pyo3_bindings"
version = "0.2.2"
name = "exo_rs"
version = "0.2.16"
description = "Add your description here"
readme = "README.md"
authors = [
@@ -15,14 +15,17 @@ 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"
@@ -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(())
}
File renamed without changes.
@@ -153,7 +153,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();
@@ -16,9 +16,7 @@ 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 pyo3_stub_gen::derive::{gen_stub_pyclass, gen_stub_pyclass_complex_enum, gen_stub_pymethods};
use tokio::sync::{Mutex, mpsc, oneshot};
mod exception {
@@ -138,7 +136,7 @@ struct PyNetworkingHandle {
}
#[gen_stub_pyclass_complex_enum]
#[pyclass]
#[pyclass(name = "FromSwarm")]
enum PyFromSwarm {
Connection {
peer_id: String,
@@ -204,9 +202,11 @@ impl PyNetworkingHandle {
})
}
#[gen_stub(skip)]
#[gen_stub(override_return_type(
type_repr="typing.Awaitable[FromSwarm]", imports=("typing")
))]
fn recv<'py>(&'py self, py: Python<'py>) -> PyResult<Bound<'py, PyAny>> {
let swarm = Arc::clone(&self.swarm);
let swarm = self.swarm.clone();
pyo3_async_runtimes::tokio::future_into_py(py, async move {
swarm
.try_lock()
@@ -297,15 +297,6 @@ impl PyNetworkingHandle {
}
}
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>()?;
@@ -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;
}
}
File renamed without changes.
@@ -2,12 +2,12 @@ import asyncio
import pytest
from _pytest.capture import CaptureFixture
from exo_pyo3_bindings import (
from exo_rs import (
Keypair,
NetworkingHandle,
NoPeersSubscribedToTopicError,
Pidfile,
PyFromSwarm,
FromSwarm,
)
@@ -39,9 +39,9 @@ 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}")
+6 -18
View File
@@ -1,7 +1,7 @@
{ inputs, ... }:
{
perSystem =
{ inputs', self', pkgs, lib, ... }:
{ inputs', pkgs, lib, ... }:
let
# Fenix nightly toolchain with all components
rustToolchain = inputs'.fenix.packages.stable.withComponents [
@@ -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 ];
@@ -79,13 +80,13 @@
};
config = {
packages = rec {
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
@@ -110,19 +111,6 @@
'';
}
);
babblerd-unwrapped = craneLib.buildPackage (
commonArgs // {
inherit cargoArtifacts;
pname = "babblerd-unwrapped";
}
);
babblerd = pkgs.writeShellApplication {
name = "babblerd";
runtimeInputs = [ self'.packages.babeld ];
text = ''
exec ${babblerd-unwrapped}/bin/babblerd "$@"
'';
};
};
checks = {
-38
View File
@@ -1,38 +0,0 @@
#!/usr/bin/env python3
import argparse
import socket
import sys
def main() -> int:
p = argparse.ArgumentParser(
description="IPv6 UDP client with optional explicit source bind"
)
p.add_argument("--dest", required=True, help="Destination IPv6 address")
p.add_argument("--port", type=int, default=45679, help="Destination UDP port")
p.add_argument("--source", help="Optional source IPv6 address to bind to")
p.add_argument("--message", default="hello", help="Payload to send")
p.add_argument(
"--timeout", type=float, default=5.0, help="Receive timeout in seconds"
)
args = p.parse_args()
s = socket.socket(socket.AF_INET6, socket.SOCK_DGRAM)
s.setsockopt(socket.IPPROTO_IPV6, socket.IPV6_V6ONLY, 1)
s.settimeout(args.timeout)
if args.source:
s.bind((args.source, 0, 0, 0))
print(f"local-before-send={s.getsockname()}")
s.sendto(args.message.encode(), (args.dest, args.port, 0, 0))
print(f"sent to=[{args.dest}]:{args.port}")
print(f"local-after-send={s.getsockname()}")
data, peer = s.recvfrom(65535)
print(f"from={peer} data={data!r}")
return 0
if __name__ == "__main__":
raise SystemExit(main())
-35
View File
@@ -1,35 +0,0 @@
#!/usr/bin/env python3
import argparse
import socket
import sys
def main() -> int:
p = argparse.ArgumentParser(
description="IPv6 UDP server bound to a specific local address"
)
p.add_argument(
"--bind", required=True, help="Local IPv6 address to bind to, e.g. fde0:..."
)
p.add_argument("--port", type=int, default=45679, help="UDP port to listen on")
p.add_argument("--reply", default="ok", help="Reply prefix")
args = p.parse_args()
s = socket.socket(socket.AF_INET6, socket.SOCK_DGRAM)
s.setsockopt(socket.IPPROTO_IPV6, socket.IPV6_V6ONLY, 1)
s.bind((args.bind, args.port, 0, 0))
print(f"listening on [{args.bind}]:{args.port}")
print(f"sockname={s.getsockname()}")
data, peer = s.recvfrom(65535)
print(f"from={peer} data={data!r}")
out = args.reply.encode() + b":" + data
s.sendto(out, peer)
print(f"sent={out!r} to={peer}")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+88 -42
View File
@@ -20,7 +20,7 @@ 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 exo.api.adapters.chat_completions import (
@@ -50,6 +50,8 @@ from exo.api.keepalive import with_sse_keepalive
from exo.api.types import (
AddCustomModelParams,
AdvancedImageParams,
AwaitInstanceReadyMessage,
AwaitInstanceTimeoutMessage,
BenchChatCompletionRequest,
BenchChatCompletionResponse,
BenchImageGenerationResponse,
@@ -344,6 +346,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)
@@ -633,6 +636,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")
@@ -761,6 +806,8 @@ class API:
if isinstance(chunk, PrefillProgressChunk):
continue
sampler.mark_prefill_done()
if chunk.finish_reason == "error":
raise HTTPException(
status_code=500,
@@ -871,10 +918,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 +951,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 +984,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 +995,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 +1062,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),
}
)
@@ -1292,7 +1332,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 +1370,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 +1389,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 +1410,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 +1534,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 +1572,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)
@@ -1573,10 +1615,10 @@ class API:
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(
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)
@@ -1609,10 +1651,10 @@ class API:
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(
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)
@@ -1914,6 +1956,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
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
+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:
+66 -19
View File
@@ -8,6 +8,9 @@ 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
from loguru import logger
from pydantic import PositiveInt
@@ -18,13 +21,12 @@ 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.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
@@ -190,7 +192,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 +205,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
@@ -270,14 +275,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,9 +337,6 @@ 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()}")
@@ -324,7 +372,6 @@ def main():
finally:
logger.info("EXO Shutdown complete")
logger_cleanup()
del pidfile
class Args(FrozenModel):
@@ -338,7 +385,7 @@ class Args(FrozenModel):
offline: bool = os.getenv("EXO_OFFLINE", "false").lower() == "true"
no_batch: bool = False
fast_synch: bool | None = None # None = auto, True = force on, False = force off
no_stdio: bool = False
legacy_daemon: bool = False
bootstrap_peers: list[str] = []
libp2p_port: int
@@ -399,9 +446,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",
+16 -3
View File
@@ -11,6 +11,10 @@ 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 (
@@ -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,6 +181,7 @@ class Master:
case TestCommand():
pass
case TextGeneration():
# set-difference => prefill-only nodes
prefill_only: set[InstanceId] = set()
for link in self.state.instance_links.values():
prefill_only.update(link.prefill_instances)
@@ -181,11 +189,13 @@ class Master:
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 +207,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}"
@@ -448,7 +459,9 @@ 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:
@@ -506,10 +519,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(
+2 -2
View File
@@ -1,4 +1,4 @@
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
@@ -11,5 +11,5 @@ class ConnectionMessage(FrozenModel):
connected: bool
@classmethod
def from_update(cls, update: PyFromSwarm.Connection) -> "ConnectionMessage":
def from_update(cls, update: FromSwarm.Connection) -> "ConnectionMessage":
return cls(node_id=NodeId(update.peer_id), 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
+4 -4
View File
@@ -12,13 +12,13 @@ from anyio import (
move_on_after,
sleep_forever,
)
from exo_pyo3_bindings import (
from exo_rs import (
AllQueuesFullError,
FromSwarm,
Keypair,
MessageTooLargeError,
NetworkingHandle,
NoPeersSubscribedToTopicError,
PyFromSwarm,
)
from filelock import FileLock
from loguru import logger
@@ -191,7 +191,7 @@ class Router:
from_swarm = await self._net.recv()
logger.debug(from_swarm)
match from_swarm:
case PyFromSwarm.Message(origin, topic, data):
case FromSwarm.Message(origin, topic, data):
logger.trace(
f"Received message on {topic} from {origin} with payload {data}"
)
@@ -202,7 +202,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}"
+1 -1
View File
@@ -46,7 +46,7 @@ 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.WARNING)
logging.getLogger("httpx").setLevel(logging.WARNING)
logging.getLogger("httpcore").setLevel(logging.WARNING)
+5
View File
@@ -2,6 +2,11 @@ from typing import Any, Type
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):
+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)
+155 -8
View File
@@ -1,13 +1,16 @@
import contextlib
import multiprocessing as mp
from dataclasses import dataclass, field
from functools import wraps
from inspect import iscoroutinefunction
from math import inf
from multiprocessing.synchronize import Event
from queue import Empty, Full
from types import TracebackType
from typing import Any, Self
from types import CoroutineType, TracebackType
from typing import Any, Callable, NoReturn, Self, cast, overload, override
from anyio import (
BrokenResourceError,
CapacityLimiter,
ClosedResourceError,
EndOfStream,
@@ -20,35 +23,172 @@ from anyio.streams.memory import (
from anyio.streams.memory import (
MemoryObjectSendStream as AnyioSender,
)
from anyio.streams.memory import (
MemoryObjectStreamState,
)
from anyio.streams.memory import (
MemoryObjectStreamState as AnyioState,
)
@dataclass(eq=False)
class ErrorOverride:
closed_resource_error: type[ClosedResourceError] = field(
default=ClosedResourceError,
)
broken_resource_error: type[BrokenResourceError] = field(
default=BrokenResourceError,
)
end_of_stream: type[EndOfStream] = field(
default=EndOfStream,
)
would_block: type[WouldBlock] = field(
default=WouldBlock,
)
@overload
def patch[**P, R](
self,
fn: Callable[P, CoroutineType[Any, Any, R]],
/,
) -> Callable[P, CoroutineType[Any, Any, R]]: ...
@overload
def patch[**P, R](
self,
fn: Callable[P, R],
/,
) -> Callable[P, R]: ...
def patch[**P, R](self, fn: Callable[P, Any], /) -> Callable[P, Any]:
"""
Returns a function with all these exceptions replaced by their overrides
"""
if iscoroutinefunction(fn):
async_fn = cast(Callable[P, CoroutineType[Any, Any, R]], fn)
@wraps(async_fn)
async def async_wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
try:
return await async_fn(*args, **kwargs)
except ClosedResourceError as e:
self._raise_replace(self.closed_resource_error, e)
except BrokenResourceError as e:
self._raise_replace(self.broken_resource_error, e)
except EndOfStream as e:
self._raise_replace(self.end_of_stream, e)
except WouldBlock as e:
self._raise_replace(self.would_block, e)
return async_wrapper
else:
sync_fn = cast(Callable[P, R], fn)
@wraps(sync_fn)
def sync_wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
try:
return sync_fn(*args, **kwargs)
except ClosedResourceError as e:
self._raise_replace(self.closed_resource_error, e)
except BrokenResourceError as e:
self._raise_replace(self.broken_resource_error, e)
except EndOfStream as e:
self._raise_replace(self.end_of_stream, e)
except WouldBlock as e:
self._raise_replace(self.would_block, e)
return sync_wrapper
@staticmethod
def _raise_replace(replacement: type[BaseException], e: BaseException) -> NoReturn:
if isinstance(e, replacement):
raise
raise replacement() from e
class Sender[T](AnyioSender[T]):
def __init__(
self,
state: MemoryObjectStreamState[T],
error_override_config: ErrorOverride | None,
):
super().__init__(_state=state)
# patch the methods we want to override errors for
#
# NOTE: it is very important that new methods which are added,
# and which can throw, are patched in this block
if (e := error_override_config) is not None:
# new methods of this class
self.clone_receiver = e.patch(self.clone_receiver)
# overridden methods
self.clone = e.patch(self.clone)
# parent methods
self.send_nowait = e.patch(self.send_nowait)
self.send = e.patch(self.send)
self.close = e.patch(self.close)
self.aclose = e.patch(self.aclose)
self.statistics = e.patch(self.statistics)
self.err_config = error_override_config
@override
def clone(self) -> "Sender[T]":
if self._closed:
raise ClosedResourceError
return Sender(_state=self._state)
return Sender(self._state, self.err_config)
def clone_receiver(self) -> "Receiver[T]":
"""Constructs a Receiver using a Senders shared state - similar to calling Receiver.clone() without needing the receiver"""
if self._closed:
raise ClosedResourceError
return Receiver(_state=self._state)
return Receiver(self._state, self.err_config)
class Receiver[T](AnyioReceiver[T]):
def __init__(
self,
state: MemoryObjectStreamState[T],
error_override_config: ErrorOverride | None,
):
super().__init__(_state=state)
# patch the methods we want to override errors for
#
# NOTE: it is very important that new methods which are added,
# and which can throw, are patched in this block
if (e := error_override_config) is not None:
# new methods of this class
self.clone_sender = e.patch(self.clone_sender)
self.collect = e.patch(self.collect)
self.receive_at_least = e.patch(self.receive_at_least)
# overridden methods
self.clone = e.patch(self.clone)
# parent methods
self.receive_nowait = e.patch(self.receive_nowait)
self.receive = e.patch(self.receive)
self.close = e.patch(self.close)
self.aclose = e.patch(self.aclose)
self.statistics = e.patch(self.statistics)
self.err_config = error_override_config
@override
def clone(self) -> "Receiver[T]":
if self._closed:
raise ClosedResourceError
return Receiver(_state=self._state)
return Receiver(self._state, self.err_config)
def clone_sender(self) -> Sender[T]:
"""Constructs a Sender using a Receivers shared state - similar to calling Sender.clone() without needing the sender"""
if self._closed:
raise ClosedResourceError
return Sender(_state=self._state)
return Sender(self._state, self.err_config)
def collect(self) -> list[T]:
"""Collect all currently available items from this receiver"""
@@ -70,6 +210,7 @@ class Receiver[T](AnyioReceiver[T]):
out.extend(self.collect())
return out
@override
def __enter__(self) -> Self:
return self
@@ -285,11 +426,17 @@ class MpReceiver[T]:
class channel[T]: # noqa: N801
"""Create a pair of asynchronous channels for communicating within the same process"""
def __new__(cls, max_buffer_size: float = inf) -> tuple[Sender[T], Receiver[T]]:
def __new__(
cls,
max_buffer_size: float = inf,
error_override_config: ErrorOverride | None = None,
) -> tuple[Sender[T], Receiver[T]]:
if max_buffer_size != inf and not isinstance(max_buffer_size, int):
raise ValueError("max_buffer_size must be either an integer or math.inf")
state = AnyioState[T](max_buffer_size)
return Sender(_state=state), Receiver(_state=state)
return Sender(state, error_override_config), Receiver(
state, error_override_config
)
class mp_channel[T]: # noqa: N801
-28
View File
@@ -1,28 +0,0 @@
import os
import sys
_STDIN_FD = 0
_STDOUT_FD = 1
_STDERR_FD = 2
def detach_stdio_to_devnull() -> None:
"""Redirect process stdio file descriptors to /dev/null."""
for stream in (sys.stdout, sys.stderr, sys.__stdout__, sys.__stderr__):
if stream is not None:
stream.flush()
stdin_fd = os.open(os.devnull, os.O_RDONLY)
stdout_fd = os.open(os.devnull, os.O_WRONLY)
stderr_fd = os.open(os.devnull, os.O_WRONLY)
try:
# dup2 closes the target fd first, but leaves the source fd open.
os.dup2(stdin_fd, _STDIN_FD)
os.dup2(stdout_fd, _STDOUT_FD)
os.dup2(stderr_fd, _STDERR_FD)
finally:
for fd in (stdin_fd, stdout_fd, stderr_fd):
if fd not in (_STDIN_FD, _STDOUT_FD, _STDERR_FD):
os.close(fd)
@@ -630,6 +630,14 @@ class InfoGatherer:
f"MacMon failed with return code {e.returncode}: {stderr_msg}"
)
self._tg.start_soon(self._monitor_memory_usage, 1)
except ProcessLookupError:
# usually throws by the process' context manager on exit
# when we ctrl+c, hence usually should be ignored;
# if anything else throws it, we explicitly don't care:
# process is dead anyways ;)
logger.warning(
"Macmon process not found - shutting down macmon monitor"
)
except Exception as e:
logger.opt(exception=e).warning("Error in macmon monitor")
self._tg.start_soon(self._monitor_memory_usage, 1)
-28
View File
@@ -1,28 +0,0 @@
from __future__ import annotations
import os
from typing import Final
from exo_pyo3_bindings import Pidfile, PidfileError
from exo.shared.constants import EXO_PID_FILE
_PIDFILE_MODE: Final = 0o600
class PidfileLockError(RuntimeError):
pass
def acquire_exo_pidfile() -> Pidfile:
path = EXO_PID_FILE
os.makedirs(os.path.dirname(path), exist_ok=True)
try:
pidfile = Pidfile(path, _PIDFILE_MODE)
pidfile.write()
except (OSError, PidfileError) as exception:
raise PidfileLockError(
f"Failed to acquire EXO pidfile at {path}: {exception}"
) from exception
return pidfile
+122
View File
@@ -24,6 +24,7 @@ class PowerSampler:
] = defaultdict(list)
self._start_time: float | None = None
self._stopped = False
self._prefill_done_at: float | None = None
def _take_sample(self, t_rel: float | None = None) -> None:
assert self._start_time is not None
@@ -38,14 +39,35 @@ class PowerSampler:
await anyio.sleep(self._interval)
self._take_sample()
def mark_prefill_done(self) -> None:
"""Anchor the prefill→generation boundary on a fresh sample.
Idempotent. Safe to call before `run()`; boundary then lands at t=0.
"""
if self._prefill_done_at is not None:
return
if self._start_time is None:
self._prefill_done_at = 0.0
return
t_rel = time.perf_counter() - self._start_time
self._take_sample(t_rel=t_rel)
self._prefill_done_at = t_rel
def result(self) -> PowerUsage:
self._stopped = True
assert self._start_time is not None, "result() called before run()"
elapsed = time.perf_counter() - self._start_time
self._take_sample(t_rel=elapsed)
# Clamp the split point to [0, elapsed] in case timing is weird (e.g.
# mark called after result, or sampler ran for < the prefill window).
split = self._prefill_done_at
if split is not None:
split = max(0.0, min(elapsed, split))
node_stats: list[NodePowerStats] = []
total_energy_j = 0.0
total_prefill_energy_j = 0.0
total_generation_energy_j = 0.0
for node_id, ts_profiles in self._samples.items():
n = len(ts_profiles)
if n == 0:
@@ -53,20 +75,68 @@ class PowerSampler:
node_energy_j = trapezoidal_energy(ts_profiles, elapsed)
avg_power_w = node_energy_j / elapsed if elapsed > 0 else 0.0
total_energy_j += node_energy_j
prefill_e: float | None = None
generation_e: float | None = None
prefill_avg: float | None = None
generation_avg: float | None = None
if split is not None:
prefill_e = trapezoidal_energy_range(ts_profiles, 0.0, split)
generation_e = trapezoidal_energy_range(ts_profiles, split, elapsed)
total_prefill_energy_j += prefill_e
total_generation_energy_j += generation_e
prefill_dt = split
generation_dt = elapsed - split
prefill_avg = prefill_e / prefill_dt if prefill_dt > 0 else 0.0
generation_avg = (
generation_e / generation_dt if generation_dt > 0 else 0.0
)
node_stats.append(
NodePowerStats(
node_id=node_id,
samples=n,
avg_sys_power=avg_power_w,
prefill_avg_sys_power=prefill_avg,
generation_avg_sys_power=generation_avg,
prefill_energy_joules=prefill_e,
generation_energy_joules=generation_e,
)
)
total_avg_sys_w = total_energy_j / elapsed if elapsed > 0 else 0.0
prefill_seconds: float | None = None
generation_seconds: float | None = None
prefill_energy_joules: float | None = None
generation_energy_joules: float | None = None
prefill_avg_w: float | None = None
generation_avg_w: float | None = None
if split is not None:
prefill_seconds = split
generation_seconds = elapsed - split
prefill_energy_joules = total_prefill_energy_j
generation_energy_joules = total_generation_energy_j
prefill_avg_w = (
total_prefill_energy_j / prefill_seconds if prefill_seconds > 0 else 0.0
)
generation_avg_w = (
total_generation_energy_j / generation_seconds
if generation_seconds > 0
else 0.0
)
return PowerUsage(
elapsed_seconds=elapsed,
nodes=node_stats,
total_avg_sys_power_watts=total_avg_sys_w,
total_energy_joules=total_energy_j,
prefill_seconds=prefill_seconds,
generation_seconds=generation_seconds,
prefill_energy_joules=prefill_energy_joules,
generation_energy_joules=generation_energy_joules,
prefill_avg_sys_power_watts=prefill_avg_w,
generation_avg_sys_power_watts=generation_avg_w,
)
@@ -89,3 +159,55 @@ def trapezoidal_energy(
continue
energy_j += (p_prev.sys_power + p_cur.sys_power) / 2.0 * dt
return energy_j
def trapezoidal_energy_range(
ts_profiles: list[tuple[float, SystemPerformanceProfile]],
t_start: float,
t_end: float,
) -> float:
"""Integrate sys_power(t) over [t_start, t_end] using the trapezoidal rule.
Linearly interpolates power at the endpoints when they fall between
existing samples, so callers can integrate over arbitrary sub-windows
(e.g. the prefill segment) without losing accuracy. Returns 0 for an
empty or zero-length window. Falls back to constant-power assumption
when only one sample exists.
"""
if t_end <= t_start:
return 0.0
if len(ts_profiles) == 0:
return 0.0
if len(ts_profiles) == 1:
return ts_profiles[0][1].sys_power * (t_end - t_start)
def power_at(t: float) -> float:
if t <= ts_profiles[0][0]:
return ts_profiles[0][1].sys_power
if t >= ts_profiles[-1][0]:
return ts_profiles[-1][1].sys_power
for i in range(1, len(ts_profiles)):
t_cur, p_cur = ts_profiles[i]
if t_cur >= t:
t_prev, p_prev = ts_profiles[i - 1]
span = t_cur - t_prev
if span <= 0:
return p_cur.sys_power
frac = (t - t_prev) / span
return p_prev.sys_power + frac * (p_cur.sys_power - p_prev.sys_power)
return ts_profiles[-1][1].sys_power
p_start = power_at(t_start)
p_end = power_at(t_end)
in_range: list[tuple[float, float]] = [
(t, profile.sys_power) for t, profile in ts_profiles if t_start < t < t_end
]
seq: list[tuple[float, float]] = [(t_start, p_start)] + in_range + [(t_end, p_end)]
energy_j = 0.0
for i in range(1, len(seq)):
dt = seq[i][0] - seq[i - 1][0]
if dt <= 0:
continue
energy_j += (seq[i - 1][1] + seq[i][1]) / 2.0 * dt
return energy_j
+121
View File
@@ -0,0 +1,121 @@
import multiprocessing as mp
import time
import pytest
from anyio import (
BrokenResourceError,
ClosedResourceError,
EndOfStream,
WouldBlock,
fail_after,
)
from loguru import logger
from exo.utils.channels import ErrorOverride, MpReceiver, MpSender, channel, mp_channel
class CustomClosedResourceError(ClosedResourceError):
pass
class CustomBrokenResourceError(BrokenResourceError):
pass
class CustomEndOfStream(EndOfStream):
pass
class CustomWouldBlock(WouldBlock):
pass
ERROR_OVERRIDE = ErrorOverride(
closed_resource_error=CustomClosedResourceError,
broken_resource_error=CustomBrokenResourceError,
end_of_stream=CustomEndOfStream,
would_block=CustomWouldBlock,
)
def foo(recv: MpReceiver[str]):
expected = ["hi", "hi 2", "bye"]
with recv as r:
for item in r:
assert item == expected.pop(0)
def bar(send: MpSender[str]):
logger.warning("hi")
send.send("hi")
time.sleep(0.1)
logger.warning("hi 2")
send.send("hi 2")
time.sleep(0.1)
logger.warning("bye")
send.send("bye")
time.sleep(0.1)
send.close()
@pytest.mark.anyio
async def test_channel_ipc():
with fail_after(0.5):
s, r = mp_channel[str]()
p1 = mp.Process(target=foo, args=(r,))
p2 = mp.Process(target=bar, args=(s,))
p1.start()
p2.start()
p1.join()
p2.join()
def test_channel_error_override_replaces_sync_errors_with_subclasses():
send, recv = channel[int](0, error_override_config=ERROR_OVERRIDE)
with pytest.raises(CustomWouldBlock) as would_block_info:
send.send_nowait(1)
assert type(would_block_info.value.__cause__) is WouldBlock
recv.close()
with pytest.raises(CustomBrokenResourceError) as broken_resource_info:
send.send_nowait(1)
assert type(broken_resource_info.value.__cause__) is BrokenResourceError
send.close()
with pytest.raises(CustomClosedResourceError) as closed_resource_info:
send.send_nowait(1)
assert type(closed_resource_info.value.__cause__) is ClosedResourceError
@pytest.mark.anyio
async def test_channel_error_override_replaces_async_errors_with_subclasses():
send, recv = channel[int](0, error_override_config=ERROR_OVERRIDE)
recv.close()
with pytest.raises(CustomBrokenResourceError) as broken_resource_info:
await send.send(1)
assert type(broken_resource_info.value.__cause__) is BrokenResourceError
send, recv = channel[int](error_override_config=ERROR_OVERRIDE)
send.close()
with pytest.raises(CustomEndOfStream) as end_of_stream_info:
await recv.receive()
assert type(end_of_stream_info.value.__cause__) is EndOfStream
@pytest.mark.anyio
async def test_channel_error_override_is_preserved_by_clones():
send, recv = channel[int](0, error_override_config=ERROR_OVERRIDE)
send_clone = send.clone()
recv.close()
with pytest.raises(CustomBrokenResourceError):
await send_clone.send(1)
send, recv = channel[int](0, error_override_config=ERROR_OVERRIDE)
cloned_send = recv.clone_sender()
recv.close()
with pytest.raises(CustomBrokenResourceError):
await cloned_send.send(1)
-168
View File
@@ -1,168 +0,0 @@
import contextlib
import os
from collections.abc import AsyncIterator
import anyio
import pytest
from anyio import EndOfStream, create_task_group, fail_after
from exo.utils.async_process import AsyncProcess
from exo.utils.channels import MpReceiver, MpSender, Receiver, mp_channel
from exo.utils.daemon import detach_stdio_to_devnull
def _write_before_and_after_detach() -> None:
os.write(1, b"before stdout\n")
os.write(2, b"before stderr\n")
detach_stdio_to_devnull()
os.write(1, b"after stdout\n")
os.write(2, b"after stderr\n")
def _write_grandchild_stdio(label: str) -> None:
os.write(1, f"{label} stdout\n".encode())
os.write(2, f"{label} stderr\n".encode())
async def _spawn_grandchild_and_report(
result_sender: MpSender[tuple[int, bytes, bytes]],
label: str,
) -> None:
result_sender.send(await _collect_spawned_child(label))
result_sender.close()
async def _collect_spawned_child(label: str) -> tuple[int, bytes, bytes]:
process = AsyncProcess(_write_grandchild_stdio, args=(label,))
async with _started_process(process):
return await _collect_process_output(process)
def _detach_stdio_then_spawn_captured_child(
result_sender: MpSender[tuple[int, bytes, bytes]],
) -> None:
detach_stdio_to_devnull()
anyio.run(_spawn_grandchild_and_report, result_sender, "grandchild")
def _detach_stdio_then_spawn_captured_children_sequentially(
result_sender: MpSender[list[tuple[int, bytes, bytes]]],
) -> None:
async def run_children() -> list[tuple[int, bytes, bytes]]:
results: list[tuple[int, bytes, bytes]] = []
for index in range(5):
results.append(await _collect_spawned_child(f"grandchild-{index}"))
return results
detach_stdio_to_devnull()
result_sender.send(anyio.run(run_children))
result_sender.close()
async def _collect_stream(stream: Receiver[bytes], output: bytearray) -> None:
while True:
try:
output.extend(await stream.receive())
except EndOfStream:
return
async def _collect_process_output(
process: AsyncProcess,
) -> tuple[int, bytes, bytes]:
stdout = bytearray()
stderr = bytearray()
exitcodes: list[int] = []
async with create_task_group() as collect_group:
collect_group.start_soon(_collect_stream, process.stdout, stdout)
collect_group.start_soon(_collect_stream, process.stderr, stderr)
exitcodes.append(await process.wait())
if not exitcodes:
raise RuntimeError("process exited without a return code")
return exitcodes[0], bytes(stdout), bytes(stderr)
@contextlib.asynccontextmanager
async def _started_process(process: AsyncProcess) -> AsyncIterator[None]:
async with create_task_group() as task_group:
await task_group.start(process.run)
try:
yield
finally:
await process.stop()
async def _run_process_and_receive[T](
process: AsyncProcess,
recv: MpReceiver[T],
*,
timeout: float,
) -> tuple[int, T]:
async with _started_process(process):
with fail_after(timeout):
result = await recv.receive_async()
exitcode = await process.wait()
return exitcode, result
@pytest.mark.anyio
async def test_detach_stdio_to_devnull_redirects_stdio_away_from_capture() -> None:
process = AsyncProcess(_write_before_and_after_detach)
async with _started_process(process):
exitcode, stdout, stderr = await _collect_process_output(process)
assert exitcode == 0
assert stdout == b"before stdout\n"
assert stderr == b"before stderr\n"
@pytest.mark.anyio
async def test_detached_stdio_process_can_spawn_and_capture_child_stdio() -> None:
send, recv = mp_channel[tuple[int, bytes, bytes]]()
process = AsyncProcess(_detach_stdio_then_spawn_captured_child, args=(send,))
try:
daemonized_parent_exitcode, result = await _run_process_and_receive(
process, recv, timeout=5
)
finally:
recv.close()
child_exitcode, child_stdout, child_stderr = result
assert daemonized_parent_exitcode == 0
assert child_exitcode == 0
assert child_stdout == b"grandchild stdout\n"
assert child_stderr == b"grandchild stderr\n"
@pytest.mark.anyio
async def test_detached_stdio_process_can_spawn_captured_children_sequentially() -> (
None
):
send, recv = mp_channel[list[tuple[int, bytes, bytes]]]()
process = AsyncProcess(
_detach_stdio_then_spawn_captured_children_sequentially,
args=(send,),
)
try:
daemonized_parent_exitcode, results = await _run_process_and_receive(
process, recv, timeout=10
)
finally:
recv.close()
assert daemonized_parent_exitcode == 0
assert results == [
(
0,
f"grandchild-{index} stdout\n".encode(),
f"grandchild-{index} stderr\n".encode(),
)
for index in range(5)
]
-40
View File
@@ -1,40 +0,0 @@
import multiprocessing as mp
import time
import pytest
from anyio import fail_after
from loguru import logger
from exo.utils.channels import MpReceiver, MpSender, mp_channel
def foo(recv: MpReceiver[str]):
expected = ["hi", "hi 2", "bye"]
with recv as r:
for item in r:
assert item == expected.pop(0)
def bar(send: MpSender[str]):
logger.warning("hi")
send.send("hi")
time.sleep(0.1)
logger.warning("hi 2")
send.send("hi 2")
time.sleep(0.1)
logger.warning("bye")
send.send("bye")
time.sleep(0.1)
send.close()
@pytest.mark.anyio
async def test_channel_ipc():
with fail_after(0.5):
s, r = mp_channel[str]()
p1 = mp.Process(target=foo, args=(r,))
p2 = mp.Process(target=bar, args=(s,))
p1.start()
p2.start()
p1.join()
p2.join()
+14 -24
View File
@@ -8,36 +8,28 @@ import textwrap
from pathlib import Path
from typing import Final
import pytest
import exo.utils.pidfile as pidfile
from exo.utils.pidfile import acquire_exo_pidfile
from exo_rs import Pidfile
_CHILD_ACQUIRE_PIDFILE_SCRIPT: Final = textwrap.dedent(
"""
import sys
from pathlib import Path
from unittest.mock import patch
import exo.utils.pidfile as pidfile
from exo.utils.pidfile import PidfileLockError, acquire_exo_pidfile
from exo_rs import Pidfile, PidfileError
with patch.object(pidfile, "EXO_PID_FILE", Path(sys.argv[1])):
try:
handle = acquire_exo_pidfile()
except PidfileLockError as exception:
print(str(exception))
raise SystemExit(73) from exception
path = Path(sys.argv[1])
try:
handle = Pidfile(path, 0o0600)
handle.write()
except (OSError, PidfileError) as exception:
print(f"Failed to acquire EXO pidfile at {path}: {exception}")
raise SystemExit(73) from exception
del handle
del handle
"""
)
def _use_pidfile_path(monkeypatch: pytest.MonkeyPatch, path: Path) -> None:
monkeypatch.setattr(pidfile, "EXO_PID_FILE", path)
def _run_child_acquire_pidfile(path: Path) -> subprocess.CompletedProcess[str]:
return subprocess.run(
[sys.executable, "-c", _CHILD_ACQUIRE_PIDFILE_SCRIPT, str(path)],
@@ -49,12 +41,11 @@ def _run_child_acquire_pidfile(path: Path) -> subprocess.CompletedProcess[str]:
def test_acquire_exo_pidfile_writes_current_pid_and_removes_on_drop(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
path = tmp_path / "exo.pid"
_use_pidfile_path(monkeypatch, path)
handle = acquire_exo_pidfile()
handle = Pidfile(path, 0o0600)
handle.write()
assert path.read_text() == str(os.getpid())
del handle
@@ -65,12 +56,11 @@ def test_acquire_exo_pidfile_writes_current_pid_and_removes_on_drop(
def test_acquire_exo_pidfile_rejects_second_process(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
path = tmp_path / "exo.pid"
_use_pidfile_path(monkeypatch, path)
handle = acquire_exo_pidfile()
handle = Pidfile(path, 0o0600)
handle.write()
try:
blocked_child = _run_child_acquire_pidfile(path)
assert blocked_child.returncode == 73
+136
View File
@@ -141,6 +141,142 @@ def test_trapezoidal_unit_single_sample() -> None:
assert trapezoidal_energy(samples, elapsed=3.0) == 42.0 * 3.0
def test_trapezoidal_range_interpolation() -> None:
"""Sub-window integration should linearly interpolate at the boundaries."""
from exo.utils.power_sampler import trapezoidal_energy_range
# Two samples: t=0 W=10, t=10 W=20 -> power(t) = 10 + t
samples = [
(0.0, _make_profile(10.0)),
(10.0, _make_profile(20.0)),
]
# Integral from t=4 to t=6: power goes 14 -> 16, mean 15, dt=2 -> 30 J
assert abs(trapezoidal_energy_range(samples, 4.0, 6.0) - 30.0) < 1e-9
# Integral over the full window matches the full trapezoidal integral.
full = trapezoidal_energy_range(samples, 0.0, 10.0)
assert abs(full - 150.0) < 1e-9
def test_trapezoidal_range_zero_window() -> None:
"""Zero-length or reversed windows integrate to zero."""
from exo.utils.power_sampler import trapezoidal_energy_range
samples = [(0.0, _make_profile(10.0)), (5.0, _make_profile(20.0))]
assert trapezoidal_energy_range(samples, 3.0, 3.0) == 0.0
assert trapezoidal_energy_range(samples, 5.0, 3.0) == 0.0
def test_trapezoidal_range_splits_sum_to_full() -> None:
"""Energy split at an arbitrary boundary should sum back to the full integral."""
from exo.utils.power_sampler import (
trapezoidal_energy,
trapezoidal_energy_range,
)
samples = [
(0.0, _make_profile(10.0)),
(1.0, _make_profile(20.0)),
(3.0, _make_profile(15.0)),
(5.0, _make_profile(25.0)),
]
full = trapezoidal_energy(samples, elapsed=5.0)
# Split at t=2.5 (between samples) — interpolation should be exact.
left = trapezoidal_energy_range(samples, 0.0, 2.5)
right = trapezoidal_energy_range(samples, 2.5, 5.0)
assert abs((left + right) - full) < 1e-9
async def test_prefill_generation_split() -> None:
"""When mark_prefill_done() is called, the result should split energy."""
state: dict[NodeId, SystemPerformanceProfile] = {
NODE_A: _make_profile(10.0),
}
sampler = PowerSampler(get_node_system=lambda: state, interval=0.02)
async with anyio.create_task_group() as tg:
tg.start_soon(sampler.run)
# "Prefill" phase: power = 10 W
await anyio.sleep(0.1)
# Mark the boundary BEFORE changing state — this matches what
# _collect_text_generation_with_stats does in production: the mark
# fires on the first non-prefill chunk, so the boundary sample is
# the genuine end-of-prefill reading rather than the new phase's.
sampler.mark_prefill_done()
state[NODE_A] = _make_profile(30.0)
# "Generation" phase: power = 30 W
await anyio.sleep(0.1)
tg.cancel_scope.cancel()
result = sampler.result()
assert result.prefill_seconds is not None
assert result.generation_seconds is not None
assert result.prefill_energy_joules is not None
assert result.generation_energy_joules is not None
assert result.prefill_avg_sys_power_watts is not None
assert result.generation_avg_sys_power_watts is not None
# Phase durations should sum to the elapsed seconds.
assert (
abs(
(result.prefill_seconds + result.generation_seconds)
- result.elapsed_seconds
)
< 1e-6
)
# Phase energies should sum to (approximately) the total.
assert (
abs(
(result.prefill_energy_joules + result.generation_energy_joules)
- result.total_energy_joules
)
< 1e-6
)
# With the boundary sample anchored at the genuine end-of-prefill (10 W),
# prefill avg should converge tightly on 10 W and generation on 30 W.
# 15 W cleanly separates the two and would catch any cross-contamination.
assert result.prefill_avg_sys_power_watts < 15.0
assert result.generation_avg_sys_power_watts > 15.0
assert result.nodes[0].prefill_avg_sys_power is not None
assert result.nodes[0].generation_avg_sys_power is not None
async def test_no_split_when_unmarked() -> None:
"""If mark_prefill_done() is never called, phase fields stay None."""
state: dict[NodeId, SystemPerformanceProfile] = {
NODE_A: _make_profile(10.0),
}
sampler = PowerSampler(get_node_system=lambda: state, interval=0.02)
async with anyio.create_task_group() as tg:
tg.start_soon(sampler.run)
await anyio.sleep(0.05)
tg.cancel_scope.cancel()
result = sampler.result()
assert result.prefill_seconds is None
assert result.generation_seconds is None
assert result.prefill_energy_joules is None
assert result.generation_energy_joules is None
assert result.nodes[0].prefill_energy_joules is None
assert result.nodes[0].generation_energy_joules is None
async def test_mark_prefill_done_is_idempotent() -> None:
"""Only the first call to mark_prefill_done() should take effect."""
state: dict[NodeId, SystemPerformanceProfile] = {
NODE_A: _make_profile(10.0),
}
sampler = PowerSampler(get_node_system=lambda: state, interval=0.02)
async with anyio.create_task_group() as tg:
tg.start_soon(sampler.run)
await anyio.sleep(0.05)
sampler.mark_prefill_done()
first_prefill_at = sampler._prefill_done_at # pyright: ignore[reportPrivateUsage]
await anyio.sleep(0.05)
sampler.mark_prefill_done()
assert sampler._prefill_done_at == first_prefill_at # pyright: ignore[reportPrivateUsage]
tg.cancel_scope.cancel()
async def test_result_stops_sampling() -> None:
"""Calling result() should stop the sampler's run loop."""
state: dict[NodeId, SystemPerformanceProfile] = {
+45 -2
View File
@@ -229,6 +229,47 @@ def has_non_kv_caches(cache: KVCacheType) -> bool:
return any(is_non_trimmable_cache_entry(c) for c in cache)
# Max snapshots retained per cache entry. Each CacheSnapshot pins detached GPU
# copies of every non-trimmable (SSM/ArraysCache, RotatingKVCache) layer, so
# retaining one per ~4096-token prefill chunk makes snapshot memory grow linearly
# with context — the dominant residual cost when a single entry is grown to long
# contexts on hybrid models (~56 MB/snapshot on Qwen3.5-122B, so a full 256K
# context = 64 snapshots ≈ 3.6 GB). A sliding window of the most-recent N caps
# this at N×per-snapshot (~0.9 GB here) while preserving the restore points
# in-place grows actually use (they always extend from the tip).
_MAX_RETAINED_SNAPSHOTS = 16
def _bounded_snapshots(snapshots: list[CacheSnapshot]) -> list[CacheSnapshot]:
"""Deduplicate snapshots by token position and bound the retained count.
Returned list is sorted ascending by ``token_count``.
"""
# Deduplicate by position, keeping the most-recently-appended snapshot per
# position. Repeated in-place grows re-snapshot positions the kept old
# snapshots already cover, which would otherwise grow `_snapshots`
# unbounded even at constant context.
# TODO: keying on token_count alone is safe only while a position uniquely
# identifies the prefix within an entry (grows are strict prefix-extensions).
# If edit-and-regenerate, sliding-window/prefix trimming, cross-entry
# snapshot sharing, per-request adapter/LoRA swap, or branchy decoding
# (beam/parallel/speculative) is added, enrich the key to
# (token_count, prefix_hash[, media/adapter id]) — else a stale snapshot
# could be restored for a different prefix (silent wrong output).
by_position: dict[int, CacheSnapshot] = {}
for snapshot in snapshots:
by_position[snapshot.token_count] = snapshot
deduped = [by_position[pos] for pos in sorted(by_position)]
# Sliding window: keep only the most-recent N positions. In-place grows
# always extend from the tip, so the newest snapshots are the ones future
# grows restore from — dropping the oldest is never incorrect: a later hit on
# a prefix older than the window finds no snapshot <= target, so get_kv_cache
# returns a fresh cache (matched_index=None) and the request takes a full cold
# prefill — correct, just slower than a partial-hit reuse for that one request.
return deduped[-_MAX_RETAINED_SNAPSHOTS:]
class KVPrefixCache:
def __init__(self, group: mx.distributed.Group | None):
self.prompts: list[mx.array] = [] # mx array of tokens (ints)
@@ -261,7 +302,9 @@ class KVPrefixCache:
self._evict_if_needed()
self.prompts.append(prompt_tokens)
self.caches.append(deepcopy(cache))
self._snapshots.append(ssm_snapshots)
self._snapshots.append(
_bounded_snapshots(ssm_snapshots) if ssm_snapshots else None
)
self._media_regions.append(media_regions or [])
self.prefill_tps.append(prefill_tps)
self._access_counter += 1
@@ -288,7 +331,7 @@ class KVPrefixCache:
self.prompts[index] = prompt_tokens
self.caches[index] = deepcopy(cache)
self._snapshots[index] = merged or None
self._snapshots[index] = _bounded_snapshots(merged) or None
self._media_regions[index] = media_regions or []
self.prefill_tps[index] = prefill_tps
self._access_counter += 1
+7 -1
View File
@@ -8,6 +8,10 @@ from loguru import logger
from exo.api.types import ImageEditsTaskParams
from exo.download.download_utils import is_read_only_model_dir, resolve_existing_model
from exo.routing.event_router import (
EventRouterBrokenResourceError,
EventRouterClosedResourceError,
)
from exo.shared.apply import apply
from exo.shared.constants import EXO_MAX_INSTANCE_RETRIES
from exo.shared.models.model_cards import ModelId, card_cache
@@ -109,7 +113,9 @@ class Worker:
tg.start_soon(self._event_applier)
tg.start_soon(self._poll_connection_updates)
tg.start_soon(self._reconcile_custom_cards)
except* (EventRouterBrokenResourceError, EventRouterClosedResourceError):
# Event router has been closed (try-star syntax handles error groups)
pass
finally:
# Actual shutdown code - waits for all tasks to complete before executing.
logger.info("Stopping Worker")
@@ -11,6 +11,7 @@ from mlx_lm.sample_utils import make_sampler
from exo.shared.types.common import ModelId
from exo.shared.types.text_generation import InputMessage, TextGenerationTaskParams
from exo.worker.engines.mlx.cache import (
CacheSnapshot,
KVPrefixCache,
cache_length,
encode_prompt,
@@ -77,6 +78,74 @@ class TestGetPrefixLength:
assert get_prefix_length(a, b) == 0
class TestSnapshotAccumulation:
"""Locks in the fix for the actual per-grow Metal leak on hybrid (SSM)
models: `update_kv_cache` must not let `_snapshots` grow without bound when
the same entry is grown in place many times."""
def test_repeated_update_does_not_accumulate_snapshots(self):
with patch(
"exo.worker.engines.mlx.cache.get_memory_used_percentage",
return_value=0.0,
):
kv_prefix_cache = KVPrefixCache(None)
initial = [
CacheSnapshot(states=[None], token_count=4096),
CacheSnapshot(states=[None], token_count=8192),
]
kv_prefix_cache.add_kv_cache(
mx.arange(10000), [KVCache()], ssm_snapshots=initial
)
# Each in-place grow re-prefills from restore_pos and produces a
# fresh snapshot at a position the retained old snapshots already
# cover. Pre-fix this appended one snapshot per grow forever.
for _ in range(50):
fresh = [CacheSnapshot(states=[None], token_count=8192)]
kv_prefix_cache.update_kv_cache(
0, mx.arange(10000), [KVCache()], fresh, restore_pos=8192
)
stored = kv_prefix_cache._snapshots[0]
assert stored is not None
# Bounded by the number of distinct snapshot positions (here 2),
# not by the 50 grows.
assert len(stored) == 2
assert sorted(s.token_count for s in stored) == [4096, 8192]
# The kept 8192 snapshot must be the most recently supplied one.
assert stored[1] is fresh[0]
def test_extension_caps_snapshots_to_sliding_window(self):
"""Extending a single entry to a long context (one snapshot per ~4096
tokens) must cap retained snapshots to a sliding window of the most-recent
N, not keep all of them that linear-in-context retention was the
residual OOM cause."""
from exo.worker.engines.mlx.cache import _MAX_RETAINED_SNAPSHOTS
with patch(
"exo.worker.engines.mlx.cache.get_memory_used_percentage",
return_value=0.0,
):
kv_prefix_cache = KVPrefixCache(None)
# 64 distinct positions = a 262144-token context at 4096/chunk.
num_positions = 64
snaps = [
CacheSnapshot(states=[None], token_count=4096 * (i + 1))
for i in range(num_positions)
]
kv_prefix_cache.add_kv_cache(
mx.arange(10), [KVCache()], ssm_snapshots=snaps
)
stored = kv_prefix_cache._snapshots[0]
assert stored is not None
# Capped at the window; the most-recent N positions are retained
# (in-place grows extend from the tip, so these are what get used).
assert len(stored) == _MAX_RETAINED_SNAPSHOTS
assert stored == snaps[-_MAX_RETAINED_SNAPSHOTS:]
assert stored[-1] is snaps[-1] # tip always kept
class TestKVPrefix:
@pytest.fixture
def mock_tokenizer(self):
+27 -3
View File
@@ -7,6 +7,9 @@ set -uo pipefail
HOST="${1:-localhost:52415}"
MODEL_ID="KevTheHermit/security-testing"
ENCODED_MODEL_ID=$(
python3 -c 'import sys, urllib.parse; print(urllib.parse.quote(sys.argv[1], safe=""))' "$MODEL_ID"
)
CUSTOM_CARDS_DIR="$HOME/.exo/custom_model_cards"
CARD_FILE="$CUSTOM_CARDS_DIR/KevTheHermit--security-testing.toml"
@@ -71,9 +74,30 @@ PLACE_BODY=$(echo "$PLACE_RESPONSE" | sed '$d')
echo " HTTP $PLACE_CODE"
echo " Response: $PLACE_BODY"
# Step 3b: Send a chat completion to actually trigger tokenizer loading
if [ "$PLACE_CODE" -ge 400 ]; then
echo " Placement failed; cannot trigger tokenizer loading."
exit 1
fi
# Step 3b: Wait for placement to materialize before inference.
echo ""
echo "[3b] Sending chat completion to trigger tokenizer load ..."
echo "[3b] Waiting for placed instance ..."
if ! AWAIT_RESPONSE=$(curl -fsS --max-time 65 \
"http://$HOST/instance/await?model_id=$ENCODED_MODEL_ID&timeout_seconds=60" |
awk '/^data: / { sub(/^data: /, ""); print; exit }'); then
echo " Timed out waiting for an instance for $MODEL_ID"
exit 1
fi
if ! printf '%s' "$AWAIT_RESPONSE" | grep -q '"type":"ready"'; then
echo " Timed out waiting for an instance for $MODEL_ID"
exit 1
fi
echo " Instance ready"
# Step 3c: Send a chat completion to actually trigger tokenizer loading
echo ""
echo "[3c] Sending chat completion to trigger tokenizer load ..."
CHAT_RESPONSE=$(curl -s -w "\n%{http_code}" --max-time 30 -X POST "http://$HOST/v1/chat/completions" \
-H "Content-Type: application/json" \
-d "{\"model\":\"$MODEL_ID\",\"messages\":[{\"role\":\"user\",\"content\":\"hello\"}],\"max_tokens\":1}")
@@ -82,7 +106,7 @@ CHAT_BODY=$(echo "$CHAT_RESPONSE" | sed '$d')
echo " HTTP $CHAT_CODE"
echo " Response: $CHAT_BODY"
echo ""
echo "[3c] Checking for RCE proof ..."
echo "[3d] Checking for RCE proof ..."
sleep 5
if [ -f /tmp/exo-rce-proof.txt ]; then
echo " VULNERABLE: Remote code executed!"
Generated
+804 -854
View File
File diff suppressed because it is too large. Load diff