ntsQuery function
- required NtsServerSpec spec,
- Duration timeout = kDefaultTimeout,
- @Deprecated('Use timeout instead.') int? timeoutMs,
- int dnsConcurrencyCap = kDefaultDnsConcurrencyCap,
- int bridgeConcurrencyCap = kDefaultBridgeConcurrencyCap,
- DateTime? verificationTime,
- @Deprecated('Use verificationTime instead.') int? verificationTimeMs,
Run a complete authenticated NTPv4 exchange against spec.
On the first call (or after the cookie pool is exhausted) this performs a full NTS-KE handshake before sending the NTPv4 request; subsequent calls reuse the cached AEAD keys and spend a stored cookie.
timeout is a single global wall-clock budget that spans DNS,
NTS-KE (TCP connect, TLS handshake, record I/O) and the AEAD-NTPv4
UDP exchange as one shrinking deadline. Defaults to
kDefaultTimeout when omitted. The deprecated timeoutMs carries
the same budget as raw milliseconds; providing it alongside an
explicit non-default timeout is rejected with
NtsError.invalidSpec.
dnsConcurrencyCap is a per-call ceiling on the process-wide bounded
DNS resolver: if the global in-flight counter has already reached
this value when the call attempts a lookup, the call short-circuits
with NtsError.timeout instead of spawning another worker thread.
Defaults to kDefaultDnsConcurrencyCap when omitted, which inherits
the package's built-in default. Because admission is gated against a
single process-wide counter, every admitted worker counts toward
every caller's threshold, and admission for a given call compares the
live pool size against that call's own cap with no awareness of which
caller's workers fill the pool. Starvation between mixed-cap callers
is therefore asymmetric. Concretely: if a dnsConcurrencyCap: 32
caller already has 4 lookups in flight, a concurrent
dnsConcurrencyCap: 4 caller is refused immediately with
NtsError.timeout (TimeoutPhase.dnsSaturation) even though it has
started no lookups of its own — its cap is already met by the other
caller's workers. The reverse cannot happen: the low-cap caller's own
workers can never push the pool past 4, so they cannot by themselves
block the high-cap caller. See ARCHITECTURE.md's "Timeout budget
and bounded DNS" section for the full mechanic.
Worker-pool occupancy and the bridge admission gate. Although
this wrapper is async, the underlying Rust call is a blocking
network exchange dispatched to the flutter_rust_bridge worker
pool: each in-flight call pins one pool thread for its full
duration — up to timeout in the worst case. The default pool
holds one thread per logical CPU, so a burst of concurrent cold
queries against many distinct hosts could otherwise occupy every
worker and stall unrelated bridge calls behind them until a thread
frees. To bound that burst, dispatch runs behind an isolate-wide
FIFO admission gate: bridgeConcurrencyCap (default
kDefaultBridgeConcurrencyCap) caps how many of this package's
calls occupy bridge workers at once, and calls beyond the cap
queue on the Dart side — holding no worker thread — until a slot
frees. The gate's state is isolate-local: each isolate gates its
own calls independently, while the FRB worker pool they land on
is shared process-wide, so a multi-isolate app's combined
occupancy is bounded by the sum of each isolate's cap, not by one
cap. Queue wait is charged against timeout: the budget
forwarded to the Rust pipeline shrinks by the time spent queued,
so the caller's total wall-clock budget stays honest, and a call
whose whole budget elapses while queued fails with
NtsError.timeout (TimeoutPhase.bridgeSaturation) without ever
dispatching. Admission compares the live in-flight count against
each call's own cap — the same asymmetric mixed-cap semantics
documented for dnsConcurrencyCap above — with one FIFO
refinement: a queued call is only overtaken by a later call whose
larger cap admits it while the queued call's own cap does not.
Concurrent calls against the same host:port are less of a
concern even at the cap: a per-key singleflight on the Rust side
collapses them onto one NTS-KE handshake, so their combined
wall-clock is bounded by a single exchange (each admitted call
still holds its own worker thread while parked, but only until the
shared handshake resolves). The gate is independent of
dnsConcurrencyCap, which bounds DNS resolver threads, not bridge
workers — and the two caps compose rather than conflict. With the
bridge cap at or below the DNS cap (the defaults are both 4), the
package's live calls alone can never saturate the DNS pool; only
detached lookups leaked by earlier timed-out calls still consume
DNS slots, which is exactly the accumulation the DNS cap exists
to bound. Raising bridgeConcurrencyCap above
dnsConcurrencyCap re-exposes the DNS gate's fail-fast: admitted
distinct-host calls that overlap in their DNS phase beyond the
DNS cap are refused immediately with
TimeoutPhase.dnsSaturation rather than queueing. That skew
suits same-host-heavy workloads (singleflight collapses their
lookups); for high distinct-host fan-out, raise both caps
together. The inverse skew — bridge cap below DNS cap — is always
safe: the extra DNS headroom simply goes unused.
The returned NtsTimeSample exposes the raw protocol primitives,
not a finished synchronized clock. utcUnixMicros is the server
transmit timestamp exactly as it appeared on the wire; it does not
include any compensation for the one-way network delay between the
server and this caller. To approximate the server's clock at the
moment the reply arrived, callers should add half the network
delay to utcUnixMicros (the standard NTP assumption of a
symmetric path). The best delay estimate is peerDelayMicros (the
RFC 5905 peer delay δ, which excludes server processing time) when
it is plausible — inside (0, roundTripMicros] — falling back to
roundTripMicros otherwise. For high-precision synchronization,
take a burst of samples and pick the one with the smallest such
delay before applying that adjustment; this is exactly what the
one-call ntsGetTime convenience does.
All arguments (spec.port, timeout, dnsConcurrencyCap,
bridgeConcurrencyCap) are validated against the FFI encoding range
(1..65535 for the port, 1 ms..4294967295 ms for the timeout,
1..4294967295 for the u32-shaped caps; bridgeConcurrencyCap
never crosses the FFI boundary but is held to the same range for
symmetry) before any FFI dispatch; out-of-range values cause the
returned Future to complete with NtsError.invalidSpec without
reaching the Rust boundary, on the same await/catch shape as
every other failure mode this wrapper surfaces.
The FFI boundary carries time at millisecond resolution, so the
microsecond precision of the typed parameters does not survive
dispatch: a timeout with a sub-millisecond component is rounded
up to the next whole millisecond (the budget is never shortened
by conversion), and a verificationTime with sub-millisecond
precision is truncated to whole milliseconds since the Unix
epoch. Neither loss is observable in practice — the wire protocol
and certificate validity windows operate at far coarser
granularity — but callers deriving these values arithmetically
should not expect microseconds to round-trip.
verificationTime, when non-null, overrides the timestamp used to
check the NTS-KE server certificate's validity window
(notBefore/notAfter) — interpreted in UTC (a non-UTC DateTime
is converted). It exists to break the cold-start clock-skew
deadlock: a
device whose real-time clock is badly wrong (factory reset, dead RTC
battery, never-set clock) cannot complete the NTS-KE TLS handshake
because the certificate is judged expired or not-yet-valid against
the skewed clock — yet NTS-KE is the very mechanism that would fix
the clock. Supplying a trusted timestamp here (for example a
build-baked "this binary cannot predate X" floor) pins the temporal
check to that instant while leaving chain-of-trust, hostname, and
signature validation fully intact: an untrusted issuer, a hostname
mismatch, or a bad signature still fails. When omitted (the default)
the system clock is used, exactly as in every prior release.
Pre-epoch instants are rejected with NtsError.invalidSpec before
dispatch. The deprecated verificationTimeMs carries the same
instant as milliseconds since the Unix epoch; providing both
parameters is rejected with NtsError.invalidSpec.
Throws an NtsError on every failure path.
Implementation
Future<NtsTimeSample> ntsQuery({
required NtsServerSpec spec,
Duration timeout = kDefaultTimeout,
@Deprecated('Use timeout instead.') int? timeoutMs,
int dnsConcurrencyCap = kDefaultDnsConcurrencyCap,
int bridgeConcurrencyCap = kDefaultBridgeConcurrencyCap,
DateTime? verificationTime,
@Deprecated('Use verificationTime instead.') int? verificationTimeMs,
}) => _dispatch(
spec: spec,
timeout: timeout,
timeoutMs: timeoutMs,
dnsConcurrencyCap: dnsConcurrencyCap,
bridgeConcurrencyCap: bridgeConcurrencyCap,
verificationTime: verificationTime,
verificationTimeMs: verificationTimeMs,
call: (ffiSpec, ffiTimeoutMs, ffiVerificationMs) async => _publicSample(
await ffi.ntsQuery(
spec: ffiSpec,
timeoutMs: ffiTimeoutMs,
dnsConcurrencyCap: dnsConcurrencyCap,
verificationTimeMs: ffiVerificationMs,
),
),
);