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 .superpowers
docs docs
HANDOFF.md HANDOFF.md
tines-gui
+14
View File
@@ -42,6 +42,20 @@ jobs:
sudo apt-get update sudo apt-get update
sudo apt-get install -y --no-install-recommends libdbus-1-dev pkg-config 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 - name: Run CI gate
run: sh scripts/ci.sh run: sh scripts/ci.sh
+1
View File
@@ -1 +1,2 @@
/target /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 - `tines-core` is `#![no_std]`; `alloc` is a cargo feature (default on). With
`--no-default-features` it must build for `thumbv7em-none-eabihf`. Only `--no-default-features` it must build for `thumbv7em-none-eabihf`. Only
`MemStore` may require `alloc`. `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 - 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 frame from the wire is untrusted: malformed bytes are dropped, never
unwrapped. unwrapped.
@@ -146,9 +151,15 @@ tines/
tines-store/ std: LogStore, the persistent append-only backend tines-store/ std: LogStore, the persistent append-only backend
tines-bt/ std: BLE transport over BlueZ (bluer) tines-bt/ std: BLE transport over BlueZ (bluer)
tines/ std: standalone service, Unix-socket JSON IPC 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-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). 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 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`). 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 ## Hosts 0.4.0 — Unreleased
`tines`, `tines-store`, `tines-bt`, `tines-ros2` and `tines-sim` move to 0.4.0 `tines`, `tines-store`, `tines-bt`, `tines-bus`, `tines-ros2` and
and depend on `tines-core = "1"`. Like tines-core 1.0.0, nothing here is `tines-sim` move to 0.4.0 and depend on `tines-core = "1"`. Like tines-core
tagged or published yet. 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 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 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 ### Known issues
- The service talks to exactly one configured `peer`. The multi-peer - The service talks to exactly one configured `peer` on the BT transport.
allowlist the fleet-fixes spec describes isn't wired up (F3). 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). - Neither host flushes on a timer or at shutdown (F18).
- The service trims and flushes only after 64 poll rounds that each stored - The service trims and flushes only after 64 poll rounds that each stored
something new. 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 - Peer time is unauthenticated until 1.1. An adopted time isn't corrected
while the host runs (F19). 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 ## 0.3.0 — 2026-10-18
### Breaking ### Breaking
Generated
+8
View File
@@ -1469,6 +1469,7 @@ dependencies = [
"serde", "serde",
"serde_json", "serde_json",
"tines-bt", "tines-bt",
"tines-bus",
"tines-core", "tines-core",
"tines-store", "tines-store",
] ]
@@ -1484,6 +1485,13 @@ dependencies = [
"tokio", "tokio",
] ]
[[package]]
name = "tines-bus"
version = "0.4.0"
dependencies = [
"tines-core",
]
[[package]] [[package]]
name = "tines-core" name = "tines-core"
version = "1.0.0" version = "1.0.0"
+1 -1
View File
@@ -1,5 +1,5 @@
[workspace] [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" resolver = "2"
[workspace.package] [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 Vinge's *A Fire Upon the Deep*: the Tines only form a mind when close enough to
hear each other. hear each other.
![1page](docs/images/tines-systems-data.png)
## Contents ## Contents
- [Design at a glance](#design-at-a-glance) - [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 - Connections produce `Contact` and `Lost` events. See
[`tines-bt/README.md`](tines-bt/README.md). [`tines-bt/README.md`](tines-bt/README.md).
- **`tines`**: the standalone service, for robots without ROS2. - **`tines`**: the standalone service, for robots without ROS2.
- It runs the exchange on a poll loop and answers `Observe`, `Belief`, - It runs the exchange on a poll loop and answers `Observe` (broadcast or
`Events` and `Status` requests on a Unix socket. addressed), `Belief`, `Events`, `List` and `Status` requests on a Unix
socket.
- It's configured by a JSON file (below). Library users call - It's configured by a JSON file (below). Library users call
`tines::serve(node, socket_path, clock, poll_interval_ms, `tines::serve(node, socket_path, clock, poll_interval_ms,
peer_time_window_ms)`. peer_time_window_ms)`.
@@ -578,9 +581,11 @@ typo can't silently fall back to a default.
| Key | Default | Meaning | | Key | Default | Meaning |
|---------------------------|--------------|---------| |---------------------------|--------------|---------|
| `node_id` | required | this robot's `NodeId`, 32 hex chars | | `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 | | `log_path` | required | the `LogStore` log file |
| `socket_path` | required | the IPC Unix socket | | `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 | | `poll_interval_ms` | 20 | exchange poll cadence |
| `trusted_clock` | unset | `"system"` to trust the system clock; the only accepted value | | `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 | | `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). while the host runs; see F19 in [`THREAT_MODEL.md`](THREAT_MODEL.md).
**Current limits** (tracked 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 - 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 64 productive poll rounds, and both hosts flush when they adopt trusted
time. 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 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 ## Crate layout
``` ```
@@ -658,9 +702,14 @@ tines/
tines-store/ std: LogStore, the persistent append-only backend. tines-store/ std: LogStore, the persistent append-only backend.
tines-bt/ std: the BLE transport (BlueZ via bluer). tines-bt/ std: the BLE transport (BlueZ via bluer).
tines/ std: the standalone service with Unix-socket IPC. 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-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 The public constants, in `tines_core::consts`, are the wire constants. They
are frozen for wire v1: changing one is a `WIRE_VERSION` bump. 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 / - **Assessed:** `master` at `3b1a644` (tines-core / tines-store / tines-bt /
tines-ros2 / tines 0.3.0, fleet-deployment milestone mostly landed) tines-ros2 / tines 0.3.0, fleet-deployment milestone mostly landed)
- **Date:** 2026-10-19 - **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 tines-core 1.0 review fixes landed. Earlier update: 2026-10-20 at
`89041ea`, after the fleet fixes. The findings keep their original text; `89041ea`, after the fleet fixes. The findings keep their original text;
each fixed one ends with a **Fixed** or **Partly fixed** line. §6 adds 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 BLE security level is requested (unchanged; 1.1).
- No two-radio check of the SeqPacket path is recorded yet. - No two-radio check of the SeqPacket path is recorded yet.
tines-bt/README.md gives the procedure but no result. 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 **Later (1.1):** authenticate the link (spec §2b). Advertised NodeIds are
unauthenticated, so the binding only becomes meaningful once there's a 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 an idle client blocks everyone. An `accept` error ends the daemon. An
oversized frame (> 1 MiB) gets an error reply without consuming its body, oversized frame (> 1 MiB) gets an error reply without consuming its body,
which desynchronizes the stream (contrary to the module docs). 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:** - **Fix:**
- Create the socket in a `0700` runtime directory with mode `0600` (or a - Create the socket in a `0700` runtime directory with mode `0600` (or a
group-restricted mode). group-restricted mode).
@@ -626,6 +640,38 @@ authenticates that estimate.
`time-sync.target`). Signing and verification in 1.1 close the forged `time-sync.target`). Signing and verification in 1.1 close the forged
case. 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 ## 7. Threats by STRIDE, mapped to findings
| | Threat | Status today | Findings | Planned mitigation | | | 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 | | 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 | | 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) | | 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 | | F7, F14, §8 | 1.1 (threat model, link authentication, signing) | 1.0 spec §2a, §2b | Open |
| F15, F16 | Accepted for now | — | — | | 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. # compiling on the pinned stable toolchain, or the scheduled job rots unseen.
cargo fmt --manifest-path fuzz/Cargo.toml -- --check cargo fmt --manifest-path fuzz/Cargo.toml -- --check
cargo clippy --locked --manifest-path fuzz/Cargo.toml --all-targets -- -D warnings 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. /// The number of frames folded into this counter since the last drain.
count: u32, 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`] /// The `Exchange`'s counted-drop table: up to [`consts::DROP_TABLE_CAP`]
@@ -323,7 +343,9 @@ pub(crate) struct Exchange<A> {
/// record. /// record.
bcast_sent: Vec<(ObservationId, A), { consts::BCAST_SENT_CAP }>, bcast_sent: Vec<(ObservationId, A), { consts::BCAST_SENT_CAP }>,
/// One [`NodeEvent::Delivered`] per broadcast id a peer has confirmed, /// 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 }>, events: Vec<NodeEvent<A>, { consts::EVENT_QUEUE_CAP }>,
/// Counted drops: distinct `(addr, DropReason)` pairs, each with a /// Counted drops: distinct `(addr, DropReason)` pairs, each with a
/// saturating count, plus a single overflow counter; reported through /// saturating count, plus a single overflow counter; reported through
@@ -375,11 +397,15 @@ impl<A: Clone + PartialEq + core::fmt::Debug> Exchange<A> {
) -> Option<Observation> { ) -> Option<Observation> {
match ev { match ev {
Event::Contact(addr) => { Event::Contact(addr) => {
self.add_peer(addr, now); if self.add_peer(addr.clone(), now) {
self.push_event(NodeEvent::Contact { addr });
}
None None
} }
Event::Lost(addr) => { Event::Lost(addr) => {
self.remove_peer(&addr); if self.remove_peer(&addr) {
self.push_event(NodeEvent::Lost { addr });
}
None None
} }
Event::Frame(addr, bytes) => self.on_frame(addr, &bytes, store, now), 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 &self.peers
} }
/// Drains the [`NodeEvent`]s — confirmed deliveries plus counted drops — /// Pushes an event onto the delivery queue with drop-oldest overflow
/// for [`crate::Node::drain_events`]. /// (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. /// [`NodeEvent::Dropped`] per non-zero counter, emptying the drop table.
/// Sized to hold the entire delivery queue plus the whole drop table and /// Sized to hold the entire event queue plus the whole drop table and
/// its overflow slot, so a full delivery queue never evicts a counted drop. /// its overflow slot, so a full queue never evicts a counted drop.
pub(crate) fn drain_events( pub(crate) fn drain_events(
&mut self, &mut self,
) -> Vec<NodeEvent<A>, { consts::EVENT_QUEUE_CAP + consts::DROP_TABLE_CAP + 1 }> { ) -> 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 /// starts a new contact too: the F6 pin and the per-contact records
/// (bounces, broadcast receipts) reset. /// (bounces, broadcast receipts) reset.
/// A repeat `Contact` also restarts the peer's idle clock. /// 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.evict_idle(now);
self.forget_contact(&addr); self.forget_contact(&addr);
if let Some(peer) = self.peers.iter_mut().find(|p| p.addr == addr) { if let Some(peer) = self.peers.iter_mut().find(|p| p.addr == addr) {
peer.id = None; peer.id = None;
peer.added_at = now; peer.added_at = now;
peer.last_inbound = None; peer.last_inbound = None;
return; return false;
} }
if self.peers.len() < consts::MAX_PEERS { if self.peers.len() < consts::MAX_PEERS {
let _ = self.peers.push(Peer::new(addr, now)); 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 /// 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) { fn remove_peer(&mut self, addr: &A) -> bool {
if let Some(idx) = self.peers.iter().position(|p| p.addr == *addr) { let removed = if let Some(idx) = self.peers.iter().position(|p| p.addr == *addr) {
self.peers.remove(idx); self.peers.remove(idx);
} true
} else {
false
};
// F13: purge the removed peer's pending acks (only its own) so their // F13: purge the removed peer's pending acks (only its own) so their
// ids are never acked again under this contact. // ids are never acked again under this contact.
self.acks.retain_other(addr); self.acks.retain_other(addr);
@@ -906,6 +953,7 @@ impl<A: Clone + PartialEq + core::fmt::Debug> Exchange<A> {
self.ack_undo = None; self.ack_undo = None;
} }
self.forget_contact(addr); self.forget_contact(addr);
removed
} }
/// Drops the per-contact records for `addr` — bounce suppression and /// 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 // delivery queue is full, drop the OLDEST Delivered rather than
// the newest (a bare `push` drops the newest) so the freshest // the newest (a bare `push` drops the newest) so the freshest
// confirmed deliveries survive overflow (F13). // confirmed deliveries survive overflow (F13).
if self.events.is_full() { self.push_event(NodeEvent::Delivered {
let _ = self.events.remove(0);
}
let _ = self.events.push(NodeEvent::Delivered {
peer: from, peer: from,
id: *id, id: *id,
}); });
@@ -2981,6 +3026,7 @@ mod tests {
let b_id = b.id(); let b_id = b.id();
store.insert(b, 0).unwrap(); store.insert(b, 0).unwrap();
deliver_to(&mut ex, &mut store, 1, b_id); deliver_to(&mut ex, &mut store, 1, b_id);
let _ = ex.drain_events(); // clear the contact event
assert!(!store.is_released(&b_id)); assert!(!store.is_released(&b_id));
let ids = heapless::Vec::from_slice(&[b_id]).unwrap(); let ids = heapless::Vec::from_slice(&[b_id]).unwrap();
@@ -3126,6 +3172,7 @@ mod tests {
store.insert(b, 0).unwrap(); store.insert(b, 0).unwrap();
// Dispatch the broadcast to a different peer (addr 2), not addr 1. // Dispatch the broadcast to a different peer (addr 2), not addr 1.
deliver_to(&mut ex, &mut store, 2, b_id); 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. // Peer 1 (addr 1) claims to hold b_id though it never did.
let ids = heapless::Vec::from_slice(&[b_id]).unwrap(); let ids = heapless::Vec::from_slice(&[b_id]).unwrap();
@@ -3154,6 +3201,7 @@ mod tests {
store.insert(b, 0).unwrap(); store.insert(b, 0).unwrap();
deliver_to(&mut ex, &mut store, 1, a_id); deliver_to(&mut ex, &mut store, 1, a_id);
deliver_to(&mut ex, &mut store, 1, b_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 { let ack_for = |ids: heapless::Vec<ObservationId, { consts::ACK_IDS_CAP }>| Frame::Ack {
from: peer, from: peer,
@@ -3195,6 +3243,7 @@ mod tests {
let b_id = b.id(); let b_id = b.id();
store.insert(b, 0).unwrap(); store.insert(b, 0).unwrap();
deliver_to(&mut ex, &mut store, 1, b_id); 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 ids = heapless::Vec::from_slice(&[b_id]).unwrap();
let ack = Frame::Ack { from: peer, ids }; let ack = Frame::Ack { from: peer, ids };
@@ -3357,6 +3406,7 @@ mod tests {
match ev { match ev {
NodeEvent::Delivered { .. } => delivered += 1, NodeEvent::Delivered { .. } => delivered += 1,
NodeEvent::Dropped { .. } => dropped += 1, NodeEvent::Dropped { .. } => dropped += 1,
NodeEvent::Contact { .. } | NodeEvent::Lost { .. } => {}
} }
} }
assert_eq!( assert_eq!(
@@ -3367,6 +3417,105 @@ mod tests {
assert_eq!(dropped, 1, "the counted drop is reported alongside them"); 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 /// 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 /// overflow counter reported with `addr: None`; the table's 32 entries are
/// all still reported with a real address. /// all still reported with a real address.
@@ -3392,7 +3541,9 @@ mod tests {
match ev { match ev {
NodeEvent::Dropped { addr: Some(..), .. } => reported += 1, NodeEvent::Dropped { addr: Some(..), .. } => reported += 1,
NodeEvent::Dropped { addr: None, .. } => overflow += 1, NodeEvent::Dropped { addr: None, .. } => overflow += 1,
NodeEvent::Delivered { .. } => {} NodeEvent::Delivered { .. }
| NodeEvent::Contact { .. }
| NodeEvent::Lost { .. } => {}
} }
} }
assert_eq!( assert_eq!(
@@ -3466,6 +3617,7 @@ mod tests {
let mut ex = Exchange::new(ME); let mut ex = Exchange::new(ME);
let mut store = MemStore::new(); let mut store = MemStore::new();
ex.on_event(Event::Contact(1), &mut store, 0); 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); ex.on_event(Event::Frame(1, hello(a, 1)), &mut store, 0);
assert_eq!(ex.peers()[0].id, Some(a)); assert_eq!(ex.peers()[0].id, Some(a));
@@ -3552,6 +3704,7 @@ mod tests {
let b_id = b.id(); let b_id = b.id();
store.insert(b, 0).unwrap(); store.insert(b, 0).unwrap();
deliver_to(&mut ex, &mut store, 1, b_id); deliver_to(&mut ex, &mut store, 1, b_id);
let _ = ex.drain_events(); // clear the contact event
let ack = Frame::Ack { let ack = Frame::Ack {
from: peer, 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 repository.workspace = true
readme.workspace = true readme.workspace = true
documentation = "https://docs.rs/tines-ros2" 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"] categories = ["science::robotics", "network-programming"]
[features] [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 authors.workspace = true
repository.workspace = true repository.workspace = true
readme.workspace = true readme.workspace = true
keywords = ["robotics", "delay-tolerant", "science::robotics", "ipc", "ble"] keywords = ["robotics", "delay-tolerant", "ipc", "ble"]
categories = ["science::robotics", "network-programming"] categories = ["science::robotics", "network-programming"]
[dependencies] [dependencies]
tines-core = { path = "../tines-core", version = "1" } tines-core = { path = "../tines-core", version = "1" }
tines-store = { path = "../tines-store", version = "0.4.0" } tines-store = { path = "../tines-store", version = "0.4.0" }
tines-bt = { path = "../tines-bt", 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 = { version = "1", features = ["derive"] }
serde_json = "1" 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 //! The [`serve`] fn hosts a tines [`Node`] and speaks a tiny
//! request/response protocol to each connecting peer. A single instance is the //! request/response protocol to each connecting peer. A single instance is the
//! control plane for its device; external tools (or another tines Node, in //! control plane for its device; external tools (or another tines Node, in
//! later tasks) drive the node through four operations: `observe`, `belief`, //! later tasks) drive the node through six operations: `observe`, `belief`,
//! `events`, and `status`. //! `events`, `status`, and `list` (the fleet console adds addressed `observe`
//! and contact/drop reporting to the `events` response).
//! //!
//! # Wire format //! # Wire format
//! //!
@@ -39,7 +40,7 @@ use std::os::unix::net::{UnixListener, UnixStream};
use std::path::Path; use std::path::Path;
use std::time::Duration; 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 tines_store::LogStore;
use crate::trust::{adopt_wall_clock, PeerTimeWindow}; 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. /// of its accept loop.
const POLL_CAP: u64 = 64; 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. /// A confirmed delivery handed out by the IPC `events` request.
#[derive(serde::Serialize, serde::Deserialize, Debug)] #[derive(serde::Serialize, serde::Deserialize, Debug)]
pub struct EventOut { pub struct EventOut {
@@ -61,6 +65,50 @@ pub struct EventOut {
pub peer: String, 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. /// A belief returned by the IPC `belief` request.
#[derive(serde::Serialize, serde::Deserialize, Debug)] #[derive(serde::Serialize, serde::Deserialize, Debug)]
pub struct BeliefOut { pub struct BeliefOut {
@@ -82,16 +130,26 @@ pub enum Request {
topic: String, topic: String,
/// Hex of the observation payload. /// Hex of the observation payload.
payload: String, 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`. /// Read the latest belief for `topic`.
Belief { Belief {
/// Hex of the topic bytes to query. /// Hex of the topic bytes to query.
topic: String, topic: String,
}, },
/// Drain outstanding confirmed deliveries and drop counters. /// Drain outstanding confirmed deliveries, contact changes, and counted
/// drops.
Events, Events,
/// Report the node's identity and log depth. /// Report the node's identity and log depth.
Status, Status,
/// List every stored observation (capped at [`LIST_CAP`]).
List,
} }
/// An IPC response. See the module docs for the wire format. /// 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. /// The stored belief, or `None` if the topic is unknown.
obs: Option<BeliefOut>, obs: Option<BeliefOut>,
}, },
/// Confirmed deliveries drained from the exchange. /// Confirmed deliveries, contact changes, and counted drops drained from
/// the exchange.
Events { Events {
/// The drained confirmed deliveries. /// The drained confirmed deliveries.
events: Vec<EventOut>, 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`. /// The node's `id`, next tines `next_seq`, and observations `stored`.
Status { Status {
@@ -122,6 +187,14 @@ pub enum Response {
/// Number of observations in the store. /// Number of observations in the store.
stored: u32, 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. /// The request was malformed or rejected.
Error { Error {
/// A human-readable explanation. /// A human-readable explanation.
@@ -147,10 +220,15 @@ pub fn handle<S, T>(
) -> Response ) -> Response
where where
S: Store + 'static, S: Store + 'static,
T: Transport, T: Transport<Addr = NodeId>,
{ {
match req { match req {
Request::Observe { topic, payload } => { Request::Observe {
topic,
payload,
to,
lifetime_ms,
} => {
let topic = match decode_hex(&topic) { let topic = match decode_hex(&topic) {
Ok(t) => t, Ok(t) => t,
Err(e) => return error(e), Err(e) => return error(e),
@@ -159,7 +237,26 @@ where
Ok(t) => t, Ok(t) => t,
Err(e) => return error(e), 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 { Ok(id) => Response::Observe {
id: encode_hex(&id), id: encode_hex(&id),
}, },
@@ -183,30 +280,76 @@ where
} }
} }
Request::Events => { Request::Events => {
let events = node let mut events = Vec::new();
.drain_events() let mut contacts = Vec::new();
.into_iter() let mut drops = Vec::new();
.filter_map(|ev| match ev { for ev in node.drain_events() {
NodeEvent::Delivered { peer, id } => Some(EventOut { match ev {
NodeEvent::Delivered { peer, id } => events.push(EventOut {
id: encode_hex(&id), id: encode_hex(&id),
peer: encode_hex(&peer), peer: encode_hex(&peer),
}), }),
// Dropped frames are surfaced to the operator via stderr NodeEvent::Contact { addr } => contacts.push(ContactOut {
// (they are the whole point of D3), not the `events` peer: encode_hex(&addr),
// response, so they filter out here. 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 { NodeEvent::Dropped {
addr, addr,
reason, reason,
count, count,
} => { } => {
eprintln!("tines: drop: {reason:?} (count={count}, addr={addr:?})"); 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. // `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 { Request::Status => Response::Status {
id: encode_hex(&node.id()), id: encode_hex(&node.id()),
@@ -232,7 +375,7 @@ struct HostState {
// [`serve`], fed by the socket read timeouts. // [`serve`], fed by the socket read timeouts.
fn poll_exchange<T>(node: &mut Node<LogStore, T>, host: &mut HostState) fn poll_exchange<T>(node: &mut Node<LogStore, T>, host: &mut HostState)
where where
T: Transport + 'static, T: Transport<Addr = NodeId> + 'static,
{ {
while node.poll(host.clock.now()) > 0 && host.rounds < POLL_CAP { while node.poll(host.clock.now()) > 0 && host.rounds < POLL_CAP {
host.rounds += 1; host.rounds += 1;
@@ -308,7 +451,7 @@ pub fn serve<T>(
peer_time_window_ms: u64, peer_time_window_ms: u64,
) -> std::io::Result<()> ) -> std::io::Result<()>
where where
T: Transport + 'static, T: Transport<Addr = NodeId> + 'static,
{ {
let socket_path = socket_path.as_ref().to_path_buf(); 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. // 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, host: &mut HostState,
) -> io::Result<()> ) -> io::Result<()>
where where
T: Transport + 'static, T: Transport<Addr = NodeId> + 'static,
{ {
loop { loop {
match read_request(stream) { match read_request(stream) {
+13 -5
View File
@@ -3,16 +3,24 @@
//! Hosts a `Node` over a Unix-domain socket and serves a //! Hosts a `Node` over a Unix-domain socket and serves a
//! small request/response IPC for `observe` / `belief` / `events` / `status`. //! small request/response IPC for `observe` / `belief` / `events` / `status`.
//! One instance runs per device — the binary (`src/main.rs`) wires a //! One instance runs per device — the binary (`src/main.rs`) wires a
//! `Node<LogStore, BtTransport>` for real BLE, while tests drive the same //! `Node<LogStore, HostTransport>` for real BLE or the host bus, while tests
//! [`serve`] / [`handle`] path with a mock transport so no radio //! drive the same [`serve`] / [`handle`] path with a mock transport so no
//! is required. //! 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)] #![deny(missing_docs)]
pub mod clock; pub mod clock;
pub mod config;
pub mod ipc; pub mod ipc;
pub mod transport;
pub mod trust; pub mod trust;
pub use clock::HostClock; 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. //! Standalone `tines` service binary.
//! //!
//! Loads a JSON config, builds a `Node<LogStore, BtTransport>` (BLE over //! Loads a JSON config, builds a `Node<LogStore, HostTransport>` (BLE over
//! `bluer`), and serves the IPC ([`tines::serve`]) over a Unix-domain socket. //! `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. //! 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`). //! The node and peer ids are 32 lowercase hex chars (the 16-byte `NodeId`).
@@ -11,14 +12,16 @@
//! # Config //! # Config
//! //!
//! A JSON object; an unknown key is an error, so a typo can't silently fall //! 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 | //! | key | required | meaning |
//! |---|---|---| //! |---|---|---|
//! | `node_id` | yes | this device's `NodeId`, 32 hex chars | //! | `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 | //! | `log_path` | yes | the LogStore log file |
//! | `socket_path` | yes | the IPC Unix socket | //! | `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 | //! | `poll_interval_ms` | no (20) | exchange poll cadence |
//! | `trusted_clock` | no (unset) | `"system"` to trust the system clock; the only accepted value | //! | `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 | //! | `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::error::Error;
use std::path::Path; 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::{serve, HostClock};
use tines_bt::{BluerRadio, BtTransport}; use tines_bt::{BluerRadio, BtTransport};
use tines_bus::BusTransport;
use tines_core::{Node, NodeId}; use tines_core::{Node, NodeId};
use tines_store::LogStore; 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>> { fn main() -> Result<(), Box<dyn Error + Send + Sync>> {
let cfg_path = std::env::args() let cfg_path = std::env::args()
.nth(1) .nth(1)
.ok_or("usage: tines <config.json>")?; .ok_or("usage: tines <config.json>")?;
let config: Config = serde_json::from_str(&std::fs::read_to_string(cfg_path)?) let config: Config = serde_json::from_str(&std::fs::read_to_string(cfg_path)?)
.map_err(|e| format!("invalid config: {e}"))?; .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 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). // LogStore is the Store (S); HostTransport wraps BLE or the bus as the
// unknown_downtime_age_ms (the broadcast floor while the wall clock is // Transport (T). unknown_downtime_age_ms (the broadcast floor while the
// unknown) is optional; leave the LogStore default when unset. // wall clock is unknown) is optional; leave the LogStore default when
// Peer estimates outside the plausible range don't count (decision J). // unset. Peer estimates outside the plausible range don't count
// (decision J).
let builder = LogStore::builder(Path::new(&config.log_path)) let builder = LogStore::builder(Path::new(&config.log_path))
.plausible_wall_ms(tines::trust::PLAUSIBLE_WALL_MS); .plausible_wall_ms(tines::trust::PLAUSIBLE_WALL_MS);
let builder = match config.unknown_downtime_age_ms { let builder = match config.unknown_downtime_age_ms {
@@ -136,8 +90,26 @@ fn main() -> Result<(), Box<dyn Error + Send + Sync>> {
.open(clock.now()) .open(clock.now())
.map_err(|e| format!("failed to open log store: {e:?}"))?; .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 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 // `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 // 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 // transport, not just over IPC. The node isn't `Send` (the BLE transport
@@ -212,50 +184,3 @@ fn node_hex(id: NodeId) -> String {
} }
s 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 { &Request::Observe {
topic: hex_encode(&topic()), topic: hex_encode(&topic()),
payload: hex_encode(&payload()), payload: hex_encode(&payload()),
to: None,
lifetime_ms: None,
}, },
); );
let id = match read_response(&mut stream) { let id = match read_response(&mut stream) {
@@ -311,7 +313,7 @@ fn events_over_socket() {
rpc(&mut stream, &Request::Events); rpc(&mut stream, &Request::Events);
match read_response(&mut stream) { match read_response(&mut stream) {
Response::Events { events } => { Response::Events { events, .. } => {
assert_eq!(events.len(), 1, "expected one confirmed delivery"); 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].id, hex_encode(&x), "event id matches observed id");
assert_eq!(events[0].peer, hex_encode(&peer), "event came from peer"); assert_eq!(events[0].peer, hex_encode(&peer), "event came from peer");
@@ -329,6 +331,8 @@ fn handle_bad_hex_is_error() {
Request::Observe { Request::Observe {
topic: "z".into(), topic: "z".into(),
payload: "ff".into(), payload: "ff".into(),
to: None,
lifetime_ms: None,
}, },
None, None,
0, 0,
@@ -348,8 +352,14 @@ fn confirmed_delivery_direct_probe() {
node.poll(0); node.poll(0);
node.poll(0); node.poll(0);
let evs = node.drain_events(); 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] { 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 } => { NodeEvent::Delivered { peer, id } => {
assert_eq!(*id, x); assert_eq!(*id, x);
assert_eq!(*peer, [7; 16]); assert_eq!(*peer, [7; 16]);
@@ -589,6 +599,8 @@ fn f2_propagation_between_instances() {
&Request::Observe { &Request::Observe {
topic: hex_encode(&topic()), topic: hex_encode(&topic()),
payload: hex_encode(&payload()), payload: hex_encode(&payload()),
to: None,
lifetime_ms: None,
}, },
); );
let obs = match read_response(&mut stream_a) { let obs = match read_response(&mut stream_a) {
@@ -694,6 +706,8 @@ fn b_stamp(
&Request::Observe { &Request::Observe {
topic: hex_encode(&topic()), topic: hex_encode(&topic()),
payload: hex_encode(format!("probe-{n}").as_bytes()), payload: hex_encode(format!("probe-{n}").as_bytes()),
to: None,
lifetime_ms: None,
}, },
); );
let id = match read_response(&mut stream) { 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() { fn below_floor_observed_at_is_not_adopted() {
assert_not_adopted("floor", 1_000_000_000_000); 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()
}