From fe57d2caf1949abcdb54a35cb610b0eb9d783fd1 Mon Sep 17 00:00:00 2001 From: pepper <3782201+rohvani@users.noreply.github.com> Date: Thu, 10 Sep 2026 12:58:32 -0400 Subject: Consolidate ChatService reconciliation and fix live IM replay Merge archives, plaintext and head previews by service ID and occurrence before applying history limits. Keep loaded history and visible context in ChatService so IM sessions and previews share one composer, with ordinary live appends remaining independent of history replay. Preserve original bodies and send-versus-receipt timing through offline delivery and translation. Match known send times exactly and online receipt times within a rolling 60-second window. Consume eligible occurrences once in chronological order so repeated messages survive reconciliation across minute boundaries. Link offer fallback text to its notification ID and advance the replay cursor for each actual row. Missing or delayed fallback rows can no longer cause the following live IM to be skipped. Dispatch ChatService presentation through the main-loop queue with runtime and current-state checks. This prevents login deadlocks from reentering font initialization in another coroutine. Await deletion invalidation before continuing cleanup so a delayed clear cannot erase later messages. Validated with the Release viewer build, all 34 LLChatServiceHistoryCore cases, and native delivery, archive, offer replay and coroutine-deadlock fixtures. The deadlock fixtures reproduce the original hangs and pass with deferred presentation. --- indra/newview/llimview.cpp | 214 ++++++++++++--------------------------------- 1 file changed, 58 insertions(+), 156 deletions(-) (limited to 'indra/newview/llimview.cpp') diff --git a/indra/newview/llimview.cpp b/indra/newview/llimview.cpp index e27ad96a63..666a243421 100644 --- a/indra/newview/llimview.cpp +++ b/indra/newview/llimview.cpp @@ -90,31 +90,6 @@ const S32 XL8_PADDING = 3; // XL8_START_TAG.size() + XL8_END_TAG.size() // Tokens are process-unique so a stale completion cannot match a recreated P2P session. static U64 sChatHistoryLoadToken = 0; -namespace -{ -bool sameHistoryRow(LLSD left, LLSD right) -{ - // Positional indexes are rebuilt with the overlay and do not change presentation. - left.erase("index"); - right.erase("index"); - return llsd_equals(left, right); -} - -bool sameHistory(const LLIMModel::chat_message_list_t& left, - const LLIMModel::chat_message_list_t& right) -{ - return left.size() == right.size() && - std::equal(left.begin(), left.end(), right.begin(), sameHistoryRow); -} - -bool isStrictHistoryPrefix(const LLIMModel::chat_message_list_t& prefix, - const LLIMModel::chat_message_list_t& complete) -{ - return prefix.size() < complete.size() && - std::equal(prefix.begin(), prefix.end(), complete.begin(), sameHistoryRow); -} -} - /** Timeout of outgoing session initialization (in seconds) */ const static U32 SESSION_INITIALIZATION_TIMEOUT = 30; @@ -626,39 +601,37 @@ void chatterBoxInvitationCoro(std::string url, LLUUID sessionId, LLIMMgr::EInvit } void translateSuccess(const LLUUID& session_id, const std::string& from, const LLUUID& from_id, const std::string& utf8_text, - U64 time_n_flags, std::string originalMsg, std::string expectLang, std::string translation, const std::string detected_language) + U64 time_n_flags, LLSD history_context, std::string expectLang, std::string translation, const std::string detected_language) { std::string message_txt(utf8_text); // filter out non-interesting responses if (!translation.empty() && ((detected_language.empty()) || (expectLang != detected_language)) - && (LLStringUtil::compareInsensitive(translation, originalMsg) != 0)) + && (LLStringUtil::compareInsensitive(translation, utf8_text) != 0)) { // Note - if this format changes, also fix code in addMessagesFromServerHistory() message_txt += XL8_START_TAG + LLTranslate::removeNoTranslateTags(translation) + XL8_END_TAG; } - // Extract info packed in time_n_flags bool log2file = (bool)(time_n_flags & (1LL << 32)); bool is_region_msg = (bool)(time_n_flags & (1LL << 33)); U32 time_stamp = (U32)(time_n_flags & 0x00000000ffffffff); - LLIMModel::getInstance()->processAddingMessage(session_id, from, from_id, message_txt, log2file, is_region_msg, time_stamp); + LLIMModel::getInstance()->processAddingMessage(session_id, from, from_id, message_txt, log2file, is_region_msg, time_stamp, history_context); } void translateFailure(const LLUUID& session_id, const std::string& from, const LLUUID& from_id, const std::string& utf8_text, - U64 time_n_flags, int status, const std::string err_msg) + U64 time_n_flags, LLSD history_context, int status, const std::string err_msg) { std::string message_txt(utf8_text); std::string msg = LLTrans::getString("TranslationFailed", LLSD().with("[REASON]", err_msg)); LLStringUtil::replaceString(msg, "\n", " "); // we want one-line error messages message_txt += XL8_START_TAG + msg + XL8_END_TAG; - // Extract info packed in time_n_flags bool log2file = (bool)(time_n_flags & (1LL << 32)); bool is_region_msg = (bool)(time_n_flags & (1LL << 33)); U32 time_stamp = (U32)(time_n_flags & 0x00000000ffffffff); - LLIMModel::getInstance()->processAddingMessage(session_id, from, from_id, message_txt, log2file, is_region_msg, time_stamp); + LLIMModel::getInstance()->processAddingMessage(session_id, from, from_id, message_txt, log2file, is_region_msg, time_stamp, history_context); } void chatterBoxHistoryCoro(std::string url, LLUUID sessionId, std::string from, std::string message, U32 timestamp) @@ -1394,8 +1367,8 @@ void LLIMModel::LLIMSession::loadHistory() { mChatHistoryLoadToken = ++sChatHistoryLoadToken; mChatHistoryLocalLoading = false; - mChatServiceHistoricalValue.clear(); - replaceHistoricalMessages(mChatServiceHistoricalValue); + mChatServiceHistory.clear(); + replaceHistoricalMessages({}); return; } @@ -1403,8 +1376,8 @@ void LLIMModel::LLIMSession::loadHistory() { mChatHistoryLoadToken = ++sChatHistoryLoadToken; mChatHistoryLocalLoading = false; - mChatServiceHistoricalValue.clear(); - replaceHistoricalMessages(mChatServiceHistoricalValue); + mChatServiceHistory.clear(); + replaceHistoricalMessages({}); return; } @@ -1463,9 +1436,9 @@ void LLIMModel::LLIMSession::loadHistory() // current, then replace the historical overlay in one operation. session->mChatHistoryArchiveSerial = result.archive_serial; session->mChatHistoryLocalLoading = false; - session->mChatServiceHistoricalValue = LLChatServiceHistory::mergeHeadPreview( - result.messages, snapshot, 200); - session->replaceHistoricalMessages(session->mChatServiceHistoricalValue); + session->mChatServiceHistory.setLoaded(result.messages); + session->replaceHistoricalMessages(LLChatServiceHistory::composeHistory( + session->mChatServiceHistory, snapshot, 200, session_id)); })) { // If dispatch infrastructure is unavailable, retain ordinary plaintext @@ -1476,7 +1449,7 @@ void LLIMModel::LLIMSession::loadHistory() { chat_message_list_t legacy; LLLogChat::loadChatHistory(mHistoryFileName, legacy); - mChatServiceHistoricalValue = legacy; + mChatServiceHistory.setLoaded(legacy); replaceHistoricalMessages(legacy); } } @@ -1484,86 +1457,12 @@ void LLIMModel::LLIMSession::loadHistory() void LLIMModel::LLIMSession::replaceHistoricalMessages(const chat_message_list_t& history) { - // Separate the append-only live tail from the currently presented historical baseline. - chat_message_list_t live; - chat_message_list_t current_historical; - for (const LLSD& message : mMsgs) - { - if (!message["is_history"].asBoolean()) - { - live.push_back(message); - } - else - { - current_historical.push_back(message); - } - } - - // A refresh may contain rows that this open session already - // displays live. Consume those exact occurrences before rebuilding the overlay. - const chat_message_list_t filtered = isP2P() - ? LLChatServiceHistory::filterLiveDuplicates(history, live) : history; - - // Normalize stitched LLLogChat fields into the IM model shape and reverse the - // ascending input into the model's newest-first historical order. - chat_message_list_t historical; - for (const LLSD& source : filtered) + if (LLChatServiceHistory::replaceHistory(mMsgs, history, isP2P())) { - const std::string from = source[LL_IM_FROM].asString(); - LLUUID from_id; - - // Plaintext transcripts store sender names but usually omit UUIDs. Recover - // resident identity from local name caches without scheduling a lookup. - if (source[LL_IM_FROM_ID].isDefined()) - { - from_id = source[LL_IM_FROM_ID].asUUID(); - } - else + if (LLFloaterIMSession* floater = LLFloaterIMSession::findInstance(mSessionID)) { - const std::string legacy_name = gCacheName->buildLegacyName(from); - from_id = LLAvatarNameCache::getInstance()->findIdByName(legacy_name); + floater->reloadMessages(false); } - - LLSD message = source; - message["from"] = from; - message["from_id"] = from_id; - message["message"] = source[LL_IM_TEXT]; - message["time"] = source[LL_IM_TIME]; - message["timestamp"] = source["timestamp"].asInteger(); - message["is_history"] = true; - message["is_region_msg"] = false; - historical.push_front(message); - } - - const bool consumed_live = filtered.size() < history.size(); - LLFloaterIMSession* floater = LLFloaterIMSession::findInstance(mSessionID); - const bool capped_live_rollover = - floater && floater->isInVisibleChain() && - consumed_live && - !historical.empty() && - history.size() == current_historical.size() && - isStrictHistoryPrefix(historical, current_historical); - - // Re-publishing the same bounded history, including eviction of its oldest - // rows after live messages become durable, must not reconstruct a visible transcript. - if (sameHistory(current_historical, historical) || capped_live_rollover) - { - return; - } - - live.insert(live.end(), historical.begin(), historical.end()); - mMsgs.swap(live); - - // Rebuild positional indexes only after the complete live/history overlay exists. - S32 index = static_cast(mMsgs.size()); - for (LLSD& message : mMsgs) - { - message["index"] = --index; - } - - if (floater) - { - floater->reloadMessages(false); } } @@ -1573,8 +1472,8 @@ void LLIMModel::LLIMSession::clearHistoricalMessages() // after presentation has been cleared. mChatHistoryLoadToken = ++sChatHistoryLoadToken; mChatHistoryLocalLoading = false; - mChatServiceHistoricalValue.clear(); - replaceHistoricalMessages(mChatServiceHistoricalValue); + mChatServiceHistory.clear(); + replaceHistoricalMessages({}); } void LLIMModel::LLIMSession::clearForHistoryDeletion() @@ -1584,7 +1483,7 @@ void LLIMModel::LLIMSession::clearForHistoryDeletion() mChatHistoryLocalLoading = false; mChatHistoryArchiveSerial = 0; mChatServicePresentationAllowed = false; - mChatServiceHistoricalValue.clear(); + mChatServiceHistory.clear(); mLastHistoryCacheDateTime.clear(); mLastHistoryCacheMsgs.clear(); mMsgs.clear(); @@ -1607,33 +1506,15 @@ void LLIMModel::LLIMSession::applyChatServiceSnapshot( return; } - // Revoking service presentation strips only service-tagged historical rows; - // legacy rows and every live row remain intact. const bool presentation_changed = snapshot.service_presentation_allowed != mChatServicePresentationAllowed; mChatServicePresentationAllowed = snapshot.service_presentation_allowed; - if (presentation_changed && !snapshot.service_presentation_allowed) - { - chat_message_list_t legacy_only; - for (const LLSD& message : mChatServiceHistoricalValue) - { - if (!message["chat_service_msg_id"].isString()) - { - legacy_only.push_back(message); - } - } - - mChatServiceHistoricalValue.swap(legacy_only); - replaceHistoricalMessages(mChatServiceHistoricalValue); - } - - // The bounded first page can update presentation before durable archive publication. - if (snapshot.service_presentation_allowed && !snapshot.head_preview.empty()) + // Apply previews and presentation changes through the shared history composer. + if (presentation_changed || !snapshot.head_preview.empty()) { - mChatServiceHistoricalValue = LLChatServiceHistory::mergeHeadPreview( - mChatServiceHistoricalValue, snapshot, 200); - replaceHistoricalMessages(mChatServiceHistoricalValue); + replaceHistoricalMessages(LLChatServiceHistory::composeHistory( + mChatServiceHistory, snapshot, 200, mSessionID)); } // The active read will restart itself if its captured presentation or archive @@ -2008,8 +1889,7 @@ bool LLIMModel::addToHistory(const LLUUID& session_id, return false; } - // P2P live rows keep their actual arrival/send time so a later asynchronous - // history refresh can reconcile the same logged occurrence exactly by minute. + // Fill missing direct timestamps for display and plaintext reconciliation. U32 model_timestamp = timestamp; if (!model_timestamp && session->isP2PSessionType()) { @@ -2060,27 +1940,34 @@ void LLIMModel::proccessOnlineOfflineNotification( } void LLIMModel::addMessage(const LLUUID& session_id, const std::string& from, const LLUUID& from_id, - const std::string& utf8_text, bool log2file /* = true */, bool is_region_msg, /* = false */ U32 time_stamp /* = 0 */) + const std::string& utf8_text, bool log2file /* = true */, bool is_region_msg, /* = false */ U32 time_stamp /* = 0 */, + LLSD history_context) { + // Capture direct-message timing before an asynchronous translation can delay it. + LLIMSession* session = findIMSession(session_id); + if (session && session->isP2P()) + { + history_context = LLChatServiceHistory::prepareLiveMessage(utf8_text, time_stamp, history_context); + } if (gSavedSettings.getBOOL("TranslateChat") && (from != SYSTEM_FROM)) { const std::string from_lang = ""; // leave empty to trigger autodetect const std::string to_lang = LLTranslate::getTranslateLanguage(); U64 time_n_flags = ((U64) time_stamp) | (log2file ? (1LL << 32) : 0) | (is_region_msg ? (1LL << 33) : 0); // boost::bind has limited parameters LLTranslate::translateMessage(from_lang, to_lang, utf8_text, - boost::bind(&translateSuccess, session_id, from, from_id, utf8_text, time_n_flags, utf8_text, from_lang, _1, _2), - boost::bind(&translateFailure, session_id, from, from_id, utf8_text, time_n_flags, _1, _2)); + boost::bind(&translateSuccess, session_id, from, from_id, utf8_text, time_n_flags, history_context, from_lang, _1, _2), + boost::bind(&translateFailure, session_id, from, from_id, utf8_text, time_n_flags, history_context, _1, _2)); } else { - processAddingMessage(session_id, from, from_id, utf8_text, log2file, is_region_msg, time_stamp); + processAddingMessage(session_id, from, from_id, utf8_text, log2file, is_region_msg, time_stamp, history_context); } } void LLIMModel::processAddingMessage(const LLUUID& session_id, const std::string& from, const LLUUID& from_id, - const std::string& utf8_text, bool log2file, bool is_region_msg, U32 time_stamp) + const std::string& utf8_text, bool log2file, bool is_region_msg, U32 time_stamp, LLSD history_context) { - LLIMSession* session = addMessageSilently(session_id, from, from_id, utf8_text, log2file, is_region_msg, time_stamp); + LLIMSession* session = addMessageSilently(session_id, from, from_id, utf8_text, log2file, is_region_msg, time_stamp, history_context); if (!session) return; @@ -2107,7 +1994,7 @@ void LLIMModel::processAddingMessage(const LLUUID& session_id, const std::string LLIMModel::LLIMSession* LLIMModel::addMessageSilently(const LLUUID& session_id, const std::string& from, const LLUUID& from_id, const std::string& utf8_text, bool log2file /* = true */, bool is_region_msg, /* false */ - U32 timestamp /* = 0 */) + U32 timestamp /* = 0 */, LLSD history_context) { LLIMSession* session = findIMSession(session_id); @@ -2123,7 +2010,16 @@ LLIMModel::LLIMSession* LLIMModel::addMessageSilently(const LLUUID& session_id, from_name = SYSTEM_FROM; } + // Direct silent callers also capture provenance; live delivery always appends. + if (session->isP2P()) + { + history_context = LLChatServiceHistory::prepareLiveMessage(utf8_text, timestamp, history_context); + } addToHistory(session_id, from_name, from_id, utf8_text, is_region_msg, timestamp); + if (session->isP2P()) + { + LLChatServiceHistory::recordLiveMessage(session->mMsgs.front(), history_context); + } if (log2file) { logToFile(getHistoryFileName(session_id), from_name, from_id, utf8_text); @@ -3502,7 +3398,8 @@ void LLIMMgr::addMessage( bool is_region_msg, U32 timestamp, // May be zero LLUUID display_id, - std::string_view display_name) + std::string_view display_name, + const LLSD& original_text) { LLUUID other_participant_id = target_id; std::string message_display_name = (display_name.empty()) ? from : std::string(display_name); @@ -3631,14 +3528,19 @@ void LLIMMgr::addMessage( if (!LLMuteList::getInstance()->isMuted(other_participant_id, LLMute::flagTextChat) && !skip_message) { - // Accepted inbound direct rows share the same immediate priority path used by an opener. - if (LLChatServiceHistory::isPersistedDirectDialog(dialog) && + // Online direct timestamps describe receipt after service creation; offline + // timestamps describe the saved send. Preserve that distinction through translation. + const bool incoming_direct = LLChatServiceHistory::isPersistedDirectDialog(dialog) && !is_region_msg && message_display_name != SYSTEM_FROM && - other_participant_id.notNull() && other_participant_id != gAgentID) + other_participant_id.notNull() && other_participant_id != gAgentID; + if (incoming_direct) { LLChatServiceHistory::prioritizeResident(other_participant_id, true); } - LLIMModel::instance().addMessage(new_session_id, message_display_name, display_id, msg, true, is_region_msg, timestamp); + LLIMModel::instance().addMessage(new_session_id, message_display_name, display_id, msg, true, is_region_msg, timestamp, + incoming_direct + ? LLChatServiceHistoryCore::incomingContext(original_text, !is_offline_msg) + : LLSD()); } // Open conversation floater if offline messages are present -- cgit v1.3 From 3c9dee368d667beda045bf6d759f0658f80312eb Mon Sep 17 00:00:00 2001 From: pepper <3782201+rohvani@users.noreply.github.com> Date: Thu, 10 Sep 2026 22:52:29 -0400 Subject: Share the ChatService quiet timer across incoming and outgoing IMs Apply the existing per-resident 15-second quiet deadline to accepted incoming direct IMs as well as sends. This prevents each received IM from immediately starting history reconciliation during an active exchange. Keep live delivery, conversation opens and first-contact discovery immediate. Rename outbound scheduler terms to activity terms while reusing the existing queue, cooldown and cancellation paths. Validated with the Windows Release viewer build, all 116 configured Windows tests and an 80-check native scheduler fixture. The scheduler fixture also compiles with GCC warnings treated as errors. --- indra/newview/llchatservicehistory.cpp | 120 ++++++++++++++++----------------- indra/newview/llchatservicehistory.h | 6 +- indra/newview/llimview.cpp | 9 +-- 3 files changed, 68 insertions(+), 67 deletions(-) (limited to 'indra/newview/llimview.cpp') diff --git a/indra/newview/llchatservicehistory.cpp b/indra/newview/llchatservicehistory.cpp index b55932bc30..ade534cd05 100644 --- a/indra/newview/llchatservicehistory.cpp +++ b/indra/newview/llchatservicehistory.cpp @@ -79,7 +79,7 @@ const char* const PENDING_NAME = "(name pending)"; const char* const LEGACY_WALL_TIME = "chat_service_legacy_wall_time"; const F64 REQUEST_SPACING = 2.1; const F64 RETRY_DELAY = 5.0; -const F64 OUTBOUND_REFRESH_DELAY = 15.0; +const F64 ACTIVITY_REFRESH_DELAY = 15.0; const F64 RATE_LIMIT_DELAY = 120.0; const F64 LIST_INTERVAL = 3600.0; const F64 NAME_TIMEOUT = 30.0; @@ -110,11 +110,11 @@ enum ESummary SUMMARY_VALID }; -enum EOutboundDiscovery +enum EActivityDiscovery { - OUTBOUND_NONE, - OUTBOUND_PENDING, - OUTBOUND_CONFIRMING + DISCOVERY_NONE, + DISCOVERY_PENDING, + DISCOVERY_CONFIRMING }; struct CapabilityContext @@ -187,10 +187,10 @@ struct Resident bool metadata_waiting = false; bool priority_waiting = false; - // Outbound bursts share one quiet deadline; unknown conversations also retain - // one bounded discovery confirmation. - F64 outbound_refresh_due = 0.0; - EOutboundDiscovery outbound_discovery = OUTBOUND_NONE; + // Incoming and outgoing IM bursts share one quiet deadline; unknown + // conversations retain one bounded discovery confirmation. + F64 activity_refresh_due = 0.0; + EActivityDiscovery activity_discovery = DISCOVERY_NONE; LLChatServiceHistory::Snapshot snapshot; }; @@ -1260,7 +1260,7 @@ bool networkEligible(const LLUUID& id, const Resident& resident, } void expireMetadata(); -void activateDueOutboundRefreshes(); +void activateDueActivityRefreshes(); bool waitForWake(F64 seconds, U32 epoch) { @@ -1270,7 +1270,7 @@ bool waitForWake(F64 seconds, U32 epoch) } // Coalesce queued wake events, then sleep until either state changes or the - // nearest pacing, list, metadata, or outbound deadline arrives. + // nearest pacing, list, metadata, or activity deadline arrives. sRuntime.wake_pending = false; sWake->discard(); llcoro::suspendUntilEventOnWithTimeout(*sWake, static_cast(llmax(0.01, seconds)), @@ -1306,7 +1306,7 @@ bool pace(const CapabilityContext& context, U32 epoch, // Record quiet-expiry priority during a global cooldown without allowing it // to bypass that cooldown or create a separate request path. - activateDueOutboundRefreshes(); + activateDueActivityRefreshes(); const F64 now = F64(LLTimer::getTotalSeconds()); const F64 remaining = sRuntime.network_not_before - now; if (remaining <= 0.0) @@ -1320,9 +1320,9 @@ bool pace(const CapabilityContext& context, U32 epoch, { wait = llmin(wait, llmax(0.01, pair.second.metadata.deadline - now)); } - if (pair.second.outbound_refresh_due > now) + if (pair.second.activity_refresh_due > now) { - wait = llmin(wait, pair.second.outbound_refresh_due - now); + wait = llmin(wait, pair.second.activity_refresh_due - now); } } const U32 pending_before = pendingMetadataCount(); @@ -1371,9 +1371,9 @@ void handleRequestFailure(const LLUUID& id, Resident& resident, S32 status) } } -bool processList(const LLSD& body, bool& confirm_outbound_discovery) +bool processList(const LLSD& body, bool& confirm_activity_discovery) { - confirm_outbound_discovery = false; + confirm_activity_discovery = false; std::vector entries; if (!validateConversationList(body, sRuntime.agent_id, entries)) { @@ -1386,21 +1386,21 @@ bool processList(const LLSD& body, bool& confirm_outbound_discovery) } // Validate the complete list before preserving priority placeholders and - // appending background work. The first miss after an outbound hint retains its + // appending background work. The first miss after an activity hint retains its // placeholder for one delayed confirmation; the next valid miss retires it. for (auto& pair : sRuntime.residents) { pair.second.listed = listed.count(pair.first) != 0; if (!pair.second.listed) { - if (pair.second.outbound_discovery == OUTBOUND_PENDING) + if (pair.second.activity_discovery == DISCOVERY_PENDING) { - pair.second.outbound_discovery = OUTBOUND_CONFIRMING; - confirm_outbound_discovery = true; + pair.second.activity_discovery = DISCOVERY_CONFIRMING; + confirm_activity_discovery = true; continue; } - pair.second.outbound_discovery = OUTBOUND_NONE; + pair.second.activity_discovery = DISCOVERY_NONE; sRuntime.queue.erase(std::remove(sRuntime.queue.begin(), sRuntime.queue.end(), pair.first), sRuntime.queue.end()); if (sRuntime.priority_resident == pair.first) @@ -1422,7 +1422,7 @@ bool processList(const LLSD& body, bool& confirm_outbound_discovery) resident.conversation_id = entry.conversation_id; resident.advertised_token = entry.last_msg_id; resident.listed = true; - resident.outbound_discovery = OUTBOUND_NONE; + resident.activity_discovery = DISCOVERY_NONE; if (resident.metadata.state == META_FAILED) { resident.metadata.state = META_UNREQUESTED; @@ -1454,7 +1454,7 @@ void requestList(const CapabilityContext& context, U32 epoch) } // Discovery mutates resident scheduling state only after the complete response - // passes strict validation. A pending outbound hint remains visible across the + // passes strict validation. A pending activity hint remains visible across the // HTTP suspension and is consumed by processList rather than this request latch. const HttpResult response = request(context.list_url, NULL); LL_INFOS("ChatServiceHistory") @@ -1473,14 +1473,14 @@ void requestList(const CapabilityContext& context, U32 epoch) } if (!response.body.isUndefined() && response.status >= 200 && response.status < 300) { - bool confirm_outbound_discovery = false; - if (processList(response.body, confirm_outbound_discovery)) + bool confirm_activity_discovery = false; + if (processList(response.body, confirm_activity_discovery)) { const F64 now = F64(LLTimer::getTotalSeconds()); - sRuntime.list_needed = confirm_outbound_discovery; + sRuntime.list_needed = confirm_activity_discovery; sRuntime.list_retry_used = false; sRuntime.next_list = now + LIST_INTERVAL; - if (confirm_outbound_discovery) + if (confirm_activity_discovery) { sRuntime.network_not_before = llmax(sRuntime.network_not_before, now + RETRY_DELAY); @@ -1520,8 +1520,8 @@ void syncResident(const LLUUID& id, const CapabilityContext& context, U32 epoch) if (!resident.listed || resident.conversation_id.empty()) { // A failed discovery remains dormant until a fresh open/list/region or - // due outbound trigger. - resident.outbound_discovery = OUTBOUND_NONE; + // due activity trigger. + resident.activity_discovery = DISCOVERY_NONE; resident.snapshot.head_preview.clear(); setWorkActive(id, false); clearPriority(id); @@ -1625,10 +1625,10 @@ void syncResident(const LLUUID& id, const CapabilityContext& context, U32 epoch) // If the first head request begins just after quiet expiry, it already // covers that burst. Cursor pages never consume a newer deadline. if (!current.first_request_started && cursor.empty() && - current.outbound_refresh_due > 0.0 && - current.outbound_refresh_due <= F64(LLTimer::getTotalSeconds())) + current.activity_refresh_due > 0.0 && + current.activity_refresh_due <= F64(LLTimer::getTotalSeconds())) { - current.outbound_refresh_due = 0.0; + current.activity_refresh_due = 0.0; } current.first_request_started = true; post["conversation_id"] = current.conversation_id; @@ -1674,9 +1674,9 @@ void syncResident(const LLUUID& id, const CapabilityContext& context, U32 epoch) break; } - // A validated response is the yield boundary for outbound deadlines + // A validated response is the yield boundary for activity deadlines // that matured while this request was in flight. - activateDueOutboundRefreshes(); + activateDueActivityRefreshes(); if (!ownsRuntime(epoch)) { return; @@ -1743,8 +1743,8 @@ void syncResident(const LLUUID& id, const CapabilityContext& context, U32 epoch) } // Archive inspection is also an awaited page boundary; apply any - // outbound priority that matured while it ran before staging rows. - activateDueOutboundRefreshes(); + // activity priority that matured while it ran before staging rows. + activateDueActivityRefreshes(); if (sRuntime.priority_resident.notNull() && sRuntime.priority_resident != id) { queueResident(id, false); @@ -2403,9 +2403,9 @@ void expireMetadata() } } -void activateDueOutboundRefreshes() +void activateDueActivityRefreshes() { - // Revoked account gates retire pending outbound activity before it can remain + // Revoked account gates retire pending activity before it can remain // a wake source or schedule service work. if (!sRuntime.rollout || !transcriptConsent() || sRuntime.state_safety != STATE_SAFE || sRuntime.cleanup_pending || @@ -2413,7 +2413,7 @@ void activateDueOutboundRefreshes() { for (auto& pair : sRuntime.residents) { - pair.second.outbound_refresh_due = 0.0; + pair.second.activity_refresh_due = 0.0; } return; } @@ -2422,14 +2422,14 @@ void activateDueOutboundRefreshes() for (auto& pair : sRuntime.residents) { Resident& resident = pair.second; - if (resident.outbound_refresh_due <= 0.0 || resident.outbound_refresh_due > now) + if (resident.activity_refresh_due <= 0.0 || resident.activity_refresh_due > now) { continue; } // Consume the deadline before reusing the existing priority/discovery path // so an expired value cannot create a rapid manager wake loop. - resident.outbound_refresh_due = 0.0; + resident.activity_refresh_due = 0.0; LLMuteList* mute = LLMuteList::getInstance(); if (mute && mute->isLoadedFromServer() && (mute->isMuted(pair.first) || @@ -2442,9 +2442,9 @@ void activateDueOutboundRefreshes() { LLChatServiceHistory::prioritizeResident(pair.first, true); } - else if (resident.outbound_discovery == OUTBOUND_NONE) + else if (resident.activity_discovery == DISCOVERY_NONE) { - resident.outbound_discovery = OUTBOUND_PENDING; + resident.activity_discovery = DISCOVERY_PENDING; LLChatServiceHistory::prioritizeResident(pair.first); } } @@ -2452,7 +2452,7 @@ void activateDueOutboundRefreshes() F64 nearestWait() { - // Sleep until the earliest list, pacing, metadata, or outbound quiet deadline; + // Sleep until the earliest list, pacing, metadata, or activity quiet deadline; // explicit wake events interrupt this deadline when state changes sooner. const F64 now = LLTimer::getTotalSeconds(); F64 deadline = sRuntime.next_list > now ? sRuntime.next_list : now + LIST_INTERVAL; @@ -2468,9 +2468,9 @@ F64 nearestWait() } if (!sRuntime.delete_requested && !sRuntime.cleanup_pending && sRuntime.state_safety == STATE_SAFE && sRuntime.rollout && transcriptConsent() && - pair.second.outbound_refresh_due > 0.0) + pair.second.activity_refresh_due > 0.0) { - deadline = llmin(deadline, pair.second.outbound_refresh_due); + deadline = llmin(deadline, pair.second.activity_refresh_due); } } return llmax(0.01, deadline - now); @@ -2536,10 +2536,10 @@ void manager(U32 epoch) continue; } - // Expire timers, activate due outbound bursts, and fill metadata slots before + // Expire timers, activate due activity bursts, and fill metadata slots before // choosing the next network occurrence. expireMetadata(); - activateDueOutboundRefreshes(); + activateDueActivityRefreshes(); fillMetadataSlots(); const CapabilityContext context = sampleContext(); @@ -2569,8 +2569,8 @@ void manager(U32 epoch) } } - // Discovery precedes resident paging; the queue itself preserves open, - // inbound, and due outbound priority without creating a second worker. + // Discovery precedes resident paging; opens and due activity share the + // resident priority queue. const bool base_network = baseNetworkEligible(context); LLMuteList* mute = LLMuteList::getInstance(); // Once all external inputs are available, record why synchronization stayed gated. @@ -2887,7 +2887,7 @@ void LLChatServiceHistory::start() { if (!allowed) { - pair.second.outbound_refresh_due = 0.0; + pair.second.activity_refresh_due = 0.0; pair.second.snapshot.head_preview.clear(); pair.second.snapshot.service_work_active = false; } @@ -3072,7 +3072,7 @@ void LLChatServiceHistory::prioritizeResident(const LLUUID& id, bool follow_acti wakeManager(); } -void LLChatServiceHistory::noteOutboundDirectMessage(const LLUUID& id) +void LLChatServiceHistory::noteDirectMessageActivity(const LLUUID& id) { if (!sRuntime.running || !sRuntime.rollout || !transcriptConsent() || sRuntime.delete_requested || id.isNull() || id == sRuntime.agent_id) @@ -3080,14 +3080,14 @@ void LLChatServiceHistory::noteOutboundDirectMessage(const LLUUID& id) return; } - // Every send replaces one quiet deadline, while first contact keeps its - // immediate bounded discovery path. + // Each incoming or outgoing IM resets the quiet deadline. First contact + // also starts bounded discovery immediately. Resident& resident = sRuntime.residents[id]; - resident.outbound_refresh_due = - F64(LLTimer::getTotalSeconds()) + OUTBOUND_REFRESH_DELAY; - if (!resident.listed && resident.outbound_discovery == OUTBOUND_NONE) + resident.activity_refresh_due = + F64(LLTimer::getTotalSeconds()) + ACTIVITY_REFRESH_DELAY; + if (!resident.listed && resident.activity_discovery == DISCOVERY_NONE) { - resident.outbound_discovery = OUTBOUND_PENDING; + resident.activity_discovery = DISCOVERY_PENDING; prioritizeResident(id); } else @@ -3257,7 +3257,7 @@ bool LLChatServiceHistory::deleteTranscriptsAsync(const delete_callback_t& callb return false; } - // Latch deletion and retire outbound deadlines so no new request or view read can + // Latch deletion and retire activity deadlines so no new request or view read can // start, and no expired wake source remains before pending privacy state is durable. sRuntime.delete_click_ticks = ticks; sRuntime.delete_callback = callback; @@ -3265,7 +3265,7 @@ bool LLChatServiceHistory::deleteTranscriptsAsync(const delete_callback_t& callb sRuntime.delete_requested = true; for (auto& pair : sRuntime.residents) { - pair.second.outbound_refresh_due = 0.0; + pair.second.activity_refresh_due = 0.0; } wakeManager(); diff --git a/indra/newview/llchatservicehistory.h b/indra/newview/llchatservicehistory.h index 2d647fa16f..2385fef798 100644 --- a/indra/newview/llchatservicehistory.h +++ b/indra/newview/llchatservicehistory.h @@ -75,11 +75,11 @@ namespace LLChatServiceHistory bool localHistoryExists(); bool localHistoryExists(const LLUUID& resident_id); - // Opens, accepted inbound activity, and due outbound bursts share one - // account-scoped priority queue. + // Opens and due direct-message activity share one account-scoped priority queue. bool isPersistedDirectDialog(EInstantMessage dialog); void prioritizeResident(const LLUUID& resident_id, bool follow_active_request = false); - void noteOutboundDirectMessage(const LLUUID& resident_id); + // Incoming and outgoing IMs reset one quiet deadline per resident. + void noteDirectMessageActivity(const LLUUID& resident_id); // Views connect first and then query so they cannot miss an active-work transition. Snapshot getSnapshot(const LLUUID& resident_id); diff --git a/indra/newview/llimview.cpp b/indra/newview/llimview.cpp index 666a243421..31d8c98419 100644 --- a/indra/newview/llimview.cpp +++ b/indra/newview/llimview.cpp @@ -2240,11 +2240,11 @@ void LLIMModel::sendMessage(const std::string& utf8_text, if((dialog == IM_NOTHING_SPECIAL) && (other_participant_id.notNull())) { - // The packet has been sent; direct outbound bursts share one quiet service - // refresh while first contact also starts bounded discovery immediately. + // The packet has been sent; incoming and outgoing IMs share one quiet + // service refresh while first contact starts bounded discovery immediately. if (session && session->isP2PSessionType()) { - LLChatServiceHistory::noteOutboundDirectMessage(other_participant_id); + LLChatServiceHistory::noteDirectMessageActivity(other_participant_id); } // Do we have to replace the /me's here? @@ -3535,7 +3535,8 @@ void LLIMMgr::addMessage( other_participant_id.notNull() && other_participant_id != gAgentID; if (incoming_direct) { - LLChatServiceHistory::prioritizeResident(other_participant_id, true); + // Incoming activity shares the outgoing quiet deadline before reconciliation. + LLChatServiceHistory::noteDirectMessageActivity(other_participant_id); } LLIMModel::instance().addMessage(new_session_id, message_display_name, display_id, msg, true, is_region_msg, timestamp, incoming_direct -- cgit v1.3 From c9cb3694cce949e7ab4638200646e91814c1cc7b Mon Sep 17 00:00:00 2001 From: pepper <3782201+rohvani@users.noreply.github.com> Date: Wed, 16 Sep 2026 12:08:40 -0400 Subject: Reconcile live IMs recorded shortly after delivery Allow ChatService timestamps up to five seconds after online receipt while retaining the existing 60-second delivery-delay limit. Forwarding can finish before the service records a message, so requiring receipt to follow created_at left a historical copy beside the live reply. Keep newest-first one-for-one matching and cover delayed recording, calendar boundaries, translations, and partial repeated-message histories. Validated with the Windows Release ChatService core target: six regression cases failed before the fix and all 38 cases pass afterward. --- indra/newview/llchatservicehistorycore.cpp | 7 +- indra/newview/llimview.cpp | 4 +- .../tests/llchatservicehistorycore_test.cpp | 108 +++++++++++++++++++-- 3 files changed, 104 insertions(+), 15 deletions(-) (limited to 'indra/newview/llimview.cpp') diff --git a/indra/newview/llchatservicehistorycore.cpp b/indra/newview/llchatservicehistorycore.cpp index e38bdea259..fc66b1e165 100644 --- a/indra/newview/llchatservicehistorycore.cpp +++ b/indra/newview/llchatservicehistorycore.cpp @@ -333,13 +333,14 @@ bool sameDirectHistoryOccurrence(const LLSD& history, const LLSD& timed) } const S32 service_timestamp = history["timestamp"].asInteger(); - // Online receipt can lag creation across minute, hour, or day boundaries. - // Use a rolling minute of delivery delay, including locally captured receipt times. + // Receipt and service recording use separate clocks and can finish in + // either order. Admit bounded service lead as well as delivery delay. if (timed["chat_service_time_source"].asString() == "receipt") { constexpr S64 MAX_DELIVERY_DELAY = 60; + constexpr S64 MAX_SERVICE_LEAD = 5; const S64 delay = static_cast(timestamp) - service_timestamp; - return delay >= 0 && delay <= MAX_DELIVERY_DELAY; + return delay >= -MAX_SERVICE_LEAD && delay <= MAX_DELIVERY_DELAY; } // Outgoing local echoes admit clock skew within their UTC minute. Saved diff --git a/indra/newview/llimview.cpp b/indra/newview/llimview.cpp index 31d8c98419..0bdb114c17 100644 --- a/indra/newview/llimview.cpp +++ b/indra/newview/llimview.cpp @@ -3528,8 +3528,8 @@ void LLIMMgr::addMessage( if (!LLMuteList::getInstance()->isMuted(other_participant_id, LLMute::flagTextChat) && !skip_message) { - // Online direct timestamps describe receipt after service creation; offline - // timestamps describe the saved send. Preserve that distinction through translation. + // Online direct timestamps describe receipt; offline timestamps describe + // the saved send. Preserve that distinction through translation. const bool incoming_direct = LLChatServiceHistory::isPersistedDirectDialog(dialog) && !is_region_msg && message_display_name != SYSTEM_FROM && other_participant_id.notNull() && other_participant_id != gAgentID; diff --git a/indra/newview/tests/llchatservicehistorycore_test.cpp b/indra/newview/tests/llchatservicehistorycore_test.cpp index b77aa97da9..d3d3548728 100644 --- a/indra/newview/tests/llchatservicehistorycore_test.cpp +++ b/indra/newview/tests/llchatservicehistorycore_test.cpp @@ -641,9 +641,10 @@ template<> template<> void object_t::test<25>() // Only online delivery admits this delay; offline send times retain their precision. delivered["chat_service_time_source"] = "sent"; ensure("offline send time stays exact", !sameDirectHistoryOccurrence(history, delivered)); - delivered["chat_service_time_source"] = "receipt"; delivered["timestamp"] = 1789060619; - ensure("delivery cannot precede service creation", !sameDirectHistoryOccurrence(history, delivered)); + ensure("offline send time rejects later service recording", !sameDirectHistoryOccurrence(history, delivered)); + delivered["chat_service_time_source"] = "receipt"; + ensure("online receipt can precede service recording", sameDirectHistoryOccurrence(history, delivered)); delivered["timestamp"] = 1789060681; ensure("delivery outside the rolling window stays distinct", !sameDirectHistoryOccurrence(history, delivered)); @@ -708,21 +709,24 @@ template<> template<> void object_t::test<27>() template<> template<> void object_t::test<28>() { - // Receipt windows use elapsed UTC seconds across hour and day boundaries. - for (const char* created : {"2026-09-10T12:59:59Z", "2026-09-10T23:59:59Z"}) + // Service lead and delivery delay use elapsed seconds across minute, hour, + // and day boundaries instead of comparing calendar minutes. + for (const char* created : {"2026-09-10T12:34:01Z", "2026-09-10T12:59:59Z", + "2026-09-10T13:00:01Z", "2026-09-10T23:59:59Z", + "2026-09-11T00:00:01Z"}) { LLSD history = serviceCopy(repeatedDelivery(0), FIRST); const S32 timestamp = S32(LLDate(created).secondsSinceEpoch()); history["timestamp"] = timestamp; LLSD delivered = repeatedDelivery(0); delivered["chat_service_time_source"] = "receipt"; - for (S32 delay : {0, 2, 5, 30, 60}) + for (S32 delay : {-5, -4, -2, -1, 0, 2, 5, 30, 60}) { delivered["timestamp"] = timestamp + delay; ensure("receipt inside the rolling window reconciles", filterDirectHistoryDuplicates({history}, {delivered}).empty()); } - for (S32 delay : {-1, 61}) + for (S32 delay : {-6, 61}) { delivered["timestamp"] = timestamp + delay; ensure_equals("receipt outside the rolling window remains", @@ -733,9 +737,11 @@ template<> template<> void object_t::test<28>() template<> template<> void object_t::test<29>() { - // Both sides can contain repeated bodies with different delays. Pair in time - // order so a later delivery does not consume an older delivery's only candidate. - for (const auto times : {std::array{0, 15, 5, 30}, {0, 15, 30, 75}, {0, 10, 10, 65}}) + // Both sides can contain repeated bodies with different delays or service + // lead. Newest-first pairing keeps a newer receipt from consuming an older + // receipt's only candidate, even when their timestamps match exactly. + for (const auto times : {std::array{0, 15, 5, 30}, {0, 15, 30, 75}, + {0, 10, 10, 65}, {65, 70, 60, 65}, {0, 6, -4, 2}}) { const LLSD first = serviceCopy(repeatedDelivery(times[0]), FIRST); const LLSD second = serviceCopy(repeatedDelivery(times[1]), SECOND); @@ -903,6 +909,8 @@ template<> template<> void object_t::test<33>() service["message"] = "123"; service["timestamp"] = S32(received - 1); ensure("translation delay does not affect service matching", sameDirectHistoryOccurrence(service, row)); + service["timestamp"] = S32(received + 1); + ensure("later service recording matches the captured receipt", sameDirectHistoryOccurrence(service, row)); } U32 timestamp = received - 86400; @@ -965,7 +973,7 @@ template<> template<> void object_t::test<35>() later["timestamp"] = 1789092446; const Messages archive{earlier, between, later}; - for (S32 delay : {0, 2, 15}) + for (S32 delay : {-5, -1, 0, 2, 15}) { History history; history.setLoaded({earlier, between}); @@ -1026,4 +1034,84 @@ template<> template<> void object_t::test<36>() } } +template<> template<> void object_t::test<37>() +{ + // Missing online wire timestamps are captured before the corresponding + // service rows are recorded. The two representations describe one reply. + Messages archive; + Messages live; + U8 ordinal = 0; + for (const char* body : {"30", "thnk you"}) + { + const U32 received = ordinal ? 1789489070 : 1789489012; + LLSD service = contextRow(++ordinal); + service["message"] = body; + service["timestamp"] = static_cast(received + 1); + archive.push_back(service); + + U32 timestamp = 0; + const LLSD context = captureLiveMessage(body, timestamp, incomingContext(body, true), received); + LLSD delivery = liveCopy(service); + delivery["timestamp"] = static_cast(timestamp); + recordLiveMessage(delivery, context, received); + live.push_front(delivery); + } + + // Preview and durable reads can arrive in either order. Repeated publication + // must keep both live replies without adding historical copies or replaying them. + for (bool loaded_first : {false, true}) + { + History history; + if (loaded_first) history.setLoaded(archive); + Messages current = live; + for (int publication = 0; publication < 3; ++publication) + { + const auto composed = history.compose(archive, live, 200, true); + ensure("recording after receipt does not add history copies", composed.empty()); + ensure("unchanged live replies do not need replay", !replaceHistory(current, composed, true)); + ensure_equals("both replies remain visible once", current.size(), size_t(2)); + history.setLoaded(archive); + } + + const auto durable = history.compose({}, live, 200, true); + ensure("durable read reconciles after preview expires", durable.empty()); + ensure("durable publication does not replay replies", !replaceHistory(current, durable, true)); + ensure_equals("newest reply retains delivery order", current.front()["message"].asString(), std::string("thnk you")); + ensure_equals("earliest reply retains delivery order", current.back()["message"].asString(), std::string("30")); + ensure("newest reply stays live", !current.front()["is_history"].asBoolean()); + ensure("earliest reply stays live", !current.back()["is_history"].asBoolean()); + } +} + +template<> template<> void object_t::test<38>() +{ + // With only the first delivery live, a later identical service message is a + // separate occurrence. Preserve its ID and the intervening message's position. + LLSD earlier = contextRow(1); + earlier["message"] = "hello"; + LLSD between = contextRow(2); + between["message"] = "6"; + between["timestamp"] = earlier["timestamp"].asInteger() + 15; + LLSD later = contextRow(3); + later["message"] = "hello"; + later["timestamp"] = earlier["timestamp"].asInteger() + 30; + LLSD delivery = liveCopy(earlier); + delivery["chat_service_time_source"] = "receipt"; + History history; + history.setLoaded({earlier, between, later}); + + const auto composed = history.compose({}, {delivery}, 200, true); + ensure_equals("one receipt consumes one occurrence", composed.size(), size_t(2)); + ensure_equals("later repeat keeps its service ID", composed.back()["chat_service_msg_id"].asString(), + later["chat_service_msg_id"].asString()); + Messages current{delivery}; + ensure("unseen service messages enter the transcript", replaceHistory(current, composed, true)); + ensure_equals("three real occurrences remain", current.size(), size_t(3)); + auto row = current.begin(); + ensure_equals("later repeat follows intervening message", (*row++)["chat_service_msg_id"].asString(), + later["chat_service_msg_id"].asString()); + ensure_equals("intervening message keeps its position", (*row++)["message"].asString(), std::string("6")); + ensure("earliest occurrence keeps its live delivery", !(*row)["is_history"].asBoolean()); +} + } -- cgit v1.3