perf(chat): use reentrant local time for chat timestamps

The open chat thread called std::localtime at least twice per message
per frame (two day keys for grouping), and more for day separators and
time labels. On glibc every call re-checks the timezone with a stat of
/etc/localtime. The chat time helpers now use util::localTime() from
the new util/local_time.h: localtime_r (localtime_s on Windows), with
no per-call timezone check and no shared static result buffer.

glibc's localtime_r reads the timezone only once, so local_time.h also
adds util::refreshTimezone() (tzset) for caches to call when they
rebuild. Nothing calls it yet; its first caller is the chat thread's
grouping-flags cache in the next commit.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-10 09:50:03 -05:00
co-authored by Claude Opus 5.5
parent e45f4c7d90
commit a69380817e
4 changed files with 62 additions and 21 deletions
+1
View File
@@ -713,6 +713,7 @@ set(APP_HEADERS
src/util/serial_job_lane.h
src/util/fetch_backoff.h
src/util/coalescing_writer.h
src/util/local_time.h
src/util/background_sampler.h
src/util/amount_format.h
src/util/base64.h
+17 -21
View File
@@ -10,6 +10,7 @@
#include "../../data/address_book.h"
#include "../../chat/chat_service.h"
#include "../../util/i18n.h"
#include "../../util/local_time.h" // reentrant localtime (chat day keys / time labels)
#include "../../util/address_validation.h" // isShieldedAddress — chat requires a z-address recipient
#include "../../util/platform.h" // getConfigDir + writeFileAtomically — conversation export (Q11)
#include "../../config/settings.h" // per-conversation mute (Q10)
@@ -134,11 +135,10 @@ std::string shorten(const std::string& s, std::size_t head = 12, std::size_t tai
std::string formatTime(std::int64_t ts) {
if (ts <= 0) return "";
std::time_t t = static_cast<std::time_t>(ts);
std::tm* tm = std::localtime(&t); // UI thread only
if (!tm) return "";
std::tm tmv;
if (!util::localTime(static_cast<std::time_t>(ts), tmv)) return "";
char buf[40];
std::strftime(buf, sizeof(buf), s_chat_time_12h ? "%Y-%m-%d %I:%M %p" : "%Y-%m-%d %H:%M", tm);
std::strftime(buf, sizeof(buf), s_chat_time_12h ? "%Y-%m-%d %I:%M %p" : "%Y-%m-%d %H:%M", &tmv);
return buf;
}
@@ -146,21 +146,19 @@ std::string formatTime(std::int64_t ts) {
// now lives in a per-day separator, so the group header doesn't repeat it). UI thread only.
std::string chatTimeOnly(std::int64_t ts) {
if (ts <= 0) return "";
std::time_t t = static_cast<std::time_t>(ts);
std::tm* tm = std::localtime(&t);
if (!tm) return "";
std::tm tmv;
if (!util::localTime(static_cast<std::time_t>(ts), tmv)) return "";
char buf[24];
std::strftime(buf, sizeof(buf), s_chat_time_12h ? "%I:%M %p" : "%H:%M", tm);
std::strftime(buf, sizeof(buf), s_chat_time_12h ? "%I:%M %p" : "%H:%M", &tmv);
return buf;
}
// Local calendar-day key (YYYYMMDD) for detecting day boundaries between messages. UI thread only.
std::int64_t chatDayKey(std::int64_t ts) {
std::time_t t = static_cast<std::time_t>(ts);
std::tm* tm = std::localtime(&t);
if (!tm) return -1;
return static_cast<std::int64_t>(tm->tm_year + 1900) * 10000 +
static_cast<std::int64_t>(tm->tm_mon + 1) * 100 + tm->tm_mday;
std::tm tmv;
if (!util::localTime(static_cast<std::time_t>(ts), tmv)) return -1;
return static_cast<std::int64_t>(tmv.tm_year + 1900) * 10000 +
static_cast<std::int64_t>(tmv.tm_mon + 1) * 100 + tmv.tm_mday;
}
// Date-separator label: "Today" / "Yesterday" / "Jul 16, 2026". UI thread only.
@@ -168,11 +166,10 @@ std::string chatDaySeparator(std::int64_t ts, std::int64_t todayKey, std::int64_
const std::int64_t k = chatDayKey(ts);
if (k == todayKey) return TR("chat_today");
if (k == yestKey) return TR("chat_yesterday");
std::time_t t = static_cast<std::time_t>(ts);
std::tm* tm = std::localtime(&t);
if (!tm) return "";
std::tm tmv;
if (!util::localTime(static_cast<std::time_t>(ts), tmv)) return "";
char buf[32];
std::strftime(buf, sizeof(buf), "%b %d, %Y", tm);
std::strftime(buf, sizeof(buf), "%b %d, %Y", &tmv);
return buf;
}
@@ -185,11 +182,10 @@ std::string relativeTime(std::int64_t ts) {
if (d < 3600) return std::to_string(d / 60) + "m";
if (d < 86400) return std::to_string(d / 3600) + "h";
if (d < 7 * 86400) return std::to_string(d / 86400) + "d";
std::time_t t = static_cast<std::time_t>(ts);
std::tm* tm = std::localtime(&t);
if (!tm) return "";
std::tm tmv;
if (!util::localTime(static_cast<std::time_t>(ts), tmv)) return "";
char buf[16];
std::strftime(buf, sizeof(buf), "%b %d", tm);
std::strftime(buf, sizeof(buf), "%b %d", &tmv);
return buf;
}
+22
View File
@@ -0,0 +1,22 @@
// DragonX Wallet - reentrant local-time helper (no per-call stat of /etc/localtime on glibc).
#pragma once
#include <ctime>
namespace dragonx::util {
// Fills `out` with the local broken-down time of `t`. Returns false on failure. Thread-safe.
inline bool localTime(std::time_t t, std::tm& out) {
#ifdef _WIN32
return localtime_s(&out, &t) == 0;
#else
return localtime_r(&t, &out) != nullptr;
#endif
}
// Re-read the TZ database / TZ env (glibc localtime_r does not do this on its own after the first call).
// Call once per cache rebuild, never per item.
inline void refreshTimezone() {
#ifdef _WIN32
_tzset();
#else
tzset();
#endif
}
} // namespace dragonx::util
+22
View File
@@ -64,6 +64,7 @@
#include "data/seed_migration_resume.h"
#include "util/address_validation.h"
#include "util/seed_phrase.h"
#include "util/local_time.h"
#include "util/image_decode_core.h"
#include "daemon/daemon_startup_diagnosis.h"
#include "util/amount_format.h"
@@ -14408,6 +14409,26 @@ void testConsolidationRunSimulation()
EXPECT_TRUE(runVerdict(sum) == RunVerdict::Clean);
}
namespace {
void testLocalTime()
{
#ifndef _WIN32
const char* prev = std::getenv("TZ");
const bool hadTz = prev != nullptr;
const std::string savedTz = hadTz ? prev : "";
setenv("TZ", "XXX-3", 1); dragonx::util::refreshTimezone(); // POSIX TZ: UTC+3, no tzdata needed
std::tm t{};
EXPECT_TRUE(dragonx::util::localTime(0, t));
EXPECT_EQ(t.tm_year, 70); EXPECT_EQ(t.tm_mday, 1); EXPECT_EQ(t.tm_hour, 3);
setenv("TZ", "YYY5", 1); dragonx::util::refreshTimezone(); // UTC-5: refreshTimezone() must pick it up
EXPECT_TRUE(dragonx::util::localTime(0, t));
EXPECT_EQ(t.tm_mday, 31); EXPECT_EQ(t.tm_hour, 19);
if (hadTz) setenv("TZ", savedTz.c_str(), 1); else unsetenv("TZ");
dragonx::util::refreshTimezone();
#endif
}
} // namespace
int main()
{
testConnectionConfig();
@@ -14644,6 +14665,7 @@ int main()
testImageDecodeCore();
testImageDecodeJobs();
testGeneratedResourceBehavior();
testLocalTime();
if (g_failures != 0) {
std::cerr << g_failures << " test assertion(s) failed\n";