Skip to content

Repository files navigation

In The Wild with 2,154 Active Installs

In the wildRAG Converter uses rusty_time to put an NTP timestamp on every chunk. It makes personal and work files AI-readable without them leaving the machine: the whole conversion runs as WebAssembly in the browser tab, with nothing uploaded and nothing to install.

rusty_time

crates.io docs.rs license

chrony, remade with Rust. A pure-Rust, memory-safe NTP/NTS client + server targeting Linux, macOS, Windows, wasm — and, since 0.2.0, bare-metal microcontrollers: the NTP codec runs no_std with no heap on Cortex-M4F, RV32 and Xtensa, and has been run on an ESP32-S3.

Claims discipline: this README makes no performance claim that is not in the ledger with the run that produced it. The corpus (TIMECORP) measures rusty_time against chrony (primary baseline) and ntpd-rs (secondary); see the mission plan §7.

Status

Pre-1.0. Milestones M0–M6 complete, M7 partial (its exit test needs a GPS/PPS lab box — see corpus/LEDGER.md).

What works: NTPv4 client and server, NTS (RFC 8915) both ends with SpaceDB-backed persistence, interleaved mode (RFC 9769), per-client and global rate limiting, recvmmsg batching, an ops control plane, platform clock drivers with service integration and packaging, a wasm client for browsers and edge functions, and reference clocks over gpsd SHM, chrony SOCK and PTP hardware clocks. The protocol layer also builds as a no_std leaf with no alloc for microcontrollers — see On a microcontroller.

Interop is verified against chrony in both directions and all three modes: chronyd selects an rtimed server as its synchronisation source over plain NTP (−4.8 µs), over NTS, and in interleaved mode (−66 ns). The parsers that face untrusted bytes survive ~30 million fuzz executions with zero crashes. TLS is rustls on a pure-Rust crypto provider; nothing in the build needs a C toolchain, on any of the eight supported targets.

Measured against chrony

Under clknetsim — chrony's own simulator, so the baseline uses the tooling its authors trust. Both arms share one config per scenario and talk to the same chronyd stratum-1 server; error is read from the simulator's ground-truth offset log, not from either daemon's opinion of itself. A null arm (chrony against itself) states the rig's own resolution, and verdicts come from a paired sign test across rounds, not from comparing two medians.

gate result
G4 server throughput MET. 138,664 replies/s vs chrony's 105,668 (1.31×) at 0.72× the CPU per reply, both resolved far outside the null-arm floor.
G2 convergence Mixed. S1 and S8 reach 1 ms in 5 s against chrony's 7 s, and S4 (10% loss) reaches it at 1300 s where chrony takes 1704 s; S6 (500 ms cold start) takes 14–16 s against chrony's 12 s.
G1 accuracy Not met — level on all five. 40 seeded worlds per scenario, paired: nothing resolved either way (S1 16/40, S6 16/40, S8 19/40, S2 24/40, S4 20/40). Ahead per packet spent on S1, S2 and S8 — chrony's accuracy for fewer packets.
G3, G5, G6 not measured; nothing claimed.

Steady-state p50 (last quarter), 40 seeded worlds, chrony vs rusty_time: S1 0.6 → 0.8 µs, S6 0.7 → 0.8 µs, S8 3.3 → 4.3 µs, S2 6.642 → 6.601 ms, S4 1.402 → 1.383 ms. 0.1.9 chooses the regression window by the standard error of the predicted offset rather than of the slope, which took S8 from 9.1 to 4.3 µs and S6 from 1.0 to 0.8 µs; S6 and S8 were both resolved behind chrony before that and are now level.

Instruction cost, deterministic and internal (not a chrony comparison): 237 Ir per served request, 13,613 Ir per client discipline step — within 1 Ir of 0.1.7 despite the multi-source rework.

Three honest notes. Earlier per-scenario accuracy wins reported here were withdrawn: they came from comparing medians on a rig whose unchanged control arm moved further than the effect. G4's p99 half is not measured — at saturation the number is the load generator's queue depth, not the server's responsiveness. And there is no cross-implementation CPU figure for the client the way there is for the server, which on a mesh is the side that runs on every node.

