LCOV - code coverage report
Current view: top level - src/jamidht - collaborative_editing.cpp (source / functions) Coverage Total Hit
Test: jami-coverage-filtered.info Lines: 79.8 % 900 718
Test Date: 2026-11-09 10:41:14 Functions: 97.2 % 72 70

            Line data    Source code
       1              : /*
       2              :  *  Copyright (C) 2004-2026 Savoir-faire Linux Inc.
       3              :  *
       4              :  *  This program is free software: you can redistribute it and/or modify
       5              :  *  it under the terms of the GNU General Public License as published by
       6              :  *  the Free Software Foundation, either version 3 of the License, or
       7              :  *  (at your option) any later version.
       8              :  *
       9              :  *  This program is distributed in the hope that it will be useful,
      10              :  *  but WITHOUT ANY WARRANTY; without even the implied warranty of
      11              :  *  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
      12              :  *  GNU General Public License for more details.
      13              :  *
      14              :  *  You should have received a copy of the GNU General Public License
      15              :  *  along with this program. If not, see <https://www.gnu.org/licenses/>.
      16              :  */
      17              : #include "collaborative_editing.h"
      18              : 
      19              : #include "jamidht/jamiaccount.h"
      20              : #include "jamidht/conversation_module.h"
      21              : #include "jamidht/conversation.h"
      22              : #include "jamidht/commit_message.h"
      23              : #include "manager.h"
      24              : #include "client/jami_signal.h"
      25              : #include "base64.h"
      26              : #include "string_utils.h"
      27              : 
      28              : #include <dhtnet/multiplexed_socket.h>
      29              : #include <msgpack.hpp>
      30              : #include <opendht/thread_pool.h>
      31              : 
      32              : #include <algorithm>
      33              : #include <chrono>
      34              : #include <future>
      35              : #include <random>
      36              : 
      37              : namespace jami {
      38              : 
      39              : // Checkpoint policy. Writing a commit for every burst of keystrokes multiplies
      40              : // the number of git objects for no benefit: on a 6000-keystroke session, the
      41              : // commit cadence is by far the dominant factor of the on-disk footprint, ahead
      42              : // of what is actually stored in each commit. Updates are therefore accumulated
      43              : // and flushed either after a pause in typing or once enough of them have piled
      44              : // up, which bounds both the footprint and the amount of work lost on a crash.
      45              : static constexpr std::chrono::seconds CHECKPOINT_IDLE {10};
      46              : static constexpr size_t CHECKPOINT_MAX_PENDING {200};
      47              : /// Ceiling for a session that has no repository to drain into.
      48              : static constexpr size_t PENDING_HARD_CAP {CHECKPOINT_MAX_PENDING * 50};
      49              : 
      50              : // What a document holds when its creator did not say: the simplest thing an
      51              : // editor can be pointed at.
      52              : static constexpr const char DEFAULT_DOC_MIME_TYPE[] = "text/plain";
      53              : 
      54              : // Ceilings on what the repository is asked to hold per item. A name is a label,
      55              : // not a place to keep content; an attachment is bounded because it is stored
      56              : // and replicated whole.
      57              : static constexpr size_t MAX_DOCUMENT_NAME_SIZE {256};
      58              : static constexpr size_t MAX_ATTACHMENT_SIZE {16 * 1024 * 1024};
      59              : 
      60              : // Key added to a document listing, alongside those read from the announcing
      61              : // commit: whether this device still holds the document. Part of the client API,
      62              : // documented on getCollaborativeDocuments().
      63              : static constexpr char DOCUMENT_STORED_LOCALLY[] = "storedLocally";
      64              : 
      65              : // Ceilings on what a single message may carry. Both the client API and the
      66              : // channels hand us opaque blobs, and both are held in memory before the engine
      67              : // gets a say. The point is not to guess a "correct" size but to keep one message
      68              : // from being able to allocate without bound: a CRDT update stays well under a
      69              : // megabyte even when it carries the whole state of a large document, and an
      70              : // awareness state is a cursor plus a display name.
      71              : static constexpr size_t MAX_UPDATE_SIZE {8 * 1024 * 1024};
      72              : static constexpr size_t MAX_AWARENESS_SIZE {8 * 1024};
      73              : /// A whole awareness message may carry one entry per peer, not just ours.
      74              : static constexpr size_t MAX_AWARENESS_MESSAGE_SIZE {64 * 1024};
      75              : 
      76              : // Awareness upkeep, with the cadence the yjs protocol settled on. A state is
      77              : // re-announced well before it is due to expire, so that losing one announcement
      78              : // does not make an editor blink out of the document; a peer that announces
      79              : // nothing for a whole timeout is one whose device is gone, not one who stopped
      80              : // typing, and its cursor is withdrawn.
      81              : static constexpr std::chrono::seconds AWARENESS_TIMEOUT {30};
      82              : static constexpr std::chrono::seconds AWARENESS_RENEW {AWARENESS_TIMEOUT / 2};
      83              : static constexpr std::chrono::seconds AWARENESS_SWEEP {AWARENESS_TIMEOUT / 10};
      84              : /// How many peers may hold a state in one document at once. An authorized member
      85              : /// picks its own client ids, so nothing but this stops one from filling the
      86              : /// table with ids nobody is behind.
      87              : static constexpr size_t MAX_AWARENESS_PEERS {256};
      88              : 
      89              : namespace {
      90              : 
      91              : // What a real-time frame carries: a Y-CRDT update or an awareness message. One
      92              : // byte on the wire, so a frame type from a newer daemon is skipped over rather
      93              : // than choked on.
      94              : constexpr uint8_t FRAME_UPDATE {0};
      95              : constexpr uint8_t FRAME_AWARENESS {1};
      96              : 
      97              : // A frame is: varuint payload length, one tag byte, the payload. The length is
      98              : // variable so the framing costs one byte on the frames that matter -- a
      99              : // keystroke's update is a few dozen bytes -- while still naming sizes up to the
     100              : // caps above.
     101              : void
     102            3 : appendVarUint(std::vector<uint8_t>& out, uint64_t value)
     103              : {
     104            3 :     while (value >= 0x80) {
     105            0 :         out.push_back(static_cast<uint8_t>(0x80 | (value & 0x7F)));
     106            0 :         value >>= 7;
     107              :     }
     108            3 :     out.push_back(static_cast<uint8_t>(value));
     109            3 : }
     110              : 
     111              : enum class VarUint { OK, INCOMPLETE, MALFORMED };
     112              : 
     113              : // Reads seven bits per byte, least significant group first, the high bit
     114              : // marking that another byte follows. What is being read comes from a peer, so
     115              : // an integer that never terminates has to end the parse, not the process.
     116              : VarUint
     117            3 : readVarUint(const uint8_t* data, size_t size, uint64_t& value, size_t& consumed)
     118              : {
     119            3 :     value = 0;
     120            3 :     for (size_t i = 0; i < size; ++i) {
     121            3 :         if (i >= 10)
     122            0 :             return VarUint::MALFORMED; // longer than any uint64 ever encodes to
     123            3 :         value |= static_cast<uint64_t>(data[i] & 0x7F) << (7 * i);
     124            3 :         if ((data[i] & 0x80) == 0) {
     125            3 :             consumed = i + 1;
     126            3 :             return VarUint::OK;
     127              :         }
     128              :     }
     129            0 :     return size >= 10 ? VarUint::MALFORMED : VarUint::INCOMPLETE;
     130              : }
     131              : 
     132              : /// One client's slice of an awareness message, as it travels the wire.
     133              : struct AwarenessWire
     134              : {
     135              :     uint64_t clientId {0};
     136              :     /// Bumped by its owner on every change. An entry whose clock is not greater
     137              :     /// than the one already held is ignored, which is what keeps a message that
     138              :     /// took a longer route from resurrecting a state everyone has moved past.
     139              :     uint64_t clock {0};
     140              :     /// The client's state as a JSON document, or "null" for a client that is
     141              :     /// gone. Opaque here: its shape is the editors' agreement.
     142              :     std::string state;
     143            4 :     MSGPACK_DEFINE(clientId, clock, state)
     144              : };
     145              : 
     146              : std::vector<uint8_t>
     147            2 : encodeAwareness(const std::vector<AwarenessWire>& entries)
     148              : {
     149            2 :     msgpack::sbuffer buffer;
     150            2 :     msgpack::pack(buffer, entries);
     151            2 :     const auto* data = reinterpret_cast<const uint8_t*>(buffer.data());
     152            6 :     return {data, data + buffer.size()};
     153            2 : }
     154              : 
     155              : // Cap a document name, cutting on a code point boundary so the result is still
     156              : // valid UTF-8 and can be put back into JSON.
     157              : std::string
     158           27 : truncatedName(std::string name)
     159              : {
     160           27 :     if (name.size() <= MAX_DOCUMENT_NAME_SIZE)
     161           27 :         return name;
     162            0 :     size_t cut = MAX_DOCUMENT_NAME_SIZE;
     163            0 :     while (cut > 0 && (static_cast<unsigned char>(name[cut]) & 0xC0) == 0x80)
     164            0 :         --cut;
     165            0 :     name.resize(cut);
     166            0 :     return name;
     167              : }
     168              : 
     169              : } // namespace
     170              : 
     171              : /// What one client id is currently sharing in a document.
     172              : struct AwarenessPeer
     173              : {
     174              :     uint64_t clock {0};
     175              :     /// JSON, or empty once the client withdrew its state.
     176              :     std::string state;
     177              :     std::chrono::steady_clock::time_point lastSeen;
     178              :     /// The account that announced this client id. Held so that a member cannot
     179              :     /// speak for a client id another one already claimed, and so that a state
     180              :     /// can still be attributed to a person when it reaches the clients.
     181              :     std::string owner;
     182              : };
     183              : 
     184              : struct CollaborativeEditing::Session
     185              : {
     186              :     std::string conversationId;
     187              :     std::string documentId;
     188              :     std::unique_ptr<YrsDocument> doc;
     189              :     // The last name handed to the clients. A remote rename cannot be spotted by
     190              :     // reading the repository before and after a synchronization -- the merge is
     191              :     // already done by the time we hear about it -- so this is what tells a name
     192              :     // that changed from one the clients have seen. Empty until the document is
     193              :     // opened: announcing a rename to a client that was never told the first name
     194              :     // would be a phantom event. Guarded by mutex_.
     195              :     std::optional<std::string> announcedName;
     196              :     // Whether a client currently has the document open. A closed holder keeps
     197              :     // replicating -- that is what holding is -- but its client is not told about
     198              :     // updates it is not looking at; reopening hands the converged state over
     199              :     // instead. Guarded by mutex_.
     200              :     bool open {false};
     201              :     std::unique_ptr<asio::steady_timer> checkpointTimer;
     202              :     // Set once the repository's stored updates have been replayed into this session.
     203              :     bool persistedLoaded {false};
     204              :     // Attachment ids the local clients already know about, so a synchronization
     205              :     // only announces what it actually brought. Seeded on open with what the
     206              :     // repository already holds: a client reads those itself, and re-announcing
     207              :     // them would make every editor redraw its images on every sync. Guarded by
     208              :     // mutex_.
     209              :     std::set<std::string> knownAttachments;
     210              : 
     211              :     // Local updates produced since the last checkpoint, base64-encoded. Guarded by
     212              :     // its own mutex: it is filled from a YrsDocument callback, which already holds
     213              :     // the document's lock, so it must not reach for the manager-wide mutex.
     214              :     std::mutex pendingMutex;
     215              :     std::vector<std::string> pending;
     216              : 
     217              :     // The timer is rearmed both from the thread producing the edits and from the
     218              :     // io thread retrying a failed checkpoint; asio timers are not thread-safe.
     219              :     std::mutex timerMutex;
     220              : 
     221              :     // Set when the pending batch reached its cap, so that continued typing stops
     222              :     // pushing the debounce timer further away and the checkpoint actually runs.
     223              :     std::atomic_bool checkpointDue {false};
     224              : 
     225              :     // Real-time state. Kept under its own lock: the upkeep timer walks it from
     226              :     // the io thread while the clients write to it, and neither has any business
     227              :     // waiting on the manager-wide lock to do so.
     228              :     std::mutex protocolMutex;
     229              :     // The live channels to the peer devices that also have the document open,
     230              :     // per device. Two devices opening towards each other at once can end up
     231              :     // with a channel each, which is why this holds a list: dropping one of the
     232              :     // pair would have each side keep the one the other just closed. Frames are
     233              :     // sent on every one of them and a duplicate merges as a no-op -- that is
     234              :     // what a CRDT is for.
     235              :     std::map<std::string, std::vector<std::shared_ptr<dhtnet::ChannelSocket>>> channels;
     236              :     std::map<uint64_t, AwarenessPeer> awareness;
     237              :     /// This device's own entry in the table above. Its clock is what tells peers
     238              :     /// which of two states they hold is the later one, so it only ever grows.
     239              :     uint64_t localClock {0};
     240              :     std::string localState;
     241              :     std::chrono::steady_clock::time_point localAnnounced;
     242              :     std::unique_ptr<asio::steady_timer> awarenessTimer;
     243              :     bool upkeepRunning {false};
     244              : };
     245              : 
     246          453 : CollaborativeEditing::CollaborativeEditing(const std::shared_ptr<JamiAccount>& account)
     247          453 :     : account_(account)
     248          453 :     , accountId_(account->getAccountID())
     249          906 :     , ioContext_(Manager::instance().ioContext())
     250          453 : {}
     251              : 
     252          453 : CollaborativeEditing::~CollaborativeEditing() = default;
     253              : 
     254              : uint64_t
     255           46 : CollaborativeEditing::replicaId()
     256              : {
     257              :     // Drawn at random, never derived from the device id. The client id is only
     258              :     // half of an item id: the other half is a per-replica counter that starts at
     259              :     // zero and is not persisted on its own. A replica that reuses an id after
     260              :     // producing items its peers already hold would restart that counter, and the
     261              :     // peers would silently drop the new items as ones they had already seen,
     262              :     // diverging for good.
     263              :     //
     264              :     // The daemon's replica never produces an item of its own -- it only applies
     265              :     // updates coming from the clients and from the peers -- so its id never
     266              :     // reaches any document. Drawing it costs nothing and keeps that property from
     267              :     // depending on the fact that nothing writes here today.
     268           46 :     if (clientId_ != 0)
     269            3 :         return clientId_;
     270           43 :     std::random_device rd;
     271              :     // 53 bits: the yjs ecosystem carries these ids through JSON, where integers
     272              :     // above 2^53 are no longer exact.
     273           43 :     std::uniform_int_distribution<uint64_t> dist(1, (uint64_t(1) << 53) - 1);
     274           43 :     std::mt19937_64 gen(rd());
     275           43 :     clientId_ = dist(gen);
     276           43 :     return clientId_;
     277           43 : }
     278              : 
     279              : std::string
     280          429 : CollaborativeEditing::key(const std::string& conversationId, const std::string& documentId)
     281              : {
     282          429 :     return conversationId + '/' + documentId;
     283              : }
     284              : 
     285              : std::shared_ptr<Conversation>
     286          621 : CollaborativeEditing::documentConversation(const std::string& documentId)
     287              : {
     288          621 :     auto account = account_.lock();
     289          621 :     if (!account)
     290            0 :         return nullptr;
     291          621 :     auto* cm = account->convModule(true);
     292          621 :     if (!cm)
     293            0 :         return nullptr;
     294          621 :     auto conversation = cm->getConversation(documentId);
     295          621 :     if (!conversation || conversation->mode() != ConversationMode::DOCUMENT)
     296          179 :         return nullptr;
     297          442 :     return conversation;
     298          621 : }
     299              : 
     300              : std::shared_ptr<CollaborativeEditing::Session>
     301           84 : CollaborativeEditing::findSession(const std::string& conversationId, const std::string& documentId)
     302              : {
     303           84 :     std::lock_guard<std::mutex> lk(mutex_);
     304           84 :     auto it = sessions_.find(key(conversationId, documentId));
     305          168 :     return it != sessions_.end() ? it->second : nullptr;
     306           84 : }
     307              : 
     308              : std::shared_ptr<CollaborativeEditing::Session>
     309           26 : CollaborativeEditing::findSessionByDocument(const std::string& documentId)
     310              : {
     311           26 :     std::lock_guard<std::mutex> lk(mutex_);
     312           26 :     for (const auto& [_, session] : sessions_)
     313           25 :         if (session && session->documentId == documentId)
     314           25 :             return session;
     315            1 :     return nullptr;
     316           26 : }
     317              : 
     318              : std::shared_ptr<CollaborativeEditing::Session>
     319           49 : CollaborativeEditing::ensureSession(const std::string& conversationId, const std::string& documentId)
     320              : {
     321           49 :     std::lock_guard<std::mutex> lk(mutex_);
     322           49 :     auto k = key(conversationId, documentId);
     323           49 :     if (auto it = sessions_.find(k); it != sessions_.end())
     324            6 :         return it->second;
     325              : 
     326           43 :     auto session = std::make_shared<Session>();
     327           43 :     session->conversationId = conversationId;
     328           43 :     session->documentId = documentId;
     329           43 :     session->doc = std::make_unique<YrsDocument>(replicaId());
     330           43 :     session->checkpointTimer = std::make_unique<asio::steady_timer>(*ioContext_);
     331           43 :     session->awarenessTimer = std::make_unique<asio::steady_timer>(*ioContext_);
     332           43 :     sessions_.emplace(k, session);
     333           43 :     return session;
     334           49 : }
     335              : 
     336              : std::string
     337           25 : CollaborativeEditing::createDocument(const std::string& conversationId,
     338              :                                      const std::string& name,
     339              :                                      const std::string& mimeType)
     340              : {
     341              :     // Settle the media type here rather than in each of the two places that
     342              :     // record it: an empty one in the initial commit and none at all in the
     343              :     // announcement would list the document as having no type while its
     344              :     // repository claimed one.
     345           25 :     const std::string type = mimeType.empty() ? DEFAULT_DOC_MIME_TYPE : mimeType;
     346              : 
     347           25 :     auto account = account_.lock();
     348           25 :     if (!account)
     349            0 :         return {};
     350           25 :     auto* cm = account->convModule();
     351           25 :     if (!cm)
     352            0 :         return {};
     353              :     // The document gets a swarm repository of its own, holding its content and
     354              :     // history; its id is the repository's. The creator is its first member.
     355           25 :     auto documentId = cm->startDocument(conversationId, type);
     356           25 :     if (documentId.empty()) {
     357            0 :         JAMI_ERROR("[Account {}] Unable to create a document repository in conversation {}", accountId_, conversationId);
     358            0 :         return {};
     359              :     }
     360              : 
     361              :     // The name describes the document, it is not part of its content: it lives
     362              :     // in the repository's profile, like a conversation's title, and reaches the
     363              :     // other holders through the ordinary repository synchronization.
     364           25 :     const auto stored = truncatedName(name);
     365           25 :     if (!stored.empty())
     366           75 :         cm->updateConversationInfos(documentId, {{"title", stored}}, false);
     367              : 
     368           25 :     auto session = ensureSession(conversationId, documentId);
     369              :     {
     370           25 :         std::lock_guard<std::mutex> lk(mutex_);
     371           25 :         session->persistedLoaded = true; // the repository is newborn: nothing to replay
     372           25 :         session->announcedName = stored;
     373           25 :         nameCache_[key(conversationId, documentId)] = stored;
     374           25 :         ++nameEpoch_;
     375           25 :     }
     376              :     {
     377              :         // Record it as announced right away. The commit below is asynchronous,
     378              :         // and a client that creates a document then opens it must not lose that
     379              :         // race against its own announcement.
     380           25 :         std::lock_guard<std::mutex> lk(announcedMtx_);
     381           25 :         announced_[conversationId].emplace(documentId);
     382           25 :     }
     383              :     // Announce the document in the conversation so that members discover it and
     384              :     // can replicate its repository. The announcement carries no content.
     385              :     //
     386              :     // Without it the document exists here and nowhere else: the other members
     387              :     // never learn about it, and the authorization that lets them replicate it is
     388              :     // derived from that very announcement. Report the failure rather than hand
     389              :     // back the id of a document nobody else can reach.
     390           25 :     auto announceResult = std::make_shared<std::promise<bool>>();
     391           25 :     auto announcedDone = announceResult->get_future();
     392           75 :     cm->createCommit(conversationId,
     393           50 :                      CommitMessage::collabDocCreated(documentId, stored, type),
     394              :                      true,
     395              :                      {},
     396           75 :                      [w = weak_from_this(),
     397              :                       conversationId,
     398              :                       documentId,
     399           25 :                       accountId = accountId_,
     400              :                       announceResult](bool ok, const std::string&) {
     401           24 :                          announceResult->set_value(ok);
     402           24 :                          if (ok)
     403           24 :                              return;
     404            0 :                          JAMI_ERROR("[Account {}] Document {} was not announced in conversation {}: "
     405              :                                     "the other members cannot reach it",
     406              :                                     accountId,
     407              :                                     documentId,
     408              :                                     conversationId);
     409            0 :                          if (auto sthis = w.lock()) {
     410              :                              {
     411            0 :                                  std::lock_guard<std::mutex> lk(sthis->announcedMtx_);
     412            0 :                                  if (auto it = sthis->announced_.find(conversationId); it != sthis->announced_.end())
     413            0 :                                      it->second.erase(documentId);
     414            0 :                              }
     415            0 :                              sthis->closeDocument(conversationId, documentId);
     416            0 :                              sthis->dropLocalReplica(conversationId, documentId);
     417              :                              // Undo startDocument() too: a repository nobody was
     418              :                              // ever told about would still be reloaded as held on
     419              :                              // every restart.
     420            0 :                              if (auto account = sthis->account_.lock())
     421            0 :                                  if (auto* cm = account->convModule())
     422            0 :                                      cm->removeDocumentReplica(documentId);
     423            0 :                          }
     424              :                      });
     425              :     // The commit is written on another thread; wait for it, so that a document
     426              :     // this call hands back is really there: listed in the conversation, and
     427              :     // announced to the members. Without this, creating a document then listing
     428              :     // the conversation's documents would be a race against our own commit.
     429           25 :     if (announcedDone.wait_for(std::chrono::seconds(30)) != std::future_status::ready) {
     430              :         // Never resolving would mean the conversation vanished under us and the
     431              :         // commit callback was dropped; the rollback above cannot run either, so
     432              :         // all that is left is to say the announcement never happened.
     433            1 :         JAMI_ERROR("[Account {}] Document {} announcement never completed in conversation {}",
     434              :                    accountId_,
     435              :                    documentId,
     436              :                    conversationId);
     437            1 :         return {};
     438              :     }
     439           24 :     if (!announcedDone.get())
     440            0 :         return {};
     441           24 :     return documentId;
     442           50 : }
     443              : 
     444              : bool
     445            1 : CollaborativeEditing::removeDocument(const std::string& conversationId, const std::string& documentId)
     446              : {
     447            1 :     auto account = account_.lock();
     448            1 :     if (!account)
     449            0 :         return false;
     450            1 :     auto* cm = account->convModule();
     451            1 :     if (!cm)
     452            0 :         return false;
     453              :     // Removing a document means retiring the commit that announced it, so that
     454              :     // commit has to be found first. Its id is also the only thing the removal
     455              :     // carries: the swarm ties an edition to the author of what it edits, which is
     456              :     // what keeps a member from retiring somebody else's document.
     457            1 :     std::string announcementId;
     458            1 :     for (const auto& doc : documents(conversationId)) {
     459            1 :         auto idIt = doc.find("id");
     460            1 :         if (idIt == doc.end() || idIt->second != documentId)
     461            0 :             continue;
     462            2 :         if (auto annIt = doc.find("announcement"); annIt != doc.end())
     463            1 :             announcementId = annIt->second;
     464            1 :         break;
     465            1 :     }
     466            1 :     if (announcementId.empty()) {
     467            0 :         JAMI_WARNING("[Account {}] [Document {}] Not removing: no announcement found in conversation {}",
     468              :                      accountId_,
     469              :                      documentId,
     470              :                      conversationId);
     471            0 :         return false;
     472              :     }
     473              :     // The peers apply the removal when the commit reaches them, and this device
     474              :     // does the same through addToHistory() once the commit lands locally: nothing
     475              :     // is erased here, so a commit that never happens leaves the document intact.
     476              :     // editMessage() is what checks that we authored the announcement, exactly as
     477              :     // it does for a shared file.
     478            1 :     cm->editMessage(conversationId, {}, announcementId);
     479            1 :     return true;
     480            1 : }
     481              : 
     482              : bool
     483            4 : CollaborativeEditing::removeDocumentLocally(const std::string& conversationId, const std::string& documentId)
     484              : {
     485            4 :     if (!isAnnouncedDocument(conversationId, documentId)) {
     486            0 :         JAMI_ERROR("[Account {}] [Document {}] Not removing from this device: it was not announced in "
     487              :                    "conversation {}",
     488              :                    accountId_,
     489              :                    documentId,
     490              :                    conversationId);
     491            0 :         return false;
     492              :     }
     493              : 
     494            4 :     if (auto account = account_.lock())
     495            4 :         if (auto* cm = account->convModule())
     496            4 :             cm->removeDocumentReplica(documentId);
     497            4 :     dropLocalReplica(conversationId, documentId);
     498            4 :     emitSignal<libjami::ConversationSignal::CollaborativeDocumentRemoved>(accountId_, conversationId, documentId, false);
     499            4 :     return true;
     500              : }
     501              : 
     502              : void
     503            2 : CollaborativeEditing::setName(const std::string& conversationId, const std::string& documentId, const std::string& name)
     504              : {
     505            2 :     auto account = account_.lock();
     506            2 :     if (!account)
     507            0 :         return;
     508            2 :     auto* cm = account->convModule();
     509            2 :     if (!cm)
     510            0 :         return;
     511            2 :     if (!documentConversation(documentId)) {
     512            0 :         JAMI_WARNING("[Account {}] [Document {}] Unable to rename: this device does not hold the document",
     513              :                      accountId_,
     514              :                      documentId);
     515            0 :         return;
     516              :     }
     517              :     // The name describes the document, it is not part of its content: keeping it
     518              :     // in the repository's profile rather than inside the CRDT is what lets the
     519              :     // daemon stay blind to what the document holds. It reaches the other holders
     520              :     // through the ordinary repository synchronization, and the swarm's own
     521              :     // validation is what restricts who may write it.
     522              :     //
     523              :     // The repository caps what it stores, so remember and announce what it really
     524              :     // holds, or the cache would answer a name no other member will ever see.
     525            2 :     const auto stored = truncatedName(name);
     526            6 :     cm->updateConversationInfos(documentId, {{"title", stored}}, true);
     527              :     {
     528            2 :         std::lock_guard<std::mutex> lk(mutex_);
     529            2 :         if (auto it = sessions_.find(key(conversationId, documentId)); it != sessions_.end())
     530            2 :             it->second->announcedName = stored;
     531            2 :         nameCache_[key(conversationId, documentId)] = stored;
     532            2 :         ++nameEpoch_;
     533            2 :     }
     534            2 :     emitRename(conversationId, documentId, stored);
     535            4 : }
     536              : 
     537              : std::string
     538           17 : CollaborativeEditing::documentName(const std::string& conversationId, const std::string& documentId)
     539              : {
     540              :     // Reading a name must stay cheap. Clients ask for it constantly: once per
     541              :     // document to list a conversation's documents, and once per message delegate
     542              :     // built while scrolling a conversation. Reading it from the repository's
     543              :     // profile means git lookups on the caller's thread -- the client's UI thread
     544              :     // -- so the answer is cached, and the cache is refreshed wherever the name
     545              :     // can change: in setName and on synchronization.
     546           17 :     const auto k = key(conversationId, documentId);
     547           17 :     uint64_t epoch = 0;
     548              :     {
     549           17 :         std::lock_guard<std::mutex> lk(mutex_);
     550           17 :         if (auto it = nameCache_.find(k); it != nameCache_.end())
     551            3 :             return it->second;
     552           14 :         epoch = nameEpoch_;
     553           17 :     }
     554           14 :     auto conversation = documentConversation(documentId);
     555           14 :     if (!conversation)
     556           14 :         return {}; // not held here: nothing worth remembering
     557            0 :     std::string name;
     558            0 :     auto infos = conversation->infos();
     559            0 :     if (auto it = infos.find("title"); it != infos.end())
     560            0 :         name = it->second;
     561            0 :     std::lock_guard<std::mutex> lk(mutex_);
     562              :     // Only remember it if nothing invalidated the cache while we were reading.
     563              :     // A synchronization landing during the read above would otherwise be
     564              :     // overwritten by the name we read just before it, and stay wrong for good.
     565            0 :     if (epoch == nameEpoch_)
     566            0 :         nameCache_[k] = name;
     567            0 :     return name;
     568            0 : }
     569              : 
     570              : std::vector<std::map<std::string, std::string>>
     571           98 : CollaborativeEditing::documents(const std::string& conversationId)
     572              : {
     573           98 :     auto account = account_.lock();
     574           98 :     if (!account)
     575            0 :         return {};
     576           98 :     auto* cm = account->convModule();
     577           98 :     if (!cm)
     578            0 :         return {};
     579           98 :     auto conversation = cm->getConversation(conversationId);
     580           98 :     if (!conversation)
     581           14 :         return {};
     582           84 :     auto docs = conversation->collaborativeDocuments();
     583              :     // The conversation knows which documents exist; whether this device still
     584              :     // holds one is whether its repository is here. A client has to be able to
     585              :     // tell them apart: one opens on what is already here, the other has to be
     586              :     // fetched back first.
     587          110 :     for (auto& doc : docs) {
     588           26 :         auto it = doc.find("id");
     589           78 :         doc[DOCUMENT_STORED_LOCALLY] = (it != doc.end() && documentConversation(it->second)) ? TRUE_STR : FALSE_STR;
     590              :     }
     591           84 :     return docs;
     592           98 : }
     593              : 
     594              : bool
     595          109 : CollaborativeEditing::isAnnouncedDocument(const std::string& conversationId, const std::string& documentId)
     596              : {
     597              :     // A document only exists once a COLLAB_DOC commit announced it in the
     598              :     // conversation. Without this check, a client naming arbitrary ids in an
     599              :     // open call would have us create a repository on disk for each.
     600              :     {
     601          109 :         std::lock_guard<std::mutex> lk(announcedMtx_);
     602          109 :         if (auto rmIt = removed_.find(conversationId); rmIt != removed_.end() && rmIt->second.count(documentId) != 0)
     603            0 :             return false;
     604          109 :         if (auto it = announced_.find(conversationId); it != announced_.end())
     605          109 :             return it->second.count(documentId) != 0;
     606          109 :     }
     607              :     // First question asked about this conversation: walking its whole history is
     608              :     // expensive, so do it once and let onDocumentAnnounced() keep the set fresh.
     609            0 :     std::set<std::string> ids;
     610            0 :     for (const auto& doc : documents(conversationId)) {
     611            0 :         if (auto it = doc.find("id"); it != doc.end())
     612            0 :             ids.emplace(it->second);
     613            0 :     }
     614            0 :     std::lock_guard<std::mutex> lk(announcedMtx_);
     615            0 :     auto& set = announced_[conversationId];
     616            0 :     set.merge(ids);
     617              :     // documents() already drops the retired ones, so a removal met earlier cannot
     618              :     // be undone by this merge; but a removal recorded meanwhile still wins.
     619            0 :     if (auto rmIt = removed_.find(conversationId); rmIt != removed_.end() && rmIt->second.count(documentId) != 0)
     620            0 :         return false;
     621            0 :     return set.count(documentId) != 0;
     622            0 : }
     623              : 
     624              : bool
     625          159 : CollaborativeEditing::knowsDocument(const std::string& documentId)
     626              : {
     627          159 :     std::lock_guard<std::mutex> lk(announcedMtx_);
     628          159 :     for (const auto& [_, ids] : announced_)
     629            0 :         if (ids.count(documentId) != 0)
     630            0 :             return true;
     631          159 :     return false;
     632          159 : }
     633              : 
     634              : bool
     635            0 : CollaborativeEditing::isRemovedDocument(const std::string& conversationId, const std::string& documentId)
     636              : {
     637            0 :     std::lock_guard<std::mutex> lk(announcedMtx_);
     638            0 :     auto it = removed_.find(conversationId);
     639            0 :     return it != removed_.end() && it->second.count(documentId) != 0;
     640            0 : }
     641              : 
     642              : void
     643            8 : CollaborativeEditing::dropLocalReplica(const std::string& conversationId, const std::string& documentId)
     644              : {
     645            8 :     std::shared_ptr<Session> session;
     646              :     {
     647            8 :         std::lock_guard<std::mutex> lk(mutex_);
     648            8 :         auto k = key(conversationId, documentId);
     649            8 :         if (auto it = sessions_.find(k); it != sessions_.end()) {
     650            5 :             session = it->second;
     651            5 :             sessions_.erase(it);
     652              :         }
     653            8 :         nameCache_.erase(k);
     654            8 :         ++nameEpoch_;
     655            8 :     }
     656              :     // Drop the live replica without checkpointing it first: the repository is
     657              :     // going with it, and writing to it would only race with the erase. The
     658              :     // timer is cancelled for the same reason -- it would fire on a repository
     659              :     // that no longer exists.
     660            8 :     if (session && session->checkpointTimer) {
     661            5 :         std::lock_guard<std::mutex> lk(session->timerMutex);
     662            5 :         session->checkpointTimer->cancel();
     663            5 :     }
     664            8 :     if (session)
     665            5 :         closeRealtimeChannels(session);
     666            8 : }
     667              : 
     668              : YrsDocument::Bytes
     669           24 : CollaborativeEditing::openDocument(const std::string& conversationId, const std::string& documentId)
     670              : {
     671           24 :     if (!ensureDocumentReplica(conversationId, documentId, true))
     672            0 :         return {};
     673           24 :     auto session = ensureSession(conversationId, documentId);
     674           24 :     auto conversation = documentConversation(documentId);
     675           42 :     const auto name = conversation ? conversation->infos()["title"] : documentName(conversationId, documentId);
     676              :     {
     677           24 :         std::lock_guard<std::mutex> lk(mutex_);
     678           24 :         session->open = true;
     679           24 :         if (!session->announcedName)
     680           18 :             session->announcedName = name;
     681           24 :     }
     682           24 :     loadPersistedState(session);
     683           24 :     auto state = session->doc->encodeStateAsUpdate();
     684              :     // Reach out to the other devices editing the document. What they produced
     685              :     // while nothing was open here is not asked for -- there is no handshake --
     686              :     // and does not need to be: it arrives with its producer's next checkpoint,
     687              :     // while everything from here on arrives live.
     688           24 :     connectRealtimeChannels(session);
     689           24 :     return state;
     690           24 : }
     691              : 
     692              : std::vector<std::string>
     693           46 : CollaborativeEditing::cloneSources(const std::shared_ptr<Conversation>& parent, const std::string& documentId)
     694              : {
     695           46 :     auto docs = parent->collaborativeDocuments();
     696          184 :     auto doc = std::find_if(docs.begin(), docs.end(), [&](const auto& item) { return item.at("id") == documentId; });
     697           46 :     if (doc == docs.end())
     698            0 :         return {};
     699           92 :     const auto& author = doc->at("author");
     700           46 :     std::vector<std::string> candidates;
     701           46 :     if (parent->isMember(author))
     702           46 :         candidates.push_back(author);
     703           92 :     auto members = parent->memberUris(author, {MemberRole::INVITED, MemberRole::BANNED, MemberRole::LEFT});
     704           46 :     candidates.insert(candidates.end(), members.begin(), members.end());
     705           46 :     std::set<std::string> online;
     706           92 :     for (const auto& device : parent->peersToSyncWith())
     707           92 :         online.emplace(parent->uriFromDevice(device.toString()));
     708          138 :     std::stable_partition(candidates.begin(), candidates.end(), [&](const auto& uri) { return online.count(uri) != 0; });
     709           46 :     return candidates;
     710           46 : }
     711              : 
     712              : bool
     713          105 : CollaborativeEditing::ensureDocumentReplica(const std::string& conversationId,
     714              :                                             const std::string& documentId,
     715              :                                             bool reopen)
     716              : {
     717          105 :     if (!isAnnouncedDocument(conversationId, documentId)) {
     718            0 :         JAMI_WARNING("[Account {}] Refusing unannounced document {}", accountId_, documentId);
     719            0 :         return false;
     720              :     }
     721          105 :     auto account = account_.lock();
     722          105 :     auto* cm = account ? account->convModule() : nullptr;
     723          105 :     auto parent = cm ? cm->getConversation(conversationId) : nullptr;
     724          105 :     if (!parent || parent->isRemoving() || !parent->isMember(account->getUsername())) {
     725            1 :         JAMI_DEBUG("[Account {}] Deferring document {}: parent is inactive", accountId_, documentId);
     726            1 :         return false;
     727              :     }
     728          104 :     if (documentConversation(documentId))
     729           58 :         return true;
     730           46 :     return cm->cloneDocumentFrom(conversationId, documentId, cloneSources(parent, documentId), reopen);
     731          105 : }
     732              : 
     733              : void
     734           15 : CollaborativeEditing::closeDocument(const std::string& conversationId, const std::string& documentId)
     735              : {
     736           15 :     auto session = findSession(conversationId, documentId);
     737           15 :     if (!session)
     738            0 :         return;
     739              :     {
     740           15 :         std::lock_guard<std::mutex> lk(mutex_);
     741           15 :         session->open = false;
     742           15 :     }
     743              :     // Flush pending edits, but keep the in-memory CRDT replica so that reopening
     744              :     // the document shows its current content. The session stays consistent via
     745              :     // persisted commits (replayed on load) and live updates from other members.
     746           15 :     if (session->checkpointTimer) {
     747           15 :         std::lock_guard<std::mutex> lk(session->timerMutex);
     748           15 :         session->checkpointTimer->cancel();
     749           15 :     }
     750           15 :     checkpointNow(session);
     751              :     // Withdraw this device's awareness state, which is what clears its cursor
     752              :     // for the other members. Done explicitly rather than left to expire so that
     753              :     // closing an editor is seen at once instead of a timeout later.
     754           15 :     publishAwareness(session, {});
     755              :     // The channels only exist between devices that are editing, and this one no
     756              :     // longer is. A reopen starts them over.
     757           15 :     closeRealtimeChannels(session);
     758           15 : }
     759              : 
     760              : void
     761           13 : CollaborativeEditing::applyUpdate(const std::string& conversationId,
     762              :                                   const std::string& documentId,
     763              :                                   const YrsDocument::Bytes& update)
     764              : {
     765           13 :     auto session = findSession(conversationId, documentId);
     766           13 :     if (!session)
     767            0 :         return;
     768           13 :     if (update.size() > MAX_UPDATE_SIZE) {
     769            0 :         JAMI_WARNING("[Account {}] [Document {}] Discarding a {} byte update from the client: "
     770              :                      "over the {} byte limit",
     771              :                      accountId_,
     772              :                      documentId,
     773              :                      update.size(),
     774              :                      MAX_UPDATE_SIZE);
     775            0 :         return;
     776              :     }
     777              :     // Merge before forwarding: an update the engine rejects must not be sent to
     778              :     // the members nor written to the repository.
     779           13 :     if (!session->doc->applyUpdate(update))
     780            0 :         return;
     781           13 :     onLocalUpdate(session, update);
     782           13 : }
     783              : 
     784              : YrsDocument::Bytes
     785            7 : CollaborativeEditing::documentState(const std::string& conversationId, const std::string& documentId)
     786              : {
     787            7 :     auto session = findSession(conversationId, documentId);
     788           14 :     return session ? session->doc->encodeStateAsUpdate() : YrsDocument::Bytes {};
     789            7 : }
     790              : 
     791              : namespace {
     792              : 
     793              : /// Frame a payload and write it to one channel.
     794              : void
     795            3 : writeFrame(const std::shared_ptr<dhtnet::ChannelSocket>& socket, uint8_t tag, const std::vector<uint8_t>& payload)
     796              : {
     797            3 :     std::vector<uint8_t> frame;
     798            3 :     frame.reserve(payload.size() + 11);
     799            3 :     appendVarUint(frame, payload.size());
     800            3 :     frame.push_back(tag);
     801            3 :     frame.insert(frame.end(), payload.begin(), payload.end());
     802            3 :     std::error_code ec;
     803            3 :     socket->write(frame.data(), frame.size(), ec);
     804            3 :     if (ec)
     805            0 :         JAMI_WARNING("Unable to send a {} byte collaborative frame: {}", frame.size(), ec.message());
     806            3 : }
     807              : 
     808              : } // namespace
     809              : 
     810              : bool
     811           20 : CollaborativeEditing::acceptsRealtimeChannel(const std::string& documentId,
     812              :                                              const std::string& peer,
     813              :                                              const std::string& deviceId)
     814              : {
     815              :     // Only for a document being edited here. A closed holder replicates through
     816              :     // the swarm and has no use for live frames; a device that never held the
     817              :     // document has nothing to accept them into; and answering for ids nobody
     818              :     // opened would let a peer probe what this device knows.
     819           20 :     auto session = findSessionByDocument(documentId);
     820           20 :     if (!session)
     821            1 :         return false;
     822              :     {
     823           19 :         std::lock_guard<std::mutex> lk(mutex_);
     824           19 :         if (!session->open)
     825           16 :             return false;
     826           19 :     }
     827              :     // Membership per the document's own replica, never the parent
     828              :     // conversation's. Invited counts as in: a document member is only ever
     829              :     // invited by a holder having just vouched for it while serving its clone,
     830              :     // and its join follows by itself -- there is no pending-invitation state to
     831              :     // wrongly admit. A joiner whose commits have not reached us at all is still
     832              :     // refused, and reached out to the moment they are merged.
     833            3 :     auto conversation = documentConversation(documentId);
     834            3 :     if (!conversation)
     835            0 :         return false; // the clone is still in flight: nothing to check against
     836            3 :     return conversation->isPeerAuthorized(peer, deviceId, true);
     837           20 : }
     838              : 
     839              : void
     840            6 : CollaborativeEditing::onRealtimeChannel(const std::string& documentId,
     841              :                                         const std::string& peer,
     842              :                                         const std::string& deviceId,
     843              :                                         std::shared_ptr<dhtnet::ChannelSocket> socket)
     844              : {
     845            6 :     auto session = findSessionByDocument(documentId);
     846            6 :     bool open = false;
     847            6 :     if (session) {
     848            6 :         std::lock_guard<std::mutex> lk(mutex_);
     849            6 :         open = session->open;
     850            6 :     }
     851            6 :     if (!open) {
     852              :         // The document was closed while the channel was being set up.
     853            0 :         socket->shutdown();
     854            0 :         return;
     855              :     }
     856              :     {
     857            6 :         std::lock_guard<std::mutex> lk(session->protocolMutex);
     858            6 :         session->channels[deviceId].push_back(socket);
     859            6 :     }
     860              : 
     861              :     // The parsing state of this one channel. Only its own receive callback
     862              :     // touches it, and those are delivered in order, so it needs no lock.
     863            6 :     auto buffer = std::make_shared<std::vector<uint8_t>>();
     864            6 :     std::weak_ptr<CollaborativeEditing> wthis = weak_from_this();
     865            6 :     std::weak_ptr<Session> wsession = session;
     866            6 :     std::weak_ptr<dhtnet::ChannelSocket> wsocket = socket;
     867            6 :     socket->setOnRecv([wthis, wsession, wsocket, peer, buffer](const uint8_t* data, size_t size) {
     868            3 :         auto sthis = wthis.lock();
     869            3 :         auto session = wsession.lock();
     870            3 :         if (!sthis || !session)
     871            0 :             return size;
     872            3 :         buffer->insert(buffer->end(), data, data + size);
     873            3 :         size_t pos = 0;
     874            6 :         while (pos < buffer->size()) {
     875            3 :             uint64_t length = 0;
     876            3 :             size_t consumed = 0;
     877            3 :             const auto res = readVarUint(buffer->data() + pos, buffer->size() - pos, length, consumed);
     878            3 :             if (res == VarUint::INCOMPLETE)
     879            0 :                 break;
     880              :             // The length is judged before anything is held against it: a peer
     881              :             // must not have us buffer megabytes of a frame that could only be
     882              :             // refused once complete. Nothing recovers a framing violation --
     883              :             // there is no way back into sync with a stream that lies about its
     884              :             // lengths -- so the channel goes down with it.
     885            3 :             if (res == VarUint::MALFORMED || length > MAX_UPDATE_SIZE) {
     886            0 :                 buffer->clear();
     887            0 :                 if (auto socket = wsocket.lock())
     888            0 :                     socket->shutdown();
     889            0 :                 return size;
     890              :             }
     891            3 :             if (buffer->size() - pos - consumed < 1 + length)
     892            0 :                 break; // the rest of the frame is still in flight
     893            3 :             const uint8_t tag = (*buffer)[pos + consumed];
     894            3 :             sthis->onFrame(session, peer, tag, buffer->data() + pos + consumed + 1, length);
     895            3 :             pos += consumed + 1 + length;
     896              :         }
     897            3 :         buffer->erase(buffer->begin(), buffer->begin() + pos);
     898            3 :         return size;
     899            3 :     });
     900            6 :     socket->onShutdown([wsession, wsocket, deviceId](const std::error_code& /*ec*/) {
     901            6 :         auto session = wsession.lock();
     902            6 :         if (!session)
     903            0 :             return;
     904            6 :         auto socket = wsocket.lock();
     905            6 :         std::lock_guard<std::mutex> lk(session->protocolMutex);
     906            6 :         auto it = session->channels.find(deviceId);
     907            6 :         if (it == session->channels.end())
     908            1 :             return;
     909            5 :         auto& list = it->second;
     910           10 :         list.erase(std::remove_if(list.begin(), list.end(), [&](const auto& s) { return !socket || s == socket; }),
     911            5 :                    list.end());
     912            5 :         if (list.empty())
     913            5 :             session->channels.erase(it);
     914            8 :     });
     915              : 
     916              :     // Tell the newcomer at once who this device is in the document: our
     917              :     // awareness state only re-announces itself at the renewal cadence, and an
     918              :     // editor joining a session should not stare at an empty document for
     919              :     // fifteen seconds before the cursors appear.
     920            6 :     std::vector<uint8_t> hello;
     921              :     {
     922            6 :         std::lock_guard<std::mutex> lk(session->protocolMutex);
     923            6 :         if (!session->localState.empty())
     924            0 :             hello = encodeAwareness({{clientId_, session->localClock, session->localState}});
     925            6 :     }
     926            6 :     if (!hello.empty())
     927            0 :         writeFrame(socket, FRAME_AWARENESS, hello);
     928            6 : }
     929              : 
     930              : void
     931           57 : CollaborativeEditing::connectRealtimeChannels(const std::shared_ptr<Session>& session)
     932              : {
     933           57 :     auto account = account_.lock();
     934           57 :     if (!account)
     935            0 :         return;
     936              :     {
     937           57 :         std::lock_guard<std::mutex> lk(mutex_);
     938           57 :         if (!session->open)
     939           14 :             return;
     940           57 :     }
     941           43 :     auto conversation = documentConversation(session->documentId);
     942           43 :     if (!conversation)
     943           15 :         return; // the clone is still in flight: its completion comes back here
     944              :     // Every device the repository lists is asked, not just the ones the
     945              :     // document's swarm is connected to right now: the channel dials through the
     946              :     // connection manager, which reaches a device the DRT has not settled on
     947              :     // yet. Most are declined -- the other end only accepts while it has the
     948              :     // document open -- and the ones that matter are the editors.
     949           28 :     const auto ownDevice = std::string(account->currentDeviceId());
     950           79 :     for (const auto& [member, devices] : conversation->memberDevices()) {
     951          102 :         for (const auto& device : devices) {
     952           51 :             const auto deviceId = device.toString();
     953           51 :             if (deviceId == ownDevice)
     954           27 :                 continue;
     955              :             {
     956           24 :                 std::lock_guard<std::mutex> lk(session->protocolMutex);
     957           24 :                 if (session->channels.count(deviceId) != 0)
     958            2 :                     continue; // already talking to it
     959           24 :             }
     960              :             // The socket is handled where the accepting side's is, in
     961              :             // YdocChannelHandler::onReady; a refusal needs nothing done.
     962           22 :             account->connectYdocDevice(device, session->documentId);
     963           51 :         }
     964           28 :     }
     965           72 : }
     966              : 
     967              : void
     968           20 : CollaborativeEditing::closeRealtimeChannels(const std::shared_ptr<Session>& session)
     969              : {
     970           20 :     decltype(session->channels) channels;
     971              :     {
     972           20 :         std::lock_guard<std::mutex> lk(session->protocolMutex);
     973           20 :         channels = std::move(session->channels);
     974           20 :         session->channels.clear();
     975           20 :     }
     976              :     // Outside the lock: shutting a socket down runs its shutdown handler, which
     977              :     // takes the same lock to unregister -- and finds nothing left to.
     978           21 :     for (auto& [_, list] : channels)
     979            2 :         for (auto& socket : list)
     980            1 :             socket->shutdown();
     981           20 : }
     982              : 
     983              : void
     984           15 : CollaborativeEditing::broadcastFrame(const std::shared_ptr<Session>& session,
     985              :                                      uint8_t tag,
     986              :                                      const std::vector<uint8_t>& payload)
     987              : {
     988           15 :     if (payload.empty())
     989            0 :         return;
     990              :     // Written outside the lock: a send can stall on a congested peer, and the
     991              :     // receive path needs the lock to route what the others are saying.
     992           15 :     std::vector<std::shared_ptr<dhtnet::ChannelSocket>> sockets;
     993              :     {
     994           15 :         std::lock_guard<std::mutex> lk(session->protocolMutex);
     995           18 :         for (const auto& [_, list] : session->channels)
     996            3 :             sockets.insert(sockets.end(), list.begin(), list.end());
     997           15 :     }
     998           18 :     for (const auto& socket : sockets)
     999            3 :         writeFrame(socket, tag, payload);
    1000           15 : }
    1001              : 
    1002              : void
    1003            3 : CollaborativeEditing::onFrame(
    1004              :     const std::shared_ptr<Session>& session, const std::string& from, uint8_t tag, const uint8_t* payload, size_t size)
    1005              : {
    1006            3 :     switch (tag) {
    1007            1 :     case FRAME_UPDATE:
    1008            1 :         onRemoteUpdate(session, YrsDocument::Bytes(payload, payload + size));
    1009            1 :         break;
    1010            2 :     case FRAME_AWARENESS:
    1011            2 :         onAwarenessPayload(session, from, payload, size);
    1012            2 :         break;
    1013            0 :     default:
    1014              :         // A frame type from a newer daemon. Skipping it -- its length is known
    1015              :         // -- is what lets one be added without every peer upgrading first.
    1016            0 :         break;
    1017              :     }
    1018            3 : }
    1019              : 
    1020              : void
    1021            1 : CollaborativeEditing::onRemoteUpdate(const std::shared_ptr<Session>& session, const YrsDocument::Bytes& update)
    1022              : {
    1023            1 :     if (update.empty())
    1024            0 :         return;
    1025              :     // Cleared before the update rather than read after it alone, so that what
    1026              :     // this update brought is not confused with what an earlier one did.
    1027            1 :     session->doc->takeChanged();
    1028            1 :     if (!session->doc->applyUpdate(update))
    1029            0 :         return; // malformed: don't hand it to the clients
    1030              :     // Nothing when the update taught the replica nothing: a frame that raced a
    1031              :     // checkpoint fetch carries what the replay already merged, and forwarding
    1032              :     // it would light an "unread" badge on a document nobody touched.
    1033            1 :     if (!session->doc->takeChanged())
    1034            0 :         return;
    1035              :     // Channels only exist while the document is open here, but a frame can slip
    1036              :     // in between the close and the sockets going down; reopening hands the
    1037              :     // merged state over instead.
    1038              :     {
    1039            1 :         std::lock_guard<std::mutex> lk(mutex_);
    1040            1 :         if (!session->open)
    1041            0 :             return;
    1042            1 :     }
    1043              :     // Not persisted here: the device that produced it checkpoints it into its own
    1044              :     // repository and it reaches ours through synchronization. Storing it again
    1045              :     // would keep one copy per member of every single edit.
    1046            1 :     emitUpdate(session->conversationId, session->documentId, update);
    1047              : }
    1048              : 
    1049              : void
    1050            2 : CollaborativeEditing::onAwarenessPayload(const std::shared_ptr<Session>& session,
    1051              :                                          const std::string& from,
    1052              :                                          const uint8_t* data,
    1053              :                                          size_t size)
    1054              : {
    1055            2 :     if (size > MAX_AWARENESS_MESSAGE_SIZE)
    1056            0 :         return;
    1057            2 :     std::vector<AwarenessWire> entries;
    1058              :     try {
    1059            2 :         msgpack::object_handle oh = msgpack::unpack(reinterpret_cast<const char*>(data), size);
    1060            2 :         oh.get().convert(entries);
    1061            2 :     } catch (const std::exception&) {
    1062            0 :         return; // a peer sent something that is not an awareness message
    1063            0 :     }
    1064              : 
    1065              :     // What the local clients have to be told, gathered while the table is locked
    1066              :     // and emitted once it is not: a signal handler is entitled to call back into
    1067              :     // this manager.
    1068            2 :     std::vector<std::pair<uint64_t, std::string>> changed;
    1069            2 :     std::vector<uint64_t> left;
    1070            2 :     bool contested = false;
    1071            2 :     const auto now = std::chrono::steady_clock::now();
    1072              :     {
    1073            2 :         std::lock_guard<std::mutex> lk(session->protocolMutex);
    1074            4 :         for (const auto& entry : entries) {
    1075            2 :             if (entry.state.size() > MAX_AWARENESS_SIZE)
    1076            0 :                 continue; // a presence state, not a payload
    1077            2 :             if (entry.clientId == clientId_) {
    1078              :                 // Someone is speaking for this device. Nothing legitimate does
    1079              :                 // that, and the protocol's own answer -- outrun it so that peers
    1080              :                 // keep our real state -- is also the right one here.
    1081            0 :                 contested = true;
    1082            0 :                 continue;
    1083              :             }
    1084            2 :             auto it = session->awareness.find(entry.clientId);
    1085            2 :             if (it != session->awareness.end()) {
    1086            1 :                 if (it->second.owner != from)
    1087            0 :                     continue; // a member may not speak for another's client id
    1088              :                 // Strictly greater: two states with the same clock are the same
    1089              :                 // state having taken two routes, and re-emitting one of them
    1090              :                 // would make a cursor jump back to where it already was.
    1091            1 :                 if (entry.clock <= it->second.clock)
    1092            0 :                     continue;
    1093              :             } else {
    1094            1 :                 if (session->awareness.size() >= MAX_AWARENESS_PEERS)
    1095            0 :                     continue;
    1096            1 :                 if (entry.state.empty() || entry.state == "null")
    1097            0 :                     continue; // a client that is gone and was never here
    1098              :             }
    1099            2 :             const bool gone = entry.state.empty() || entry.state == "null";
    1100            2 :             if (gone) {
    1101            1 :                 session->awareness.erase(entry.clientId);
    1102            1 :                 left.push_back(entry.clientId);
    1103              :             } else {
    1104            1 :                 auto& peer = session->awareness[entry.clientId];
    1105            1 :                 peer.clock = entry.clock;
    1106            1 :                 peer.state = entry.state;
    1107            1 :                 peer.lastSeen = now;
    1108            1 :                 peer.owner = from;
    1109            1 :                 changed.emplace_back(entry.clientId, entry.state);
    1110              :             }
    1111              :         }
    1112            2 :     }
    1113              : 
    1114            3 :     for (const auto& [clientId, state] : changed)
    1115            2 :         emitSignal<libjami::ConversationSignal::CollaborativeAwarenessChanged>(accountId_,
    1116            1 :                                                                                session->conversationId,
    1117            1 :                                                                                session->documentId,
    1118              :                                                                                from,
    1119              :                                                                                clientId,
    1120              :                                                                                state);
    1121            3 :     for (auto clientId : left)
    1122            2 :         emitSignal<libjami::ConversationSignal::CollaborativeParticipantLeft>(accountId_,
    1123            1 :                                                                               session->conversationId,
    1124            1 :                                                                               session->documentId,
    1125              :                                                                               from,
    1126              :                                                                               clientId);
    1127            2 :     if (contested) {
    1128            0 :         JAMI_WARNING("[Account {}] [Document {}] {} announced this device's client id; re-announcing",
    1129              :                      accountId_,
    1130              :                      session->documentId,
    1131              :                      from);
    1132            0 :         std::string state;
    1133              :         {
    1134            0 :             std::lock_guard<std::mutex> lk(session->protocolMutex);
    1135            0 :             state = session->localState;
    1136            0 :         }
    1137            0 :         publishAwareness(session, state);
    1138            0 :     }
    1139            2 :     scheduleAwarenessUpkeep(session);
    1140            2 : }
    1141              : 
    1142              : void
    1143            1 : CollaborativeEditing::setAwareness(const std::string& conversationId,
    1144              :                                    const std::string& documentId,
    1145              :                                    const std::string& state)
    1146              : {
    1147            1 :     if (state.size() > MAX_AWARENESS_SIZE) {
    1148            0 :         JAMI_WARNING("[Account {}] [Document {}] Refusing to broadcast an oversized awareness state",
    1149              :                      accountId_,
    1150              :                      documentId);
    1151            0 :         return;
    1152              :     }
    1153              :     // Only for a document this device has open: an awareness state is about
    1154              :     // where its editor is, and there is no editor before that.
    1155            1 :     if (auto session = findSession(conversationId, documentId))
    1156            1 :         publishAwareness(session, state);
    1157              : }
    1158              : 
    1159              : void
    1160           16 : CollaborativeEditing::publishAwareness(const std::shared_ptr<Session>& session, const std::string& state)
    1161              : {
    1162           16 :     AwarenessWire entry;
    1163              :     {
    1164           16 :         std::lock_guard<std::mutex> lk(session->protocolMutex);
    1165              :         // Withdrawing a state that was never shared would tell the members about
    1166              :         // an editor they were never told about in the first place.
    1167           16 :         if (state.empty() && session->localState.empty())
    1168           14 :             return;
    1169            2 :         entry.clientId = clientId_;
    1170            2 :         entry.clock = ++session->localClock;
    1171              :         // "null" is how a client that is no longer there is spelled.
    1172            3 :         entry.state = state.empty() ? "null" : state;
    1173            2 :         session->localState = state;
    1174            2 :         session->localAnnounced = std::chrono::steady_clock::now();
    1175           16 :     }
    1176            6 :     broadcastFrame(session, FRAME_AWARENESS, encodeAwareness({entry}));
    1177            2 :     scheduleAwarenessUpkeep(session);
    1178           18 : }
    1179              : 
    1180              : void
    1181            4 : CollaborativeEditing::scheduleAwarenessUpkeep(const std::shared_ptr<Session>& session)
    1182              : {
    1183            4 :     if (!session->awarenessTimer)
    1184            0 :         return;
    1185              :     {
    1186            4 :         std::lock_guard<std::mutex> lk(session->protocolMutex);
    1187              :         // One timer at a time, and none at all while nobody is editing: a
    1188              :         // document nobody has open must not keep waking the process up.
    1189            4 :         if (session->upkeepRunning)
    1190            2 :             return;
    1191            2 :         if (session->awareness.empty() && session->localState.empty())
    1192            0 :             return;
    1193            2 :         session->upkeepRunning = true;
    1194            2 :         session->awarenessTimer->expires_after(AWARENESS_SWEEP);
    1195            4 :     }
    1196            2 :     std::weak_ptr<CollaborativeEditing> wthis = weak_from_this();
    1197            2 :     std::weak_ptr<Session> wsession = session;
    1198            2 :     session->awarenessTimer->async_wait([wthis, wsession](const asio::error_code& ec) {
    1199            2 :         auto sthis = wthis.lock();
    1200            2 :         auto session = wsession.lock();
    1201            2 :         if (!sthis || !session)
    1202            2 :             return;
    1203              :         {
    1204            0 :             std::lock_guard<std::mutex> lk(session->protocolMutex);
    1205            0 :             session->upkeepRunning = false;
    1206            0 :         }
    1207            0 :         if (!ec)
    1208            0 :             sthis->awarenessUpkeep(session);
    1209            4 :     });
    1210            2 : }
    1211              : 
    1212              : void
    1213            0 : CollaborativeEditing::awarenessUpkeep(const std::shared_ptr<Session>& session)
    1214              : {
    1215            0 :     const auto now = std::chrono::steady_clock::now();
    1216            0 :     std::vector<std::pair<uint64_t, std::string>> expired;
    1217            0 :     std::string renew;
    1218              :     {
    1219            0 :         std::lock_guard<std::mutex> lk(session->protocolMutex);
    1220            0 :         for (auto it = session->awareness.begin(); it != session->awareness.end();) {
    1221            0 :             if (now - it->second.lastSeen >= AWARENESS_TIMEOUT) {
    1222            0 :                 expired.emplace_back(it->first, it->second.owner);
    1223            0 :                 it = session->awareness.erase(it);
    1224              :             } else {
    1225            0 :                 ++it;
    1226              :             }
    1227              :         }
    1228              :         // Re-announced well before it would expire elsewhere, so that a single
    1229              :         // lost message does not make this device blink out of the document.
    1230            0 :         if (!session->localState.empty() && now - session->localAnnounced >= AWARENESS_RENEW)
    1231            0 :             renew = session->localState;
    1232            0 :     }
    1233            0 :     for (const auto& [clientId, owner] : expired)
    1234            0 :         emitSignal<libjami::ConversationSignal::CollaborativeParticipantLeft>(accountId_,
    1235            0 :                                                                               session->conversationId,
    1236            0 :                                                                               session->documentId,
    1237              :                                                                               owner,
    1238              :                                                                               clientId);
    1239            0 :     if (!renew.empty())
    1240            0 :         publishAwareness(session, renew); // rearms the timer on its way out
    1241              :     else
    1242            0 :         scheduleAwarenessUpkeep(session);
    1243            0 : }
    1244              : 
    1245              : void
    1246           13 : CollaborativeEditing::onLocalUpdate(const std::shared_ptr<Session>& session, const YrsDocument::Bytes& update)
    1247              : {
    1248              :     // Real-time path: hand the incremental update to the devices editing along.
    1249           13 :     broadcastFrame(session, FRAME_UPDATE, update);
    1250              :     // Durable path: accumulate the update for the next checkpoint.
    1251           13 :     queueUpdate(session, update);
    1252           13 : }
    1253              : 
    1254              : void
    1255           37 : CollaborativeEditing::replayStoredUpdates(const std::shared_ptr<Session>& session)
    1256              : {
    1257           37 :     auto conversation = documentConversation(session->documentId);
    1258           37 :     if (!conversation)
    1259            0 :         return;
    1260              :     // The updates come from peers' commits, so their content is not ours to
    1261              :     // trust: a malformed one must cost that one update, not the whole replay.
    1262           51 :     for (const auto& encoded : conversation->documentUpdates()) {
    1263              :         try {
    1264           14 :             session->doc->applyUpdate(base64::decode(encoded));
    1265            0 :         } catch (const std::exception& e) {
    1266            0 :             JAMI_WARNING("[Account {}] [Document {}] Skipping unreadable stored update: {}",
    1267              :                          accountId_,
    1268              :                          session->documentId,
    1269              :                          e.what());
    1270            0 :         }
    1271           37 :     }
    1272           37 : }
    1273              : 
    1274              : void
    1275           24 : CollaborativeEditing::loadPersistedState(const std::shared_ptr<Session>& session)
    1276              : {
    1277           24 :     if (!documentConversation(session->documentId))
    1278           20 :         return; // The pending clone will replay its state when it completes.
    1279              :     {
    1280            9 :         std::lock_guard<std::mutex> lk(mutex_);
    1281            9 :         if (session->persistedLoaded)
    1282            5 :             return;
    1283            9 :     }
    1284              :     // Replay the updates stored in the document's repository. Nothing is
    1285              :     // signalled: the caller encodes the converged state and hands it to the
    1286              :     // client that asked to open the document.
    1287            4 :     replayStoredUpdates(session);
    1288              :     // What is already stored is not news: the client resolves the attachments
    1289              :     // of the state it is being handed. Only what arrives afterwards is signalled.
    1290              :     // The asymmetry is deliberate. For a document being opened the client pulls,
    1291              :     // asking for each reference it meets while rendering; announcing every stored
    1292              :     // attachment here would push bytes nobody has asked for yet, on a document the
    1293              :     // user may never scroll through. The signal exists for the opposite case: an
    1294              :     // attachment landing in an already open document, which the client has no
    1295              :     // reason to look for.
    1296            4 :     std::vector<std::string> ids;
    1297            4 :     if (auto conversation = documentConversation(session->documentId))
    1298            4 :         ids = conversation->documentAttachmentIds();
    1299              :     // Only now: a session flagged as loaded is never replayed again, so flagging
    1300              :     // it before the replay would freeze a partially rebuilt document.
    1301            4 :     std::lock_guard<std::mutex> lk(mutex_);
    1302            4 :     session->persistedLoaded = true;
    1303            4 :     for (auto& id : ids)
    1304            0 :         session->knownAttachments.insert(std::move(id));
    1305            4 : }
    1306              : 
    1307              : void
    1308           13 : CollaborativeEditing::queueUpdate(const std::shared_ptr<Session>& session, const YrsDocument::Bytes& update)
    1309              : {
    1310           13 :     const bool held = documentConversation(session->documentId) != nullptr;
    1311           13 :     bool capReached = false;
    1312              :     {
    1313           13 :         std::lock_guard<std::mutex> lk(session->pendingMutex);
    1314              :         // Without a repository nothing will ever drain this: keep the last batch
    1315              :         // so a repository appearing later still saves recent work, and drop the
    1316              :         // rest rather than growing without bound.
    1317           13 :         if (!held && session->pending.size() >= PENDING_HARD_CAP)
    1318            0 :             session->pending.erase(session->pending.begin(), session->pending.begin() + CHECKPOINT_MAX_PENDING);
    1319           13 :         session->pending.emplace_back(base64::encode(update));
    1320           13 :         capReached = session->pending.size() >= CHECKPOINT_MAX_PENDING;
    1321           13 :     }
    1322              :     // Checkpointing reads the document back, so it must never run inline on the
    1323              :     // path that just wrote to it: scheduleCheckpoint() only arms a timer.
    1324           13 :     if (capReached)
    1325            0 :         session->checkpointDue = true;
    1326           13 :     scheduleCheckpoint(session, capReached ? std::chrono::seconds(0) : CHECKPOINT_IDLE);
    1327           13 : }
    1328              : 
    1329              : void
    1330           13 : CollaborativeEditing::scheduleCheckpoint(const std::shared_ptr<Session>& session, std::chrono::seconds delay)
    1331              : {
    1332           13 :     if (!session->checkpointTimer)
    1333            0 :         return;
    1334              :     // Once the batch has reached its cap, stop letting new edits push the
    1335              :     // deadline further away: sustained typing would otherwise never checkpoint.
    1336           13 :     if (delay.count() > 0 && session->checkpointDue)
    1337            0 :         return;
    1338           13 :     std::weak_ptr<CollaborativeEditing> wthis = weak_from_this();
    1339           13 :     std::weak_ptr<Session> wsession = session;
    1340           13 :     std::lock_guard<std::mutex> lk(session->timerMutex);
    1341           13 :     session->checkpointTimer->expires_after(delay);
    1342           13 :     session->checkpointTimer->async_wait([wthis, wsession](const asio::error_code& ec) {
    1343           13 :         if (ec) // cancelled by a newer edit (debounce) or by shutdown
    1344            8 :             return;
    1345            5 :         auto sthis = wthis.lock();
    1346            5 :         auto session = wsession.lock();
    1347            5 :         if (sthis && session)
    1348            5 :             sthis->checkpointNow(session);
    1349            5 :     });
    1350           13 : }
    1351              : 
    1352              : void
    1353           58 : CollaborativeEditing::checkpointNow(const std::shared_ptr<Session>& session)
    1354              : {
    1355           58 :     session->checkpointDue = false;
    1356           58 :     auto account = account_.lock();
    1357           58 :     if (!account)
    1358            0 :         return;
    1359           58 :     auto* cm = account->convModule();
    1360           58 :     if (!cm)
    1361            0 :         return;
    1362              :     // Before draining anything: a drained batch with nowhere to go is lost.
    1363              :     // Keeping the batch queued means a replica appearing later -- the document
    1364              :     // being opened, which is what clones it -- still saves recent work.
    1365           58 :     if (!documentConversation(session->documentId))
    1366            4 :         return;
    1367           54 :     std::vector<std::string> batch;
    1368              :     {
    1369           54 :         std::lock_guard<std::mutex> lk(session->pendingMutex);
    1370           54 :         batch.swap(session->pending);
    1371           54 :     }
    1372           54 :     if (batch.empty())
    1373           41 :         return;
    1374              : 
    1375              :     // The checkpoint is a commit in the document's own swarm: committing it is
    1376              :     // also what announces it, so the other holders fetch it through the same
    1377              :     // pipeline that moves conversation messages. Nothing else needs sending.
    1378           39 :     cm->createCommit(session->documentId,
    1379           26 :                      CommitMessage::checkpoint(batch),
    1380              :                      true,
    1381              :                      {},
    1382           26 :                      [w = weak_from_this(), wsession = std::weak_ptr<Session>(session), batch](bool ok,
    1383              :                                                                                                const std::string&) {
    1384           13 :                          if (ok)
    1385           13 :                              return;
    1386              :                          // Keep the updates queued so the next checkpoint retries
    1387              :                          // them rather than silently losing the edits they carry,
    1388              :                          // and make sure a retry is actually scheduled even if the
    1389              :                          // user has stopped typing.
    1390            0 :                          auto sthis = w.lock();
    1391            0 :                          auto session = wsession.lock();
    1392            0 :                          if (!sthis || !session)
    1393            0 :                              return;
    1394              :                          {
    1395            0 :                              std::lock_guard<std::mutex> lk(session->pendingMutex);
    1396            0 :                              session->pending.insert(session->pending.begin(), batch.begin(), batch.end());
    1397            0 :                          }
    1398            0 :                          sthis->scheduleCheckpoint(session, CHECKPOINT_IDLE);
    1399            0 :                      });
    1400           99 : }
    1401              : 
    1402              : std::vector<std::map<std::string, std::string>>
    1403          105 : CollaborativeEditing::history(const std::string& /*conversationId*/, const std::string& documentId, size_t max)
    1404              : {
    1405          105 :     auto conversation = documentConversation(documentId);
    1406          210 :     return conversation ? conversation->documentHistory(max) : std::vector<std::map<std::string, std::string>> {};
    1407          105 : }
    1408              : 
    1409              : YrsDocument::Bytes
    1410            3 : CollaborativeEditing::documentStateAt(const std::string& /*conversationId*/,
    1411              :                                       const std::string& documentId,
    1412              :                                       const std::string& commitId)
    1413              : {
    1414            3 :     auto conversation = documentConversation(documentId);
    1415            3 :     if (!conversation)
    1416            0 :         return {};
    1417              : 
    1418              :     // Nothing at all when the checkpoint is unknown, which is what the public
    1419              :     // contract promises. It has to be told apart from a checkpoint that exists
    1420              :     // and holds nothing: the two would otherwise be the same answer, and a
    1421              :     // client restoring an early, legitimately empty version could not tell
    1422              :     // whether it was allowed to.
    1423            3 :     const auto stored = conversation->documentUpdatesAt(commitId);
    1424            3 :     if (!stored)
    1425            0 :         return {};
    1426              : 
    1427              :     // Replay into a throwaway replica: the live document must not be touched.
    1428              :     // What the client does with that state -- show it, restore it, diff it -- is
    1429              :     // its own business, and depends on a document type the daemon ignores.
    1430            3 :     YrsDocument snapshot {replicaId()};
    1431            7 :     for (const auto& encoded : *stored) {
    1432              :         try {
    1433            4 :             snapshot.applyUpdate(base64::decode(encoded));
    1434            0 :         } catch (const std::exception& e) {
    1435            0 :             JAMI_WARNING("[Account {}] [Document {}] Skipping unreadable stored update: {}",
    1436              :                          accountId_,
    1437              :                          documentId,
    1438              :                          e.what());
    1439            0 :         }
    1440              :     }
    1441            3 :     return snapshot.encodeStateAsUpdate();
    1442            3 : }
    1443              : 
    1444              : void
    1445           81 : CollaborativeEditing::onDocumentAnnounced(const std::string& conversationId, const std::string& documentId)
    1446              : {
    1447              :     {
    1448           81 :         std::lock_guard<std::mutex> lk(announcedMtx_);
    1449           81 :         if (auto it = removed_.find(conversationId); it != removed_.end() && it->second.count(documentId) != 0)
    1450            0 :             return;
    1451           81 :         announced_[conversationId].emplace(documentId);
    1452           81 :         if (!replicating_.emplace(key(conversationId, documentId)).second)
    1453            0 :             return;
    1454           81 :     }
    1455              :     // The caller can hold the parent conversation's locks. Fetch only after
    1456              :     // leaving that path, without pretending a client has opened the document.
    1457           81 :     dht::ThreadPool::io().run([w = weak_from_this(), conversationId, documentId] {
    1458           81 :         if (auto sthis = w.lock()) {
    1459           81 :             sthis->ensureDocumentReplica(conversationId, documentId, false);
    1460              :             {
    1461           81 :                 std::lock_guard<std::mutex> lk(sthis->announcedMtx_);
    1462           81 :                 sthis->replicating_.erase(key(conversationId, documentId));
    1463           81 :             }
    1464              :             // A clone in progress reports when it lands; otherwise report now.
    1465           81 :             auto account = sthis->account_.lock();
    1466           81 :             auto* cm = account ? account->convModule() : nullptr;
    1467           81 :             if (cm && !cm->isCloningDocument(documentId)) {
    1468           78 :                 auto stored = sthis->documentConversation(documentId) != nullptr;
    1469           78 :                 emitSignal<libjami::ConversationSignal::CollaborativeDocumentReplicated>(sthis->accountId_,
    1470           78 :                                                                                          conversationId,
    1471           78 :                                                                                          documentId,
    1472              :                                                                                          stored);
    1473              :             }
    1474          162 :         }
    1475           81 :     });
    1476              : }
    1477              : 
    1478              : void
    1479         1003 : CollaborativeEditing::syncDocuments(const std::string& conversationId)
    1480              : {
    1481         1003 :     auto account = account_.lock();
    1482         1003 :     if (!account)
    1483            0 :         return;
    1484         1003 :     auto* cm = account->convModule();
    1485         1003 :     auto parent = cm ? cm->getConversation(conversationId) : nullptr;
    1486         2005 :     if (!parent || parent->mode() == ConversationMode::DOCUMENT || parent->isRemoving()
    1487         2005 :         || !parent->isMember(account->getUsername()))
    1488           10 :         return;
    1489          999 :     for (const auto& document : parent->collaborativeDocuments())
    1490         1005 :         onDocumentAnnounced(conversationId, document.at("id"));
    1491         1013 : }
    1492              : 
    1493              : void
    1494            4 : CollaborativeEditing::onDocumentRemoved(const std::string& conversationId, const std::string& documentId)
    1495              : {
    1496              :     {
    1497            4 :         std::lock_guard<std::mutex> lk(announcedMtx_);
    1498            4 :         removed_[conversationId].emplace(documentId);
    1499            4 :         if (auto it = announced_.find(conversationId); it != announced_.end())
    1500            4 :             it->second.erase(documentId);
    1501            4 :     }
    1502            4 :     dropLocalReplica(conversationId, documentId);
    1503              :     // The repository goes too: a document nobody can open again would otherwise
    1504              :     // outlive its own removal on every device that held it. From another
    1505              :     // thread: this runs while addToHistory() holds the parent conversation's
    1506              :     // lock, and tearing a conversation down takes locks of its own.
    1507            4 :     dht::ThreadPool::io().run([w = account_, documentId] {
    1508            4 :         if (auto account = w.lock())
    1509            4 :             if (auto* cm = account->convModule())
    1510            4 :                 cm->removeDocumentReplica(documentId);
    1511            4 :     });
    1512            4 :     emitSignal<libjami::ConversationSignal::CollaborativeDocumentRemoved>(accountId_, conversationId, documentId, true);
    1513            4 : }
    1514              : 
    1515              : void
    1516           47 : CollaborativeEditing::onRepositoryUpdated(const std::string& conversationId,
    1517              :                                           const std::string& documentId,
    1518              :                                           bool contentChanged)
    1519              : {
    1520              :     // A remote rename can land on a document nobody has open here, and both
    1521              :     // early returns below are reachable in that case: drop the cached name first
    1522              :     // or a client would keep showing the old one until the account restarts.
    1523              :     {
    1524           47 :         std::lock_guard<std::mutex> lk(mutex_);
    1525           47 :         nameCache_.erase(key(conversationId, documentId));
    1526           47 :         ++nameEpoch_;
    1527           47 :     }
    1528           47 :     auto session = findSession(conversationId, documentId);
    1529           47 :     auto conversation = documentConversation(documentId);
    1530           47 :     if (!conversation)
    1531            2 :         return;
    1532           45 :     if (!session) {
    1533           12 :         if (contentChanged)
    1534            8 :             emitUpdate(conversationId, documentId, {});
    1535           12 :         return;
    1536              :     }
    1537              :     // What the replica knows before the replay, so that what it learns from it
    1538              :     // can be told apart from what it already had.
    1539           33 :     const auto before = session->doc->encodeStateVector();
    1540           33 :     session->doc->takeChanged();
    1541              :     // Applying an update the replica already knows is a no-op for a CRDT, so
    1542              :     // replaying the whole set is correct, just more work than strictly needed.
    1543           33 :     replayStoredUpdates(session);
    1544              :     {
    1545           33 :         std::lock_guard<std::mutex> lk(mutex_);
    1546           33 :         session->persistedLoaded = true;
    1547           33 :     }
    1548              :     // Nothing at all when the replay taught us nothing -- a rename-only commit,
    1549              :     // or updates the real-time path had already delivered -- or every client
    1550              :     // would light an "unread" badge for a document nobody touched. The question
    1551              :     // is put to yrs rather than answered by measuring the diff: yrs appends the
    1552              :     // whole deletion set to a diff without diffing it, so a diff carrying no new
    1553              :     // content still measures a few bytes on any document where a character was
    1554              :     // ever erased.
    1555              :     //
    1556              :     // What is sent is only what the replay brought, not the whole document: a
    1557              :     // synchronization usually carries a handful of keystrokes, and re-encoding
    1558              :     // a 300 kB document for each of them would push megabytes a minute through
    1559              :     // the client API for nothing. A closed holder's client is not handed the
    1560              :     // content either -- it gets the converged state when it reopens -- but it
    1561              :     // is told that there is some: an update with an empty payload, which is
    1562              :     // what lets it badge a document nobody here is watching.
    1563              :     bool tellClient;
    1564              :     {
    1565           33 :         std::lock_guard<std::mutex> lk(mutex_);
    1566           33 :         tellClient = session->open;
    1567           33 :     }
    1568           33 :     if (session->doc->takeChanged())
    1569            3 :         emitUpdate(conversationId, documentId, tellClient ? session->doc->encodeDiff(before) : YrsDocument::Bytes {});
    1570              :     // Independent of the updates above: an attachment is not part of the CRDT,
    1571              :     // so a synchronization can bring the payload of a reference the real-time
    1572              :     // path delivered long before, with no update at all to show for it.
    1573           33 :     emitNewAttachments(session);
    1574              :     // The name travels with the repository now, so a remote rename lands here.
    1575              :     // It cannot be detected by reading the name around the replay: the caller
    1576              :     // merges before calling us, so both reads would return the name from after
    1577              :     // the merge. What we compare against is the last name we told the clients.
    1578           33 :     std::string name;
    1579              :     {
    1580           33 :         auto infos = conversation->infos();
    1581           66 :         if (auto it = infos.find("title"); it != infos.end())
    1582           33 :             name = it->second;
    1583           33 :     }
    1584           33 :     auto renamed = false;
    1585              :     {
    1586           33 :         std::lock_guard<std::mutex> lk(mutex_);
    1587           33 :         nameCache_[key(conversationId, documentId)] = name;
    1588           33 :         ++nameEpoch_;
    1589              :         // Only a name the clients have already been told can be seen to change.
    1590           33 :         if (session->announcedName && *session->announcedName != name)
    1591           14 :             renamed = true;
    1592           33 :         if (session->announcedName)
    1593           33 :             session->announcedName = name;
    1594           33 :     }
    1595           33 :     if (renamed)
    1596           14 :         emitRename(conversationId, documentId, name);
    1597              :     // A synchronization is also how new editors become reachable: the clone
    1598              :     // this session may have been waiting for just landed, or a joiner's
    1599              :     // membership commits were just merged -- the very merge that entitles the
    1600              :     // device this replica refused a moment ago to its channel.
    1601           33 :     connectRealtimeChannels(session);
    1602           61 : }
    1603              : 
    1604              : void
    1605          492 : CollaborativeEditing::flush()
    1606              : {
    1607          492 :     std::vector<std::shared_ptr<Session>> sessions;
    1608              :     {
    1609          492 :         std::lock_guard<std::mutex> lk(mutex_);
    1610          492 :         sessions.reserve(sessions_.size());
    1611          530 :         for (const auto& [_, session] : sessions_)
    1612           38 :             sessions.emplace_back(session);
    1613          492 :     }
    1614          530 :     for (const auto& session : sessions) {
    1615           38 :         if (session->checkpointTimer) {
    1616           38 :             std::lock_guard<std::mutex> lk(session->timerMutex);
    1617           38 :             session->checkpointTimer->cancel();
    1618           38 :         }
    1619              :         // No compaction here: the edits are already durable, packing is pure
    1620              :         // housekeeping, and doing it inline would stall the account
    1621              :         // unregistration for seconds per open document.
    1622           38 :         checkpointNow(session);
    1623              :     }
    1624          492 : }
    1625              : 
    1626              : void
    1627           12 : CollaborativeEditing::emitUpdate(const std::string& conversationId,
    1628              :                                  const std::string& documentId,
    1629              :                                  const YrsDocument::Bytes& update)
    1630              : {
    1631              :     // One channel for every document type: the payload is an opaque Y-CRDT
    1632              :     // update, and the client merges it into its own replica. That is what makes
    1633              :     // a plain-text editor and a rich-text editor listen to the same signal.
    1634           12 :     emitSignal<libjami::ConversationSignal::CollaborativeDocumentUpdate>(accountId_, conversationId, documentId, update);
    1635           12 : }
    1636              : 
    1637              : void
    1638           16 : CollaborativeEditing::emitRename(const std::string& conversationId,
    1639              :                                  const std::string& documentId,
    1640              :                                  const std::string& name)
    1641              : {
    1642           16 :     emitSignal<libjami::ConversationSignal::CollaborativeDocumentRenamed>(accountId_, conversationId, documentId, name);
    1643           16 : }
    1644              : 
    1645              : void
    1646           33 : CollaborativeEditing::emitNewAttachments(const std::shared_ptr<Session>& session)
    1647              : {
    1648           33 :     auto conversation = documentConversation(session->documentId);
    1649           33 :     if (!conversation)
    1650            0 :         return;
    1651           33 :     auto ids = conversation->documentAttachmentIds();
    1652           33 :     std::vector<std::string> fresh;
    1653              :     {
    1654           33 :         std::lock_guard<std::mutex> lk(mutex_);
    1655           35 :         for (auto& id : ids)
    1656            2 :             if (session->knownAttachments.insert(id).second)
    1657            1 :                 fresh.push_back(std::move(id));
    1658           33 :     }
    1659           34 :     for (const auto& id : fresh)
    1660            2 :         emitSignal<libjami::ConversationSignal::CollaborativeAttachmentAdded>(accountId_,
    1661            1 :                                                                               session->conversationId,
    1662            1 :                                                                               session->documentId,
    1663              :                                                                               id);
    1664           33 : }
    1665              : 
    1666              : std::string
    1667            1 : CollaborativeEditing::addAttachment(const std::string& conversationId,
    1668              :                                     const std::string& documentId,
    1669              :                                     const std::vector<uint8_t>& data)
    1670              : {
    1671            1 :     if (data.empty() || data.size() > MAX_ATTACHMENT_SIZE) {
    1672            0 :         JAMI_WARNING("[Account {}] [Document {}] Attachment refused: {} byte(s), limit is {}",
    1673              :                      accountId_,
    1674              :                      documentId,
    1675              :                      data.size(),
    1676              :                      MAX_ATTACHMENT_SIZE);
    1677            0 :         return {};
    1678              :     }
    1679            1 :     auto account = account_.lock();
    1680            1 :     if (!account)
    1681            0 :         return {};
    1682            1 :     auto* cm = account->convModule();
    1683            1 :     if (!cm)
    1684            0 :         return {};
    1685              :     // The payload is a commit in the document's own swarm, and committing it is
    1686              :     // also what announces it: peers fetch it straight away rather than showing a
    1687              :     // placeholder until the next checkpoint.
    1688            1 :     auto id = cm->addDocumentAttachment(documentId, data);
    1689            1 :     if (id.empty())
    1690            0 :         return {};
    1691            1 :     if (auto session = findSession(conversationId, documentId)) {
    1692              :         // Ours already: the client that stored it holds the bytes, and the next
    1693              :         // synchronization must not announce them back to it.
    1694            1 :         std::lock_guard<std::mutex> lk(mutex_);
    1695            1 :         session->knownAttachments.insert(id);
    1696            2 :     }
    1697            1 :     return id;
    1698            1 : }
    1699              : 
    1700              : std::vector<uint8_t>
    1701            3 : CollaborativeEditing::attachment(const std::string& /*conversationId*/,
    1702              :                                  const std::string& documentId,
    1703              :                                  const std::string& attachmentId)
    1704              : {
    1705              :     // Readable without an editing session: a client browsing the history of a
    1706              :     // document it has not opened still has to resolve what it refers to.
    1707            3 :     auto conversation = documentConversation(documentId);
    1708            6 :     return conversation ? conversation->documentAttachment(attachmentId) : std::vector<uint8_t> {};
    1709            3 : }
    1710              : 
    1711              : } // namespace jami
        

Generated by: LCOV version 2.0-1