Compare commits

19 Commits
Author SHA1 Message Date
kyle 2e85154db5 add NASA handbook 1-pager
CI / msrv (push) Successful in 45s
CI / ci (push) Successful in 2m27s
2026-10-26 22:14:02 -07:00
kyle 76263ccbf0 docs: AGENTS.md — bus crate layout, standalone gui, decision C in core constraints
CI / msrv (push) Successful in 1m5s
CI / ci (push) Successful in 2m55s
2026-10-25 17:21:31 -07:00
kyle e40f0d5052 fix(gui): cap ipc_client frame reads at the service's 1 MiB frame cap
A torn stream could yield an arbitrary u32 length prefix, turning a
re-dial into a multi-GiB allocation attempt. Mirror the service reader:
len > MAX_FRAME is InvalidData, so the worker treats it as a dead
connection and re-dials instead of OOMing.
2026-10-25 01:53:28 -07:00
kyle 6c6729324e docs: fleet console, bus transport, widened IPC 2026-10-25 01:24:38 -07:00
kyle 2a917088f7 feat(gui): node window (status strip, observe, belief, store, events, container) 2026-10-25 01:05:37 -07:00
kyle 97ea5f8f12 feat(gui): launcher window (create fleet, node controls, actions) 2026-10-25 00:36:41 -07:00
kyle 2be008e4b0 feat(gui): docker orchestration and fleet supervision (bus daemon) 2026-10-25 00:08:57 -07:00
kyle 01cd416bf0 chore(gui): ignore the standalone crate's target dir 2026-10-24 23:56:48 -07:00
kyle ca4ec4c5dd feat(gui): tines-gui skeleton (ipc client, config gen, theme) 2026-10-24 23:56:43 -07:00
kyle 7b30550454 feat(docker): runtime image for the tines service 2026-10-24 23:16:07 -07:00
kyle e275967e6f feat(host): service transport choice (bt|bus) and lib-level config 2026-10-24 23:13:31 -07:00
kyle ee87808b05 feat(bus): tines-bus transport and daemon over a host IPC bus 2026-10-24 23:00:37 -07:00
kyle 01373d809d feat(host): widen service IPC — addressed observe, List, contact/drop events
New public names: ContactOut, DropOut, ObservationOut, LIST_CAP.
2026-10-24 22:36:55 -07:00
kyle c1cad1661c feat(core): emit Contact/Lost NodeEvents (decision C) 2026-10-24 22:28:08 -07:00
kyle 0a8eda3077 docs(superpowers): fleet console design doc and implementation plan 2026-10-24 22:23:17 -07:00
kyleandClaude Opus 5.5 a4be737f17 ci: pin cargo-semver-checks by version and checksum
CI / msrv (push) Successful in 52s
CI / ci (push) Successful in 1m57s
Download the v0.51.0 release binary directly and verify its SHA-256 instead
of installing whatever cargo-binstall and cargo-semver-checks are latest,
matching the SHA-pinned actions elsewhere in the workflows.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 11:44:20 -07:00
kyle 7f66884ab5 update tines-ros2 keywords 2026-10-22 10:20:30 -07:00
kyle 2a4cc82336 update tines keywords 2026-10-22 10:20:03 -07:00
kyle 09ca4e19e1 ci: gate tines-core 1.x changes against the published 1.0.0
CI / msrv (push) Successful in 53s
CI / ci (push) Failing after 13m24s
cargo-semver-checks with a pinned 1.0.0 baseline in scripts/ci.sh (skipped
where the tool is absent); the GitHub Actions ci job installs a prebuilt
binary so the gate is enforced there.
2026-10-22 09:19:47 -07:00
51 changed files with 10372 additions and 176 deletions
+1
View File
@@ -3,3 +3,4 @@
.superpowers
docs
HANDOFF.md
tines-gui
+14
View File
@@ -42,6 +42,20 @@ jobs:
sudo apt-get update
sudo apt-get install -y --no-install-recommends libdbus-1-dev pkg-config
# The semver gate in scripts/ci.sh compares tines-core against the
# published 1.0.0; a prebuilt binary keeps this fast (a from-source
# install would dominate the job). Pinned by version and SHA-256, like
# the actions above; bump both together.
- name: Install cargo-semver-checks
env:
CSC_VERSION: v0.51.0
CSC_SHA256: cacadafaa5cd27ae6d05a74a103266917ebab4c5e43c898360e177d4fbf06a65
run: |
curl -fsSLo csc.tar.gz "https://github.com/obi1kenobi/cargo-semver-checks/releases/download/${CSC_VERSION}/cargo-semver-checks-x86_64-unknown-linux-gnu.tar.gz"
echo "${CSC_SHA256} csc.tar.gz" | sha256sum -c -
tar xzf csc.tar.gz -C "$HOME/.cargo/bin" cargo-semver-checks
rm csc.tar.gz
- name: Run CI gate
run: sh scripts/ci.sh
+1
View File
@@ -1 +1,2 @@
/target
.worktrees/
+11
View File
@@ -67,6 +67,11 @@ call `poll()` in its loop, and `observe()` when it sees something.
- `tines-core` is `#![no_std]`; `alloc` is a cargo feature (default on). With
`--no-default-features` it must build for `thumbv7em-none-eabihf`. Only
`MemStore` may require `alloc`.
- Decision C (fleet console, approved 2026-10-24): the only sanctioned core
change — the additive `NodeEvent::Contact` / `NodeEvent::Lost` variants,
so hosts can see peers come and go. `Contact` fires only for a newly
added peer; idle eviction stays silent. Everything else in core stays
frozen for 1.0.
- No async, no threads, no I/O, and no panic on untrusted input in core. Every
frame from the wire is untrusted: malformed bytes are dropped, never
unwrapped.
@@ -146,9 +151,15 @@ tines/
tines-store/ std: LogStore, the persistent append-only backend
tines-bt/ std: BLE transport over BlueZ (bluer)
tines/ std: standalone service, Unix-socket JSON IPC
tines-bus/ std: host bus transport — hub-and-spoke Unix-socket daemon
(`tines-busd`) for simulated fleets
tines-ros2/ std: ROS2 node; rclrs behind the `ros2` feature
```
`tines-gui/` sits outside the workspace (like `fuzz/`): a standalone crate
with its own manifest and lockfile — the desktop fleet console. It's gated
by `scripts/ci.sh` separately from the workspace crates.
Only `tines-core` is headed for 1.0; the host crates stay 0.x (1.0 spec, D4).
They're std-only and may use threads and I/O, but they're still the trust
boundary for radio, IPC and disk input (see `THREAT_MODEL.md`).
+44 -5
View File
@@ -190,9 +190,9 @@ fixed in core; see `THREAT_MODEL.md`.
## Hosts 0.4.0 — Unreleased
`tines`, `tines-store`, `tines-bt`, `tines-ros2` and `tines-sim` move to 0.4.0
and depend on `tines-core = "1"`. Like tines-core 1.0.0, nothing here is
tagged or published yet.
`tines`, `tines-store`, `tines-bt`, `tines-bus`, `tines-ros2` and
`tines-sim` move to 0.4.0 and depend on `tines-core = "1"`. Like tines-core
1.0.0, nothing here is tagged or published yet.
The 0.3.0 release shipped only `tines-core` and `tines-sim`. The four other
host crates were added afterwards, during the fleet milestone, and carried the
@@ -339,8 +339,9 @@ milestone's host work (F2–F5, F17).
### Known issues
- The service talks to exactly one configured `peer`. The multi-peer
allowlist the fleet-fixes spec describes isn't wired up (F3).
- The service talks to exactly one configured `peer` on the BT transport.
The multi-peer allowlist the fleet-fixes spec describes isn't wired up
(F3). The new bus transport drops the limit, as a test medium only.
- Neither host flushes on a timer or at shutdown (F18).
- The service trims and flushes only after 64 poll rounds that each stored
something new.
@@ -348,6 +349,44 @@ milestone's host work (F2–F5, F17).
- Peer time is unauthenticated until 1.1. An adopted time isn't corrected
while the host runs (F19).
### Fleet console (unreleased, on `feat/fleet-console`)
The desktop console for driving a containerized fleet, and the host pieces
it rides on:
- **`tines-bus`** (new workspace crate): a host-only transport over a
hub-and-spoke Unix-socket daemon (`tines-busd`). `Addr = NodeId`, like
the BT transport. A client registers its `NodeId`, then sends data
envelopes carrying a 32-byte routing header (`from`, `to`); the daemon
drops any envelope whose `from` isn't that socket's registered id or
whose `to` isn't registered, and forwards the frame verbatim. Envelopes
are length-capped. A bus peer loses the BT transport's single-peer
limit — any registered peer is reachable. It is a test medium for
simulated fleets, not a deployment transport.
- **Transport choice in the service** (tines): config `transport` is
`"bt"` (default) or `"bus"`, with `bus_socket` naming the daemon's
socket. `HostTransport` wraps both, so the service runs BLE or the bus
from the same config file.
- **Widened IPC** (tines): `Observe` gains `to` / `lifetime_ms` for
addressed observations (both set together or neither); new `List`
(observation ids, with a `truncated` flag at the cap) and `Events`
(contacts, drops and drop counts) requests. The new request kinds keep
the existing error posture: a bad request gets an `Error` response,
never a panic or a partial write.
- **Decision C** (core): the additive `NodeEvent::Contact` /
`NodeEvent::Lost` variants, so hosts see peers come and go. `Contact`
fires only for a newly added peer; idle eviction stays silent. The only
sanctioned core change for the console.
- **Fleet console** (`tines-gui`, standalone crate outside the workspace):
a launcher (create a fleet, add nodes, start/stop containers, open node
windows) and a per-container node window (status strip, observation
send, belief read, events feed) driving the service IPC. BT mode runs a
privileged container with the host adapter passed through; sim mode runs
N containers over the bus daemon. Fleet state lives under
`$XDG_STATE_HOME/tines-console/fleets/<fleet>/`, created `0700`.
- **Runtime image** (`Dockerfile.tines`): the two-stage container for the
service plus `tines-busd`; the GUI drives docker as a CLI subprocess.
## 0.3.0 — 2026-10-18
### Breaking
Generated
+8
View File
@@ -1469,6 +1469,7 @@ dependencies = [
"serde",
"serde_json",
"tines-bt",
"tines-bus",
"tines-core",
"tines-store",
]
@@ -1484,6 +1485,13 @@ dependencies = [
"tokio",
]
[[package]]
name = "tines-bus"
version = "0.4.0"
dependencies = [
"tines-core",
]
[[package]]
name = "tines-core"
version = "1.0.0"
+1 -1
View File
@@ -1,5 +1,5 @@
[workspace]
members = ["tines-core", "tines-sim", "tines-store", "tines-bt", "tines-ros2", "tines"]
members = ["tines-core", "tines-sim", "tines-store", "tines-bt", "tines-bus", "tines-ros2", "tines"]
resolver = "2"
[workspace.package]
+50
View File
@@ -0,0 +1,50 @@
# Runtime image for the tines service. Two stages: a build stage that
# compiles the workspace crates, and a slim runtime with the release
# binaries. The same image serves both fleet-console modes — BT mode adds
# --privileged / host networking / D-Bus plumbing at `docker run` time
# (Task 7), and bus mode just points the config at a host bus socket.
# ---- build ---------------------------------------------------------------
FROM ubuntu:24.04 AS build
RUN apt-get update && apt-get install -y --no-install-recommends \
build-essential pkg-config cmake curl ca-certificates \
libclang-dev clang libdbus-1-dev libudev-dev \
&& rm -rf /var/lib/apt/lists/*
# Minimal rustup: default host, no extra targets or toolchains.
RUN curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs \
| sh -s -- -y --profile minimal
ENV PATH="/root/.cargo/bin:${PATH}"
WORKDIR /workspace
# The workspace manifest and every member crate dir (all members' manifests
# must be present for cargo to resolve the workspace).
COPY Cargo.toml Cargo.lock ./
COPY tines-core ./tines-core
COPY tines-store ./tines-store
COPY tines-bt ./tines-bt
COPY tines-bus ./tines-bus
COPY tines-sim ./tines-sim
COPY tines-ros2 ./tines-ros2
COPY tines ./tines
RUN cargo build --release -p tines -p tines-bus
# ---- runtime --------------------------------------------------------------
FROM ubuntu:24.04
RUN apt-get update && apt-get install -y --no-install-recommends \
libdbus-1-3 libudev1 bluez dbus \
&& rm -rf /var/lib/apt/lists/*
COPY --from=build /workspace/target/release/tines /usr/local/bin/
COPY --from=build /workspace/target/release/tines-busd /usr/local/bin/
# The container command is the pinned argv from the fleet console:
# `tines /data/config.json` — the binary comes first because the service
# reads the config path from argv[1]. No ENTRYPOINT (it would swallow the
# argv); no default CMD (a bare `docker run` without the command is an
# error, which is what we want).
+53 -4
View File
@@ -14,6 +14,8 @@ protocol that runs over whatever datagram link the robot has. The name is from
Vinge's *A Fire Upon the Deep*: the Tines only form a mind when close enough to
hear each other.
![1page](docs/images/tines-systems-data.png)
## Contents
- [Design at a glance](#design-at-a-glance)
@@ -560,8 +562,9 @@ A robot runs either the service or the ROS2 node, not both: each owns its
- Connections produce `Contact` and `Lost` events. See
[`tines-bt/README.md`](tines-bt/README.md).
- **`tines`**: the standalone service, for robots without ROS2.
- It runs the exchange on a poll loop and answers `Observe`, `Belief`,
`Events` and `Status` requests on a Unix socket.
- It runs the exchange on a poll loop and answers `Observe` (broadcast or
addressed), `Belief`, `Events`, `List` and `Status` requests on a Unix
socket.
- It's configured by a JSON file (below). Library users call
`tines::serve(node, socket_path, clock, poll_interval_ms,
peer_time_window_ms)`.
@@ -578,9 +581,11 @@ typo can't silently fall back to a default.
| Key | Default | Meaning |
|---------------------------|--------------|---------|
| `node_id` | required | this robot's `NodeId`, 32 hex chars |
| `peer` | required | the one peer's `NodeId`, 32 hex chars |
| `peer` | bt only | the one peer's `NodeId`, 32 hex chars |
| `log_path` | required | the `LogStore` log file |
| `socket_path` | required | the IPC Unix socket |
| `transport` | `"bt"` | `"bt"` for BLE, or `"bus"` for the host bus transport |
| `bus_socket` | bus only | the bus daemon's Unix socket |
| `poll_interval_ms` | 20 | exchange poll cadence |
| `trusted_clock` | unset | `"system"` to trust the system clock; the only accepted value |
| `peer_time_window_ms` | 10000 | how long to collect peer time estimates before adopting the latest |
@@ -635,7 +640,8 @@ Peer time is unauthenticated until 1.1, and an adopted time isn't corrected
while the host runs; see F19 in [`THREAT_MODEL.md`](THREAT_MODEL.md).
**Current limits** (tracked in [`THREAT_MODEL.md`](THREAT_MODEL.md)):
- The service talks to one configured `peer`.
- On the BT transport the service talks to one configured `peer`; the bus
transport (simulated fleets) reaches any peer registered with the daemon.
- The hosts don't flush on a timer or at shutdown. The service flushes after
64 productive poll rounds, and both hosts flush when they adopt trusted
time.
@@ -644,6 +650,44 @@ while the host runs; see F19 in [`THREAT_MODEL.md`](THREAT_MODEL.md).
The two-device BLE check in `tines-bt/README.md` hasn't been recorded yet.
## The fleet console (lab desktop)
`tines-gui/` (a standalone crate outside the workspace, like `fuzz/`) is
the desktop console: an orchestrator that runs a whole fleet in containers.
It is never a node host — the GUI embeds no `Node` and no radio runs in the
GUI process; every node lives in a container the GUI drives over the
service IPC.
- **Launcher** (`tines-gui` with no arguments): create a fleet, add nodes,
start/stop containers, open a node window.
- **Node window** (`tines-gui --node <dir> <container-id> bt|sim`): one
window per container — a status strip, observation send (broadcast or
addressed, with a lifetime), belief read, and a live events feed
(contacts, drops). It speaks the service IPC; nothing else.
- **Modes.** BT mode runs one privileged container with the host's
Bluetooth adapter passed through, so that node reaches real robots. Sim
mode runs N containers over the bus transport (below), with no radio.
- Fleet state lives under
`$XDG_STATE_HOME/tines-console/fleets/<fleet>/`, created `0700`: one
directory per node with its config, log and IPC socket.
### The bus transport
`tines-bus` (a workspace member) is a host-only transport for simulated
fleets: a hub-and-spoke daemon (`tines-busd`) that containers dial over
Unix sockets. Its `Addr` is the `NodeId`, like the BT transport.
- A client registers its `NodeId` with the daemon, then sends data
envelopes carrying a 32-byte routing header (`from`, `to`). The daemon
drops any envelope whose `from` isn't that socket's registered id or
whose `to` isn't registered, and forwards the frame verbatim — it never
decodes frame contents.
- Envelopes are length-capped; an oversized one is a protocol violation.
- The bus is a **test medium**, not a deployment transport: no encryption,
no authentication beyond the registration check. The service picks it
with `transport = "bus"` plus `bus_socket`, which also drops the BT
transport's single-peer limit — any registered peer is reachable.
## Crate layout
```
@@ -658,9 +702,14 @@ tines/
tines-store/ std: LogStore, the persistent append-only backend.
tines-bt/ std: the BLE transport (BlueZ via bluer).
tines/ std: the standalone service with Unix-socket IPC.
tines-bus/ std: the host bus transport — a hub-and-spoke Unix-socket
daemon (`tines-busd`) for simulated fleets.
tines-ros2/ std: the ROS2 node (rclrs behind the `ros2` feature).
```
`tines-gui/` sits outside the workspace (like `fuzz/`): the desktop fleet
console. Build it with `cargo test --manifest-path tines-gui/Cargo.toml`.
The public constants, in `tines_core::consts`, are the wire constants. They
are frozen for wire v1: changing one is a `WIRE_VERSION` bump.
+48 -1
View File
@@ -3,7 +3,11 @@
- **Assessed:** `master` at `3b1a644` (tines-core / tines-store / tines-bt /
tines-ros2 / tines 0.3.0, fleet-deployment milestone mostly landed)
- **Date:** 2026-10-19
- **Updated:** 2026-10-21 at `411daa0` (`feat/tines-core-1-0`), after the
- **Updated:** 2026-10-25, at `2a917088` (`feat/fleet-console`), after the
fleet console work: F3's single-peer limit is now BT-only (the new bus
transport drops it, as a test medium); F11 gains the widened IPC; F20
(bus daemon) added. Earlier updates: 2026-10-21 at
`411daa0` (`feat/tines-core-1-0`), after the
tines-core 1.0 review fixes landed. Earlier update: 2026-10-20 at
`89041ea`, after the fleet fixes. The findings keep their original text;
each fixed one ends with a **Fixed** or **Partly fixed** line. §6 adds
@@ -240,6 +244,11 @@ for a poll loop, `docs/superpowers/plans/2026-10-18-tines-fleet-deployment.md:20
- No BLE security level is requested (unchanged; 1.1).
- No two-radio check of the SeqPacket path is recorded yet.
tines-bt/README.md gives the procedure but no result.
- *At `2a917088` (fleet console):* the new bus transport (tines-bus)
drops the single-peer limit — bus peers reach any id registered with
the daemon. That is a **test medium** for simulated fleets over host
Unix sockets, not a radio deployment; on real radios the single-peer
limit stands until the allowlist work lands.
**Later (1.1):** authenticate the link (spec §2b). Advertised NodeIds are
unauthenticated, so the binding only becomes meaningful once there's a
@@ -420,6 +429,11 @@ re-queues what was already sent.
an idle client blocks everyone. An `accept` error ends the daemon. An
oversized frame (> 1 MiB) gets an error reply without consuming its body,
which desynchronizes the stream (contrary to the module docs).
- *At `2a917088` (fleet console):* the IPC widened — `Observe` gained
`to`/`lifetime_ms`, and `List` and `Events` were added — without
changing this posture. The new request kinds validate their inputs, cap
their outputs (`List` truncates with a flag), and answer a bad request
with an `Error` response, never a panic or a partial write.
- **Fix:**
- Create the socket in a `0700` runtime directory with mode `0600` (or a
group-restricted mode).
@@ -626,6 +640,38 @@ authenticates that estimate.
`time-sync.target`). Signing and verification in 1.1 close the forged
case.
### F20 — The bus daemon is a host attack surface (Medium, A3)
*Added 2026-10-25, with the fleet console's bus transport (tines-bus).*
The bus daemon (`tines-busd`) stands in for the radio in simulated fleets:
N service containers dial one daemon over host Unix sockets and exchange
tines frames through it. It is host-local infrastructure and was built
with the same distrust of input as the IPC server:
- **Registration is explicit.** A client registers its `NodeId` before
sending data, and the daemon drops (and counts) any envelope whose
`from` isn't that socket's registered id, or whose `to` isn't a
registered id.
- **Envelopes are length-capped** at `MAX_FRAME + 32` header bytes
(`MAX_PAYLOAD`); an oversized length header is a protocol violation that
closes the connection.
- **The daemon decodes nothing beyond the 32-byte routing header.** Frame
contents are forwarded verbatim, so a malformed frame is the receiving
node's problem, exactly as on the radio.
- **The fleet directory is `0700`** (tines-gui `config::fleet_dir` and
per-node dirs), so node sockets, logs and configs under it are private
to the owning user.
- **Effect (A3):** another local user can neither read nor join a fleet
(directory permissions), nor spoof frames through the daemon (the
registration check). Root can do anything on the host, as always. The
daemon is bench equipment: it carries no encryption and authenticates
nothing, and must not become a deployment transport.
- **Open:** the daemon rate-limits nothing. A buggy or hostile local
client can flood envelopes at the fan-out rate. Accepted for the test
medium; F10-style quotas would be the fix if the daemon ever left the
bench.
## 7. Threats by STRIDE, mapped to findings
| | Threat | Status today | Findings | Planned mitigation |
@@ -708,6 +754,7 @@ agreed fuzz budget hasn't been run and recorded yet.
| F10–F12 | Host hardening, before a robot leaves the lab | `docs/superpowers/specs/2026-10-19-tines-host-hardening.md` | Open |
| F6, F8, F9, F13 | tines-core 1.0 | `docs/superpowers/specs/2026-10-18-tines-1-0-design.md` §10 | Fixed (§1); F13's Reassembler displacement accepted |
| F19 | Hosts and tines-store (decision J, plausibility bounds); authentication in 1.1 | 1.0 spec §9, decision J; §2a | Partly mitigated (§1) |
| F20 | Fleet console: the bus daemon's posture (registration, caps, 0700 dirs) is built in; per-peer rate limits are open | fleet console design | Mitigated by design (§6) |
| F7, F14, §8 | 1.1 (threat model, link authentication, signing) | 1.0 spec §2a, §2b | Open |
| F15, F16 | Accepted for now | — | — |
Binary file not shown.

After

Width:  |  Height:  |  Size: 292 KiB

