ntsQuery function

Future<NtsTimeSample> ntsQuery({
  1. required NtsServerSpec spec,
  2. Duration timeout = kDefaultTimeout,
  3. @Deprecated('Use timeout instead.') int? timeoutMs,
  4. int dnsConcurrencyCap = kDefaultDnsConcurrencyCap,
  5. int bridgeConcurrencyCap = kDefaultBridgeConcurrencyCap,
  6. DateTime? verificationTime,
  7. @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,
    ),
  ),
);