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/llavataractions.cpp | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) (limited to 'indra/newview/llavataractions.cpp') diff --git a/indra/newview/llavataractions.cpp b/indra/newview/llavataractions.cpp index fb1426a235..c8d80f3dd5 100644 --- a/indra/newview/llavataractions.cpp +++ b/indra/newview/llavataractions.cpp @@ -42,6 +42,7 @@ #include "llconversationlog.h" #include "llfloateravatarpicker.h" // for LLFloaterAvatarPicker #include "llfloaterconversationpreview.h" +#include "llchatservicehistory.h" #include "llfloatergroupinvite.h" #include "llfloatergroups.h" #include "llfloaterreg.h" @@ -1240,6 +1241,7 @@ void LLAvatarActions::inviteToGroup(const LLUUID& id) // static void LLAvatarActions::viewChatHistory(const LLUUID& id) { + if (LLChatServiceHistory::historySuppressed()) return; const std::vector& conversations = LLConversationLog::instance().getConversations(); std::vector::const_iterator iter = conversations.begin(); @@ -1252,7 +1254,7 @@ void LLAvatarActions::viewChatHistory(const LLUUID& id) } } - if (LLLogChat::isTranscriptExist(id)) + if (LLLogChat::isTranscriptExist(id) || LLChatServiceHistory::localHistoryExists(id)) { LLAvatarName avatar_name; LLSD extended_id(id); @@ -1260,6 +1262,7 @@ void LLAvatarActions::viewChatHistory(const LLUUID& id) LLAvatarNameCache::get(id, &avatar_name); extended_id[LL_FCP_COMPLETE_NAME] = avatar_name.getCompleteName(); extended_id[LL_FCP_ACCOUNT_NAME] = avatar_name.getAccountName(); + extended_id[LL_FCP_PARTICIPANT_ID] = id; LLFloaterReg::showInstance("preview_conversation", extended_id, true); } } -- 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/llavataractions.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 8248b904d4962050b00620fa81e09ec85194b4c8 Mon Sep 17 00:00:00 2001 From: pepper <3782201+rohvani@users.noreply.github.com> Date: Mon, 21 Sep 2026 17:20:34 -0400 Subject: Centralize history availability in the transcript query Make LLLogChat::isTranscriptExist apply the deletion gate and check both plaintext and direct service history. Restore its menu and profile callers to that existing query, removing their repeated policy and extra includes. Keep group history on the ordinary transcript path. --- indra/newview/llavataractions.cpp | 2 +- indra/newview/llchathistory.cpp | 8 +------- indra/newview/llfloaterimcontainer.cpp | 8 ++------ indra/newview/lllogchat.cpp | 8 +++++++- indra/newview/lllogchat.h | 1 + indra/newview/llpanelpeoplemenus.cpp | 5 +---- indra/newview/llpanelprofile.cpp | 6 +----- 7 files changed, 14 insertions(+), 24 deletions(-) (limited to 'indra/newview/llavataractions.cpp') diff --git a/indra/newview/llavataractions.cpp b/indra/newview/llavataractions.cpp index 70f722a247..b0f9d5ac59 100644 --- a/indra/newview/llavataractions.cpp +++ b/indra/newview/llavataractions.cpp @@ -1277,7 +1277,7 @@ void LLAvatarActions::viewChatHistory(const LLUUID& id) } } - if (LLLogChat::isTranscriptExist(id) || LLChatServiceHistory::localHistoryExists(id)) + if (LLLogChat::isTranscriptExist(id)) { LLAvatarName avatar_name; LLSD extended_id(id); diff --git a/indra/newview/llchathistory.cpp b/indra/newview/llchathistory.cpp index 8db072b445..68f7e48b3e 100644 --- a/indra/newview/llchathistory.cpp +++ b/indra/newview/llchathistory.cpp @@ -31,7 +31,6 @@ #include #include "llavatarnamecache.h" -#include "llchatservicehistory.h" #include "llinstantmessage.h" #include "llimview.h" @@ -1020,12 +1019,7 @@ 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)); - - const bool history_available = - !LLChatServiceHistory::historySuppressed() && - (LLLogChat::isTranscriptExist(mAvatarID) || - LLChatServiceHistory::localHistoryExists(mAvatarID)); - menu->setItemEnabled("Chat History", history_available); + menu->setItemEnabled("Chat History", LLLogChat::isTranscriptExist(mAvatarID)); } menu->setItemEnabled("Map", (LLAvatarTracker::instance().isBuddyOnline(mAvatarID) && is_agent_mappable(mAvatarID)) || gAgent.isGodlike() ); diff --git a/indra/newview/llfloaterimcontainer.cpp b/indra/newview/llfloaterimcontainer.cpp index 91201e5770..db57c549f4 100644 --- a/indra/newview/llfloaterimcontainer.cpp +++ b/indra/newview/llfloaterimcontainer.cpp @@ -1558,9 +1558,7 @@ bool LLFloaterIMContainer::enableContextMenuItem(const LLSD& userdata) else { bool is_group = (getCurSelectedViewModelItem()->getType() == LLConversationItem::CONV_SESSION_GROUP); - return !LLChatServiceHistory::historySuppressed() && - (LLLogChat::isTranscriptExist(uuids.front(), is_group) || - (!is_group && LLChatServiceHistory::localHistoryExists(uuids.front()))); + return LLLogChat::isTranscriptExist(uuids.front(), is_group); } } } @@ -1592,9 +1590,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) || - LLChatServiceHistory::localHistoryExists(uuids.front())); + return LLLogChat::isTranscriptExist(uuids.front(), false); } // Handle options that are applicable to all including the user agent diff --git a/indra/newview/lllogchat.cpp b/indra/newview/lllogchat.cpp index 060352ab4c..6e43b54178 100644 --- a/indra/newview/lllogchat.cpp +++ b/indra/newview/lllogchat.cpp @@ -963,13 +963,19 @@ void LLLogChat::notifyTranscriptCreated() // static bool LLLogChat::isTranscriptExist(const LLUUID& avatar_id, bool is_group) { + // History actions share the deletion gate and both direct transcript sources. + if (LLChatServiceHistory::historySuppressed()) + { + return false; + } LLAvatarName avatar_name; LLAvatarNameCache::get(avatar_id, &avatar_name); std::string avatar_user_name = avatar_name.getAccountName(); if(!is_group) { std::replace(avatar_user_name.begin(), avatar_user_name.end(), '.', '_'); - return isTranscriptFileFound(makeLogFileName(avatar_user_name)); + return isTranscriptFileFound(makeLogFileName(avatar_user_name)) || + LLChatServiceHistory::localHistoryExists(avatar_id); } else { diff --git a/indra/newview/lllogchat.h b/indra/newview/lllogchat.h index 5e24a658b3..73253961a3 100644 --- a/indra/newview/lllogchat.h +++ b/indra/newview/lllogchat.h @@ -134,6 +134,7 @@ public: // until every legacy transcript target has been removed safely. static bool deleteTranscriptContent(const std::string& directory); static void notifyTranscriptCreated(); + // Whether history can be opened: plaintext or direct service rows, subject to deletion. static bool isTranscriptExist(const LLUUID& avatar_id, bool is_group=false); static bool isNearbyTranscriptExist(); static bool isAdHocTranscriptExist(std::string file_name); diff --git a/indra/newview/llpanelpeoplemenus.cpp b/indra/newview/llpanelpeoplemenus.cpp index 2640285ecd..62e726d21d 100644 --- a/indra/newview/llpanelpeoplemenus.cpp +++ b/indra/newview/llpanelpeoplemenus.cpp @@ -38,7 +38,6 @@ #include "llavataractions.h" #include "llcallingcard.h" // for LLAvatarTracker #include "lllogchat.h" -#include "llchatservicehistory.h" #include "llparcel.h" #include "llviewermenu.h" // for gMenuHolder #include "llconversationmodel.h" @@ -248,9 +247,7 @@ bool PeopleContextMenu::enableContextMenuItem(const LLSD& userdata) } else if (item == std::string("can_callog")) { - return !LLChatServiceHistory::historySuppressed() && - (LLLogChat::isTranscriptExist(mUUIDs.front()) || - LLChatServiceHistory::localHistoryExists(mUUIDs.front())); + return LLLogChat::isTranscriptExist(mUUIDs.front()); } else if (item == std::string("can_im") || item == std::string("can_invite")) { diff --git a/indra/newview/llpanelprofile.cpp b/indra/newview/llpanelprofile.cpp index b2afe7e054..b77c947f8f 100644 --- a/indra/newview/llpanelprofile.cpp +++ b/indra/newview/llpanelprofile.cpp @@ -57,7 +57,6 @@ #include "llagent.h" //gAgent #include "llagentpicksinfo.h" #include "llavataractions.h" -#include "llchatservicehistory.h" #include "llavatarpropertiesprocessor.h" #include "llcallingcard.h" #include "llcommandhandler.h" @@ -1567,9 +1566,7 @@ bool LLPanelProfileSecondLife::onEnableMenu(const LLSD& userdata) } else if (item_name == "chat_history") { - return !LLChatServiceHistory::historySuppressed() && - (LLLogChat::isTranscriptExist(agent_id) || - LLChatServiceHistory::localHistoryExists(agent_id)); + return LLLogChat::isTranscriptExist(agent_id); } else if (item_name == "add_friend") { @@ -2493,4 +2490,3 @@ void LLPanelProfile::createClassified() mPanelClassifieds->createClassified(); mTabContainer->selectTabPanel(mPanelClassifieds); } - -- cgit v1.3