RFA-334 · Case file with fixtures · Case 306 of 694 · Runtime evidence
Narrowing Duration::as_nanos Loses Large Durations
Duration spans can exceed what u64 nanoseconds represent. Preserve the seconds and subsecond components, keep total nanoseconds as u128, or reject values before narrowing into a protocol field.
- Reviewed
- Rust
- Rust 1.98.1, edition 2024
- Targets
- all Rust targets
- Profiles
- dev, release, test
Direct answer
What this Rust failure means
- Why it happens
- The complete Duration range needs u128 total nanoseconds, while from_nanos accepts u64 and an as cast silently discards high bits.
- First discriminating check
- Compare as_nanos with u64::MAX and preserve seconds plus subsecond nanoseconds or use checked narrowing at the destination boundary.
I converted a Duration into nanoseconds for a storage field and then reconstructed it with Duration::from_nanos. The code looked like a round-trip. The hidden as u64 made it a truncation.
The failing program starts with a large valid duration, calls as_nanos() as u64, and rebuilds it. The new duration is not equal to the original.
The two APIs use different integer widths
Duration::as_nanos returns u128. It needs that width because Duration stores up to u64 whole seconds plus a nanosecond fraction. Expressing all those seconds in nanoseconds can exceed u64.
Duration::from_nanos accepts u64. It can construct every duration whose total nanoseconds fit in that narrower input, not every possible Duration.
The signatures warn that these functions are not mathematical inverses over the complete domain.
as performs narrowing rather than validation
Casting u128 to u64 with as retains the low bits and discards the high bits. It does not return an error. The resulting value can look reasonable while representing a completely different duration.
This is more dangerous than a panic because the program continues with corrupted time data. A timeout, retention period, certificate field, or scheduled delay can shrink silently.
I use u64::try_from(total_nanos) when a destination really is limited to u64. Failure then becomes a schema or validation error.
Preserve Duration in its natural components
The repaired fixture reconstructs the value from as_secs() and subsec_nanos. Those components match the representation accepted by Duration::new without narrowing the total.
If I control a schema, I can store seconds as u64 and nanos as a bounded u32, or store total nanoseconds in a true 128-bit representation. The schema must define normalization and maximum values.
Copying the Duration directly is even simpler inside one Rust process because it is a Copy value.
The practical u64-nanosecond range is smaller than many expect
u64::MAX nanoseconds covers roughly 584 years. That is enormous for a request timeout but not for every timestamp difference, archival field, astronomical interval, or sentinel value.
More importantly, correctness should not depend on “nobody will use such a large value” when the type explicitly permits it. I place a checked product limit at ingestion and return a clear message if the field is meant to be much smaller.
Then later conversions can rely on a validated newtype rather than scattered assumptions.
Units and widths belong together
A raw integer called timeout is incomplete. It needs a unit, width, range, and rounding rule. Milliseconds in u64 cover a much larger duration than nanoseconds in u64, but lose sub-millisecond precision.
When crossing FFI or a database, I document all four facts. I avoid casts hidden inside serialization helpers because reviewers cannot see whether they validate.
Protocol evolution is easier when the unit appears in the field name, such as timeout_ns, and a versioned limit is written down.
Floating-point conversion is another contract
Converting duration to floating-point seconds can represent a wide range but loses integer precision at large magnitudes. It does not repair the integer-width mismatch; it chooses a different one.
I select representation from required maximum range and resolution. For accounting or exact scheduling fields, integer components are often clearer. For approximate display, floating-point seconds may be fine.
The conversion boundary owns rounding, overflow, and non-finite handling.
Tests need values around the destination limit
I test zero, one nanosecond, exactly u64::MAX nanoseconds, one nanosecond beyond that when constructible, a large seconds-only duration, and a value with both components.
Round-trip properties are constrained to the representation's supported domain. Outside it, the expected result is a validation error, never truncation.
I also test serialized values in other languages because a receiver may parse a 128-bit decimal into a 64-bit number or a JavaScript floating-point number.
What the evidence proves
RFA-334 uses Duration::from_secs(u64::MAX) to make the width mismatch undeniable on Rust 1.98.1. It proves that the total exceeds u64 and that component reconstruction remains exact.
The core principle is that a unit conversion can also be a range conversion. as_nanos honestly returns the width its domain needs. If my next system accepts less, I validate that narrower contract instead of using as to remove the evidence.