From 473d6c2558c72a8dbbcf591e387c080baf2f531a Mon Sep 17 00:00:00 2001 From: pepper <3782201+rohvani@users.noreply.github.com> Date: Tue, 11 Aug 2026 21:02:47 -0400 Subject: Implement ChatService persistent history stitching Synchronize eligible direct IM conversations through the ChatService capabilities into durable per-resident CSV archives. Validate deterministic conversation IDs, UUIDv1 ordering, complete discovery and history payloads, and the account-wide deletion cutoff before a bounded pass can append or rebuild an archive. Run discovery, pacing, retries, metadata resolution, and participant paging through one account-scoped manager. Immediately prioritize new Direct sessions, P2P previews, and accepted inbound IMs while honoring transcript consent, authoritative mute state, capability changes, and fail-closed account state. Stitch ordinary and monthly legacy plaintext before the oldest accumulated service row, keep normal plaintext logging active, and merge the first validated service page in memory while older backfill continues. Drive Direct and Preview reloads and their nonmodal loading status from shared resident snapshots. Maintain the human-readable chat_service_index.csv manifest and protect archive updates with strict validation, torn-tail repair, corrupt-file preservation, private file stamps, and atomic replacement. Extend Delete Transcripts with a durable TimeUUID boundary and an asynchronous sweep that suppresses every historical source until cleanup completes safely. Add core coverage for deterministic IDs, UUIDv1 and timestamp validation, strict page and list contracts, RFC 4180 CSV quoting, archive scanning, torn tails, and corrupt records. --- indra/newview/llnotificationhandlerutil.cpp | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) (limited to 'indra/newview/llnotificationhandlerutil.cpp') diff --git a/indra/newview/llnotificationhandlerutil.cpp b/indra/newview/llnotificationhandlerutil.cpp index cdf7f05ada..ca11107f61 100644 --- a/indra/newview/llnotificationhandlerutil.cpp +++ b/indra/newview/llnotificationhandlerutil.cpp @@ -327,11 +327,11 @@ void LLHandlerUtil::decIMMesageCounter(const LLNotificationPtr& notification) { LLSD arg; arg["session_id"] = session_id; - session->mNumUnread--; + session->mNumUnread = llmax(0, session->mNumUnread - 1); arg["num_unread"] = session->mNumUnread; - session->mParticipantUnreadMessageCount--; + session->mParticipantUnreadMessageCount = + llmax(0, session->mParticipantUnreadMessageCount - 1); arg["participant_unread"] = session->mParticipantUnreadMessageCount; LLIMModel::getInstance()->mNewMsgSignal(arg); } } - -- cgit v1.3 From e3c57f57c7585c1cf99d4b86fd70b06b1ce2b58d Mon Sep 17 00:00:00 2001 From: pepper <3782201+rohvani@users.noreply.github.com> Date: Thu, 13 Aug 2026 13:40:10 -0400 Subject: Improve ChatService history readability Add phase-oriented comments around history synchronization, archive validation, asynchronous view updates, and transcript deletion. Expand dense control flow and restore readable spacing throughout the ChatService implementation and its integration points without changing behavior. Restore unrelated end-of-file formatting so it drops out of the overall PR diff. --- indra/llmessage/llavatarnamecache.cpp | 1 + indra/newview/CMakeLists.txt | 1 + indra/newview/app_settings/settings.xml | 20 +- indra/newview/llappviewer.cpp | 3 + indra/newview/llavataractions.cpp | 7 +- indra/newview/llchathistory.cpp | 6 +- indra/newview/llchatservicehistory.cpp | 933 ++++++++++++++++++--- indra/newview/llchatservicehistory.h | 24 + indra/newview/llchatservicehistorycore.cpp | 147 +++- indra/newview/llchatservicehistorycore.h | 29 +- indra/newview/llconversationlog.cpp | 1 + indra/newview/llconversationloglist.cpp | 2 + indra/newview/llfloaterconversationlog.cpp | 5 + indra/newview/llfloaterconversationpreview.cpp | 143 +++- indra/newview/llfloaterconversationpreview.h | 6 + indra/newview/llfloaterimcontainer.cpp | 20 +- indra/newview/llfloaterimsession.cpp | 19 +- indra/newview/llfloaterimsession.h | 2 + indra/newview/llfloaterimsessiontab.cpp | 3 + indra/newview/llfloaterpreference.cpp | 8 +- indra/newview/llfloaterpreference.h | 2 + indra/newview/llimview.cpp | 148 +++- indra/newview/llimview.h | 16 +- indra/newview/lllogchat.cpp | 51 +- indra/newview/lllogchat.h | 6 +- indra/newview/llnotificationhandlerutil.cpp | 1 + indra/newview/llpanelprofile.cpp | 1 + indra/newview/llviewerregion.cpp | 1 + .../tests/llchatservicehistorycore_test.cpp | 61 +- 29 files changed, 1427 insertions(+), 240 deletions(-) (limited to 'indra/newview/llnotificationhandlerutil.cpp') diff --git a/indra/llmessage/llavatarnamecache.cpp b/indra/llmessage/llavatarnamecache.cpp index c024c2092f..86b36315dc 100644 --- a/indra/llmessage/llavatarnamecache.cpp +++ b/indra/llmessage/llavatarnamecache.cpp @@ -924,3 +924,4 @@ bool max_age_from_cache_control(const std::string& cache_control, S32 *max_age) } return false; } + diff --git a/indra/newview/CMakeLists.txt b/indra/newview/CMakeLists.txt index 622ebceac0..0b5d41b9dd 100644 --- a/indra/newview/CMakeLists.txt +++ b/indra/newview/CMakeLists.txt @@ -2466,3 +2466,4 @@ if (LL_TESTS) endif (LL_TESTS) check_message_template(${VIEWER_BINARY_NAME}) + diff --git a/indra/newview/app_settings/settings.xml b/indra/newview/app_settings/settings.xml index ac026b80bd..bde77cc6c5 100644 --- a/indra/newview/app_settings/settings.xml +++ b/indra/newview/app_settings/settings.xml @@ -16496,16 +16496,16 @@ 0 ChatServiceEnabled - - Comment - Enable accumulated direct-message history from ChatService for this login. - Persist - 1 - Type - Boolean - Value - 1 - + + Comment + Enable accumulated direct-message history from ChatService for this login. + Persist + 1 + Type + Boolean + Value + 1 + SingleModeDoubleClickOpenWindow Comment diff --git a/indra/newview/llappviewer.cpp b/indra/newview/llappviewer.cpp index a1f1f96257..cc35f0bd5d 100644 --- a/indra/newview/llappviewer.cpp +++ b/indra/newview/llappviewer.cpp @@ -5425,6 +5425,7 @@ void LLAppViewer::idleShutdown() // All floaters are closed. Tell server we want to quit. if (!logoutRequestSent()) { + // Fence outstanding history work before logout tears down its account and capability context. LLChatServiceHistory::stop(); sendLogoutRequest(); @@ -5774,6 +5775,7 @@ void LLAppViewer::idleNetwork() void LLAppViewer::disconnectViewer() { + // This is idempotent and also covers disconnect paths that bypass the normal logout request. LLChatServiceHistory::stop(); if (gDisconnected) @@ -6109,6 +6111,7 @@ void LLAppViewer::handleLoginComplete() gDebugInfo["MainloopTimeoutState"] = LLAppViewer::instance()->mMainloopTimeout->getState(); } + // Start only after login has established the per-account storage and region capability context. LLChatServiceHistory::start(); mOnLoginCompleted(); diff --git a/indra/newview/llavataractions.cpp b/indra/newview/llavataractions.cpp index c8d80f3dd5..d5224b1643 100644 --- a/indra/newview/llavataractions.cpp +++ b/indra/newview/llavataractions.cpp @@ -1241,7 +1241,12 @@ void LLAvatarActions::inviteToGroup(const LLUUID& id) // static void LLAvatarActions::viewChatHistory(const LLUUID& id) { - if (LLChatServiceHistory::historySuppressed()) return; + // A pending account-wide deletion suppresses both transcript and service sources. + if (LLChatServiceHistory::historySuppressed()) + { + return; + } + const std::vector& conversations = LLConversationLog::instance().getConversations(); std::vector::const_iterator iter = conversations.begin(); diff --git a/indra/newview/llchathistory.cpp b/indra/newview/llchathistory.cpp index 2a8acbedff..fc9959b21d 100644 --- a/indra/newview/llchathistory.cpp +++ b/indra/newview/llchathistory.cpp @@ -977,10 +977,12 @@ protected: menu->setItemVisible("Zoom In", should_show_zoom && gObjectList.findObject(mAvatarID)); menu->setItemEnabled("Block Unblock", LLAvatarActions::canBlock(mAvatarID)); menu->setItemEnabled("Mute Text", LLAvatarActions::canBlock(mAvatarID)); - menu->setItemEnabled("Chat History", + + const bool history_available = !LLChatServiceHistory::historySuppressed() && (LLLogChat::isTranscriptExist(mAvatarID) || - LLChatServiceHistory::localHistoryExists(mAvatarID))); + LLChatServiceHistory::localHistoryExists(mAvatarID)); + menu->setItemEnabled("Chat History", history_available); } menu->setItemEnabled("Map", (LLAvatarTracker::instance().isBuddyOnline(mAvatarID) && is_agent_mappable(mAvatarID)) || gAgent.isGodlike() ); diff --git a/indra/newview/llchatservicehistory.cpp b/indra/newview/llchatservicehistory.cpp index 2351a73655..74b7353068 100644 --- a/indra/newview/llchatservicehistory.cpp +++ b/indra/newview/llchatservicehistory.cpp @@ -53,6 +53,7 @@ #include #include +// Keep exact-path legacy parsing behind LLLogChat's narrow stitching bridge. struct LLChatServiceHistoryAccess { static void loadLegacy(const std::string& path, std::list& messages, @@ -82,9 +83,27 @@ const U64 UUID_TICK_LIMIT = (1ULL << 60); const U32 MAX_PASS_ROWS = 50000; const size_t MAX_PASS_BYTES = 64 * 1024 * 1024; -enum EStateSafety { STATE_UNKNOWN, STATE_SAFE, STATE_UNSAFE }; -enum EMetadata { META_UNREQUESTED, META_PENDING, META_RESOLVED, META_FAILED }; -enum ESummary { SUMMARY_ABSENT, SUMMARY_UNPREPARED, SUMMARY_VALID }; +enum EStateSafety +{ + STATE_UNKNOWN, + STATE_SAFE, + STATE_UNSAFE +}; + +enum EMetadata +{ + META_UNREQUESTED, + META_PENDING, + META_RESOLVED, + META_FAILED +}; + +enum ESummary +{ + SUMMARY_ABSENT, + SUMMARY_UNPREPARED, + SUMMARY_VALID +}; struct CapabilityContext { @@ -92,23 +111,36 @@ struct CapabilityContext std::string list_url; std::string history_url; - bool complete() const { return region_id.notNull() && !list_url.empty() && !history_url.empty(); } + bool complete() const + { + return region_id.notNull() && !list_url.empty() && !history_url.empty(); + } + bool operator==(const CapabilityContext& rhs) const { return region_id == rhs.region_id && list_url == rhs.list_url && history_url == rhs.history_url; } - bool operator!=(const CapabilityContext& rhs) const { return !(*this == rhs); } + + bool operator!=(const CapabilityContext& rhs) const + { + return !(*this == rhs); + } }; struct Summary { + // Logical validity and retained-row count. ESummary state = SUMMARY_UNPREPARED; U32 rows = 0; bool has_rows = false; + + // Physical identity captured by the last successful scan or publication. bool file_exists = false; U64 file_size = 0; S64 file_mtime = 0; + + // Validated TimeUUID bounds for seam and append decisions. TimeUuidKey oldest; TimeUuidKey newest; }; @@ -124,13 +156,17 @@ struct Metadata struct Resident { + // Discovery identity and the durable service range already covered. std::string conversation_id; std::string advertised_token; std::string covered_token; U32 covered_serial = 0; U32 archive_serial = 0; + Summary summary; Metadata metadata; + + // Coalesced scheduler state for this resident's current or next pass. bool listed = false; bool force_head = false; bool forced_followup = false; @@ -138,6 +174,7 @@ struct Resident bool retry_used = false; bool metadata_waiting = false; bool priority_waiting = false; + LLChatServiceHistory::Snapshot snapshot; }; @@ -156,38 +193,57 @@ struct HttpResult struct Runtime { + // Account lifecycle and manager wake state. U32 epoch = 0; bool running = false; bool rollout = false; bool wake_pending = false; + + // Discovery and archive-maintenance state. bool initialized_archives = false; bool list_valid = false; bool list_needed = true; bool list_retry_used = false; + + // Deletion command and storage-maintenance state. bool delete_requested = false; bool delete_active = false; bool index_dirty = false; bool local_content_exists = false; + + // Durable deletion and privacy gates. U64 delete_click_ticks = 0; U64 deleted_before_ticks = 0; EStateSafety state_safety = STATE_UNKNOWN; bool cleanup_pending = false; + + // Manager-wide network deadlines. F64 network_not_before = 0.0; F64 next_list = 0.0; + + // Login-scoped capability, storage, and identity inputs. CapabilityContext context; std::string account_dir; std::string delimiter; LLUUID agent_id; + + // One active resident plus a coalesced priority/background queue. LLUUID active_resident; LLUUID priority_resident; std::deque queue; std::map residents; + + // Account-scoped completions and settings observers. LLChatServiceHistory::delete_callback_t delete_callback; boost::signals2::connection consent_connection; boost::signals2::connection show_history_connection; }; +// The main-thread manager owns all mutable scheduling and resident state for one +// login. Epoch checks fence coroutine and callback work across account changes. Runtime sRuntime; + +// General-queue filesystem jobs serialize with view scans and transcript deletion. LLMutex sStorageMutex; std::unique_ptr sWake; boost::signals2::signal sSnapshotSignal; @@ -207,8 +263,15 @@ std::string accountPath(const std::string& filename) std::string childPath(const std::string& directory, const std::string& delimiter, const std::string& filename) { - if (directory.empty() || filename.empty()) return directory + filename; - if (directory.back() == '/' || directory.back() == '\\') return directory + filename; + if (directory.empty() || filename.empty()) + { + return directory + filename; + } + if (directory.back() == '/' || directory.back() == '\\') + { + return directory + filename; + } + return directory + delimiter + filename; } @@ -252,6 +315,8 @@ void wakeManager() void publishSnapshot(const LLUUID& id, Resident& resident) { + // Recompute account-wide presentation permission at publication time so views + // cannot retain service rows after consent, state, or deletion gates change. resident.snapshot.archive_serial = resident.archive_serial; resident.snapshot.metadata_resolved = resident.metadata.state == META_RESOLVED; if (resident.snapshot.metadata_resolved) @@ -277,6 +342,8 @@ void setWorkActive(const LLUUID& id, bool active) void queueResident(const LLUUID& id, bool priority) { + // One resident has at most one queued occurrence. A fresh priority trigger moves + // that occurrence to the front without bypassing global request pacing. sRuntime.queue.erase(std::remove(sRuntime.queue.begin(), sRuntime.queue.end(), id), sRuntime.queue.end()); if (priority) @@ -291,7 +358,10 @@ void queueResident(const LLUUID& id, bool priority) void clearPriority(const LLUUID& id) { - if (sRuntime.priority_resident == id) sRuntime.priority_resident.setNull(); + if (sRuntime.priority_resident == id) + { + sRuntime.priority_resident.setNull(); + } } void failResidentPass(const LLUUID& id, Resident& resident) @@ -304,6 +374,8 @@ void failResidentPass(const LLUUID& id, Resident& resident) bool parseDecimalTicks(const std::string& text, U64& value) { + // Privacy cutoffs use one canonical unsigned decimal form bounded to UUIDv1's + // 60-bit timestamp field. if (text.empty() || (text.size() > 1 && text[0] == '0') || text.find_first_not_of("0123456789") != std::string::npos) { @@ -325,6 +397,8 @@ bool parseDecimalTicks(const std::string& text, U64& value) bool inspectRegular(const std::string& path, bool& exists) { + // Owned artifacts must be regular files; directories and reparse/symlink paths + // fail inspection rather than being followed. exists = false; #if LL_WINDOWS const std::wstring wide = ll_convert(path); @@ -352,7 +426,11 @@ bool syncDirectory(const std::string& path) return true; #else const std::string::size_type separator = path.find_last_of("/\\"); - if (separator == std::string::npos) return false; + if (separator == std::string::npos) + { + return false; + } + const std::string directory = path.substr(0, separator); const int descriptor = ::open(directory.c_str(), O_RDONLY | O_DIRECTORY); if (descriptor < 0) @@ -373,7 +451,10 @@ bool syncFile(const std::string& path) FILE_ATTRIBUTE_NORMAL, NULL); if (handle == INVALID_HANDLE_VALUE || !FlushFileBuffers(handle)) { - if (handle != INVALID_HANDLE_VALUE) CloseHandle(handle); + if (handle != INVALID_HANDLE_VALUE) + { + CloseHandle(handle); + } return false; } CloseHandle(handle); @@ -382,7 +463,10 @@ bool syncFile(const std::string& path) const int descriptor = ::open(path.c_str(), O_RDONLY); if (descriptor < 0 || ::fsync(descriptor) != 0) { - if (descriptor >= 0) ::close(descriptor); + if (descriptor >= 0) + { + ::close(descriptor); + } return false; } ::close(descriptor); @@ -392,6 +476,8 @@ bool syncFile(const std::string& path) bool writeReplace(const std::string& destination, const std::string& contents, bool durable) { + // Write a complete sibling temporary before one atomic replacement. Privacy + // state additionally flushes file and directory metadata before returning. const std::string temporary = destination + ".tmp"; bool exists = false; if (!inspectRegular(destination, exists) || !inspectRegular(temporary, exists)) @@ -414,7 +500,10 @@ bool writeReplace(const std::string& destination, const std::string& contents, b // Privacy state is flushed before replacement; its directory entry is committed afterward. if (durable) { - if (!syncFile(temporary)) return false; + if (!syncFile(temporary)) + { + return false; + } } #if LL_WINDOWS const std::wstring from = ll_convert(temporary); @@ -435,10 +524,16 @@ bool writeReplace(const std::string& destination, const std::string& contents, b bool replacePrefix(const std::string& path, std::streamoff bytes) { + // Torn-tail repair copies only the last parser-confirmed prefix, then replaces + // the canonical without interpreting or rewriting valid records. const std::string temporary = path + ".tmp"; bool exists = false; if (bytes < 0 || !inspectRegular(path, exists) || !exists || - !inspectRegular(temporary, exists)) return false; + !inspectRegular(temporary, exists)) + { + return false; + } + llifstream input(path.c_str(), std::ios::binary); llofstream output(temporary.c_str(), std::ios::binary | std::ios::trunc); std::array buffer; @@ -448,7 +543,10 @@ bool replacePrefix(const std::string& path, std::streamoff bytes) const std::streamsize count = static_cast( llmin(remaining, buffer.size())); input.read(buffer.data(), count); - if (input.gcount() != count) return false; + if (input.gcount() != count) + { + return false; + } output.write(buffer.data(), count); remaining -= count; } @@ -456,7 +554,10 @@ bool replacePrefix(const std::string& path, std::streamoff bytes) output.close(); const bool input_ok = input.is_open() && !input.bad(); input.close(); - if (!input_ok || output.fail() || remaining) return false; + if (!input_ok || output.fail() || remaining) + { + return false; + } #if LL_WINDOWS const std::wstring from = ll_convert(temporary); const std::wstring to = ll_convert(path); @@ -475,7 +576,12 @@ bool readStateFile(const std::string& path, StateResult& state) } llifstream input(path.c_str(), std::ios::binary); LLSD data; - if (!input.is_open()) return false; + if (!input.is_open()) + { + return false; + } + + // Privacy state accepts exactly the two canonical fields and no trailing bytes. const S32 parsed = LLSDSerialize::fromXMLDocument(data, input, false); input.clear(); input >> std::ws; @@ -494,6 +600,8 @@ bool readStateFile(const std::string& path, StateResult& state) StateResult loadState(const std::string& path) { + // A valid canonical state is authoritative. Any ambiguous canonical/temporary + // combination fails closed until deletion recovery publishes a new state. StateResult result; const std::string temporary = path + ".tmp"; bool canonical_exists = false; @@ -523,7 +631,13 @@ StateResult loadState(const std::string& path) U64 recoverBoundaryCandidate(const std::string& path) { bool exists = false; - if (!inspectRegular(path, exists) || !exists) return 0; + if (!inspectRegular(path, exists) || !exists) + { + return 0; + } + + // Recovery salvages only a canonical prior cutoff; malformed surrounding state + // cannot weaken the newer click-time boundary. llifstream input(path.c_str(), std::ios::binary); LLSD data; U64 boundary = 0; @@ -538,6 +652,8 @@ U64 recoverBoundaryCandidate(const std::string& path) bool recoverStateForDelete(const std::string& path, U64 boundary) { + // Build a durable pending state through an integration-owned recovery path so + // an unsafe canonical remains present until its replacement is ready. const std::string temporary = path + ".tmp"; const std::string::size_type separator = path.find_last_of("/\\"); const std::string recovery = path.substr(0, separator + 1) + INDEX_NAME + ".tmp"; @@ -545,31 +661,63 @@ bool recoverStateForDelete(const std::string& path, U64 boundary) data["deleted_before_uuid_ticks"] = std::to_string(boundary); data["cleanup_pending"] = true; std::ostringstream serialized; - if (!LLSDSerialize::toPrettyXML(data, serialized)) return false; + if (!LLSDSerialize::toPrettyXML(data, serialized)) + { + return false; + } + + // The index temporary is an owned scratch name that deletion already knows how + // to sweep; it cannot be mistaken for authoritative privacy state. + if (LLFile::remove(recovery, ENOENT) != 0 && errno != ENOENT) + { + return false; + } - // Use an owned index temporary so an unsafe state entry remains until replacement. - if (LLFile::remove(recovery, ENOENT) != 0 && errno != ENOENT) return false; llofstream output(recovery.c_str(), std::ios::binary | std::ios::trunc); - if (!output.is_open()) return false; + if (!output.is_open()) + { + return false; + } output << serialized.str(); output.flush(); output.close(); - if (output.fail()) return false; - if (!syncFile(recovery)) return false; + if (output.fail()) + { + return false; + } + if (!syncFile(recovery)) + { + return false; + } #if LL_WINDOWS if (!MoveFileExW(ll_convert(recovery).c_str(), ll_convert(temporary).c_str(), - MOVEFILE_REPLACE_EXISTING | MOVEFILE_WRITE_THROUGH)) return false; + MOVEFILE_REPLACE_EXISTING | MOVEFILE_WRITE_THROUGH)) + { + return false; + } #else if (::rename(recovery.c_str(), temporary.c_str()) != 0 || - !syncDirectory(temporary)) return false; + !syncDirectory(temporary)) + { + return false; + } #endif - if (LLFile::remove(path, ENOENT) != 0 && errno != ENOENT) return false; + if (LLFile::remove(path, ENOENT) != 0 && errno != ENOENT) + { + return false; + } #if LL_WINDOWS if (!MoveFileExW(ll_convert(temporary).c_str(), - ll_convert(path).c_str(), MOVEFILE_WRITE_THROUGH)) return false; + ll_convert(path).c_str(), MOVEFILE_WRITE_THROUGH)) + { + return false; + } #else - if (::rename(temporary.c_str(), path.c_str()) != 0 || !syncDirectory(path)) return false; + if (::rename(temporary.c_str(), path.c_str()) != 0 || !syncDirectory(path)) + { + return false; + } #endif return true; } @@ -591,9 +739,22 @@ bool clearUnsafeStateTemporary(const std::string& path) { const std::string temporary = path + ".tmp"; bool exists = false; - if (inspectRegular(temporary, exists)) return true; - if (LLFile::remove(temporary, ENOENT) != 0 && errno != ENOENT) return false; - if (!inspectRegular(temporary, exists) || exists) return false; + if (inspectRegular(temporary, exists)) + { + return true; + } + + // A nonregular temporary is never authoritative and must be removed before a + // normal durable replacement can reuse its exact owned path. + if (LLFile::remove(temporary, ENOENT) != 0 && errno != ENOENT) + { + return false; + } + if (!inspectRegular(temporary, exists) || exists) + { + return false; + } + return syncDirectory(path); } @@ -649,8 +810,14 @@ bool prepareArchive(const std::string& path, const LLUUID& resident_id, const LL U64 boundary, Summary& summary, bool& bytes_changed) { LLMutexLock lock(&sStorageMutex); + + // Classify the canonical under the same lock used by mutation and view scans. bool canonical_exists = false; - if (!inspectRegular(path, canonical_exists)) return false; + if (!inspectRegular(path, canonical_exists)) + { + return false; + } + ArchiveScan scan; if (scanArchive(path, agent_id, resident_id, boundary, 0, scan)) { @@ -659,6 +826,8 @@ bool prepareArchive(const std::string& path, const LLUUID& resident_id, const LL } if (scan.state == ARCHIVE_TORN) { + // Repair only when the private physical stamp still matches the classified + // file, then rescan the replacement before publishing its summary. U64 current_size = 0; S64 current_mtime = 0; if (!archiveStamp(path, current_size, current_mtime) || @@ -685,9 +854,13 @@ bool prepareArchive(const std::string& path, const LLUUID& resident_id, const LL if (!inspectRegular(corrupt, corrupt_exists)) { if (LLFile::remove(corrupt, ENOENT) != 0 && errno != ENOENT) + { return false; + } if (!inspectRegular(corrupt, corrupt_exists) || corrupt_exists) + { return false; + } } if (LLFile::remove(corrupt, ENOENT) != 0 && errno != ENOENT) { @@ -717,15 +890,27 @@ bool publishRows(const std::string& path, const std::vector& rows, { return true; } + + // The captured summary is a private physical identity check. External changes + // abort publication instead of reconciling unknown bytes. bool exists = false; - if (!inspectRegular(path, exists) || exists != resulting.file_exists) return false; + if (!inspectRegular(path, exists) || exists != resulting.file_exists) + { + return false; + } if (exists) { U64 file_size = 0; S64 file_mtime = 0; if (!archiveStamp(path, file_size, file_mtime) || - file_size != resulting.file_size || file_mtime != resulting.file_mtime) return false; + file_size != resulting.file_size || file_mtime != resulting.file_mtime) + { + return false; + } } + + // New and rebuilt archives publish one complete ascending CSV. Existing valid + // archives append only the fully validated newer stage. if (!append) { std::ostringstream output; @@ -758,12 +943,23 @@ bool publishRows(const std::string& path, const std::vector& rows, { return false; } - if (needs_separator) output << '\n'; - for (const Row& row : rows) writeCsvRow(output, row); + if (needs_separator) + { + output << '\n'; + } + for (const Row& row : rows) + { + writeCsvRow(output, row); + } output.flush(); output.close(); - if (output.fail()) return false; + if (output.fail()) + { + return false; + } } + + // Advance the in-memory summary only after the filesystem mutation succeeds. resulting.state = SUMMARY_VALID; resulting.rows += static_cast(rows.size()); if (!resulting.has_rows) @@ -785,7 +981,10 @@ std::vector enumerateArchives(const std::string& directory, while (iterator.next(name)) { LLUUID id; - if (canonicalArchiveName(name, id)) result.push_back(id); + if (canonicalArchiveName(name, id)) + { + result.push_back(id); + } } std::sort(result.begin(), result.end()); result.erase(std::unique(result.begin(), result.end()), result.end()); @@ -813,6 +1012,8 @@ bool regenerateIndex(const std::vector& ids, const std::string& directory, const std::string& delimiter) { LLMutexLock lock(&sStorageMutex); + + // Remove unsafe index paths before rebuilding the non-authoritative manifest. const std::string index = childPath(directory, delimiter, INDEX_NAME); const std::string temporary = index + ".tmp"; for (const std::string& path : { index, temporary }) @@ -822,10 +1023,17 @@ bool regenerateIndex(const std::vector& ids, if (!regular || (path == temporary && exists)) { if (LLFile::remove(path, ENOENT) != 0 && errno != ENOENT) + { + return false; + } + if (!inspectRegular(path, exists) || exists) + { return false; - if (!inspectRegular(path, exists) || exists) return false; + } } } + + // Include exactly the valid canonical archives still present at regeneration. std::vector> archives; for (const LLUUID& id : ids) { @@ -840,6 +1048,8 @@ bool regenerateIndex(const std::vector& ids, const int removed = LLFile::remove(index, ENOENT); return removed == 0 || errno == ENOENT; } + + // Pending metadata remains explicit and is replaced on a later regeneration. std::ostringstream output; output << INDEX_HEADER; for (const auto& archive : archives) @@ -856,6 +1066,9 @@ bool regenerateIndex(const std::vector& ids, HttpResult request(const std::string& url, const LLSD* post) { HttpResult result; + + // Every request advances one manager-wide deadline before suspension, so opens + // and retries cannot bypass the 2.1-second service spacing. sRuntime.network_not_before = llmax(sRuntime.network_not_before, F64(LLTimer::getTotalSeconds()) + REQUEST_SPACING); LLCoreHttpUtil::HttpCoroutineAdapter::ptr_t adapter = @@ -873,6 +1086,8 @@ HttpResult request(const std::string& url, const LLSD* post) result.status = status.getType(); if (status) { + // Capability adapters may place the response body either in the explicit + // content field or at the response root. result.body = response.has(LLCoreHttpUtil::HttpCoroutineAdapter::HTTP_RESULTS_CONTENT) ? response[LLCoreHttpUtil::HttpCoroutineAdapter::HTTP_RESULTS_CONTENT] : response; @@ -891,6 +1106,8 @@ bool retryable(S32 status) bool nameBlocked(const LLUUID& id, const Resident& resident) { + // Blocking fails closed until the authoritative mute list and the shared name + // record can evaluate both UUID and legacy username forms. LLMuteList* mute = LLMuteList::getInstance(); if (!mute || !mute->isLoadedFromServer() || mute->isMuted(id)) { @@ -948,7 +1165,12 @@ bool ensureMetadata(const LLUUID& id, Resident& resident) boost::signals2::connection connection = LLAvatarNameCache::get( id, [id, epoch, attempt](const LLUUID&, const LLAvatarName& name) { - if (!sRuntime.running || sRuntime.epoch != epoch) return; + // Epoch and attempt checks discard callbacks from a prior account or a + // timed-out lookup before they mutate resident state. + if (!sRuntime.running || sRuntime.epoch != epoch) + { + return; + } auto found = sRuntime.residents.find(id); if (found == sRuntime.residents.end() || found->second.metadata.state != META_PENDING || @@ -982,6 +1204,7 @@ bool ensureMetadata(const LLUUID& id, Resident& resident) bool baseNetworkEligible(const CapabilityContext& context) { + // The shared gate is re-evaluated before every request and publication phase. return sRuntime.running && sRuntime.rollout && transcriptConsent() && sRuntime.state_safety == STATE_SAFE && !sRuntime.cleanup_pending && !sRuntime.delete_requested && context.complete() && @@ -998,12 +1221,21 @@ void expireMetadata(); bool waitForWake(F64 seconds, U32 epoch) { - if (!ownsRuntime(epoch) || !sWake) return false; + if (!ownsRuntime(epoch) || !sWake) + { + return false; + } + + // Coalesce queued wake events, then sleep until either state changes or the + // nearest pacing, list, or metadata deadline arrives. sRuntime.wake_pending = false; sWake->discard(); llcoro::suspendUntilEventOnWithTimeout(*sWake, static_cast(llmax(0.01, seconds)), LLSDMap("timeout", true)); - if (!ownsRuntime(epoch)) return false; + if (!ownsRuntime(epoch)) + { + return false; + } sRuntime.wake_pending = false; return true; } @@ -1011,6 +1243,8 @@ bool waitForWake(F64 seconds, U32 epoch) bool pace(const CapabilityContext& context, U32 epoch, const LLUUID& resident_id = LLUUID::null) { + // Pacing remains interruptible so lifecycle, capability, mute, metadata, and + // deletion changes can cancel a queued request before network I/O begins. while (ownsRuntime(epoch)) { if (sampleContext() != context || sRuntime.delete_requested) @@ -1036,16 +1270,23 @@ bool pace(const CapabilityContext& context, U32 epoch, for (const auto& pair : sRuntime.residents) { if (pair.second.metadata.state == META_PENDING) + { wait = llmin(wait, llmax(0.01, pair.second.metadata.deadline - now)); + } } const U32 pending_before = pendingMetadataCount(); - if (!waitForWake(wait, epoch)) return false; + if (!waitForWake(wait, epoch)) + { + return false; + } expireMetadata(); if (pendingMetadataCount() < pending_before && pendingMetadataCount() < 8) { // Give the manager a turn to fill a newly-open name slot before cooldown ends. - if (resident_id.notNull()) queueResident(resident_id, - sRuntime.priority_resident == resident_id); + if (resident_id.notNull()) + { + queueResident(resident_id, sRuntime.priority_resident == resident_id); + } return false; } } @@ -1065,6 +1306,8 @@ void handleRequestFailure(const LLUUID& id, Resident& resident, S32 status) } if (retryable(status) && !resident.retry_used) { + // Transport, timeout, and server failures receive one delayed retry for this + // resident pass; later ordinary triggers may start a fresh pass. resident.retry_used = true; sRuntime.network_not_before = llmax(sRuntime.network_not_before, F64(LLTimer::getTotalSeconds()) + RETRY_DELAY); @@ -1085,7 +1328,10 @@ bool processList(const LLSD& body) return false; } std::set listed; - for (const ListEntry& entry : entries) listed.insert(entry.resident_id); + for (const ListEntry& entry : entries) + { + listed.insert(entry.resident_id); + } // Validate the complete list before preserving priority placeholders and appending background work. for (auto& pair : sRuntime.residents) @@ -1095,7 +1341,10 @@ bool processList(const LLSD& body) { sRuntime.queue.erase(std::remove(sRuntime.queue.begin(), sRuntime.queue.end(), pair.first), sRuntime.queue.end()); - if (sRuntime.priority_resident == pair.first) sRuntime.priority_resident.setNull(); + if (sRuntime.priority_resident == pair.first) + { + sRuntime.priority_resident.setNull(); + } pair.second.metadata_waiting = false; pair.second.priority_waiting = false; setWorkActive(pair.first, false); @@ -1112,7 +1361,12 @@ bool processList(const LLSD& body) resident.advertised_token = entry.last_msg_id; resident.listed = true; if (resident.metadata.state == META_FAILED) + { resident.metadata.state = META_UNREQUESTED; + } + + // Queue only when discovery or archive coverage says the head may contain + // unseen rows; token equality is not used as a TimeUUID ordering claim. if (!placeholder && (first_list || changed || resident.force_head || resident.covered_token != resident.advertised_token || resident.covered_serial != resident.archive_serial)) @@ -1127,11 +1381,23 @@ bool processList(const LLSD& body) void requestList(const CapabilityContext& context, U32 epoch) { - if (!pace(context, epoch)) return; - if (!baseNetworkEligible(context)) return; + if (!pace(context, epoch)) + { + return; + } + if (!baseNetworkEligible(context)) + { + return; + } + + // Discovery mutates resident scheduling state only after the complete response + // passes strict validation. const HttpResult response = request(context.list_url, NULL); if (!ownsRuntime(epoch) || sampleContext() != context || sRuntime.delete_requested || - !baseNetworkEligible(context)) return; + !baseNetworkEligible(context)) + { + return; + } if (response.status == 429) { sRuntime.network_not_before = llmax(sRuntime.network_not_before, @@ -1164,7 +1430,13 @@ void requestList(const CapabilityContext& context, U32 epoch) void syncResident(const LLUUID& id, const CapabilityContext& context, U32 epoch) { auto found = sRuntime.residents.find(id); - if (found == sRuntime.residents.end()) return; + if (found == sRuntime.residents.end()) + { + return; + } + + // Establish discovery, blocking, metadata, and archive-summary prerequisites + // before this resident becomes the manager's active network pass. bool needs_prepare = false; bool had_boundary = false; TimeUuidKey stored_newest; @@ -1206,6 +1478,9 @@ void syncResident(const LLUUID& id, const CapabilityContext& context, U32 epoch) resident.summary.has_rows; stored_newest = resident.summary.newest; } + + // Stage a complete bounded pass in memory. No canonical bytes change until every + // traversed page validates and reaches a terminal condition or durable seam. std::vector staged; std::set pass_ids; size_t staged_bytes = 0; @@ -1217,9 +1492,18 @@ void syncResident(const LLUUID& id, const CapabilityContext& context, U32 epoch) { if (!pace(context, epoch, id)) { - if (!ownsRuntime(epoch)) return; + if (!ownsRuntime(epoch)) + { + return; + } found = sRuntime.residents.find(id); - if (found == sRuntime.residents.end()) return; + if (found == sRuntime.residents.end()) + { + return; + } + + // Temporary account-wide ineligibility retains one occurrence; permanent + // resident ineligibility clears priority and leaves the pass dormant. Resident& suspended = found->second; suspended.snapshot.head_preview.clear(); setWorkActive(id, false); @@ -1234,16 +1518,26 @@ void syncResident(const LLUUID& id, const CapabilityContext& context, U32 epoch) if (!already_queued) { if (temporarily_ineligible) + { queueResident(id, sRuntime.priority_resident == id); + } else + { clearPriority(id); + } } return; } + + // Capture the validated conversation and cursor immediately before issuing + // this page request. LLSD post; { found = sRuntime.residents.find(id); - if (!ownsRuntime(epoch) || found == sRuntime.residents.end()) return; + if (!ownsRuntime(epoch) || found == sRuntime.residents.end()) + { + return; + } Resident& current = found->second; if (sRuntime.priority_resident.notNull() && sRuntime.priority_resident != id) { @@ -1254,13 +1548,22 @@ void syncResident(const LLUUID& id, const CapabilityContext& context, U32 epoch) current.first_request_started = true; post["conversation_id"] = current.conversation_id; post["limit"] = 100; - if (!cursor.empty()) post["before_msg_id"] = cursor; + if (!cursor.empty()) + { + post["before_msg_id"] = cursor; + } } + const HttpResult response = request(context.history_url, &post); Page page; found = sRuntime.residents.find(id); - if (!ownsRuntime(epoch) || found == sRuntime.residents.end()) return; + if (!ownsRuntime(epoch) || found == sRuntime.residents.end()) { + return; + } + { + // Recheck account and resident eligibility after suspension, then validate + // the complete response before exposing preview or staging rows. Resident& after_request = found->second; if (sRuntime.delete_requested || sampleContext() != context || !networkEligible(id, after_request, context)) @@ -1292,7 +1595,10 @@ void syncResident(const LLUUID& id, const CapabilityContext& context, U32 epoch) } if (!exposed_preview && !page.rows.empty()) { - if (!networkEligible(id, after_request, context)) break; + if (!networkEligible(id, after_request, context)) + { + break; + } after_request.snapshot.head_preview = page.rows; exposed_preview = true; publishSnapshot(id, after_request); @@ -1314,7 +1620,10 @@ void syncResident(const LLUUID& id, const CapabilityContext& context, U32 epoch) return prepareArchive(path, id, agent_id, boundary, summary, bytes_changed); }); found = sRuntime.residents.find(id); - if (!ownsRuntime(epoch) || found == sRuntime.residents.end()) return; + if (!ownsRuntime(epoch) || found == sRuntime.residents.end()) + { + return; + } { Resident& after_prepare = found->second; if (!prepared) @@ -1347,7 +1656,10 @@ void syncResident(const LLUUID& id, const CapabilityContext& context, U32 epoch) // Archive preparation can suspend across logout; never retain its old Resident reference. found = sRuntime.residents.find(id); - if (!ownsRuntime(epoch) || found == sRuntime.residents.end()) return; + if (!ownsRuntime(epoch) || found == sRuntime.residents.end()) + { + return; + } Resident& page_resident = found->second; bool reached_stored_newest = false; for (const Row& row : page.rows) @@ -1368,11 +1680,17 @@ void syncResident(const LLUUID& id, const CapabilityContext& context, U32 epoch) sRuntime.active_resident.setNull(); return; } + + // The durable newest key is the accumulation boundary. Reaching it ends + // this pass without re-reading or reconciling older canonical rows. if (had_boundary && row.key <= stored_newest) { reached_stored_newest = true; break; } + + // Count the serialized field payload against one pass-wide memory bound + // before retaining the row. const size_t bytes = row.conversation_id.size() + row.msg_id.size() + 36 + row.from_name.size() + row.message.size() + std::to_string(row.dialog).size() + row.created_at.size(); @@ -1401,23 +1719,36 @@ void syncResident(const LLUUID& id, const CapabilityContext& context, U32 epoch) if (!complete) { found = sRuntime.residents.find(id); - if (!ownsRuntime(epoch) || found == sRuntime.residents.end()) return; + if (!ownsRuntime(epoch) || found == sRuntime.residents.end()) + { + return; + } Resident& final_resident = found->second; final_resident.snapshot.head_preview.clear(); if (std::find(sRuntime.queue.begin(), sRuntime.queue.end(), id) == sRuntime.queue.end()) { failResidentPass(id, final_resident); } - else publishSnapshot(id, final_resident); + else + { + publishSnapshot(id, final_resident); + } sRuntime.active_resident.setNull(); return; } + + // The service pages arrive newest-first; canonical publication is oldest-first. std::sort(staged.begin(), staged.end(), [](const Row& left, const Row& right) { return left.key < right.key; }); + found = sRuntime.residents.find(id); - if (!ownsRuntime(epoch) || found == sRuntime.residents.end()) return; + if (!ownsRuntime(epoch) || found == sRuntime.residents.end()) + { + return; + } + Resident& final_resident = found->second; if (!networkEligible(id, final_resident, context) || sRuntime.delete_requested || sampleContext() != context) @@ -1433,26 +1764,36 @@ void syncResident(const LLUUID& id, const CapabilityContext& context, U32 epoch) bool publication_failed = false; if (!staged.empty()) { - // The final gate precedes the one bounded mutation job; later gate changes stop follow-up. Summary resulting = final_resident.summary; - const bool append = final_resident.summary.state == SUMMARY_VALID && final_resident.summary.has_rows; + const bool append = final_resident.summary.state == SUMMARY_VALID && + final_resident.summary.has_rows; const std::string account_dir = sRuntime.account_dir; const std::string path = archivePath(account_dir, sRuntime.delimiter, id); + + // Re-sample account, capability, deletion, and blocking gates immediately + // before dispatching one bounded mutation. A later Delete sweep owns any + // artifact that lands after this final dispatch boundary. if (!ownsRuntime(epoch) || sRuntime.account_dir != account_dir || sampleContext() != context || sRuntime.delete_requested || !networkEligible(id, final_resident, context)) { return; } + LL::WorkQueue::ptr_t general = LL::WorkQueue::getInstance("General"); changed = general && general->waitForResult( [path, staged, append, &resulting]() mutable { return publishRows(path, staged, append, resulting); }); + found = sRuntime.residents.find(id); if (!ownsRuntime(epoch) || sRuntime.account_dir != account_dir || - found == sRuntime.residents.end()) return; + found == sRuntime.residents.end()) + { + return; + } + Resident& after_publish = found->second; if (changed) { @@ -1474,27 +1815,39 @@ void syncResident(const LLUUID& id, const CapabilityContext& context, U32 epoch) publication_failed = true; } } + found = sRuntime.residents.find(id); - if (!ownsRuntime(epoch) || found == sRuntime.residents.end()) return; + if (!ownsRuntime(epoch) || found == sRuntime.residents.end()) + { + return; + } + + // Publish the new archive generation, clear the transient preview, and retain at + // most one inbound follow-up that arrived after this pass began. Resident& applied = found->second; + if (sRuntime.delete_requested) { applied.snapshot.head_preview.clear(); sRuntime.active_resident.setNull(); return; } + if (changed || staged.empty()) { applied.covered_token = applied.advertised_token; applied.covered_serial = applied.archive_serial; } + if (!publication_failed) { applied.retry_used = false; applied.force_head = false; } + applied.snapshot.head_preview.clear(); publishSnapshot(id, applied); + if (applied.forced_followup && networkEligible(id, applied, context)) { applied.forced_followup = false; @@ -1504,7 +1857,11 @@ void syncResident(const LLUUID& id, const CapabilityContext& context, U32 epoch) { setWorkActive(id, false); } - if (sRuntime.priority_resident == id) sRuntime.priority_resident.setNull(); + + if (sRuntime.priority_resident == id) + { + sRuntime.priority_resident.setNull(); + } sRuntime.active_resident.setNull(); } @@ -1512,6 +1869,9 @@ bool sweepServiceArtifacts(const std::string& directory, const std::string& deli const std::string& state_path) { LLMutexLock lock(&sStorageMutex); + + // Snapshot only integration-owned names while holding the storage boundary; + // canonical privacy state is deliberately excluded from the sweep. LLDirIterator iterator(directory, "chat_service_*"); std::string name; std::vector targets; @@ -1522,16 +1882,30 @@ bool sweepServiceArtifacts(const std::string& directory, const std::string& deli targets.push_back(childPath(directory, delimiter, name)); } } - // State temporary is non-authoritative once pending has committed and is swept explicitly. + + // State temporary is non-authoritative once pending has committed and is swept + // explicitly. targets.push_back(state_path + ".tmp"); + bool success = true; for (const std::string& path : targets) { - if (LLFile::remove(path, ENOENT) != 0 && errno != ENOENT) success = false; + if (LLFile::remove(path, ENOENT) != 0 && errno != ENOENT) + { + success = false; + } + bool exists = false; - if (!inspectRegular(path, exists) || exists) success = false; + if (!inspectRegular(path, exists) || exists) + { + success = false; + } + } + + if (!syncDirectory(state_path)) + { + success = false; } - if (!syncDirectory(state_path)) success = false; return success; } @@ -1540,6 +1914,7 @@ void finishDelete(bool success) LLChatServiceHistory::delete_callback_t callback = sRuntime.delete_callback; sRuntime.delete_callback.clear(); sRuntime.delete_active = false; + if (success) { sRuntime.delete_requested = false; @@ -1552,7 +1927,11 @@ void finishDelete(bool success) sRuntime.delete_requested = true; LLNotificationsUtil::add("ChatServiceHistoryDeleteFailed"); } - if (callback) callback(success); + + if (callback) + { + callback(success); + } LLLogChat::notifyTranscriptCreated(); } @@ -1561,7 +1940,9 @@ void runDelete(U32 epoch) const std::string state_path = childPath(sRuntime.account_dir, sRuntime.delimiter, STATE_NAME); U64 boundary = sRuntime.delete_click_ticks; - // Publish pending state before invalidating views or removing any local content. + // Publish the inclusive cutoff as pending before invalidating views or removing + // content. This durable ordering keeps every historical source fail-closed after + // a crash at any later point in the sweep. { LLMutexLock lock(&sStorageMutex); boundary = llmax(boundary, llmax(recoverBoundaryCandidate(state_path), @@ -1578,8 +1959,12 @@ void runDelete(U32 epoch) return; } } + sRuntime.deleted_before_ticks = boundary; sRuntime.cleanup_pending = true; + + // Cancel queued synchronization and clear every displayed historical owner only + // after pending state is durable. sRuntime.queue.clear(); sRuntime.active_resident.setNull(); sRuntime.priority_resident.setNull(); @@ -1590,8 +1975,12 @@ void runDelete(U32 epoch) pair.second.snapshot.service_work_active = false; publishSnapshot(pair.first, pair.second); } + for (auto& pair : LLIMModel::instance().mId2SessionMap) + { pair.second->clearForHistoryDeletion(); + } + const LLFloaterReg::const_instance_list_t& previews = LLFloaterReg::getFloaterList("preview_conversation"); for (LLFloater* floater : previews) @@ -1603,8 +1992,11 @@ void runDelete(U32 epoch) preview->closeFloater(); } } + LLFloaterIMSessionTab::processChatHistoryStyleUpdate(true); + // Sweep legacy transcripts first, then service-owned artifacts, through the + // shared filesystem mutation boundaries on the General queue. LL::WorkQueue::ptr_t general = LL::WorkQueue::getInstance("General"); const std::string chat_logs_dir = gDirUtilp ? gDirUtilp->getPerAccountChatLogsDir() : std::string(); @@ -1614,19 +2006,30 @@ void runDelete(U32 epoch) { return !chat_logs_dir.empty() && LLLogChat::deleteTranscriptContent(chat_logs_dir); }); - if (!ownsRuntime(epoch)) return; + if (!ownsRuntime(epoch)) + { + return; + } const bool service = legacy && general->waitForResult( [account_dir, delimiter, state_path]() + { + return sweepServiceArtifacts(account_dir, delimiter, state_path); + }); + if (!ownsRuntime(epoch)) { - return sweepServiceArtifacts(account_dir, delimiter, state_path); - }); - if (!ownsRuntime(epoch)) return; + return; + } if (!service) { finishDelete(false); return; } - if (!ownsRuntime(epoch)) return; + + // Clear pending only after both sweeps and their required directory syncs succeed. + if (!ownsRuntime(epoch)) + { + return; + } { LLMutexLock lock(&sStorageMutex); if (!writeState(state_path, boundary, false)) @@ -1636,7 +2039,12 @@ void runDelete(U32 epoch) return; } } - if (!ownsRuntime(epoch)) return; + if (!ownsRuntime(epoch)) + { + return; + } + + // Reset cached summaries last so subsequent discovery starts from empty storage. sRuntime.residents.clear(); sRuntime.initialized_archives = true; sRuntime.local_content_exists = false; @@ -1645,6 +2053,8 @@ void runDelete(U32 epoch) bool initializeArchives(U32 epoch) { + // Discover account-local artifacts off the main thread; individual archives are + // prepared incrementally by the manager so startup remains bounded. LL::WorkQueue::ptr_t general = LL::WorkQueue::getInstance("General"); const std::string directory = sRuntime.account_dir; const std::string delimiter = sRuntime.delimiter; @@ -1656,7 +2066,10 @@ bool initializeArchives(U32 epoch) hasOwnedContent(directory)); }) : initial_artifacts_t(); - if (!ownsRuntime(epoch)) return false; + if (!ownsRuntime(epoch)) + { + return false; + } sRuntime.local_content_exists = artifacts.second; const std::vector& ids = artifacts.first; for (const LLUUID& id : ids) @@ -1673,7 +2086,13 @@ bool prepareOneArchive(U32 epoch) for (auto& pair : sRuntime.residents) { Resident& resident = pair.second; - if (resident.summary.state != SUMMARY_UNPREPARED) continue; + if (resident.summary.state != SUMMARY_UNPREPARED) + { + continue; + } + + // Prepare one archive per manager turn so network priority and lifecycle + // events can interleave with storage maintenance. const LLUUID id = pair.first; const std::string path = archivePath(sRuntime.account_dir, sRuntime.delimiter, id); const LLUUID agent_id = sRuntime.agent_id; @@ -1686,9 +2105,15 @@ bool prepareOneArchive(U32 epoch) { return prepareArchive(path, id, agent_id, boundary, summary, changed); }); - if (!ownsRuntime(epoch)) return false; + if (!ownsRuntime(epoch)) + { + return false; + } auto found = sRuntime.residents.find(id); - if (found == sRuntime.residents.end()) return false; + if (found == sRuntime.residents.end()) + { + return false; + } Resident& current = found->second; if (prepared) { @@ -1699,7 +2124,10 @@ bool prepareOneArchive(U32 epoch) { ++current.archive_serial; } - if (summary.has_rows) ensureMetadata(id, current); + if (summary.has_rows) + { + ensureMetadata(id, current); + } publishSnapshot(id, current); LLLogChat::notifyTranscriptCreated(); } @@ -1728,11 +2156,16 @@ void fillMetadataSlots() pair.second.metadata.state == META_UNREQUESTED; }); } - if (found == sRuntime.residents.end()) return; + if (found == sRuntime.residents.end()) + { + return; + } if (ensureMetadata(found->first, found->second)) { if (found->second.metadata_waiting) + { queueResident(found->first, found->second.priority_waiting); + } found->second.metadata_waiting = false; found->second.priority_waiting = false; } @@ -1741,6 +2174,8 @@ void fillMetadataSlots() bool updateIndex(U32 epoch) { + // Capture one stable manifest input, regenerate it under the storage mutex, then + // detect mutations that arrived while the General-queue job was suspended. std::vector archives; std::map names; for (const auto& pair : sRuntime.residents) @@ -1751,7 +2186,9 @@ bool updateIndex(U32 epoch) archives.push_back(pair.first); } if (pair.second.metadata.state == META_RESOLVED) + { names[pair.first] = pair.second.metadata.name; + } } LL::WorkQueue::ptr_t general = LL::WorkQueue::getInstance("General"); const bool updated = general && general->waitForResult( @@ -1760,7 +2197,10 @@ bool updateIndex(U32 epoch) { return regenerateIndex(archives, names, directory, delimiter); }); - if (!ownsRuntime(epoch)) return false; + if (!ownsRuntime(epoch)) + { + return false; + } if (updated) { std::vector current_archives; @@ -1769,12 +2209,19 @@ bool updateIndex(U32 epoch) { if (pair.second.summary.state == SUMMARY_VALID && pair.second.summary.file_exists) + { current_archives.push_back(pair.first); + } if (pair.second.metadata.state == META_RESOLVED) + { current_names.push_back(pair.first); + } } std::vector indexed_names; - for (const auto& pair : names) indexed_names.push_back(pair.first); + for (const auto& pair : names) + { + indexed_names.push_back(pair.first); + } sRuntime.index_dirty = archives != current_archives || indexed_names != current_names; return true; } @@ -1783,6 +2230,8 @@ bool updateIndex(U32 epoch) void expireMetadata() { + // A timed-out shared lookup releases its bounded slot and leaves the resident + // dormant until a later ordinary trigger resets failed metadata. const F64 now = LLTimer::getTotalSeconds(); for (auto& pair : sRuntime.residents) { @@ -1801,29 +2250,42 @@ void expireMetadata() F64 nearestWait() { + // Sleep until the earliest list, pacing, or metadata 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; - if (sRuntime.network_not_before > now) deadline = llmin(deadline, sRuntime.network_not_before); + if (sRuntime.network_not_before > now) + { + deadline = llmin(deadline, sRuntime.network_not_before); + } for (const auto& pair : sRuntime.residents) { if (pair.second.metadata.state == META_PENDING) + { deadline = llmin(deadline, pair.second.metadata.deadline); + } } return llmax(0.01, deadline - now); } void manager(U32 epoch) { + // One account-scoped coroutine advances deletion, initialization, network work, + // and storage maintenance in priority order. while (sRuntime.running && sRuntime.epoch == epoch) { if (sRuntime.delete_active) { runDelete(epoch); - if (!ownsRuntime(epoch)) return; + if (!ownsRuntime(epoch)) + { + return; + } continue; } if (sRuntime.state_safety == STATE_UNKNOWN) { + // Load privacy state before archives, views, or network work become eligible. LL::WorkQueue::ptr_t general = LL::WorkQueue::getInstance("General"); const std::string state_path = childPath(sRuntime.account_dir, sRuntime.delimiter, STATE_NAME); @@ -1834,7 +2296,10 @@ void manager(U32 epoch) return loadState(state_path); }) : StateResult(); - if (!sRuntime.running || sRuntime.epoch != epoch) return; + if (!sRuntime.running || sRuntime.epoch != epoch) + { + return; + } sRuntime.state_safety = state.safety; sRuntime.deleted_before_ticks = state.boundary; sRuntime.cleanup_pending = state.cleanup_pending; @@ -1853,15 +2318,22 @@ void manager(U32 epoch) } if (sRuntime.state_safety == STATE_SAFE && !sRuntime.initialized_archives) { - if (!initializeArchives(epoch)) return; + if (!initializeArchives(epoch)) + { + return; + } continue; } + + // Fill shared metadata slots before choosing the next network occurrence. expireMetadata(); fillMetadataSlots(); const CapabilityContext context = sampleContext(); if (context != sRuntime.context) { + // Capability context changes restart discovery but retain valid archive + // coverage and resident priority state for this account. sRuntime.context = context; if (context.complete()) { @@ -1870,14 +2342,22 @@ void manager(U32 epoch) for (auto& pair : sRuntime.residents) { if (pair.second.metadata.state == META_FAILED) + { pair.second.metadata.state = META_UNREQUESTED; + } } } else { - for (auto& pair : sRuntime.residents) setWorkActive(pair.first, false); + for (auto& pair : sRuntime.residents) + { + setWorkActive(pair.first, false); + } } } + + // Discovery precedes resident paging; the queue itself preserves immediate + // open and inbound priority without creating a second worker. const bool base_network = baseNetworkEligible(context); if (base_network) { @@ -1889,7 +2369,10 @@ void manager(U32 epoch) if (sRuntime.list_needed) { requestList(context, epoch); - if (!ownsRuntime(epoch)) return; + if (!ownsRuntime(epoch)) + { + return; + } continue; } if (!sRuntime.queue.empty()) @@ -1897,7 +2380,10 @@ void manager(U32 epoch) const LLUUID id = sRuntime.queue.front(); sRuntime.queue.pop_front(); syncResident(id, context, epoch); - if (!ownsRuntime(epoch)) return; + if (!ownsRuntime(epoch)) + { + return; + } continue; } @@ -1915,30 +2401,53 @@ void manager(U32 epoch) { sRuntime.active_resident.setNull(); for (auto& pair : sRuntime.residents) + { setWorkActive(pair.first, false); + } } + // Storage maintenance yields to every eligible priority/network occurrence. if (sRuntime.state_safety == STATE_SAFE && !sRuntime.cleanup_pending && prepareOneArchive(epoch)) { continue; } - if (!ownsRuntime(epoch)) return; + if (!ownsRuntime(epoch)) + { + return; + } if (sRuntime.index_dirty && sRuntime.state_safety == STATE_SAFE && !sRuntime.cleanup_pending) { - if (!ownsRuntime(epoch)) return; - if (updateIndex(epoch)) continue; - if (!ownsRuntime(epoch)) return; + if (!ownsRuntime(epoch)) + { + return; + } + if (updateIndex(epoch)) + { + continue; + } + if (!ownsRuntime(epoch)) + { + return; + } + } + if (!waitForWake(nearestWait(), epoch)) + { + return; } - if (!waitForWake(nearestWait(), epoch)) return; } } bool clickTicks(U64& ticks) { + // Convert the click-time microsecond clock into the inclusive final UUIDv1 tick + // for that microsecond using checked integer arithmetic only. const U64 sampled = LLTimer::getTotalTime(); - if (sampled > static_cast(LLONG_MAX)) return false; + if (sampled > static_cast(LLONG_MAX)) + { + return false; + } const S64 offset = static_cast(gUTCOffset) * 1000000LL; S64 corrected = static_cast(sampled); if ((offset > 0 && corrected > LLONG_MAX - offset) || @@ -1958,8 +2467,17 @@ bool clickTicks(U64& ticks) bool legacyWallEpoch(const std::string& text, F64& epoch) { - if (text.size() < 15) return false; - int year = 0, month = 0, day = 0, hour = 0, minute = 0; + if (text.size() < 15) + { + return false; + } + + // Legacy transcripts may use either a 12-hour suffix or a 24-hour wall clock. + int year = 0; + int month = 0; + int day = 0; + int hour = 0; + int minute = 0; char suffix[3] = {}; int consumed = 0; if (std::sscanf(text.c_str(), "%d/%d/%d %d:%d %2s%n", &year, &month, &day, @@ -1967,9 +2485,17 @@ bool legacyWallEpoch(const std::string& text, F64& epoch) { if (consumed != static_cast(text.size()) || (strcmp(suffix, "AM") && strcmp(suffix, "PM")) || hour < 1 || hour > 12) + { return false; - if (!strcmp(suffix, "AM")) hour %= 12; - else if (hour != 12) hour += 12; + } + if (!strcmp(suffix, "AM")) + { + hour %= 12; + } + else if (hour != 12) + { + hour += 12; + } } else { @@ -1977,10 +2503,14 @@ bool legacyWallEpoch(const std::string& text, F64& epoch) if (std::sscanf(text.c_str(), "%d/%d/%d %d:%d%n", &year, &month, &day, &hour, &minute, &consumed) != 5 || consumed != static_cast(text.size()) || hour < 0 || hour > 23) + { return false; + } } if (minute < 0 || minute > 59) + { return false; + } try { const boost::posix_time::ptime value( @@ -2032,9 +2562,13 @@ LLChatServiceHistory::HistoryResult readStitched( { result.maintenance_needed = true; } - if (archive.state != ARCHIVE_VALID) archive = ArchiveScan(); + if (archive.state != ARCHIVE_VALID) + { + archive = ArchiveScan(); + } } + // Resolve the ordinary transcript and monthly shards in their canonical order. std::list legacy; LLSD parameters; parameters["load_all_history"] = true; @@ -2044,11 +2578,14 @@ LLChatServiceHistory::HistoryResult readStitched( LLChatServiceHistoryAccess::loadLegacy(path, legacy, parameters); } - // Only the durable archive's oldest key controls the legacy/service seam. const F64 service_epoch = archive.has_oldest ? static_cast(archive.oldest.ticks - UUID_EPOCH) / 10000000.0 : 0.0; std::vector> dated; std::list undated; + + // Only the durable archive's oldest key controls the legacy/service seam. Keep + // ambiguous rows conservatively and require dated legacy rows to precede the + // service boundary by the existing seven-hour tolerance. for (const LLSD& message : legacy) { F64 wall = 0.0; @@ -2062,30 +2599,62 @@ LLChatServiceHistory::HistoryResult readStitched( } } std::stable_sort(dated.begin(), dated.end(), - [](const auto& left, const auto& right) { return left.first < right.first; }); - for (const auto& item : dated) result.messages.push_back(item.second); - for (const LLSD& message : undated) result.messages.push_back(message); + [](const auto& left, const auto& right) + { + return left.first < right.first; + }); + for (const auto& item : dated) + { + result.messages.push_back(item.second); + } + + for (const LLSD& message : undated) + { + result.messages.push_back(message); + } + + // Merge the transient first page by exact service identity. It may improve + // presentation but never changes the durable seam or archive summary. 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 : service) + { + canonical_ids.insert(row.msg_id); + } for (const Row& row : preview) { - if (!canonical_ids.count(row.msg_id)) service.push_back(row); + 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; }); - for (const Row& row : service) result.messages.push_back(serviceMessage(row)); - while (limit && result.messages.size() > limit) result.messages.pop_front(); + + for (const Row& row : service) + { + result.messages.push_back(serviceMessage(row)); + } + + while (limit && result.messages.size() > limit) + { + result.messages.pop_front(); + } + return result; } } void LLChatServiceHistory::start() { + // Reset any prior account first, then capture all per-login paths, IDs, gates, + // and signal connections before launching the manager coroutine. stop(); + ++sRuntime.epoch; sRuntime.running = true; sRuntime.rollout = gSavedSettings.getBOOL(ENABLED_SETTING); @@ -2095,15 +2664,20 @@ void LLChatServiceHistory::start() sRuntime.state_safety = STATE_UNKNOWN; sRuntime.next_list = 0.0; sRuntime.list_needed = true; + if (!sWake) { sWake.reset(new LLEventMailDrop("ChatServiceHistoryWake", true)); } sWake->discard(); + sRegionConnection = gAgent.addRegionChangedCallback([]() { LLChatServiceHistory::regionChanged(); }); + + // Consent changes revoke transient presentation immediately and wake the manager + // to re-evaluate every resident under the new account-wide gate. if (gSavedPerAccountSettings.controlExists("KeepConversationLogTranscripts")) { sRuntime.consent_connection = gSavedPerAccountSettings @@ -2123,6 +2697,9 @@ void LLChatServiceHistory::start() wakeManager(); }); } + + // Presentation changes reload model-owned sessions without creating another + // history scheduler. if (gSavedPerAccountSettings.controlExists("LogShowHistory")) { sRuntime.show_history_connection = gSavedPerAccountSettings @@ -2132,9 +2709,13 @@ void LLChatServiceHistory::start() LLFloaterIMSessionTab::processChatHistoryStyleUpdate(true); }); } + regionChanged(); const U32 epoch = sRuntime.epoch; - LLCoros::instance().launch("ChatServiceHistory", [epoch]() { manager(epoch); }); + LLCoros::instance().launch("ChatServiceHistory", [epoch]() + { + manager(epoch); + }); } void LLChatServiceHistory::stop() @@ -2143,12 +2724,22 @@ void LLChatServiceHistory::stop() { sRuntime.running = false; ++sRuntime.epoch; - for (auto& pair : sRuntime.residents) pair.second.metadata.connection.disconnect(); - if (sWake) sWake->post(LLSD(true)); + + for (auto& pair : sRuntime.residents) + { + pair.second.metadata.connection.disconnect(); + } + + if (sWake) + { + sWake->post(LLSD(true)); + } } + sRegionConnection.disconnect(); sRuntime.consent_connection.disconnect(); sRuntime.show_history_connection.disconnect(); + const U32 epoch = sRuntime.epoch; sRuntime = Runtime(); sRuntime.epoch = epoch; @@ -2164,6 +2755,7 @@ void LLChatServiceHistory::regionChanged() wakeManager(); }); } + wakeManager(); } @@ -2190,25 +2782,42 @@ bool LLChatServiceHistory::servicePresentationAllowed() bool LLChatServiceHistory::localHistoryExists() { - if (sRuntime.delete_active) return false; + if (sRuntime.delete_active) + { + return false; + } + if (sRuntime.delete_requested || sRuntime.cleanup_pending || - sRuntime.state_safety == STATE_UNSAFE) return true; + sRuntime.state_safety == STATE_UNSAFE) + { + return true; + } + for (const auto& pair : sRuntime.residents) { - if (pair.second.summary.has_rows) return true; + if (pair.second.summary.has_rows) + { + return true; + } } + return sRuntime.local_content_exists; } bool LLChatServiceHistory::localHistoryExists(const LLUUID& resident_id) { - if (!servicePresentationAllowed()) return false; + if (!servicePresentationAllowed()) + { + return false; + } + const auto found = sRuntime.residents.find(resident_id); if (found == sRuntime.residents.end() || found->second.summary.state == SUMMARY_UNPREPARED) { wakeManager(); return false; } + return found->second.summary.has_rows; } @@ -2219,25 +2828,40 @@ bool LLChatServiceHistory::isPersistedDirectDialog(EInstantMessage dialog) void LLChatServiceHistory::prioritizeResident(const LLUUID& id, bool inbound) { - if (!sRuntime.running || id.isNull()) return; + if (!sRuntime.running || id.isNull()) + { + return; + } + + // Coalesce opens and inbound messages into one front occurrence. An inbound that + // arrives after the active request began records at most one follow-up pass. Resident& resident = sRuntime.residents[id]; resident.force_head = true; resident.retry_used = false; - if (resident.metadata.state == META_FAILED) resident.metadata.state = META_UNREQUESTED; + if (resident.metadata.state == META_FAILED) + { + resident.metadata.state = META_UNREQUESTED; + } + if (sRuntime.active_resident == id) { - if (inbound && resident.first_request_started) resident.forced_followup = true; + if (inbound && resident.first_request_started) + { + resident.forced_followup = true; + } } else { queueResident(id, true); sRuntime.priority_resident = id; } + if (!resident.listed) { sRuntime.list_needed = true; sRuntime.list_retry_used = false; } + const CapabilityContext context = sampleContext(); const bool potentially_active = sRuntime.rollout && transcriptConsent() && sRuntime.state_safety == STATE_SAFE && !sRuntime.cleanup_pending && @@ -2267,24 +2891,42 @@ boost::signals2::connection LLChatServiceHistory::setSnapshotChanged( std::list LLChatServiceHistory::mergeHeadPreview( const std::list& loaded, const Snapshot& snapshot, U32 limit) { + // 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) { if (message["chat_service_msg_id"].isString()) + { 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); + if (!ids.count(row.msg_id)) + { + additions.push_back(row); + } } + 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(); + + for (const Row& row : additions) + { + result.push_back(serviceMessage(row)); + } + + while (limit && result.size() > limit) + { + result.pop_front(); + } + return result; } @@ -2292,10 +2934,20 @@ bool LLChatServiceHistory::loadStitchedHistory( const LLUUID& id, const std::string& legacy_stem, U32 limit, const history_callback_t& callback) { - if (!callback || id.isNull() || historySuppressed()) return false; + if (!callback || id.isNull() || historySuppressed()) + { + return false; + } + + // Capture account and archive generations with the filesystem inputs so the + // main-queue completion can detect any intervening lifecycle or publication. Resident& resident = sRuntime.residents[id]; const bool include_service = servicePresentationAllowed(); - if (include_service) ensureMetadata(id, resident); + if (include_service) + { + ensureMetadata(id, resident); + } + const U32 epoch = sRuntime.epoch; const U32 serial = resident.archive_serial; const U64 boundary = sRuntime.deleted_before_ticks; @@ -2305,9 +2957,16 @@ bool LLChatServiceHistory::loadStitchedHistory( const std::string archive_path = archivePath(sRuntime.account_dir, sRuntime.delimiter, id); std::vector legacy_paths; LLLogChat::getTranscriptFamily(legacy_stem, legacy_paths); + LL::WorkQueue::ptr_t main = LL::WorkQueue::getInstance("mainloop"); LL::WorkQueue::ptr_t general = LL::WorkQueue::getInstance("General"); - if (!main || !general) return false; + if (!main || !general) + { + return false; + } + + // Read and stitch off the main thread; maintenance requests are applied only if + // the captured archive serial is still current. return main->postTo(general, [id, agent_id, archive_path, legacy_paths, limit, epoch, serial, boundary, include_service, preview]() @@ -2328,19 +2987,31 @@ bool LLChatServiceHistory::loadStitchedHistory( wakeManager(); } } + callback(result); }); } bool LLChatServiceHistory::deleteTranscriptsAsync(const delete_callback_t& callback) { - if (!sRuntime.running || sRuntime.delete_active || !callback) return false; + if (!sRuntime.running || sRuntime.delete_active || !callback) + { + return false; + } + U64 ticks = 0; - if (!clickTicks(ticks)) return false; + if (!clickTicks(ticks)) + { + return false; + } + + // Latch deletion immediately so no new request or view read starts before the + // manager publishes pending privacy state. sRuntime.delete_click_ticks = ticks; sRuntime.delete_callback = callback; sRuntime.delete_active = true; sRuntime.delete_requested = true; + wakeManager(); return true; } diff --git a/indra/newview/llchatservicehistory.h b/indra/newview/llchatservicehistory.h index 6280981e00..86fd8b7f70 100644 --- a/indra/newview/llchatservicehistory.h +++ b/indra/newview/llchatservicehistory.h @@ -23,22 +23,37 @@ namespace LLChatServiceHistory { + // Resident snapshots are published on the main thread and contain every value + // direct-IM and Preview consumers need to render or reload history. struct Snapshot { + // Remote, metadata, or storage work is still active for this resident. bool service_work_active = false; + + // Service rows may be presented under the current account-wide gates. bool service_presentation_allowed = false; + + // Consumers reject local reads captured before the latest archive mutation. U32 archive_serial = 0; + + // Shared name metadata is ready for blocking and legacy-path resolution. bool metadata_resolved = false; LLAvatarName metadata; + + // The validated first page is presentation-only until durable publication. std::vector head_preview; }; + // An asynchronous stitched read carries the account and archive generations + // needed to reject results that became stale while filesystem work ran. struct HistoryResult { std::list messages; U32 account_epoch = 0; U32 archive_serial = 0; bool included_service = false; + + // The reader classified a canonical archive that manager-side maintenance must revisit. bool maintenance_needed = false; }; @@ -46,10 +61,13 @@ namespace LLChatServiceHistory typedef boost::function delete_callback_t; typedef boost::function snapshot_callback_t; + // The synchronizer has one lifecycle per logged-in account. void start(); void stop(); void regionChanged(); + // Account-wide gates keep all historical sources fail-closed during unsafe state + // recovery or transcript deletion. bool enabledForLogin(); U32 accountEpoch(); bool historySuppressed(); @@ -57,15 +75,21 @@ namespace LLChatServiceHistory bool localHistoryExists(); bool localHistoryExists(const LLUUID& resident_id); + // Open and accepted-inbound activity share one account-scoped priority queue. bool isPersistedDirectDialog(EInstantMessage dialog); void prioritizeResident(const LLUUID& resident_id, bool inbound = false); + + // 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); + // 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, U32 limit, const history_callback_t& callback); + + // Deletion records its durable cutoff before clearing views or sweeping files. bool deleteTranscriptsAsync(const delete_callback_t& callback); } diff --git a/indra/newview/llchatservicehistorycore.cpp b/indra/newview/llchatservicehistorycore.cpp index 5f25347021..a8506c687a 100644 --- a/indra/newview/llchatservicehistorycore.cpp +++ b/indra/newview/llchatservicehistorycore.cpp @@ -40,6 +40,7 @@ namespace bool cleanText(const std::string& value, size_t limit) { + // Wire and CSV text must remain bounded, NUL-free, and canonical UTF-8. return value.size() <= limit && value.find('\0') == std::string::npos && wstring_to_utf8str(utf8str_to_wstring(value)) == value; } @@ -51,6 +52,7 @@ namespace { return false; } + return LLStringUtil::convertToS32(text, dialog) && persistedDirectDialog(dialog); } @@ -58,6 +60,8 @@ namespace const LLUUID& agent_id, const LLUUID& resident_id, Row& row) { + // A stored row is trusted only after every field and cross-field identity + // constraint matches the current account and resident. if (fields.size() != COLUMN_COUNT || fields[0] != directConversationId(agent_id, resident_id) || !parseTimeUuid(fields[1], row.key) || @@ -69,6 +73,7 @@ namespace { return false; } + row.conversation_id = fields[0]; row.msg_id = fields[1]; row.from_name = fields[3]; @@ -76,7 +81,8 @@ namespace return true; } - // Decode RFC-4180 records incrementally so archive validation never needs a second parser. + // Decode RFC-4180 records incrementally so validation and torn-tail recovery + // classify the same byte stream with one parser. bool readRecord(std::istream& input, std::vector& fields, bool& eof_torn, bool& had_record) { @@ -87,11 +93,14 @@ namespace bool quoted = false; bool quote_closed = false; bool started = false; + for (;;) { const int raw = input.get(); if (raw == EOF) { + // EOF after a complete unquoted field is a valid final record. An + // open quote or too few columns marks only the final record as torn. had_record = started; eof_torn = started && (quoted || fields.size() + 1 < COLUMN_COUNT); if (started && !eof_torn && !quoted) @@ -102,6 +111,9 @@ namespace } started = true; const char ch = static_cast(raw); + + // Quoted fields retain delimiters and newlines; doubled quotes decode + // to one literal quote. if (quoted) { if (ch != '"') @@ -119,6 +131,9 @@ namespace quote_closed = true; continue; } + + // Outside quotes, delimiters advance columns and either newline style + // terminates the record. Bytes after a closing quote are invalid. if (ch == '"') { if (!field.empty() || quote_closed) @@ -179,13 +194,18 @@ namespace #if LL_WINDOWS const DWORD attributes = GetFileAttributesW(ll_convert(path).c_str()); if (attributes == INVALID_FILE_ATTRIBUTES) + { return GetLastError() == ERROR_FILE_NOT_FOUND || GetLastError() == ERROR_PATH_NOT_FOUND; + } exists = true; return !(attributes & (FILE_ATTRIBUTE_DIRECTORY | FILE_ATTRIBUTE_REPARSE_POINT)); #else struct stat status; - if (::lstat(path.c_str(), &status) != 0) return errno == ENOENT; + if (::lstat(path.c_str(), &status) != 0) + { + return errno == ENOENT; + } exists = true; return S_ISREG(status.st_mode); #endif @@ -216,6 +236,7 @@ bool parseCanonicalUuid(const std::string& text, LLUUID& id) { return false; } + id.set(text, false); return id.asString() == text; } @@ -235,10 +256,12 @@ bool parseTimeUuid(const std::string& text, TimeUuidKey& key) const U64 time_mid = (U64(id.mData[4]) << 8) | U64(id.mData[5]); const U64 time_high = (U64(id.mData[6] & 0x0f) << 8) | U64(id.mData[7]); key.ticks = (time_high << 48) | (time_mid << 32) | time_low; + for (U32 pos = 0; pos < key.tail.size(); ++pos) { key.tail[pos] = static_cast(id.mData[pos + 8]); } + return true; } @@ -246,8 +269,15 @@ bool persistedDirectDialog(S32 dialog) { switch (dialog) { - case 0: case 3: case 4: case 20: case 22: case 26: case 38: + case 0: + case 3: + case 4: + case 20: + case 22: + case 26: + case 38: return true; + default: return false; } @@ -264,31 +294,59 @@ bool parseCreatedAt(const std::string& text, std::string& normalized) { return false; } + + // Reject signed, variable-width, and otherwise non-decimal date fields before + // converting the fixed calendar components. for (U32 position : DIGIT_POSITIONS) { - if (text[position] < '0' || text[position] > '9') return false; + if (text[position] < '0' || text[position] > '9') + { + return false; + } } + const U32 year = std::stoi(text.substr(0, 4)); const U32 month = std::stoi(text.substr(5, 2)); const U32 day = std::stoi(text.substr(8, 2)); const U32 hour = std::stoi(text.substr(11, 2)); const U32 minute = std::stoi(text.substr(14, 2)); const U32 second = std::stoi(text.substr(17, 2)); - static const U32 DAYS_PER_MONTH[] = { 31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31 }; + static const U32 DAYS_PER_MONTH[] = { + 31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31 + }; if (!year || month < 1 || month > 12 || hour > 23 || minute > 59 || second > 59) + { return false; + } + + // Validate the day against the parsed month, including Gregorian leap years. U32 month_days = DAYS_PER_MONTH[month - 1]; - if (month == 2 && (year % 400 == 0 || (year % 4 == 0 && year % 100 != 0))) ++month_days; - if (day < 1 || day > month_days) return false; + if (month == 2 && (year % 400 == 0 || (year % 4 == 0 && year % 100 != 0))) + { + ++month_days; + } + if (day < 1 || day > month_days) + { + return false; + } + // Fractional seconds are optional but must contain at least one digit. size_t position = 19; if (position < text.size() && text[position] == '.') { const size_t fraction = ++position; while (position < text.size() && text[position] >= '0' && text[position] <= '9') + { ++position; - if (position == fraction) return false; + } + if (position == fraction) + { + return false; + } } + + // Offset-less service timestamps are normalized to UTC. Explicit zones must + // consume the complete suffix and remain within clock bounds. std::string parsed = text; if (position == text.size()) { @@ -296,7 +354,10 @@ bool parseCreatedAt(const std::string& text, std::string& normalized) } else if (text[position] == 'Z') { - if (position + 1 != text.size()) return false; + if (position + 1 != text.size()) + { + return false; + } } else if (text[position] == '+' || text[position] == '-') { @@ -310,17 +371,22 @@ bool parseCreatedAt(const std::string& text, std::string& normalized) } const U32 hours = (text[position + 1] - '0') * 10 + text[position + 2] - '0'; const U32 minutes = (text[position + 4] - '0') * 10 + text[position + 5] - '0'; - if (hours > 23 || minutes > 59) return false; + if (hours > 23 || minutes > 59) + { + return false; + } } else { return false; } + LLDate date; if (!date.fromString(parsed)) { return false; } + normalized = parsed; return true; } @@ -329,14 +395,19 @@ bool validateConversationList(const LLSD& value, const LLUUID& agent_id, std::vector& entries) { entries.clear(); + + // Reject the complete discovery response before extracting any direct entries. if (!value.isArray() || value.size() > 10000) { return false; } + std::set residents; std::set conversations; for (LLSD::array_const_iterator it = value.beginArray(); it != value.endArray(); ++it) { + // Every list row must have a known type and bounded canonical identifiers, + // even when non-direct rows are ignored by this feature. std::string type; std::string conversation; if (!it->isMap() || !llsdString(*it, "conversation_type", type) || @@ -350,6 +421,9 @@ 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. std::string resident_text; std::string token; LLUUID resident; @@ -365,8 +439,10 @@ bool validateConversationList(const LLSD& value, const LLUUID& agent_id, { return false; } + entries.push_back({resident, conversation, token}); } + return true; } @@ -377,6 +453,9 @@ bool validateHistoryPage(const LLSD& value, const LLUUID& agent_id, U64 deleted_before_ticks, Page& page) { page = Page(); + + // The service echoes the exact conversation and fixed page size. Coercible LLSD + // types are rejected so paging state cannot drift across malformed responses. if (!value.isMap() || !value["conversation_id"].isString() || value["conversation_id"].asString() != conversation_id || !value["limit"].isInteger() || value["limit"].asInteger() != 100 || @@ -384,6 +463,9 @@ bool validateHistoryPage(const LLSD& value, const LLUUID& agent_id, { return false; } + + // Cursor echoes are exact: the head omits one, while older pages repeat the + // requested UUID unchanged. const LLSD& echoed = value["before_msg_id"]; if ((requested_cursor.empty() && !echoed.isUndefined()) || (!requested_cursor.empty() && @@ -397,9 +479,13 @@ bool validateHistoryPage(const LLSD& value, const LLUUID& agent_id, { return false; } + std::set ids; TimeUuidKey previous; bool have_previous = false; + + // Validate every row before the page can affect scheduler or archive state. + // UUIDv1 keys must be unique, strictly descending, and below the request cursor. for (LLSD::array_const_iterator it = value["messages"].beginArray(); it != value["messages"].endArray(); ++it) { @@ -424,9 +510,13 @@ bool validateHistoryPage(const LLSD& value, const LLUUID& agent_id, { return false; } + have_previous = true; previous = row.key; page.next_cursor = row.msg_id; + + // The account-wide cutoff is inclusive. Once reached, older rows from the + // same validated page remain suppressed and older paging terminates. if (row.key.ticks <= deleted_before_ticks) { page.cutoff_reached = true; @@ -436,11 +526,13 @@ bool validateHistoryPage(const LLSD& value, const LLUUID& agent_id, page.rows.push_back(row); } } + page.terminal = value["messages"].size() == 0 || page.cutoff_reached; if (page.terminal) { page.next_cursor.clear(); } + return true; } @@ -450,6 +542,7 @@ std::string quoteCsv(const std::string& value) { return value; } + std::string result("\""); for (char ch : value) { @@ -459,6 +552,7 @@ std::string quoteCsv(const std::string& value) result.push_back('"'); } } + return result + '"'; } @@ -473,14 +567,26 @@ void writeCsvRow(std::ostream& output, const Row& row) bool archiveStamp(const std::string& path, U64& file_size, S64& file_mtime) { bool exists = false; - if (!inspectRegularFile(path, exists) || !exists) return false; + if (!inspectRegularFile(path, exists) || !exists) + { + return false; + } + std::error_code error; const std::filesystem::path native_path = fsyspath(path); const uintmax_t size = std::filesystem::file_size(native_path, error); - if (error || size > std::numeric_limits::max()) return false; + if (error || size > std::numeric_limits::max()) + { + return false; + } + const std::filesystem::file_time_type write_time = std::filesystem::last_write_time(native_path, error); - if (error) return false; + if (error) + { + return false; + } + file_size = static_cast(size); file_mtime = static_cast(write_time.time_since_epoch().count()); return true; @@ -491,6 +597,8 @@ bool scanArchive(const std::string& path, const LLUUID& agent_id, U32 display_cap, ArchiveScan& scan) { scan = ArchiveScan(); + + // Only a regular canonical path may participate in archive reads. bool exists = false; if (!inspectRegularFile(path, exists)) { @@ -514,6 +622,7 @@ bool scanArchive(const std::string& path, const LLUUID& agent_id, return false; } + // The exact header is the first committed record and is never repairable data. std::vector fields; bool torn = false; bool present = false; @@ -522,10 +631,14 @@ bool scanArchive(const std::string& path, const LLUUID& agent_id, scan.state = ARCHIVE_CORRUPT; return false; } + scan.valid_prefix_bytes = input.tellg(); TimeUuidKey previous; bool have_previous = false; std::deque newest; + + // Fold records in ascending TimeUUID order while retaining only the newest + // display_cap rows. Complete malformed records are corrupt; incomplete EOF is torn. for (;;) { if (!readRecord(input, fields, torn, present)) @@ -543,6 +656,7 @@ bool scanArchive(const std::string& path, const LLUUID& agent_id, scan.state = ARCHIVE_TORN; break; } + Row row; if (!rowFromFields(fields, agent_id, resident_id, row) || (have_previous && !(previous < row.key))) @@ -550,14 +664,19 @@ bool scanArchive(const std::string& path, const LLUUID& agent_id, scan.state = ARCHIVE_CORRUPT; return false; } + have_previous = true; previous = row.key; const std::streamoff position = input.tellg(); scan.valid_prefix_bytes = position >= 0 ? position : scan.file_size; + + // Deleted rows still participate in structural and ordering validation but + // do not enter the visible summary. if (row.key.ticks <= deleted_before_ticks) { continue; } + if (!scan.has_oldest) { scan.has_oldest = true; @@ -565,6 +684,7 @@ bool scanArchive(const std::string& path, const LLUUID& agent_id, } scan.newest = row.key; ++scan.row_count; + if (display_cap) { newest.push_back(row); @@ -574,6 +694,7 @@ bool scanArchive(const std::string& path, const LLUUID& agent_id, } } } + scan.display_rows.assign(newest.begin(), newest.end()); return scan.state == ARCHIVE_VALID; } diff --git a/indra/newview/llchatservicehistorycore.h b/indra/newview/llchatservicehistorycore.h index 9d2301ebf5..d9ac647a57 100644 --- a/indra/newview/llchatservicehistorycore.h +++ b/indra/newview/llchatservicehistorycore.h @@ -24,6 +24,8 @@ namespace LLChatServiceHistoryCore extern const char* const CSV_HEADER; extern const char* const INDEX_HEADER; + // Cassandra compares UUIDv1 timestamps first and the final eight bytes as signed + // values when timestamps tie. struct TimeUuidKey { U64 ticks = 0; @@ -31,11 +33,21 @@ namespace LLChatServiceHistoryCore bool operator==(const TimeUuidKey& rhs) const; bool operator<(const TimeUuidKey& rhs) const; - bool operator>(const TimeUuidKey& rhs) const { return rhs < *this; } - bool operator<=(const TimeUuidKey& rhs) const { return !(rhs < *this); } - bool operator>=(const TimeUuidKey& rhs) const { return !(*this < rhs); } + bool operator>(const TimeUuidKey& rhs) const + { + return rhs < *this; + } + bool operator<=(const TimeUuidKey& rhs) const + { + return !(rhs < *this); + } + bool operator>=(const TimeUuidKey& rhs) const + { + return !(*this < rhs); + } }; + // Row is the single validated representation shared by wire pages and CSV files. struct Row { std::string conversation_id; @@ -48,6 +60,7 @@ namespace LLChatServiceHistoryCore TimeUuidKey key; }; + // Direct entries are extracted only after the complete discovery list validates. struct ListEntry { LLUUID resident_id; @@ -55,6 +68,8 @@ namespace LLChatServiceHistoryCore std::string last_msg_id; }; + // Pages retain rows newer than the account deletion cutoff and expose the next + // validated cursor only while older paging remains necessary. struct Page { std::vector rows; @@ -63,6 +78,7 @@ namespace LLChatServiceHistoryCore bool cutoff_reached = false; }; + // Archive states distinguish repairable torn tails from complete corrupt records. enum EArchiveState { ARCHIVE_ABSENT, @@ -72,6 +88,8 @@ namespace LLChatServiceHistoryCore ARCHIVE_FAILED }; + // A scan folds the canonical file into a bounded display window and a compact + // summary without materializing the complete archive. struct ArchiveScan { EArchiveState state = ARCHIVE_ABSENT; @@ -91,6 +109,7 @@ namespace LLChatServiceHistoryCore bool persistedDirectDialog(S32 dialog); bool parseCreatedAt(const std::string& text, std::string& normalized); + // Both wire validators are all-or-nothing and clear their output before parsing. bool validateConversationList(const LLSD& value, const LLUUID& agent_id, std::vector& entries); bool validateHistoryPage(const LLSD& value, const LLUUID& agent_id, @@ -101,10 +120,14 @@ namespace LLChatServiceHistoryCore std::string quoteCsv(const std::string& value); void writeCsvRow(std::ostream& output, const Row& row); + + // scanArchive never repairs storage; callers decide whether a classified archive + // may be displayed, repaired, or quarantined. bool scanArchive(const std::string& path, const LLUUID& agent_id, const LLUUID& resident_id, U64 deleted_before_ticks, U32 display_cap, ArchiveScan& scan); + // Capture the regular file's physical identity for guarded repair and publication. bool archiveStamp(const std::string& path, U64& file_size, S64& file_mtime); } diff --git a/indra/newview/llconversationlog.cpp b/indra/newview/llconversationlog.cpp index c9ae6ccdce..e5adedd2ba 100644 --- a/indra/newview/llconversationlog.cpp +++ b/indra/newview/llconversationlog.cpp @@ -614,6 +614,7 @@ bool LLConversationLog::loadFromFile(const std::string& filename) // CHUI-325 // The conversation log should be capped to the last 30 days. Conversations with the last utterance // being over 30 days old should be purged from the conversation log text file on login. + // P2P conversations are retained so accumulated local/service history remains discoverable. if (conversation.getConversationType() != LLIMModel::LLIMSession::P2P_SESSION && conversation.isOlderThan(CONVERSATION_LIFETIME)) { diff --git a/indra/newview/llconversationloglist.cpp b/indra/newview/llconversationloglist.cpp index c07f7cdfcb..270caba0b2 100644 --- a/indra/newview/llconversationloglist.cpp +++ b/indra/newview/llconversationloglist.cpp @@ -366,7 +366,9 @@ void LLConversationLogList::onCustomAction(const LLSD& userdata) else if ("chat_history" == command_name) { if (!LLChatServiceHistory::historySuppressed()) + { LLFloaterReg::showInstance("preview_conversation", selected_conversation_session_id, true); + } } else if ("offer_teleport" == command_name) { diff --git a/indra/newview/llfloaterconversationlog.cpp b/indra/newview/llfloaterconversationlog.cpp index 876c351ab0..144741a768 100644 --- a/indra/newview/llfloaterconversationlog.cpp +++ b/indra/newview/llfloaterconversationlog.cpp @@ -108,14 +108,18 @@ void LLFloaterConversationLog::onCustomAction (const LLSD& userdata) else if ("view_nearby_chat_history" == command_name) { if (!LLChatServiceHistory::historySuppressed()) + { LLFloaterReg::showInstance("preview_conversation", LLSD(LLUUID::null), true); + } } } bool LLFloaterConversationLog::isActionEnabled(const LLSD& userdata) { if (userdata.asString() == "view_nearby_chat_history") + { return !LLChatServiceHistory::historySuppressed(); + } return true; } @@ -144,3 +148,4 @@ bool LLFloaterConversationLog::isActionChecked(const LLSD& userdata) return false; } + diff --git a/indra/newview/llfloaterconversationpreview.cpp b/indra/newview/llfloaterconversationpreview.cpp index 6b871e3bb2..767bf95b7f 100644 --- a/indra/newview/llfloaterconversationpreview.cpp +++ b/indra/newview/llfloaterconversationpreview.cpp @@ -104,18 +104,23 @@ bool LLFloaterConversationPreview::postBuild() file = conv->getHistoryFileName(); mIsGroup = (LLIMModel::LLIMSession::GROUP_SESSION == conv->getConversationType()); mIsP2P = (LLIMModel::LLIMSession::P2P_SESSION == conv->getConversationType()); - if (mIsP2P) mParticipantID = conv->getParticipantID(); + if (mIsP2P) + { + mParticipantID = conv->getParticipantID(); + } } else { name = LLTrans::getString("NearbyChatTitle"); file = "chat"; } + mChatHistoryFileName = file; if (mIsGroup && !LLStringUtil::endsWith(mChatHistoryFileName, GROUP_CHAT_SUFFIX)) { mChatHistoryFileName += GROUP_CHAT_SUFFIX; } + LLStringUtil::format_map_t args; args["[NAME]"] = name; std::string title = getString("Title", args); @@ -132,6 +137,9 @@ void LLFloaterConversationPreview::setPages(std::list* messages, const std { // additional protection to avoid changes of mMessages in setPages() LLMutexLock lock(&mMutex); + + // Preserve the reader's distance from the newest page while asynchronous + // reloads replace the backing list. const S32 old_last_page = mMessages && !mMessages->empty() ? (static_cast(mMessages->size()) - 1) / mPageSize : 0; const S32 distance_from_newest = llmax(0, old_last_page - mCurrentPage); @@ -144,6 +152,7 @@ void LLFloaterConversationPreview::setPages(std::list* messages, const std ? 0 : (static_cast(mMessages->size()) - 1) / mPageSize; mCurrentPage = llmax(0, last_page - distance_from_newest); + // Publish the new page range together so draw() never renders stale bounds. mPageSpinner->setEnabled(true); mPageSpinner->setMaxValue((F32)(last_page+1)); mPageSpinner->set((F32)(mCurrentPage+1)); @@ -161,6 +170,7 @@ void LLFloaterConversationPreview::setPages(std::list* messages, const std void LLFloaterConversationPreview::draw() { + // Local reads and account-scoped service work share one nonmodal status panel. const bool loading = mIsP2P && (mServiceLocalLoading || LLChatServiceHistory::getSnapshot(mParticipantID).service_work_active); @@ -170,13 +180,22 @@ void LLFloaterConversationPreview::draw() getChildView("chat_service_loading")->setVisible(loading); LLLoadingIndicator* indicator = getChild("chat_service_loading_wheel"); - if (loading) indicator->start(); else indicator->stop(); + if (loading) + { + indicator->start(); + } + else + { + indicator->stop(); + } } + if(mShowHistory) { showHistory(); mShowHistory = false; } + LLFloater::draw(); } @@ -186,6 +205,7 @@ void LLFloaterConversationPreview::onOpen(const LLSD& key) { return; } + mOpened = true; ++mServiceToken; mPageSpinner = getChild("history_page_spin"); @@ -197,6 +217,8 @@ void LLFloaterConversationPreview::onOpen(const LLSD& key) if (mIsP2P) { + // Connect before querying the snapshot so an active-work transition cannot + // be missed between Preview opening and its first local read. LLHandle handle = getDerivedHandle(); mServiceSnapshotConnection = LLChatServiceHistory::setSnapshotChanged( @@ -205,8 +227,11 @@ void LLFloaterConversationPreview::onOpen(const LLSD& key) { LLFloaterConversationPreview* floater = handle.get(); if (floater && changed == participant) + { floater->onServiceSnapshot(snapshot); + } }); + const LLChatServiceHistory::Snapshot snapshot = LLChatServiceHistory::getSnapshot(mParticipantID); mServicePresentationAllowed = snapshot.service_presentation_allowed; @@ -214,24 +239,42 @@ void LLFloaterConversationPreview::onOpen(const LLSD& key) LLChatServiceHistory::prioritizeResident(mParticipantID); onServiceSnapshot(snapshot); startServiceLoad(); + return; } + + // Legacy Preview reloads after plaintext writes, using a weak handle so a closed + // floater is never re-entered by the content signal. LLHandle handle = getDerivedHandle(); mHistoryContentConnection = LLLogChat::getInstance()->setSaveHistorySignal([handle]() { LLFloaterConversationPreview* floater = handle.get(); if (floater && floater->mOpened && !LLChatServiceHistory::historySuppressed()) + { floater->startLegacyLoad(); + } }); + startLegacyLoad(); } void LLFloaterConversationPreview::startLegacyLoad() { - if (!mOpened || mIsP2P) return; - if (LLChatServiceHistory::historySuppressed()) return; + if (!mOpened || mIsP2P) + { + return; + } + + if (LLChatServiceHistory::historySuppressed()) + { + return; + } + + // Each save signal schedules at most one loader; completion reconnects through + // the normal Preview lifecycle rather than overlapping worker threads. mHistoryContentConnection.disconnect(); + if (!LLLogChat::getInstance()->historyThreadsFinished(mSessionID)) { LLNotificationsUtil::add("ChatHistoryIsBusyAlert"); @@ -239,7 +282,9 @@ void LLFloaterConversationPreview::startLegacyLoad() closeFloater(); return; } + const U64 load_token = ++mServiceToken; + LLSD load_params; load_params["load_all_history"] = true; load_params["cut_off_todays_date"] = false; @@ -254,6 +299,7 @@ void LLFloaterConversationPreview::startLegacyLoad() LLSD loading; loading[LL_IM_TEXT] = LLTrans::getString("loading_chat_logs"); mMessages->push_back(loading); + // The actual message list to load from file // Will be deleted in a separate thread LLDeleteHistoryThread not to freeze UI // LLDeleteHistoryThread is started in destructor @@ -269,7 +315,8 @@ void LLFloaterConversationPreview::startLegacyLoad() loadThread->setLoadEndSignal( [handle, load_token, main](std::list* loaded, const std::string& file_name) { - // Copy before the delete thread reclaims loader-owned data, then apply on main. + // Copy before the delete thread reclaims loader-owned data, then fence + // the main-thread apply with the current open token and privacy gate. std::shared_ptr> copy = std::make_shared>(*loaded); if (main) @@ -297,13 +344,18 @@ void LLFloaterConversationPreview::startLegacyLoad() void LLFloaterConversationPreview::onClose(bool app_quitting) { + // Close is a lifecycle fence for callbacks, not a request to cancel shared service work. mOpened = false; ++mServiceToken; mServiceLocalLoading = false; mServiceReloadPending = false; mHistoryContentConnection.disconnect(); mServiceSnapshotConnection.disconnect(); - if (mIsP2P) return; + + if (mIsP2P) + { + return; + } if (!mHistoryThreadsBusy) { LLDeleteHistoryThread* deleteThread = LLLogChat::getInstance()->getDeleteHistoryThread(mSessionID); @@ -316,18 +368,35 @@ void LLFloaterConversationPreview::onClose(bool app_quitting) void LLFloaterConversationPreview::invalidateHistory() { + // Advancing the token invalidates every outstanding legacy or stitched completion + // before deletion clears the visible backing lists. ++mServiceToken; mServiceLocalLoading = false; mServiceReloadPending = false; - if (mMessages) mMessages->clear(); - if (mChatHistory) mChatHistory->clear(); + + if (mMessages) + { + mMessages->clear(); + } + + if (mChatHistory) + { + mChatHistory->clear(); + } } void LLFloaterConversationPreview::startServiceLoad() { - if (!mOpened || mServiceLocalLoading || mParticipantID.isNull()) return; + if (!mOpened || mServiceLocalLoading || mParticipantID.isNull()) + { + return; + } + + // One local stitched read may be active per Preview. Snapshot changes set a + // follow-up flag instead of starting overlapping archive scans. const U64 token = ++mServiceToken; mServiceLocalLoading = true; + LLHandle handle = getDerivedHandle(); if (!LLChatServiceHistory::loadStitchedHistory( @@ -335,7 +404,9 @@ void LLFloaterConversationPreview::startServiceLoad() [handle, token](const LLChatServiceHistory::HistoryResult& result) { if (LLFloaterConversationPreview* floater = handle.get()) + { floater->onServiceLoaded(token, result); + } })) { mServiceLocalLoading = false; @@ -345,31 +416,48 @@ void LLFloaterConversationPreview::startServiceLoad() void LLFloaterConversationPreview::onServiceLoaded( U64 token, const LLChatServiceHistory::HistoryResult& result) { - if (!mOpened || token != mServiceToken) return; + if (!mOpened || token != mServiceToken) + { + return; + } + mServiceLocalLoading = false; const LLChatServiceHistory::Snapshot snapshot = LLChatServiceHistory::getSnapshot(mParticipantID); + + // Account transitions and the common privacy gate invalidate the captured read + // before any result reaches the backing list. if (result.account_epoch != LLChatServiceHistory::accountEpoch() || LLChatServiceHistory::historySuppressed()) { return; } + const bool reload_pending = mServiceReloadPending; mServiceReloadPending = false; + + // A changed archive serial or presentation gate requires a fresh read from the + // current source set. if (result.archive_serial != snapshot.archive_serial) { startServiceLoad(); return; } + if (result.included_service != snapshot.service_presentation_allowed) { startServiceLoad(); return; } + mServiceAppliedSerial = result.archive_serial; + + // 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); setPages(new std::list(merged), mChatHistoryFileName); + if (reload_pending) { startServiceLoad(); @@ -379,35 +467,62 @@ void LLFloaterConversationPreview::onServiceLoaded( void LLFloaterConversationPreview::onServiceSnapshot( const LLChatServiceHistory::Snapshot& snapshot) { - if (!mOpened) return; + if (!mOpened) + { + return; + } + + // Presentation revocation removes only service-tagged rows immediately; legacy + // rows remain available under the common account history gate. const bool presentation_changed = snapshot.service_presentation_allowed != mServicePresentationAllowed; mServicePresentationAllowed = snapshot.service_presentation_allowed; + if (presentation_changed && mServiceLocalLoading) + { 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); + 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 && snapshot.metadata_resolved) { mChatHistoryFileName = LLCacheName::buildUsername(snapshot.metadata.getUserName()); mServiceNameReloaded = true; - if (mServiceLocalLoading) mServiceReloadPending = true; - else startServiceLoad(); + if (mServiceLocalLoading) + { + mServiceReloadPending = true; + } + else + { + startServiceLoad(); + } } + + // 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()) { std::list merged = LLChatServiceHistory::mergeHeadPreview( mMessages ? *mMessages : std::list(), snapshot, 10000); setPages(new std::list(merged), mChatHistoryFileName); } + + // A serial or presentation transition needs one fresh durable stitched read. if (!mServiceLocalLoading && (presentation_changed || snapshot.archive_serial != mServiceAppliedSerial)) { diff --git a/indra/newview/llfloaterconversationpreview.h b/indra/newview/llfloaterconversationpreview.h index 6b02d60f2d..4c03095e94 100644 --- a/indra/newview/llfloaterconversationpreview.h +++ b/indra/newview/llfloaterconversationpreview.h @@ -49,9 +49,13 @@ public: void draw() override; void onOpen(const LLSD& key) override; void onClose(bool app_quitting) override; + + // Fence outstanding loads and clear the visible history after account deletion. void invalidateHistory(); private: + // Non-P2P previews retain the legacy loader, while P2P previews use the shared + // stitched loader and resident snapshot stream. void onMoreHistoryBtnClick(); void showHistory(); void startLegacyLoad(); @@ -81,6 +85,8 @@ private: bool mLoadingIndicatorVisible; bool mServiceNameReloaded; bool mServicePresentationAllowed; + + // Tokens fence asynchronous applies across close, reopen, and reload requests. U64 mServiceToken; U32 mServiceAppliedSerial; boost::signals2::connection mHistoryContentConnection; diff --git a/indra/newview/llfloaterimcontainer.cpp b/indra/newview/llfloaterimcontainer.cpp index 81a51617bb..9aa2382e09 100644 --- a/indra/newview/llfloaterimcontainer.cpp +++ b/indra/newview/llfloaterimcontainer.cpp @@ -1396,7 +1396,13 @@ void LLFloaterIMContainer::doToSelectedConversation(const std::string& command, } else if("chat_history" == command) { - if (LLChatServiceHistory::historySuppressed()) return; + // The deletion latch hides every local history source until its durable + // sweep completes. + if (LLChatServiceHistory::historySuppressed()) + { + return; + } + if (selectedIDS.size() > 0) { if(conversationItem->getType() == LLConversationItem::CONV_SESSION_GROUP) @@ -1433,7 +1439,9 @@ void LLFloaterIMContainer::doToSelectedConversation(const std::string& command, if("chat_history" == command) { if (!LLChatServiceHistory::historySuppressed()) + { LLFloaterReg::showInstance("preview_conversation", LLSD(LLUUID::null), true); + } } } } @@ -1508,7 +1516,11 @@ bool LLFloaterIMContainer::enableContextMenuItem(const LLSD& userdata) //Enable Chat history item for ad-hoc and group conversations if ("can_chat_history" == item && uuids.size() > 0) { - if (LLChatServiceHistory::historySuppressed()) return false; + // History is unavailable while an account-wide deletion is pending. + if (LLChatServiceHistory::historySuppressed()) + { + return false; + } //Disable menu item if selected participant is user agent if(uuids.front() != gAgentID) { @@ -1529,7 +1541,7 @@ bool LLFloaterIMContainer::enableContextMenuItem(const LLSD& userdata) { bool is_group = (getCurSelectedViewModelItem()->getType() == LLConversationItem::CONV_SESSION_GROUP); return !LLChatServiceHistory::historySuppressed() && - (LLLogChat::isTranscriptExist(uuids.front(),is_group) || + (LLLogChat::isTranscriptExist(uuids.front(), is_group) || (!is_group && LLChatServiceHistory::localHistoryExists(uuids.front()))); } } @@ -1563,7 +1575,7 @@ bool LLFloaterIMContainer::enableContextMenuItem(const std::string& item, uuid_v if ("can_chat_history" == item && is_single_select) { return !LLChatServiceHistory::historySuppressed() && - (LLLogChat::isTranscriptExist(uuids.front(),false) || + (LLLogChat::isTranscriptExist(uuids.front(), false) || LLChatServiceHistory::localHistoryExists(uuids.front())); } diff --git a/indra/newview/llfloaterimsession.cpp b/indra/newview/llfloaterimsession.cpp index 6db370916e..11116c4cc5 100644 --- a/indra/newview/llfloaterimsession.cpp +++ b/indra/newview/llfloaterimsession.cpp @@ -1124,18 +1124,29 @@ void LLFloaterIMSession::processSessionUpdate(const LLSD& session_update) // virtual void LLFloaterIMSession::draw() { - const bool loading = mSession && mIsP2PChat && + // The floater presents both model-owned local reads and account-scoped service + // work without scheduling either source from draw(). + const bool loading = + mSession && + mIsP2PChat && gSavedPerAccountSettings.getBOOL("LogShowHistory") && (mSession->isChatHistoryLoading() || - LLChatServiceHistory::getSnapshot( - mSession->mOtherParticipantID).service_work_active); + LLChatServiceHistory::getSnapshot(mSession->mOtherParticipantID).service_work_active); + if (loading != mChatServiceLoadingVisible) { mChatServiceLoadingVisible = loading; getChildView("chat_service_loading")->setVisible(loading); LLLoadingIndicator* indicator = getChild("chat_service_loading_wheel"); - if (loading) indicator->start(); else indicator->stop(); + if (loading) + { + indicator->start(); + } + else + { + indicator->stop(); + } } // add people who were added via dropPerson() diff --git a/indra/newview/llfloaterimsession.h b/indra/newview/llfloaterimsession.h index d90354c718..01147f1311 100644 --- a/indra/newview/llfloaterimsession.h +++ b/indra/newview/llfloaterimsession.h @@ -189,6 +189,8 @@ private: LLFrameTimer mOtherTypingTimer; bool mSessionInitialized; + + // Cache the rendered state so draw() starts or stops the loading indicator only on transitions. bool mChatServiceLoadingVisible; LLSD mQueuedMsgsForInit; diff --git a/indra/newview/llfloaterimsessiontab.cpp b/indra/newview/llfloaterimsessiontab.cpp index 2e044edc46..89e2a1f231 100644 --- a/indra/newview/llfloaterimsessiontab.cpp +++ b/indra/newview/llfloaterimsessiontab.cpp @@ -1057,6 +1057,9 @@ void LLFloaterIMSessionTab::processChatHistoryStyleUpdate(bool clean_messages/* // Model-owned direct history is reloaded once, including sessions without a floater. LLIMModel::instance().reloadDirectHistories(); } + + // P2P floaters render the model reload above; other floaters retain their + // existing clean-and-reload behavior. LLFloaterReg::const_instance_list_t& inst_list = LLFloaterReg::getFloaterList("impanel"); for (LLFloaterReg::const_instance_list_t::const_iterator iter = inst_list.begin(); iter != inst_list.end(); ++iter) diff --git a/indra/newview/llfloaterpreference.cpp b/indra/newview/llfloaterpreference.cpp index 5251c9fb58..40def409a2 100644 --- a/indra/newview/llfloaterpreference.cpp +++ b/indra/newview/llfloaterpreference.cpp @@ -543,8 +543,9 @@ bool LLFloaterPreference::postBuild() void LLFloaterPreference::updateDeleteTranscriptsButton() { - mDeleteTranscriptsBtn->setEnabled(!mDeleteTranscriptsPending && - (LLLogChat::transcriptFilesExist() || LLChatServiceHistory::localHistoryExists())); + const bool history_exists = + LLLogChat::transcriptFilesExist() || LLChatServiceHistory::localHistoryExists(); + mDeleteTranscriptsBtn->setEnabled(!mDeleteTranscriptsPending && history_exists); } void LLFloaterPreference::onDoNotDisturbResponseChanged() @@ -1920,9 +1921,12 @@ void LLFloaterPreference::onDeleteTranscriptsResponse(const LLSD& notification, { if (0 == LLNotificationsUtil::getSelectedOption(notification, response)) { + // Disable repeated requests immediately. The weak handle keeps the + // main-queue completion safe if Preferences closes during the durable sweep. LLHandle handle = getDerivedHandle(); mDeleteTranscriptsPending = true; updateDeleteTranscriptsButton(); + if (!LLChatServiceHistory::deleteTranscriptsAsync( [handle](bool) { diff --git a/indra/newview/llfloaterpreference.h b/indra/newview/llfloaterpreference.h index 3d3f081462..446dbf4402 100644 --- a/indra/newview/llfloaterpreference.h +++ b/indra/newview/llfloaterpreference.h @@ -235,6 +235,8 @@ private: LLScrollListCtrl* mEnabledPopups = nullptr; LLScrollListCtrl* mDisabledPopups = nullptr; LLButton* mDeleteTranscriptsBtn = nullptr; + + // Prevent duplicate account-wide deletion requests while the durable sweep runs. bool mDeleteTranscriptsPending = false; LLButton* mEnablePopupBtn = nullptr; LLButton* mDisablePopupBtn = nullptr; diff --git a/indra/newview/llimview.cpp b/indra/newview/llimview.cpp index 2823b6d256..aad4c4f5eb 100644 --- a/indra/newview/llimview.cpp +++ b/indra/newview/llimview.cpp @@ -1362,6 +1362,8 @@ void LLIMModel::LLIMSession::loadHistory() mLastHistoryCacheMsgs.clear(); mLastHistoryCacheDateTime.clear(); + // Presentation and privacy gates clear only historical rows; live session rows + // stay in the model in their current order. if (!gSavedPerAccountSettings.getBOOL("LogShowHistory")) { mChatHistoryLoadToken = ++sChatHistoryLoadToken; @@ -1370,6 +1372,7 @@ void LLIMModel::LLIMSession::loadHistory() replaceHistoricalMessages(mChatServiceHistoricalValue); return; } + if (LLChatServiceHistory::historySuppressed()) { mChatHistoryLoadToken = ++sChatHistoryLoadToken; @@ -1378,8 +1381,10 @@ void LLIMModel::LLIMSession::loadHistory() replaceHistoricalMessages(mChatServiceHistoricalValue); return; } + if (!isP2P()) { + // Nearby, group, and ad-hoc sessions retain the synchronous legacy loader. chat_message_list_t chat_history; LLLogChat::loadChatHistory(mHistoryFileName, chat_history, LLSD(), isGroupChat()); replaceHistoricalMessages(chat_history); @@ -1390,43 +1395,57 @@ void LLIMModel::LLIMSession::loadHistory() const LLUUID session_id = mSessionID; const LLUUID participant_id = mOtherParticipantID; mChatHistoryLocalLoading = true; + + // P2P history is stitched off-thread. The session ID, participant ID, and + // process-unique token jointly fence completion against session recreation. if (!LLChatServiceHistory::loadStitchedHistory( - participant_id, mHistoryFileName, 200, - [session_id, participant_id, token](const LLChatServiceHistory::HistoryResult& result) - { - LLIMSession* session = LLIMModel::instance().findIMSession(session_id); - if (!session || session->mOtherParticipantID != participant_id || - session->mChatHistoryLoadToken != token) - { - return; - } - if (result.account_epoch != LLChatServiceHistory::accountEpoch() || - LLChatServiceHistory::historySuppressed() || - !gSavedPerAccountSettings.getBOOL("LogShowHistory")) + participant_id, mHistoryFileName, 200, + [session_id, participant_id, token](const LLChatServiceHistory::HistoryResult& result) { + LLIMSession* session = LLIMModel::instance().findIMSession(session_id); + if (!session || session->mOtherParticipantID != participant_id || + session->mChatHistoryLoadToken != token) + { + return; + } + + // Account, privacy, presentation, and archive generations must still + // match before the asynchronous result can replace model history. + if (result.account_epoch != LLChatServiceHistory::accountEpoch() || + LLChatServiceHistory::historySuppressed() || + !gSavedPerAccountSettings.getBOOL("LogShowHistory")) + { + session->mChatHistoryLocalLoading = false; + return; + } + + const LLChatServiceHistory::Snapshot snapshot = + LLChatServiceHistory::getSnapshot(participant_id); + if (result.included_service != snapshot.service_presentation_allowed) + { + session->loadHistory(); + return; + } + + if (result.archive_serial != snapshot.archive_serial) + { + session->loadHistory(); + return; + } + + // Merge the transient validated head only after the durable read is + // current, then replace the historical overlay in one operation. + session->mChatHistoryArchiveSerial = result.archive_serial; session->mChatHistoryLocalLoading = false; - return; - } - const LLChatServiceHistory::Snapshot snapshot = - LLChatServiceHistory::getSnapshot(participant_id); - if (result.included_service != snapshot.service_presentation_allowed) - { - session->loadHistory(); - return; - } - if (result.archive_serial != snapshot.archive_serial) - { - session->loadHistory(); - return; - } - session->mChatHistoryArchiveSerial = result.archive_serial; - session->mChatHistoryLocalLoading = false; - session->mChatServiceHistoricalValue = LLChatServiceHistory::mergeHeadPreview( - result.messages, snapshot, 200); - session->replaceHistoricalMessages(session->mChatServiceHistoricalValue); - })) + session->mChatServiceHistoricalValue = LLChatServiceHistory::mergeHeadPreview( + result.messages, snapshot, 200); + session->replaceHistoricalMessages(session->mChatServiceHistoricalValue); + })) { + // If dispatch infrastructure is unavailable, retain ordinary plaintext + // history unless the account-wide privacy gate suppresses all sources. mChatHistoryLocalLoading = false; + if (!LLChatServiceHistory::historySuppressed()) { chat_message_list_t legacy; @@ -1443,8 +1462,14 @@ void LLIMModel::LLIMSession::replaceHistoricalMessages(const chat_message_list_t chat_message_list_t live; for (const LLSD& message : mMsgs) { - if (!message["is_history"].asBoolean()) live.push_back(message); + if (!message["is_history"].asBoolean()) + { + live.push_back(message); + } } + + // 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 : history) { @@ -1458,16 +1483,27 @@ void LLIMModel::LLIMSession::replaceHistoricalMessages(const chat_message_list_t message["is_region_msg"] = false; historical.push_front(message); } + 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; + for (LLSD& message : mMsgs) + { + message["index"] = --index; + } + if (LLFloaterIMSession* floater = LLFloaterIMSession::findInstance(mSessionID)) + { floater->reloadMessages(false); + } } void LLIMModel::LLIMSession::clearHistoricalMessages() { + // Advancing the token prevents an older asynchronous load from restoring rows + // after presentation has been cleared. mChatHistoryLoadToken = ++sChatHistoryLoadToken; mChatHistoryLocalLoading = false; mChatServiceHistoricalValue.clear(); @@ -1488,35 +1524,51 @@ void LLIMModel::LLIMSession::clearForHistoryDeletion() mNumUnread = 0; mParticipantUnreadMessageCount = 0; mHasOfflineMessage = false; + if (LLFloaterIMSession* floater = LLFloaterIMSession::findInstance(mSessionID)) + { floater->reloadMessages(false); + } } void LLIMModel::LLIMSession::applyChatServiceSnapshot( const LLChatServiceHistory::Snapshot& snapshot) { if (!gSavedPerAccountSettings.getBOOL("LogShowHistory") || - LLChatServiceHistory::historySuppressed()) return; + LLChatServiceHistory::historySuppressed()) + { + 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); + 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()) { - // Merge the bounded first page in memory; the durable archive remains the seam authority. mChatServiceHistoricalValue = LLChatServiceHistory::mergeHeadPreview( mChatServiceHistoricalValue, snapshot, 200); replaceHistoricalMessages(mChatServiceHistoricalValue); } + if (presentation_changed || snapshot.archive_serial != mChatHistoryArchiveSerial) { loadHistory(); @@ -1530,21 +1582,32 @@ void LLIMModel::LLIMSession::startHistoryLoading() loadHistory(); return; } + + // Connect before querying so the session cannot miss a resident snapshot change + // between model insertion and its initial stitched read. const LLUUID session_id = mSessionID; const LLUUID participant_id = mOtherParticipantID; mChatServiceSnapshotConnection = LLChatServiceHistory::setSnapshotChanged( [session_id, participant_id](const LLUUID& changed, const LLChatServiceHistory::Snapshot& snapshot) { - if (changed != participant_id) return; + if (changed != participant_id) + { + return; + } + LLIMSession* session = LLIMModel::instance().findIMSession(session_id); if (session && session->mOtherParticipantID == participant_id) + { session->applyChatServiceSnapshot(snapshot); + } }); + const LLChatServiceHistory::Snapshot snapshot = LLChatServiceHistory::getSnapshot(participant_id); mChatHistoryArchiveSerial = snapshot.archive_serial; mChatServicePresentationAllowed = snapshot.service_presentation_allowed; + applyChatServiceSnapshot(snapshot); LLChatServiceHistory::prioritizeResident(participant_id); loadHistory(); @@ -1557,9 +1620,13 @@ LLIMModel::LLIMSession* LLIMModel::findIMSession(const LLUUID& session_id) const void LLIMModel::reloadDirectHistories() { + // Model-owned P2P sessions reload even when no floater currently exists. for (auto& pair : mId2SessionMap) { - if (pair.second->isP2P()) pair.second->loadHistory(); + if (pair.second->isP2P()) + { + pair.second->loadHistory(); + } } } @@ -1776,6 +1843,9 @@ bool LLIMModel::newSession(const LLUUID& session_id, const std::string& name, co LLIMSession *session = new LLIMSession(session_id, name, type, other_participant_id, voiceChannelInfo, ids, has_offline_msg); mId2SessionMap[session_id] = session; + + // Insert first so snapshot callbacks and asynchronous completions can resolve the + // newly-created session through the model. session->startHistoryLoading(); // When notifying observer, name of session is used instead of "name", because they may not be the diff --git a/indra/newview/llimview.h b/indra/newview/llimview.h index d3d4eb1d86..3ea8ebac43 100644 --- a/indra/newview/llimview.h +++ b/indra/newview/llimview.h @@ -28,9 +28,9 @@ #define LL_LLIMVIEW_H #include "../llui/lldockablefloater.h" +#include "llchatservicehistory.h" #include "lleventtimer.h" #include "llinstantmessage.h" -#include "llchatservicehistory.h" #include "lllogchat.h" #include "llvoicechannel.h" @@ -117,13 +117,19 @@ public: /** ad-hoc sessions involve sophisticated chat history file naming schemes */ void buildHistoryFileName(); + // Direct history is an asynchronous overlay; these operations never discard live rows. void loadHistory(); void startHistoryLoading(); void replaceHistoricalMessages(const chat_message_list_t& history); void applyChatServiceSnapshot(const LLChatServiceHistory::Snapshot& snapshot); void clearHistoricalMessages(); + + // Account deletion is the explicit exception that clears both live and historical rows. void clearForHistoryDeletion(); - bool isChatHistoryLoading() const { return mChatHistoryLocalLoading; } + bool isChatHistoryLoading() const + { + return mChatHistoryLocalLoading; + } LLUUID mSessionID; std::string mName; @@ -147,7 +153,11 @@ public: S32 mNumUnread; chat_message_list_t mMsgs; + + // Historical rows are replaced as one overlay so live rows remain untouched. chat_message_list_t mChatServiceHistoricalValue; + + // Process-unique tokens and archive serials reject stale asynchronous reads. U64 mChatHistoryLoadToken = 0; U32 mChatHistoryArchiveSerial = 0; bool mChatHistoryLocalLoading = false; @@ -195,6 +205,8 @@ public: * Returns NULL if the session does not exist */ LLIMSession* findIMSession(const LLUUID& session_id) const; + + // Reload every model-owned direct session, including sessions without an open floater. void reloadDirectHistories(); /** diff --git a/indra/newview/lllogchat.cpp b/indra/newview/lllogchat.cpp index e8bc2bb43d..306d490fff 100644 --- a/indra/newview/lllogchat.cpp +++ b/indra/newview/lllogchat.cpp @@ -150,24 +150,38 @@ namespace void parseHistoryFile(LLFILE* input, std::list& messages, const LLSD& load_params, bool load_all) { + // A bounded load seeks near EOF and discards the first partial line; full + // stitched reads parse from the beginning of each exact transcript path. char buffer[LOG_RECALL_SIZE]; bool skip_partial_line = true; if (load_all || fseek(input, (LOG_RECALL_SIZE - 1) * -1, SEEK_END)) { skip_partial_line = false; - if (fseek(input, 0, SEEK_SET)) return; + if (fseek(input, 0, SEEK_SET)) + { + return; + } } + while (fgets(buffer, LOG_RECALL_SIZE, input)) { + // Normalize either newline style before applying legacy multiline rules. size_t length = strlen(buffer); while (length && (buffer[length - 1] == '\n' || buffer[length - 1] == '\r')) + { buffer[--length] = '\0'; + } + if (skip_partial_line) { skip_partial_line = false; continue; } + std::string line(remove_utf8_bom(buffer)); + + // Restore mention URLs only when the inexpensive marker indicates the + // formatter's markdown wrapper may be present. if (line.find("/mention)") != std::string::npos) { static const boost::regex mention_regex( @@ -175,14 +189,22 @@ namespace boost::regex::perl | boost::regex::icase); line = boost::regex_replace(line, mention_regex, "$2"); } + + // Indented lines continue the prior multiline message in the current + // plaintext format. if (!line.empty() && line[0] == ' ') { line.erase(0, MULTI_LINE_PREFIX.length()); append_to_last_message(messages, '\n' + line); continue; } + + // Parser failures remain readable as plain transcript text. LLSD item; - if (!LLChatLogParser::parse(line, item, load_params)) item[LL_IM_TEXT] = line; + if (!LLChatLogParser::parse(line, item, load_params)) + { + item[LL_IM_TEXT] = line; + } messages.push_back(item); } } @@ -539,6 +561,7 @@ void LLLogChat::getTranscriptFamily(const std::string& file_name, std::vectorgetPerAccountChatLogsDir() + gDirUtilp->getDirDelimiter(); @@ -548,14 +571,17 @@ void LLLogChat::getTranscriptFamily(const std::string& file_name, std::vector shards; - LLDirIterator iterator(directory, stem + "-????-??." + LL_TRANSCRIPT_FILE_EXTENSION); + // Split the glob before the separator so its question marks cannot form a C++ trigraph. + LLDirIterator iterator(directory, stem + "-????" "-??." + LL_TRANSCRIPT_FILE_EXTENSION); std::string name; while (iterator.next(name)) { shards.push_back(directory + name); } + std::sort(shards.begin(), shards.end()); paths.insert(paths.end(), shards.begin(), shards.end()); } @@ -564,7 +590,11 @@ void LLLogChat::getTranscriptFamily(const std::string& file_name, std::vector& messages, const LLSD& load_params) { - if (LLChatServiceHistory::historySuppressed()) return; + if (LLChatServiceHistory::historySuppressed()) + { + return; + } + loadChatHistoryExactUnchecked(path, messages, load_params); } @@ -576,6 +606,7 @@ void LLLogChat::loadChatHistoryExactUnchecked( { return; } + LLFILE* input = LLFile::fopen(path, LLFILE_MODE("rb")); if (!input) { @@ -901,6 +932,8 @@ bool LLLogChat::deleteTranscriptContent(const std::string& directory) } } + // Retry each captured path independently while writers remain excluded. Any + // exhausted target keeps the durable account deletion in its pending state. for (const std::string& fullpath : list_of_transcriptions) { S32 retry_count = 0; @@ -935,9 +968,13 @@ bool LLLogChat::deleteTranscriptContent(const std::string& directory) #if LL_WINDOWS return true; #else - // Commit removed directory entries before durable Delete can publish completion. + // Flush removed directory entries before durable Delete can publish completion. const int descriptor = ::open(directory.c_str(), O_RDONLY | O_DIRECTORY); - if (descriptor < 0) return false; + if (descriptor < 0) + { + return false; + } + const bool synced = ::fsync(descriptor) == 0; ::close(descriptor); return synced; diff --git a/indra/newview/lllogchat.h b/indra/newview/lllogchat.h index 0474fe7cc3..468093eff7 100644 --- a/indra/newview/lllogchat.h +++ b/indra/newview/lllogchat.h @@ -109,7 +109,8 @@ public: static void getListOfTranscriptBackupFiles(std::vector& list_of_transcriptions); static void loadChatHistory(const std::string& file_name, std::list& messages, const LLSD& load_params = LLSD(), bool is_group = false); - // ChatService resolves the family once, then reuses this canonical parser per exact path. + // Enumerate the ordinary transcript followed by lexical monthly shards, then + // reuse the canonical parser for each exact path. static void getTranscriptFamily(const std::string& file_name, std::vector& paths); static void loadChatHistoryExact(const std::string& path, std::list& messages, const LLSD& load_params = LLSD()); @@ -126,6 +127,9 @@ public: std::vector& listOfFilesToMove); static void deleteTranscripts(); + + // The success-returning sweep lets durable ChatService deletion remain pending + // until every legacy transcript target has been removed safely. static bool deleteTranscriptContent(); static bool deleteTranscriptContent(const std::string& directory); static void notifyTranscriptCreated(); diff --git a/indra/newview/llnotificationhandlerutil.cpp b/indra/newview/llnotificationhandlerutil.cpp index ca11107f61..fc973e00ae 100644 --- a/indra/newview/llnotificationhandlerutil.cpp +++ b/indra/newview/llnotificationhandlerutil.cpp @@ -327,6 +327,7 @@ void LLHandlerUtil::decIMMesageCounter(const LLNotificationPtr& notification) { LLSD arg; arg["session_id"] = session_id; + // Keep the decrements clamped if notification dismissal follows session cleanup. session->mNumUnread = llmax(0, session->mNumUnread - 1); arg["num_unread"] = session->mNumUnread; session->mParticipantUnreadMessageCount = diff --git a/indra/newview/llpanelprofile.cpp b/indra/newview/llpanelprofile.cpp index 417fa57f6d..b2afe7e054 100644 --- a/indra/newview/llpanelprofile.cpp +++ b/indra/newview/llpanelprofile.cpp @@ -2493,3 +2493,4 @@ void LLPanelProfile::createClassified() mPanelClassifieds->createClassified(); mTabContainer->selectTabPanel(mPanelClassifieds); } + diff --git a/indra/newview/llviewerregion.cpp b/indra/newview/llviewerregion.cpp index 3ec12e46e8..624d8c3682 100644 --- a/indra/newview/llviewerregion.cpp +++ b/indra/newview/llviewerregion.cpp @@ -3842,3 +3842,4 @@ void LLViewerRegion::applyCacheMiscExtras(LLViewerObject* obj) } } } + diff --git a/indra/newview/tests/llchatservicehistorycore_test.cpp b/indra/newview/tests/llchatservicehistorycore_test.cpp index 7b5cbe9dfe..3a160a95c2 100644 --- a/indra/newview/tests/llchatservicehistorycore_test.cpp +++ b/indra/newview/tests/llchatservicehistorycore_test.cpp @@ -1,6 +1,11 @@ /** * @file llchatservicehistorycore_test.cpp * @brief Strict ChatService wire, TimeUUID, and archive tests. + * + * $LicenseInfo:firstyear=2026&license=viewerlgpl$ + * Second Life Viewer Source Code + * Copyright (C) 2026, Linden Research, Inc. + * $/LicenseInfo$ */ #include "linden_common.h" #include "../test/lltut.h" @@ -31,6 +36,7 @@ LLSD wireRow(const std::string& id, const LLUUID& from = RESIDENT) row["message"] = "hello"; row["dialog"] = 0; row["created_at"] = "2026-08-12T12:34:56.123Z"; + return row; } @@ -40,7 +46,11 @@ LLSD wirePage(const LLSD& rows, const std::string& cursor = std::string()) page["conversation_id"] = directConversationId(AGENT, RESIDENT); page["limit"] = 100; page["messages"] = rows; - if (!cursor.empty()) page["before_msg_id"] = cursor; + if (!cursor.empty()) + { + page["before_msg_id"] = cursor; + } + return page; } @@ -52,9 +62,13 @@ template<> template<> void object_t::test<1>() template<> template<> void object_t::test<2>() { - TimeUuidKey first, second; + // UUID parsing accepts only canonical version-1 identifiers and preserves their + // timestamp order. + TimeUuidKey first; + TimeUuidKey second; ensure("UUIDv1 parses", parseTimeUuid(FIRST, first)); ensure("timestamp ordering", parseTimeUuid(SECOND, second) && first < second); + ensure("UUIDv4 rejected", !parseTimeUuid( "00000001-0000-4000-8000-000000000000", first)); ensure("noncanonical UUID rejected", !parseTimeUuid( @@ -63,7 +77,9 @@ template<> template<> void object_t::test<2>() template<> template<> void object_t::test<3>() { - TimeUuidKey low, high; + // Equal-timestamp tails follow Cassandra's signed-byte comparison. + TimeUuidKey low; + TimeUuidKey high; parseTimeUuid(FIRST, low); parseTimeUuid("00000001-0000-1000-bfff-000000000000", high); ensure("Cassandra signed tail ordering", low < high); @@ -71,9 +87,11 @@ template<> template<> void object_t::test<3>() template<> template<> void object_t::test<4>() { + // A valid page is strictly descending and exposes its oldest row as the cursor. LLSD rows = LLSD::emptyArray(); rows.append(wireRow(SECOND)); rows.append(wireRow(FIRST, AGENT)); + Page page; ensure("strict descending page", validateHistoryPage( wirePage(rows), AGENT, RESIDENT, directConversationId(AGENT, RESIDENT), @@ -84,13 +102,16 @@ template<> template<> void object_t::test<4>() template<> template<> void object_t::test<5>() { + // Ordering and exact LLSD types are page-wide validation requirements. LLSD rows = LLSD::emptyArray(); rows.append(wireRow(FIRST)); rows.append(wireRow(SECOND)); + Page page; ensure("ascending page rejected", !validateHistoryPage( wirePage(rows), AGENT, RESIDENT, directConversationId(AGENT, RESIDENT), "", 0, page)); + LLSD wrong = wirePage(LLSD::emptyArray()); wrong["limit"] = "100"; ensure("coercible type rejected", !validateHistoryPage( @@ -99,6 +120,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. LLSD list = LLSD::emptyArray(); LLSD entry; entry["conversation_type"] = "direct"; @@ -106,10 +128,13 @@ template<> template<> void object_t::test<6>() entry["other_participant_id"] = RESIDENT.asString(); entry["last_msg_id"] = SECOND; list.append(entry); + std::vector entries; ensure("strict list accepted", validateConversationList(list, AGENT, entries)); + list.append(entry); ensure("duplicate resident rejected", !validateConversationList(list, AGENT, entries)); + LLSD null_list = LLSD::emptyArray(); entry["other_participant_id"] = LLUUID::null.asString(); entry["conversation_id"] = directConversationId(AGENT, LLUUID::null); @@ -120,15 +145,21 @@ template<> template<> void object_t::test<6>() template<> template<> void object_t::test<7>() { std::string normalized; + + // Accept the supported UTC, explicit-offset, offset-less, and fractional forms. ensure("UTC timestamp", parseCreatedAt("2026-08-12T12:34:56Z", normalized)); ensure("offset timestamp", parseCreatedAt("2026-08-12T12:34:56+02:30", normalized)); ensure("offset-less timestamp", parseCreatedAt("2026-08-12T12:34:56", normalized)); ensure("fractional timestamp", parseCreatedAt("2026-08-12T12:34:56.123Z", normalized)); + + // Reject malformed suffixes and noncanonical field shapes. ensure("reject trailing timestamp data", !parseCreatedAt("2026-08-12T12:34:56Z123", normalized)); ensure("reject invalid offset", !parseCreatedAt("2026-08-12T12:34:56+99:99", normalized)); ensure("reject signed date field", !parseCreatedAt("2026-+1-12T12:34:56Z", normalized)); ensure("reject empty fraction", !parseCreatedAt("2026-08-12T12:34:56.Z", normalized)); + + // Enforce calendar and clock ranges after the fixed-width shape validates. ensure("reject month", !parseCreatedAt("2026-13-12T12:34:56Z", normalized)); ensure("reject day", !parseCreatedAt("2026-08-35T12:34:56Z", normalized)); ensure("reject nonleap day", !parseCreatedAt("2026-02-29T12:34:56Z", normalized)); @@ -137,6 +168,8 @@ template<> template<> void object_t::test<7>() ensure("reject minute", !parseCreatedAt("2026-08-12T12:60:56Z", normalized)); ensure("reject second", !parseCreatedAt("2026-08-12T12:34:60Z", normalized)); ensure("bad width", !parseCreatedAt("2026-8-12T12:34:56Z", normalized)); + + // Persisted history uses an explicit IM-dialog allowlist. ensure("dialog allowlist", persistedDirectDialog(38) && !persistedDirectDialog(99)); } @@ -148,22 +181,29 @@ template<> template<> void object_t::test<8>() std::string validArchive() { - Row first, second; - first.conversation_id = second.conversation_id = directConversationId(AGENT, RESIDENT); + // Build a minimal ascending archive used by valid, torn, and corrupt scan cases. + Row first; + Row second; + first.conversation_id = directConversationId(AGENT, RESIDENT); + second.conversation_id = first.conversation_id; first.msg_id = FIRST; second.msg_id = SECOND; first.from_id = AGENT; second.from_id = RESIDENT; - first.from_name = second.from_name = "Resident"; + first.from_name = "Resident"; + second.from_name = first.from_name; first.message = "first"; second.message = "second, quoted"; - first.created_at = second.created_at = "2026-08-12T12:34:56Z"; + first.created_at = "2026-08-12T12:34:56Z"; + second.created_at = first.created_at; parseTimeUuid(first.msg_id, first.key); parseTimeUuid(second.msg_id, second.key); + std::ostringstream output; output << CSV_HEADER; writeCsvRow(output, first); writeCsvRow(output, second); + return output.str(); } @@ -171,6 +211,7 @@ template<> template<> void object_t::test<9>() { NamedTempFile file("chatservice", validArchive()); ArchiveScan scan; + ensure("valid archive", scanArchive(file.getPath().string(), AGENT, RESIDENT, 0, 1, scan)); ensure_equals("two summarized", scan.row_count, U32(2)); ensure_equals("newest display cap", scan.display_rows.size(), size_t(1)); @@ -179,8 +220,10 @@ template<> template<> void object_t::test<9>() template<> template<> void object_t::test<10>() { + // An incomplete final quoted record is repairable torn-tail state. NamedTempFile file("chatservice", validArchive() + "\"unterminated"); ArchiveScan scan; + ensure("torn EOF rejected", !scanArchive( file.getPath().string(), AGENT, RESIDENT, 0, 0, scan)); ensure_equals("torn distinguished", scan.state, ARCHIVE_TORN); @@ -188,12 +231,16 @@ template<> template<> void object_t::test<10>() template<> template<> void object_t::test<11>() { + // A newline-terminated row with an unsupported dialog is complete corruption, + // not a repairable EOF fragment. std::string content = validArchive(); const std::string needle = ",0,2026-08-12T12:34:56Z\n"; const size_t last = content.rfind(needle); content.replace(last, needle.size(), ",99,2026-08-12T12:34:56Z\n"); + NamedTempFile file("chatservice", content); ArchiveScan scan; + ensure("complete malformed row rejected", !scanArchive( file.getPath().string(), AGENT, RESIDENT, 0, 0, scan)); ensure_equals("corrupt distinguished", scan.state, ARCHIVE_CORRUPT); -- cgit v1.3 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(-) (limited to 'indra/newview/llnotificationhandlerutil.cpp') 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