Simplify pre-epoch local_time formatting

Floor the seconds before calling gmtime instead of adjusting tm_sec, and
keep the subsecond remainder in the common duration type, which fixes
overflow for narrow reps such as duration<int16_t, std::micro>.
This commit is contained in:
Victor Zverovich 2026-10-17 07:26:03 -07:00
parent 6ba294f5a6
commit 4ded41f186
2 changed files with 50 additions and 14 deletions

View File

@ -2233,27 +2233,25 @@ struct formatter<local_time<Duration>, Char>
auto format(local_time<Duration> val, FormatContext& ctx) const
-> decltype(ctx.out()) {
auto time_since_epoch = val.time_since_epoch();
auto seconds_since_epoch =
detail::duration_cast<std::chrono::seconds>(time_since_epoch);
auto secs = detail::duration_cast<std::chrono::seconds>(time_since_epoch);
// duration_cast truncates towards zero, so the remainder is negative
// before the epoch. Compute it before flooring the seconds because
// converting floored seconds back overflows at the limits, e.g.
// nanoseconds(INT64_MIN). Keep it in the common duration type as well
// since Duration may be too narrow, e.g. duration<int16_t, std::micro>.
auto subsecs = time_since_epoch - secs;
if (subsecs.count() < 0) {
secs -= std::chrono::seconds(1);
subsecs += std::chrono::seconds(1);
}
// Use gmtime to prevent time zone conversion since local_time has an
// unspecified time zone.
std::tm t = gmtime(seconds_since_epoch.count());
std::tm t = gmtime(secs.count());
using period = typename Duration::period;
if (period::num == 1 && period::den == 1 &&
!std::is_floating_point<typename Duration::rep>::value) {
return formatter<std::tm, Char>::format(t, ctx);
}
auto subsecs =
detail::duration_cast<Duration>(time_since_epoch - seconds_since_epoch);
if (subsecs.count() < 0) {
auto second = detail::duration_cast<Duration>(std::chrono::seconds(1));
if (t.tm_sec != 0) {
--t.tm_sec;
} else {
t = gmtime(seconds_since_epoch.count() - 1);
}
subsecs += second;
}
return formatter<std::tm, Char>::do_format(t, ctx, &subsecs);
}
};

View File

@ -342,6 +342,44 @@ TEST(chrono_test, local_time) {
EXPECT_EQ(fmt::format("{:%S}", pre_epoch), "01.123");
}
TEST(chrono_test, local_time_before_epoch) {
using std::chrono::milliseconds;
using std::chrono::nanoseconds;
using std::chrono::seconds;
auto ms = [](long long n) {
return fmt::local_time<milliseconds>(milliseconds(n));
};
// Subseconds are a remainder of a floored second, not a negative offset.
EXPECT_EQ(fmt::format("{}", ms(-500)), "1969-12-31 23:59:59.500");
EXPECT_EQ(fmt::format("{}", ms(-1000)), "1969-12-31 23:59:59.000");
EXPECT_EQ(fmt::format("{}", ms(-1500)), "1969-12-31 23:59:58.500");
// Flooring the second also moves to the previous minute.
EXPECT_EQ(fmt::format("{}", ms(-61500)), "1969-12-31 23:58:58.500");
EXPECT_EQ(fmt::format("{}", fmt::local_time<std::chrono::duration<double>>(
std::chrono::duration<double>(-1.5))),
"1969-12-31 23:59:58.500000");
// A whole-second duration has no fractional part to format.
EXPECT_EQ(fmt::format("{}", fmt::local_time<seconds>(seconds(-1))),
"1969-12-31 23:59:59");
// Flooring the seconds must not convert them back to the input precision,
// which would overflow. Only a sanitizer detects this, so the result is
// irrelevant and gmtime may reject the value on platforms with a narrower
// supported range.
auto ns_min = nanoseconds((std::numeric_limits<nanoseconds::rep>::min)());
try {
(void)fmt::format("{}", fmt::local_time<nanoseconds>(ns_min));
} catch (const fmt::format_error&) {
}
}
TEST(chrono_test, local_time_narrow_duration) {
// int16_t microseconds hold less than a second, so the subsecond remainder
// does not fit in the duration itself.
using narrow = std::chrono::duration<int16_t, std::micro>;
EXPECT_EQ(fmt::format("{}", fmt::local_time<narrow>(narrow(-30000))),
"1969-12-31 23:59:59.970000");
}
template <typename T, FMT_ENABLE_IF(fmt::detail::has_tm_gmtoff<T>::value)>
auto set_tm_gmtoff(T& time, long offset) -> bool {
time.tm_gmtoff = offset;