@@ -0,0 +1,435 @@
# tines Fleet Console Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** A desktop console (`tines-gui`) that operates tines nodes in two containerized modes — a Bluetooth container talking to real robots, and a simulated fleet of containers exchanging frames over a new IPC-bus transport — with the service IPC widened to expose the full node surface.
**Architecture:** Three new pieces plus widenings. `tines-bus` (workspace member): a `Transport` impl over a host Unix-socket bus daemon. `tines-gui` (standalone crate outside the workspace, like `fuzz/`): egui/eframe launcher + one process per container, speaking the service IPC and driving docker via CLI. The `tines` service gains a transport choice and a widened IPC (addressed observe, contact/lost/dropped events, store listing). Core gains exactly two additive `NodeEvent` variants.
**Tech Stack:** Rust 1.99.0 (pinned), tines-core (no_std, untouched except Task 1), bluer/BlueZ, std Unix sockets, egui/eframe 0.36 (glow, wayland), docker CLI subprocess.
**Spec:** `docs/superpowers/specs/2026-10-24-tines-fleet-console-design.md` — the plan argues from the spec, so the spec travels with it; executors read both.
## Global Constraints
- Repo rules (AGENTS.md): TDD (failing test → fail → implement → pass → commit), conventional commits, `sh scripts/ci.sh` green before every commit, specs/plans are read-only history, README tracks code, THREAT_MODEL.md updated when a change touches a finding.
- Toolchain: `1.99.0`, edition 2021, workspace resolver 2; `thumbv7em-none-eabihf` target installed.
- `tines-core` stays `no_std`, no async/threads/I/O, no panic on untrusted input; the ONLY core change in this work is Task 1 (two additive `NodeEvent` variants, enum is `#[non_exhaustive]`).
- Wire constants frozen: `MAX_FRAME=1500`, `WIRE_VERSION=4`; the bus carries core wire frames verbatim inside a bus-local envelope (no wire-format change).
- Service IPC: length-prefixed (u32 LE) JSON, 1 MiB cap, topics/payloads/ids as lowercase hex on the wire; malformed input → `Error` response, never a panic. Service `Config` keeps `deny_unknown_fields`.
- The `Node` is never in the GUI process; the GUI only speaks IPC to service processes.
- `tines-gui` is a standalone crate at repo root (NOT a workspace member; precedent: `fuzz/`). `tines-bus` IS a workspace member.
- eframe features exactly `glow`, `default_fonts`, `wayland`; `vsync = false`; opaque window painted with the omarchy theme (`~/.local/state/omarchy/current/theme/colors.toml`, keys `background`/`foreground`/`accent`, directory watch via `notify` — copy the mechanism from `~/src/aleph-keyring/crates/aleph-gui`).
- Docker is driven as a CLI subprocess (`docker` binary); no docker SDK.
- New public names in `tines-core` (Task 1) are called out in the commit message and AGENTS.md.
## Review Focus
The five failure modes most likely to bite a person using this software, with the test that pins each, owned by the task that owns the code:
1. **A bus client lies about who it is** (frame's declared `from` ≠ its registered NodeId) — the receiving transport must drop the frame (bt's F6 rule), and the daemon must drop/close on a routing-header mismatch. → Task 3 tests.
2. **`observe` with `to` but no `lifetime_ms`** (or vice versa) — must be an `Error`, not a half-addressed observation. → Task 2 tests.
3. **A huge store breaks `List`** — the response must cap at `LIST_CAP` entries and set `truncated: true`, never exceed the 1 MiB frame cap. → Task 2 tests.
4. **A config that pairs the wrong transport with the wrong keys** (bt without `peer`, bus without `bus_socket`, unknown key) — startup error, no silent fallback. → Task 4 tests.
5. **BLE reconnect storms** (bt re-fires `Contact` on every reconnection) — `NodeEvent::Contact` fires only for a newly-added peer, so the console feed isn't flooded; `Lost` fires only for a peer that was present. → Task 1 tests.
---
### Task 1: Core — `NodeEvent::Contact` / `NodeEvent::Lost`
**Files:**
- Modify: `tines-core/src/exchange.rs` (the `NodeEvent` enum, `on_event`, `add_peer`/`remove_peer` call sites)
- Test: tests in `tines-core/src/exchange.rs`'s `#[cfg(test)]` module
**Interfaces:**
- Produces: `NodeEvent<A>::Contact { addr: A }` and `NodeEvent<A>::Lost { addr: A }` — additive variants on the `#[non_exhaustive]` enum. Semantics: emitted from `Exchange::on_event` when a transport `Event::Contact(addr)` adds a NEW peer (repeat `Contact` for a known peer restarts its idle clock and emits nothing), and when `Event::Lost(addr)` removes a peer that was present (`Lost` for an unknown addr emits nothing). Idle eviction (`evict_idle`, F8) stays silent. The variant carries the transport `Addr` (core never maps addr→NodeId); for both bt and bus the addr IS the `NodeId`.
- Consumes: nothing new; `NodeEvent` already derives `Clone, Copy, Debug, PartialEq`.
- [ ] **Step 1: Write the failing tests** in `tines-core/src/exchange.rs`:
`contact_for_new_peer_emits_event` — `on_event(Event::Contact(addr))` then `drain_events()` contains exactly `Contact { addr }`; a second `on_event(Event::Contact(addr))` for the same addr drains empty.
`lost_for_known_peer_emits_event` — `Contact` then `Lost(addr)` drains `Lost { addr }`; `Lost` for a never-seen addr drains empty.
`idle_eviction_emits_nothing` — drive `next_frame` with `now` past `PEER_IDLE_MS` so the peer is evicted; `drain_events()` is empty.
`contact_event_rides_the_drop_oldest_queue` — fill the event queue past `EVENT_QUEUE_CAP` with `Delivered`-style pushes, then a `Contact`; the drained oldest event is gone, the `Contact` survives (heap-safe drop-oldest behavior, no panic).
- [ ] **Step 2: Run tests to verify they fail**
Run: `cargo test -p tines-core contact_for_new_peer_emits_event lost_for_known_peer_emits_event idle_eviction_emits_nothing contact_event_rides_the_drop_oldest_queue`
Expected: compile errors (variants don't exist) / assertion failures.
- [ ] **Step 3: Implement** — add the two variants to `NodeEvent<A>` with the doc comments from the spec (decision C); in `on_event`, replace the `Contact`/`Lost` arms to push into `self.events` with the drop-oldest pattern already used by `Delivered` (`let _ = self.events.push(...)`), gated on new-peer/known-peer as above. Check peer presence via the peers table before `add_peer`/`remove_peer`.
- [ ] **Step 4: Run tests to verify they pass**
Run: `cargo test -p tines-core && sh scripts/check-no-std.sh && cargo clippy -p tines-core --all-targets -- -D warnings`
Expected: all green; the thumbv7em no_std build still passes.
- [ ] **Step 5: Commit**
```bash
git add tines-core/src/exchange.rs
git commit -m "feat(core): emit Contact/Lost NodeEvents (decision C)"
```
---
### Task 2: Service IPC widening
**Files:**
- Modify: `tines/src/ipc.rs` (Request/Response enums, new out types, `handle`)
- Test: `tines/tests/ipc.rs` (existing `MockTransport` already surfaces one `Contact`)
**Interfaces:**
- Consumes: Task 1's `NodeEvent::Contact/Lost`; `Node::observe_to(to: NodeId, lifetime_ms: u64, topic, payload, observed_at, now) -> Result<ObservationId, ObserveError>`; `Store::for_each_id` + `Store::get(id, now)`.
- Produces (all new variants are additive; the existing four operations keep their exact wire shape):
- `Request::Observe { topic, payload, to: Option<String>, lifetime_ms: Option<u64> }` — `to` and `lifetime_ms` both set → `observe_to`; both unset → `observe`; exactly one set → `Response::Error { message: "to and lifetime_ms must be set together" }`.
- `Request::List` → `Response::List { observations: Vec<ObservationOut>, truncated: bool }` with `LIST_CAP = 1024` entries max; `truncated` is `true` when more exist. `ObservationOut { id: String, observer: String, topic: String, payload: String, to: Option<String>, lifetime_ms: Option<u64>, age_ms: u64, observed_at: Option<u64> }` (all hex where binary; `observed_at` from the stored observation).
- `Response::Events` gains two fields (existing `events` field unchanged): `contacts: Vec<ContactOut>` and `drops: Vec<DropOut>`. `ContactOut { peer: String, joined: bool }` (hex addr; `joined: false` = lost). `DropOut { peer: Option<String>, reason: String, count: u32 }` (`None` = overflow slot). Drops now surface here AND on stderr (stderr line stays).
- `Status` unchanged.
- [ ] **Step 1: Write the failing tests** in `tines/tests/ipc.rs`:
`observe_addressed_roundtrip` — `Observe { to, lifetime_ms }` → `Response::Observe { id }`; then `List` shows that observation with matching `to`/`lifetime_ms`.
`observe_half_addressed_is_error` — `to` set, `lifetime_ms` none → `Error`; and vice versa.
`list_returns_all_observations` — observe one broadcast + one addressed → `List` has both, fields correct, `truncated` false.
`list_caps_at_list_cap` — observe `LIST_CAP + 1` small observations in a loop → `List` has exactly `LIST_CAP` entries and `truncated: true`.
`events_include_contacts` — fresh `serve` with the mock (its first poll surfaces `Contact`) → `Events` response has `contacts == [ContactOut { peer: <mock peer hex>, joined: true }]`.
`events_include_lost_and_drops` — push `Event::Lost(peer)` into the mock's pending queue and inject a garbage frame; `Events` reports `contacts` with `joined: false` and a `drops` entry with the right `reason`.
`existing_events_field_unchanged` — the Delivered path (mock auto-acks) still fills `events` exactly as before.
- [ ] **Step 2: Run tests to verify they fail**
Run: `cargo test -p tines --test ipc observe_addressed_roundtrip list_returns_all_observations events_include_contacts`
Expected: FAIL (unknown fields/variants).
- [ ] **Step 3: Implement** — extend the enums and add the `handle` arms in `tines/src/ipc.rs`; encode the drop path (`NodeEvent::Dropped`) into `DropOut` instead of stderr-only; keep the existing `_ => None` arm for future variants. Reuse the existing `encode_hex`/`decode_hex`.
- [ ] **Step 4: Run tests to verify they pass**
Run: `cargo test -p tines && cargo clippy -p tines --all-targets -- -D warnings`
Expected: PASS; existing ipc tests still green (wire compat for the four old ops).
- [ ] **Step 5: Commit**
```bash
git add tines/src/ipc.rs tines/tests/ipc.rs
git commit -m "feat(host): widen service IPC — addressed observe, List, contact/drop events"
```
---
### Task 3: `tines-bus` crate — bus transport + daemon
**Files:**
- Create: `tines-bus/Cargo.toml` (deps: `tines-core = { path = "../tines-core", version = "1" }`; std only, no serde), `tines-bus/src/lib.rs`, `tines-bus/src/transport.rs`, `tines-bus/src/daemon.rs`, `tines-bus/src/bin/tines-busd.rs`
- Test: `tines-bus/tests/transport.rs`, `tines-bus/tests/daemon.rs`
- Modify: `Cargo.toml` (workspace members += `tines-bus`)
**Interfaces:**
- Consumes: `tines_core::transport::{Event, FrameBuf, SendError, Transport}`, `tines_core::frame_sender`, `tines_core::consts::MAX_FRAME`, `NodeId`.
- Produces:
- `pub trait BusLink` (the narrow I/O seam, mirroring bt's `Radio`): `fn poll(&mut self) -> Option<BusEvent>; fn send(&mut self, to: &NodeId, data: &[u8]) -> Result<(), BusError>; fn mtu(&self) -> usize;` with `BusEvent::{ Joined(NodeId), Left(NodeId), Frame(NodeId, FrameBuf), Closed }` and `pub enum BusError { Io(std::io::Error), Unreachable }`. The real link (daemon client) lives in the same crate, thread-driven, NOT part of the pure logic.
- `pub struct BusTransport` — `pub fn new(me: NodeId, allowlist: Option<NodeId>, socket: impl AsRef<Path>) -> std::io::Result<BusTransport>`; `impl Transport for BusTransport { type Addr = NodeId; ... }`. Poll maps `Joined`/`Left` → `Event::Contact`/`Event::Lost`, `Frame` → `Event::Frame` after the spoofing check (`frame_sender(frame) == peer`), `Closed` → `Event::Lost` for every bound peer. Frames/events from a non-allowlisted peer are dropped (allowlist `None` = any peer). `send` to a non-allowlisted or unknown peer → `SendError::Unreachable`; outbound frames must declare `me` as sender.
- `pub fn daemon::serve(socket: impl AsRef<Path>) -> std::io::Result<()>` — bind (removing a stale socket file first), accept, run the protocol below. Binary `tines-busd` takes the socket path as argv[1].
- **Bus envelope (pinned, bus-local — the core frame inside is untouched):** every message is `[u32 LE length][payload]`, length counting payload bytes only.
- Hello (client→daemon, first message): payload = 16-byte `NodeId` (len 16). A second hello for an already-registered id replaces the old connection (old socket closed).
- Data (client→daemon): payload = `[16B from][16B to][frame]`. Daemon drops (and counts on stderr) any message whose `from` ≠ that socket's registered id, or whose `to` is not registered; otherwise forwards the envelope verbatim to `to`'s socket. Frames longer than `MAX_FRAME` close the connection.
- Control (daemon→client): payload = `[1B kind][16B NodeId]` (len 17); kind `0 = joined`, `1 = left`; sent to every other registered client on join, and to all remaining on leave. EOF/error from a client's socket = leave + close, daemon stays up.
- The daemon is single-threaded `poll`-based over nonblocking sockets; per-connection state = registered id (`Option<NodeId>`), read buffer. No frame content is decoded by the daemon beyond the routing header.
- [ ] **Step 1: Write the failing tests**
`tines-bus/tests/transport.rs` (spawn `daemon::serve` on a thread with a tempfile dir socket, then drive pure `BusTransport`s; two nodes, allowlist `None`):
- `two_transports_exchange_frames` — A.send(to=B, frame declaring `from`=A) → B.poll yields `Event::Frame(A, bytes)` with identical bytes.
- `spoofed_frame_is_dropped` — B's link delivers a frame declaring `from`=C (not B's bound peer); B.poll does not yield it.
- `join_and_leave_become_contact_and_lost` — A connects first; when B connects, A.poll yields `Event::Contact(B)`; when B drops, A yields `Event::Lost(B)`.
- `allowlist_filters` — A with allowlist `Some(B)`: frames/join events for C never surface; `send` to C → `SendError::Unreachable`.
`tines-bus/tests/daemon.rs` (drive raw sockets against the daemon):
- `duplicate_hello_replaces` — second connection helloing an already-registered id kills the first (its read returns EOF).
- `mismatched_from_is_dropped` — a socket registered as A sends a data envelope with `from`=B: the addressed peer receives nothing; the daemon remains alive for the next message.
- `unknown_dest_is_dropped` — data to an unregistered id: dropped, sender stays connected.
- `oversized_frame_closes_connection` — a data envelope with a frame larger than `MAX_FRAME`: daemon closes that connection only.
- [ ] **Step 2: Run tests to verify they fail**
Run: `cargo test -p tines-bus`
Expected: FAIL (crate does not exist).
- [ ] **Step 3: Implement** the crate: pure `BusTransport` logic over the `BusLink` trait in `transport.rs`; the daemon and its real socket link in `daemon.rs`; the thin `tines-busd` bin. Add `tines-bus` to the workspace members.
- [ ] **Step 4: Run tests to verify they pass**
Run: `cargo test -p tines-bus && cargo clippy -p tines-bus --all-targets -- -D warnings && cargo fmt --all -- --check`
Expected: PASS.
- [ ] **Step 5: Commit**
```bash
git add Cargo.toml Cargo.lock tines-bus/
git commit -m "feat(bus): tines-bus transport and daemon over a host IPC bus"
```
---
### Task 4: Service transport choice + config moves to the lib
**Files:**
- Modify: `tines/Cargo.toml` (add `tines-bus` path dep), `tines/src/main.rs` (config parse + node construction), `tines/src/lib.rs` (new modules)
- Create: `tines/src/config.rs` (the `Config`/`TrustedClock`/`TransportKind` types, moved from `main.rs` so clients can parse generated configs), `tines/src/transport.rs` (`HostTransport`)
- Test: config tests in `tines/src/config.rs` (moved), new integration test `tines/tests/service_bus.rs`
**Interfaces:**
- Consumes: Task 3's `BusTransport`; `BtTransport` as today.
- Produces:
- `tines::config::Config` (pub, `deny_unknown_fields`): existing keys unchanged except `peer: Option<String>`; new keys `transport: Option<TransportKind>` (default `bt`; `TransportKind` serde-rename lowercase: `bt` | `bus`) and `bus_socket: Option<String>`.
- Validation (pub fn on `Config`, called by `main` before anything else): `transport = bt` + `peer = None` → error `"transport bt requires peer"`; `transport = bus` + `bus_socket = None` → error `"transport bus requires bus_socket"`; bus + `peer = Some(..)` = allowlist of one, bus + `None` = any peer.
- `tines::transport::HostTransport` — `pub enum HostTransport { Bt(BtTransport), Bus(BusTransport) }` with `impl Transport for HostTransport { type Addr = NodeId; ... }` delegating to the inner transport.
- `main.rs` builds `HostTransport` per config (bt: `BluerRadio::new(me, peer)` + `BtTransport::new(me, peer, radio)`; bus: `BusTransport::new(me, peer, &config.bus_socket)`) and hands it to the unchanged `serve`. Everything else in `main` is untouched.
- [ ] **Step 1: Write the failing tests**
Config tests (moved + new, in `tines/src/config.rs`): existing parse tests keep passing (minimal bt config, `trusted_clock`, unknown field, peer-time window); new: `bus_config_parses` (transport "bus", bus_socket, no peer), `bt_without_peer_is_invalid`, `bus_without_socket_is_invalid`, `unknown_transport_is_rejected`, `transport_defaults_to_bt`.
`tines/tests/service_bus.rs` (the sim-fleet deliverable, no docker): spawn the real `tines-busd` on a tempdir socket; spawn two real `tines` service processes (`env!("CARGO_BIN_EXE_tines")`) with generated bus configs (`trusted_clock: "system"`); connect each socket with the length-prefixed framing; `Observe` on node A; poll `Belief` on node B until it sees the payload (deadline ~5 s); `List` on B contains it; `Events` on both eventually reports `Contact { joined: true }`.
- [ ] **Step 2: Run tests to verify they fail**
Run: `cargo test -p tines --test service_bus`
Expected: FAIL (no `service_bus.rs`, config types not in lib).
- [ ] **Step 3: Implement** — move `Config`/`TrustedClock` into `tines/src/config.rs` (pub, re-export from `lib.rs`), add `TransportKind` + validation, add `HostTransport` in `tines/src/transport.rs`, rewire `main.rs` (`tines::config::Config`, the validation call, `HostTransport` construction). `peer` becomes `Option<String>`; the bt branch errors via validation.
- [ ] **Step 4: Run tests to verify they pass**
Run: `cargo test -p tines && cargo clippy -p tines --all-targets -- -D warnings`
Expected: PASS — including `service_bus.rs` (two real services converge over the bus) and all pre-existing ipc tests.
- [ ] **Step 5: Commit**
```bash
git add tines/
git commit -m "feat(host): service transport choice (bt|bus) and lib-level config"
```
---
### Task 5: Runtime docker image
**Files:**
- Create: `Dockerfile.tines`
- Modify: `.dockerignore` (verify it excludes `target/`, `.git/`, `tines-gui/`)
**Interfaces:**
- Produces: image tag `tines-stack:latest`, built with `docker build -f Dockerfile.tines -t tines-stack:latest .`. Two stages:
- build: `FROM ubuntu:24.04`; apt: `build-essential pkg-config cmake curl ca-certificates libclang-dev clang libdbus-1-dev libudev-dev`; rustup minimal (profile minimal, default host); `WORKDIR /workspace`; `COPY Cargo.toml Cargo.lock ./` then each workspace crate dir (`tines-core`, `tines-store`, `tines-bt`, `tines-bus`, `tines-sim`, `tines-ros2`, `tines` — every member's Cargo.toml must be present for cargo to resolve); `RUN cargo build --release -p tines -p tines-bus`.
- runtime: `FROM ubuntu:24.04`; apt: `libdbus-1-3 libudev1 bluez dbus`; `COPY --from=build` the two release binaries to `/usr/local/bin/`; `ENTRYPOINT ["tines"]` (config path comes as the container arg; no default `CMD`).
- The same image serves both modes; BT mode's `--privileged`/host-network/D-Bus flags are runtime args in Task 7.
- [ ] **Step 1: Write the failing verification** — nothing to test in Rust; the check is the build + smoke below.
- [ ] **Step 2: Verify it fails**
Run: `docker build -f Dockerfile.tines -t tines-stack:latest .`
Expected: FAIL (no Dockerfile).
- [ ] **Step 3: Implement** the Dockerfile as pinned above.
- [ ] **Step 4: Verify it passes**
Run: `docker build -f Dockerfile.tines -t tines-stack:latest .` then smoke: `docker run --rm -d --name tines-smoke tines-stack:latest /data/config.json` against a host-written bt config in a mounted dir → `sleep 2; docker logs tines-smoke` contains `tines: listening on`; then `docker stop tines-smoke`.
Expected: build succeeds; smoke log line appears. (The radio-less bt service must still start and serve IPC — no adapter is not fatal.)
- [ ] **Step 5: Commit**
```bash
git add Dockerfile.tines
git commit -m "feat(docker): runtime image for the tines service"
```
---
### Task 6: `tines-gui` skeleton — IPC client, config generation, theme
**Files:**
- Create: `tines-gui/Cargo.toml` (deps: `tines = { path = "../tines" }`, `eframe = { version = "0.36", default-features = false, features = ["glow", "default_fonts", "wayland"] }`, `egui = "0.36"`, `serde`, `serde_json`, `notify = "8"`; dev-deps: `egui_kittest = "0.36"` with the `wgpu` feature, `tempfile`), `tines-gui/src/main.rs`, `tines-gui/src/ipc_client.rs`, `tines-gui/src/config.rs`, `tines-gui/src/theme.rs`
- Test: `tines-gui/tests/ipc_client.rs`, `tines-gui/tests/config.rs`
- Modify: `scripts/ci.sh` (append gui checks mirroring the fuzz lines: `cargo fmt --manifest-path tines-gui/Cargo.toml -- --check`, `cargo clippy --manifest-path tines-gui/Cargo.toml --all-targets -- -D warnings`, `cargo test --manifest-path tines-gui/Cargo.toml`)
**Interfaces:**
- Consumes: `tines::{Request, Response}` and `tines::config::Config` (Task 2/4); the service's wire framing (u32 LE length + JSON).
- Produces:
- `tines_gui::ipc_client::IpcClient` — `pub fn spawn(socket: PathBuf) -> io::Result<IpcClient>` (connects on a worker thread; queues requests); `pub fn request(&self, req: Request) -> Result<u64, ClientError>` (encodes + queues, returns a monotonically increasing message id); `pub fn try_recv(&self) -> Option<(u64, Response)>` (nonblocking); `pub fn reconnect(&self)` (re-dials; drops nothing). `pub enum ClientError { Io(io::Error), Json(serde_json::Error) }`. Request→response correlation by id; responses arrive out of order safely (client tags each frame with the id — **client-side framing**: `[u32 LE len][u32 LE msg_id][Request JSON]`, response `[u32 LE len][u32 LE msg_id][Response JSON]`; the msg_id sits inside the length-prefixed body, so the service-side protocol is untouched and the client keeps its own correlation). Timeouts are the GUI's business (the client only reports what arrives).
- `tines_gui::config::{fleet_dir, node_dir, generate_node_id, NodeConfig}` — `pub fn fleet_dir(fleet: &str) -> PathBuf` = `$XDG_STATE_HOME|~/.local/state` + `tines-console/fleets/<fleet>` (created `0700`); `pub fn node_dir(fleet: &str, node: &str) -> PathBuf`; `pub fn generate_node_id() -> String` (32 lowercase hex from 16 bytes of `/dev/urandom`); `pub struct NodeConfig { node_id: String, peer: Option<String>, log_path: String, socket_path: String, transport: String, bus_socket: Option<String>, trusted_clock: Option<String> }` serializing to EXACTLY the service `Config` schema (unknown keys are fatal on the service side). `write(fleet, node) -> io::Result<PathBuf>` writes `<node_dir>/config.json`.
- `tines_gui::theme::OmarchyTheme` — the aleph-keyring mechanism: load `~/.local/state/omarchy/current/theme/colors.toml` keys `background`/`foreground`/`accent`, `notify` watch on the `current` directory, repaint on change.
- [ ] **Step 1: Write the failing tests**
`tines-gui/tests/config.rs` — `generated_config_parses_as_service_config`: build a `NodeConfig` for bt and for bus, serialize, and deserialize each with `serde_json` into `tines::config::Config` (proves the schema matches the service exactly); `node_id_is_32_hex_chars`; `fleet_dir_is_0700`.
`tines-gui/tests/ipc_client.rs` — a test-local fake server (std `UnixListener`) speaking the service framing: `client_roundtrips_observe` (server decodes `Request::Observe`, replies `Response::Observe`; client's `try_recv` yields the matching id); `client_survives_server_restart` (kill listener, `reconnect`, next request round-trips).
- [ ] **Step 2: Run tests to verify they fail**
Run: `cargo test --manifest-path tines-gui/Cargo.toml`
Expected: FAIL (crate does not exist).
- [ ] **Step 3: Implement** the three modules + the crate manifest. `main.rs` for now: parse args, if none print `"tines-gui: launcher"` and exit 0 (Task 8 wires the real UI).
- [ ] **Step 4: Run tests to verify they pass**
Run: `cargo test --manifest-path tines-gui/Cargo.toml && cargo clippy --manifest-path tines-gui/Cargo.toml --all-targets -- -D warnings && cargo fmt --manifest-path tines-gui/Cargo.toml -- --check`
Expected: PASS.
- [ ] **Step 5: Commit**
```bash
git add tines-gui/ scripts/ci.sh
git commit -m "feat(gui): tines-gui skeleton — IPC client, config generation, omarchy theme"
```
---
### Task 7: Docker orchestration + bus-daemon supervision
**Files:**
- Create: `tines-gui/src/docker.rs`, `tines-gui/src/fleet.rs`
- Test: `tines-gui/tests/docker.rs` (command-building only — no real docker), `tines-gui/tests/fleet.rs`
**Interfaces:**
- Consumes: Task 6's `config` module; `tines-busd` binary on PATH (built in Task 3).
- Produces:
- `tines_gui::docker::Docker` — `pub fn image() -> &'static str` = `"tines-stack:latest"`; `pub fn build_image(&self) -> Result<(), DockerError>` (`docker build -f Dockerfile.tines -t tines-stack:latest .` from the repo root); `pub fn start_bt(&self, name: &str, fleet: &str) -> Result<ContainerId, DockerError>` — flags pinned: `docker run -d --name tines-bt-<name> --privileged --net=host -v /run/dbus/system_bus_socket:/run/dbus/system_bus_socket -v <fleet_dir>/<name>:/data tines-stack:latest tines /data/config.json`; `pub fn start_sim(&self, name: &str, fleet: &str)` — same minus privileged/net-host/dbus, name `tines-sim-<name>`; `pub fn stop(&self, id: &ContainerId)`; `pub fn remove(&self, id: &ContainerId)`; `pub fn logs(&self, id: &ContainerId, tail: u32) -> Result<String, DockerError>`; `pub fn is_running(&self, id: &ContainerId) -> bool` (docker inspect exit status). `pub struct ContainerId(pub String);` `pub enum DockerError { NotInstalled, Spawn(io::Error), Exit { code: i32, stderr: String } }`. All commands run via `std::process::Command`, args as pinned Vecs (never shell-interpolated).
- `tines_gui::fleet::Fleet` — `pub fn create(fleet: &str) -> io::Result<Fleet>` (mkdir 0700 via Task 6 `fleet_dir`; spawn `tines-busd <fleet_dir>/bus.sock` as a child, stdout/stderr inherited); `pub fn bus_socket(&self) -> PathBuf`; `pub fn add_node(&self, name: &str, node_id: Option<&str>, mode: Mode, peer: Option<&str>) -> io::Result<NodeSpec>` (writes the config JSON; bus mode sets `bus_socket` = `/data/bus.sock` — the in-container path, since the fleet dir is mounted at `/data`; log/socket paths `/data/<name>/log`, `/data/<name>/sock`); `pub fn stop_bus(&mut self)` (kill child). `pub enum Mode { Bt, Sim }`; `pub struct NodeSpec { pub name: String, pub dir: PathBuf }`. On drop without `stop_bus`, the daemon child is killed.
- [ ] **Step 1: Write the failing tests**
`tines-gui/tests/docker.rs` — the exact command vectors: `start_bt_args_match_pinned_flags`, `start_sim_args_match_pinned_flags` (assert the full argv), `stop_and_remove_args`.
`tines-gui/tests/fleet.rs` — `fleet_dir_layout` (dirs exist, bus.sock parent exists, daemon child spawned), `add_node_bus_config_mounts_data` (config JSON: `transport == "bus"`, `bus_socket == "/data/bus.sock"`, `socket_path == "/data/<name>/sock"`, and it parses as `tines::config::Config`), `add_node_bt_config_has_peer`, `generated_id_roundtrips`.
- [ ] **Step 2: Run tests to verify they fail**
Run: `cargo test --manifest-path tines-gui/Cargo.toml`
Expected: FAIL (modules do not exist).
- [ ] **Step 3: Implement** `docker.rs` and `fleet.rs` exactly as pinned.
- [ ] **Step 4: Run tests to verify they pass**
Run: `cargo test --manifest-path tines-gui/Cargo.toml && cargo clippy --manifest-path tines-gui/Cargo.toml --all-targets -- -D warnings`
Expected: PASS.
- [ ] **Step 5: Commit**
```bash
git add tines-gui/
git commit -m "feat(gui): docker orchestration and bus-daemon supervision"
```
---
### Task 8a: Launcher UI
**Files:**
- Create: `tines-gui/src/launcher.rs`
- Modify: `tines-gui/src/main.rs` (arg dispatch: no args → launcher; `--node <dir> <container-id> <mode>` → node window (Task 8b))
- Test: `tines-gui/tests/launcher.rs` (egui_kittest)
**Interfaces:**
- Consumes: Tasks 6/7 (`Fleet`, `Docker`, `config`, theme).
- Produces: `pub struct Launcher { fleet: Option<Fleet>, docker: Docker, containers: Vec<ContainerEntry>, children: Vec<std::process::Child>, actions: VecDeque<LauncherAction> }` with `pub struct ContainerEntry { name: String, id: ContainerId, mode: Mode }` and `pub enum LauncherAction { StartContainer { name: String, mode: Mode }, StopContainer(ContainerId), RemoveContainer(ContainerId), OpenWindow { name: String, dir: PathBuf, id: ContainerId, mode: Mode }, BuildImage }`. `pub fn update(&mut self, ctx: &egui::Context)` renders: mode picker (Bt / Sim), fleet name field + create, node list (name, mode, running state), per-node Open/Stop/Remove buttons, image build button, log tail pane for the selected container. State changes and subprocess spawns happen ONLY by draining `actions` in `main.rs` (keeps the UI testable headless).
- [ ] **Step 1: Write the failing tests** (`egui_kittest`, no real docker/daemon — inject a `Fleet`-less state and assert on `actions`):
`create_fleet_action_queues_spawn` — type a fleet name, click Create → `actions` contains `StartContainer`-preceding spawn intent (assert via the fleet field set + action drain helper).
`open_window_queues_spawn` — click a node's Open → `actions` contains `OpenWindow` with the right name/dir.
`stop_queues_stop_action` — click Stop → `StopContainer` with the right id.
- [ ] **Step 2: Run tests to verify they fail**
Run: `cargo test --manifest-path tines-gui/Cargo.toml --test launcher`
Expected: FAIL.
- [ ] **Step 3: Implement** `launcher.rs` + the `main.rs` dispatch + action drain (spawn children with `--node`; build image on `BuildImage`).
- [ ] **Step 4: Run tests to verify they pass**
Run: `cargo test --manifest-path tines-gui/Cargo.toml --test launcher && cargo clippy --manifest-path tines-gui/Cargo.toml --all-targets -- -D warnings`
Expected: PASS.
- [ ] **Step 5: Commit**
```bash
git add tines-gui/
git commit -m "feat(gui): launcher — fleet creation, container controls, window spawn"
```
---
### Task 8b: Node window UI
**Files:**
- Create: `tines-gui/src/window.rs`
- Modify: `tines-gui/src/main.rs` (the `--node` arm builds the window app)
- Test: `tines-gui/tests/window.rs` (egui_kittest)
**Interfaces:**
- Consumes: Task 6's `IpcClient`, Task 2's widened protocol, Task 8a's arg dispatch.
- Produces: `pub struct NodeWin { client: IpcClient, name: String, mode: Mode, transport: TransportKind, status: Option<StatusData>, beliefs: VecDeque<BeliefOut>, feed: VecDeque<FeedEntry>, store_rows: Vec<ObservationOut>, last_id: Option<String> }` with `pub enum FeedEntry { Contact { peer: String, joined: bool }, Delivered { peer: String, id: String }, Dropped { peer: Option<String>, reason: String, count: u32 } }`. `pub fn update(&mut self, ctx: &egui::Context)` renders the spec's layout: status strip (node id from `Status`, next_seq, stored, transport from the launcher-passed arg — NOT from the wire), observe panel (topic, payload, optional recipient + lifetime, text-with-hex-toggle), belief panel, store browser (from `List`), events feed, container panel (`docker logs` via `Docker`, stop/remove buttons). All I/O goes through the `IpcClient`; the UI thread never blocks (requests queue, responses drain each frame; `Events` polled every 500 ms). BT windows show the single-peer hint from the spec.
- `eframe` app wiring follows the wayland-gui skill: `NativeOptions { vsync: false, ... }`, `with_transparent(false)`, `OmarchyTheme` colors painted every frame.
- [ ] **Step 1: Write the failing tests** (egui_kittest against `NodeWin::update` with the fake server from Task 6's test helpers moved into a shared test util):
`status_strip_shows_node_id` — after the fake server answers `Status`, the strip text contains the hex id.
`observe_button_queues_observe` — type topic/payload, click Send → the client queue carries `Request::Observe` with hex-encoded topic/payload and no `to`.
`addressed_fields_queue_observe_to` — fill recipient + lifetime → `Request::Observe` carries `to`/`lifetime_ms`.
`feed_shows_contact` — fake server answers `Events` with a contact → feed contains the peer hex.
- [ ] **Step 2: Run tests to verify they fail**
Run: `cargo test --manifest-path tines-gui/Cargo.toml --test window`
Expected: FAIL.
- [ ] **Step 3: Implement** `window.rs` and the `--node` arm.
- [ ] **Step 4: Run tests to verify they pass**
Run: `cargo test --manifest-path tines-gui/Cargo.toml --test window && cargo clippy --manifest-path tines-gui/Cargo.toml --all-targets -- -D warnings`
Expected: PASS.
- [ ] **Step 5: Manual verification** — `cargo run --manifest-path tines-gui/Cargo.toml`; build the image; create a 2-node sim fleet; confirm both windows show contact events, an observation typed in one appears in the other's store and beliefs; then stop and remove the fleet. Screenshot the window and match chrome to the desktop theme (wayland-gui skill).
- [ ] **Step 6: Commit**
```bash
git add tines-gui/
git commit -m "feat(gui): node window — observe, beliefs, store, events, container panel"
```
---
### Task 9: Docs and housekeeping
**Files:**
- Modify: `AGENTS.md` (crate layout: add `tines-bus`; note `tines-gui` as a standalone local tool like `fuzz/`; note decision C in the core section), `README.md` (console + bus transport sections tracking the code), `CHANGELOG.md` (Unreleased: bus transport, widened IPC, transport choice, console), `THREAT_MODEL.md` (F3: bus drops the single-peer limit as a test medium; new entries: bus daemon as a host attack surface — 0700 fleet dir, routing-header checks; IPC widening — new request kinds keep the Error posture)
**Interfaces:** none — prose only, tracking code that exists.
- [ ] **Step 1:** Write the doc edits (nothing to test).
- [ ] **Step 2:** Run the full gate: `sh scripts/ci.sh` — everything green, including the new gui lines.
- [ ] **Step 3: Commit**
```bash
git add AGENTS.md README.md CHANGELOG.md THREAT_MODEL.md
git commit -m "docs: fleet console, bus transport, widened IPC"
```
---
## Final Verification (before considering the work done)
- `sh scripts/ci.sh` fully green (workspace + gui + fuzz lines).
- `cargo test --manifest-path tines-gui/Cargo.toml` green.
- Manual run from Task 8b Step 5 completed: sim fleet of 2 converges, contacts visible, addressed observe visible, BT mode smoke (container starts, window opens, radio optional).
- `docker build -f Dockerfile.tines -t tines-stack:latest .` green.
- Design doc + plan committed with the code; CHANGELOG entry present.
@@ -0,0 +1,230 @@
# tines fleet console + simulated fleet — design
Date: 2026-10-24
Status: approved by Kyle (2026-10-24) — decision C: add
`NodeEvent::Contact`/`NodeEvent::Lost` to core (see "The how")
## Intent
A desktop GUI (new `tines-gui` crate) that operates tines nodes in two
containerized modes:
1. **Bluetooth**: a container with the host Bluetooth adapter passed
through runs the full robot stack; its node talks to real tines
robots over BLE.
2. **Simulated fleet**: containers on the host run the full stack with a
new IPC-bus transport (new `tines-bus` crate); they exchange tines
frames with each other over a host bus.
The GUI is a console: a launcher screen plus one window per container.
Each window exposes the whole node control surface — observe (including
addressed), belief, store listing, events (contacts, drops, confirmed
deliveries), status — so tines can be exercised without hand-rolled JSON.
## Why
- Testing tines today means hand-crafting length-prefixed JSON to a Unix
socket; contacts and custody can only be watched in stderr.
- The two-device BLE check (`tines-bt/README.md`) has never been
recorded; a BT container plus a console makes it a routine manual test.
- The only exchange transports are BLE (real radio) and tines-sim's
in-process memory transport; nothing lets separate processes exchange
frames. A simulated fleet on one machine needs a host IPC transport.
- No runtime container image exists (`Dockerfile.ros2` is a CI
build-check).
- Kyle wants to test tines functionality and interact with tines-enabled
robots (hexapod on a Jetson running ROS2, Pi-class robots).
## Goals
- G1. `tines-gui`: an egui/eframe Wayland app following this desktop's
conventions (the `wayland-gui` skill): a launcher, plus one GUI process
per container.
- G2. BT mode: a containerized full stack with host Bluetooth passthrough;
the container's node is a real radio peer for robots in range.
- G3. Sim mode: a new `tines-bus` host transport crate over a host IPC
bus; containers plug in and exchange frames; fleet size is
operator-chosen.
- G4. The `tines` service config gains a transport choice (`bt` default,
`bus`); existing config files keep working.
- G5. Widened service IPC: addressed observe (`to` + `lifetime_ms` via
`Node::observe_to`), contact/lost events, dropped-frame events, and a
store listing; the existing four operations are unchanged.
- G6. GUI windows expose every node operation; the docker lifecycle
(create, start, stop, remove, logs) is managed from the GUI.
- G7. A runtime container image in the repo (new Dockerfile); both modes
run from the same image with different runtime flags.
## Non-goals
- `tines-core` changes limited to one additive pair (decision C, approved
2026-10-24): `NodeEvent::Contact`/`NodeEvent::Lost` variants, so the
console's event feed can show live contacts. Nothing else in core
changes: it stays `no_std`, and the 1.0 branch is unaffected (the enum
is non-exhaustive). The bus is a host crate implementing the existing
`Transport` trait; the IPC widening is in the `tines` service crate.
- No `tines-sim` changes: the simulator remains the protocol test
harness; the simulated fleet runs real services over the bus transport.
- No ROS2 integration this round: containers run the service (Pi-class
load). `tines-ros2` is untouched; a ROS2-backed container is future
work.
- Not a fleet deployment tool: the console is for development, testing
and interacting; systemd units and production deployment stay out of
scope.
- No node in the GUI process: the `Node` is not `Send` and the GUI never
hosts one; it only speaks IPC to containerized services.
## Anti-goals
- No weakening of the trust boundary: the widened IPC keeps the existing
posture — malformed input gets an `Error` response, never a panic;
frame caps stay; hex-on-the-wire stays.
- No silent config breakage: `transport` defaults to `bt`; existing
configs parse unchanged; unknown keys are still rejected.
- No radio in the GUI process; no BLE access outside the BT container.
- No X11: eframe features `glow`/`default_fonts`/`wayland` only.
- No docker SDK dependency: docker is driven as a subprocess (CLI).
## Constraints
- AGENTS.md applies unchanged: spec-first, TDD with
`sh scripts/ci.sh` before commit, conventional commits. New crates go
in the workspace and the AGENTS.md crate layout.
- The `Node` is not `Send`; the service's inline poll architecture is
unchanged. `tines-bus` is a `Transport` implementation and must not
require `Send` of the node (same as `BtTransport`).
- The bus carries the core's wire frames (`MAX_FRAME` = 1500 and friends
are unchanged).
- Docker is available on this desktop; the GUI shells out to it.
- The console follows `wayland-gui` conventions: theme watch on
`~/.local/state/omarchy/current`, `vsync = false`, opaque window
painted with the theme background.
## The how (decided with Kyle)
### Core change (decision C, approved 2026-10-24)
Add `NodeEvent::Contact` / `NodeEvent::Lost` variants to core, emitted
from the exchange's `on_event` when the transport reports a contact
starting or ending. The enum is non-exhaustive and the variants ride the
existing drop-oldest event queue, so this is additive and changes no
wire format. This is the only core change in this work.
### Modes
- **Launcher**: two choices — Bluetooth (real robots) and Simulated
fleet. From it: create containers (name them, choose or generate node
ids), see the running fleet, open any node's window.
- **One GUI process per container** (Kyle: "one GUI instance per
container"): the launcher spawns a child process of the same binary in
node-window mode, so a hung container can't freeze the fleet view.
- **BT mode**: `docker run` privileged, host network, D-Bus access
(bluer/BlueZ needs the system bus and the hci device). Service config:
node id, peer (the service still takes one — F3 remainder; the GUI
exposes exactly what the service supports), log path on a named
volume, socket path on a mounted directory. The GUI connects to the
socket on the host filesystem.
- **Sim mode**: the launcher starts a bus daemon on the host, then N
containers, each running the service with the bus transport and the
socket directory mounted. GUI windows drive each node.
- **Bus access control** (wntrmute's call): the bus daemon's socket lives
in a per-fleet directory under the operator's XDG state directory,
created `0700`. Same-user local processes are already inside the trust
boundary (THREAT_MODEL); the bus adds no new posture.
- **Status strip transport** (wntrmute's call): the launcher knows the
transport because it wrote the container's config; it passes it to the
window process. `Status` on the wire is unchanged.
- **Addressed panel in BT mode** (wntrmute's call): the panel stays in
every window — `observe_to` is a valid node operation whatever the
transport. BT windows show a hint that the transport currently
exchanges with exactly one configured peer, so an addressed
observation to anyone else will sit until its lifetime expires.
### The bus transport (`tines-bus`, new crate)
- Implements `Transport` with `Addr = NodeId`, exactly like `tines-bt`:
frames are the core wire frames; a frame's declared sender is checked
against the bound peer (same address-spoofing rule).
- **Topology** (wntrmute's call; Kyle can override): hub-and-spoke. A
small bus daemon (a bin in the crate) binds a Unix socket in a
per-fleet directory on the host; each node connects once. The bus fans
every frame out to all connected nodes — a shared medium, like radio —
and reports joins/leaves so the transport can emit `Contact`/`Lost`.
- **Peers**: the bus transport enforces core's `MAX_PEERS` (8), as
tines-bt does; an empty allowlist means any bus peer (the bus is a
test medium, so it drops bt's single-peer F3 limit).
- **Framing**: length-prefixed core frames over the socket; no new wire
format.
- **Contact model**: any frame from a bus peer is an implicit contact
(core already treats unknown addresses that way); daemon disconnect →
`Lost` for every peer.
### Service changes (`tines` crate)
- **Config**: new optional `transport` key, `"bt"` (default) or `"bus"`;
with `bus`, a `bus_socket` path key. `peer` stays required for `bt`;
for `bus` it is an optional allowlist (empty = any peer).
- **IPC widening** (backward-compatible: new tagged variants; the
existing four operations are untouched):
- `Observe` gains optional `to` (hex `NodeId`) and `lifetime_ms`
(present together or not at all) → `Node::observe_to`; without them,
the existing `observe` path.
- `Events` also returns contact/lost events (peer hex + kind) and
dropped-frame events (reason + count), alongside confirmed
deliveries. This changes the current D3 surfacing (drops to stderr
only) so the console can show them; the stderr line stays.
- New `List` request → the stored observations (id, observer, topic,
payload, addressed destination, age), capped so a response fits the
1 MiB frame cap.
- `Status` unchanged.
- Trusted-time behavior is unchanged; the console shows service stderr
via `docker logs`.
### The GUI (`tines-gui`, new crate)
- **Window layout**: status strip (node id, next seq, stored count,
transport, socket path); observe panel (topic, payload, optional
recipient + lifetime, broadcast default; text mode with a hex toggle —
the wire is hex, the operator should not have to hex-encode by hand);
belief panel (query a topic; list known topics from `List`); store
browser (`List` view with refresh); events feed (contacts, lost,
delivered, dropped, live); container panel (`docker logs` tail,
stop/remove/restart).
- **Config generation**: the launcher writes each container's service
config JSON (matching the `deny_unknown_fields` schema), generates
random node ids unless the operator supplies them, and manages
volume/socket paths under a per-fleet state directory.
- **IPC client**: the GUI speaks the widened IPC through a client module
implementing the length-prefixed JSON framing. The request/response
types are public in the `tines` crate (lib + bin), so the GUI can
depend on the lib and share the types (builder's call, leaning yes).
### Docker image
- New `Dockerfile.tines`: builds the workspace, installs BlueZ runtime
dependencies; the entrypoint runs the `tines` service with a mounted
config. Both modes use this image; BT mode adds the privileged/host
network and D-Bus/hci plumbing at run time.
## What's left to the builder
- Exact JSON schemas for the new IPC variants (within the constraints
above).
- Bus daemon protocol details (framing, join/leave messages, fan-out).
- egui layout specifics and the theme integration.
- Docker run flags per mode (device passthrough, volume layout, image
build/tag flow).
- Launcher supervision of child processes and daemons (PID tracking,
cleanup on exit).
## Success looks like
- Sim mode: N containers exchange over the bus; broadcast observations
converge in every node; an addressed observation with custody relay is
watchable in the events feeds.
- BT mode: the container's node exchanges with a real tines robot over
BLE — the two-device check from `tines-bt/README.md` is finally run
and recorded from the console.
- Every operation in the widened IPC is reachable from a window without
hand-rolled JSON.
+12
View File
@@ -24,3 +24,15 @@ sh scripts/check-no-std.sh
# compiling on the pinned stable toolchain, or the scheduled job rots unseen.
cargo fmt --manifest-path fuzz/Cargo.toml -- --check
cargo clippy --locked --manifest-path fuzz/Cargo.toml --all-targets -- -D warnings
# The tines-gui crate sits outside the workspace like fuzz/ (standalone
# manifest + lockfile, GUI release cadence), but it must keep resolving and
# compiling on the pinned stable toolchain.
cargo fmt --manifest-path tines-gui/Cargo.toml -- --check
cargo clippy --locked --manifest-path tines-gui/Cargo.toml --all-targets -- -D warnings
# Post-1.0.0 release gate: every 1.x change to tines-core must stay
# semver-compatible with the published 1.0.0. Skipped where the tool is not
# installed (`cargo install cargo-semver-checks --locked`); the GitHub Actions
# `ci` job installs a prebuilt binary so the gate is enforced there.
if command -v cargo-semver-checks >/dev/null 2>&1; then
cargo semver-checks check-release -p tines-core --baseline-version 1.0.0
fi
+15
View File
@@ -0,0 +1,15 @@
[package]
name = "tines-bus"
version.workspace = true
edition.workspace = true
license.workspace = true
description = "Bus transport for tines: exchange wire frames between nodes over a host IPC bus (Unix sockets), identity-preserving (Addr = NodeId)."
authors.workspace = true
repository.workspace = true
readme.workspace = true
documentation = "https://docs.rs/tines-bus"
keywords = ["robotics", "gossip", "delay-tolerant", "ipc", "unix-socket"]
categories = ["science::robotics", "network-programming"]
[dependencies]
tines-core = { path = "../tines-core", version = "1" }
+16
View File
@@ -0,0 +1,16 @@
//! The tines bus daemon: routes envelopes between connected clients on a
//! host IPC socket. Usage: `tines-busd <socket-path>`.
fn main() {
let socket = match std::env::args().nth(1) {
Some(s) => s,
None => {
eprintln!("usage: tines-busd <socket-path>");
std::process::exit(2);
}
};
if let Err(e) = tines_bus::daemon::serve(&socket) {
eprintln!("tines-busd: {e}");
std::process::exit(1);
}
}
+318
View File
@@ -0,0 +1,318 @@
//! The bus daemon: a single-threaded, nonblocking hub that routes envelopes
//! between connected clients.
//!
//! # Protocol
//!
//! Every message is `[u32 LE length][payload]`, the length counting payload
//! bytes only (bus-local; the core frames inside are untouched).
//!
//! - **Hello** (client→daemon, first message): payload = 16-byte `NodeId`.
//! A hello for an already-registered id replaces the old connection (the
//! old socket is closed).
//! - **Data** (client→daemon): payload = `[16B from][16B to][frame]`. The
//! daemon drops (and counts on stderr) any message whose `from` is not that
//! socket's registered id, or whose `to` is not registered; otherwise it
//! forwards the envelope verbatim to `to`'s socket. Frames longer than
//! [`MAX_FRAME`] close the connection.
//! - **Control** (daemon→client): payload = `[1B kind][16B NodeId]`; kind
//! `0 = joined`, `1 = left`; sent to every other registered client on join,
//! and to all remaining on leave.
//!
//! EOF or an error from a client's socket is a leave plus close; the daemon
//! itself stays up. No frame content is decoded beyond the routing header.
use std::io::{self, ErrorKind, Read, Write};
use std::os::unix::net::{UnixListener, UnixStream};
use std::path::Path;
use std::time::Duration;
use tines_core::consts::MAX_FRAME;
use tines_core::NodeId;
/// Routing-header size (from + to) of a data envelope.
const HEADER_BYTES: usize = 32;
/// Longest envelope payload the daemon will buffer or forward.
const MAX_PAYLOAD: usize = MAX_FRAME + HEADER_BYTES;
fn id_of(bytes: &[u8]) -> NodeId {
let mut id = [0u8; 16];
id.copy_from_slice(bytes);
id
}
fn control(kind: u8, id: NodeId) -> Vec<u8> {
let mut env = Vec::with_capacity(4 + 17);
env.extend_from_slice(&17u32.to_le_bytes());
env.push(kind);
env.extend_from_slice(&id);
env
}
/// Read one envelope's payload from `s`; `Ok(None)` means clean EOF.
pub(crate) fn read_envelope(s: &mut UnixStream) -> io::Result<Option<Vec<u8>>> {
let mut len_buf = [0u8; 4];
let n = s.read(&mut len_buf)?;
if n == 0 {
return Ok(None);
}
if n != 4 {
return Err(io::Error::new(
ErrorKind::InvalidData,
"short envelope length header",
));
}
let len = u32::from_le_bytes(len_buf) as usize;
if len > MAX_PAYLOAD {
return Err(io::Error::new(
ErrorKind::InvalidData,
"envelope over the size cap",
));
}
let mut payload = vec![0u8; len];
s.read_exact(&mut payload)?;
Ok(Some(payload))
}
struct Conn {
id: Option<NodeId>,
stream: UnixStream,
buf: Vec<u8>,
/// Set to evict this connection on the next apply pass.
close_pending: bool,
}
/// A queued action from processing one message.
enum Action {
/// Close the connection at `idx`; if it was registered, announce a leave.
Close { idx: usize },
/// Write a prepared envelope to the connection at `to`.
Forward { to: usize, env: Vec<u8> },
}
/// Serve the bus on `socket` until the process dies.
///
/// A stale socket file from a previous run is removed first so `bind` cannot
/// fail on it.
pub fn serve(socket: impl AsRef<Path>) -> io::Result<()> {
let socket = socket.as_ref().to_path_buf();
let _ = std::fs::remove_file(&socket);
let listener = UnixListener::bind(&socket)?;
listener.set_nonblocking(true)?;
let mut conns: Vec<Conn> = Vec::new();
loop {
// Accept everything pending.
loop {
match listener.accept() {
Ok((stream, _)) => {
stream.set_nonblocking(true)?;
conns.push(Conn {
id: None,
stream,
buf: Vec::new(),
close_pending: false,
});
}
Err(ref e) if e.kind() == ErrorKind::WouldBlock => break,
Err(_) => break,
}
}
// Read + parse each connection, queueing actions.
let mut actions: Vec<Action> = Vec::new();
for i in 0..conns.len() {
let (closed, msgs) = {
let c = &mut conns[i];
let mut closed = false;
let mut chunk = [0u8; 4096];
loop {
match c.stream.read(&mut chunk) {
Ok(0) => {
closed = true;
break;
}
Ok(n) => {
c.buf.extend_from_slice(&chunk[..n]);
if n < chunk.len() {
break;
}
}
Err(ref e) if e.kind() == ErrorKind::WouldBlock => break,
Err(_) => {
closed = true;
break;
}
}
}
// Split complete envelopes out of the buffer.
let mut msgs = Vec::new();
loop {
if c.buf.len() < 4 {
break;
}
let len = u32::from_le_bytes([c.buf[0], c.buf[1], c.buf[2], c.buf[3]]) as usize;
if len > MAX_PAYLOAD {
// Oversized envelope: protocol violation.
closed = true;
break;
}
if c.buf.len() < 4 + len {
break;
}
msgs.push(c.buf[4..4 + len].to_vec());
c.buf.drain(..4 + len);
}
(closed, msgs)
};
// Process complete messages even when the connection is being
// closed: a burst hello+oversized-data must still register the
// id so the leave is announced to the right peers.
for msg in msgs {
process_msg(i, &msg, &mut conns, &mut actions);
}
if closed {
actions.push(Action::Close { idx: i });
}
}
apply_actions(&mut actions, &mut conns);
std::thread::sleep(Duration::from_millis(2));
}
}
/// Handle one envelope from the connection at `idx`.
fn process_msg(idx: usize, payload: &[u8], conns: &mut [Conn], actions: &mut Vec<Action>) {
let (before, rest) = conns.split_at_mut(idx);
let (me, after) = rest.split_at_mut(1);
let me = &mut me[0];
match me.id {
None => {
// Unregistered sockets may only hello.
if payload.len() != 16 {
actions.push(Action::Close { idx });
return;
}
let id = id_of(payload);
// A hello for an already-registered id replaces the old
// connection (silently: peers never see a flap).
let is_new = !before.iter().chain(after.iter()).any(|c| c.id == Some(id));
if !is_new {
for c in before.iter_mut().chain(after.iter_mut()) {
if c.id == Some(id) {
c.close_pending = true;
}
}
}
me.id = Some(id);
if is_new {
let env = control(0, id);
for (j, c) in before
.iter()
.enumerate()
.chain(after.iter().enumerate().map(|(k, c)| (k + idx + 1, c)))
{
if c.id.is_some() {
actions.push(Action::Forward {
to: j,
env: env.clone(),
});
}
}
// ...and the newcomer learns everyone already here.
for c in before.iter().chain(after.iter()) {
if let Some(existing) = c.id {
actions.push(Action::Forward {
to: idx,
env: control(0, existing),
});
}
}
}
}
Some(my_id) => {
if payload.len() == 16 {
// Repeat hello on an already-registered socket: no-op.
return;
}
if payload.len() < 32 {
actions.push(Action::Close { idx });
return;
}
let from = id_of(&payload[0..16]);
let to = id_of(&payload[16..32]);
let frame_len = payload.len() - 32;
if from != my_id {
// The daemon certifies `from`: never route a forgery.
eprintln!("tines-busd: dropped: from mismatch on registered socket");
return;
}
if frame_len > MAX_FRAME {
actions.push(Action::Close { idx });
return;
}
// `position()` on the chain is the actual index only for peers
// before the sender; after the sender it is one short.
let to_idx = before.iter().position(|c| c.id == Some(to)).or_else(|| {
after
.iter()
.position(|c| c.id == Some(to))
.map(|k| k + idx + 1)
});
match to_idx {
Some(j) => {
let mut env = Vec::with_capacity(4 + payload.len());
env.extend_from_slice(&(payload.len() as u32).to_le_bytes());
env.extend_from_slice(payload);
actions.push(Action::Forward { to: j, env });
}
None => eprintln!("tines-busd: dropped: unknown destination"),
}
}
}
}
/// Run queued actions: forwards first, then closes (announcing leaves).
fn apply_actions(actions: &mut [Action], conns: &mut Vec<Conn>) {
// Forwards (only to live, registered connections).
for a in actions.iter() {
if let Action::Forward { to, env } = a {
if let Some(c) = conns.get_mut(*to) {
if !c.close_pending && c.id.is_some() && c.stream.write_all(env).is_err() {
c.close_pending = true;
}
}
}
}
// Closes. A registered connection that leaves (EOF, error, protocol
// violation) is announced to everyone remaining; an evicted duplicate
// already has `close_pending` set and is silent.
let mut announced: Vec<NodeId> = Vec::new();
for a in actions.iter() {
if let Action::Close { idx } = a {
if let Some(c) = conns.get_mut(*idx) {
c.close_pending = true;
if let Some(id) = c.id {
announced.push(id);
}
}
}
}
if !announced.is_empty() {
let envs: Vec<Vec<u8>> = announced.iter().map(|id| control(1, *id)).collect();
for env in envs {
for c in conns.iter_mut() {
if c.close_pending || c.id.is_none() {
continue;
}
if c.stream.write_all(&env).is_err() {
c.close_pending = true;
}
}
}
}
conns.retain(|c| !c.close_pending);
}
+13
View File
@@ -0,0 +1,13 @@
//! The tines bus transport: exchange wire frames between nodes over a host
//! IPC bus (Unix sockets), identity-preserving (`Addr = NodeId`).
//!
//! [`BusTransport`] is the tines [`tines_core::Transport`] implementation;
//! [`daemon`] is the hub that routes frames between all connected nodes. The
//! two sides of a fleet talk through one shared daemon socket.
#![deny(missing_docs)]
pub mod daemon;
mod transport;
pub use transport::{BusError, BusEvent, BusLink, BusTransport, DaemonLink};
+285
View File
@@ -0,0 +1,285 @@
//! The bus transport: a [`Transport`] whose peers are other tines nodes
//! connected to the same host bus daemon (see [`crate::daemon`]).
//!
//! The pure state machine ([`BusTransport`]) is generic over a narrow I/O
//! seam, [`BusLink`], mirroring how `tines-bt` splits logic from radio. The
//! real link ([`DaemonLink`]) is a thread-driven client of the bus daemon;
//! it lives in this crate but is not part of the pure logic.
use std::io::{self, Write};
use std::path::Path;
use std::sync::mpsc;
use tines_core::consts::MAX_FRAME;
use tines_core::{frame_sender, Event, FrameBuf, SendError, Transport};
use crate::daemon;
pub use tines_core::NodeId;
/// An event observed on the bus link.
#[derive(Debug)]
pub enum BusEvent {
/// A peer registered with the daemon.
Joined(NodeId),
/// A registered peer left (or its connection was replaced).
Left(NodeId),
/// A data envelope arrived, attributed to the daemon-certified sender.
Frame(NodeId, Box<FrameBuf>),
/// The link itself closed (daemon gone, socket error, or local shutdown).
Closed,
}
/// Errors a [`BusLink`] can report from `send`.
#[derive(Debug)]
pub enum BusError {
/// The underlying socket/channel failed.
Io(io::Error),
/// The destination is not reachable on this link.
Unreachable,
}
/// The narrow I/O seam behind [`BusTransport`].
///
/// `send` receives the *frame bytes only*; the link stamps the envelope with
/// the sender's identity, so a caller cannot forge who a frame is from.
pub trait BusLink {
/// Poll for the next link event, if any is ready.
fn poll(&mut self) -> Option<BusEvent>;
/// Queue `data` for delivery to `to`.
fn send(&mut self, to: &NodeId, data: &[u8]) -> Result<(), BusError>;
/// Largest frame this link can carry (its transport MTU).
fn mtu(&self) -> usize;
}
/// A thread-driven client of the bus daemon.
///
/// A writer thread owns the socket (sending the hello first, then queued
/// envelopes) and a reader thread owns a clone, decoding inbound envelopes
/// into [`BusEvent`]s. Dropping the link drops the writer's queue, which
/// shuts the socket down, which unblocks the reader — no explicit shutdown
/// protocol needed.
#[derive(Debug)]
pub struct DaemonLink {
me: NodeId,
out_tx: mpsc::Sender<Vec<u8>>,
ev_rx: mpsc::Receiver<BusEvent>,
}
impl DaemonLink {
/// Connect to the daemon at `socket` and hello as `me`.
///
/// The connect retries briefly because a freshly spawned daemon binds
/// asynchronously.
pub fn connect(me: NodeId, socket: &Path) -> io::Result<DaemonLink> {
let mut stream = retry_connect(socket)?;
let mut reader = stream.try_clone()?;
let (out_tx, out_rx) = mpsc::channel::<Vec<u8>>();
let (ev_tx, ev_rx) = mpsc::channel::<BusEvent>();
// Writer thread: hello first, then queued envelopes. On queue
// disconnect (the transport was dropped) it shuts the socket down,
// which also unblocks the reader thread.
let mut hello = Vec::with_capacity(4 + 16);
hello.extend_from_slice(&16u32.to_le_bytes());
hello.extend_from_slice(&me);
std::thread::spawn(move || {
if stream.write_all(&hello).is_err() {
let _ = stream.shutdown(std::net::Shutdown::Both);
return;
}
while let Ok(env) = out_rx.recv() {
if stream.write_all(&env).is_err() {
break;
}
}
let _ = stream.shutdown(std::net::Shutdown::Both);
});
// Reader thread: decode envelopes into events.
std::thread::spawn(move || {
loop {
let payload = match daemon::read_envelope(&mut reader) {
Ok(Some(p)) => p,
Ok(None) | Err(_) => {
let _ = ev_tx.send(BusEvent::Closed);
return;
}
};
let ev = match payload.len() {
17 => match payload[0] {
0 => BusEvent::Joined(id_of(&payload[1..17])),
_ => BusEvent::Left(id_of(&payload[1..17])),
},
n if n >= 32 => {
let from = id_of(&payload[0..16]);
let frame = match FrameBuf::from_slice(&payload[32..]) {
Ok(f) => f,
// Overlong frame: protocol violation, treat as
// a dead link.
Err(_) => {
let _ = ev_tx.send(BusEvent::Closed);
return;
}
};
BusEvent::Frame(from, Box::new(frame))
}
_ => continue, // unknown message shape: ignore
};
if ev_tx.send(ev).is_err() {
return;
}
}
});
Ok(DaemonLink { me, out_tx, ev_rx })
}
}
impl BusLink for DaemonLink {
fn poll(&mut self) -> Option<BusEvent> {
self.ev_rx.try_recv().ok()
}
fn send(&mut self, to: &NodeId, data: &[u8]) -> Result<(), BusError> {
// Envelope: [u32 LE payload len][from][to][frame].
let mut env = Vec::with_capacity(4 + 32 + data.len());
env.extend_from_slice(&((32 + data.len()) as u32).to_le_bytes());
env.extend_from_slice(&self.me);
env.extend_from_slice(to);
env.extend_from_slice(data);
self.out_tx
.send(env)
.map_err(|_| BusError::Io(io::Error::new(io::ErrorKind::BrokenPipe, "bus link closed")))
}
fn mtu(&self) -> usize {
MAX_FRAME
}
}
/// Connect to `socket`, retrying briefly while a just-spawned daemon binds.
fn retry_connect(socket: &Path) -> io::Result<std::os::unix::net::UnixStream> {
let mut last_err = None;
for _ in 0..40 {
match std::os::unix::net::UnixStream::connect(socket) {
Ok(s) => return Ok(s),
Err(e) => last_err = Some(e),
}
std::thread::sleep(std::time::Duration::from_millis(5));
}
Err(last_err.unwrap_or_else(|| {
io::Error::new(io::ErrorKind::ConnectionRefused, "bus daemon never bound")
}))
}
fn id_of(bytes: &[u8]) -> NodeId {
let mut id = [0u8; 16];
id.copy_from_slice(bytes);
id
}
/// The bus transport: tines `Transport` over a [`BusLink`].
///
/// `type Addr = NodeId` — the daemon certifies each message's sender, so bus
/// peer identity is as strong as the other transports'.
pub struct BusTransport<L: BusLink = DaemonLink> {
me: NodeId,
allowlist: Option<NodeId>,
peers: Vec<NodeId>,
link: L,
closed: bool,
}
impl BusTransport<DaemonLink> {
/// Create a bus transport connected to the daemon at `socket`.
///
/// `allowlist` pins the one peer this transport may see and reach
/// (`None` = every bus peer), mirroring the single-peer allowlist of the
/// bt transport.
pub fn new(
me: NodeId,
allowlist: Option<NodeId>,
socket: impl AsRef<Path>,
) -> io::Result<BusTransport<DaemonLink>> {
let link = DaemonLink::connect(me, socket.as_ref())?;
Ok(BusTransport {
me,
allowlist,
peers: Vec::new(),
link,
closed: false,
})
}
}
impl<L: BusLink> BusTransport<L> {
/// Whether `peer` passes the allowlist (and is not ourselves).
fn allows(&self, peer: &NodeId) -> bool {
*peer != self.me && self.allowlist.is_none_or(|a| a == *peer)
}
}
impl<L: BusLink> Transport for BusTransport<L> {
type Addr = NodeId;
fn poll(&mut self) -> Option<Event<NodeId>> {
loop {
match self.link.poll()? {
BusEvent::Joined(addr) => {
if !self.allows(&addr) || self.peers.contains(&addr) {
continue;
}
self.peers.push(addr);
return Some(Event::Contact(addr));
}
BusEvent::Left(addr) => {
if let Some(pos) = self.peers.iter().position(|p| *p == addr) {
self.peers.remove(pos);
return Some(Event::Lost(addr));
}
}
BusEvent::Frame(peer, frame) => {
if !self.allows(&peer) {
continue;
}
// The daemon certifies the envelope's `from`; the frame's
// own declared sender must agree with it.
if frame_sender(frame.as_slice()) != Some(peer) {
continue;
}
return Some(Event::Frame(peer, *frame));
}
BusEvent::Closed => {
if self.closed {
return None;
}
self.closed = true;
// Every bound peer is gone; drain one `Lost` per poll.
return self.peers.pop().map(Event::Lost);
}
}
}
}
fn send(&mut self, to: &NodeId, frame: &[u8]) -> Result<(), SendError> {
if !self.allows(to) || !self.peers.contains(to) {
return Err(SendError::Unreachable);
}
if frame.len() > self.mtu() {
return Err(SendError::TooLarge);
}
match self.link.send(to, frame) {
Ok(()) => Ok(()),
// The only failure modes left are a dead link (nothing is
// reachable any more) and a bad destination.
Err(_) => Err(SendError::Unreachable),
}
}
fn mtu(&self) -> usize {
self.link.mtu()
}
}
+34
View File
@@ -0,0 +1,34 @@
//! Shared helpers for the tines-bus integration tests.
#![allow(dead_code)]
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicUsize, Ordering};
static COUNTER: AtomicUsize = AtomicUsize::new(0);
/// A fresh, unique scratch directory under the system temp dir.
pub fn unique_dir(tag: &str) -> PathBuf {
let n = COUNTER.fetch_add(1, Ordering::Relaxed);
let dir = std::env::temp_dir().join(format!("tines-bus-{tag}-{}-{n}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
dir
}
/// Spawn the bus daemon on a thread and return its socket path once bound.
pub fn spawn_daemon(dir: &Path) -> PathBuf {
let sock = dir.join("bus.sock");
let s = sock.clone();
std::thread::spawn(move || {
if let Err(e) = tines_bus::daemon::serve(&s) {
panic!("daemon failed: {e}");
}
});
// Wait for the daemon to bind, so the first connect cannot race it.
for _ in 0..400 {
if sock.exists() {
return sock;
}
std::thread::sleep(std::time::Duration::from_millis(5));
}
panic!("daemon did not bind in time");
}
+197
View File
@@ -0,0 +1,197 @@
//! Protocol-level tests driving raw Unix sockets straight at the daemon.
mod common;
use common::{spawn_daemon, unique_dir};
use std::io::{ErrorKind, Read, Write};
use std::os::unix::net::UnixStream;
use std::time::{Duration, Instant};
use tines_core::consts::MAX_FRAME;
type NodeId = [u8; 16];
fn connect(sock: &std::path::Path) -> UnixStream {
let s = UnixStream::connect(sock).unwrap();
s.set_read_timeout(Some(Duration::from_secs(2))).unwrap();
s
}
fn send_envelope(s: &mut UnixStream, payload: &[u8]) {
let mut buf = Vec::with_capacity(4 + payload.len());
buf.extend_from_slice(&(payload.len() as u32).to_le_bytes());
buf.extend_from_slice(payload);
s.write_all(&buf).unwrap();
}
fn hello(s: &mut UnixStream, id: &NodeId) {
send_envelope(s, id);
}
fn data(s: &mut UnixStream, from: &NodeId, to: &NodeId, frame: &[u8]) {
let mut p = Vec::with_capacity(32 + frame.len());
p.extend_from_slice(from);
p.extend_from_slice(to);
p.extend_from_slice(frame);
send_envelope(s, &p);
}
/// Read one envelope's payload; `Ok(None)` means EOF.
fn read_envelope(s: &mut UnixStream) -> std::io::Result<Option<Vec<u8>>> {
let mut len_buf = [0u8; 4];
let n = s.read(&mut len_buf)?;
if n == 0 {
return Ok(None);
}
assert_eq!(n, 4, "short length header");
let len = u32::from_le_bytes(len_buf) as usize;
let mut payload = vec![0u8; len];
s.read_exact(&mut payload)?;
Ok(Some(payload))
}
fn expect_control(s: &mut UnixStream, kind: u8, id: &NodeId) {
let p = read_envelope(s)
.unwrap()
.expect("connection closed unexpectedly");
assert_eq!(p.len(), 17, "control payload is kind + 16-byte id");
assert_eq!(p[0], kind);
assert_eq!(&p[1..17], id);
}
/// Assert the socket stays silent for `window_ms`, then return.
fn expect_no_data(s: &mut UnixStream, window_ms: u64) {
s.set_read_timeout(Some(Duration::from_millis(100)))
.unwrap();
let deadline = Instant::now() + Duration::from_millis(window_ms);
let mut buf = [0u8; 1];
loop {
match s.read(&mut buf) {
Ok(0) => panic!("connection closed, expected silence"),
Ok(_) => panic!("expected no data, got some"),
Err(e) if e.kind() == ErrorKind::WouldBlock || e.kind() == ErrorKind::TimedOut => {
if Instant::now() >= deadline {
return;
}
std::thread::sleep(Duration::from_millis(5));
}
Err(e) => panic!("unexpected read error: {e}"),
}
}
}
#[test]
fn newcomer_learns_existing_peers() {
let dir = unique_dir("newcomer");
let sock = spawn_daemon(&dir);
let mut a = connect(&sock);
hello(&mut a, &[1; 16]);
// B joins after A: B must be told A is already there.
let mut b = connect(&sock);
hello(&mut b, &[2; 16]);
expect_control(&mut a, 0, &[2; 16]);
expect_control(&mut b, 0, &[1; 16]);
}
#[test]
fn duplicate_hello_replaces() {
let dir = unique_dir("dup");
let sock = spawn_daemon(&dir);
let mut s1 = connect(&sock);
hello(&mut s1, &[1; 16]);
let mut s2 = connect(&sock);
hello(&mut s2, &[1; 16]);
// The first connection is closed by the daemon...
let mut buf = [0u8; 1];
assert_eq!(
s1.read(&mut buf).unwrap(),
0,
"first connection must see EOF after replacement"
);
// ...and the daemon stays up, serving the replacement: a third peer's
// join reaches s2 as a control message.
let mut s3 = connect(&sock);
hello(&mut s3, &[2; 16]);
expect_control(&mut s2, 0, &[2; 16]);
}
#[test]
fn mismatched_from_is_dropped() {
let dir = unique_dir("mismatch");
let sock = spawn_daemon(&dir);
let mut a = connect(&sock);
hello(&mut a, &[1; 16]);
let mut b = connect(&sock);
hello(&mut b, &[2; 16]);
// B learns about A on join (the newcomer announcement).
expect_control(&mut b, 0, &[1; 16]);
// A (registered as [1;16]) claims to be [2;16]: nothing may reach B.
data(&mut a, &[2; 16], &[2; 16], &[0xAB; 10]);
expect_no_data(&mut b, 300);
// The daemon survives and A is still connected: a well-formed message
// is forwarded verbatim.
data(&mut a, &[1; 16], &[2; 16], &[0xCD; 10]);
let p = read_envelope(&mut b).unwrap().expect("connection closed");
assert_eq!(p.len(), 42);
assert_eq!(&p[0..16], &[1; 16]);
assert_eq!(&p[16..32], &[2; 16]);
assert_eq!(&p[32..42], &[0xCD; 10]);
}
#[test]
fn unknown_dest_is_dropped() {
let dir = unique_dir("unknown");
let sock = spawn_daemon(&dir);
let mut a = connect(&sock);
hello(&mut a, &[1; 16]);
// No [9;16] registered: dropped, and A stays connected.
data(&mut a, &[1; 16], &[9; 16], &[0xAB; 10]);
let mut b = connect(&sock);
hello(&mut b, &[2; 16]);
// Wait until the daemon has registered B: A sees the join control.
expect_control(&mut a, 0, &[2; 16]);
// B's side of the same join: the newcomer learns A is already here.
expect_control(&mut b, 0, &[1; 16]);
data(&mut a, &[1; 16], &[2; 16], &[0xCD; 10]);
let p = read_envelope(&mut b).unwrap().expect("connection closed");
assert_eq!(p.len(), 42);
assert_eq!(&p[32..42], &[0xCD; 10]);
}
#[test]
fn oversized_frame_closes_connection() {
let dir = unique_dir("oversize");
let sock = spawn_daemon(&dir);
let mut a = connect(&sock);
hello(&mut a, &[1; 16]);
let mut b = connect(&sock);
hello(&mut b, &[2; 16]);
// Handshake: once A has seen B join, both hellos are registered, and
// B has learned A on its side too.
expect_control(&mut a, 0, &[2; 16]);
expect_control(&mut b, 0, &[1; 16]);
// A frame one byte over MAX_FRAME: that connection is closed...
data(&mut a, &[1; 16], &[2; 16], &vec![0u8; MAX_FRAME + 1]);
let mut buf = [0u8; 1];
assert_eq!(
a.read(&mut buf).unwrap(),
0,
"oversized frame must close the connection"
);
// ...and B learns A left; the daemon stays up for everyone else.
expect_control(&mut b, 1, &[1; 16]);
}
+191
View File
@@ -0,0 +1,191 @@
//! End-to-end tests for the bus transport: real `BusTransport`s on both ends
//! of a live daemon, exchanging core wire frames.
mod common;
use common::{spawn_daemon, unique_dir};
use std::path::Path;
use std::time::{Duration, Instant};
use tines_bus::BusTransport;
use tines_core::{consts, Event, SendError, Transport};
type NodeId = [u8; 16];
/// A minimally valid wire frame declaring `from` as its sender:
/// `WIRE_VERSION`, a variant tag byte, then the 16-byte sender id
/// (`frame_sender` reads exactly those bytes).
fn frame_from(from: &NodeId) -> Vec<u8> {
let mut f = Vec::with_capacity(18);
f.push(consts::WIRE_VERSION);
f.push(0);
f.extend_from_slice(from);
f
}
/// Poll `t` until `pred` accepts an event, or panic after `timeout`.
fn poll_until(
t: &mut BusTransport,
mut pred: impl FnMut(&Event<NodeId>) -> bool,
timeout: Duration,
) -> Event<NodeId> {
let deadline = Instant::now() + timeout;
loop {
if let Some(ev) = t.poll() {
if pred(&ev) {
return ev;
}
}
assert!(
Instant::now() < deadline,
"timed out waiting for a bus event"
);
std::thread::sleep(Duration::from_millis(5));
}
}
/// Assert `t` yields no events for `window`, then give up.
fn expect_quiet(t: &mut BusTransport, window: Duration) {
let deadline = Instant::now() + window;
while Instant::now() < deadline {
assert!(t.poll().is_none(), "expected no events, got one");
std::thread::sleep(Duration::from_millis(5));
}
}
fn daemon_for(dir: &Path) -> std::path::PathBuf {
spawn_daemon(dir)
}
#[test]
fn two_transports_exchange_frames() {
let dir = unique_dir("xfer");
let sock = daemon_for(&dir);
let mut a = BusTransport::new([1; 16], None, &sock).unwrap();
let mut b = BusTransport::new([2; 16], None, &sock).unwrap();
// A learns about B when B joins.
match poll_until(
&mut a,
|ev| matches!(ev, Event::Contact(_)),
Duration::from_secs(3),
) {
Event::Contact(id) => assert_eq!(id, [2; 16]),
other => panic!("expected Contact, got {other:?}"),
}
let frame = frame_from(&[1; 16]);
a.send(&[2; 16], &frame).unwrap();
// B receives the frame from A with identical bytes.
match poll_until(
&mut b,
|ev| matches!(ev, Event::Frame(_, _)),
Duration::from_secs(3),
) {
Event::Frame(peer, fbuf) => {
assert_eq!(peer, [1; 16]);
assert_eq!(fbuf.as_slice(), frame.as_slice());
}
other => panic!("expected Frame, got {other:?}"),
}
}
#[test]
fn spoofed_frame_is_dropped() {
let dir = unique_dir("spoof");
let sock = daemon_for(&dir);
let mut a = BusTransport::new([1; 16], None, &sock).unwrap();
let mut b = BusTransport::new([2; 16], None, &sock).unwrap();
poll_until(
&mut a,
|ev| matches!(ev, Event::Contact(_)),
Duration::from_secs(3),
);
// B's side of the same join: the newcomer learns A is there.
match poll_until(
&mut b,
|ev| matches!(ev, Event::Contact(_)),
Duration::from_secs(3),
) {
Event::Contact(id) => assert_eq!(id, [1; 16]),
other => panic!("expected Contact, got {other:?}"),
}
// A (complicit) sends a frame that claims to be from C: the bus stamps
// A as the envelope sender, so B must never surface it.
let spoofed = frame_from(&[9; 16]);
a.send(&[2; 16], &spoofed).unwrap();
expect_quiet(&mut b, Duration::from_millis(400));
// The link is still healthy: a genuine frame from A arrives intact.
let real = frame_from(&[1; 16]);
a.send(&[2; 16], &real).unwrap();
match poll_until(
&mut b,
|ev| matches!(ev, Event::Frame(_, _)),
Duration::from_secs(3),
) {
Event::Frame(peer, fbuf) => {
assert_eq!(peer, [1; 16]);
assert_eq!(fbuf.as_slice(), real.as_slice());
}
other => panic!("expected Frame, got {other:?}"),
}
}
#[test]
fn join_and_leave_become_contact_and_lost() {
let dir = unique_dir("joinleave");
let sock = daemon_for(&dir);
let mut a = BusTransport::new([1; 16], None, &sock).unwrap();
let b = BusTransport::new([2; 16], None, &sock).unwrap();
match poll_until(
&mut a,
|ev| matches!(ev, Event::Contact(_)),
Duration::from_secs(3),
) {
Event::Contact(id) => assert_eq!(id, [2; 16]),
other => panic!("expected Contact, got {other:?}"),
}
drop(b);
match poll_until(
&mut a,
|ev| matches!(ev, Event::Lost(_)),
Duration::from_secs(3),
) {
Event::Lost(id) => assert_eq!(id, [2; 16]),
other => panic!("expected Lost, got {other:?}"),
}
}
#[test]
fn allowlist_filters() {
let dir = unique_dir("allow");
let sock = daemon_for(&dir);
let mut a = BusTransport::new([1; 16], Some([2; 16]), &sock).unwrap();
let _b = BusTransport::new([2; 16], None, &sock).unwrap();
let _c = BusTransport::new([3; 16], None, &sock).unwrap();
// Only the allowlisted peer surfaces.
match poll_until(
&mut a,
|ev| matches!(ev, Event::Contact(_)),
Duration::from_secs(3),
) {
Event::Contact(id) => assert_eq!(id, [2; 16]),
other => panic!("expected Contact, got {other:?}"),
}
// C's join is filtered out: nothing else surfaces.
expect_quiet(&mut a, Duration::from_millis(400));
// And sending to a non-allowlisted peer is refused.
let frame = frame_from(&[1; 16]);
match a.send(&[3; 16], &frame) {
Err(SendError::Unreachable) => {}
other => panic!("expected Unreachable, got {other:?}"),
}
}
+171 -18
View File
@@ -99,6 +99,26 @@ pub enum NodeEvent<A> {
/// The number of frames folded into this counter since the last drain.
count: u32,
},
/// A new contact started with the peer at `addr` (a first `Contact`
/// event for an addr we were not already tracking). A repeat `Contact`
/// for a known peer restarts the contact silently. Idle eviction (F8)
/// does not emit this.
///
/// `addr` is the transport address; for both the BLE and bus transports
/// it IS the peer's [`NodeId`]. Best-effort telemetry: when the event
/// queue is full, an incoming contact is dropped rather than evicting
/// queued events.
Contact {
/// The wire address of the peer the contact started with.
addr: A,
},
/// The peer at `addr` was lost: a transport `Lost` event for a peer we
/// were tracking. A `Lost` for an unknown addr, and F8 idle eviction,
/// emit nothing.
Lost {
/// The wire address of the peer that was lost.
addr: A,
},
}
/// The `Exchange`'s counted-drop table: up to [`consts::DROP_TABLE_CAP`]
@@ -323,7 +343,9 @@ pub(crate) struct Exchange<A> {
/// record.
bcast_sent: Vec<(ObservationId, A), { consts::BCAST_SENT_CAP }>,
/// One [`NodeEvent::Delivered`] per broadcast id a peer has confirmed,
/// pushed drop-oldest when full. Drain through [`Exchange::drain_events`].
/// plus one [`NodeEvent::Contact`]/[`NodeEvent::Lost`] per contact
/// change, pushed drop-oldest when full. Drain through
/// [`Exchange::drain_events`].
events: Vec<NodeEvent<A>, { consts::EVENT_QUEUE_CAP }>,
/// Counted drops: distinct `(addr, DropReason)` pairs, each with a
/// saturating count, plus a single overflow counter; reported through
@@ -375,11 +397,15 @@ impl<A: Clone + PartialEq + core::fmt::Debug> Exchange<A> {
) -> Option<Observation> {
match ev {
Event::Contact(addr) => {
self.add_peer(addr, now);
if self.add_peer(addr.clone(), now) {
self.push_event(NodeEvent::Contact { addr });
}
None
}
Event::Lost(addr) => {
self.remove_peer(&addr);
if self.remove_peer(&addr) {
self.push_event(NodeEvent::Lost { addr });
}
None
}
Event::Frame(addr, bytes) => self.on_frame(addr, &bytes, store, now),
@@ -829,13 +855,24 @@ impl<A: Clone + PartialEq + core::fmt::Debug> Exchange<A> {
&self.peers
}
/// Drains the [`NodeEvent`]s — confirmed deliveries plus counted drops —
/// for [`crate::Node::drain_events`].
/// Pushes an event onto the delivery queue with drop-oldest overflow
/// (F13): when the queue is full the OLDEST event is evicted so the
/// freshest telemetry survives (a bare `push` would drop the newest).
fn push_event(&mut self, ev: NodeEvent<A>) {
if self.events.is_full() {
let _ = self.events.remove(0);
}
let _ = self.events.push(ev);
}
/// Drains the [`NodeEvent`]s — contacts and losses, confirmed deliveries,
/// plus counted drops — for [`crate::Node::drain_events`].
///
/// Returns confirmed deliveries first, oldest first, then one
/// Returns queued events first, oldest first (contacts/losses and
/// deliveries share the queue in the order they happened), then one
/// [`NodeEvent::Dropped`] per non-zero counter, emptying the drop table.
/// Sized to hold the entire delivery queue plus the whole drop table and
/// its overflow slot, so a full delivery queue never evicts a counted drop.
/// Sized to hold the entire event queue plus the whole drop table and
/// its overflow slot, so a full queue never evicts a counted drop.
pub(crate) fn drain_events(
&mut self,
) -> Vec<NodeEvent<A>, { consts::EVENT_QUEUE_CAP + consts::DROP_TABLE_CAP + 1 }> {
@@ -862,18 +899,25 @@ impl<A: Clone + PartialEq + core::fmt::Debug> Exchange<A> {
/// starts a new contact too: the F6 pin and the per-contact records
/// (bounces, broadcast receipts) reset.
/// A repeat `Contact` also restarts the peer's idle clock.
fn add_peer(&mut self, addr: A, now: Tick) {
///
/// Returns `true` only when a new [`Peer`] was actually added to the
/// table (the contact is "new" for the console's event feed). A repeat
/// contact, and a contact that cannot be added because the table is
/// full ([`consts::MAX_PEERS`]), return `false`.
fn add_peer(&mut self, addr: A, now: Tick) -> bool {
self.evict_idle(now);
self.forget_contact(&addr);
if let Some(peer) = self.peers.iter_mut().find(|p| p.addr == addr) {
peer.id = None;
peer.added_at = now;
peer.last_inbound = None;
return;
return false;
}
if self.peers.len() < consts::MAX_PEERS {
let _ = self.peers.push(Peer::new(addr, now));
return true;
}
false
}
/// F8: removes, as if Lost, every peer with no inbound frame (or, if it
@@ -894,10 +938,13 @@ impl<A: Clone + PartialEq + core::fmt::Debug> Exchange<A> {
}
}
fn remove_peer(&mut self, addr: &A) {
if let Some(idx) = self.peers.iter().position(|p| p.addr == *addr) {
fn remove_peer(&mut self, addr: &A) -> bool {
let removed = if let Some(idx) = self.peers.iter().position(|p| p.addr == *addr) {
self.peers.remove(idx);
}
true
} else {
false
};
// F13: purge the removed peer's pending acks (only its own) so their
// ids are never acked again under this contact.
self.acks.retain_other(addr);
@@ -906,6 +953,7 @@ impl<A: Clone + PartialEq + core::fmt::Debug> Exchange<A> {
self.ack_undo = None;
}
self.forget_contact(addr);
removed
}
/// Drops the per-contact records for `addr` — bounce suppression and
@@ -1047,10 +1095,7 @@ impl<A: Clone + PartialEq + core::fmt::Debug> Exchange<A> {
// delivery queue is full, drop the OLDEST Delivered rather than
// the newest (a bare `push` drops the newest) so the freshest
// confirmed deliveries survive overflow (F13).
if self.events.is_full() {
let _ = self.events.remove(0);
}
let _ = self.events.push(NodeEvent::Delivered {
self.push_event(NodeEvent::Delivered {
peer: from,
id: *id,
});
@@ -2981,6 +3026,7 @@ mod tests {
let b_id = b.id();
store.insert(b, 0).unwrap();
deliver_to(&mut ex, &mut store, 1, b_id);
let _ = ex.drain_events(); // clear the contact event
assert!(!store.is_released(&b_id));
let ids = heapless::Vec::from_slice(&[b_id]).unwrap();
@@ -3126,6 +3172,7 @@ mod tests {
store.insert(b, 0).unwrap();
// Dispatch the broadcast to a different peer (addr 2), not addr 1.
deliver_to(&mut ex, &mut store, 2, b_id);
let _ = ex.drain_events(); // clear the contact event
// Peer 1 (addr 1) claims to hold b_id though it never did.
let ids = heapless::Vec::from_slice(&[b_id]).unwrap();
@@ -3154,6 +3201,7 @@ mod tests {
store.insert(b, 0).unwrap();
deliver_to(&mut ex, &mut store, 1, a_id);
deliver_to(&mut ex, &mut store, 1, b_id);
let _ = ex.drain_events(); // clear the contact event
let ack_for = |ids: heapless::Vec<ObservationId, { consts::ACK_IDS_CAP }>| Frame::Ack {
from: peer,
@@ -3195,6 +3243,7 @@ mod tests {
let b_id = b.id();
store.insert(b, 0).unwrap();
deliver_to(&mut ex, &mut store, 1, b_id);
let _ = ex.drain_events(); // clear the contact event
let ids = heapless::Vec::from_slice(&[b_id]).unwrap();
let ack = Frame::Ack { from: peer, ids };
@@ -3357,6 +3406,7 @@ mod tests {
match ev {
NodeEvent::Delivered { .. } => delivered += 1,
NodeEvent::Dropped { .. } => dropped += 1,
NodeEvent::Contact { .. } | NodeEvent::Lost { .. } => {}
}
}
assert_eq!(
@@ -3367,6 +3417,105 @@ mod tests {
assert_eq!(dropped, 1, "the counted drop is reported alongside them");
}
/// A first `Contact` for an addr emits one [`NodeEvent::Contact`] naming
/// it; a repeat `Contact` for a known peer restarts the contact silently.
#[test]
fn contact_for_new_peer_emits_event() {
let mut ex = Exchange::new(ME);
let mut store = MemStore::new();
ex.on_event(Event::Contact(1), &mut store, 0);
let events = ex.drain_events();
assert_eq!(events.len(), 1, "a new contact emits one event");
assert_eq!(events[0], NodeEvent::Contact { addr: 1 });
ex.on_event(Event::Contact(1), &mut store, 0);
assert!(
ex.drain_events().is_empty(),
"a repeat contact for a known peer emits nothing"
);
}
/// `Lost` for a peer we were tracking emits [`NodeEvent::Lost`]; `Lost`
/// for an unknown addr emits nothing.
#[test]
fn lost_for_known_peer_emits_event() {
let mut ex = Exchange::new(ME);
let mut store = MemStore::new();
ex.on_event(Event::Contact(1), &mut store, 0);
let _ = ex.drain_events(); // clear the contact event
ex.on_event(Event::Lost(1), &mut store, 0);
let events = ex.drain_events();
assert_eq!(events.len(), 1, "losing a known peer emits one event");
assert_eq!(events[0], NodeEvent::Lost { addr: 1 });
ex.on_event(Event::Lost(2), &mut store, 0);
assert!(
ex.drain_events().is_empty(),
"losing an unknown peer emits nothing"
);
}
/// F8 idle eviction removes the peer but is not a transport contact
/// event: it must stay silent in the event feed.
#[test]
fn idle_eviction_emits_nothing() {
let mut ex = Exchange::new(ME);
let mut store = MemStore::new();
ex.on_event(Event::Contact(1), &mut store, 0);
let _ = ex.drain_events();
let mut buf = [0u8; consts::MAX_FRAME];
let _ = ex.next_frame(
&store,
consts::MAX_FRAME,
consts::PEER_IDLE_MS + 1,
&mut buf,
);
assert!(
ex.drain_events().is_empty(),
"idle eviction is not a contact event"
);
}
/// A full event queue evicts the OLDEST event for an incoming contact
/// (F13): contact telemetry is best-effort, queued deliveries survive.
#[test]
fn contact_event_rides_the_drop_oldest_queue() {
let mut store = MemStore::new();
let mut ex = Exchange::<u8>::new(ME);
for seq in 0..consts::EVENT_QUEUE_CAP as u64 {
ex.events
.push(NodeEvent::Delivered {
peer: [7; 16],
id: [seq as u8; 16],
})
.unwrap();
}
ex.on_event(Event::Contact(1), &mut store, 0);
let drained = ex.drain_events();
assert_eq!(
drained.len(),
consts::EVENT_QUEUE_CAP,
"the queue stays at capacity"
);
let first = match &drained[0] {
NodeEvent::Delivered { id, .. } => *id,
other => panic!("expected the first event to be a Delivered, got {other:?}"),
};
assert_eq!(
first, [1u8; 16],
"the oldest Delivered (seq 0) was evicted for the contact"
);
match drained.last().unwrap() {
NodeEvent::Contact { addr } => assert_eq!(*addr, 1),
other => panic!("expected the newest event to be the Contact, got {other:?}"),
}
}
/// Once the drop-counter table is full, the next drop folds into a single
/// overflow counter reported with `addr: None`; the table's 32 entries are
/// all still reported with a real address.
@@ -3392,7 +3541,9 @@ mod tests {
match ev {
NodeEvent::Dropped { addr: Some(..), .. } => reported += 1,
NodeEvent::Dropped { addr: None, .. } => overflow += 1,
NodeEvent::Delivered { .. } => {}
NodeEvent::Delivered { .. }
| NodeEvent::Contact { .. }
| NodeEvent::Lost { .. } => {}
}
}
assert_eq!(
@@ -3466,6 +3617,7 @@ mod tests {
let mut ex = Exchange::new(ME);
let mut store = MemStore::new();
ex.on_event(Event::Contact(1), &mut store, 0);
let _ = ex.drain_events(); // clear the contact event
ex.on_event(Event::Frame(1, hello(a, 1)), &mut store, 0);
assert_eq!(ex.peers()[0].id, Some(a));
@@ -3552,6 +3704,7 @@ mod tests {
let b_id = b.id();
store.insert(b, 0).unwrap();
deliver_to(&mut ex, &mut store, 1, b_id);
let _ = ex.drain_events(); // clear the contact event
let ack = Frame::Ack {
from: peer,
+3
View File
@@ -0,0 +1,3 @@
# The root .gitignore's `/target` is anchored to the repo root; this
# standalone crate's target dir needs its own ignore.
/target
+4600
View File
File diff suppressed because it is too large Load Diff
+23
View File
@@ -0,0 +1,23 @@
[package]
name = "tines-gui"
version = "0.1.0"
edition = "2024"
license = "Apache-2.0"
description = "Fleet console for tines: launcher and per-node windows driving the service IPC"
publish = false
[dependencies]
tines = { path = "../tines", version = "0.4.0" }
eframe = { version = "0.36", default-features = false, features = ["glow", "default_fonts", "wayland"] }
egui = "0.36"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
notify = "8"
[dev-dependencies]
egui_kittest = { version = "0.36", features = ["wgpu", "eframe"] }
tempfile = "3"
# Like fuzz/, this crate sits outside the workspace (the GUI is a separate
# deliverable with its own release cadence).
[workspace]
+133
View File
@@ -0,0 +1,133 @@
//! Fleet/node layout on disk and the service configs the launcher writes.
//!
//! The launcher owns per-fleet state under
//! `$XDG_STATE_HOME|~/.local/state/tines-console/`, and each node's
//! `config.json` is exactly the service's [`tines::config::Config`] schema —
//! the service rejects unknown keys, so the generated config must match it
//! byte-for-byte (pinned by `tests/config.rs`).
use std::fs::{self, Permissions};
use std::io::{self, Read};
use std::os::unix::fs::PermissionsExt;
use std::path::PathBuf;
/// The per-fleet state dir, created `0700` on first use:
/// `$XDG_STATE_HOME|~/.local/state` + `tines-console/fleets/<fleet>`.
pub fn fleet_dir(fleet: &str) -> PathBuf {
let base = state_home();
let dir = base.join("tines-console").join("fleets").join(fleet);
if let Err(e) = fs::create_dir_all(&dir) {
eprintln!("tines-gui: cannot create fleet dir {}: {e}", dir.display());
}
// create_dir_all honors the umask; the fleets carry node sockets and
// logs, so they are private by design.
if let Err(e) = fs::set_permissions(&dir, Permissions::from_mode(0o700)) {
eprintln!("tines-gui: cannot chmod fleet dir {}: {e}", dir.display());
}
dir
}
/// A node's dir inside a fleet: `<fleet_dir>/<node>`. Not created here —
/// [`NodeConfig::write`] creates it when a config lands.
pub fn node_dir(fleet: &str, node: &str) -> PathBuf {
fleet_dir(fleet).join(node)
}
/// A fresh random node id: 32 lowercase hex chars from 16 bytes of
/// `/dev/urandom`.
pub fn generate_node_id() -> String {
let mut buf = [0u8; 16];
let mut f =
std::fs::File::open("/dev/urandom").expect("/dev/urandom is available on this platform");
f.read_exact(&mut buf)
.expect("reading 16 bytes from /dev/urandom");
buf.iter().map(|b| format!("{b:02x}")).collect()
}
/// A node's service config, serializing to exactly the service schema.
#[derive(Debug, Clone, serde::Serialize)]
pub struct NodeConfig {
/// This node's id (32 lowercase hex chars).
pub node_id: String,
/// bt: the peer; bus: the allowlist of one, `None` for any peer.
#[serde(skip_serializing_if = "Option::is_none")]
pub peer: Option<String>,
/// Absolute path to the LogStore log inside the container.
pub log_path: String,
/// Absolute path to the IPC socket inside the container.
pub socket_path: String,
/// `"bt"` or `"bus"`.
pub transport: String,
/// bus only: the bus daemon's socket inside the container.
#[serde(skip_serializing_if = "Option::is_none")]
pub bus_socket: Option<String>,
/// `"system"` when the host clock is trustworthy (the container's is).
#[serde(skip_serializing_if = "Option::is_none")]
pub trusted_clock: Option<String>,
}
impl NodeConfig {
/// A bt-mode config: one peer over the BLE transport.
pub fn bt(
node_id: String,
peer: Option<String>,
log_path: String,
socket_path: String,
) -> Self {
NodeConfig {
node_id,
peer,
log_path,
socket_path,
transport: "bt".into(),
bus_socket: None,
trusted_clock: Some("system".into()),
}
}
/// A bus-mode config: peers are every node on the bus daemon (or the
/// allowlisted one).
pub fn bus(
node_id: String,
peer: Option<String>,
log_path: String,
socket_path: String,
bus_socket: String,
) -> Self {
NodeConfig {
node_id,
peer,
log_path,
socket_path,
transport: "bus".into(),
bus_socket: Some(bus_socket),
trusted_clock: Some("system".into()),
}
}
/// Write this config to `<node_dir(fleet, node)>/config.json` (creating
/// the node dir, `0700`) and return the path.
pub fn write(&self, fleet: &str, node: &str) -> io::Result<PathBuf> {
let dir = node_dir(fleet, node);
fs::create_dir_all(&dir)?;
fs::set_permissions(&dir, Permissions::from_mode(0o700))?;
let path = dir.join("config.json");
let json = serde_json::to_string_pretty(self).map_err(io::Error::other)?;
fs::write(&path, json)?;
Ok(path)
}
}
/// `$XDG_STATE_HOME` when set to an absolute path, else `~/.local/state`.
fn state_home() -> PathBuf {
if let Some(dir) = std::env::var_os("XDG_STATE_HOME") {
let p = PathBuf::from(dir);
if p.is_absolute() {
return p;
}
}
std::env::var_os("HOME")
.map(PathBuf::from)
.unwrap_or_else(std::env::temp_dir)
.join(".local/state")
}
+217
View File
@@ -0,0 +1,217 @@
//! Docker orchestration: build the runtime image and drive one container
//! per node. Every command runs through `std::process::Command` with
//! pinned argv vectors — nothing is ever shell-interpolated.
use std::io;
use std::path::PathBuf;
use std::process::{Command, Output};
use crate::config::fleet_dir;
/// A docker container id (the value `docker run -d` prints).
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct ContainerId(pub String);
/// Failures the docker module can report.
#[derive(Debug)]
pub enum DockerError {
/// The `docker` binary is not on PATH.
NotInstalled,
/// The docker process could not be spawned.
Spawn(io::Error),
/// Docker ran but exited nonzero.
Exit {
/// The exit code.
code: i32,
/// The process's captured stderr.
stderr: String,
},
}
impl std::fmt::Display for DockerError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
DockerError::NotInstalled => write!(f, "docker is not installed or not on PATH"),
DockerError::Spawn(e) => write!(f, "failed to spawn docker: {e}"),
DockerError::Exit { code, stderr } => {
write!(f, "docker exited {code}: {}", stderr.trim())
}
}
}
}
impl std::error::Error for DockerError {}
/// The docker frontend. A unit struct: docker state lives in the daemon,
/// not here.
pub struct Docker;
impl Docker {
/// The pinned runtime image tag.
pub fn image() -> &'static str {
"tines-stack:latest"
}
/// Build the runtime image from the repo root — the crate's parent
/// directory, baked in at compile time (`CARGO_MANIFEST_DIR` points at
/// `tines-gui/`).
pub fn build_image(&self) -> Result<(), DockerError> {
let root = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.parent()
.expect("tines-gui lives one level below the repo root")
.to_path_buf();
let argv: Vec<String> = vec![
"docker".into(),
"build".into(),
"-f".into(),
"Dockerfile.tines".into(),
"-t".into(),
Self::image().into(),
".".into(),
];
let mut cmd = spawn(&argv)?;
cmd.current_dir(root);
run(&mut cmd)?;
Ok(())
}
/// The exact argv for `start_bt`. Exposed for tests and debugging;
/// [`Self::start_bt`] runs this vector verbatim.
pub fn start_bt_argv(&self, name: &str, fleet: &str) -> Vec<String> {
vec![
"docker".into(),
"run".into(),
"-d".into(),
"--name".into(),
format!("tines-bt-{name}"),
"--privileged".into(),
"--net=host".into(),
"-v".into(),
"/run/dbus/system_bus_socket:/run/dbus/system_bus_socket".into(),
"-v".into(),
format!("{}:/data", node_dir(fleet, name).display()),
Self::image().into(),
"tines".into(),
"/data/config.json".into(),
]
}
/// The exact argv for `start_sim`. Exposed for tests and debugging;
/// [`Self::start_sim`] runs this vector verbatim.
pub fn start_sim_argv(&self, name: &str, fleet: &str) -> Vec<String> {
vec![
"docker".into(),
"run".into(),
"-d".into(),
"--name".into(),
format!("tines-sim-{name}"),
"-v".into(),
format!("{}:/data", node_dir(fleet, name).display()),
Self::image().into(),
"tines".into(),
"/data/config.json".into(),
]
}
/// The exact argv for `stop`. Exposed for tests and debugging.
pub fn stop_argv(&self, id: &ContainerId) -> Vec<String> {
vec!["docker".into(), "stop".into(), id.0.clone()]
}
/// The exact argv for `remove`. Exposed for tests and debugging.
pub fn remove_argv(&self, id: &ContainerId) -> Vec<String> {
vec!["docker".into(), "rm".into(), id.0.clone()]
}
/// The exact argv for `logs`. Exposed for tests and debugging.
pub fn logs_argv(&self, id: &ContainerId, tail: u32) -> Vec<String> {
vec![
"docker".into(),
"logs".into(),
"--tail".into(),
tail.to_string(),
id.0.clone(),
]
}
/// Start a bt-mode node container: privileged, host networking, the
/// system D-Bus socket (for the host Bluetooth adapter), and the node's
/// fleet dir mounted at `/data`. Returns the container id.
pub fn start_bt(&self, name: &str, fleet: &str) -> Result<ContainerId, DockerError> {
let out = run_output(&self.start_bt_argv(name, fleet))?;
Ok(ContainerId(out.trim().to_string()))
}
/// Start a sim-mode node container: same shape, no privileges or host
/// networking. Returns the container id.
pub fn start_sim(&self, name: &str, fleet: &str) -> Result<ContainerId, DockerError> {
let out = run_output(&self.start_sim_argv(name, fleet))?;
Ok(ContainerId(out.trim().to_string()))
}
/// Stop a container.
pub fn stop(&self, id: &ContainerId) -> Result<(), DockerError> {
run(&mut spawn(&self.stop_argv(id))?)
}
/// Remove a (stopped) container.
pub fn remove(&self, id: &ContainerId) -> Result<(), DockerError> {
run(&mut spawn(&self.remove_argv(id))?)
}
/// The container's recent logs (stdout and stderr merged), newest
/// `tail` lines last.
pub fn logs(&self, id: &ContainerId, tail: u32) -> Result<String, DockerError> {
run_output(&self.logs_argv(id, tail))
}
/// Whether `docker inspect` succeeds for the id — i.e. the container
/// exists (the inspect exit status is the pin).
pub fn is_running(&self, id: &ContainerId) -> bool {
match Command::new("docker").arg("inspect").arg(&id.0).output() {
Ok(Output { status, .. }) => status.success(),
Err(_) => false,
}
}
}
/// `<fleet_dir>/<name>` — the dir mounted at `/data` inside the container.
fn node_dir(fleet: &str, name: &str) -> PathBuf {
fleet_dir(fleet).join(name)
}
/// Run a command expecting success, capturing stdout.
fn run_output(argv: &[String]) -> Result<String, DockerError> {
let out = spawn(argv)?.output().map_err(DockerError::Spawn)?;
if out.status.success() {
Ok(String::from_utf8_lossy(&out.stdout).into_owned())
} else {
Err(DockerError::Exit {
code: out.status.code().unwrap_or(-1),
stderr: String::from_utf8_lossy(&out.stderr).into_owned(),
})
}
}
/// Run a command expecting success, output inherited.
fn run(cmd: &mut Command) -> Result<(), DockerError> {
let status = cmd.status().map_err(DockerError::Spawn)?;
if status.success() {
Ok(())
} else {
Err(DockerError::Exit {
code: status.code().unwrap_or(-1),
stderr: String::new(),
})
}
}
/// The docker binary or a `NotInstalled` error.
fn spawn(argv: &[String]) -> Result<Command, DockerError> {
if Command::new("docker").arg("--version").output().is_err() {
return Err(DockerError::NotInstalled);
}
let mut cmd = Command::new(&argv[0]);
cmd.args(&argv[1..]);
Ok(cmd)
}
+119
View File
@@ -0,0 +1,119 @@
//! A fleet: the on-disk layout plus the bus daemon that serves it.
//!
//! [`Fleet::create`] makes the fleet dir (`0700`, via
//! [`crate::config::fleet_dir`]) and spawns `tines-busd
//! <fleet_dir>/bus.sock` as a supervised child — the daemon is what lets
//! every sim-mode node on the fleet exchange frames over the host IPC bus.
//! Node configs land under `<fleet_dir>/<name>/config.json` and mount into
//! the containers at `/data`, so all container-side paths are `/data/...`.
use std::io;
use std::path::PathBuf;
use std::process::{Child, Command};
use crate::config::{NodeConfig, fleet_dir, generate_node_id};
/// How a node connects to its peers.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Mode {
/// A real Bluetooth radio (privileged container, host adapter).
Bt,
/// The host IPC bus (simulated fleet).
Sim,
}
/// What `add_node` produced: the node's name and its config dir.
#[derive(Debug, Clone)]
pub struct NodeSpec {
/// The node name (also the dir and container-name stem).
pub name: String,
/// `<fleet_dir>/<name>` — holds `config.json`.
pub dir: PathBuf,
}
/// A live fleet with a supervised bus daemon.
pub struct Fleet {
/// The fleet name.
fleet: String,
/// `<fleet_dir>`.
dir: PathBuf,
/// The daemon child; `None` after `stop_bus`.
busd: Option<Child>,
}
impl Fleet {
/// Create the fleet dir (`0700`) and spawn the bus daemon on
/// `<fleet_dir>/bus.sock`. `tines-busd` must be on PATH.
pub fn create(fleet: &str) -> io::Result<Fleet> {
let dir = fleet_dir(fleet);
let child = Command::new("tines-busd")
.arg(dir.join("bus.sock"))
.spawn()
.map_err(|e| {
io::Error::new(
e.kind(),
format!("cannot spawn tines-busd (is it on PATH?): {e}"),
)
})?;
Ok(Fleet {
fleet: fleet.to_string(),
dir,
busd: Some(child),
})
}
/// The bus daemon's socket: `<fleet_dir>/bus.sock`.
pub fn bus_socket(&self) -> PathBuf {
self.dir.join("bus.sock")
}
/// Write a node's service config and return its spec. A missing
/// `node_id` gets a fresh generated one. Bus mode points `bus_socket`
/// at the in-container `/data/bus.sock` (the fleet dir is mounted at
/// `/data`); log and IPC paths are `/data/<name>/log` and
/// `/data/<name>/sock`.
pub fn add_node(
&self,
name: &str,
node_id: Option<&str>,
mode: Mode,
peer: Option<&str>,
) -> io::Result<NodeSpec> {
let node_id = match node_id {
Some(id) => id.to_string(),
None => generate_node_id(),
};
let peer = peer.map(str::to_string);
let log_path = format!("/data/{name}/log");
let socket_path = format!("/data/{name}/sock");
let config = match mode {
Mode::Bt => NodeConfig::bt(node_id, peer, log_path, socket_path),
Mode::Sim => NodeConfig::bus(
node_id,
peer,
log_path,
socket_path,
"/data/bus.sock".into(),
),
};
config.write(&self.fleet, name)?;
Ok(NodeSpec {
name: name.to_string(),
dir: self.dir.join(name),
})
}
/// Stop the bus daemon.
pub fn stop_bus(&mut self) {
if let Some(mut child) = self.busd.take() {
let _ = child.kill();
let _ = child.wait();
}
}
}
impl Drop for Fleet {
fn drop(&mut self) {
self.stop_bus();
}
}
+236
View File
@@ -0,0 +1,236 @@
//! An async, queue-based client for the `tines` service IPC.
//!
//! The wire protocol is the service's own: `[u32 LE len][JSON]`. The client
//! tags frames with a monotonically increasing message id **on the
//! client-side queue only** — `request` enqueues `[id, JSON body]` and the
//! worker thread writes `[len][JSON]` to the socket, so the service-side
//! protocol is untouched. The service answers one request per connection in
//! order, so the worker pairs responses with the outstanding ids FIFO; the
//! GUI polls [`IpcClient::try_recv`] for `(id, response)` pairs.
//!
//! The worker owns the socket and re-dials forever: a dead service never
//! blocks `request` (requests queue), and [`IpcClient::reconnect`] forces a
//! re-dial without dropping queued requests. If the connection dies between
//! a write and its response, the request is replayed after the re-dial —
//! observe is the dominant verb here, and re-observing beats losing data.
use std::collections::VecDeque;
use std::fmt;
use std::io::{self, Read, Write};
use std::os::unix::net::UnixStream;
use std::path::PathBuf;
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use std::sync::mpsc::{self, Receiver, Sender};
use std::thread;
use std::time::Duration;
use tines::{Request, Response};
/// Errors the client can report.
#[derive(Debug)]
pub enum ClientError {
/// A socket-level failure (including a dead worker thread).
Io(io::Error),
/// A request failed to serialize.
Json(serde_json::Error),
}
impl fmt::Display for ClientError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
ClientError::Io(e) => write!(f, "ipc client io: {e}"),
ClientError::Json(e) => write!(f, "ipc client json: {e}"),
}
}
}
impl std::error::Error for ClientError {}
/// Shared state between the handle and the worker thread.
struct Shared {
/// Set by [`IpcClient::reconnect`]; the worker redials and clears it.
force_reconnect: AtomicBool,
}
/// A queue-based client for one service socket.
pub struct IpcClient {
out: Sender<(u64, Vec<u8>)>,
rx: Receiver<(u64, Response)>,
next_id: Arc<AtomicU64>,
shared: Arc<Shared>,
_socket: PathBuf,
}
impl IpcClient {
/// Spawn the worker thread and return the client. The socket is dialed
/// on the worker thread, so this succeeds even while the service is
/// down.
pub fn spawn(socket: PathBuf) -> io::Result<IpcClient> {
let (out_tx, out_rx) = mpsc::channel();
let (resp_tx, resp_rx) = mpsc::channel();
let next_id = Arc::new(AtomicU64::new(0));
let shared = Arc::new(Shared {
force_reconnect: AtomicBool::new(false),
});
let worker_shared = Arc::clone(&shared);
let worker_socket = socket.clone();
thread::Builder::new()
.name("tines-gui-ipc".into())
.spawn(move || worker(worker_socket, out_rx, resp_tx, worker_shared))?;
Ok(IpcClient {
out: out_tx,
rx: resp_rx,
next_id,
shared,
_socket: socket,
})
}
/// Encode and queue a request; returns the message id it will be
/// answered under.
pub fn request(&self, req: Request) -> Result<u64, ClientError> {
let body = serde_json::to_vec(&req).map_err(ClientError::Json)?;
let id = self.next_id.fetch_add(1, Ordering::Relaxed);
self.out
.send((id, body))
.map_err(|e| ClientError::Io(io::Error::new(io::ErrorKind::BrokenPipe, e)))?;
Ok(id)
}
/// Nonblocking: the next completed `(message id, response)`, if any.
pub fn try_recv(&self) -> Option<(u64, Response)> {
self.rx.try_recv().ok()
}
/// Force the worker to drop its current connection and re-dial. Queued
/// requests are kept and replayed after the new dial.
pub fn reconnect(&self) {
self.shared.force_reconnect.store(true, Ordering::Relaxed);
}
}
/// Write one `[u32 LE len][body]` frame to the stream.
fn write_frame(stream: &mut UnixStream, body: &[u8]) -> io::Result<()> {
let len = (body.len() as u32).to_le_bytes();
stream.write_all(&len)?;
stream.write_all(body)?;
stream.flush()
}
/// Read one `[u32 LE len][body]` frame from the stream.
///
/// Mirrors the service's reader: a length prefix above [`MAX_FRAME`] is a
/// protocol violation (`InvalidData`) rather than an allocation, so a torn
/// or hostile frame kills the connection (worker re-dials) instead of the
/// process.
fn read_frame(stream: &mut impl Read) -> io::Result<Vec<u8>> {
let mut len_buf = [0u8; 4];
stream.read_exact(&mut len_buf)?;
let len = u32::from_le_bytes(len_buf) as usize;
if len > MAX_FRAME {
return Err(io::Error::new(
io::ErrorKind::InvalidData,
format!("frame {len} bytes exceeds {MAX_FRAME}"),
));
}
let mut buf = vec![0u8; len];
stream.read_exact(&mut buf)?;
Ok(buf)
}
/// 1 MiB cap on a single JSON frame, matching the service's own reader.
const MAX_FRAME: usize = 1 << 20;
/// The worker loop: dial, serve requests in order, redial forever.
fn worker(
socket: PathBuf,
out: Receiver<(u64, Vec<u8>)>,
resp: Sender<(u64, Response)>,
shared: Arc<Shared>,
) {
let mut backlog: VecDeque<(u64, Vec<u8>)> = VecDeque::new();
loop {
// Dial (keeping the queue drained while we wait for the service).
let mut stream = loop {
match UnixStream::connect(&socket) {
Ok(s) => break s,
Err(_) => {
while let Ok(x) = out.try_recv() {
backlog.push_back(x);
}
thread::sleep(Duration::from_millis(50));
}
}
};
// Serve this connection until it dies or reconnect() asks.
loop {
if shared.force_reconnect.swap(false, Ordering::Relaxed) {
break;
}
let (id, body) = if let Some(x) = backlog.pop_front() {
x
} else {
match out.recv_timeout(Duration::from_millis(50)) {
Ok(x) => x,
Err(mpsc::RecvTimeoutError::Timeout) => continue,
Err(mpsc::RecvTimeoutError::Disconnected) => return,
}
};
// A reconnect() may have landed while this request waited in the
// channel: redial first rather than write on a connection that
// may already be dead.
if shared.force_reconnect.swap(false, Ordering::Relaxed) {
backlog.push_front((id, body));
break;
}
if write_frame(&mut stream, &body).is_err() {
backlog.push_front((id, body));
break;
}
match read_frame(&mut stream) {
Ok(buf) => {
let response = serde_json::from_slice::<Response>(&buf).unwrap_or_else(|e| {
Response::Error {
message: format!("unparseable service response: {e}"),
}
});
let _ = resp.send((id, response));
}
Err(_) => {
// The connection died mid-exchange; replay the request
// on the next dial rather than dropping it.
backlog.push_front((id, body));
break;
}
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn read_frame_accepts_a_valid_frame() {
let body = b"{\"ok\":true}";
let mut frame = Vec::new();
frame.extend_from_slice(&(body.len() as u32).to_le_bytes());
frame.extend_from_slice(body);
assert_eq!(read_frame(&mut &frame[..]).unwrap(), body);
}
#[test]
fn read_frame_rejects_an_oversized_len_prefix() {
// A torn stream could yield any u32; the cap keeps it from becoming
// an allocation.
let mut frame = ((MAX_FRAME as u32) + 1).to_le_bytes().to_vec();
frame.extend_from_slice(&[0u8; 8]);
let err = read_frame(&mut &frame[..]).unwrap_err();
assert_eq!(err.kind(), io::ErrorKind::InvalidData);
}
}
+330
View File
@@ -0,0 +1,330 @@
//! The launcher window: fleet creation, node/container controls, and
//! per-node window spawns.
//!
//! [`Launcher::update`] renders and queues intents — it never spawns a
//! process (except the one-time bus daemon inside `Fleet::create`, which is
//! a non-blocking `spawn`). Everything else drains through
//! [`Launcher::drain_actions`], called by `main.rs` after each frame; the
//! headless tests drive `update` and assert on the queued actions.
use std::collections::{HashMap, VecDeque};
use std::path::PathBuf;
use std::process::Child;
use egui::Context;
use crate::config::fleet_dir;
use crate::docker::{ContainerId, Docker};
use crate::fleet::{Fleet, Mode};
/// One node container the launcher knows about.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ContainerEntry {
/// The node name (also the dir and container-name stem).
pub name: String,
/// The container id `docker run -d` returned.
pub id: ContainerId,
/// How the node connects to its peers.
pub mode: Mode,
}
/// An intent the UI queued; `main.rs` executes these by draining the queue.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum LauncherAction {
/// Add the node to the fleet and start its container.
StartContainer {
/// The node name.
name: String,
/// How the node connects to its peers.
mode: Mode,
},
/// Stop a running container.
StopContainer(ContainerId),
/// Remove a stopped container.
RemoveContainer(ContainerId),
/// Spawn a dedicated window process for this node.
OpenWindow {
/// The node name.
name: String,
/// The node's config dir (the `--node` arg).
dir: PathBuf,
/// The container the window controls.
id: ContainerId,
/// How the node connects to its peers.
mode: Mode,
},
/// Build the runtime image.
BuildImage,
}
/// The launcher window's full state.
pub struct Launcher {
/// The created fleet; `None` until Create succeeds.
pub fleet: Option<Fleet>,
/// The docker frontend.
pub docker: Docker,
/// Known node containers (only successful starts land here).
pub containers: Vec<ContainerEntry>,
/// Spawned node-window processes.
pub children: Vec<Child>,
/// Intents queued by [`Launcher::update`], drained by `main.rs`.
pub actions: VecDeque<LauncherAction>,
/// The fleet-name field.
pub fleet_name: String,
/// The node-name field for Add.
pub node_name: String,
/// The bt peer field (bt mode only; the pinned `StartContainer` action
/// carries no peer, so it is read from here at drain time).
pub node_peer: String,
/// The mode picker.
pub mode: Mode,
/// The selected row's name (drives the log pane).
pub selected: Option<String>,
/// The selected container's last-fetched log tail.
pub log_tail: Option<String>,
/// The latest error to surface.
pub error: Option<String>,
/// `docker inspect` results, refreshed at most once per second.
running: HashMap<ContainerId, bool>,
/// When the running map was last refreshed.
last_running_check: f64,
}
impl Default for Launcher {
fn default() -> Self {
Self::new()
}
}
impl Launcher {
/// An empty launcher: no fleet, no containers, sim mode.
pub fn new() -> Launcher {
Launcher {
fleet: None,
docker: Docker,
containers: Vec::new(),
children: Vec::new(),
actions: VecDeque::new(),
fleet_name: String::new(),
node_name: String::new(),
node_peer: String::new(),
mode: Mode::Sim,
selected: None,
log_tail: None,
error: None,
running: HashMap::new(),
last_running_check: f64::NEG_INFINITY,
}
}
/// Render the launcher and queue intents. Side effects are limited to
/// the fleet creation itself (a non-blocking daemon spawn) and one
/// `docker logs` fetch when a row is selected.
pub fn update(&mut self, ctx: &Context) {
let now = ctx.input(|i| i.time);
if now - self.last_running_check > 1.0 {
self.last_running_check = now;
self.running = self
.containers
.iter()
.map(|c| (c.id.clone(), self.docker.is_running(&c.id)))
.collect();
}
// egui 0.36 is Ui-based: rebuild the root Ui the eframe App would
// have passed, so the panels have a parent.
let mut ui = egui::Ui::new(
ctx.clone(),
egui::Id::new("tines-gui-root"),
egui::UiBuilder::new().max_rect(ctx.content_rect()),
);
egui::Panel::top("controls").show(&mut ui, |ui| {
ui.horizontal(|ui| {
ui.label("Mode:");
ui.selectable_value(&mut self.mode, Mode::Bt, "Bt");
ui.selectable_value(&mut self.mode, Mode::Sim, "Sim");
});
ui.horizontal(|ui| {
ui.label("Fleet name:");
ui.text_edit_singleline(&mut self.fleet_name);
if ui.button("Create").clicked() {
match Fleet::create(self.fleet_name.trim()) {
Ok(fleet) => {
self.fleet = Some(fleet);
self.error = None;
}
Err(e) => self.error = Some(format!("cannot create fleet: {e}")),
}
}
});
if self.mode == Mode::Bt {
ui.horizontal(|ui| {
ui.label("Peer (bt):");
ui.text_edit_singleline(&mut self.node_peer);
});
}
ui.horizontal(|ui| {
ui.label("Node name:");
ui.text_edit_singleline(&mut self.node_name);
if ui.button("Add").clicked() {
self.actions.push_back(LauncherAction::StartContainer {
name: self.node_name.trim().to_string(),
mode: self.mode,
});
}
});
if ui.button("Build image").clicked() {
self.actions.push_back(LauncherAction::BuildImage);
}
if let Some(error) = &self.error {
ui.colored_label(egui::Color32::from_rgb(0xff, 0x55, 0x55), error);
}
});
egui::CentralPanel::default().show(&mut ui, |ui| {
egui::Grid::new("nodes")
.striped(true)
.min_col_width(80.0)
.show(ui, |ui| {
ui.label("node");
ui.label("mode");
ui.label("state");
ui.label("");
ui.end_row();
for entry in &self.containers {
let running = self.running.get(&entry.id).copied().unwrap_or(false);
let name = entry.name.clone();
if ui
.selectable_label(self.selected.as_deref() == Some(&name), &entry.name)
.clicked()
{
self.selected = Some(name.clone());
self.log_tail = self.docker.logs(&entry.id, 50).ok();
}
ui.label(match entry.mode {
Mode::Bt => "bt",
Mode::Sim => "sim",
});
ui.label(if running { "running" } else { "stopped" });
ui.horizontal(|ui| {
if ui.button("Open").clicked() {
self.actions.push_back(LauncherAction::OpenWindow {
name: name.clone(),
dir: fleet_dir(self.fleet_name.trim()).join(&name),
id: entry.id.clone(),
mode: entry.mode,
});
}
if ui.button("Stop").clicked() {
self.actions
.push_back(LauncherAction::StopContainer(entry.id.clone()));
}
if ui.button("Remove").clicked() {
self.actions
.push_back(LauncherAction::RemoveContainer(entry.id.clone()));
}
});
ui.end_row();
}
});
});
egui::Panel::bottom("logs").show(&mut ui, |ui| {
ui.heading(match &self.selected {
Some(name) => format!("logs — {name}"),
None => "logs — select a container".to_string(),
});
if let Some(tail) = &self.log_tail {
egui::ScrollArea::vertical()
.max_height(120.0)
.auto_shrink([false, false])
.show(ui, |ui| {
ui.monospace(tail);
});
}
});
}
/// Execute every queued intent. This is the only place containers are
/// started/stopped/removed and node windows spawned; `main.rs` calls it
/// after each frame.
pub fn drain_actions(&mut self) {
while let Some(action) = self.actions.pop_front() {
match action {
LauncherAction::StartContainer { name, mode } => {
let Some(fleet) = self.fleet.as_ref() else {
self.error = Some("create a fleet first".to_string());
continue;
};
let peer = (mode == Mode::Bt && !self.node_peer.trim().is_empty())
.then(|| self.node_peer.trim().to_string());
if let Err(e) = fleet.add_node(&name, None, mode, peer.as_deref()) {
self.error = Some(format!("cannot add node {name}: {e}"));
continue;
}
let started = match mode {
Mode::Bt => self.docker.start_bt(&name, self.fleet_name.trim()),
Mode::Sim => self.docker.start_sim(&name, self.fleet_name.trim()),
};
match started {
Ok(id) => self.containers.push(ContainerEntry { name, id, mode }),
Err(e) => self.error = Some(format!("cannot start {name}: {e}")),
}
}
LauncherAction::StopContainer(id) => {
if let Err(e) = self.docker.stop(&id) {
self.error = Some(format!("cannot stop {}: {e}", id.0));
}
}
LauncherAction::RemoveContainer(id) => {
if let Err(e) = self.docker.remove(&id) {
self.error = Some(format!("cannot remove {}: {e}", id.0));
}
self.containers.retain(|c| c.id != id);
if self.selected.as_ref() == Some(&id.0) {
self.selected = None;
self.log_tail = None;
}
}
LauncherAction::OpenWindow {
name,
dir,
id,
mode,
} => {
let mode_arg = match mode {
Mode::Bt => "bt",
Mode::Sim => "sim",
};
match std::env::current_exe().and_then(|exe| {
std::process::Command::new(exe)
.arg("--node")
.arg(&dir)
.arg(&id.0)
.arg(mode_arg)
.spawn()
}) {
Ok(child) => self.children.push(child),
Err(e) => {
self.error = Some(format!("cannot open window for {name}: {e}"));
}
}
}
LauncherAction::BuildImage => {
if let Err(e) = self.docker.build_image() {
self.error = Some(format!("image build failed: {e}"));
}
}
}
}
}
}
impl eframe::App for Launcher {
fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) {
Launcher::update(self, ui.ctx());
}
}
+16
View File
@@ -0,0 +1,16 @@
//! The tines fleet console.
//!
//! A launcher plus one window per container: each window drives a running
//! `tines` service over its Unix-socket IPC. This crate holds the pieces
//! shared by those windows — the IPC client ([`ipc_client`]), the
//! config-generation half of the launcher ([`config`]), and the omarchy
//! theme mechanism ([`theme`]).
#![deny(missing_docs)]
pub mod config;
pub mod docker;
pub mod fleet;
pub mod ipc_client;
pub mod launcher;
pub mod theme;
pub mod window;
+147
View File
@@ -0,0 +1,147 @@
//! tines-gui entry point: no args opens the launcher window;
//! `--node <dir> <container-id> <mode>` opens a node window.
use eframe::egui;
use tines_gui::docker::ContainerId;
use tines_gui::fleet::Mode;
use tines_gui::launcher::Launcher;
use tines_gui::window::{NodeAction, NodeWin};
fn main() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
let args: Vec<String> = std::env::args().collect();
match args.as_slice() {
[_, flag, dir, id, mode] if flag == "--node" => {
let mode = match mode.as_str() {
"bt" => Mode::Bt,
"sim" => Mode::Sim,
other => {
eprintln!("tines-gui: unknown mode {other:?} (want bt|sim)");
std::process::exit(2);
}
};
run_node_window(dir.into(), ContainerId(id.clone()), mode)?;
}
[_] => run_launcher()?,
_ => {
eprintln!("usage: tines-gui [--node <dir> <container-id> <mode>]");
std::process::exit(2);
}
}
Ok(())
}
/// The launcher window: renders via `Launcher::update`, then drains the
/// action queue — the only place subprocess spawns happen.
struct LauncherApp {
launcher: Launcher,
theme: tines_gui::theme::OmarchyTheme,
watching: bool,
}
impl eframe::App for LauncherApp {
fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) {
let ctx = ui.ctx().clone();
self.theme = tines_gui::theme::OmarchyTheme::load();
self.theme.apply(&ctx);
if !self.watching {
let ctx = ctx.clone();
if tines_gui::theme::OmarchyTheme::watch(move || ctx.request_repaint()).is_ok() {
self.watching = true;
}
}
self.launcher.update(&ctx);
self.launcher.drain_actions();
}
}
/// The node window: renders via `NodeWin::update`, then drains the
/// container actions (stop/remove/logs).
struct NodeWinApp {
node: NodeWin,
theme: tines_gui::theme::OmarchyTheme,
watching: bool,
}
impl eframe::App for NodeWinApp {
fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) {
let ctx = ui.ctx().clone();
self.theme = tines_gui::theme::OmarchyTheme::load();
self.theme.apply(&ctx);
if !self.watching {
let ctx = ctx.clone();
if tines_gui::theme::OmarchyTheme::watch(move || ctx.request_repaint()).is_ok() {
self.watching = true;
}
}
self.node.update(&ctx);
while let Some(action) = self.node.actions.pop_front() {
match action {
NodeAction::Stop => {
if let Err(e) = self.node.docker.stop(&self.node.container_id) {
self.node.error = Some(format!("cannot stop: {e}"));
}
}
NodeAction::Remove => {
if let Err(e) = self.node.docker.remove(&self.node.container_id) {
self.node.error = Some(format!("cannot remove: {e}"));
}
}
NodeAction::FetchLogs => match self.node.docker.logs(&self.node.container_id, 50) {
Ok(tail) => self.node.apply_log_tail(tail),
Err(e) => self.node.error = Some(format!("cannot fetch logs: {e}")),
},
}
}
}
}
fn options(title: &str, size: [f32; 2]) -> eframe::NativeOptions {
let viewport = egui::ViewportBuilder::default()
.with_app_id("tines-gui")
.with_title(title)
.with_inner_size(size);
eframe::NativeOptions {
viewport,
glow_options: eframe::egui_glow::GlowConfiguration {
vsync: false,
..Default::default()
},
..Default::default()
}
}
fn run_launcher() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
eframe::run_native(
"tines-gui",
options("tines — fleet console", [760.0, 520.0]),
Box::new(|_cc| {
Ok(Box::new(LauncherApp {
launcher: Launcher::new(),
theme: tines_gui::theme::OmarchyTheme::load(),
watching: false,
}))
}),
)
.map_err(|e| format!("eframe: {e}").into())
}
fn run_node_window(
dir: std::path::PathBuf,
container_id: ContainerId,
mode: Mode,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
let node = NodeWin::new(dir, container_id, mode);
let title = format!("tines — {}", node.name);
eframe::run_native(
"tines-gui-node",
options(&title, [680.0, 480.0]),
Box::new(move |_cc| {
Ok(Box::new(NodeWinApp {
node,
theme: tines_gui::theme::OmarchyTheme::load(),
watching: false,
}))
}),
)
.map_err(|e| format!("eframe: {e}").into())
}
+130
View File
@@ -0,0 +1,130 @@
//! The omarchy theme: the live palette plus a watch that repaints on change.
//!
//! Reads `~/.local/state/omarchy/current/theme/colors.toml` for the three
//! keys the console uses — `background`, `foreground`, `accent`. Omarchy
//! rewrites the `current` directory in place on theme change, so the watch
//! is on the directory (recursive), not the file.
use std::path::PathBuf;
use egui::Color32;
use notify::Watcher;
/// The three palette colors the console paints with.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct OmarchyTheme {
/// The window background.
pub background: Color32,
/// The window foreground (text).
pub foreground: Color32,
/// The accent (highlights, focus).
pub accent: Color32,
}
/// The app's own named fallback — used only when the live theme is missing
/// or unreadable. Deliberately not the omarchy palette: the console must be
/// visibly on fallback, not approximating the real theme.
pub const FALLBACK: OmarchyTheme = OmarchyTheme {
background: Color32::from_rgb(0x16, 0x18, 0x1d),
foreground: Color32::from_rgb(0xd7, 0xdb, 0xe2),
accent: Color32::from_rgb(0x7a, 0xa2, 0xf7),
};
impl OmarchyTheme {
/// Load the live theme; [`FALLBACK`] when it is missing or malformed.
pub fn load() -> OmarchyTheme {
match read_theme_file() {
Some(t) => t,
None => FALLBACK,
}
}
/// Apply the palette: egui visuals plus an opaque background fill so
/// the window is never transparent on Wayland. Call every frame.
pub fn apply(&self, ctx: &egui::Context) {
let mut visuals = egui::Visuals::dark();
visuals.panel_fill = self.background;
visuals.window_fill = self.background;
visuals.window_stroke = egui::Stroke::NONE;
visuals.override_text_color = Some(self.foreground);
visuals.selection.bg_fill = self.accent;
visuals.widgets.hovered.bg_stroke = egui::Stroke::new(1.0, self.accent);
visuals.widgets.active.bg_stroke = egui::Stroke::new(1.0, self.accent);
visuals.widgets.noninteractive.bg_stroke = egui::Stroke::new(1.0, self.accent);
ctx.set_visuals(visuals);
let painter = ctx.layer_painter(egui::LayerId::new(
egui::Order::Background,
egui::Id::new("tines-gui-bg"),
));
painter.rect_filled(ctx.content_rect(), 0.0, self.background);
}
/// Watch the omarchy `current` theme dir (recursively) and call
/// `on_change` whenever it changes — the caller repaints from there.
/// Returns an error only if the watcher cannot be installed.
pub fn watch<F>(on_change: F) -> notify::Result<()>
where
F: FnMut() + Send + 'static,
{
// notify's callback is Fn, ours is FnMut: share it through a mutex.
let on_change = std::sync::Mutex::new(on_change);
let mut watcher =
notify::recommended_watcher(move |res: notify::Result<notify::Event>| {
if res.is_ok()
&& let Ok(mut f) = on_change.lock()
{
f();
}
})?;
watcher.watch(&current_dir(), notify::RecursiveMode::Recursive)?;
// The watcher must outlive the call: park it on a thread.
std::thread::spawn(move || {
std::thread::park();
let _ = watcher;
});
Ok(())
}
}
/// `~/.local/state/omarchy/current` — the live-theme indirection.
fn current_dir() -> PathBuf {
let home = std::env::var_os("HOME")
.map(PathBuf::from)
.unwrap_or_else(std::env::temp_dir);
home.join(".local/state/omarchy/current")
}
fn read_theme_file() -> Option<OmarchyTheme> {
let text = std::fs::read_to_string(current_dir().join("theme/colors.toml")).ok()?;
let mut background = None;
let mut foreground = None;
let mut accent = None;
for line in text.lines() {
let Some((key, value)) = line.split_once('=') else {
continue; // blank lines and comments carry no key
};
let parsed = parse_hex(value);
match key.trim() {
"background" => background = parsed,
"foreground" => foreground = parsed,
"accent" => accent = parsed,
_ => {}
}
}
Some(OmarchyTheme {
background: background?,
foreground: foreground?,
accent: accent?,
})
}
fn parse_hex(s: &str) -> Option<Color32> {
let s = s.trim().trim_matches('"').strip_prefix('#')?;
if s.len() != 6 {
return None;
}
let r = u8::from_str_radix(&s[0..2], 16).ok()?;
let g = u8::from_str_radix(&s[2..4], 16).ok()?;
let b = u8::from_str_radix(&s[4..6], 16).ok()?;
Some(Color32::from_rgb(r, g, b))
}
+437
View File
@@ -0,0 +1,437 @@
//! The node window: the whole service control surface for one container.
//!
//! All node I/O goes through the [`IpcClient`] — [`NodeWin::update`] queues
//! requests (Status/List once, Events every 500 ms, Observe/Belief on
//! clicks) and drains responses each frame; the UI thread never blocks.
//! Container operations (stop/remove/logs) are queued as [`NodeAction`]s
//! and drained by `main.rs`, like the launcher's action queue.
use std::collections::VecDeque;
use std::path::PathBuf;
use egui::Context;
use tines::{BeliefOut, ObservationOut, Request, Response, TransportKind};
use crate::docker::{ContainerId, Docker};
use crate::fleet::Mode;
use crate::ipc_client::IpcClient;
/// The status strip's data, from `Response::Status`.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct StatusData {
/// Hex of the node's own `NodeId`.
pub id: String,
/// Next tines sequence number the node will use.
pub next_seq: u64,
/// Number of observations in the store.
pub stored: u32,
}
/// One events-feed row.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum FeedEntry {
/// A peer joined or was lost.
Contact {
/// Hex of the peer's `NodeId`.
peer: String,
/// `true` for a new contact, `false` for a loss.
joined: bool,
},
/// A confirmed delivery.
Delivered {
/// Hex of the peer the observation reached.
peer: String,
/// Hex of the delivered observation's id.
id: String,
},
/// Counted drops drained from the exchange.
Dropped {
/// Hex of the peer the dropped frame came from, or `None` for the
/// overflow slot.
peer: Option<String>,
/// The drop reason, debug-formatted.
reason: String,
/// The number of frames folded into the counter.
count: u32,
},
}
/// An intent the container panel queued; `main.rs` drains these.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum NodeAction {
/// `docker stop` the container.
Stop,
/// `docker rm` the container.
Remove,
/// Re-fetch the log tail.
FetchLogs,
}
/// One node window's full state.
pub struct NodeWin {
/// The node's IPC client (dialed at the node's service socket).
pub client: IpcClient,
/// The node name (the config dir's leaf).
pub name: String,
/// How the node connects to its peers.
pub mode: Mode,
/// The transport the service is configured for, derived from `mode`.
pub transport: TransportKind,
/// The latest `Status` answer.
pub status: Option<StatusData>,
/// Latest beliefs, most recent first.
pub beliefs: VecDeque<BeliefOut>,
/// The events feed, oldest first.
pub feed: VecDeque<FeedEntry>,
/// The store browser's rows, from the latest `List`.
pub store_rows: VecDeque<ObservationOut>,
/// The id of the most recently inserted observation.
pub last_id: Option<String>,
/// The docker frontend (container panel).
pub docker: Docker,
/// The container this window controls.
pub container_id: ContainerId,
/// Intents queued by [`NodeWin::update`], drained by `main.rs`.
pub actions: VecDeque<NodeAction>,
/// The observe panel's topic field.
pub topic: String,
/// The observe panel's payload field.
pub payload: String,
/// The observe panel's recipient field (hex-encoded on send).
pub recipient: String,
/// The observe panel's lifetime field (ms).
pub lifetime: String,
/// When on, the topic/payload/recipient inputs are already hex.
pub hex_input: bool,
/// The belief panel's topic field.
pub belief_topic: String,
/// The container's last-fetched log tail.
pub log_tail: Option<String>,
/// The latest error to surface.
pub error: Option<String>,
/// Outstanding requests (responses not yet drained).
pub outstanding: usize,
/// When the Events poll last fired (input time).
pub last_events_poll: f64,
/// Whether the initial Status request went out.
status_requested: bool,
/// Whether the initial List request went out.
list_requested: bool,
}
/// Encode text as lowercase hex (the service's wire form for topics,
/// payloads, and node ids).
fn to_hex(text: &str) -> String {
let mut out = String::with_capacity(text.len() * 2);
for byte in text.as_bytes() {
out.push_str(&format!("{byte:02x}"));
}
out
}
impl NodeWin {
/// Build the window: dial the service at `<dir>/sock`, name from the
/// dir's leaf, transport from the mode.
pub fn new(dir: PathBuf, container_id: ContainerId, mode: Mode) -> NodeWin {
let name = dir
.file_name()
.map(|n| n.to_string_lossy().to_string())
.unwrap_or_else(|| "node".to_string());
let transport = match mode {
Mode::Bt => TransportKind::Bt,
Mode::Sim => TransportKind::Bus,
};
let client = IpcClient::spawn(dir.join("sock")).expect("ipc worker spawns");
NodeWin {
client,
name,
mode,
transport,
status: None,
beliefs: VecDeque::new(),
feed: VecDeque::new(),
store_rows: VecDeque::new(),
last_id: None,
docker: Docker,
container_id,
actions: VecDeque::new(),
topic: String::new(),
payload: String::new(),
recipient: String::new(),
lifetime: String::new(),
hex_input: false,
belief_topic: String::new(),
log_tail: None,
error: None,
outstanding: 0,
last_events_poll: f64::NEG_INFINITY,
status_requested: false,
list_requested: false,
}
}
/// Queue a request and count it outstanding.
fn send(&mut self, req: Request) {
match self.client.request(req) {
Ok(_) => self.outstanding += 1,
Err(e) => self.error = Some(e.to_string()),
}
}
/// Apply one drained response.
fn apply(&mut self, resp: Response) {
match resp {
Response::Status {
id,
next_seq,
stored,
} => {
self.status = Some(StatusData {
id,
next_seq,
stored,
});
}
Response::List {
observations,
truncated: _,
} => self.store_rows = observations.into(),
Response::Belief { obs } => {
if let Some(obs) = obs {
self.beliefs.push_front(obs);
}
}
Response::Observe { id } => self.last_id = Some(id),
Response::Events {
events,
contacts,
drops,
} => {
for contact in contacts {
self.feed.push_back(FeedEntry::Contact {
peer: contact.peer,
joined: contact.joined,
});
}
for event in events {
self.feed.push_back(FeedEntry::Delivered {
peer: event.peer,
id: event.id,
});
}
for drop in drops {
self.feed.push_back(FeedEntry::Dropped {
peer: drop.peer,
reason: drop.reason,
count: drop.count,
});
}
}
Response::Error { message } => self.error = Some(message),
}
}
/// Render the node window and queue intents. Side effects are limited
/// to queueing requests on the [`IpcClient`] (responses drain here
/// too); container intents wait in `actions` for `main.rs`.
pub fn update(&mut self, ctx: &Context) {
while let Some((_id, resp)) = self.client.try_recv() {
self.outstanding = self.outstanding.saturating_sub(1);
self.apply(resp);
}
if !self.status_requested {
self.status_requested = true;
self.send(Request::Status);
}
if !self.list_requested {
self.list_requested = true;
self.send(Request::List);
}
let now = ctx.input(|i| i.time);
if self.last_events_poll.is_nan() || now - self.last_events_poll >= 0.5 {
self.last_events_poll = now;
self.send(Request::Events);
}
// egui 0.36 is Ui-based: rebuild the root Ui, like the launcher.
let mut ui = egui::Ui::new(
ctx.clone(),
egui::Id::new("tines-node-root"),
egui::UiBuilder::new().max_rect(ctx.content_rect()),
);
egui::Panel::top("strip").show(&mut ui, |ui| {
let (id, next_seq, stored) = match &self.status {
Some(s) => (s.id.clone(), s.next_seq.to_string(), s.stored.to_string()),
None => ("—".to_string(), "—".to_string(), "—".to_string()),
};
ui.horizontal(|ui| {
ui.label(format!(
"node {id} · seq {next_seq} · stored {stored} · transport {}",
match self.transport {
TransportKind::Bt => "bt",
TransportKind::Bus => "bus",
}
));
if self.mode == Mode::Bt {
ui.separator();
ui.label("bt: single peer — addressed observes beyond the configured peer have no route");
}
});
});
egui::Panel::left("controls")
.resizable(true)
.show(&mut ui, |ui| {
ui.heading("Observe");
ui.label("topic:");
ui.text_edit_singleline(&mut self.topic);
ui.label("payload:");
ui.text_edit_singleline(&mut self.payload);
ui.label("recipient (optional):");
ui.text_edit_singleline(&mut self.recipient);
ui.label("lifetime ms (optional):");
ui.text_edit_singleline(&mut self.lifetime);
ui.checkbox(&mut self.hex_input, "input is hex");
if ui.button("Send").clicked() {
let encode = |s: &str| {
if self.hex_input {
s.to_string()
} else {
to_hex(s)
}
};
let to = (!self.recipient.is_empty()).then(|| encode(&self.recipient));
let lifetime_ms = (!self.lifetime.is_empty())
.then(|| self.lifetime.trim().parse::<u64>().ok())
.flatten();
self.send(Request::Observe {
topic: encode(&self.topic),
payload: encode(&self.payload),
to,
lifetime_ms,
});
}
if let Some(id) = &self.last_id {
ui.label(format!("last: {id}"));
}
ui.separator();
ui.heading("Belief");
ui.label("topic:");
ui.text_edit_singleline(&mut self.belief_topic);
if ui.button("Fetch").clicked() {
let topic = if self.hex_input {
self.belief_topic.clone()
} else {
to_hex(&self.belief_topic)
};
self.send(Request::Belief { topic });
}
for belief in &self.beliefs {
ui.label(format!("{}: {}", belief.topic, belief.payload));
}
ui.separator();
ui.heading("Container");
ui.horizontal(|ui| {
if ui.button("Refresh logs").clicked() {
self.actions.push_back(NodeAction::FetchLogs);
}
if ui.button("Stop").clicked() {
self.actions.push_back(NodeAction::Stop);
}
if ui.button("Remove").clicked() {
self.actions.push_back(NodeAction::Remove);
}
});
if let Some(tail) = &self.log_tail {
egui::ScrollArea::vertical()
.max_height(120.0)
.auto_shrink([false, false])
.show(ui, |ui| {
ui.monospace(tail);
});
}
});
egui::CentralPanel::default().show(&mut ui, |ui| {
ui.heading("Store");
if ui.button("Refresh").clicked() {
self.send(Request::List);
}
egui::ScrollArea::vertical().show(ui, |ui| {
egui::Grid::new("store").striped(true).show(ui, |ui| {
ui.label("id");
ui.label("topic");
ui.label("payload");
ui.label("age ms");
ui.end_row();
for obs in &self.store_rows {
ui.monospace(&obs.id);
ui.monospace(&obs.topic);
ui.monospace(&obs.payload);
ui.label(obs.age_ms.to_string());
ui.end_row();
}
});
});
});
egui::Panel::right("feed")
.resizable(true)
.show(&mut ui, |ui| {
ui.heading("Events");
egui::ScrollArea::vertical().show(ui, |ui| {
for entry in &self.feed {
match entry {
FeedEntry::Contact { peer, joined } => {
ui.label(format!(
"contact {peer} {}",
if *joined { "joined" } else { "lost" }
));
}
FeedEntry::Delivered { peer, id } => {
ui.label(format!("delivered {id} to {peer}"));
}
FeedEntry::Dropped {
peer,
reason,
count,
} => {
let peer = peer.as_deref().unwrap_or("overflow");
ui.label(format!("dropped {count} from {peer}: {reason}"));
}
}
}
});
});
if let Some(error) = &self.error {
egui::Panel::bottom("error").show(&mut ui, |ui| {
ui.colored_label(egui::Color32::from_rgb(0xff, 0x55, 0x55), error);
});
}
// Keep frames flowing while responses are in flight (the harness
// settles when this stops).
if self.outstanding > 0 {
ctx.request_repaint();
}
}
/// The container panel's latest log tail (set by `main.rs`'s drain).
pub fn apply_log_tail(&mut self, tail: String) {
self.log_tail = Some(tail);
}
}
impl eframe::App for NodeWin {
fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) {
NodeWin::update(self, ui.ctx());
}
}
+162
View File
@@ -0,0 +1,162 @@
//! Shared test setup for the integration suites. Compiled per test binary;
//! only declare `mod common;` in tests that use these helpers.
use std::os::unix::fs::PermissionsExt;
use std::path::PathBuf;
use std::sync::OnceLock;
use tines::{Request, Response};
/// One shared scratch setup per test binary: a temp dir that becomes both
/// XDG_STATE_HOME (fleet dirs) and a PATH dir holding a fake `tines-busd`.
/// Env is process-global, so it is set exactly once before any test touches
/// `fleet_dir` — a per-test `set_var` from parallel test threads would race.
#[allow(dead_code)]
pub fn env_with_fake_busd() -> &'static PathBuf {
static DIR: OnceLock<PathBuf> = OnceLock::new();
DIR.get_or_init(|| {
let dir = std::env::temp_dir().join(format!("tines-gui-test-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
// Fake tines-busd: create the socket named by argv[1], then stay
// alive so the spawning Fleet keeps a live child.
let fake = dir.join("tines-busd");
std::fs::write(&fake, "#!/bin/sh\ntouch \"$1\"\nsleep 60\n").unwrap();
std::fs::set_permissions(&fake, std::fs::Permissions::from_mode(0o755)).unwrap();
let path = format!(
"{}:{}",
dir.display(),
std::env::var("PATH").unwrap_or_default()
);
// SAFETY: set once, before any fleet_dir call; this process is a
// test binary that exits afterwards, so nothing else observes it.
unsafe {
std::env::set_var("XDG_STATE_HOME", &dir);
std::env::set_var("PATH", path);
}
dir
})
}
/// A fake `tines` service speaking the real wire framing
/// (`[u32 LE len][JSON]`), answering every request through a handler
/// closure and logging every request it receives. Serving runs on a
/// thread; dropping the service shuts it down.
#[allow(dead_code)]
pub struct FakeService {
/// The socket the service is bound to (what the client dials).
pub socket: PathBuf,
requests: std::sync::Arc<std::sync::Mutex<Vec<Request>>>,
shutdown: std::sync::Arc<std::sync::atomic::AtomicBool>,
listener: Option<std::os::unix::net::UnixListener>,
handle: Option<std::thread::JoinHandle<()>>,
}
#[allow(dead_code)]
impl FakeService {
/// Bind `socket` and serve requests forever (until dropped).
pub fn start<F>(socket: PathBuf, handler: F) -> FakeService
where
F: Fn(&Request) -> Response + Send + Sync + 'static,
{
use std::io::{Read, Write};
use std::os::unix::net::UnixListener;
if let Some(parent) = socket.parent() {
std::fs::create_dir_all(parent).unwrap();
}
use std::time::Duration;
// A restarted service finds the previous one's socket path still on
// disk (Linux leaves the file behind), so remove the stale path
// first — exactly what a real service does.
let _ = std::fs::remove_file(&socket);
let listener = UnixListener::bind(&socket).unwrap();
listener.set_nonblocking(true).unwrap();
let requests = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
let shutdown = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
let serve_listener = listener.try_clone().unwrap();
let serve_requests = std::sync::Arc::clone(&requests);
let serve_shutdown = std::sync::Arc::clone(&shutdown);
let handle = std::thread::spawn(move || {
while !serve_shutdown.load(std::sync::atomic::Ordering::Relaxed) {
match serve_listener.accept() {
Ok((mut stream, _)) => {
// Timeout idle reads so `Drop`'s join can always
// finish: the serve thread would otherwise block in
// `read_exact` on a connection the client keeps
// open with nothing in flight.
let _ = stream.set_read_timeout(Some(Duration::from_millis(250)));
// Serve this connection until it closes.
loop {
let mut len_buf = [0u8; 4];
if stream.read_exact(&mut len_buf).is_err() {
break;
}
let len = u32::from_le_bytes(len_buf) as usize;
let mut buf = vec![0u8; len];
if stream.read_exact(&mut buf).is_err() {
break;
}
let req: Request = match serde_json::from_slice(&buf) {
Ok(req) => req,
Err(e) => {
let body = serde_json::to_vec(&Response::Error {
message: format!("unparseable: {e}"),
})
.unwrap();
let len = (body.len() as u32).to_le_bytes();
let _ = stream.write_all(&len);
let _ = stream.write_all(&body);
continue;
}
};
let body = serde_json::to_vec(&handler(&req)).unwrap();
serve_requests.lock().unwrap().push(req);
let len = (body.len() as u32).to_le_bytes();
if stream.write_all(&len).is_err() || stream.write_all(&body).is_err() {
break;
}
let _ = stream.flush();
}
}
Err(e) if e.kind() == std::io::ErrorKind::WouldBlock => {
// Keep the poll tight: the harness can burn dozens
// of frames in the time one 10ms sleep takes, and
// the client's round trip must land within the
// step budget.
std::thread::sleep(std::time::Duration::from_millis(1));
}
Err(_) => break,
}
}
});
FakeService {
socket,
requests,
shutdown,
listener: Some(listener),
handle: Some(handle),
}
}
/// True when any received request satisfies the predicate.
pub fn any_request(&self, pred: impl Fn(&Request) -> bool) -> bool {
self.requests.lock().unwrap().iter().any(pred)
}
}
impl Drop for FakeService {
fn drop(&mut self) {
self.shutdown
.store(true, std::sync::atomic::Ordering::Relaxed);
self.listener.take();
if let Some(handle) = self.handle.take() {
let _ = handle.join();
}
}
}
+75
View File
@@ -0,0 +1,75 @@
//! The GUI's generated configs must deserialize as the service's `Config`
//! byte-for-byte: unknown keys are fatal on the service side.
use tines_gui::config::{NodeConfig, generate_node_id};
#[test]
fn generated_config_parses_as_service_config() {
// bt: transport bt, a peer, no bus socket.
let bt = NodeConfig::bt(
generate_node_id(),
Some(generate_node_id()),
"/tmp/fleet/log".into(),
"/tmp/fleet/node.sock".into(),
);
let json = serde_json::to_string(&bt).unwrap();
let parsed: tines::config::Config =
serde_json::from_str(&json).expect("bt config must parse as the service config");
assert_eq!(parsed.node_id, bt.node_id);
assert_eq!(parsed.peer, bt.peer);
assert_eq!(parsed.bus_socket, None);
assert_eq!(parsed.validate(), Ok(()));
// bus: transport bus, no peer, a bus socket.
let bus = NodeConfig::bus(
generate_node_id(),
None,
"/tmp/fleet/log".into(),
"/tmp/fleet/node.sock".into(),
"/tmp/fleet/bus.sock".into(),
);
let json = serde_json::to_string(&bus).unwrap();
let parsed: tines::config::Config =
serde_json::from_str(&json).expect("bus config must parse as the service config");
assert_eq!(parsed.node_id, bus.node_id);
assert_eq!(parsed.peer, None);
assert_eq!(parsed.bus_socket, Some("/tmp/fleet/bus.sock".to_string()));
assert_eq!(parsed.validate(), Ok(()));
}
#[test]
fn node_id_is_32_hex_chars() {
let id = generate_node_id();
assert_eq!(id.len(), 32, "node id must be 32 chars, got {id:?}");
assert!(
id.chars()
.all(|c| c.is_ascii_hexdigit() && !c.is_ascii_uppercase()),
"node id must be lowercase hex, got {id:?}"
);
}
#[test]
fn fleet_dir_is_0700() {
use std::os::unix::fs::PermissionsExt;
// Point XDG_STATE_HOME at a scratch dir so the test never touches the
// real ~/.local/state, and fleet_dir's creation is what we inspect.
let scratch = std::env::temp_dir().join(format!("tines-gui-state-{}", std::process::id()));
std::fs::remove_dir_all(&scratch).ok();
std::fs::create_dir_all(&scratch).unwrap();
// SAFETY: this is the only test in this binary touching fleet_dir, and
// the test binary dies right after; the env change cannot leak to other
// tests in other binaries.
unsafe { std::env::set_var("XDG_STATE_HOME", &scratch) };
let dir = tines_gui::config::fleet_dir("fleet-a");
let meta = std::fs::metadata(&dir).expect("fleet dir must be created");
assert!(meta.is_dir());
assert_eq!(
meta.permissions().mode() & 0o777,
0o700,
"fleet dir must be 0700"
);
std::fs::remove_dir_all(&scratch).ok();
}
+74
View File
@@ -0,0 +1,74 @@
//! Command-building tests for the docker module: the exact argv vectors,
//! no real docker involved.
use tines_gui::config::fleet_dir;
use tines_gui::docker::{ContainerId, Docker};
#[test]
fn start_bt_args_match_pinned_flags() {
let docker = Docker;
let name = "scout";
let fleet = "yard";
let mounted = fleet_dir(fleet).join(name);
let mounted = mounted.display().to_string();
let argv = docker.start_bt_argv(name, fleet);
let expected: Vec<String> = vec![
"docker".into(),
"run".into(),
"-d".into(),
"--name".into(),
format!("tines-bt-{name}"),
"--privileged".into(),
"--net=host".into(),
"-v".into(),
"/run/dbus/system_bus_socket:/run/dbus/system_bus_socket".into(),
"-v".into(),
format!("{mounted}:/data"),
"tines-stack:latest".into(),
"tines".into(),
"/data/config.json".into(),
];
assert_eq!(argv, expected);
}
#[test]
fn start_sim_args_match_pinned_flags() {
let docker = Docker;
let name = "cargo";
let fleet = "yard";
let mounted = fleet_dir(fleet).join(name);
let mounted = mounted.display().to_string();
let argv = docker.start_sim_argv(name, fleet);
let expected: Vec<String> = vec![
"docker".into(),
"run".into(),
"-d".into(),
"--name".into(),
format!("tines-sim-{name}"),
"-v".into(),
format!("{mounted}:/data"),
"tines-stack:latest".into(),
"tines".into(),
"/data/config.json".into(),
];
assert_eq!(argv, expected);
}
#[test]
fn stop_and_remove_args() {
let docker = Docker;
let id = ContainerId("abc123".into());
assert_eq!(docker.stop_argv(&id), vec!["docker", "stop", "abc123"]);
assert_eq!(docker.remove_argv(&id), vec!["docker", "rm", "abc123"]);
assert_eq!(
docker.logs_argv(&id, 25),
vec!["docker", "logs", "--tail", "25", "abc123"]
);
}
#[test]
fn image_name_is_pinned() {
assert_eq!(Docker::image(), "tines-stack:latest");
}
+103
View File
@@ -0,0 +1,103 @@
//! Fleet layout and node-config tests. The bus daemon is a fake `tines-busd`
//! seeded onto PATH (see `common`; the real binary isn't on PATH for unit
//! tests): it creates the socket file named by argv[1], so the fleet's
//! spawn protocol is what's under test — the real daemon is covered by
//! tines-bus's own suite and Task 4's e2e.
mod common;
use std::os::unix::fs::PermissionsExt;
use std::time::{Duration, Instant};
use tines_gui::fleet::{Fleet, Mode};
fn fleet_name(tag: &str) -> String {
format!(
"test-{tag}-{}",
std::thread::current().name().unwrap_or("t")
)
}
#[test]
fn fleet_dir_layout() {
common::env_with_fake_busd();
let fleet = Fleet::create(&fleet_name("layout")).expect("fleet creates");
let dir = tines_gui::config::fleet_dir(&fleet_name("layout"));
let meta = std::fs::metadata(&dir).expect("fleet dir exists");
assert!(meta.is_dir());
assert_eq!(meta.permissions().mode() & 0o777, 0o700);
// The fake daemon creates the bus socket the fleet asked it to bind.
let socket = fleet.bus_socket();
assert_eq!(socket, dir.join("bus.sock"));
let deadline = Instant::now() + Duration::from_secs(5);
while !socket.exists() {
assert!(
Instant::now() < deadline,
"bus.sock never appeared: {}",
socket.display()
);
std::thread::sleep(Duration::from_millis(50));
}
}
#[test]
fn add_node_bus_config_mounts_data() {
common::env_with_fake_busd();
let fleet_name = fleet_name("bus");
let fleet = Fleet::create(&fleet_name).expect("fleet creates");
let spec = fleet
.add_node("alpha", Some(&"11".repeat(16)), Mode::Sim, None)
.expect("node added");
let json = std::fs::read_to_string(spec.dir.join("config.json")).unwrap();
let parsed: tines::config::Config =
serde_json::from_str(&json).expect("config parses as the service config");
assert_eq!(parsed.transport, Some(tines::config::TransportKind::Bus));
assert_eq!(parsed.bus_socket, Some("/data/bus.sock".to_string()));
assert_eq!(parsed.socket_path, "/data/alpha/sock".to_string());
assert_eq!(parsed.log_path, "/data/alpha/log".to_string());
assert_eq!(parsed.node_id, "11".repeat(16));
assert_eq!(parsed.validate(), Ok(()));
}
#[test]
fn add_node_bt_config_has_peer() {
common::env_with_fake_busd();
let fleet_name = fleet_name("bt");
let fleet = Fleet::create(&fleet_name).expect("fleet creates");
let peer = "22".repeat(16);
let spec = fleet
.add_node("bravo", None, Mode::Bt, Some(&peer))
.expect("node added");
let json = std::fs::read_to_string(spec.dir.join("config.json")).unwrap();
let parsed: tines::config::Config =
serde_json::from_str(&json).expect("config parses as the service config");
assert_eq!(parsed.transport, Some(tines::config::TransportKind::Bt));
assert_eq!(parsed.peer, Some(peer));
assert_eq!(parsed.bus_socket, None);
assert_eq!(parsed.validate(), Ok(()));
}
#[test]
fn generated_id_roundtrips() {
common::env_with_fake_busd();
let fleet_name = fleet_name("gen");
let fleet = Fleet::create(&fleet_name).expect("fleet creates");
let spec = fleet
.add_node("charlie", None, Mode::Sim, None)
.expect("node added");
let json = std::fs::read_to_string(spec.dir.join("config.json")).unwrap();
let parsed: tines::config::Config =
serde_json::from_str(&json).expect("config parses as the service config");
assert_eq!(parsed.node_id.len(), 32);
assert!(
parsed
.node_id
.chars()
.all(|c| c.is_ascii_hexdigit() && !c.is_ascii_uppercase()),
"generated id must be lowercase hex"
);
}
+133
View File
@@ -0,0 +1,133 @@
//! The IPC client against a fake service speaking the real wire framing
//! (`[u32 LE len][JSON]`), from a test-local UnixListener.
use std::io::{Read, Write};
use std::os::unix::net::{UnixListener, UnixStream};
use std::path::{Path, PathBuf};
use std::thread;
use std::time::{Duration, Instant};
use tines::{Request, Response};
use tines_gui::ipc_client::IpcClient;
fn scratch_dir(tag: &str) -> PathBuf {
let dir = std::env::temp_dir().join(format!("tines-gui-ipc-{tag}-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
dir
}
fn read_frame(stream: &mut UnixStream) -> Vec<u8> {
let mut len_buf = [0u8; 4];
stream.read_exact(&mut len_buf).unwrap();
let len = u32::from_le_bytes(len_buf) as usize;
let mut buf = vec![0u8; len];
stream.read_exact(&mut buf).unwrap();
buf
}
fn write_frame(stream: &mut UnixStream, body: &[u8]) {
let len = (body.len() as u32).to_le_bytes();
stream.write_all(&len).unwrap();
stream.write_all(body).unwrap();
stream.flush().unwrap();
}
/// Answer one Observe request with an Observe response, then end the
/// connection (EOF).
fn answer_once(socket: &Path) {
// A restarted server finds the previous server's socket path still on
// disk (Linux leaves the file behind when a bound listener drops), so
// it removes the stale path first — exactly what a real service does.
let _ = std::fs::remove_file(socket);
let (mut stream, _) = UnixListener::bind(socket)
.unwrap()
.accept()
.expect("server accepts");
let body = read_frame(&mut stream);
// Request-agnostic: the client-side tests only assert the id pairing,
// so any well-formed request gets an Observe-shaped response.
let _: Request = serde_json::from_slice(&body).expect("well-formed request");
write_frame(
&mut stream,
&serde_json::to_vec(&Response::Observe { id: "00".into() }).unwrap(),
);
// Dropping the stream here closes the connection: the client sees EOF.
}
#[test]
fn client_roundtrips_observe() {
let dir = scratch_dir("rt");
let socket = dir.join("s.sock");
let server_socket = socket.clone();
let server = thread::spawn(move || answer_once(&server_socket));
let client = IpcClient::spawn(socket.clone()).expect("client spawns");
let id = client
.request(Request::Observe {
topic: "ab".into(),
payload: "cd".into(),
to: None,
lifetime_ms: None,
})
.expect("request queues");
let deadline = Instant::now() + Duration::from_secs(5);
loop {
if let Some((got_id, resp)) = client.try_recv() {
assert_eq!(got_id, id, "response must carry the request id");
assert!(
matches!(resp, Response::Observe { .. }),
"expected Observe response, got {resp:?}"
);
break;
}
assert!(Instant::now() < deadline, "no response arrived");
thread::sleep(Duration::from_millis(20));
}
server.join().unwrap();
}
#[test]
fn client_survives_server_restart() {
let dir = scratch_dir("restart");
let socket = dir.join("s.sock");
// First server: one round trip, then it goes away.
let server_socket = socket.clone();
let first = thread::spawn(move || answer_once(&server_socket));
let client = IpcClient::spawn(socket.clone()).expect("client spawns");
let first_id = client
.request(Request::Events)
.expect("first request queues");
let deadline = Instant::now() + Duration::from_secs(5);
loop {
if let Some((got, _)) = client.try_recv() {
assert_eq!(got, first_id);
break;
}
assert!(Instant::now() < deadline, "no response to first request");
thread::sleep(Duration::from_millis(20));
}
first.join().unwrap();
// Restart: a fresh listener on the same path answers the next request.
client.reconnect();
let server_socket = socket.clone();
let second = thread::spawn(move || answer_once(&server_socket));
let second_id = client
.request(Request::Events)
.expect("second request queues");
let deadline = Instant::now() + Duration::from_secs(5);
loop {
if let Some((got, _)) = client.try_recv() {
assert_eq!(got, second_id, "ids keep increasing across restarts");
break;
}
assert!(Instant::now() < deadline, "no response after restart");
thread::sleep(Duration::from_millis(20));
}
second.join().unwrap();
}
+108
View File
@@ -0,0 +1,108 @@
//! Launcher UI tests (egui_kittest, headless). No real docker or daemon:
//! the fake `tines-busd` is seeded on PATH (see `common`), and the
//! containers are injected directly into the launcher's state.
mod common;
use egui::accesskit;
use egui_kittest::Harness;
use egui_kittest::kittest::Queryable;
use tines_gui::config::fleet_dir;
use tines_gui::docker::ContainerId;
use tines_gui::fleet::Mode;
use tines_gui::launcher::{ContainerEntry, Launcher, LauncherAction};
/// The test-side action drain: peek at what the UI queued (main.rs owns the
/// real drain with the subprocess spawns).
fn drain(launcher: &mut Launcher) -> Vec<LauncherAction> {
launcher.actions.drain(..).collect()
}
#[test]
fn create_fleet_action_queues_spawn() {
common::env_with_fake_busd();
let mut harness = Harness::new_eframe(|_cc| Launcher::new());
harness.run();
// Type the fleet name into the first text field, then click Create.
harness
.get_all_by_role(accesskit::Role::TextInput)
.next()
.expect("fleet name field")
.focus();
harness.run();
harness
.get_all_by_role(accesskit::Role::TextInput)
.next()
.expect("fleet name field")
.type_text("yard");
harness.run();
harness.get_by_label("Create").click();
harness.run();
let launcher = harness.state();
assert!(
launcher.fleet.is_some(),
"Create must set the fleet (spawning the bus daemon)"
);
// The action queue must be drainable; fleet creation itself is not an
// action (it is the fleet field).
let launcher = harness.state_mut();
assert!(drain(launcher).is_empty());
}
#[test]
fn open_window_queues_spawn() {
common::env_with_fake_busd();
let mut harness = Harness::new_eframe(|_cc| {
let mut launcher = Launcher::new();
launcher.fleet_name = "yard".to_string();
launcher.containers.push(ContainerEntry {
name: "alpha".to_string(),
id: ContainerId("deadbeef".to_string()),
mode: Mode::Sim,
});
launcher
});
harness.run();
harness.get_by_label("Open").click();
harness.run();
let actions = drain(harness.state_mut());
assert_eq!(
actions,
vec![LauncherAction::OpenWindow {
name: "alpha".to_string(),
dir: fleet_dir("yard").join("alpha"),
id: ContainerId("deadbeef".to_string()),
mode: Mode::Sim,
}]
);
}
#[test]
fn stop_queues_stop_action() {
common::env_with_fake_busd();
let mut harness = Harness::new_eframe(|_cc| {
let mut launcher = Launcher::new();
launcher.containers.push(ContainerEntry {
name: "bravo".to_string(),
id: ContainerId("cafebabe".to_string()),
mode: Mode::Bt,
});
launcher
});
harness.run();
harness.get_by_label("Stop").click();
harness.run();
let actions = drain(harness.state_mut());
assert_eq!(
actions,
vec![LauncherAction::StopContainer(ContainerId(
"cafebabe".to_string()
))]
);
}
+220
View File
@@ -0,0 +1,220 @@
//! Node window tests (egui_kittest, headless). The fake service speaks the
//! real wire framing and answers scripted responses; the window is driven
//! through `NodeWin::update` and asserted via the harness tree and the
//! fake's received-request log.
mod common;
use std::path::PathBuf;
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use std::time::{Duration, Instant};
use egui::accesskit;
use egui_kittest::Harness;
use egui_kittest::kittest::Queryable;
use tines::{ContactOut, Request, Response};
use tines_gui::docker::ContainerId;
use tines_gui::fleet::Mode;
use tines_gui::window::NodeWin;
/// One scratch dir per test: the fake service binds `<dir>/sock`, which is
/// exactly where the node window dials.
fn scratch_dir(tag: &str) -> PathBuf {
std::env::temp_dir().join(format!(
"tines-gui-window-{tag}-{}-{}",
std::process::id(),
COUNTER.fetch_add(1, Ordering::Relaxed)
))
}
static COUNTER: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
fn new_window(dir: &std::path::Path) -> NodeWin {
NodeWin::new(
dir.to_path_buf(),
ContainerId("deadbeef".to_string()),
Mode::Sim,
)
}
/// The harness: generous max steps so async IPC round trips can land
/// (the default 4 panics on any slow dial).
fn harness_for(dir: &std::path::Path) -> Harness<'static, NodeWin> {
let dir = dir.to_path_buf();
Harness::builder()
.with_max_steps(400)
.build_eframe(move |_cc| new_window(&dir))
}
/// A service that answers Status/List/Belief/Observe and returns one
/// contact from the first Events drain.
fn default_handler() -> impl Fn(&Request) -> Response + Send + Sync + 'static {
let contact_sent = Arc::new(AtomicBool::new(false));
move |req: &Request| match req {
Request::Status => Response::Status {
id: "0a0b".to_string(),
next_seq: 7,
stored: 3,
},
Request::Events => {
if contact_sent.swap(true, Ordering::Relaxed) {
Response::Events {
events: vec![],
contacts: vec![],
drops: vec![],
}
} else {
Response::Events {
events: vec![],
contacts: vec![ContactOut {
peer: "bb".to_string(),
joined: true,
}],
drops: vec![],
}
}
}
Request::List => Response::List {
observations: vec![],
truncated: false,
},
Request::Belief { .. } => Response::Belief { obs: None },
Request::Observe { .. } => Response::Observe {
id: "00".to_string(),
},
}
}
/// The response drain is a background worker: poll the fake's log until
/// the predicate holds or the deadline passes.
fn wait_for<F: Fn(&Request) -> bool>(fake: &common::FakeService, pred: F) {
let deadline = Instant::now() + Duration::from_secs(5);
loop {
if fake.any_request(&pred) {
return;
}
assert!(Instant::now() < deadline, "request never arrived");
std::thread::sleep(Duration::from_millis(20));
}
}
/// Focus the `idx`-th text input and type `text` into it. The iterator
/// borrows the harness, so it must drop before each `run()`.
fn type_into(harness: &mut Harness<'_, NodeWin>, idx: usize, text: &str) {
{
let mut inputs = harness.get_all_by_role(accesskit::Role::TextInput);
inputs.nth(idx).unwrap().focus();
}
settle(harness);
{
let mut inputs = harness.get_all_by_role(accesskit::Role::TextInput);
inputs.nth(idx).unwrap().type_text(text);
}
settle(harness);
}
/// Step the app until every outstanding request has been answered (real
/// time between steps: the IPC round trip runs on a background thread, and
/// a tight `run()` loop burns hundreds of frames before the first response
/// lands — while the 500ms events poll keeps queueing, so `run()` never
/// settles and panics at max_steps).
fn settle(harness: &mut Harness<'_, NodeWin>) {
let deadline = Instant::now() + Duration::from_secs(5);
loop {
harness.step();
if harness.state().outstanding == 0 {
return;
}
assert!(
Instant::now() < deadline,
"service never answered the outstanding requests"
);
std::thread::sleep(Duration::from_millis(5));
}
}
#[test]
fn status_strip_shows_node_id() {
let dir = scratch_dir("status");
let fake = common::FakeService::start(dir.join("sock"), default_handler());
let mut harness = harness_for(&dir);
settle(&mut harness);
// The strip is one label; the node id hex must appear in it.
assert!(
harness.get_all_by_label_contains("0a0b").count() >= 1,
"status strip must show the node id answered by Status"
);
drop(fake);
}
#[test]
fn observe_button_queues_observe() {
let dir = scratch_dir("observe");
let fake = common::FakeService::start(dir.join("sock"), default_handler());
let mut harness = harness_for(&dir);
settle(&mut harness);
// Text inputs render in panel order: topic, payload, recipient,
// lifetime. Focus-then-type lands text in each field.
type_into(&mut harness, 0, "hi");
type_into(&mut harness, 1, "yo");
harness.get_by_label("Send").click();
settle(&mut harness);
wait_for(&fake, |req| {
matches!(
req,
Request::Observe {
topic,
payload,
to: None,
lifetime_ms: None,
} if topic == "6869" && payload == "796f"
)
});
}
#[test]
fn addressed_fields_queue_observe_to() {
let dir = scratch_dir("addressed");
let fake = common::FakeService::start(dir.join("sock"), default_handler());
let mut harness = harness_for(&dir);
settle(&mut harness);
// topic (0), payload (1), recipient (2), lifetime (3).
for (idx, text) in [(0usize, "hi"), (1, "yo"), (2, "aa"), (3, "5000")] {
type_into(&mut harness, idx, text);
}
harness.get_by_label("Send").click();
settle(&mut harness);
wait_for(&fake, |req| {
matches!(
req,
Request::Observe {
topic,
payload,
to: Some(to),
lifetime_ms: Some(5000),
} if topic == "6869" && payload == "796f" && to == "6161"
)
});
}
#[test]
fn feed_shows_contact() {
let dir = scratch_dir("feed");
let fake = common::FakeService::start(dir.join("sock"), default_handler());
let mut harness = harness_for(&dir);
settle(&mut harness);
assert!(
harness.get_all_by_label_contains("bb").count() >= 1,
"the events feed must show the contact's peer hex"
);
drop(fake);
}
+1 -1
View File
@@ -8,7 +8,7 @@ authors.workspace = true
repository.workspace = true
readme.workspace = true
documentation = "https://docs.rs/tines-ros2"
keywords = ["robotics", "ros2", "gossip", "delay-tolerant", "science::robotics"]
keywords = ["robotics", "ros2", "gossip", "delay-tolerant"]
categories = ["science::robotics", "network-programming"]
[features]
+2 -1
View File
@@ -7,13 +7,14 @@ description = "Standalone tines service: hosts a tines Node over a Unix-domain-s
authors.workspace = true
repository.workspace = true
readme.workspace = true
keywords = ["robotics", "delay-tolerant", "science::robotics", "ipc", "ble"]
keywords = ["robotics", "delay-tolerant", "ipc", "ble"]
categories = ["science::robotics", "network-programming"]
[dependencies]
tines-core = { path = "../tines-core", version = "1" }
tines-store = { path = "../tines-store", version = "0.4.0" }
tines-bt = { path = "../tines-bt", version = "0.4.0" }
tines-bus = { path = "../tines-bus", version = "0.4.0" }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
+194
View File
@@ -0,0 +1,194 @@
//! On-disk service configuration, shared by the `tines` binary and its
//! clients (the fleet console generates these configs and drives the service
//! it configures).
//!
//! The config is JSON with `deny_unknown_fields`; every key except the core
//! `node_id` / `log_path` / `socket_path` is optional. See [`Config`] for the
//! per-key contract, and [`Config::validate`] for the transport constraints.
use crate::trust::DEFAULT_PEER_TIME_WINDOW_MS;
/// On-disk configuration for a running service. Unknown keys are rejected.
#[derive(Debug, serde::Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Config {
/// This device's advertised `NodeId`, 32 lowercase hex chars.
pub node_id: String,
/// The peer's advertised `NodeId`, 32 lowercase hex chars. Required for
/// the bt transport (its single peer); for the bus transport it is the
/// allowlist of one — `None` means any bus peer.
pub peer: Option<String>,
/// Absolute path to the LogStore append-only log.
pub log_path: String,
/// Unix-domain socket path the IPC listens on.
pub socket_path: String,
/// Exchange poll interval in milliseconds. The real default belongs to the
/// builder; 20 ms keeps the link responsive without burning idle cycles.
#[serde(default = "default_poll_interval_ms")]
pub poll_interval_ms: u64,
/// Trust the system clock for the host's wall time, instead of deriving it
/// from a peer's observation. Set to `"system"` when the machine's clock is
/// reliable — an RTC, or a GPS/NTP-disciplined clock — the operator asserts
/// it rather than tines detecting it. Left unset, tines falls back to a
/// peer observation, and, with none available, stays quarantined. Any
/// other value is a config error.
#[serde(default)]
pub trusted_clock: Option<TrustedClock>,
/// How long, from the first plausible peer estimate, to keep collecting
/// estimates before adopting the latest (spec decision J).
#[serde(default = "default_peer_time_window_ms")]
pub peer_time_window_ms: u64,
/// The `unknown_downtime_age_ms` broadcast floor passed to LogStore while
/// the wall clock is unknown (the fold's unknown-downtime branch). Optional;
/// leave the LogStore default (`24h`) when unset.
///
/// The floor must stay below the host's trim `max_age_ms`. That bound waits
/// for host hardening — the trim defaults are still empty — so this check is
/// not validated until then.
#[serde(default)]
pub unknown_downtime_age_ms: Option<u64>,
/// Which transport the service runs: `bt` (default) or `bus`.
#[serde(default)]
pub transport: Option<TransportKind>,
/// Absolute path to the bus daemon's Unix-domain socket. Required when
/// `transport` is `bus`.
#[serde(default)]
pub bus_socket: Option<String>,
}
impl Config {
/// Check the transport constraints. Called by `main` before anything
/// else touches the config.
///
/// - `bt` (the default) requires `peer`.
/// - `bus` requires `bus_socket`; `peer` is the allowlist of one, and
/// `None` means any peer.
pub fn validate(&self) -> Result<(), String> {
match self.transport.unwrap_or(TransportKind::Bt) {
TransportKind::Bt if self.peer.is_none() => Err("transport bt requires peer".into()),
TransportKind::Bus if self.bus_socket.is_none() => {
Err("transport bus requires bus_socket".into())
}
_ => Ok(()),
}
}
}
/// The accepted values of `trusted_clock`.
#[derive(Debug, PartialEq, Eq, serde::Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum TrustedClock {
/// Trust the system clock (`"system"`).
System,
}
/// The transport the service runs over.
#[derive(Debug, PartialEq, Eq, Clone, Copy, serde::Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum TransportKind {
/// The BLE transport (the default), with the single configured peer.
Bt,
/// The host bus transport, peers = every node on the bus daemon (or the
/// allowlisted one).
Bus,
}
fn default_poll_interval_ms() -> u64 {
20
}
fn default_peer_time_window_ms() -> u64 {
DEFAULT_PEER_TIME_WINDOW_MS
}
#[cfg(test)]
mod tests {
use super::{Config, TransportKind, TrustedClock};
const BASE: &str = r#""node_id": "00000000000000000000000000000001",
"peer": "00000000000000000000000000000002",
"log_path": "/var/lib/tines/log",
"socket_path": "/run/tines.sock""#;
fn parse(extra: &str) -> Result<Config, serde_json::Error> {
serde_json::from_str(&format!("{{{BASE}{extra}}}"))
}
#[test]
fn defaults() {
let c = parse("").expect("minimal config");
assert_eq!(c.poll_interval_ms, 20);
assert_eq!(c.peer_time_window_ms, 10_000);
assert_eq!(c.trusted_clock, None);
assert_eq!(c.unknown_downtime_age_ms, None);
}
#[test]
fn trusted_clock_system_is_accepted() {
let c = parse(r#", "trusted_clock": "system""#).expect("system");
assert_eq!(c.trusted_clock, Some(TrustedClock::System));
}
#[test]
fn trusted_clock_unknown_value_is_rejected() {
assert!(parse(r#", "trusted_clock": "System""#).is_err());
assert!(parse(r#", "trusted_clock": "rtc""#).is_err());
assert!(parse(r#", "trusted_clock": "systme""#).is_err());
}
#[test]
fn unknown_field_is_rejected() {
assert!(parse(r#", "trusted_clok": "system""#).is_err());
}
#[test]
fn peer_time_window_is_configurable() {
let c = parse(r#", "peer_time_window_ms": 2500"#).expect("window");
assert_eq!(c.peer_time_window_ms, 2_500);
}
const BUS_BASE: &str = r#""node_id": "00000000000000000000000000000001",
"log_path": "/var/lib/tines/log",
"socket_path": "/run/tines.sock""#;
fn parse_bus(extra: &str) -> Result<Config, serde_json::Error> {
serde_json::from_str(&format!("{{{BUS_BASE}{extra}}}"))
}
#[test]
fn bus_config_parses() {
let c = parse_bus(r#", "transport": "bus", "bus_socket": "/run/tines-bus.sock""#)
.expect("bus config");
assert_eq!(c.transport, Some(TransportKind::Bus));
assert_eq!(c.bus_socket.as_deref(), Some("/run/tines-bus.sock"));
assert_eq!(c.peer, None);
assert_eq!(c.validate(), Ok(()));
}
#[test]
fn bt_without_peer_is_invalid() {
let c = parse_bus("").expect("bt config without peer parses");
assert_eq!(c.validate(), Err("transport bt requires peer".to_string()));
}
#[test]
fn bus_without_socket_is_invalid() {
let c = parse_bus(r#", "transport": "bus""#).expect("parses");
assert_eq!(
c.validate(),
Err("transport bus requires bus_socket".to_string())
);
}
#[test]
fn unknown_transport_is_rejected() {
assert!(parse_bus(r#", "transport": "wifi""#).is_err());
}
#[test]
fn transport_defaults_to_bt() {
let c = parse("").expect("minimal config");
assert_eq!(c.transport, None);
assert_eq!(c.validate(), Ok(()));
}
}
+167 -24
View File
@@ -3,8 +3,9 @@
//! The [`serve`] fn hosts a tines [`Node`] and speaks a tiny
//! request/response protocol to each connecting peer. A single instance is the
//! control plane for its device; external tools (or another tines Node, in
//! later tasks) drive the node through four operations: `observe`, `belief`,
//! `events`, and `status`.
//! later tasks) drive the node through six operations: `observe`, `belief`,
//! `events`, `status`, and `list` (the fleet console adds addressed `observe`
//! and contact/drop reporting to the `events` response).
//!
//! # Wire format
//!
@@ -39,7 +40,7 @@ use std::os::unix::net::{UnixListener, UnixStream};
use std::path::Path;
use std::time::Duration;
use tines_core::{Node, NodeEvent, Store, Transport, TrimPolicy};
use tines_core::{Node, NodeEvent, NodeId, Store, Transport, TrimPolicy};
use tines_store::LogStore;
use crate::trust::{adopt_wall_clock, PeerTimeWindow};
@@ -52,6 +53,9 @@ const MAX_FRAME: usize = 1 << 20; // 1 MiB cap on a single JSON frame
/// of its accept loop.
const POLL_CAP: u64 = 64;
/// Max entries a `List` response returns; `truncated` reports when more exist.
pub const LIST_CAP: usize = 1024;
/// A confirmed delivery handed out by the IPC `events` request.
#[derive(serde::Serialize, serde::Deserialize, Debug)]
pub struct EventOut {
@@ -61,6 +65,50 @@ pub struct EventOut {
pub peer: String,
}
/// A contact change handed out by the IPC `events` request: a transport
/// `Contact` (`joined: true`) or `Lost` (`joined: false`) for one peer.
#[derive(serde::Serialize, serde::Deserialize, Debug)]
pub struct ContactOut {
/// Hex of the peer's `NodeId`.
pub peer: String,
/// `true` for a new contact, `false` for a loss.
pub joined: bool,
}
/// A counted drop handed out by the IPC `events` request.
#[derive(serde::Serialize, serde::Deserialize, Debug)]
pub struct DropOut {
/// Hex of the peer the dropped frame came from, or `None` for the
/// overflow slot (which has no address to name).
pub peer: Option<String>,
/// The drop reason, debug-formatted (e.g. `"VersionMismatch { version: 255 }"`).
pub reason: String,
/// The number of frames folded into this counter since the last drain.
pub count: u32,
}
/// One stored observation handed out by the IPC `list` request.
#[derive(serde::Serialize, serde::Deserialize, Debug)]
pub struct ObservationOut {
/// Hex of the `ObservationId`.
pub id: String,
/// Hex of the observer's `NodeId`.
pub observer: String,
/// Hex of the topic.
pub topic: String,
/// Hex of the payload.
pub payload: String,
/// Hex of the destination, or `None` for a broadcast.
pub to: Option<String>,
/// The addressed lifetime in ms, or `None` for a broadcast.
pub lifetime_ms: Option<u64>,
/// How old the observation is, in ms (see `Observation::age_ms`).
pub age_ms: u64,
/// The observer's wall-clock time, ms since the Unix epoch, or `None`
/// when the observer had no trusted clock.
pub observed_at: Option<u64>,
}
/// A belief returned by the IPC `belief` request.
#[derive(serde::Serialize, serde::Deserialize, Debug)]
pub struct BeliefOut {
@@ -82,16 +130,26 @@ pub enum Request {
topic: String,
/// Hex of the observation payload.
payload: String,
/// Hex of the destination `NodeId` for an addressed observation.
/// Must be set together with `lifetime_ms`.
#[serde(default)]
to: Option<String>,
/// The addressed lifetime in ms. Must be set together with `to`.
#[serde(default)]
lifetime_ms: Option<u64>,
},
/// Read the latest belief for `topic`.
Belief {
/// Hex of the topic bytes to query.
topic: String,
},
/// Drain outstanding confirmed deliveries and drop counters.
/// Drain outstanding confirmed deliveries, contact changes, and counted
/// drops.
Events,
/// Report the node's identity and log depth.
Status,
/// List every stored observation (capped at [`LIST_CAP`]).
List,
}
/// An IPC response. See the module docs for the wire format.
@@ -108,10 +166,17 @@ pub enum Response {
/// The stored belief, or `None` if the topic is unknown.
obs: Option<BeliefOut>,
},
/// Confirmed deliveries drained from the exchange.
/// Confirmed deliveries, contact changes, and counted drops drained from
/// the exchange.
Events {
/// The drained confirmed deliveries.
events: Vec<EventOut>,
/// The drained contact changes (joined or lost), in order.
#[serde(default)]
contacts: Vec<ContactOut>,
/// The drained counted drops.
#[serde(default)]
drops: Vec<DropOut>,
},
/// The node's `id`, next tines `next_seq`, and observations `stored`.
Status {
@@ -122,6 +187,14 @@ pub enum Response {
/// Number of observations in the store.
stored: u32,
},
/// Every stored observation, up to [`LIST_CAP`] entries.
List {
/// The stored observations.
observations: Vec<ObservationOut>,
/// `true` when more observations exist than fit in
/// [`LIST_CAP`] entries.
truncated: bool,
},
/// The request was malformed or rejected.
Error {
/// A human-readable explanation.
@@ -147,10 +220,15 @@ pub fn handle<S, T>(
) -> Response
where
S: Store + 'static,
T: Transport,
T: Transport<Addr = NodeId>,
{
match req {
Request::Observe { topic, payload } => {
Request::Observe {
topic,
payload,
to,
lifetime_ms,
} => {
let topic = match decode_hex(&topic) {
Ok(t) => t,
Err(e) => return error(e),
@@ -159,7 +237,26 @@ where
Ok(t) => t,
Err(e) => return error(e),
};
match node.observe(&topic, &payload, observed_at, now) {
let result = match (to, lifetime_ms) {
(None, None) => node.observe(&topic, &payload, observed_at, now),
(Some(to), Some(lifetime_ms)) => {
let to = match decode_hex(&to) {
Ok(t) => t,
Err(e) => return error(e),
};
let to: NodeId = match to.try_into() {
Ok(id) => id,
Err(_) => {
return error("to must be a 16-byte NodeId (32 hex chars)");
}
};
node.observe_to(to, lifetime_ms, &topic, &payload, observed_at, now)
}
// An addressed observation needs both a destination and a
// lifetime; half-addressed is almost certainly a client bug.
_ => return error("to and lifetime_ms must be set together"),
};
match result {
Ok(id) => Response::Observe {
id: encode_hex(&id),
},
@@ -183,30 +280,76 @@ where
}
}
Request::Events => {
let events = node
.drain_events()
.into_iter()
.filter_map(|ev| match ev {
NodeEvent::Delivered { peer, id } => Some(EventOut {
let mut events = Vec::new();
let mut contacts = Vec::new();
let mut drops = Vec::new();
for ev in node.drain_events() {
match ev {
NodeEvent::Delivered { peer, id } => events.push(EventOut {
id: encode_hex(&id),
peer: encode_hex(&peer),
}),
// Dropped frames are surfaced to the operator via stderr
// (they are the whole point of D3), not the `events`
// response, so they filter out here.
NodeEvent::Contact { addr } => contacts.push(ContactOut {
peer: encode_hex(&addr),
joined: true,
}),
NodeEvent::Lost { addr } => contacts.push(ContactOut {
peer: encode_hex(&addr),
joined: false,
}),
// Drops surface here AND on stderr (they are the whole
// point of D3; the stderr line stays for `tines` logs).
NodeEvent::Dropped {
addr,
reason,
count,
} => {
eprintln!("tines: drop: {reason:?} (count={count}, addr={addr:?})");
None
drops.push(DropOut {
peer: addr.map(|a| encode_hex(&a)),
reason: format!("{reason:?}"),
count,
});
}
// `NodeEvent` is non-exhaustive: ignore any future variant.
_ => None,
})
.collect();
Response::Events { events }
_ => {}
}
}
Response::Events {
events,
contacts,
drops,
}
}
Request::List => {
let mut ids = Vec::with_capacity(LIST_CAP.min(node.store().len()));
let mut truncated = false;
node.store().for_each_id(&mut |id| {
if ids.len() < LIST_CAP {
ids.push(*id);
} else {
truncated = true;
}
});
let mut observations = Vec::with_capacity(ids.len());
for id in &ids {
if let Some(obs) = node.store().get(id, now) {
observations.push(ObservationOut {
id: encode_hex(&obs.id()),
observer: encode_hex(&obs.observer),
topic: encode_hex(&obs.topic),
payload: encode_hex(&obs.payload),
to: obs.dest.as_ref().map(|d| encode_hex(&d.to)),
lifetime_ms: obs.dest.as_ref().map(|d| d.lifetime_ms),
age_ms: obs.age_ms,
observed_at: obs.observed_at,
});
}
}
Response::List {
observations,
truncated,
}
}
Request::Status => Response::Status {
id: encode_hex(&node.id()),
@@ -232,7 +375,7 @@ struct HostState {
// [`serve`], fed by the socket read timeouts.
fn poll_exchange<T>(node: &mut Node<LogStore, T>, host: &mut HostState)
where
T: Transport + 'static,
T: Transport<Addr = NodeId> + 'static,
{
while node.poll(host.clock.now()) > 0 && host.rounds < POLL_CAP {
host.rounds += 1;
@@ -308,7 +451,7 @@ pub fn serve<T>(
peer_time_window_ms: u64,
) -> std::io::Result<()>
where
T: Transport + 'static,
T: Transport<Addr = NodeId> + 'static,
{
let socket_path = socket_path.as_ref().to_path_buf();
// Clear a stale socket file left by a previous run so bind() can't fail.
@@ -370,7 +513,7 @@ fn serve_stream<T>(
host: &mut HostState,
) -> io::Result<()>
where
T: Transport + 'static,
T: Transport<Addr = NodeId> + 'static,
{
loop {
match read_request(stream) {
+13 -5
View File
@@ -3,16 +3,24 @@
//! Hosts a `Node` over a Unix-domain socket and serves a
//! small request/response IPC for `observe` / `belief` / `events` / `status`.
//! One instance runs per device — the binary (`src/main.rs`) wires a
//! `Node<LogStore, BtTransport>` for real BLE, while tests drive the same
//! [`serve`] / [`handle`] path with a mock transport so no radio
//! is required.
//! `Node<LogStore, HostTransport>` for real BLE or the host bus, while tests
//! drive the same [`serve`] / [`handle`] path with a mock transport so no
//! radio is required.
//!
//! The IPC protocol is documented in [`ipc`].
//! The IPC protocol is documented in [`ipc`]; the on-disk configuration in
//! [`config`].
#![deny(missing_docs)]
pub mod clock;
pub mod config;
pub mod ipc;
pub mod transport;
pub mod trust;
pub use clock::HostClock;
pub use ipc::{handle, serve, BeliefOut, EventOut, Request, Response};
pub use config::{Config, TransportKind, TrustedClock};
pub use ipc::{
handle, serve, BeliefOut, ContactOut, DropOut, EventOut, ObservationOut, Request, Response,
LIST_CAP,
};
pub use transport::HostTransport;
+38 -113
View File
@@ -1,7 +1,8 @@
//! Standalone `tines` service binary.
//!
//! Loads a JSON config, builds a `Node<LogStore, BtTransport>` (BLE over
//! `bluer`), and serves the IPC ([`tines::serve`]) over a Unix-domain socket.
//! Loads a JSON config, builds a `Node<LogStore, HostTransport>` (BLE over
//! `bluer`, or the host bus — see [`tines::config::TransportKind`]), and
//! serves the IPC ([`tines::serve`]) over a Unix-domain socket.
//! One instance per device — the typical deployment on robots without ROS2.
//!
//! The node and peer ids are 32 lowercase hex chars (the 16-byte `NodeId`).
@@ -11,14 +12,16 @@
//! # Config
//!
//! A JSON object; an unknown key is an error, so a typo can't silently fall
//! back to a default.
//! back to a default. The full contract lives in [`tines::config::Config`].
//!
//! | key | required | meaning |
//! |---|---|---|
//! | `node_id` | yes | this device's `NodeId`, 32 hex chars |
//! | `peer` | yes | the peer's `NodeId`, 32 hex chars |
//! | `peer` | bt only | the peer's `NodeId`, 32 hex chars; on bus, the allowlist of one (`None` = any bus peer) |
//! | `log_path` | yes | the LogStore log file |
//! | `socket_path` | yes | the IPC Unix socket |
//! | `transport` | no (`bt`) | `"bt"` or `"bus"` |
//! | `bus_socket` | bus only | the bus daemon's Unix socket |
//! | `poll_interval_ms` | no (20) | exchange poll cadence |
//! | `trusted_clock` | no (unset) | `"system"` to trust the system clock; the only accepted value |
//! | `peer_time_window_ms` | no (10000) | how long to collect peer time estimates before adopting the latest |
@@ -50,81 +53,32 @@
use std::error::Error;
use std::path::Path;
use tines::trust::{adopt_wall_clock, system_wall_ms, DEFAULT_PEER_TIME_WINDOW_MS};
use tines::config::{Config, TransportKind, TrustedClock};
use tines::transport::HostTransport;
use tines::trust::{adopt_wall_clock, system_wall_ms};
use tines::{serve, HostClock};
use tines_bt::{BluerRadio, BtTransport};
use tines_bus::BusTransport;
use tines_core::{Node, NodeId};
use tines_store::LogStore;
/// On-disk configuration for a running service. Unknown keys are rejected.
#[derive(Debug, serde::Deserialize)]
#[serde(deny_unknown_fields)]
struct Config {
/// This device's advertised `NodeId`, 32 lowercase hex chars.
node_id: String,
/// The peer's advertised `NodeId`, 32 lowercase hex chars.
peer: String,
/// Absolute path to the LogStore append-only log.
log_path: String,
/// Unix-domain socket path the IPC listens on.
socket_path: String,
/// Exchange poll interval in milliseconds. The real default belongs to the
/// builder; 20 ms keeps the link responsive without burning idle cycles.
#[serde(default = "default_poll_interval_ms")]
poll_interval_ms: u64,
/// Trust the system clock for the host's wall time, instead of deriving it
/// from a peer's observation. Set to `"system"` when the machine's clock is
/// reliable — an RTC, or a GPS/NTP-disciplined clock — the operator asserts
/// it rather than tines detecting it. Left unset, tines falls back to a
/// peer observation, and, with none available, stays quarantined. Any
/// other value is a config error.
#[serde(default)]
trusted_clock: Option<TrustedClock>,
/// How long, from the first plausible peer estimate, to keep collecting
/// estimates before adopting the latest (spec decision J).
#[serde(default = "default_peer_time_window_ms")]
peer_time_window_ms: u64,
/// The `unknown_downtime_age_ms` broadcast floor passed to LogStore while
/// the wall clock is unknown (the fold's unknown-downtime branch). Optional;
/// leave the LogStore default (`24h`) when unset.
///
/// The floor must stay below the host's trim `max_age_ms`. That bound waits
/// for host hardening — the trim defaults are still empty — so this check is
/// not validated until then.
#[serde(default)]
unknown_downtime_age_ms: Option<u64>,
}
/// The accepted values of `trusted_clock`.
#[derive(Debug, PartialEq, Eq, serde::Deserialize)]
#[serde(rename_all = "lowercase")]
enum TrustedClock {
/// Trust the system clock (`"system"`).
System,
}
fn default_poll_interval_ms() -> u64 {
20
}
fn default_peer_time_window_ms() -> u64 {
DEFAULT_PEER_TIME_WINDOW_MS
}
fn main() -> Result<(), Box<dyn Error + Send + Sync>> {
let cfg_path = std::env::args()
.nth(1)
.ok_or("usage: tines <config.json>")?;
let config: Config = serde_json::from_str(&std::fs::read_to_string(cfg_path)?)
.map_err(|e| format!("invalid config: {e}"))?;
config
.validate()
.map_err(|e| format!("invalid config: {e}"))?;
let me = parse_node_id(&config.node_id)?;
let peer = parse_node_id(&config.peer)?;
// LogStore is the Store (S); BtTransport wraps it as the Transport (T).
// unknown_downtime_age_ms (the broadcast floor while the wall clock is
// unknown) is optional; leave the LogStore default when unset.
// Peer estimates outside the plausible range don't count (decision J).
// LogStore is the Store (S); HostTransport wraps BLE or the bus as the
// Transport (T). unknown_downtime_age_ms (the broadcast floor while the
// wall clock is unknown) is optional; leave the LogStore default when
// unset. Peer estimates outside the plausible range don't count
// (decision J).
let builder = LogStore::builder(Path::new(&config.log_path))
.plausible_wall_ms(tines::trust::PLAUSIBLE_WALL_MS);
let builder = match config.unknown_downtime_age_ms {
@@ -136,8 +90,26 @@ fn main() -> Result<(), Box<dyn Error + Send + Sync>> {
.open(clock.now())
.map_err(|e| format!("failed to open log store: {e:?}"))?;
// bt: the single configured peer (validation guarantees it). bus: the
// configured peer is the allowlist of one; None means any bus peer.
let transport = match config.transport.unwrap_or(TransportKind::Bt) {
TransportKind::Bt => {
let peer = parse_node_id(config.peer.as_deref().expect("validated"))?;
let radio = BluerRadio::new(me, peer);
let transport = BtTransport::new(me, peer, radio);
HostTransport::Bt(BtTransport::new(me, peer, radio))
}
TransportKind::Bus => {
let allowlist = match &config.peer {
Some(p) => Some(parse_node_id(p)?),
None => None,
};
HostTransport::Bus(BusTransport::new(
me,
allowlist,
config.bus_socket.as_deref().expect("validated"),
)?)
}
};
// `serve` runs the exchange inline (a poll loop fed by the socket read
// timeouts) on this same thread, so observations and beliefs flow over the
// transport, not just over IPC. The node isn't `Send` (the BLE transport
@@ -212,50 +184,3 @@ fn node_hex(id: NodeId) -> String {
}
s
}
#[cfg(test)]
mod tests {
use super::{Config, TrustedClock};
const BASE: &str = r#""node_id": "00000000000000000000000000000001",
"peer": "00000000000000000000000000000002",
"log_path": "/var/lib/tines/log",
"socket_path": "/run/tines.sock""#;
fn parse(extra: &str) -> Result<Config, serde_json::Error> {
serde_json::from_str(&format!("{{{BASE}{extra}}}"))
}
#[test]
fn defaults() {
let c = parse("").expect("minimal config");
assert_eq!(c.poll_interval_ms, 20);
assert_eq!(c.peer_time_window_ms, 10_000);
assert_eq!(c.trusted_clock, None);
assert_eq!(c.unknown_downtime_age_ms, None);
}
#[test]
fn trusted_clock_system_is_accepted() {
let c = parse(r#", "trusted_clock": "system""#).expect("system");
assert_eq!(c.trusted_clock, Some(TrustedClock::System));
}
#[test]
fn trusted_clock_unknown_value_is_rejected() {
assert!(parse(r#", "trusted_clock": "System""#).is_err());
assert!(parse(r#", "trusted_clock": "rtc""#).is_err());
assert!(parse(r#", "trusted_clock": "systme""#).is_err());
}
#[test]
fn unknown_field_is_rejected() {
assert!(parse(r#", "trusted_clok": "system""#).is_err());
}
#[test]
fn peer_time_window_is_configurable() {
let c = parse(r#", "peer_time_window_ms": 2500"#).expect("window");
assert_eq!(c.peer_time_window_ms, 2_500);
}
}
+39
View File
@@ -0,0 +1,39 @@
//! The host's chosen transport: either the BLE radio ([`BtTransport`]) or the
//! host bus ([`BusTransport`]), per the service config.
use tines_bt::BtTransport;
use tines_bus::BusTransport;
use tines_core::{Event, NodeId, SendError, Transport};
/// The transport the service runs, selected by [`crate::config::Config`].
pub enum HostTransport {
/// The BLE transport with the single configured peer.
Bt(BtTransport),
/// The host bus transport with the (optionally allowlisted) bus peers.
Bus(BusTransport),
}
impl Transport for HostTransport {
type Addr = NodeId;
fn poll(&mut self) -> Option<Event<NodeId>> {
match self {
HostTransport::Bt(t) => t.poll(),
HostTransport::Bus(t) => t.poll(),
}
}
fn send(&mut self, to: &NodeId, frame: &[u8]) -> Result<(), SendError> {
match self {
HostTransport::Bt(t) => t.send(to, frame),
HostTransport::Bus(t) => t.send(to, frame),
}
}
fn mtu(&self) -> usize {
match self {
HostTransport::Bt(t) => t.mtu(),
HostTransport::Bus(t) => t.mtu(),
}
}
}
+288 -2
View File
@@ -266,6 +266,8 @@ fn observe_and_belief_over_socket() {
&Request::Observe {
topic: hex_encode(&topic()),
payload: hex_encode(&payload()),
to: None,
lifetime_ms: None,
},
);
let id = match read_response(&mut stream) {
@@ -311,7 +313,7 @@ fn events_over_socket() {
rpc(&mut stream, &Request::Events);
match read_response(&mut stream) {
Response::Events { events } => {
Response::Events { events, .. } => {
assert_eq!(events.len(), 1, "expected one confirmed delivery");
assert_eq!(events[0].id, hex_encode(&x), "event id matches observed id");
assert_eq!(events[0].peer, hex_encode(&peer), "event came from peer");
@@ -329,6 +331,8 @@ fn handle_bad_hex_is_error() {
Request::Observe {
topic: "z".into(),
payload: "ff".into(),
to: None,
lifetime_ms: None,
},
None,
0,
@@ -348,8 +352,14 @@ fn confirmed_delivery_direct_probe() {
node.poll(0);
node.poll(0);
let evs = node.drain_events();
assert_eq!(evs.len(), 1, "one confirmed delivery after observe+ack");
// The mock's first poll surfaces a Contact (now a NodeEvent) ahead of the
// confirmed delivery.
assert_eq!(evs.len(), 2, "contact event then confirmed delivery");
match &evs[0] {
NodeEvent::Contact { addr } => assert_eq!(*addr, [7; 16]),
other => panic!("expected a Contact event, got {other:?}"),
}
match &evs[1] {
NodeEvent::Delivered { peer, id } => {
assert_eq!(*id, x);
assert_eq!(*peer, [7; 16]);
@@ -589,6 +599,8 @@ fn f2_propagation_between_instances() {
&Request::Observe {
topic: hex_encode(&topic()),
payload: hex_encode(&payload()),
to: None,
lifetime_ms: None,
},
);
let obs = match read_response(&mut stream_a) {
@@ -694,6 +706,8 @@ fn b_stamp(
&Request::Observe {
topic: hex_encode(&topic()),
payload: hex_encode(format!("probe-{n}").as_bytes()),
to: None,
lifetime_ms: None,
},
);
let id = match read_response(&mut stream) {
@@ -895,3 +909,275 @@ fn forged_estimate_does_not_hide_an_honest_one() {
fn below_floor_observed_at_is_not_adopted() {
assert_not_adopted("floor", 1_000_000_000_000);
}
// --- Widened IPC (fleet console): addressed observe, List, contact/drop events
/// An addressed `Observe` (both `to` and `lifetime_ms` set) returns the id and
/// the observation shows up in `List` with matching addressing.
#[test]
fn observe_addressed_roundtrip() {
let dir = unique_dir("addressed");
let mut node = build_node([3; 16], &dir);
let to = [4u8; 16];
let resp = handle(
&mut node,
Request::Observe {
topic: hex_encode(&topic()),
payload: hex_encode(&payload()),
to: Some(hex_encode(&to)),
lifetime_ms: Some(60_000),
},
None,
0,
);
let id = match resp {
Response::Observe { id } => id,
other => panic!("expected Observe, got {other:?}"),
};
match handle(&mut node, Request::List, None, 0) {
Response::List {
observations,
truncated,
} => {
assert!(!truncated);
assert_eq!(observations.len(), 1);
let o = &observations[0];
assert_eq!(o.id, id);
assert_eq!(o.observer, hex_encode(&[3u8; 16]));
assert_eq!(o.topic, hex_encode(&topic()));
assert_eq!(o.payload, hex_encode(&payload()));
assert_eq!(o.to.as_deref(), Some(hex_encode(&to).as_str()));
assert_eq!(o.lifetime_ms, Some(60_000));
}
other => panic!("expected List, got {other:?}"),
}
}
/// Exactly one of `to` / `lifetime_ms` set is an error, in either direction.
#[test]
fn observe_half_addressed_is_error() {
let dir = unique_dir("half");
let mut node = build_node([3; 16], &dir);
for (to, lifetime) in [(Some(hex_encode(&[4u8; 16])), None), (None, Some(1u64))] {
let resp = handle(
&mut node,
Request::Observe {
topic: hex_encode(&topic()),
payload: hex_encode(&payload()),
to,
lifetime_ms: lifetime,
},
None,
0,
);
match resp {
Response::Error { message } => {
assert!(
message.contains("must be set together"),
"unexpected message: {message}"
);
}
other => panic!("expected Error, got {other:?}"),
}
}
}
/// `List` returns every stored observation (broadcast and addressed) with
/// correct fields.
#[test]
fn list_returns_all_observations() {
let dir = unique_dir("listall");
let mut node = build_node([3; 16], &dir);
let _ = handle(
&mut node,
Request::Observe {
topic: hex_encode(&topic()),
payload: hex_encode(b"broadcast"),
to: None,
lifetime_ms: None,
},
None,
0,
);
let _ = handle(
&mut node,
Request::Observe {
topic: hex_encode(&topic()),
payload: hex_encode(b"addressed"),
to: Some(hex_encode(&[4u8; 16])),
lifetime_ms: Some(1_000),
},
None,
0,
);
match handle(&mut node, Request::List, None, 0) {
Response::List {
observations,
truncated,
} => {
assert!(!truncated);
assert_eq!(observations.len(), 2, "both observations listed");
let broadcast = observations
.iter()
.find(|o| o.payload == hex_encode(b"broadcast"))
.expect("broadcast present");
assert_eq!(broadcast.to, None);
assert_eq!(broadcast.lifetime_ms, None);
assert_eq!(broadcast.topic, hex_encode(&topic()));
assert_eq!(broadcast.observer, hex_encode(&[3u8; 16]));
let addressed = observations
.iter()
.find(|o| o.payload == hex_encode(b"addressed"))
.expect("addressed present");
assert_eq!(
addressed.to.as_deref(),
Some(hex_encode(&[4u8; 16]).as_str())
);
assert_eq!(addressed.lifetime_ms, Some(1_000));
}
other => panic!("expected List, got {other:?}"),
}
}
/// `List` caps at `LIST_CAP` entries and reports truncation when more exist.
#[test]
fn list_caps_at_list_cap() {
let dir = unique_dir("listcap");
let mut node = build_node([3; 16], &dir);
for _ in 0..(tines::LIST_CAP + 1) {
let resp = handle(
&mut node,
Request::Observe {
topic: hex_encode(&topic()),
payload: hex_encode(b"x"),
to: None,
lifetime_ms: None,
},
None,
0,
);
assert!(matches!(resp, Response::Observe { .. }));
}
match handle(&mut node, Request::List, None, 0) {
Response::List {
observations,
truncated,
} => {
assert_eq!(observations.len(), tines::LIST_CAP);
assert!(truncated, "more observations exist than fit");
}
other => panic!("expected List, got {other:?}"),
}
}
/// The transport's first `Contact` surfaces in `Events.contacts` as
/// `joined: true`, alongside an empty `events` (no deliveries yet).
#[test]
fn events_include_contacts() {
let dir = unique_dir("contacts");
let socket = dir.join("ipc.sock");
let peer = [7; 16];
let mut node = build_node(peer, &dir);
node.poll(0); // the mock's first poll surfaces the Contact
let serve_path = socket.clone();
let _handle =
thread::spawn(move || serve(node, serve_path, HostClock::start(), 20, WINDOW_MS).unwrap());
let mut stream = connect(&socket);
rpc(&mut stream, &Request::Events);
match read_response(&mut stream) {
Response::Events {
events,
contacts,
drops,
} => {
assert!(events.is_empty(), "no deliveries before any ack");
assert_eq!(drops.len(), 0, "nothing was dropped");
assert_eq!(contacts.len(), 1, "one contact surfaced");
assert_eq!(contacts[0].peer, hex_encode(&peer));
assert!(contacts[0].joined);
}
other => panic!("expected Events, got {other:?}"),
}
}
/// `Lost` surfaces as `joined: false`, and a garbage frame surfaces in
/// `drops` with its reason (while the stderr line stays in the service).
#[test]
fn events_include_lost_and_drops() {
let dir = unique_dir("lost");
let peer = [7; 16];
let mut node = build_node(peer, &dir);
node.poll(0); // Contact -> NodeEvent::Contact
node.transport_mut().pending.push_back(Event::Lost(peer));
node.poll(0); // Lost -> NodeEvent::Lost
node.transport_mut().pending.push_back(Event::Frame(
peer,
FrameBuf::from_slice(&[0xff; 8]).expect("small garbage frame fits"),
));
node.poll(0); // garbage -> counted drop
match handle(&mut node, Request::Events, None, 0) {
Response::Events {
events,
contacts,
drops,
} => {
assert!(events.is_empty());
assert_eq!(contacts.len(), 2, "contact then loss");
assert!(contacts[0].joined);
assert_eq!(contacts[0].peer, hex_encode(&peer));
assert!(!contacts[1].joined, "the lost contact is closed");
assert_eq!(contacts[1].peer, hex_encode(&peer));
assert_eq!(drops.len(), 1, "one counted drop");
assert_eq!(drops[0].peer.as_deref(), Some(hex_encode(&peer).as_str()));
assert!(
drops[0].reason.contains("VersionMismatch"),
"the drop names its reason: {}",
drops[0].reason
);
assert_eq!(drops[0].count, 1);
}
other => panic!("expected Events, got {other:?}"),
}
}
/// The existing `events` field still reports confirmed deliveries exactly as
/// before the widening (the mock auto-ack path).
#[test]
fn existing_events_field_unchanged() {
let dir = unique_dir("unchanged");
let socket = dir.join("ipc.sock");
let peer = [7; 16];
let mut node = build_node(peer, &dir);
let x = node.observe(&topic(), &payload(), None, 0).unwrap();
node.poll(0);
node.poll(0);
node.poll(0);
let serve_path = socket.clone();
let _handle =
thread::spawn(move || serve(node, serve_path, HostClock::start(), 20, WINDOW_MS).unwrap());
let mut stream = connect(&socket);
rpc(&mut stream, &Request::Events);
match read_response(&mut stream) {
Response::Events { events, .. } => {
assert_eq!(events.len(), 1, "one confirmed delivery");
assert_eq!(events[0].id, hex_encode(&x));
assert_eq!(events[0].peer, hex_encode(&peer));
}
other => panic!("expected Events, got {other:?}"),
}
}
+215
View File
@@ -0,0 +1,215 @@
//! End-to-end bus fleet test: two real `tines` service processes converge
//! over a live `tines-busd` — the sim-fleet deliverable without docker.
//!
//! Node A observes a broadcast; node B learns it over the bus (belief, store
//! list) and both see the contact change.
use std::io::{Read, Write};
use std::os::unix::net::UnixStream;
use std::path::{Path, PathBuf};
use std::process::{Child, Command, Stdio};
use std::sync::atomic::{AtomicUsize, Ordering};
use std::time::{Duration, Instant};
use tines::{Request, Response};
static COUNTER: AtomicUsize = AtomicUsize::new(0);
fn unique_dir(tag: &str) -> PathBuf {
let n = COUNTER.fetch_add(1, Ordering::Relaxed);
let dir = std::env::temp_dir().join(format!(
"tines-service-bus-{tag}-{}-{n}",
std::process::id()
));
std::fs::create_dir_all(&dir).unwrap();
dir
}
fn node_id_hex(n: u8) -> String {
format!("{n:02x}").repeat(16)
}
fn spawn_tines(config_path: &Path, dir: &Path) -> Child {
let stderr = std::fs::File::create(dir.join("stderr.log")).unwrap();
Command::new(env!("CARGO_BIN_EXE_tines"))
.arg(config_path)
.stderr(Stdio::from(stderr))
.stdout(Stdio::null())
.spawn()
.expect("spawn tines service")
}
fn connect(path: &Path, deadline: Instant) -> UnixStream {
loop {
if let Ok(s) = UnixStream::connect(path) {
return s;
}
assert!(
Instant::now() < deadline,
"failed to connect to {}",
path.display()
);
std::thread::sleep(Duration::from_millis(20));
}
}
fn rpc(stream: &mut UnixStream, req: &Request) {
let body = serde_json::to_vec(req).expect("serialize request");
let len = (body.len() as u32).to_le_bytes();
stream.write_all(&len).unwrap();
stream.write_all(&body).unwrap();
stream.flush().unwrap();
}
fn read_response(stream: &mut UnixStream) -> Response {
let mut len_buf = [0u8; 4];
stream.read_exact(&mut len_buf).unwrap();
let len = u32::from_le_bytes(len_buf) as usize;
let mut buf = vec![0u8; len];
stream.read_exact(&mut buf).unwrap();
serde_json::from_slice(&buf).expect("valid response")
}
/// Poll `poll_once` until `pred` accepts a response, or fail after `deadline`.
fn poll_until(
mut poll_once: impl FnMut() -> Response,
pred: impl Fn(&Response) -> bool,
deadline: Instant,
) -> Response {
loop {
let r = poll_once();
if pred(&r) {
return r;
}
assert!(Instant::now() < deadline, "timed out polling the service");
std::thread::sleep(Duration::from_millis(50));
}
}
#[test]
fn two_services_converge_over_bus() {
let dir = unique_dir("converge");
let bus_socket = dir.join("bus.sock");
// The bus daemon runs in-process on a thread (a thread in the test
// process, not an external binary, so the test has no CARGO_BIN_EXE
// dependency); poll until it has bound its socket.
let daemon_socket = bus_socket.clone();
std::thread::spawn(move || {
let _ = tines_bus::daemon::serve(&daemon_socket);
});
{
let mut bound = UnixStream::connect(&bus_socket);
let start = Instant::now();
while bound.is_err() && start.elapsed() < Duration::from_secs(5) {
std::thread::sleep(Duration::from_millis(20));
bound = UnixStream::connect(&bus_socket);
}
assert!(bound.is_ok(), "bus daemon did not bind its socket");
}
// Write both configs.
let mk_config = |node: &str, log: &str, sock: &str| -> String {
serde_json::json!({
"node_id": node,
"log_path": dir.join(log).to_str().unwrap(),
"socket_path": dir.join(sock).to_str().unwrap(),
"trusted_clock": "system",
"transport": "bus",
"bus_socket": bus_socket.to_str().unwrap(),
})
.to_string()
};
let cfg_a = dir.join("a.json");
let cfg_b = dir.join("b.json");
std::fs::write(&cfg_a, mk_config(&node_id_hex(1), "a.log", "a.sock")).unwrap();
std::fs::write(&cfg_b, mk_config(&node_id_hex(2), "b.log", "b.sock")).unwrap();
// The generated configs must parse as the lib-level Config type.
let _: tines::config::Config =
serde_json::from_str(&std::fs::read_to_string(&cfg_a).unwrap()).unwrap();
let mut a_proc = spawn_tines(&cfg_a, &dir);
let mut b_proc = spawn_tines(&cfg_b, &dir);
let deadline = Instant::now() + Duration::from_secs(10);
let mut a = connect(&dir.join("a.sock"), deadline);
let mut b = connect(&dir.join("b.sock"), deadline);
let topic = hex("status");
let payload = hex("ping");
// A observes a broadcast.
rpc(
&mut a,
&Request::Observe {
topic: topic.clone(),
payload: payload.clone(),
to: None,
lifetime_ms: None,
},
);
match read_response(&mut a) {
Response::Observe { .. } => {}
other => panic!("expected Observe response, got {other:?}"),
}
// B's belief converges over the bus.
poll_until(
|| {
rpc(
&mut b,
&Request::Belief {
topic: topic.clone(),
},
);
read_response(&mut b)
},
|r| matches!(r, Response::Belief { obs: Some(o) } if o.payload == payload),
deadline,
);
// B's store listing contains it too.
rpc(&mut b, &Request::List);
match read_response(&mut b) {
Response::List { observations, .. } => {
assert!(
observations
.iter()
.any(|o| o.payload == payload && o.topic == topic),
"observation missing from B's store list: {observations:?}"
);
}
other => panic!("expected List response, got {other:?}"),
}
// Both nodes saw the contact change.
for (stream, expected_peer) in [(&mut a, node_id_hex(2)), (&mut b, node_id_hex(1))] {
poll_until(
|| {
rpc(stream, &Request::Events);
read_response(stream)
},
|r| {
matches!(r, Response::Events { contacts, .. }
if contacts.iter().any(|c| c.joined && c.peer == expected_peer))
},
deadline,
);
}
drop(a);
drop(b);
let _ = a_proc.kill();
let _ = b_proc.kill();
let _ = a_proc.wait();
let _ = b_proc.wait();
}
fn hex(bytes: &str) -> String {
bytes
.as_bytes()
.iter()
.map(|b| format!("{b:02x}"))
.collect()
}