Vahag Byurat  ·  All Projects
Python · systemd · chrony / linuxptp · Docker · Ansible

MESHWATCH Lab

A distributed radar-mesh simulator written in pure standard-library Python, then deployed four increasingly real ways, until the deployment environment, not the application, is the thing being tested.

Pure stdlib · 86 tests real chrony · real ptp4l Fence on uncertainty Educational use only
The Guided Demo
Nine minutes, end to end
Your browser cannot play this video: a narrated walkthrough of the full observed pipeline, from rack bring-up to fault injection and post-deploy validation.
A narrated walkthrough of the whole system running live: the Compose rack coming up through its bring-up gates, the console fusing tracks from real UDP sockets, chaos faults injected from the panel, and the post-deploy validation pass, recorded from the running rack, and narrated by an AI clone of my own voice.
The Best Idea In It
A clock in holdover cannot measure its own error

Every node in the mesh timestamps its detections from its own disciplined clock, and fusion has to decide which of those timestamps it still believes. The obvious gate is to check each node's clock offset and reject the ones that are out. That gate is unbuildable, and the reason is the whole point: a node that has lost its time reference is in holdover, and holdover is precisely the state of not knowing your own offset. Ask the box and it will answer honestly and wrongly.

So the gating quantity is not the offset. It is the bound: the error the node can prove it has not yet exceeded, which is exactly what chrony's Skew and Root dispersion and PTP's clockClass ladder already encode:

# the fence quantity: a bound, not a measurement U(t) = U_lock + skew·t + (D/2)·t² U > 100 ns → 30 m of range error → WARN U > 1 µs → 300 m → FENCE reference back and servo held s2 ≥ 30 s → unfence
The thresholds are derived from the sensor model rather than picked round: light travels 300 m in a microsecond, and measurement sigma in this sim runs 120 m to 350 m depending on the node class. One microsecond of clock error is the point where timing error becomes indistinguishable from measurement noise.
Fence on the bound, not on the offset. The offset is the one number a box in holdover does not have.
Your browser cannot play this video. It shows a node's uncertainty crossing the fence threshold after its time reference is denied.
The gate firing, recorded from the running console and time-compressed. RL-03 starts LOCKED at 147 ns of uncertainty; its reference is denied a second or so in, the servo falls out of s2, and the node enters HOLDOVER with its TCXO drifting unsupervised. The bound crosses the 1,000 ns fence line eighteen seconds of holdover later (the first FENCED frame reads 1,045 ns) and keeps climbing to 11,145 ns at 220 s while the node goes on scanning and reporting. The strip along the bottom is the simulator's own timing state, burned into each frame.

The consequence is enforced structurally. The simulator carries a god-view field for a node's true error because the physics needs one, and no code path may render it into a tool-shaped line. A leak test pins that. What a holdover box may print is what it can still measure locally, plus the bound it can compute. Everything else would be a lie the model was telling on the hardware's behalf.

Because the bound grows at the oscillator's rate, the same event has three visibly different outcomes. The lightweight node class carries a TCXO and spends its budget in about twenty seconds; the large fixed class carries an OCXO and lasts roughly an hour; the grandmasters carry rubidium and last about sixteen hours. A fenced node keeps scanning and keeps painting its own scope: nothing is wrong with the radar. Only fusion stops admitting its plots.

The failure mode most models get backwards

Deny GNSS across the whole domain and the naive expectation is a cascade of fences. What actually happens is quieter: a PTP client disciplines itself from the grandmaster, not from the sky, and that Announce stream never stopped. The grandmasters drop into holdover, every leaf stays SLAVE/s2 and simply inherits a growing uncertainty. Absolute time degrades for the mesh together; inter-node relative sync (the thing fusion actually consumes) holds throughout. Nothing free-runs, nothing fences. Modelling it as "every leaf free-runs" inverts the rule the model exists to respect.

That idea survives the trip out of the simulator. On the real Linux deployment it becomes a systemd unit: meshwatch-timesync-gate is a Type=oneshot gate that the fusion service and every templated meshwatch-node@ instance carry a Requires= on. It asserts that chronyd answers on its command socket (not that the unit is active), which source was selected (reference ID 7F7F0101 means "syncing to itself" and fails unless this host is the designated rack master), leap status, offset inside budget, and then a role-aware PTP check. Mask chrony on a node and the radar does not degrade. It refuses to start.

$ systemctl mask --now chrony && systemctl restart meshwatch-timesync-gate GATE FAIL: the host clock is not fit to timestamp radar data. meshwatch-node@*.service will NOT start (they carry Requires=meshwatch-timesync-gate.service). $ systemctl start meshwatch-node@RL-22 A dependency job for meshwatch-node@RL-22.service failed. … and on the other host, four seconds later: LINK LOST RL-22: no heartbeat for 4 s; mesh continues on remaining sensors