The rig is now seeded, so two identical arms produce bit-identical output and its resolution floor is exactly zero. Any gap in the tables above is code.

Every figure above, with the run that produced it and the defects the comparison uncovered, is in corpus/LEDGER.md.

Two things to read before deploying this

Poll interval matters less than it did, but still matters. Over a simulated day on the drifty-oscillator scenario, last-quarter mean error:

maxpoll rusty_time chrony
6 (64 s) 17.9 µs 4.3 µs
10 (1024 s), the default 127 µs 10.0 µs

0.1.7 chooses the regression window from the data, which took the default-poll figure from 1452 µs to 127 µs. A gap remains at long polls; --maxpoll 6 is still the better setting if microseconds matter.

Multi-source selection matches chrony. Three servers, one of them five seconds wrong (tools/corpus/multisource.sh), final error after 600 simulated seconds:

worlds within 1 ms worst final error
0.1.7 4 / 8 2996 ms — captured by the liar
0.1.9 16 / 16 0.004 ms
chrony 16 / 16 0.005 ms

The fix was structural, not a tuning knob. Every source used to run its own copy of the discipline loop — its own frequency, drain and budget — while only the selected one was allowed to reach the clock, which left every unselected source having booked a correction that never happened. Seven different ways of cleaning up after that were measured and every one was worse than leaving the wrong books alone. There is now one loop per clock and one register per source, so an unselected source never produces a plan at all and there is nothing to clean up.

Bounding what a source may do to your clock

rtimed sync <server> --maxchange 1000 1 2

chrony's maxchange, and off by default exactly as chrony's is — the right value is a policy question about the deployment, not something a library can guess. Beyond start updates, a correction larger than offset seconds is refused and the clock is left running exactly as it was; after ignore consecutive refusals the daemon exits rather than keep reporting a synchronisation it is not performing. A negative ignore never exits.

It matters more here than on a server you own. Authentication proves who a source is, not that it is telling the truth — and on a mesh, a capability's expiry is decided by this clock, so anything that can move it can move the boundary between revoked and valid.

Install

cargo install rusty_time-daemon    # rtimed — the daemon
cargo install rusty_time-ctl       # rtimec — the control client
cargo install rusty_time-sim       # timecorp — the corpus runner

As a library:

[dependencies]
rusty_time-core = "0.2"    # protocol + discipline, no I/O, wasm-clean
rusty_time-nts  = "0.2"    # NTS (RFC 8915)
# What can this machine actually do?
rtimec doctor

# Authenticated query
rtimed query time.cloudflare.com --nts

# Run an NTS-capable server (self-signs a dev certificate if none is given)
rtimed serve --nts --state /var/lib/rusty_time/state.spacedb

# Ask it what it is doing
rtimec serverstats
rtimec clients 20

# Install as a system service (systemd / launchd / Windows SCM)
rtimed service show          # print the unit for this platform
sudo rtimed service install  # write it where the platform expects

In the browser

A page has no clock it may set, so @remade-with-rust/rusty-time estimates the offset instead and serves a corrected now() with an honest error bound. The wire format is a real NTPv4 packet, so the browser runs the same codec and filter as the daemon:

bash tools/wasm/build-npm.sh                        # 31 KB wasm module
rtimed serve --gateway 127.0.0.1:8199 \
             --gateway-assets crates/rusty_time-wasm/pkg
# then open http://127.0.0.1:8199/
const client = new TimeClient();
const rnd = new Uint32Array(2); crypto.getRandomValues(rnd);
const request = client.build_request(Date.now(), rnd[0], rnd[1]);
const reply = new Uint8Array(await (await fetch('/time',
  { method: 'POST', body: request })).arrayBuffer());
client.process_response(reply, Date.now(), performance.now());

client.now_ms(Date.now(), performance.now());   // corrected, never steps back
client.confidence_ms(performance.now());        // error bound, widens if stale

