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/llchatservicehistory.cpp | 398 +++++-------- indra/newview/llchatservicehistory.h | 15 +- indra/newview/llchatservicehistorycore.cpp | 371 +++++++++++- indra/newview/llchatservicehistorycore.h | 42 +- indra/newview/llfloaterconversationpreview.cpp | 28 +- indra/newview/llfloaterconversationpreview.h | 1 + indra/newview/llfloaterimsession.cpp | 26 +- indra/newview/llimprocessing.cpp | 8 +- indra/newview/llimview.cpp | 214 ++----- indra/newview/llimview.h | 15 +- indra/newview/llnotificationhandler.h | 9 +- indra/newview/llnotificationhandlerutil.cpp | 27 +- indra/newview/llnotificationofferhandler.cpp | 6 +- .../tests/llchatservicehistorycore_test.cpp | 640 +++++++++++++++++++++ 14 files changed, 1338 insertions(+), 462 deletions(-) diff --git a/indra/newview/llchatservicehistory.cpp b/indra/newview/llchatservicehistory.cpp index b73937b546..b55932bc30 100644 --- a/indra/newview/llchatservicehistory.cpp +++ b/indra/newview/llchatservicehistory.cpp @@ -26,6 +26,7 @@ #include "llfloaterconversationpreview.h" #include "llfloaterreg.h" #include "llfloaterimsessiontab.h" +#include "llfloaterimsession.h" #include "llimview.h" #include "lllogchat.h" #include "llmutelist.h" @@ -270,6 +271,22 @@ bool ownsRuntime(U32 epoch) return sRuntime.running && sRuntime.epoch == epoch; } +bool postPresentation(const LL::WorkQueue::Work& work) +{ + // Manager and name-cache coroutines may resume inside a yielding UI operation. + // Run presentation on the main-loop coroutine, after that operation unwinds. + const U32 epoch = sRuntime.epoch; + LL::WorkQueue::ptr_t main = LL::WorkQueue::getInstance("mainloop"); + return main && main->post([epoch, work]() + { + // Queued UI work belongs only to the login that scheduled it. + if (ownsRuntime(epoch)) + { + work(); + } + }); +} + std::string accountPath(const std::string& filename) { return gDirUtilp ? gDirUtilp->getExpandedFilename(LL_PATH_PER_SL_ACCOUNT, filename) @@ -343,7 +360,17 @@ void publishSnapshot(const LLUUID& id, Resident& resident) sRuntime.rollout && transcriptConsent() && sRuntime.state_safety == STATE_SAFE && !sRuntime.cleanup_pending && !sRuntime.delete_requested; - sSnapshotSignal(id, resident.snapshot); + postPresentation([id]() + { + // Deliver current state, not a captured preview that may have been revoked + // or replaced while this notification waited for the main loop. + auto found = sRuntime.residents.find(id); + if (found != sRuntime.residents.end()) + { + const auto snapshot = found->second.snapshot; + sSnapshotSignal(id, snapshot); + } + }); } void setWorkActive(const LLUUID& id, bool active) @@ -1151,8 +1178,7 @@ U32 pendingMetadataCount() bool ensureMetadata(const LLUUID& id, Resident& resident) { - // A resolved record is stable for this login. Republishing it from a view load - // would synchronously re-enter that load through the resident snapshot signal. + // A resolved record is stable for this login; view loads need no new publication. if (resident.metadata.state == META_RESOLVED) { return true; @@ -1888,16 +1914,22 @@ void syncResident(const LLUUID& id, const CapabilityContext& context, U32 epoch) const F64 archived_seconds = after_publish.summary.newest.ticks >= UUID_EPOCH ? static_cast(after_publish.summary.newest.ticks - UUID_EPOCH) / 10000000.0 : static_cast(time_corrected()); - const LLAvatarName& name = after_publish.metadata.name; - LLConversationLog::instance().addServiceConversation( - LLIMMgr::computeSessionID(IM_NOTHING_SPECIAL, id), - name.getCompleteName(), - LLCacheName::buildUsername(name.getUserName()), - id, - U64Seconds(LLUnits::Seconds::fromValue(archived_seconds))); + const LLAvatarName name = after_publish.metadata.name; + postPresentation([id, name, archived_seconds]() + { + if (!LLChatServiceHistory::historySuppressed()) + { + LLConversationLog::instance().addServiceConversation( + LLIMMgr::computeSessionID(IM_NOTHING_SPECIAL, id), + name.getCompleteName(), + LLCacheName::buildUsername(name.getUserName()), + id, + U64Seconds(LLUnits::Seconds::fromValue(archived_seconds))); + } + }); } - LLLogChat::notifyTranscriptCreated(); + postPresentation(LLLogChat::notifyTranscriptCreated); } else { @@ -1907,7 +1939,7 @@ void syncResident(const LLUUID& id, const CapabilityContext& context, U32 epoch) after_publish.covered_token.clear(); sRuntime.index_dirty = true; sRuntime.local_content_exists = true; - LLLogChat::notifyTranscriptCreated(); + postPresentation(LLLogChat::notifyTranscriptCreated); publication_failed = true; } } @@ -2021,14 +2053,20 @@ void finishDelete(bool success) else { sRuntime.delete_requested = true; - LLNotificationsUtil::add("ChatServiceHistoryDeleteFailed"); } - if (callback) + postPresentation([success, callback]() { - callback(success); - } - LLLogChat::notifyTranscriptCreated(); + if (!success) + { + LLNotificationsUtil::add("ChatServiceHistoryDeleteFailed"); + } + if (callback) + { + callback(success); + } + LLLogChat::notifyTranscriptCreated(); + }); } void runDelete(U32 epoch) @@ -2072,24 +2110,45 @@ void runDelete(U32 epoch) publishSnapshot(pair.first, pair.second); } - for (auto& pair : LLIMModel::instance().mId2SessionMap) + // Await invalidation on the main loop before sweeping files or accepting new + // history. A late clear must not discard messages received after deletion. + LL::WorkQueue::ptr_t main = LL::WorkQueue::getInstance("mainloop"); + const bool cleared = main && main->waitForResult([epoch]() { - pair.second->clearForHistoryDeletion(); - } + if (!ownsRuntime(epoch)) + { + return false; + } - const LLFloaterReg::const_instance_list_t& previews = - LLFloaterReg::getFloaterList("preview_conversation"); - for (LLFloater* floater : previews) - { - if (LLFloaterConversationPreview* preview = - dynamic_cast(floater)) + for (auto& pair : LLIMModel::instance().mId2SessionMap) { - preview->invalidateHistory(); - preview->closeFloater(); + pair.second->clearForHistoryDeletion(); } - } - LLFloaterIMSessionTab::processChatHistoryStyleUpdate(true); + const LLFloaterReg::const_instance_list_t& previews = + LLFloaterReg::getFloaterList("preview_conversation"); + for (LLFloater* floater : previews) + { + if (LLFloaterConversationPreview* preview = + dynamic_cast(floater)) + { + preview->invalidateHistory(); + preview->closeFloater(); + } + } + + LLFloaterIMSessionTab::processChatHistoryStyleUpdate(true); + return true; + }); + if (!ownsRuntime(epoch)) + { + return; + } + if (!cleared) + { + finishDelete(false); + return; + } // Sweep legacy transcripts first, then service-owned artifacts, through the // shared filesystem mutation boundaries on the General queue. @@ -2225,7 +2284,7 @@ bool prepareOneArchive(U32 epoch) ensureMetadata(id, current); } publishSnapshot(id, current); - LLLogChat::notifyTranscriptCreated(); + postPresentation(LLLogChat::notifyTranscriptCreated); } return prepared; } @@ -2452,11 +2511,14 @@ void manager(U32 epoch) sRuntime.state_safety = state.safety; sRuntime.deleted_before_ticks = state.boundary; sRuntime.cleanup_pending = state.cleanup_pending; - LLLogChat::notifyTranscriptCreated(); - if (state.safety == STATE_SAFE && !state.cleanup_pending) + postPresentation([]() { - LLFloaterIMSessionTab::processChatHistoryStyleUpdate(true); - } + LLLogChat::notifyTranscriptCreated(); + if (!LLChatServiceHistory::historySuppressed()) + { + LLFloaterIMSessionTab::processChatHistoryStyleUpdate(true); + } + }); if (state.safety == STATE_SAFE && state.cleanup_pending) { sRuntime.delete_active = true; @@ -2691,75 +2753,6 @@ bool legacyWallEpoch(const std::string& text, F64& epoch) } } -bool sameSenderAndText(const LLSD& left, const LLSD& right) -{ - if (left[LL_IM_TEXT].asString() != right[LL_IM_TEXT].asString()) - { - return false; - } - - // Prefer exact resident identity when both sources carry it; otherwise compare - // their canonical resident usernames. - const bool left_has_id = left[LL_IM_FROM_ID].isDefined(); - const bool right_has_id = right[LL_IM_FROM_ID].isDefined(); - return left_has_id && right_has_id - ? left[LL_IM_FROM_ID].asUUID() == right[LL_IM_FROM_ID].asUUID() - : sameDirectSenderName(left[LL_IM_FROM].asString(), - right[LL_IM_FROM].asString()); -} - -bool sameSenderAndText(const LLSD& legacy, const Row& service) -{ - if (legacy[LL_IM_TEXT].asString() != service.message) - { - return false; - } - - return legacy[LL_IM_FROM_ID].isDefined() - ? legacy[LL_IM_FROM_ID].asUUID() == service.from_id - : sameDirectSenderName(legacy[LL_IM_FROM].asString(), service.from_name); -} - -bool sameLegacyMinute(F64 wall_epoch, F64 utc_epoch) -{ - // Legacy transcript minutes are SLT but do not encode whether UTC-7 or UTC-8 - // applied. Either exact minute may identify the authoritative service row. - for (const F64 offset : { 7.0 * 3600.0, 8.0 * 3600.0 }) - { - const F64 minute = wall_epoch + offset; - if (utc_epoch >= minute && utc_epoch < minute + 60.0) - { - return true; - } - } - return false; -} - -S64 utcMinute(F64 epoch) -{ - return static_cast(epoch) / 60; -} - -bool sameHistoryLiveOccurrence(const LLSD& history, const LLSD& live) -{ - if (!sameSenderAndText(history, live) || - !live["timestamp"].isInteger() || live["timestamp"].asInteger() <= 0) - { - return false; - } - - const F64 live_epoch = static_cast(live["timestamp"].asInteger()); - if (history["chat_service_msg_id"].isString() && - history["timestamp"].isInteger() && history["timestamp"].asInteger() > 0) - { - const U32 history_epoch = static_cast(history["timestamp"].asInteger()); - return history_epoch / 60 == static_cast(live_epoch) / 60; - } - - return history[LEGACY_WALL_TIME].isReal() && - sameLegacyMinute(history[LEGACY_WALL_TIME].asReal(), live_epoch); -} - LLSD serviceMessage(const Row& row) { LLSD message; @@ -2781,7 +2774,7 @@ LLSD serviceMessage(const Row& row) LLChatServiceHistory::HistoryResult readStitched( const LLUUID& id, const LLUUID& agent_id, const std::string& archive_path, const std::vector& legacy_paths, U32 limit, U32 epoch, U32 serial, - U64 boundary, bool include_service, std::vector preview) + U64 boundary, bool include_service, const std::vector& preview) { LLChatServiceHistory::HistoryResult result; result.account_epoch = epoch; @@ -2813,40 +2806,8 @@ LLChatServiceHistory::HistoryResult readStitched( LLChatServiceHistoryAccess::loadLegacy(path, legacy, parameters); } - // Canonical rows win exact identity over preview rows, then all service rows - // share one stable TimeUUID order for seam reconciliation and final display. - std::set canonical_ids; - std::vector service = archive.display_rows; - for (const Row& row : service) - { - canonical_ids.insert(row.msg_id); - } - for (const Row& row : preview) - { - if (!canonical_ids.count(row.msg_id)) - { - service.push_back(row); - } - } - std::sort(service.begin(), service.end(), [](const Row& left, const Row& right) - { - return left.key < right.key; - }); - std::vector service_placed(service.size(), false); - std::multimap canonical_minutes; - for (size_t pos = 0; pos < service.size(); ++pos) - { - if (canonical_ids.count(service[pos].msg_id)) - { - canonical_minutes.emplace( - utcMinute(LLDate(service[pos].created_at).secondsSinceEpoch()), pos); - } - } - const F64 service_epoch = archive.has_oldest ? static_cast(archive.oldest.ticks - UUID_EPOCH) / 10000000.0 : 0.0; - std::vector> dated; - std::list undated; // Admit only the legacy prefix that may predate the durable service boundary. // Offset-less SLT timestamps use their earliest UTC interpretation (UTC-7). @@ -2855,65 +2816,26 @@ LLChatServiceHistory::HistoryResult readStitched( F64 wall = 0.0; if (!legacyWallEpoch(message[LL_IM_DATE_TIME].asString(), wall)) { - undated.push_back(message); + result.messages.push_back(message); } else if (!archive.has_oldest || legacyWallMayPrecedeService(wall, service_epoch)) { LLSD stitched = message; stitched[LEGACY_WALL_TIME] = wall; - - // Reconcile exact overlaps only inside the admitted legacy prefix, - // consuming one occurrence from each source. - size_t match = service.size(); - for (const F64 offset : { 7.0 * 3600.0, 8.0 * 3600.0 }) - { - const auto range = canonical_minutes.equal_range(utcMinute(wall + offset)); - for (auto candidate = range.first; candidate != range.second; ++candidate) - { - const size_t pos = candidate->second; - if (!service_placed[pos] && sameSenderAndText(message, service[pos])) - { - match = pos; - break; - } - } - if (match != service.size()) - { - break; - } - } - if (match != service.size()) - { - stitched = serviceMessage(service[match]); - service_placed[match] = true; - } - dated.emplace_back(wall, stitched); + result.messages.push_back(stitched); } } - std::stable_sort(dated.begin(), dated.end(), - [](const auto& left, const auto& right) - { - return left.first < right.first; - }); - - for (const auto& item : dated) + // Storage reads and preview publications share the same occurrence-aware seam. + std::list service; + for (const Row& row : archive.display_rows) { - result.messages.push_back(item.second); + service.push_back(serviceMessage(row)); } - - for (const LLSD& message : undated) - { - result.messages.push_back(message); - } - - // Append service rows that did not replace their exact legacy occurrence. - for (size_t pos = 0; pos < service.size(); ++pos) + for (const Row& row : preview) { - if (!service_placed[pos]) - { - result.messages.push_back(serviceMessage(service[pos])); - } + service.push_back(serviceMessage(row)); } + result.messages = mergeDirectHistory(result.messages, service); // Apply the consumer limit to the complete seam order, retaining the service tail. while (limit && result.messages.size() > limit) @@ -2983,7 +2905,10 @@ void LLChatServiceHistory::start() .getControl("LogShowHistory")->getSignal()->connect( [](LLControlVariable*, const LLSD&, const LLSD&) { - LLFloaterIMSessionTab::processChatHistoryStyleUpdate(true); + postPresentation([]() + { + LLFloaterIMSessionTab::processChatHistoryStyleUpdate(true); + }); }); } @@ -3189,79 +3114,72 @@ boost::signals2::connection LLChatServiceHistory::setSnapshotChanged( return sSnapshotSignal.connect(callback); } -std::list LLChatServiceHistory::filterLiveDuplicates( - const std::list& history, const std::list& live) +LLChatServiceHistory::Messages LLChatServiceHistory::composeHistory( + History& history, const Snapshot& snapshot, U32 limit, const LLUUID& session_id) { - std::vector live_rows; - for (const LLSD& message : live) + Messages preview; + if (snapshot.service_presentation_allowed) + { + for (const Row& row : snapshot.head_preview) + { + preview.push_back(serviceMessage(row)); + } + } + else { - live_rows.push_back(&message); + history.clearService(); } - std::vector consumed(live_rows.size(), false); - // Matching is occurrence-aware: repeated identical messages consume repeated - // rows one-for-one instead of collapsing to one value. - std::list filtered; - for (const LLSD& message : history) + // Preview has no live rows. An IM session supplies only its append-only deliveries. + Messages live; + LLFloaterIMSession* floater = nullptr; + if (session_id.notNull()) { - bool duplicate = false; - for (size_t pos = 0; pos < live_rows.size(); ++pos) + floater = LLFloaterIMSession::findInstance(session_id); + if (const auto* session = LLIMModel::instance().findIMSession(session_id)) { - if (!consumed[pos] && sameHistoryLiveOccurrence(message, *live_rows[pos])) + for (const LLSD& message : session->mMsgs) { - consumed[pos] = true; - duplicate = true; - break; + if (!message["is_history"].asBoolean()) + { + live.push_back(message); + } } } - if (!duplicate) - { - filtered.push_back(message); - } } - return filtered; + return history.compose(preview, live, limit, floater && floater->isInVisibleChain()); } -std::list LLChatServiceHistory::mergeHeadPreview( - const std::list& loaded, const Snapshot& snapshot, U32 limit) +bool LLChatServiceHistory::replaceHistory(Messages& current, const Messages& history, bool direct) { - // Deduplicate transient preview rows against the loaded archive by exact message - // ID, then apply the consumer's ordinary newest-row limit. - std::list result = loaded; - std::set ids; - for (const LLSD& message : result) + Messages resolved = history; + // Legacy names can acquire a UUID from local caches without starting a lookup. + // Leave unknown IDs absent so reconciliation can still use the resident name. + for (LLSD& message : resolved) { - if (message["chat_service_msg_id"].isString()) + const LLSD& source = message; + if (!source[LL_IM_FROM_ID].isDefined()) { - ids.insert(message["chat_service_msg_id"].asString()); - } - } - - std::vector additions; - for (const Row& row : snapshot.head_preview) - { - if (!ids.count(row.msg_id)) - { - additions.push_back(row); + const LLUUID id = LLAvatarNameCache::getInstance()->findIdByName( + LLCacheName::buildLegacyName(source[LL_IM_FROM].asString())); + if (id.notNull()) + { + message[LL_IM_FROM_ID] = id; + } } } + return LLChatServiceHistoryCore::replaceHistory(current, resolved, direct); +} - std::sort(additions.begin(), additions.end(), [](const Row& left, const Row& right) - { - return left.key < right.key; - }); - - for (const Row& row : additions) - { - result.push_back(serviceMessage(row)); - } - - while (limit && result.size() > limit) - { - result.pop_front(); - } +LLSD LLChatServiceHistory::prepareLiveMessage( + const std::string& text, U32& timestamp, const LLSD& context) +{ + return captureLiveMessage(text, timestamp, context, static_cast(time_corrected())); +} - return result; +void LLChatServiceHistory::recordLiveMessage(LLSD& message, const LLSD& context) +{ + LLChatServiceHistoryCore::recordLiveMessage(message, context, static_cast(time_corrected())); } bool LLChatServiceHistory::loadStitchedHistory( diff --git a/indra/newview/llchatservicehistory.h b/indra/newview/llchatservicehistory.h index 62564d52dd..2d647fa16f 100644 --- a/indra/newview/llchatservicehistory.h +++ b/indra/newview/llchatservicehistory.h @@ -84,12 +84,17 @@ namespace LLChatServiceHistory // Views connect first and then query so they cannot miss an active-work transition. Snapshot getSnapshot(const LLUUID& resident_id); boost::signals2::connection setSnapshotChanged(const snapshot_callback_t& callback); - std::list mergeHeadPreview(const std::list& loaded, - const Snapshot& snapshot, U32 limit); + using History = LLChatServiceHistoryCore::History; + using Messages = LLChatServiceHistoryCore::Messages; - // Remove one historical occurrence for each exact same-minute live occurrence. - std::list filterLiveDuplicates(const std::list& history, - const std::list& live); + // Both views compose from their loaded history; open IMs also retain visible context. + Messages composeHistory(History& history, const Snapshot& snapshot, U32 limit, + const LLUUID& session_id = LLUUID::null); + bool replaceHistory(Messages& current, const Messages& history, bool direct); + + // Core IM delivery carries one opaque context through translation and logging. + LLSD prepareLiveMessage(const std::string& text, U32& timestamp, const LLSD& context); + void recordLiveMessage(LLSD& message, const LLSD& context); // The callback runs on the main queue after legacy and service storage are read. bool loadStitchedHistory(const LLUUID& resident_id, const std::string& legacy_stem, diff --git a/indra/newview/llchatservicehistorycore.cpp b/indra/newview/llchatservicehistorycore.cpp index 06fdd8ecf6..44fe43b608 100644 --- a/indra/newview/llchatservicehistorycore.cpp +++ b/indra/newview/llchatservicehistorycore.cpp @@ -1,6 +1,6 @@ /** * @file llchatservicehistorycore.cpp - * @brief Strict ChatService wire, TimeUUID, and CSV primitives. + * @brief ChatService wire/storage primitives and direct history composition. * * $LicenseInfo:firstyear=2026&license=viewerlgpl$ * Second Life Viewer Source Code @@ -15,6 +15,7 @@ #include "lldate.h" #include "llfile.h" #include "llstring.h" +#include "llsdutil.h" #include "fsyspath.h" #if LL_WINDOWS @@ -27,6 +28,7 @@ #include #include #include +#include #include namespace LLChatServiceHistoryCore @@ -286,15 +288,370 @@ bool persistedDirectDialog(S32 dialog) bool sameDirectSenderName(const std::string& left, const std::string& right) { - if (left == right) + // Extract the resident name from complete transcript names before normalizing + // legacy and dotted forms; the display-name prefix is presentation only. + const std::string left_resident = + LLCacheName::buildUsername(LLCacheName::buildLegacyName(left)); + const std::string right_resident = + LLCacheName::buildUsername(LLCacheName::buildLegacyName(right)); + return !left_resident.empty() && left_resident == right_resident; +} + +bool sameDirectHistoryOccurrence(const LLSD& history, const LLSD& timed) +{ + if (!timed["timestamp"].isInteger() || timed["timestamp"].asInteger() <= 0) { - return true; + return false; } - // Compare resident usernames even when the chat service supplies a legacy name. - const std::string left_resident = LLCacheName::buildUsername(left); - const std::string right_resident = LLCacheName::buildUsername(right); - return !left_resident.empty() && left_resident == right_resident; + // Service bodies omit offline notices and translations. Plaintext contains + // the rendered body, so compare it against the same displayed value. + const bool service = history["chat_service_msg_id"].isString(); + const LLSD& text = service && timed["chat_service_original_text"].isString() + ? timed["chat_service_original_text"] : timed["message"]; + if (history["message"].asString() != text.asString()) + { + return false; + } + + // UUIDs are authoritative when available; plaintext otherwise identifies + // its sender by the resident username, including complete-name spellings. + const bool same_sender = history["from_id"].isDefined() && timed["from_id"].isDefined() + ? history["from_id"].asUUID() == timed["from_id"].asUUID() + : sameDirectSenderName(history["from"].asString(), timed["from"].asString()); + if (!same_sender) + { + return false; + } + + const S32 timestamp = timed["timestamp"].asInteger(); + if (service) + { + if (!history["timestamp"].isInteger() || history["timestamp"].asInteger() <= 0) + { + return false; + } + + 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. + if (timed["chat_service_time_source"].asString() == "receipt") + { + constexpr S64 MAX_DELIVERY_DELAY = 60; + const S64 delay = static_cast(timestamp) - service_timestamp; + return delay >= 0 && delay <= MAX_DELIVERY_DELAY; + } + + // Outgoing local echoes admit clock skew within their UTC minute. Saved + // offline send timestamps and unmarked callers retain exact seconds. + if (timed["chat_service_time_source"].asString() == "local") + { + return service_timestamp / 60 == timestamp / 60; + } + + return service_timestamp == timestamp; + } + + // Offline IMs are logged when delivered, potentially days after being sent. + // Legacy SLT minutes admit either UTC-7 or UTC-8 because no offset is stored. + if (!history["chat_service_legacy_wall_time"].isReal()) + { + return false; + } + const S32 logged = timed["chat_service_log_timestamp"].isInteger() + ? timed["chat_service_log_timestamp"].asInteger() : timestamp; + for (const F64 offset : { 7.0 * 3600.0, 8.0 * 3600.0 }) + { + const F64 minute = history["chat_service_legacy_wall_time"].asReal() + offset; + if (logged >= minute && logged < minute + 60.0) + { + return true; + } + } + return false; +} + +std::list filterDirectHistoryDuplicates( + const std::list& history, const std::list& live) +{ + // Reserve known send times before receipt intervals and local-clock minutes. + // Within each kind, the earliest delivery takes the earliest eligible occurrence. + auto priority = [](const LLSD& message) + { + const std::string source = message["chat_service_time_source"].asString(); + return source == "receipt" ? 1 : source == "local" ? 2 : 0; + }; + std::vector deliveries; + for (const LLSD& message : live) deliveries.push_back(&message); + std::stable_sort(deliveries.begin(), deliveries.end(), [&](const LLSD* a, const LLSD* b) + { + return priority(*a) != priority(*b) ? priority(*a) < priority(*b) + : (*a)["timestamp"].asInteger() < (*b)["timestamp"].asInteger(); + }); + + // Sorted iterators allow matching in time order while retaining the source order. + Messages result = history; + std::vector service; + for (auto row = result.cbegin(); row != result.cend(); ++row) + { + if ((*row)["chat_service_msg_id"].isString()) service.push_back(row); + } + std::stable_sort(service.begin(), service.end(), [](auto a, auto b) + { + return (*a)["timestamp"].asInteger() < (*b)["timestamp"].asInteger(); + }); + std::vector matched(deliveries.size(), false); + for (size_t pos = 0; pos < deliveries.size(); ++pos) + { + const auto match = std::find_if(service.begin(), service.end(), [&](auto row) + { + return row != result.cend() && sameDirectHistoryOccurrence(*row, *deliveries[pos]); + }); + if (match != service.end()) + { + result.erase(*match); + *match = result.cend(); + matched[pos] = true; + } + } + + // Plaintext uses the remaining occurrences. Saved/translated deliveries can + // also identify a decorated log copy that could not match the raw service body. + for (size_t pos = 0; pos < deliveries.size(); ++pos) + { + const LLSD& delivery = *deliveries[pos]; + if (matched[pos] && !(delivery["chat_service_original_text"].isString() && + delivery["chat_service_original_text"].asString() != delivery["message"].asString())) + { + continue; + } + const auto match = std::find_if(result.begin(), result.end(), [&](const LLSD& row) + { + return !row["chat_service_msg_id"].isString() && sameDirectHistoryOccurrence(row, delivery); + }); + if (match != result.end()) result.erase(match); + } + return result; +} + +std::list interleaveDirectHistory(const std::list& history, + const std::list& live) +{ + std::list result = live; + auto position = result.begin(); + + // A service head may contain messages sent after live delivery began. Insert + // them by UTC time while retaining both sources' order and untimed live anchors. + for (const LLSD& message : history) + { + const S32 timestamp = message["timestamp"].asInteger(); + while (position != result.end() && + (timestamp <= 0 || (*position)["timestamp"].asInteger() <= 0 || + (*position)["timestamp"].asInteger() >= timestamp)) + { + ++position; + } + result.insert(position, message); + } + return result; +} + +Messages mergeDirectHistory(const Messages& loaded, const Messages& incoming) +{ + // Each input is a seam already reconciled against its own service IDs. + // Reconcile only IDs missing from that input, then union legacy occurrences. + struct Seam + { + Messages legacy; + std::map service; + explicit Seam(const Messages& messages) + { + for (const LLSD& row : messages) + { + TimeUuidKey key; + if (parseTimeUuid(row["chat_service_msg_id"].asString(), key)) + service.emplace(key, row); + else + legacy.push_back(row); + } + } + } left(loaded), right(incoming); + + auto reconcile = [](Seam& seam, const Seam& other) + { + // Index minutes so full transcript reads do not compare every pair. + std::multimap added; + for (const auto& entry : other.service) + { + if (!seam.service.count(entry.first)) + added.emplace(entry.second["timestamp"].asInteger() / 60, &entry.second); + } + seam.legacy.remove_if([&](const LLSD& row) + { + if (!row["chat_service_legacy_wall_time"].isReal()) return false; + for (S64 offset : {7 * 3600, 8 * 3600}) + { + const S64 minute = (static_cast(row["chat_service_legacy_wall_time"].asReal()) + offset) / 60; + const auto range = added.equal_range(minute); + for (auto candidate = range.first; candidate != range.second; ++candidate) + { + if (sameDirectHistoryOccurrence(row, *candidate->second)) + { + added.erase(candidate); + return true; + } + } + } + return false; + }); + }; + reconcile(left, right); + reconcile(right, left); + + // Shared plaintext occurrences count once; distinct repeated lines retain + // the greater occurrence count from the two bounded source windows. + Messages unmatched = left.legacy; + for (const LLSD& row : right.legacy) + { + const auto match = std::find_if(unmatched.begin(), unmatched.end(), + [&](const LLSD& candidate) { return llsd_equals(candidate, row); }); + if (match == unmatched.end()) left.legacy.push_back(row); + else unmatched.erase(match); + } + left.legacy.sort([](const LLSD& a, const LLSD& b) + { + const LLSD& at = a["chat_service_legacy_wall_time"]; + const LLSD& bt = b["chat_service_legacy_wall_time"]; + return at.isReal() && (!bt.isReal() || at.asReal() < bt.asReal()); + }); + + // Loaded canonical values win preview identity; all service rows use TimeUUID order. + left.service.insert(right.service.begin(), right.service.end()); + for (const auto& entry : left.service) left.legacy.push_back(entry.second); + return left.legacy; +} + +void History::setLoaded(const Messages& messages) +{ + mLoaded = messages; + if (messages.empty()) + { + mVisible.clear(); + } +} + +void History::clear() +{ + mLoaded.clear(); + mVisible.clear(); +} + +void History::clearService() +{ + auto service = [](const LLSD& row) { return row["chat_service_msg_id"].isString(); }; + mLoaded.remove_if(service); + mVisible.remove_if(service); +} + +Messages History::compose(const Messages& preview, const Messages& live, U32 limit, + bool retain_context) +{ + // Retain bounded visible context while open, including plaintext that has not + // acquired a service ID. Compose before filtering so live copies cannot evict it. + Messages result = mergeDirectHistory(mLoaded, preview); + if (retain_context) + { + result = mergeDirectHistory(result, mVisible); + } + result = filterDirectHistoryDuplicates(result, live); + while (limit && result.size() > limit) + { + result.pop_front(); + } + mVisible = retain_context ? result : Messages(); + return result; +} + +bool replaceHistory(Messages& current, const Messages& history, bool direct) +{ + Messages live; + for (const LLSD& message : current) + { + if (!message["is_history"].asBoolean()) + { + live.push_back(message); + } + } + Messages historical; + for (const LLSD& source : history) + { + LLSD message = source; + message["timestamp"] = source["timestamp"].asInteger(); + message["is_history"] = true; + message["is_region_msg"] = false; + historical.push_front(message); + } + if (direct) + { + live = interleaveDirectHistory(historical, live); + } + else + { + live.insert(live.end(), historical.begin(), historical.end()); + } + + // Index changes alone do not require replaying inline notification widgets. + if (current.size() == live.size() && + std::equal(current.begin(), current.end(), live.begin(), [](LLSD left, LLSD right) + { + left.erase("index"); + right.erase("index"); + return llsd_equals(left, right); + })) + { + return false; + } + current.swap(live); + S32 index = static_cast(current.size()); + for (LLSD& message : current) + { + message["index"] = --index; + } + return true; +} + +LLSD incomingContext(const LLSD& original_text, bool online) +{ + LLSD context; + context["chat_service_original_text"] = original_text; + context["chat_service_time_source"] = online ? "receipt" : "sent"; + return context; +} + +LLSD captureLiveMessage(const std::string& text, U32& timestamp, const LLSD& context, U32 now) +{ + LLSD captured = context; + if (!context["chat_service_original_text"].isString()) + { + captured["chat_service_original_text"] = text; + } + if (!context["chat_service_time_source"].isString() || + (!timestamp && context["chat_service_time_source"].asString() != "receipt")) + { + captured["chat_service_time_source"] = timestamp ? "sent" : "local"; + } + if (!timestamp) + { + timestamp = now; + } + return captured; +} + +void recordLiveMessage(LLSD& message, const LLSD& context, U32 logged_at) +{ + message["chat_service_original_text"] = context["chat_service_original_text"]; + message["chat_service_time_source"] = context["chat_service_time_source"]; + message["chat_service_log_timestamp"] = static_cast(logged_at); } bool legacyWallMayPrecedeService(F64 wall_epoch, F64 service_epoch) diff --git a/indra/newview/llchatservicehistorycore.h b/indra/newview/llchatservicehistorycore.h index 9b4254eca6..ebf47e809e 100644 --- a/indra/newview/llchatservicehistorycore.h +++ b/indra/newview/llchatservicehistorycore.h @@ -1,6 +1,6 @@ /** * @file llchatservicehistorycore.h - * @brief Strict ChatService wire, TimeUUID, and CSV primitives. + * @brief ChatService wire/storage primitives and direct history composition. * * $LicenseInfo:firstyear=2026&license=viewerlgpl$ * Second Life Viewer Source Code @@ -16,6 +16,7 @@ #include #include #include +#include #include #include @@ -110,6 +111,45 @@ namespace LLChatServiceHistoryCore bool parseCreatedAt(const std::string& text, std::string& normalized); bool sameDirectSenderName(const std::string& left, const std::string& right); + // Compare service rows against original bodies and plaintext against logged bodies/times. + bool sameDirectHistoryOccurrence(const LLSD& history, const LLSD& timed); + + // Reserve known send times before receipt intervals and local-clock minute matches. + std::list filterDirectHistoryDuplicates(const std::list& history, + const std::list& live); + + using Messages = std::list; + + // Union historical seams by service ID and plaintext occurrence, consuming overlaps one-for-one. + Messages mergeDirectHistory(const Messages& loaded, const Messages& service); + + // Preview publications start from the loaded value; visible context is retained separately. + class History + { + public: + void setLoaded(const Messages& messages); + void clear(); + void clearService(); + Messages compose(const Messages& preview, const Messages& live, U32 limit, + bool retain_context = false); + + private: + Messages mLoaded; + Messages mVisible; + }; + + // Preserve live order and notification rows; replace history and reindex only on change. + bool replaceHistory(Messages& current, const Messages& history, bool direct); + + // Capture timing before translation and keep its provenance with the original wire body. + LLSD incomingContext(const LLSD& original_text, bool online); + LLSD captureLiveMessage(const std::string& text, U32& timestamp, const LLSD& context, U32 now); + void recordLiveMessage(LLSD& message, const LLSD& context, U32 logged_at); + + // Both inputs are newest-first; interleave timed history without reordering live rows. + std::list interleaveDirectHistory(const std::list& history, + const std::list& live); + // Legacy SLT wall times carry no UTC offset; UTC-7 is their earliest possible // interpretation at the durable service boundary. bool legacyWallMayPrecedeService(F64 wall_epoch, F64 service_epoch); diff --git a/indra/newview/llfloaterconversationpreview.cpp b/indra/newview/llfloaterconversationpreview.cpp index 767bf95b7f..f46d28342f 100644 --- a/indra/newview/llfloaterconversationpreview.cpp +++ b/indra/newview/llfloaterconversationpreview.cpp @@ -208,6 +208,7 @@ void LLFloaterConversationPreview::onOpen(const LLSD& key) mOpened = true; ++mServiceToken; + mServiceHistory.clear(); mPageSpinner = getChild("history_page_spin"); mPageSpinner->setCommitCallback( boost::bind(&LLFloaterConversationPreview::onMoreHistoryBtnClick, this)); @@ -347,6 +348,7 @@ void LLFloaterConversationPreview::onClose(bool app_quitting) // Close is a lifecycle fence for callbacks, not a request to cancel shared service work. mOpened = false; ++mServiceToken; + mServiceHistory.clear(); mServiceLocalLoading = false; mServiceReloadPending = false; mHistoryContentConnection.disconnect(); @@ -371,6 +373,7 @@ void LLFloaterConversationPreview::invalidateHistory() // Advancing the token invalidates every outstanding legacy or stitched completion // before deletion clears the visible backing lists. ++mServiceToken; + mServiceHistory.clear(); mServiceLocalLoading = false; mServiceReloadPending = false; @@ -454,8 +457,9 @@ void LLFloaterConversationPreview::onServiceLoaded( // Merge the bounded transient head only after the durable read matches the // current snapshot, then preserve the reader's relative page position. - std::list merged = LLChatServiceHistory::mergeHeadPreview( - result.messages, snapshot, 10000); + mServiceHistory.setLoaded(result.messages); + std::list merged = LLChatServiceHistory::composeHistory( + mServiceHistory, snapshot, 10000); setPages(new std::list(merged), mChatHistoryFileName); if (reload_pending) @@ -482,20 +486,6 @@ void LLFloaterConversationPreview::onServiceSnapshot( { mServiceReloadPending = true; } - if (presentation_changed && !snapshot.service_presentation_allowed && mMessages) - { - std::list* legacy_only = new std::list(); - for (const LLSD& message : *mMessages) - { - if (!message["chat_service_msg_id"].isString()) - { - legacy_only->push_back(message); - } - } - - setPages(legacy_only, mChatHistoryFileName); - } - // A CSV-only cold-cache Preview performs one extra read when shared metadata // supplies the legacy transcript stem. if (mChatHistoryFileName.empty() && !mServiceNameReloaded && @@ -515,10 +505,10 @@ void LLFloaterConversationPreview::onServiceSnapshot( // The first validated service page may update the open Preview before archive // preparation and older paging complete. - if (snapshot.service_presentation_allowed && !snapshot.head_preview.empty()) + if (presentation_changed || !snapshot.head_preview.empty()) { - std::list merged = LLChatServiceHistory::mergeHeadPreview( - mMessages ? *mMessages : std::list(), snapshot, 10000); + std::list merged = LLChatServiceHistory::composeHistory( + mServiceHistory, snapshot, 10000); setPages(new std::list(merged), mChatHistoryFileName); } diff --git a/indra/newview/llfloaterconversationpreview.h b/indra/newview/llfloaterconversationpreview.h index 4c03095e94..c36dae564c 100644 --- a/indra/newview/llfloaterconversationpreview.h +++ b/indra/newview/llfloaterconversationpreview.h @@ -89,6 +89,7 @@ private: // Tokens fence asynchronous applies across close, reopen, and reload requests. U64 mServiceToken; U32 mServiceAppliedSerial; + LLChatServiceHistory::History mServiceHistory; boost::signals2::connection mHistoryContentConnection; boost::signals2::connection mServiceSnapshotConnection; }; diff --git a/indra/newview/llfloaterimsession.cpp b/indra/newview/llfloaterimsession.cpp index 64c0354ddd..4b9e63ae5a 100644 --- a/indra/newview/llfloaterimsession.cpp +++ b/indra/newview/llfloaterimsession.cpp @@ -843,6 +843,16 @@ void LLFloaterIMSession::updateMessages() for (; iter != iter_end; ++iter) { LLSD msg = *iter; + // Consume hidden fallbacks and expired offer markers as well as visible rows. + mLastMessageIndex = msg["index"].asInteger(); + + // An active inline offer replaces only its explicitly linked text log. + // The fallback can be absent or arrive after unrelated chat messages. + const LLUUID notification_log_id = msg["notification_log_id"].asUUID(); + if (notification_log_id.notNull() && LLNotificationsUtil::find(notification_log_id) != NULL) + { + continue; + } std::string time = msg["time"].asString(); LLUUID from_id = msg["from_id"].asUUID(); @@ -879,7 +889,7 @@ void LLFloaterIMSession::updateMessages() channel->hideToast(chat.mNotifId); } } - // if notification doesn't exist - try to use next message which should be log entry + // Expired offers leave their text fallback to render independently. else { continue; @@ -893,20 +903,6 @@ void LLFloaterIMSession::updateMessages() // Add the message to the chat log appendMessage(chat); - mLastMessageIndex = msg["index"].asInteger(); - - // if it is a notification - next message is a notification history log, so skip it - if (chat.mNotifId.notNull() && LLNotificationsUtil::find(chat.mNotifId) != NULL) - { - if (++iter == iter_end) - { - break; - } - else - { - mLastMessageIndex++; - } - } } } } diff --git a/indra/newview/llimprocessing.cpp b/indra/newview/llimprocessing.cpp index bd5a2cddee..409e213e0e 100644 --- a/indra/newview/llimprocessing.cpp +++ b/indra/newview/llimprocessing.cpp @@ -519,6 +519,10 @@ void LLIMProcessing::processNewMessage(LLUUID from_id, break; case IM_NOTHING_SPECIAL: // p2p IM + // Record delivery metadata so live transport can be distinguished from history retrieval. + LL_INFOS("ChatServiceHistory") << "Direct IM received: sender_id=" << from_id + << ", offline=" << (offline == IM_OFFLINE) + << ", timestamp=" << timestamp << ", bytes=" << message.size() << LL_ENDL; // Don't show dialog, just do IM if (!gAgent.isGodlike() && gAgent.inPrelude() @@ -622,6 +626,7 @@ void LLIMProcessing::processNewMessage(LLUUID from_id, real_name = SYSTEM_FROM; } + // Keep the undecorated body for matching against service history. gIMMgr->addMessage(session_id, from_id, name, @@ -635,7 +640,8 @@ void LLIMProcessing::processNewMessage(LLUUID from_id, region_message, timestamp, LLUUID::null, - real_name); + real_name, + notice_name.empty() ? LLSD(message) : LLSD()); } else { 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 diff --git a/indra/newview/llimview.h b/indra/newview/llimview.h index 3ea8ebac43..4406cbc019 100644 --- a/indra/newview/llimview.h +++ b/indra/newview/llimview.h @@ -155,7 +155,7 @@ public: chat_message_list_t mMsgs; // Historical rows are replaced as one overlay so live rows remain untouched. - chat_message_list_t mChatServiceHistoricalValue; + LLChatServiceHistory::History mChatServiceHistory; // Process-unique tokens and archive serials reject stale asynchronous reads. U64 mChatHistoryLoadToken = 0; @@ -252,6 +252,7 @@ public: * Add a message to an IM Model - the message is saved in a message store associated with a session specified by session_id * and also saved into a file if log2file is specified. * It sends new message signal for each added message. + * history_context carries ChatService metadata through delivery and translation. */ void addMessage(const LLUUID& session_id, const std::string& from, @@ -259,7 +260,8 @@ public: const std::string& utf8_text, bool log2file = true, bool is_region_msg = false, - U32 time_stamp = 0); + U32 time_stamp = 0, + LLSD history_context = LLSD()); void processAddingMessage(const LLUUID& session_id, const std::string& from, @@ -267,13 +269,15 @@ public: const std::string& utf8_text, bool log2file, bool is_region_msg, - U32 time_stamp); + U32 time_stamp, + LLSD history_context = LLSD()); /** * Similar to addMessage(...) above but won't send a signal about a new message added */ LLIMModel::LLIMSession* 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); + const std::string& utf8_text, bool log2file = true, bool is_region_msg = false, U32 timestamp = 0, + LLSD history_context = LLSD()); /** * Add a system message to an IM Model @@ -396,7 +400,8 @@ public: bool is_region_msg = false, U32 timestamp = 0, LLUUID display_id = LLUUID::null, - std::string_view display_name = ""); + std::string_view display_name = "", + const LLSD& original_text = LLSD()); void addSystemMessage(const LLUUID& session_id, const std::string& message_name, const LLSD& args); diff --git a/indra/newview/llnotificationhandler.h b/indra/newview/llnotificationhandler.h index a259a4b248..0099adca88 100644 --- a/indra/newview/llnotificationhandler.h +++ b/indra/newview/llnotificationhandler.h @@ -287,17 +287,20 @@ public: /** * Writes notification message to IM session. + * notification_id links a text fallback to its inline offer, when present. */ static void logToIM(const EInstantMessage& session_type, const std::string& session_name, const std::string& from_name, const std::string& message, const LLUUID& session_owner_id, - const LLUUID& from_id); + const LLUUID& from_id, const LLUUID& notification_id = LLUUID::null); /** * Writes notification message to IM p2p session. */ - static void logToIMP2P(const LLNotificationPtr& notification, bool to_file_only = false); - static void logToIMP2P(const LLUUID& from_id, const std::string& message, bool to_file_only = false); + static void logToIMP2P(const LLNotificationPtr& notification, bool to_file_only = false, + const LLUUID& notification_id = LLUUID::null); + static void logToIMP2P(const LLUUID& from_id, const std::string& message, bool to_file_only = false, + const LLUUID& notification_id = LLUUID::null); /** * Writes group notice notification message to IM group session. diff --git a/indra/newview/llnotificationhandlerutil.cpp b/indra/newview/llnotificationhandlerutil.cpp index fc973e00ae..935094fdc0 100644 --- a/indra/newview/llnotificationhandlerutil.cpp +++ b/indra/newview/llnotificationhandlerutil.cpp @@ -72,7 +72,7 @@ bool LLHandlerUtil::isIMFloaterOpened(const LLNotificationPtr& notification) void LLHandlerUtil::logToIM(const EInstantMessage& session_type, const std::string& session_name, const std::string& from_name, const std::string& message, const LLUUID& session_owner_id, - const LLUUID& from_id) + const LLUUID& from_id, const LLUUID& notification_id) { std::string from = from_name; if (from_name.empty()) @@ -111,6 +111,14 @@ void LLHandlerUtil::logToIM(const EInstantMessage& session_type, S32 participant_unread = session->mParticipantUnreadMessageCount; LLIMModel::instance().addMessageSilently(session_id, from, from_id, message); + + // Link the text fallback to its inline offer before the floater sees it. + // Name lookup may complete after other messages have arrived. + if (notification_id.notNull()) + { + session->mMsgs.front()["notification_log_id"] = notification_id; + } + // we shouldn't increment counters when logging, so restore them session->mNumUnread = unread; session->mParticipantUnreadMessageCount = participant_unread; @@ -121,15 +129,17 @@ void LLHandlerUtil::logToIM(const EInstantMessage& session_type, } void log_name_callback(const LLAvatarName& av_name, const std::string& from_name, - const std::string& message, const LLUUID& from_id) + const std::string& message, const LLUUID& from_id, + const LLUUID& notification_id) { LLHandlerUtil::logToIM(IM_NOTHING_SPECIAL, av_name.getUserName(), from_name, message, - from_id, LLUUID()); + from_id, LLUUID(), notification_id); } // static -void LLHandlerUtil::logToIMP2P(const LLUUID& from_id, const std::string& message, bool to_file_only) +void LLHandlerUtil::logToIMP2P(const LLUUID& from_id, const std::string& message, bool to_file_only, + const LLUUID& notification_id) { if (!gCacheName) { @@ -145,19 +155,20 @@ void LLHandlerUtil::logToIMP2P(const LLUUID& from_id, const std::string& message if(to_file_only) { - LLAvatarNameCache::get(from_id, boost::bind(&log_name_callback, _2, "", message, LLUUID())); + LLAvatarNameCache::get(from_id, boost::bind(&log_name_callback, _2, "", message, LLUUID(), notification_id)); } else { - LLAvatarNameCache::get(from_id, boost::bind(&log_name_callback, _2, INTERACTIVE_SYSTEM_FROM, message, from_id)); + LLAvatarNameCache::get(from_id, boost::bind(&log_name_callback, _2, INTERACTIVE_SYSTEM_FROM, message, from_id, notification_id)); } } // static -void LLHandlerUtil::logToIMP2P(const LLNotificationPtr& notification, bool to_file_only) +void LLHandlerUtil::logToIMP2P(const LLNotificationPtr& notification, bool to_file_only, + const LLUUID& notification_id) { LLUUID from_id = notification->getPayload()["from_id"]; - logToIMP2P(from_id, notification->getMessage(), to_file_only); + logToIMP2P(from_id, notification->getMessage(), to_file_only, notification_id); } // static diff --git a/indra/newview/llnotificationofferhandler.cpp b/indra/newview/llnotificationofferhandler.cpp index dfca370b46..aa875ab247 100644 --- a/indra/newview/llnotificationofferhandler.cpp +++ b/indra/newview/llnotificationofferhandler.cpp @@ -150,6 +150,8 @@ bool LLOfferHandler::processNotification(const LLNotificationPtr& notification, { // log only to file if notif panel can be embedded to IM and IM is opened bool file_only = add_notif_to_im && LLHandlerUtil::isIMFloaterOpened(notification); + // Only a log paired with an inline offer may be hidden during replay. + const LLUUID notification_id = add_notif_to_im ? notification->getID() : LLUUID::null; if ((notification->getName() == "TeleportOffered" || notification->getName() == "TeleportOffered_MaturityExceeded" || notification->getName() == "TeleportOffered_MaturityBlocked")) @@ -157,11 +159,11 @@ bool LLOfferHandler::processNotification(const LLNotificationPtr& notification, boost::regex r("\\s*([^<]*)?\\s*( - )?", boost::regex::perl|boost::regex::icase); std::string stripped_msg = boost::regex_replace(notification->getMessage(), r, ""); - LLHandlerUtil::logToIMP2P(notification->getPayload()["from_id"], stripped_msg,file_only); + LLHandlerUtil::logToIMP2P(notification->getPayload()["from_id"], stripped_msg, file_only, notification_id); } else { - LLHandlerUtil::logToIMP2P(notification, file_only); + LLHandlerUtil::logToIMP2P(notification, file_only, notification_id); } } } diff --git a/indra/newview/tests/llchatservicehistorycore_test.cpp b/indra/newview/tests/llchatservicehistorycore_test.cpp index 52bc16edba..6316d5b7bc 100644 --- a/indra/newview/tests/llchatservicehistorycore_test.cpp +++ b/indra/newview/tests/llchatservicehistorycore_test.cpp @@ -13,6 +13,8 @@ #include "../test/namedtempfile.h" #include "../llchatservicehistorycore.h" #include "../llmutelist.h" +#include +#include "llsdutil.h" struct LLMuteListTestAccess { @@ -309,4 +311,642 @@ template<> template<> void object_t::test<14>() ensure("ambiguous DST row retained", legacyWallMayPrecedeService(wall, daylight_utc + 30.0 * 60.0)); } + +template<> template<> void object_t::test<15>() +{ + // Complete transcript names carry the resident identity inside parentheses. + ensure("complete name matches legacy resident name", + sameDirectSenderName("Bridie (bridie.linden)", "Bridie Linden")); + ensure("complete name matches dotted resident name", + sameDirectSenderName("Bridie (bridie.linden)", "bridie.linden")); + ensure("single-name resident matches Resident suffix", + sameDirectSenderName("beanie lin alt (danyanka)", "danyanka Resident")); + ensure("resident names compare without case", + sameDirectSenderName("BRIDIE.LINDEN", "Bridie Linden")); + + // Display-name changes and collisions must not change the compared identity. + ensure("different display names for one resident match", + sameDirectSenderName("Old Name (bridie.linden)", "New Name (bridie.linden)")); + ensure("same display name for different residents stays distinct", + !sameDirectSenderName("Bridie (bridie.linden)", "Bridie (danyanka)")); + ensure("display name alone does not identify the resident", + !sameDirectSenderName("Bridie", "Bridie (bridie.linden)")); + ensure("missing names do not identify a resident", !sameDirectSenderName("", "")); +} + +template<> template<> void object_t::test<16>() +{ + LLSD history; + history["from_id"] = RESIDENT; + history["message"] = "hello 1"; + history["timestamp"] = 1789005928; + history["chat_service_msg_id"] = FIRST; + + // Offline delivery carries its original body independently of the localized notice. + LLSD delivered = history; + delivered.erase("chat_service_msg_id"); + delivered["message"] = "(Saved Wed Sep 09 22:05:28 2026)hello 1"; + delivered["chat_service_original_text"] = "hello 1"; + ensure("offline service copy matches delivered original", sameDirectHistoryOccurrence(history, delivered)); + delivered["message"] = "(Enregistre le mercredi)hello 1"; + ensure("offline notice localization does not affect identity", sameDirectHistoryOccurrence(history, delivered)); + delivered["message"] = "hello 1 (bonjour 1)"; + ensure("translation does not affect identity", sameDirectHistoryOccurrence(history, delivered)); + + // Never infer original text by stripping something the sender may have typed. + delivered["message"] = "(Saved Wed Sep 09 22:05:28 2026)hello 1"; + delivered["chat_service_original_text"] = delivered["message"]; + ensure("literal saved prefix remains message content", !sameDirectHistoryOccurrence(history, delivered)); + delivered.erase("chat_service_original_text"); + ensure("unidentified decorated text is not guessed", !sameDirectHistoryOccurrence(history, delivered)); + delivered["message"] = "hello 1"; + ensure("plain live delivery still matches", sameDirectHistoryOccurrence(history, delivered)); + delivered["from_id"] = AGENT; + ensure("another sender stays distinct", !sameDirectHistoryOccurrence(history, delivered)); + delivered["from_id"] = RESIDENT; + delivered["timestamp"] = 1789005988; + ensure("another send minute stays distinct", !sameDirectHistoryOccurrence(history, delivered)); +} + +template<> template<> void object_t::test<17>() +{ + LLSD delivered; + delivered["from_id"] = RESIDENT; + delivered["from"] = "peppertest Resident"; + delivered["message"] = "(Saved Wed Sep 09 22:05:28 2026)hello 1"; + delivered["chat_service_original_text"] = "hello 1"; + delivered["timestamp"] = 1789005928; + delivered["chat_service_log_timestamp"] = 1789092365; + + // An offline message delivered the next day has a plaintext receipt minute + // that differs from its service send minute; both copies identify the same delivery. + LLSD legacy; + legacy["from"] = "peppertest"; + legacy["message"] = delivered["message"]; + const F64 logged_minute = (1789092365 / 60) * 60; + legacy["chat_service_legacy_wall_time"] = logged_minute - 7.0 * 3600.0; + ensure("plaintext matches the delivery minute", sameDirectHistoryOccurrence(legacy, delivered)); + legacy["chat_service_legacy_wall_time"] = logged_minute - 8.0 * 3600.0; + ensure("plaintext accepts the standard-time SLT offset", sameDirectHistoryOccurrence(legacy, delivered)); + legacy["message"] = "hello 1"; + ensure("plaintext matches its logged body exactly", !sameDirectHistoryOccurrence(legacy, delivered)); + legacy["message"] = delivered["message"]; + legacy.erase("chat_service_legacy_wall_time"); + ensure("undated plaintext is not guessed", !sameDirectHistoryOccurrence(legacy, delivered)); +} + +template<> template<> void object_t::test<18>() +{ + auto row = [](const std::string& text, S32 timestamp) + { + return LLSD().with("message", text).with("timestamp", timestamp); + }; + const std::list live{ + row("hello 3", 1789005932), row("hello 2", 1789005930), row("hello 1", 1789005928) + }; + const std::list history{row("hello", 1789005955), row("older", 1789005800)}; + auto merged = interleaveDirectHistory(history, live); + auto it = merged.begin(); + ensure_equals("fourth message follows the offline deliveries", (*it++)["message"].asString(), "hello"); + ensure_equals("third delivery keeps its place", (*it++)["message"].asString(), "hello 3"); + ensure_equals("second delivery keeps its place", (*it++)["message"].asString(), "hello 2"); + ensure_equals("first delivery keeps its place", (*it++)["message"].asString(), "hello 1"); + ensure_equals("older history precedes the live conversation", (*it++)["message"].asString(), "older"); + ensure("all occurrences retained", it == merged.end()); + + // Timestamp ties keep the existing live row later; undated history stays at the start. + merged = interleaveDirectHistory({row("tie", 1789005930), row("undated", 0)}, live); + it = merged.begin(); + ensure_equals("newest live stays newest", (*it++)["message"].asString(), "hello 3"); + ensure_equals("live wins timestamp tie", (*it++)["message"].asString(), "hello 2"); + ensure_equals("equal-time history retains its occurrence", (*it++)["message"].asString(), "tie"); + ensure_equals("oldest live stays in delivery order", (*it++)["message"].asString(), "hello 1"); + ensure_equals("undated history remains before live rows", (*it++)["message"].asString(), "undated"); + ensure("interleaving leaves source lists unchanged", live.size() == 3 && history.size() == 2); +} + +template<> template<> void object_t::test<19>() +{ + // A retained service row at :05 is a different occurrence from a new send at + // :35, even when its sender, text, and minute are identical. + LLSD history; + history["from_id"] = RESIDENT; + history["message"] = "ok"; + history["timestamp"] = 1789005905; + history["chat_service_msg_id"] = FIRST; + + LLSD delivered = history; + delivered.erase("chat_service_msg_id"); + delivered["timestamp"] = 1789005935; + ensure("a later repeat in the same minute stays distinct", + !sameDirectHistoryOccurrence(history, delivered)); + delivered["timestamp"] = 1789005904; + ensure("a preceding send in the same minute stays distinct", + !sameDirectHistoryOccurrence(history, delivered)); + delivered["timestamp"] = 1789005905; + ensure("the matching service second reconciles", + sameDirectHistoryOccurrence(history, delivered)); + + // Missing precision provides no evidence for consuming a service occurrence. + delivered["timestamp"] = 0; + ensure("an untimed delivery stays distinct", !sameDirectHistoryOccurrence(history, delivered)); +} + +template<> template<> void object_t::test<20>() +{ + LLSD history; + history["from_id"] = AGENT; + history["message"] = "hello"; + history["timestamp"] = 1789005905; + history["chat_service_msg_id"] = FIRST; + + // Outgoing echoes use a local clock; server persistence may occur in another second. + LLSD echo = history; + echo.erase("chat_service_msg_id"); + echo["chat_service_time_source"] = "local"; + echo["timestamp"] = 1789005904; + ensure("local send before persistence reconciles", sameDirectHistoryOccurrence(history, echo)); + echo["timestamp"] = 1789005906; + ensure("local clock ahead of the server reconciles", sameDirectHistoryOccurrence(history, echo)); + + // Timestamp tolerance does not relax sender/body identity or extend past the minute. + echo["message"] = "hello (bonjour)"; + echo["chat_service_original_text"] = "hello"; + ensure("translated local echo reconciles its original body", sameDirectHistoryOccurrence(history, echo)); + echo["chat_service_original_text"] = "another message"; + ensure("different local body stays distinct", !sameDirectHistoryOccurrence(history, echo)); + echo["chat_service_original_text"] = "hello"; + echo["from_id"] = RESIDENT; + ensure("another sender stays distinct for local time", !sameDirectHistoryOccurrence(history, echo)); + echo["from_id"] = AGENT; + echo["timestamp"] = 1789005965; + ensure("local time in another minute stays distinct", !sameDirectHistoryOccurrence(history, echo)); + + // Supplied timestamps use exact seconds even for the current agent's messages. + echo["timestamp"] = 1789005906; + echo["chat_service_time_source"] = "sent"; + ensure("supplied outgoing timestamp requires exact seconds", !sameDirectHistoryOccurrence(history, echo)); + echo["timestamp"] = 1789005905; + ensure("supplied outgoing timestamp matches its service second", sameDirectHistoryOccurrence(history, echo)); + echo["chat_service_time_source"] = "local"; + history["timestamp"] = 0; + ensure("local provenance does not make an untimed service row match", !sameDirectHistoryOccurrence(history, echo)); +} + +// Repeated incoming bodies share a plaintext minute but have distinct supplied seconds. +LLSD repeatedDelivery(S32 second) +{ + LLSD message; + message["from_id"] = RESIDENT; + message["from"] = "Example Resident"; + message["message"] = "ok"; + message["timestamp"] = 1789005900 + second; + message["chat_service_log_timestamp"] = message["timestamp"]; + return message; +} + +LLSD serviceCopy(const LLSD& delivery, const std::string& id) +{ + LLSD message = delivery; + message["chat_service_msg_id"] = id; + return message; +} + +LLSD plaintextCopy(const LLSD& delivery) +{ + LLSD message; + message["from"] = delivery["from"]; + message["message"] = delivery["message"]; + message["chat_service_legacy_wall_time"] = + F64(delivery["timestamp"].asInteger() / 60 * 60) - 7.0 * 3600.0; + return message; +} + +template<> template<> void object_t::test<21>() +{ + const LLSD earlier = repeatedDelivery(5); + const LLSD later = repeatedDelivery(35); + const LLSD legacy = plaintextCopy(earlier); + const LLSD service = serviceCopy(later, FIRST); + + // Partial coverage leaves plaintext before the only service row, which must + // reserve the :35 delivery before the plaintext minute can consume it. + for (bool history_reversed : {false, true}) + { + for (bool live_reversed : {false, true}) + { + std::list history{legacy, service}; + std::list live{later, earlier}; + if (history_reversed) history.reverse(); + if (live_reversed) live.reverse(); + ensure("partial service coverage reconciles both deliveries in either order", + filterDirectHistoryDuplicates(history, live).empty()); + } + } +} + +template<> template<> void object_t::test<22>() +{ + const LLSD earlier = repeatedDelivery(5); + const LLSD later = repeatedDelivery(35); + const LLSD legacy = plaintextCopy(earlier); + LLSD keep_before = legacy; + keep_before["message"] = "before"; + LLSD keep_after = legacy; + keep_after["message"] = "after"; + const std::list history{legacy, keep_before, serviceCopy(later, FIRST), legacy, keep_after}; + const std::list live{later, earlier}; + + // One exact reservation leaves only one occurrence for plaintext. Extra + // identical history and unrelated rows remain in their original positions. + const auto filtered = filterDirectHistoryDuplicates(history, live); + ensure_equals("each live occurrence is consumed once", filtered.size(), size_t(3)); + auto row = filtered.begin(); + ensure_equals("first unmatched row keeps its position", (*row++)["message"].asString(), "before"); + ensure("extra repeated occurrence retains its plaintext source", !row->has("chat_service_msg_id")); + ensure_equals("extra repeated occurrence remains", (*row++)["message"].asString(), "ok"); + ensure_equals("last unmatched row keeps its position", (*row++)["message"].asString(), "after"); + ensure_equals("history input is unchanged", history.size(), size_t(5)); + ensure_equals("live input is unchanged", live.size(), size_t(2)); + ensure_equals("no live rows leaves history intact", filterDirectHistoryDuplicates(history, {}).size(), history.size()); + ensure("no history leaves an empty result", filterDirectHistoryDuplicates({}, live).empty()); +} + +template<> template<> void object_t::test<23>() +{ + const LLSD supplied = repeatedDelivery(5); + LLSD local = repeatedDelivery(34); + local["chat_service_time_source"] = "local"; + const LLSD service_earlier = serviceCopy(supplied, FIRST); + const LLSD service_later = serviceCopy(repeatedDelivery(35), SECOND); + + // A local-minute fallback must not take an exact supplied match's place, + // regardless of the order of the service rows or live deliveries. + for (bool history_reversed : {false, true}) + { + for (bool live_reversed : {false, true}) + { + std::list history{service_earlier, service_later}; + std::list live{local, supplied}; + if (history_reversed) history.reverse(); + if (live_reversed) live.reverse(); + ensure("exact service matches precede local-minute fallback", + filterDirectHistoryDuplicates(history, live).empty()); + } + } +} + +template<> template<> void object_t::test<24>() +{ + LLSD earlier = repeatedDelivery(5); + LLSD later = repeatedDelivery(35); + const LLSD service_earlier = serviceCopy(earlier, FIRST); + const LLSD service_later = serviceCopy(later, SECOND); + for (LLSD* delivery : {&earlier, &later}) + { + (*delivery)["chat_service_original_text"] = "ok"; + (*delivery)["message"] = "ok (d'accord)"; + } + const LLSD legacy = plaintextCopy(earlier); + std::list history{legacy, service_later, legacy, service_earlier}; + const std::list live{later, earlier}; + + // Translated or saved delivery can cover both its decorated plaintext and + // raw service representation, with separate one-for-one consumption. + ensure("decorated plaintext and raw service copies both reconcile", + filterDirectHistoryDuplicates(history, live).empty()); + history.push_front(legacy); + history.push_back(service_earlier); + const auto filtered = filterDirectHistoryDuplicates(history, live); + ensure_equals("extra occurrences in each representation remain", filtered.size(), size_t(2)); + ensure("extra decorated plaintext retains its source", !filtered.front().has("chat_service_msg_id")); + ensure_equals("extra service retains its source", filtered.back()["chat_service_msg_id"].asString(), std::string(FIRST)); +} + +template<> template<> void object_t::test<25>() +{ + // Service creation can precede online delivery across a second boundary. + LLSD history = serviceCopy(repeatedDelivery(0), FIRST); + history["message"] = "123"; + history["datetime"] = "2026-09-10T17:17:00.904000Z"; + history["timestamp"] = S32(LLDate(history["datetime"].asString()).secondsSinceEpoch()); + LLSD delivered = history; + delivered.erase("chat_service_msg_id"); + delivered["timestamp"] = 1789060621; + delivered["chat_service_time_source"] = "receipt"; + ensure("online delivery reconciles the preceding service second", + filterDirectHistoryDuplicates({history}, {delivered}).empty()); + + // 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)); + delivered["timestamp"] = 1789060681; + ensure("delivery outside the rolling window stays distinct", !sameDirectHistoryOccurrence(history, delivered)); + + // Elapsed delay crosses minute boundaries, with exact sender and body checks. + history["timestamp"] = 1789060619; + delivered["timestamp"] = 1789060620; + ensure("delivery delay crosses a minute boundary", sameDirectHistoryOccurrence(history, delivered)); + delivered["from_id"] = AGENT; + ensure("delivery tolerance retains sender identity", !sameDirectHistoryOccurrence(history, delivered)); + delivered["from_id"] = RESIDENT; + delivered["message"] = "1234"; + ensure("delivery tolerance retains body identity", !sameDirectHistoryOccurrence(history, delivered)); +} + +template<> template<> void object_t::test<26>() +{ + const LLSD earlier = serviceCopy(repeatedDelivery(0), FIRST); + const LLSD later = serviceCopy(repeatedDelivery(1), SECOND); + LLSD delivered = repeatedDelivery(1); + delivered["chat_service_time_source"] = "receipt"; + + // Receipt times do not identify a particular repeated send. Allocate the earliest + // eligible occurrence, preserving the other occurrence even at the same second. + for (bool reversed : {false, true}) + { + std::list history{earlier, later}; + if (reversed) history.reverse(); + const auto filtered = filterDirectHistoryDuplicates(history, {delivered}); + ensure_equals("one live delivery consumes one service occurrence", filtered.size(), size_t(1)); + ensure_equals("the later repeated send survives", filtered.front()["chat_service_msg_id"].asString(), std::string(SECOND)); + } +} + +template<> template<> void object_t::test<27>() +{ + const LLSD earlier = serviceCopy(repeatedDelivery(0), FIRST); + const LLSD later = serviceCopy(repeatedDelivery(1), SECOND); + LLSD first_delivery = repeatedDelivery(1); + first_delivery["chat_service_time_source"] = "receipt"; + LLSD second_delivery = first_delivery; + + // Two deliveries in one second remain separate occurrences even when one + // service creation crossed the boundary. + for (bool reversed : {false, true}) + { + std::list history{earlier, later}; + if (reversed) history.reverse(); + ensure("both adjacent service occurrences reconcile one-for-one", + filterDirectHistoryDuplicates(history, {first_delivery, second_delivery}).empty()); + } + + // Delivery matches reserve their occurrence before local clocks and plaintext minutes. + LLSD local = repeatedDelivery(0); + local["chat_service_time_source"] = "local"; + const LLSD much_later = serviceCopy(repeatedDelivery(35), SECOND); + ensure("delivery matching precedes local-minute matching", + filterDirectHistoryDuplicates({earlier, much_later}, {local, first_delivery}).empty()); + const LLSD legacy = plaintextCopy(first_delivery); + ensure("delivery matching precedes plaintext matching", + filterDirectHistoryDuplicates({legacy, earlier}, {first_delivery, repeatedDelivery(35)}).empty()); +} + +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"}) + { + 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}) + { + delivered["timestamp"] = timestamp + delay; + ensure("receipt inside the rolling window reconciles", + filterDirectHistoryDuplicates({history}, {delivered}).empty()); + } + for (S32 delay : {-1, 61}) + { + delivered["timestamp"] = timestamp + delay; + ensure_equals("receipt outside the rolling window remains", + filterDirectHistoryDuplicates({history}, {delivered}).size(), size_t(1)); + } + } +} + +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}}) + { + const LLSD first = serviceCopy(repeatedDelivery(times[0]), FIRST); + const LLSD second = serviceCopy(repeatedDelivery(times[1]), SECOND); + LLSD first_delivery = repeatedDelivery(times[2]); + LLSD second_delivery = repeatedDelivery(times[3]); + first_delivery["chat_service_time_source"] = "receipt"; + second_delivery["chat_service_time_source"] = "receipt"; + for (bool history_reversed : {false, true}) + { + for (bool live_reversed : {false, true}) + { + std::list history{first, second}; + std::list live{first_delivery, second_delivery}; + if (history_reversed) history.reverse(); + if (live_reversed) live.reverse(); + ensure("delayed repeats reconcile independently of source order", + filterDirectHistoryDuplicates(history, live).empty()); + } + } + } +} + +// Stable service IDs let the view retain context without guessing plaintext identity. +LLSD contextRow(U8 ordinal) +{ + LLUUID id(FIRST); + id.mData[3] = ordinal; + LLSD row = serviceCopy(repeatedDelivery(ordinal * 10), id.asString().c_str()); + row["message"] = std::to_string(ordinal); + return row; +} + +LLSD liveCopy(LLSD row) +{ + row.erase("chat_service_msg_id"); + row["is_history"] = false; + return row; +} + +template<> template<> void object_t::test<30>() +{ + const LLSD first = serviceCopy(repeatedDelivery(5), FIRST); + const LLSD second = serviceCopy(repeatedDelivery(35), SECOND); + const LLSD legacy = plaintextCopy(first); + History history; + history.setLoaded(mergeDirectHistory({legacy, legacy, legacy}, {first})); + + // Reapplying a preview must not consume the next identical plaintext occurrence. + const Messages expected{legacy, first, second}; + for (int publication = 0; publication < 3; ++publication) + { + const auto result = history.compose({second, first}, {}, 10); + ensure_equals("preview keeps all three occurrences", result.size(), expected.size()); + ensure("preview is idempotent and chronological", + std::equal(result.begin(), result.end(), expected.begin(), + [](const LLSD& a, const LLSD& b) { return llsd_equals(a, b); })); + } + + // Canonical ID identity wins over a stale preview, and limits follow reconciliation. + LLSD stale = first; + stale["message"] = "stale preview"; + const auto limited = history.compose({second, stale, second}, {}, 2); + ensure_equals("duplicate IDs do not evict other history", limited.size(), size_t(2)); + ensure_equals("canonical value wins", limited.front()["message"].asString(), std::string("ok")); + ensure_equals("service tail stays in TimeUUID order", limited.back()["chat_service_msg_id"].asString(), std::string(SECOND)); +} + +template<> template<> void object_t::test<31>() +{ + const Messages initial{contextRow(1), contextRow(2), contextRow(3)}; + const Messages bounded{contextRow(2), contextRow(3), contextRow(4)}; + const LLSD delivered = liveCopy(contextRow(4)); + History history; + history.setLoaded(initial); + history.compose({}, {}, 3, true); + history.setLoaded(bounded); + auto result = history.compose({}, {delivered}, 3, true); + ensure_equals("live copy does not evict older visible context", result.size(), size_t(3)); + ensure_equals("oldest context survives rollover", result.front()["message"].asString(), std::string("1")); + + // A late delivery may replace any retained row, including the newest one. + for (U8 ordinal : {2, 3}) + { + history.clear(); + history.setLoaded(initial); + history.compose({}, {}, 3, true); + history.setLoaded(bounded); + history.compose({}, {delivered}, 3, true); + for (int publication = 0; publication < 3; ++publication) + { + result = history.compose({}, {liveCopy(contextRow(ordinal)), delivered}, 3, true); + ensure_equals("late live delivery replaces one retained occurrence", result.size(), size_t(2)); + ensure_equals("unrelated context remains", result.front()["message"].asString(), std::string("1")); + } + } + + history.clear(); + history.setLoaded(initial); + history.compose({}, {}, 3, true); + history.setLoaded({contextRow(4), contextRow(5), contextRow(6)}); + const Messages live{liveCopy(contextRow(6)), liveCopy(contextRow(5)), delivered}; + ensure_equals("an all-live source retains open history", history.compose({}, live, 3, true).size(), size_t(3)); + ensure_equals("repeated all-live publication is stable", history.compose({}, live, 3, true).size(), size_t(3)); + ensure("hidden view releases old context", history.compose({}, live, 3).empty()); + history.setLoaded(initial); + history.compose({}, {}, 3, true); + history.setLoaded({}); + ensure("empty load clears retained context", history.compose({}, {}, 3, true).empty()); + + // Revoking service consent leaves plaintext, while deletion clears both sources. + const LLSD legacy = plaintextCopy(repeatedDelivery(5)); + history.setLoaded({legacy, contextRow(1)}); + history.compose({}, {}, 3, true); + history.clearService(); + ensure_equals("service revocation keeps legacy", history.compose({}, {}, 3, true).size(), size_t(1)); + history.clear(); + ensure("clear removes every historical source", history.compose({}, {}, 3, true).empty()); +} + +template<> template<> void object_t::test<32>() +{ + LLSD offer; + offer["notification_id"] = AGENT; + offer["timestamp"] = 0; + offer["is_history"] = false; + LLSD first = liveCopy(contextRow(4)); + LLSD second = first; + first["message"] = "test"; + second["message"] = "test"; + second["timestamp"] = first["timestamp"].asInteger() + 1; + Messages current{second, first, offer}; + ensure("initial history changes the model", replaceHistory(current, {contextRow(1)}, true)); + ensure_equals("two live tests and the offer survive", current.size(), size_t(4)); + auto row = current.begin(); + ensure_equals("latest test stays live", (*row++)["message"].asString(), std::string("test")); + ensure_equals("first test stays live", (*row++)["message"].asString(), std::string("test")); + ensure_equals("inline offer identity survives", (*row)["notification_id"].asUUID(), AGENT); + ensure("identical publication avoids replay", !replaceHistory(current, {contextRow(1)}, true)); + ensure("clearing history changes the model", replaceHistory(current, {}, true)); + ensure_equals("clearing history retains both live sends and offer", current.size(), size_t(3)); + S32 index = 3; + for (const LLSD& message : current) + { + ensure_equals("indexes remain contiguous", message["index"].asInteger(), --index); + } +} + +template<> template<> void object_t::test<33>() +{ + constexpr U32 received = 1789060621; + for (bool supplied : {false, true}) + { + U32 timestamp = supplied ? received : 0; + LLSD captured = captureLiveMessage("123", timestamp, incomingContext("123", true), received); + ensure_equals("supplied and synthesized receipt times agree", timestamp, received); + captured = captureLiveMessage("123 (translated)", timestamp, captured, received + 90); + LLSD row = repeatedDelivery(0); + row["timestamp"] = static_cast(timestamp); + row["message"] = "123 (translated)"; + recordLiveMessage(row, captured, received + 90); + ensure_equals("translation preserves original body", row["chat_service_original_text"].asString(), std::string("123")); + ensure_equals("translation preserves receipt provenance", row["chat_service_time_source"].asString(), std::string("receipt")); + ensure_equals("logging time follows translation completion", row["chat_service_log_timestamp"].asInteger(), S32(received + 90)); + LLSD service = serviceCopy(repeatedDelivery(0), FIRST); + service["message"] = "123"; + service["timestamp"] = S32(received - 1); + ensure("translation delay does not affect service matching", sameDirectHistoryOccurrence(service, row)); + } + + U32 timestamp = received - 86400; + LLSD offline = captureLiveMessage("(Saved yesterday)123", timestamp, incomingContext("123", false), received); + ensure_equals("offline delivery keeps the send time", timestamp, received - 86400); + ensure_equals("offline time is authoritative", offline["chat_service_time_source"].asString(), std::string("sent")); + timestamp = 0; + offline = captureLiveMessage("(Saved)123", timestamp, incomingContext("123", false), received); + ensure_equals("missing send time cannot become authoritative", offline["chat_service_time_source"].asString(), std::string("local")); + timestamp = 0; + LLSD echo = captureLiveMessage("123", timestamp, LLSD(), received); + ensure_equals("outgoing echo captures its local clock", echo["chat_service_time_source"].asString(), std::string("local")); + ensure_equals("outgoing echo captures before translation", timestamp, received); +} + +template<> template<> void object_t::test<34>() +{ + const LLSD old = contextRow(1); + const LLSD legacy = plaintextCopy(old); + const LLSD live = liveCopy(contextRow(4)); + History history; + history.setLoaded({legacy, contextRow(2), contextRow(3)}); + history.compose({}, {}, 3, true); + history.setLoaded({contextRow(2), contextRow(3), contextRow(4)}); + for (int publication = 0; publication < 3; ++publication) + { + const auto result = history.compose({}, {live}, 3, true); + ensure_equals("capped service rollover retains older plaintext", result.size(), size_t(3)); + ensure("retained plaintext keeps its source", !result.front().has("chat_service_msg_id")); + } + history.setLoaded({old, contextRow(2), contextRow(3)}); + auto result = history.compose({}, {live}, 3, true); + ensure_equals("new archive row replaces retained plaintext", result.size(), size_t(3)); + ensure_equals("replaced plaintext acquires the canonical ID", result.front()["chat_service_msg_id"].asString(), old["chat_service_msg_id"].asString()); + + // Each window may already have consumed a different member of repeated plaintext. + const LLSD first = serviceCopy(repeatedDelivery(5), FIRST); + const LLSD second = serviceCopy(repeatedDelivery(35), SECOND); + const LLSD repeat = plaintextCopy(first); + const Messages left = mergeDirectHistory({repeat, repeat, repeat}, {first}); + const Messages right = mergeDirectHistory({repeat, repeat, repeat}, {second}); + result = mergeDirectHistory(left, right); + ensure_equals("overlapping windows preserve three original occurrences", result.size(), size_t(3)); + ensure_equals("repeated seam publication is stable", mergeDirectHistory(result, right).size(), size_t(3)); + ensure_equals("source window order preserves counts", mergeDirectHistory(right, left).size(), size_t(3)); +} + } -- cgit v1.3 From bbb47725bab7c427c6589404990b2ed4e242a39e Mon Sep 17 00:00:00 2001 From: pepper <3782201+rohvani@users.noreply.github.com> Date: Thu, 10 Sep 2026 22:26:47 -0400 Subject: Preserve repeated IM order across conversation reopening Match newest live deliveries to newest eligible service occurrences so an older repeated message keeps its historical position after its session is recreated. Retain one-for-one matching within the rolling delivery window. Cover late head publication, repeated refreshes and reopening with an earlier offline message sharing the live message's body. All 116 Windows Release test targets pass, including 35 ChatService cases. GCC also compiles the test source with warnings treated as errors. --- indra/newview/llchatservicehistorycore.cpp | 7 +-- .../tests/llchatservicehistorycore_test.cpp | 54 ++++++++++++++++++++-- 2 files changed, 55 insertions(+), 6 deletions(-) diff --git a/indra/newview/llchatservicehistorycore.cpp b/indra/newview/llchatservicehistorycore.cpp index 44fe43b608..deeba807b4 100644 --- a/indra/newview/llchatservicehistorycore.cpp +++ b/indra/newview/llchatservicehistorycore.cpp @@ -375,7 +375,8 @@ std::list filterDirectHistoryDuplicates( const std::list& history, const std::list& live) { // Reserve known send times before receipt intervals and local-clock minutes. - // Within each kind, the earliest delivery takes the earliest eligible occurrence. + // Within each kind, match newest deliveries to newest eligible occurrences so + // earlier repeated messages retain their historical position. auto priority = [](const LLSD& message) { const std::string source = message["chat_service_time_source"].asString(); @@ -386,7 +387,7 @@ std::list filterDirectHistoryDuplicates( std::stable_sort(deliveries.begin(), deliveries.end(), [&](const LLSD* a, const LLSD* b) { return priority(*a) != priority(*b) ? priority(*a) < priority(*b) - : (*a)["timestamp"].asInteger() < (*b)["timestamp"].asInteger(); + : (*a)["timestamp"].asInteger() > (*b)["timestamp"].asInteger(); }); // Sorted iterators allow matching in time order while retaining the source order. @@ -398,7 +399,7 @@ std::list filterDirectHistoryDuplicates( } std::stable_sort(service.begin(), service.end(), [](auto a, auto b) { - return (*a)["timestamp"].asInteger() < (*b)["timestamp"].asInteger(); + return (*a)["timestamp"].asInteger() > (*b)["timestamp"].asInteger(); }); std::vector matched(deliveries.size(), false); for (size_t pos = 0; pos < deliveries.size(); ++pos) diff --git a/indra/newview/tests/llchatservicehistorycore_test.cpp b/indra/newview/tests/llchatservicehistorycore_test.cpp index 6316d5b7bc..81a87eef84 100644 --- a/indra/newview/tests/llchatservicehistorycore_test.cpp +++ b/indra/newview/tests/llchatservicehistorycore_test.cpp @@ -664,15 +664,15 @@ template<> template<> void object_t::test<26>() LLSD delivered = repeatedDelivery(1); delivered["chat_service_time_source"] = "receipt"; - // Receipt times do not identify a particular repeated send. Allocate the earliest - // eligible occurrence, preserving the other occurrence even at the same second. + // Prefer the most recent eligible occurrence when a receipt can match repeated + // sends, preserving the earlier occurrence in history. for (bool reversed : {false, true}) { std::list history{earlier, later}; if (reversed) history.reverse(); const auto filtered = filterDirectHistoryDuplicates(history, {delivered}); ensure_equals("one live delivery consumes one service occurrence", filtered.size(), size_t(1)); - ensure_equals("the later repeated send survives", filtered.front()["chat_service_msg_id"].asString(), std::string(SECOND)); + ensure_equals("the earlier repeated send survives", filtered.front()["chat_service_msg_id"].asString(), std::string(FIRST)); } } @@ -949,4 +949,52 @@ template<> template<> void object_t::test<34>() ensure_equals("source window order preserves counts", mergeDirectHistory(right, left).size(), size_t(3)); } +template<> template<> void object_t::test<35>() +{ + // Recreating a session leaves only the latest delivery live. An offline repeat + // within its receipt window must retain its earlier place in the transcript. + LLSD earlier = contextRow(1); + earlier["message"] = "hello"; + earlier["timestamp"] = 1789092403; + LLSD between = contextRow(2); + between["message"] = "6"; + between["timestamp"] = 1789092414; + LLSD later = contextRow(3); + later["message"] = "hello"; + later["timestamp"] = 1789092446; + const Messages archive{earlier, between, later}; + + for (S32 delay : {0, 2, 15}) + { + History history; + history.setLoaded({earlier, between}); + LLSD delivery = liveCopy(later); + delivery["chat_service_time_source"] = "receipt"; + delivery["timestamp"] = later["timestamp"].asInteger() + delay; + Messages current{delivery}; + + // The archive read can complete before the latest service head arrives. + replaceHistory(current, history.compose({}, {delivery}, 10, true), true); + for (int publication = 0; publication < 3; ++publication) + { + const auto composed = history.compose(archive, {delivery}, 10, true); + ensure_equals("one delivery replaces only one historical occurrence", composed.size(), size_t(2)); + ensure_equals("the earlier hello retains its identity and position", + composed.front()["chat_service_msg_id"].asString(), earlier["chat_service_msg_id"].asString()); + const bool changed = replaceHistory(current, composed, true); + ensure_equals("unchanged publications do not replay the transcript", changed, publication == 0); + ensure_equals("both hellos and the intervening message remain", current.size(), size_t(3)); + } + + // Reopening with all rows historical preserves the same message order. + Messages reopened; + replaceHistory(reopened, archive, true); + auto expected = reopened.begin(); + for (const LLSD& row : current) + { + ensure_equals("reopening preserves message order", row["message"].asString(), (*expected++)["message"].asString()); + } + } +} + } -- 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(-) 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 6d8dd429d671e7d261404435b85e9e76831d29c1 Mon Sep 17 00:00:00 2001 From: pepper <3782201+rohvani@users.noreply.github.com> Date: Thu, 10 Sep 2026 23:13:02 -0400 Subject: Skip system conversations during ChatService discovery A stored Second Life notification can appear as a direct conversation with an all-zero participant UUID. Validate that entry but exclude it from resident history so it no longer rejects the entire conversation list and prevents other archives from downloading. Cover lists containing only a system entry and mixed system/resident entries in either order. Validated with the Release viewer build, all 116 Windows tests including 36 ChatService core cases, and GCC warning-as-error checks of the portable core and tests. --- indra/newview/llchatservicehistorycore.cpp | 13 ++++++--- .../tests/llchatservicehistorycore_test.cpp | 33 ++++++++++++++++++++-- 2 files changed, 40 insertions(+), 6 deletions(-) diff --git a/indra/newview/llchatservicehistorycore.cpp b/indra/newview/llchatservicehistorycore.cpp index deeba807b4..e38bdea259 100644 --- a/indra/newview/llchatservicehistorycore.cpp +++ b/indra/newview/llchatservicehistorycore.cpp @@ -799,15 +799,15 @@ bool validateConversationList(const LLSD& value, const LLUUID& agent_id, continue; } - // Direct entries must identify one unique peer and use the deterministic - // account/resident conversation ID with a valid latest-message token. + // Direct entries use the deterministic account/participant conversation ID + // with a valid latest-message token and no duplicate participants. std::string resident_text; std::string token; LLUUID resident; TimeUuidKey token_key; if (!llsdString(*it, "other_participant_id", resident_text) || !llsdString(*it, "last_msg_id", token) || - !parseCanonicalUuid(resident_text, resident) || resident.isNull() || + !parseCanonicalUuid(resident_text, resident) || resident == agent_id || !parseTimeUuid(token, token_key) || conversation != directConversationId(agent_id, resident) || @@ -817,7 +817,12 @@ bool validateConversationList(const LLSD& value, const LLUUID& agent_id, return false; } - entries.push_back({resident, conversation, token}); + // The service also lists system traffic with a null participant; only + // resident conversations can have a local history archive. + if (resident.notNull()) + { + entries.push_back({resident, conversation, token}); + } } return true; diff --git a/indra/newview/tests/llchatservicehistorycore_test.cpp b/indra/newview/tests/llchatservicehistorycore_test.cpp index 81a87eef84..b77aa97da9 100644 --- a/indra/newview/tests/llchatservicehistorycore_test.cpp +++ b/indra/newview/tests/llchatservicehistorycore_test.cpp @@ -144,7 +144,7 @@ template<> template<> void object_t::test<5>() template<> template<> void object_t::test<6>() { - // Discovery rejects duplicate or null direct peers after accepting one canonical row. + // Discovery rejects duplicate peers and ignores the null system participant. LLSD list = LLSD::emptyArray(); LLSD entry; entry["conversation_type"] = "direct"; @@ -163,7 +163,8 @@ template<> template<> void object_t::test<6>() entry["other_participant_id"] = LLUUID::null.asString(); entry["conversation_id"] = directConversationId(AGENT, LLUUID::null); null_list.append(entry); - ensure("null resident rejected", !validateConversationList(null_list, AGENT, entries)); + ensure("null system participant ignored", validateConversationList(null_list, AGENT, entries)); + ensure("system conversation has no resident history", entries.empty()); } template<> template<> void object_t::test<7>() @@ -997,4 +998,32 @@ template<> template<> void object_t::test<35>() } } +template<> template<> void object_t::test<36>() +{ + // System traffic may share a discovery response with ordinary resident history. + LLSD peer; + peer["conversation_type"] = "direct"; + peer["conversation_id"] = directConversationId(AGENT, RESIDENT); + peer["other_participant_id"] = RESIDENT.asString(); + peer["last_msg_id"] = SECOND; + LLSD system = peer; + system["conversation_id"] = directConversationId(AGENT, LLUUID::null); + system["other_participant_id"] = LLUUID::null.asString(); + + for (bool system_first : {true, false}) + { + LLSD list = LLSD::emptyArray(); + list.append(system_first ? system : peer); + list.append(system_first ? peer : system); + std::vector entries; + ensure("system entry does not block resident discovery", + validateConversationList(list, AGENT, entries)); + ensure_equals("only the resident is scheduled", entries.size(), size_t(1)); + ensure_equals("resident identity preserved", entries.front().resident_id, RESIDENT); + ensure_equals("conversation identity preserved", entries.front().conversation_id, + peer["conversation_id"].asString()); + ensure_equals("history token preserved", entries.front().last_msg_id, std::string(SECOND)); + } +} + } -- 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(-) 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