The Ladder
One application, four deployment realities

The simulator is the easy half. The interesting half is that the same artifact gets deployed four times into environments that are progressively harder to lie to. Each rung exists because the one above it hit a wall the substrate could not move, and naming that wall out loud is most of the lesson.

THE FOUR ENVIRONMENTS each rung adds what the one above could not 1 systemd, hand-built, in a container Ubuntu 24.04, real PID 1 systemd, the source mounted read-only + units, ordering, journald, and a service that refuses to start − one host: nothing the radar emits crosses a network 2 the docker-compose rack four hosts on their own bridge: one fusion host, three radar nodes + real chrony NTP and a real ptp4l BMCA electing a GM across hosts − no container may steer the shared CLOCK_REALTIME (chronyd -x) 3 a Multipass VM, captured as cloud-init an Ubuntu guest that owns its own kernel and its own clock + real clock discipline, md RAID, kernel modules, DMI, a real reboot − still no PHC: hardware timestamping wants a real NIC 4 the Ansible push the workstation is the control node; the VM is the rack + crosses the machine boundary: rsync, TLS front door, hot-add a site − one VM: not yet a rack of real machines on real switches
The application is unchanged at every rung: the deployment entry points subclass the simulator's own classes to bind rack addresses, and no file under sim/ is modified.

Rung two is where the timing story stops being a model. Three node hosts and an aggregation host sit on their own bridge; plots are real UDP datagrams crossing it, registration is a real TCP handshake fusion can really reject, and the nodes' chrony really measures the rack master across the network: 2.85 µs, an actual NTP measurement, not a rendered one. Real ptp4l runs a real BMCA across hosts and elects a grandmaster. What the containers cannot do is steer anything: there is one CLOCK_REALTIME on the machine, shared by every container, so chronyd runs with -x and ptp4l with free_running 1. Servo lock, makestep, and recovery from an injected offset are untestable there, and the README says so rather than faking them. That is the entire justification for rung three.

The lesson only a real host could teach

Moving the same compose file onto an actual Ubuntu VM broke all four hosts at once: exit 255, crash-looping, zero log lines. Real Ubuntu wraps every container in the docker-default AppArmor profile, which denies the mounts systemd performs at boot. The Mac's LinuxKit VM has no AppArmor, so nothing about rungs one and two could ever have surfaced it. apparmor=unconfined joined the flag list with the story attached, which is the only reason it will still make sense in a year.

Rung four is about the boundary rather than the box. The workstation becomes the control node and the source of truth for the code; the VM becomes the rack. nginx terminates TLS so exactly one socket is reachable from outside, and the playbook proves the exposure model on every deploy by asserting from the workstation that 443 answers and that the console, control and telemetry ports do not. Adding a radar is one inventory line plus systemctl enable --now meshwatch-node@RL-23; the new site walks the same join pipeline as everything else, and removal deliberately does not notify fusion: at the fusion layer a decommissioned radar and a destroyed one are the same event.


Validation
A rack-check suite that is itself under test

rack_check is a pytest suite split along one seam: collectors shell out to systemctl is-active, ss -tuln, df -P, chronyc -c tracking, docker ps and ip -j addr and return plain data; the assertions are parametrized directly over a declarative config.yaml. Adding a required service or port to the config adds a test case with no code change, so a per-site variant is a config file rather than a fork.

The seam also buys the thing most validation tooling never has: a test story of its own. Set RACK_CHECK_SNAPSHOT and the collectors read captured JSON instead of a live machine. The healthy snapshot passes all eleven checks. The degraded snapshot fails exactly three, and the three are the deliverable:

FAIL service radar-agg is inactive (expected active) FAIL chrony offset 347.810 ms exceeds limit 10.0 ms (stratum 3, leap status Normal) FAIL mount / has 4% free (minimum 10% required) 8 PASS everything the seeded faults did not touch Three seeded faults, three findings, no cascade: the suite localizes rather than collapsing. Every message is written for someone standing at the rack, so a red run is a work order and not a stack trace.

Run live on the deployed rack it comes back ten green and one red, and the red is deliberate. systemctl is-active docker asks whether the container runtime is healthy. But the runtime is the layer below this host; it is what is running it. Putting a dockerd inside a workload container to make the check pass would be a worse lie than the failure, so the row stays red and is documented as a bare-metal item.

The chrony assertion carries a subtler trap. On the aggregation host it passes with an offset of exactly zero, because that host is the rack's own reference. A perfect zero there is not health, it is a node measuring itself, and the shared timing validator encodes the rule directly: read the reference ID before you read the offset.

The gate that caught something real

The image writes a release manifest (a sha256 of every shipped file), and bring-up gate G8 verifies the deployed tree against it. During development it caught a docker cp hot-patch of two scripts onto a running host, which is exactly the failure that makes a rack stop matching its own build and nobody notices for a week:

$ sha256sum -c /etc/meshwatch/release.manifest /opt/meshwatch/deploy/bringup.sh: FAILED [ FAIL ] the deployed source does not match the release manifest [ FIX ] someone edited code on the box, or a bind-mount is shadowing the image.
86Simulator tests
pure stdlib
53Timing-lab tests
12Ordered bring-up
gates, G0–G11
6Hands-on labs
with worked answers

The bring-up sequence exists twice on purpose: once inside the simulator as a model with shaped evidence, and once as a shell script running the actual commands on the deployed host. Both stop at the first failure and mark everything after it BLOCKED rather than failed, because only the first failure is actionable, and a checkout runner that turns eleven gates red for one dead unit has told you nothing you can act on.


Fidelity
The document that argues against the demo

A simulator that flatters itself is worse than no simulator, because someone will eventually cite it. So the repository carries an adversarial self-audit of the model against real behaviour, produced by a line-by-line survey, in which every finding lands in one of four buckets: FAITHFUL (matches reality, with evidence), ACKNOWLEDGED (simplified, and the code already says so), SILENT (simplified or wrong, and nothing on screen flags it), and ABSENT (no analog here at all).

A simulation is only as useful as its author's willingness to publish its SILENT column.

SILENTThe mesh is a star

Every node is constructed with the fusion center's literal address. There is no discovery, no node-to-node byte, no relay, no partition tolerance. The boot log says "mesh bootstrap"; the topology is hub-and-spoke by construction. "Self-forming" is a word this simulation does not earn, and no other document had said so.

SILENTMulti-monostatic, not multistatic

Each radar hears only its own echoes; what gets fused are finished plots. The irony is load-bearing: true bistatic measurement math is exactly what would make the microsecond fence budget matter, and plot-level fusion only ever needed the 400 ms plausibility gate.

SILENT"TCP unaffected" is an artifact

The chaos shim impairs only UDP egress, so the demo's tidy "data plane lossy, control plane consistent" is partly protocol-selective chaos rather than netem truth. Real netem at 60% loss craters TCP. The honest lesson is that TCP survives loss by paying latency.

FIXEDErrors the survey actually found

Wind advecting 180° backwards. A clock identity using the wrong middle octets for linuxptp's EUI-64 form. A 16-hex image digest where real repo digests are 64. Longitude smoothing that broke at the antimeridian, in the sim's own Aleutian geography. All fixed; the table stays, because knowing what was wrong is part of the record.

The claim that is never negotiable

Host time synchronisation is modelled. RF phase coherence is not, and is never implied. Coherent combining across apertures needs alignment five orders of magnitude tighter than this model's fence budget, and it is solved in the RF domain with a distributed local-oscillator network, not by chrony and not by PTP. Getting chronyc tracking green does not buy a coherent array. The console greys the second meter out, and a named test pins the separation so it cannot quietly regress.


The Trainers
Learning the layer by breaking it on purpose

Six labs build the deployment layer by hand (units, sockets, containerisation, an air-gapped chrony master/client pair, a device-file reader under systemd, md RAID on a VM), and four of them close by pointing at the production artifact that does the same thing properly. That order is deliberate: running someone else's bring-up script before you can read a unit file teaches nothing.

On top of that sit two scored fault-injection trainers, and their interesting property is the verification rather than the faults. linux-dojo injects twelve real systemd failures into a rootless user unit: a stripped exec bit, a masked unit, a decoy process holding the port, a crash loop that has already hit its start limit. And check never trusts the edit: it requires the unit active, stable rather than flapping, and actually echoing a UDP probe on loopback, plus a drill-specific assertion. netlab does the same for ten Docker networking faults across a three-container topology (a DNS divorce, a one-way conntrack rule, an MTU black hole, a name thief), and its check demands the page load from the host port and an in-container fetch by DNS name to return valid telemetry repeatedly and the UDP time echo to answer. An imposter does not get lucky three times.

Why the verification is the design

Every netlab fault is mapped, in its own catalogue, to a failure class that turns up in real integration labs: a service that moved VLANs, an over-eager hardening script, a daemon bound to loopback, a stale hosts-file hack, a bad deploy stuck in a crash loop. Scoring the fix would teach recall. Scoring the end-to-end recovery teaches the thing that actually transfers: that "active" is not "working", and the only evidence that counts comes from the far end of the path.

MESHWATCH is an original simulation inspired by publicly-described radar-mesh concepts. It is not a replica of any product, and the platform names, chassis and part numbers in it are notional. The radar returns are simulated and labelled as such everywhere they appear; the deployment around them is not.

Educational use only. Not for commercial use or AI/ML training.