On a microcontroller

A chip has no allocator to spare, so rusty_time-core's protocol layer builds with no std and no alloc: the NTPv4 packet codec, RFC 7822 extension fields, and the RFC 5905 §8 offset/delay arithmetic. It reads no clock, opens no socket and owns no buffer, so an SNTP client needs nothing else from us.

rusty_time-core = { version = "0.2", default-features = false }
use rusty_time_core::ntp::{NtpPacket, NtpTimestamp, offset_delay};

// Build a request. The nonce SHOULD be unpredictable rather than the real
// clock: it is echoed back as origin_ts and is the only spoofing defence an
// unauthenticated client has.
let req = NtpPacket::client_request(4, NtpTimestamp(nonce));
let wire: [u8; 48] = req.to_bytes();          // no allocation

// ...send `wire`, receive 48 bytes back...
let resp = NtpPacket::parse(&reply)?;          // never panics on untrusted bytes
let (offset, delay) = offset_delay(t1, t2, t3, t4);

Everything above the wire — the sample filter, falseticker selection, the discipline loop, the server — needs Vec/String and stays behind the default-on std feature. Default builds are unchanged.

platform status
thumbv7em-none-eabihf (Cortex-M4F) compile-gated in CI, every push
riscv32imac-unknown-none-elf (RV32) compile-gated in CI, every push
xtensa-esp32s3-none-elf (ESP32-S3) run on the part — 30/30 checks, bare-metal/esp32s3/

The CI rungs prove it builds; the leaf's own test suite runs against the same no_std code path on the host (cargo test -p rusty_time-core --no-default-features) to prove it is right. The ESP32-S3 row is the third claim — that it runs on silicon — and it is hand-run, because it needs Espressif's Rust fork that no CI runner has:

=== rusty_time-core leaf on ESP32-S3 (xtensa, no_std, NO alloc) ===
[1] client request                    5 checks   ok
[2] server response (literal image)  14 checks   ok
[3] offset / delay (RFC 5905 §8)      5 checks   ok
      offset 0.09999999997671694 s   delay 0.05000000004656613 s
      2 s across the 2036 era wrap measured as 2 s
[4] extension fields and rejections    6 checks   ok

checks passed 30 / 30
RESULT: PASS -- the rusty_time-core leaf ran on the board

That firmware parses a hand-written 48-byte wire image rather than the codec's own output — parsing what we wrote would only prove the codec is self-consistent — and runs the arithmetic where f64 is soft-float, which is the half a host test cannot stand in for. It links no heap at all.

Verifying a build

tools/smoke/smoke.sh runs the built binaries end to end on the current platform without touching the system clock, and checks that the measured offset is sane rather than merely that packets were exchanged:

RTIMED=./target/release/rtimed RTIMEC=./target/release/rtimec bash tools/smoke/smoke.sh

Layout

crates/rusty_time-core/    protocol + algorithms (portable, wasm-clean)
crates/rusty_time-nts/     NTS record layer + AEAD (RFC 8915)
crates/rusty_time-clock/   platform clock seam (linux / macos / windows / virtual)
crates/rusty_time-api/     typed op/report types (JSON public wire)
crates/rusty_time-daemon/  deliverable: rtimed
crates/rusty_time-ctl/     deliverable: rtimec
crates/rusty_time-wasm/    virtual clock for wasm targets
crates/rusty_time-alloc/   the allocator seam
crates/rusty_time-sim/     deliverable: timecorp — deterministic corpus runner
bare-metal/esp32s3/        on-board proof of the no_std leaf (excluded from the workspace)
corpus/                    scenarios, results, LEDGER.md
fuzz/                      cargo-fuzz targets (packet, config, NTS records)

License

MIT OR Apache-2.0. Clean-room implementation from RFCs 5905/7822/8915 and published algorithm descriptions; no chrony (GPLv2) source is used.

About

chrony, remade with Rust: a pure-Rust NTPv4 + NTS time client and server for Linux, macOS, Windows and wasm. No C toolchain anywhere in the build.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages