diff --git a/include/hermes/VM/JSLib/DateCache.h b/include/hermes/VM/JSLib/DateCache.h new file mode 100644 index 00000000000..84fefc90a73 --- /dev/null +++ b/include/hermes/VM/JSLib/DateCache.h @@ -0,0 +1,211 @@ +/* + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +#ifndef HERMES_VM_JSLIB_DATECACHE_H +#define HERMES_VM_JSLIB_DATECACHE_H + +#include "hermes/VM/JSLib/DateUtil.h" + +namespace hermes { +namespace vm { + +/// Type of time represented by an epoch. +enum class TimeType : int8_t { + Local, + Utc, +}; + +/// Cache local time offset (including daylight saving offset). +/// +/// Standard offset is computed via localTZA() and cached. DST is cached in an +/// array of time intervals, time in the same interval has a fixed DST offset. +/// For every new time point that is not included in any existing time interval, +/// compute its DST offset, try extending an existing interval, or creating a +/// new one and storing to the cache array (replace the least recently used +/// cache if it's full). +/// +/// The algorithm of the DST caching is described below: +/// 1. Initialize every interval in the DST cache array to be [t_max, -t_max], +/// which is considered an "empty" interval, here t_max is the maximum epoch +/// time supported by some OS time APIs. Initialize candidate_ to point to the +/// first interval in the array. And constant kDSTDeltaMs is the maximum +/// time range that can have at most one DST transition. Each entry also has an +/// epoch value that is used to find out the least recently used entry, but we +/// omit the operations on them in this description. +/// 2. Given a UTC time point t, check if it's included in candidate_, if yes, +/// return the DST offset of candidate_. Otherwise, go to step 3. +/// 3. Search the cache array and try to find two intervals: +/// before: [s_before, e_before] where s_before <= t and as close as +/// possible. +// after: [s_after, e_after] where t < s_after and e_after ends as early as +/// possible. +/// If one interval is not found, let it point to an empty interval in the +/// cache array (if no empty interval in it, reset the least recently used +/// one to empty and use it). Assign before to candidate_. +/// 4. Check if the the before interval is empty, if yes, compute the DST of t, +/// and set its interval to [t, t]. Otherwise, go to step 5. +/// 5. Check if t is included in the new non-empty before interval, if yes, +/// return its DST. Otherwise, go to step 6. +/// 6. If t > R_new, where R_new = before.end + kDSTDeltaMs, compute the DST +/// offset for t and call extendOrRecomputeCacheEntry(after, t, offset) (this +/// function is described later) to update after, assign after to candidate_, +/// return offset. Otherwise, go to step 7. +/// 7. If t <= R_new < after.start, compute the DST offset for t and call +/// extendOrRecomputeCacheEntry(after, t, offset) to update after. +/// 8. If before.offset == after.offset, merge after to before and +/// reset after, then return the offset. Otherwise, go to step 9. +/// 9. At this step, there must be one DST transition in interval +/// (before.end, after.start], compute DST of t and do binary search to +/// find a time point that has the same offset as t, and extend before or +/// after to it. In the end, return the offset (if after is hit, assign it +/// to candidate_). +/// 10. Given a new UTC time point, repeat step 2 - 9. +/// +/// Algorithm for extendOrRecomputeAfterCache(entry, t, offset): +/// 1. If offset == entry.offset and entry.start-kDSTDeltaMs <= t <= entry.end, +/// let entry.start = t and return. +/// 2. If entry is not empty, scan every entry in the cache array (except +/// candidate_), find out the least recently used one, reset it to empty. +/// 3. Assign offset to entry and update its interval to be [t, t]. +/// +/// Note that on Linux, the standard offset and DST offset is unchanged even if +/// TZ is updated, since the underlying time API in C library caches the time +/// zone. On Windows, currently we don't detect TZ changes as well. But this +/// could change if we migrate the usage of C API to Win32 API. On MacOS, the +/// time API does not cache, so we will check if the standard offset has changed +/// in computeDaylightSaving(), and reset both the standard offset cache and DST +/// cache in the next call to getLocalTimeOffset() or daylightSavingOffsetInMs() +/// (the current call will still use the old TZ). This is to ensure that they +/// are consistent w.r.t. the current TZ. +class LocalTimeOffsetCache { + public: + /// All runtime functionality should use the instance provided in + /// JSLibStorage, this is public only because we need to construct a instance + /// in the unit tests (alternatively, we have to create a singleton function + /// to be used in the tests). + LocalTimeOffsetCache() { + reset(); + } + + /// Reset the standard local time offset and the DST cache. + void reset() { + ::tzset(); + ltza_ = localTZA(); + caches_.fill(DSTCacheEntry{}); + candidate_ = caches_.data(); + epoch_ = 0; + needsToReset_ = false; + } + + /// Compute local timezone offset (DST included). + /// \param timeMs time in milliseconds. + /// \param timeType whether \p timeMs is UTC or local time. + double getLocalTimeOffset(double timeMs, TimeType timeType); + + /// \param utcTimeMs UTC epoch in milliseconds. + /// \return Daylight saving offset at time \p utcTimeMs. + int daylightSavingOffsetInMs(int64_t utcTimeMs); + + private: + LocalTimeOffsetCache(const LocalTimeOffsetCache &) = delete; + LocalTimeOffsetCache operator=(const LocalTimeOffsetCache &) = delete; + + /// Number of cached time intervals for DST. + static constexpr unsigned kCacheSize = 32; + /// Default length of each time interval. The implementation relies on the + /// fact that no time zones have more than one daylight savings offset change + /// per 19 days. In Egypt in 2010 they decided to suspend DST during Ramadan. + /// This led to a short interval where DST is in effect from September 10 to + /// September 30. + static constexpr int64_t kDSTDeltaMs = 19 * SECONDS_PER_DAY * MS_PER_SECOND; + // The largest time that can be passed to OS date-time library functions. + static constexpr int64_t kMaxEpochTimeInMs = + std::numeric_limits::max() * MS_PER_SECOND; + + struct DSTCacheEntry { + /// Start and end time of this DST cache interval, in UTC time. + int64_t startMs{kMaxEpochTimeInMs}; + int64_t endMs{-kMaxEpochTimeInMs}; + /// The DST offset in [startMs, endMs]. + int dstOffsetMs{0}; + /// Used for LRU. + int epoch{0}; + + /// \return whether this is a valid interval. + bool isEmpty() const { + return startMs > endMs; + } + + /// \return True if \p utcTimeMs is included in this interval. + bool include(int64_t utcTimeMs) const { + return startMs <= utcTimeMs && utcTimeMs <= endMs; + } + }; + + /// Compute the DST offset at UTC time \p timeMs. + /// Note that this may update needsToReset_ if it detects a different + /// standard offset than the cached one. + int computeDaylightSaving(int64_t utcTimeMs); + + /// Increase the epoch counter and return it. + int bumpEpoch() { + ++epoch_; + return epoch_; + } + + /// Find the least recently used DST cache and reuse it. + /// \param skip do not scan this cache. Technically, it can be nullptr, which + /// means we don't skip any entry in the cache array. But currently we always + /// passed in an meaningful entry pointer (e.g., candidate_). + /// \return an empty DST cache. This should never return nullptr. + DSTCacheEntry *leastRecentlyUsedExcept(const DSTCacheEntry *const skip); + + /// Scan the DST caches to find a cached interval starts at or before \p + /// timeMs (as late as possible) and an interval ends after \p timeMs (as + /// early as possible). If none is found, reuse an empty cache. Return the + /// found two cache entries. + std::tuple findBeforeAndAfterEntries( + int64_t timeMs); + + /// If entry->dstOffsetMs == \p dstOffsetMs and \p timeMs is included in + /// [entry->startMs - kDSTDeltaMs, entry->endMs], extend entry to + /// [timeMs, entry->endMs]. + /// Otherwise, let \p entry point to the least recently used cache entry + /// (except the candidate_ cache) and update its interval to be [timeMs, + /// timeMs], and its DST offset to be \p dstOffsetMs. + void extendOrRecomputeCacheEntry( + DSTCacheEntry *&entry, + int64_t timeMs, + int dstOffsetMs); + + /// Integer counter used to find least recently used cache. + int epoch_; + /// Point to one entry in the caches_ array, and this invariant always + /// holds: candidate_->startMs <= t <= candidate_->endMs, where t is the last + /// seen time point. It likely will cover subsequent time points. + DSTCacheEntry *candidate_; + /// A list of cached intervals computed for previously seen time points. + /// In the beginning, each interval is initialized to empty. When every + /// interval is non-empty and we need a new empty one, reset the least + /// recently used one (by comparing the epoch value) to empty. + std::array caches_; + /// The standard local timezone offset (without DST offset). + int64_t ltza_; + /// Whether needs to reset the cache and ltza_. + /// We don't do reset in the middle of daylightSavingOffsetInMs() (essentially + /// before any call to computeDaylightSaving() that will detect TZ changes) + /// because it may cause that function never return if another thread is + /// keeping updating TZ. But that means we may return incorrect result before + /// reset(). This is consistent with previous implementation of utcTime() and + /// localTime(). + bool needsToReset_; +}; + +} // namespace vm +} // namespace hermes + +#endif // HERMES_VM_JSLIB_DATECACHE_H diff --git a/include/hermes/VM/JSLib/DateUtil.h b/include/hermes/VM/JSLib/DateUtil.h index 0807e7deb24..40a190abbad 100644 --- a/include/hermes/VM/JSLib/DateUtil.h +++ b/include/hermes/VM/JSLib/DateUtil.h @@ -41,6 +41,10 @@ constexpr double MS_PER_MINUTE = MS_PER_SECOND * SECONDS_PER_MINUTE; constexpr double MS_PER_HOUR = MS_PER_MINUTE * MINUTES_PER_HOUR; constexpr double MS_PER_DAY = MS_PER_HOUR * HOURS_PER_DAY; +// Time value ranges from ES2024 21.4.1.1. +constexpr int64_t TIME_RANGE_SECS = SECONDS_PER_DAY * 100000000LL; +constexpr int64_t TIME_RANGE_MS = TIME_RANGE_SECS * MS_PER_SECOND; + //===----------------------------------------------------------------------===// // Current time diff --git a/include/hermes/VM/JSLib/JSLibStorage.h b/include/hermes/VM/JSLib/JSLibStorage.h index 0f02084de67..0bdd5173066 100644 --- a/include/hermes/VM/JSLib/JSLibStorage.h +++ b/include/hermes/VM/JSLib/JSLibStorage.h @@ -8,6 +8,8 @@ #ifndef HERMES_VM_JSLIB_RUNTIMECOMMONSTORAGE_H #define HERMES_VM_JSLIB_RUNTIMECOMMONSTORAGE_H +#include "hermes/VM/JSLib/DateCache.h" + #include namespace hermes { @@ -26,6 +28,9 @@ struct JSLibStorage { /// PRNG used by Math.random() std::mt19937_64 randomEngine_; bool randomEngineSeeded_ = false; + + /// Time zone offset cache used in conversion between UTC and local time. + LocalTimeOffsetCache localTimeOffsetCache; }; } // namespace vm diff --git a/lib/VM/CMakeLists.txt b/lib/VM/CMakeLists.txt index 61c9cf3f22a..c3dbc596e0b 100644 --- a/lib/VM/CMakeLists.txt +++ b/lib/VM/CMakeLists.txt @@ -113,6 +113,7 @@ set(source_files JSLib/RegExp.cpp JSLib/RegExpStringIterator.cpp JSLib/DateUtil.cpp + JSLib/DateCache.cpp JSLib/Sorting.cpp JSLib/Symbol.cpp JSLib/Date.cpp JSLib/DateUtil.cpp diff --git a/lib/VM/JSLib/DateCache.cpp b/lib/VM/JSLib/DateCache.cpp new file mode 100644 index 00000000000..96eb1be5efc --- /dev/null +++ b/lib/VM/JSLib/DateCache.cpp @@ -0,0 +1,281 @@ +/* + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +#include "hermes/VM/JSLib/DateCache.h" + +namespace hermes { +namespace vm { + +double LocalTimeOffsetCache::getLocalTimeOffset( + double timeMs, + TimeType timeType) { + if (needsToReset_) { + reset(); + } + + if (timeType == TimeType::Utc) { + // Out of allowed range by the spec, return NaN. + if (timeMs < -TIME_RANGE_MS || timeMs > TIME_RANGE_MS) + return std::numeric_limits::quiet_NaN(); + + return ltza_ + daylightSavingOffsetInMs(timeMs); + } + // To compute the DST offset, we need to use UTC time (as required by + // daylightSavingOffsetInMs()). However, getting the exact UTC time is not + // possible since that would be circular. Therefore, we approximate the UTC + // time by subtracting the standard time adjustment and then subtracting an + // additional hour to comply with the spec's requirements + // (https://tc39.es/ecma262/#sec-utc-t). + // + // For example, imagine a transition to DST that goes from UTC+0 to UTC+1, + // moving 00:00 to 01:00. Any time in the skipped hour gets mapped to a + // UTC time before the transition when we subtract an hour (e.g., 00:30 -> + // 23:30), which will correctly result in DST not being in effect. + // + // Similarly, during a transition from DST back to standard time, the hour + // from 00:00 to 01:00 is repeated. A local time in the repeated hour + // similarly gets mapped to a UTC time before the transition. + // + // Note that this will not work if the timezone offset has historical/future + // changes (which generates a different ltza than the one obtained here). + double guessUTC = timeMs - ltza_ - MS_PER_HOUR; + if (guessUTC < -TIME_RANGE_MS || guessUTC > TIME_RANGE_MS) + return std::numeric_limits::quiet_NaN(); + return ltza_ + daylightSavingOffsetInMs(guessUTC); +} + +int LocalTimeOffsetCache::computeDaylightSaving(int64_t utcTimeMs) { + std::time_t t = utcTimeMs / MS_PER_SECOND; + std::tm tm; + int ltza; +#ifdef _WINDOWS + auto err = ::localtime_s(&tm, &t); + if (err) { + return 0; + } + // It's not officially documented that whether Windows C API caches time zone, + // but actual testing shows it does. So for now, we don't detect TZ changes + // and reset the cache here. Otherwise, we have to call tzset() and + // _get_timezone(), which is thread unsafe. And this behavior is the same as + // on Linux. +#else + std::tm *brokenTime = ::localtime_r(&t, &tm); + if (!brokenTime) { + return 0; + } + int dstOffset = tm.tm_isdst ? MS_PER_HOUR : 0; + ltza = tm.tm_gmtoff * MS_PER_SECOND - dstOffset; + // If ltza changes, we need to reset the cache. + if (ltza != ltza_) { + needsToReset_ = true; + } +#endif + return tm.tm_isdst ? MS_PER_HOUR : 0; +} + +int LocalTimeOffsetCache::daylightSavingOffsetInMs(int64_t utcTimeMs) { + if (needsToReset_) { + reset(); + } + + // Some OS library calls don't work right for dates that cannot be represented + // with int32_t. ES5.1 requires to map the time to a year with same + // leap-year-ness and same starting day for the year. But for compatibility, + // other engines, such as V8, use the actual year if it is in the range of + // 1970..2037, which corresponds to the time range 0..kMaxEpochTimeInMs. + if (utcTimeMs < 0 || utcTimeMs > kMaxEpochTimeInMs) { + utcTimeMs = + detail::equivalentTime(utcTimeMs / MS_PER_SECOND) * MS_PER_SECOND; + } + + // Reset the counter to avoid overflow. Each call of this function may + // increase epoch_ by more than 1 (conservatively smaller than 10), so we need + // to subtract it from max value. In practice, this won't happen frequently + // since most time we should see cache hit. + if (LLVM_UNLIKELY( + epoch_ >= std::numeric_limits::max() - 10)) { + reset(); + } + + // Cache hit. + if (candidate_->include(utcTimeMs)) { + candidate_->epoch = bumpEpoch(); + return candidate_->dstOffsetMs; + } + + // Try to find cached intervals that happen before/after utcTimeMs. + auto [before, after] = findBeforeAndAfterEntries(utcTimeMs); + // Set candidate_ to before by default, and reassign it in case that the + // after cache is hit or used. + candidate_ = before; + + // No cached interval yet, compute a new one with utcTimeMs. + if (before->isEmpty()) { + int dstOffset = computeDaylightSaving(utcTimeMs); + before->dstOffsetMs = dstOffset; + before->startMs = utcTimeMs; + before->endMs = utcTimeMs; + before->epoch = bumpEpoch(); + return dstOffset; + } + + // Hits in the cached interval. + if (before->include(utcTimeMs)) { + before->epoch = bumpEpoch(); + return before->dstOffsetMs; + } + + // If utcTimeMs is larger than before->endMs + kDSTDeltaMs, we can't safely + // extend before, because it could have more than one DST transition in the + // interval. Instead, try if we can extend after (or recompute it). + if ((utcTimeMs - kDSTDeltaMs) > before->endMs) { + int dstOffset = computeDaylightSaving(utcTimeMs); + extendOrRecomputeCacheEntry(after, utcTimeMs, dstOffset); + // May help cache hit in subsequent calls (in case that the passed in time + // values are adjacent). + candidate_ = after; + return dstOffset; + } + + // Now, utcTimeMs is in the range of (before->endMs, before->endMs + + // kDSTDeltaMs]. + + before->epoch = bumpEpoch(); + int64_t newAfterStart = before->endMs < kMaxEpochTimeInMs - kDSTDeltaMs + ? before->endMs + kDSTDeltaMs + : kMaxEpochTimeInMs; + // If after starts too late, extend it to newAfterStart or recompute it. + if (newAfterStart < after->startMs) { + int dstOffset = computeDaylightSaving(newAfterStart); + extendOrRecomputeCacheEntry(after, newAfterStart, dstOffset); + } else { + after->epoch = bumpEpoch(); + } + + // Now after->startMs is in (before->endMs, before->endMs + kDSTDeltaMs]. + + // If before and after have the same DST offset, merge them. + if (before->dstOffsetMs == after->dstOffsetMs) { + before->endMs = after->endMs; + *after = DSTCacheEntry{}; + return before->dstOffsetMs; + } + + // Binary search in (before->endMs, after->startMs] for DST transition + // point. Note that after->startMs could be smaller than before->endMs + // + kDSTDeltaMs, but that small interval has the same DST offset, so we + // can ignore them in the below search. + // Though 5 iterations should be enough to cover kDSTDeltaMs, if the + // assumption of only one transition in kDSTDeltaMs no longer holds, we may + // not be able to search the result. We'll stop the loop after 5 iterations + // anyway. + for (int i = 4; i >= 0; --i) { + int delta = after->startMs - before->endMs; + int64_t middle = before->endMs + delta / 2; + int middleDstOffset = computeDaylightSaving(middle); + if (before->dstOffsetMs == middleDstOffset) { + before->endMs = middle; + if (utcTimeMs <= before->endMs) { + return middleDstOffset; + } + } else { + assert(after->dstOffsetMs == middleDstOffset); + after->startMs = middle; + if (utcTimeMs >= after->startMs) { + // May help cache hit in subsequent calls (in case that the passed in + // time values are adjacent). + candidate_ = after; + return middleDstOffset; + } + } + } + + // Fallthrough path of the binary search, just compute the DST for utcTimeMs. + return computeDaylightSaving(utcTimeMs); +} + +LocalTimeOffsetCache::DSTCacheEntry * +LocalTimeOffsetCache::leastRecentlyUsedExcept(const DSTCacheEntry *const skip) { + DSTCacheEntry *result = nullptr; + for (auto &cache : caches_) { + if (&cache == skip) + continue; + if (!result || result->epoch > cache.epoch) + result = &cache; + } + *result = DSTCacheEntry{}; + return result; +} + +std::tuple< + LocalTimeOffsetCache::DSTCacheEntry *, + LocalTimeOffsetCache::DSTCacheEntry *> +LocalTimeOffsetCache::findBeforeAndAfterEntries(int64_t timeMs) { + LocalTimeOffsetCache::DSTCacheEntry *before = nullptr; + LocalTimeOffsetCache::DSTCacheEntry *after = nullptr; + + // `before` should start as late as possible, while `after` should end as + // early as possible so that they're closest to timeMs. + for (auto &cache : caches_) { + if (cache.startMs <= timeMs) { + if (!before || before->startMs < cache.startMs) + before = &cache; + } else if (timeMs < cache.endMs) { + if (!after || after->endMs > cache.endMs) + after = &cache; + } + } + + // None is found, reuse an empty cache for later computation. + if (!before) { + before = leastRecentlyUsedExcept(after); + } + if (!after) { + after = leastRecentlyUsedExcept(before); + } + + assert( + before && after && before != after && + "`before` and `after` interval should be valid"); + assert( + before->isEmpty() || + before->startMs <= timeMs && + "the start time of `before` must start on or before timeMs"); + assert( + after->isEmpty() || + timeMs < after->startMs && + "The start time of `after` must start after timeMs"); + assert( + before->isEmpty() || after->isEmpty() || + before->endMs < after->startMs && + "`before` interval must strictly start before `after` interval"); + + return {before, after}; +} + +void LocalTimeOffsetCache::extendOrRecomputeCacheEntry( + DSTCacheEntry *&entry, + int64_t timeMs, + int dstOffsetMs) { + // It's safe to extend the interval if timeMs is in the checked range. + if (entry->dstOffsetMs == dstOffsetMs && + entry->startMs - kDSTDeltaMs <= timeMs && timeMs <= entry->endMs) { + entry->startMs = timeMs; + } else { + // Recompute the after cache using timeMs. + if (!entry->isEmpty()) { + entry = leastRecentlyUsedExcept(candidate_); + } + entry->startMs = timeMs; + entry->endMs = timeMs; + entry->dstOffsetMs = dstOffsetMs; + entry->epoch = bumpEpoch(); + } +} + +} // namespace vm +} // namespace hermes diff --git a/lib/VM/JSLib/DateUtil.cpp b/lib/VM/JSLib/DateUtil.cpp index ad7a08dfc0f..6f2441f146d 100644 --- a/lib/VM/JSLib/DateUtil.cpp +++ b/lib/VM/JSLib/DateUtil.cpp @@ -347,10 +347,6 @@ static int32_t equivalentYearAsEpochDays( return epochDaysForYear2006To2033[eqYear - 2006]; } -static const int32_t SECS_PER_DAY = 24 * 60 * 60; -// Numbers are from 15.9.1.1 Time Values and Time Range -static const int64_t TIME_RANGE_SECS = SECS_PER_DAY * 100000000LL; - /// Returns an equivalent time for the purpose of determining DST using the /// rules in ES5.1 15.9.1.8 Daylight Saving Time Adjustment /// @@ -367,12 +363,12 @@ int32_t detail::equivalentTime(int64_t epochSecs) { // function in https://github.com/v8/v8/blob/master/src/date.h assert(epochSecs >= -TIME_RANGE_SECS && epochSecs <= TIME_RANGE_SECS); int64_t epochDays, secsOfDay; - floorDivMod(epochSecs, SECS_PER_DAY, &epochDays, &secsOfDay); + floorDivMod(epochSecs, SECONDS_PER_DAY, &epochDays, &secsOfDay); int32_t year, yearAsEpochDays, dayOfYear; // Narrowing of epochDays will not result in truncation decomposeEpochDays(epochDays, &year, &yearAsEpochDays, &dayOfYear); int32_t eqYearAsEpochDays = equivalentYearAsEpochDays(year, yearAsEpochDays); - return (eqYearAsEpochDays + dayOfYear) * SECS_PER_DAY + secsOfDay; + return (eqYearAsEpochDays + dayOfYear) * SECONDS_PER_DAY + secsOfDay; } double daylightSavingTA(double t) {